devaanand commited on
Commit
4d1197e
·
1 Parent(s): e8f94ba

chore: add MIT LICENSE and production deploy assets

Browse files

- LICENSE: MIT (Culture and Morality Lab, UMass Amherst). The site states
the tool is free and open source, so the repo now carries a matching
license. MIT is the standard for academic research software and maximizes
reuse and citation.
- deploy/reverse-proxy-worker.js: Cloudflare Worker that serves the Hugging
Face Space at the lab's custom domain (Spaces have no native custom-domain
support), keeping the domain in the address bar.
- deploy/PRODUCTION_RUNBOOK.md: full steps to move to lab-owned accounts
(Supabase, R2, HF Space, Groq), migrate data, and wire the domain.
- .gitignore: exclude personal .agents/ and .codex/ tooling.

.gitignore CHANGED
@@ -13,3 +13,7 @@ node_modules/
13
  # os
14
  .DS_Store
15
  .env
 
 
 
 
 
13
  # os
14
  .DS_Store
15
  .env
16
+
17
+ # personal multi-agent tooling (not part of the project; .claude/ is the shared one)
18
+ .agents/
19
+ .codex/
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Culture and Morality Lab, University of Massachusetts Amherst
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
deploy/PRODUCTION_RUNBOOK.md ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Production Deployment Runbook
2
+
3
+ Moving the CCR Platform from the personal dev setup to the lab's own accounts,
4
+ and putting it on a custom domain. Written for a one-sitting migration: the code
5
+ is already unified in the lab GitHub repo, the app is feature-complete, and data
6
+ already lives on managed services (Postgres + object storage). This runbook
7
+ carries all of it over to lab-owned accounts.
8
+
9
+ Terminology: "the Space" is the Hugging Face Space that runs the app. "Custom
10
+ domain" is whatever the PI picks (examples below use `ccr.culturemoralitylab.org`;
11
+ swap in the chosen domain everywhere).
12
+
13
+ There are no em dashes in this file on purpose (project rule).
14
+
15
+ ---
16
+
17
+ ## 0. What is already done
18
+
19
+ - Code is unified in `Culture-and-Morality-Lab/ccr-platform` (public), with clean
20
+ commit history.
21
+ - License texts are staged in the repo root (`LICENSE-MIT`, `LICENSE-APACHE`);
22
+ one gets promoted to `LICENSE` once the PI picks.
23
+ - The app is one portable container (`Dockerfile`); the same image runs on any
24
+ host, so "deploy" is a git push to the Space.
25
+ - Data services are already in use on the dev instance (Supabase Postgres via
26
+ `DATABASE_URL`, Cloudflare R2 via the `CCR_S3_*` secrets), so this is a
27
+ copy-to-lab-accounts job, not new infrastructure.
28
+
29
+ ## 1. Accounts to create (lab email, free)
30
+
31
+ | Service | Purpose | Cost |
32
+ | --- | --- | --- |
33
+ | Hugging Face (account or org) | Hosts the Space | Free (cpu-basic) |
34
+ | Supabase | Postgres database + Google sign-in | Free tier |
35
+ | Cloudflare | Domain registrar + DNS + R2 storage + the reverse-proxy Worker | Free (domain ~20/yr; R2 needs a card on file but is free at this scale) |
36
+ | Groq | AI item-drafting key | Free tier |
37
+ | GitHub | Already done (lab org repo exists) | Free |
38
+
39
+ Save every password and key in a password manager as you go. These are the lab's
40
+ institutional credentials, not personal logins.
41
+
42
+ ## 2. Secrets sheet (fill privately, never commit)
43
+
44
+ The new Space needs these. Collect the values as you complete each section
45
+ below, then paste them into the Space in section 7.
46
+
47
+ | Name | Store | Value source |
48
+ | --- | --- | --- |
49
+ | `CCR_SESSION_SECRET` | Secret | Generate fresh: `python3 -c "import secrets; print(secrets.token_hex(32))"` (do NOT reuse the dev value) |
50
+ | `DATABASE_URL` | Secret | New Supabase project, Session pooler URI, with the DB password in it (section 3) |
51
+ | `SUPABASE_URL` | Secret | New Supabase project > Settings > API |
52
+ | `SUPABASE_ANON_KEY` | Secret | Same page (anon public key, NOT service_role) |
53
+ | `CCR_STORAGE` | Variable | `s3` |
54
+ | `CCR_S3_ENDPOINT` | Secret | New R2: `https://<account_id>.r2.cloudflarestorage.com` (section 5) |
55
+ | `CCR_S3_BUCKET` | Secret | New R2 bucket name |
56
+ | `CCR_S3_ACCESS_KEY_ID` | Secret | New R2 API token |
57
+ | `CCR_S3_SECRET_ACCESS_KEY` | Secret | New R2 API token |
58
+ | `GROQ_API_KEY` | Secret | New lab Groq key |
59
+ | `CCR_APP_URL` | Variable | `https://<custom-domain>` (section 8) |
60
+ | `CCR_COOKIE_SECURE` | Variable | `1` |
61
+ | `CCR_ANON_TTL_HOURS` | Variable | `24` |
62
+ | `ADMIN_EMAILS` | Variable | Comma-separated admin emails (confirm with PI) |
63
+ | `CCR_MAX_ROWS` | Variable | `50000` (matches the dev instance) |
64
+
65
+ Hugging Face keeps Variables and Secrets in two separate stores. A name defined
66
+ in BOTH puts the Space into CONFIG_ERROR before it builds. Put each name in one
67
+ store only, per the table.
68
+
69
+ ## 3. Supabase project (database + sign-in)
70
+
71
+ 1. New project under the lab's Supabase org. Match the region to the current
72
+ dev project (read it from the dev `DATABASE_URL`: the pooler host contains it,
73
+ e.g. `aws-0-us-east-1...` means US East). Generate a strong DB password and
74
+ save it.
75
+ 2. Turn OFF "Enable Data API" (the app talks to Postgres directly and never uses
76
+ the REST API; leaving it off closes that surface). Leave "Enable automatic
77
+ RLS" on. Do NOT connect GitHub (the app manages its own schema).
78
+ 3. Settings > API: copy the Project URL and the anon key into the secrets sheet
79
+ (`SUPABASE_URL`, `SUPABASE_ANON_KEY`).
80
+ 4. Connect > Session pooler: copy the URI, insert the DB password, into
81
+ `DATABASE_URL`.
82
+ 5. Google sign-in (do this before launch; the button is hidden without it):
83
+ - Google Cloud console (lab Google account) > OAuth consent screen (External)
84
+ > Credentials > Create OAuth client ID (Web application).
85
+ - Supabase > Authentication > Providers > Google > Enable. Copy the shown
86
+ callback (`https://<PROJECT_REF>.supabase.co/auth/v1/callback`) and add it
87
+ as an authorized redirect URI in the Google client. Paste the Google client
88
+ id and secret back into the Supabase Google form.
89
+ - Supabase > Authentication > URL Configuration: add the redirect URLs
90
+ `http://127.0.0.1:8000/api/auth/google/callback` and, once the domain is
91
+ live, `https://<custom-domain>/api/auth/google/callback`.
92
+
93
+ ## 4. Migrate the database (carry accounts and data over)
94
+
95
+ Run BEFORE the new Space boots for the first time, so the app finds the data
96
+ already present rather than creating empty tables.
97
+
98
+ ```sh
99
+ # Dump the app's tables from the current (dev) database. Use the dev
100
+ # DATABASE_URL from your local .env. --schema=public keeps it to the app's
101
+ # tables; --no-owner --no-acl avoids role mismatches between projects.
102
+ pg_dump "$DEV_DATABASE_URL" --schema=public --no-owner --no-acl -Fc -f ccr_public.dump
103
+
104
+ # Restore into the NEW Supabase project.
105
+ pg_restore --no-owner --no-acl --clean --if-exists \
106
+ -d "$NEW_DATABASE_URL" ccr_public.dump
107
+ ```
108
+
109
+ If `pg_dump` version-mismatches against Supabase, use the Postgres client that
110
+ matches the server major version (or the `supabase db dump` CLI). The app's
111
+ startup auto-migration adds any missing columns after this, so a slightly older
112
+ dump still boots cleanly.
113
+
114
+ ## 5. R2 object storage (uploaded files)
115
+
116
+ 1. Cloudflare > R2 > create a bucket under the lab account (this is when R2 asks
117
+ for a card on file; the 10 GB free tier covers this use).
118
+ 2. Create an R2 API token (Object Read and Write) scoped to that bucket. Put the
119
+ access key id, secret, endpoint, and bucket name into the secrets sheet.
120
+ 3. Copy the existing files over (R2 is S3-compatible):
121
+
122
+ ```sh
123
+ # From the dev R2 to the lab R2. Fill both endpoint/keys from each account.
124
+ aws s3 sync \
125
+ --endpoint-url "$DEV_S3_ENDPOINT" "s3://$DEV_BUCKET" ./r2-migrate-tmp
126
+ aws s3 sync \
127
+ --endpoint-url "$LAB_S3_ENDPOINT" ./r2-migrate-tmp "s3://$LAB_BUCKET"
128
+ # (or use rclone with two remotes: rclone sync dev:bucket lab:bucket)
129
+ ```
130
+
131
+ ## 6. Domain purchase (on the PI call, card needed)
132
+
133
+ 1. Cloudflare > Domain Registration > register the chosen domain (recommended:
134
+ buy at Cloudflare so domain, DNS, R2, and the Worker share one account).
135
+ Availability was last checked recently and can change daily, so register the
136
+ same day it is chosen.
137
+ 2. If the domain is bought elsewhere, add it to Cloudflare and point its
138
+ nameservers at Cloudflare, so the Worker route in section 8 can attach.
139
+
140
+ ## 7. Create the lab Space and add secrets
141
+
142
+ 1. New Space under the lab Hugging Face account: SDK = Docker, hardware =
143
+ cpu-basic (free, same as dev). Give it the same repo.
144
+ 2. Point the local repo's Space remote at the new Space and push:
145
+
146
+ ```sh
147
+ git remote set-url hf https://huggingface.co/spaces/<lab-owner>/<space>
148
+ git push hf main # use the lab HF username + a WRITE token when prompted
149
+ ```
150
+
151
+ 3. Space > Settings > Variables and secrets: enter every row from the secrets
152
+ sheet (section 2), each in the store the table specifies.
153
+ 4. The Space builds and boots. Because the database was restored in section 4,
154
+ accounts and data are already there.
155
+
156
+ ## 8. Wire the custom domain (reverse proxy)
157
+
158
+ Hugging Face Spaces cannot serve a custom domain directly, so a small Cloudflare
159
+ Worker serves the Space at the lab domain and keeps the domain in the address bar.
160
+
161
+ 1. Get the Space direct host: Space > Settings > Embed this Space > Direct URL,
162
+ of the form `https://<owner>-<space>.hf.space`.
163
+ 2. Open `deploy/reverse-proxy-worker.js`, set `UPSTREAM_HOST` to that host
164
+ (without the `https://`).
165
+ 3. Cloudflare > Workers & Pages > Create Worker > paste the file > Deploy.
166
+ 4. That Worker > Settings > Domains & Routes > Add Custom Domain >
167
+ `ccr.<domain>` (Cloudflare provisions TLS automatically).
168
+ 5. Set the Space Variable `CCR_APP_URL=https://ccr.<domain>` and confirm the
169
+ Supabase redirect URL from section 3.5 uses the same domain. Restart the
170
+ Space so it picks up `CCR_APP_URL`.
171
+
172
+ ## 9. Launch checklist (verify before announcing)
173
+
174
+ - Site loads on the custom domain; the address bar stays on the domain while
175
+ navigating.
176
+ - Google sign-in completes and returns to the custom domain (not hf.space).
177
+ - Password sign-in works; an account created on the dev instance is present
178
+ (confirms the data migration).
179
+ - Upload a small corpus, pick a construct, run it, open results.
180
+ - AI drafting: "Draft with AI" tab works with the lab Groq key; the drafted
181
+ items appear, and a saved AI construct shows the "AI-generated, not validated"
182
+ label through picker, results, and the metadata download.
183
+ - `/guide` loads; `/admin` is reachable for the emails in `ADMIN_EMAILS`.
184
+ - Promote the license: `git mv LICENSE-MIT LICENSE && rm LICENSE-APACHE` (or the
185
+ reverse), commit, push. Needed because the site publicly says the tool is
186
+ open-source.
187
+
188
+ ## 10. Cut over and retire the old instance
189
+
190
+ - Pause (do not delete) the old personal Space once the new one is verified, so
191
+ it is a fallback for a day or two.
192
+ - Keep the dev database and R2 until the new instance has run cleanly for a few
193
+ days, then retire them.
194
+
195
+ ---
196
+
197
+ ## Optional, post-launch
198
+
199
+ ### Keep the Space awake (free)
200
+
201
+ Free Spaces sleep after about 48 hours idle (first visitor then waits about a
202
+ minute). A free uptime pinger (for example UptimeRobot) hitting the domain every
203
+ few hours keeps it warm without paying. The durable fix is an always-on host
204
+ (next item).
205
+
206
+ ### Always-on host (later)
207
+
208
+ The same container runs on any host (Fly, Railway, Hetzner, a UMass VM). Moving
209
+ there removes the sleep behavior AND gives native custom-domain support, so the
210
+ Worker in section 8 can be dropped. Not a launch blocker.
211
+
212
+ ### One-push deploys (GitHub Action)
213
+
214
+ Auto-deploy the Space on every merge to `main`, so nobody needs the Space remote.
215
+ Add a GitHub Actions workflow that mirrors `main` to the Space, with an HF write
216
+ token stored as a GitHub Actions secret (`HF_TOKEN`):
217
+
218
+ ```yaml
219
+ # .github/workflows/deploy-hf.yml
220
+ name: Deploy to Hugging Face Space
221
+ on:
222
+ push:
223
+ branches: [main]
224
+ jobs:
225
+ deploy:
226
+ runs-on: ubuntu-latest
227
+ steps:
228
+ - uses: actions/checkout@v4
229
+ with: { fetch-depth: 0 }
230
+ - name: Push to Space
231
+ env:
232
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
233
+ run: |
234
+ git push "https://lab:${HF_TOKEN}@huggingface.co/spaces/<lab-owner>/<space>" main
235
+ ```
236
+
237
+ ### Switch AI drafting to Claude Haiku (later)
238
+
239
+ Add `ANTHROPIC_API_KEY` as a Space secret and the app auto-selects Anthropic over
240
+ Groq (`CCR_GENERATION_PROVIDER` can force either). Groq stays as the free
241
+ fallback. The provenance stamp records which model drafted each construct, so
242
+ constructs drafted during the Groq period stay traceable.
deploy/reverse-proxy-worker.js ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Cloudflare Worker: reverse proxy that serves the CCR Platform Hugging Face
3
+ * Space at the lab's own domain (e.g. ccr.culturemoralitylab.org or
4
+ * www.psychologicaltextanalysis.org), so the address bar keeps the custom
5
+ * domain throughout. The Space host never appears to the visitor.
6
+ *
7
+ * Why a Worker: Hugging Face Spaces do not support custom domains on ANY tier
8
+ * (free or paid), so the domain has to be served in front of the Space. The
9
+ * Workers free tier (100k requests/day) covers lab traffic comfortably.
10
+ *
11
+ * SETUP
12
+ * 1. Set UPSTREAM_HOST below to the Space's direct host. Find it on the Space
13
+ * page: Settings > "Embed this Space" > Direct URL. It has the form
14
+ * https://<owner>-<space>.hf.space (lowercased, non-alphanumerics become
15
+ * hyphens), e.g. culture-and-morality-lab-ccr-platform.hf.space.
16
+ * 2. Cloudflare dashboard > Workers & Pages > Create Worker > paste this file
17
+ * > Deploy.
18
+ * 3. That Worker > Settings > Domains & Routes > Add Custom Domain > enter the
19
+ * subdomain (ccr.culturemoralitylab.org). Cloudflare provisions TLS
20
+ * automatically. The domain must already be in this Cloudflare account.
21
+ *
22
+ * PAIRS WITH (already on the migration runbook, deploy/PRODUCTION_RUNBOOK.md):
23
+ * - Space secret CCR_APP_URL = https://<your-custom-domain>
24
+ * (the app builds the Google sign-in return URL from this).
25
+ * - Supabase Auth > URL Configuration: add
26
+ * https://<your-custom-domain>/api/auth/google/callback to the redirect list.
27
+ *
28
+ * NOTES
29
+ * - redirect: "manual" is required. The app returns 3xx responses (the Google
30
+ * sign-in hand-off, and the post-login redirect back to "/"). Manual mode
31
+ * passes those to the browser instead of the Worker following them
32
+ * server-side, so sign-in works and app redirects stay on the custom domain.
33
+ * - The app's own redirects are relative ("/"), so they resolve against the
34
+ * custom domain automatically. The block below rewrites only the rare
35
+ * absolute redirect that points back at the Space host, as a safety net.
36
+ * - Host-only cookies (no Domain attribute) scope to the custom domain through
37
+ * the proxy with no Set-Cookie rewriting needed.
38
+ * - No websockets in the app; uploads are capped at 50 MB, under the Worker
39
+ * request-body limit.
40
+ *
41
+ * AFTER DEPLOY, verify once: load the site on the custom domain, sign in with
42
+ * Google (the address bar should return to the custom domain, not hf.space),
43
+ * upload a small corpus, run it. If Google sign-in lands on an error, re-check
44
+ * CCR_APP_URL and the Supabase redirect URL above.
45
+ */
46
+
47
+ const UPSTREAM_HOST = "REPLACE-WITH-owner-space.hf.space";
48
+
49
+ export default {
50
+ async fetch(request) {
51
+ const incoming = new URL(request.url);
52
+
53
+ // Same path and query, sent to the Space host over HTTPS.
54
+ const target = new URL(request.url);
55
+ target.hostname = UPSTREAM_HOST;
56
+ target.protocol = "https:";
57
+ target.port = "";
58
+
59
+ // Clone onto the target URL (preserves method, headers, and body). The
60
+ // Host header is derived from the target URL, so the Space routes correctly.
61
+ const proxied = new Request(target, request);
62
+ const response = await fetch(proxied, { redirect: "manual" });
63
+
64
+ // Safety net: rewrite an absolute redirect back at the Space host onto the
65
+ // custom domain. App redirects are relative, so this rarely fires.
66
+ const location = response.headers.get("location");
67
+ if (location && location.startsWith(`https://${UPSTREAM_HOST}`)) {
68
+ const headers = new Headers(response.headers);
69
+ headers.set(
70
+ "location",
71
+ incoming.origin + location.slice(`https://${UPSTREAM_HOST}`.length),
72
+ );
73
+ return new Response(response.body, {
74
+ status: response.status,
75
+ statusText: response.statusText,
76
+ headers,
77
+ });
78
+ }
79
+ return response;
80
+ },
81
+ };