@striae-org/striae 7.0.1 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.env.example +8 -14
  2. package/functions/api/_shared/lists-client.ts +39 -0
  3. package/functions/api/_shared/registration-allowlist.ts +5 -4
  4. package/functions/api/auth/can-register.ts +7 -2
  5. package/functions/api/pdf/[[path]].ts +4 -1
  6. package/functions/api/user/[[path]].ts +11 -5
  7. package/package.json +10 -8
  8. package/scripts/deploy-all.sh +3 -3
  9. package/scripts/deploy-config/modules/prompt.sh +43 -7
  10. package/scripts/deploy-config/modules/scaffolding.sh +19 -0
  11. package/scripts/deploy-config/modules/validation.sh +3 -0
  12. package/scripts/deploy-config.sh +0 -33
  13. package/scripts/deploy-pages-secrets.sh +1 -10
  14. package/scripts/deploy-worker-secrets.sh +19 -1
  15. package/scripts/install-workers.sh +4 -3
  16. package/scripts/update-markdown-versions.cjs +1 -0
  17. package/workers/audit-worker/package.json +2 -2
  18. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  19. package/workers/data-worker/package.json +2 -2
  20. package/workers/data-worker/wrangler.jsonc.example +1 -1
  21. package/workers/image-worker/package.json +2 -2
  22. package/workers/image-worker/wrangler.jsonc.example +1 -1
  23. package/workers/lists-worker/package.json +13 -0
  24. package/workers/lists-worker/src/lists-worker.ts +97 -0
  25. package/workers/lists-worker/src/types.ts +4 -0
  26. package/workers/lists-worker/wrangler.jsonc.example +23 -0
  27. package/workers/pdf-worker/package.json +2 -2
  28. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  29. package/workers/user-worker/package.json +2 -2
  30. package/workers/user-worker/wrangler.jsonc.example +1 -1
  31. package/wrangler.toml.example +6 -2
  32. package/app/config-example/members.emails +0 -11
  33. package/app/config-example/primershear.emails +0 -6
  34. package/scripts/deploy-members-emails.sh +0 -102
  35. package/scripts/deploy-primershear-emails.sh +0 -101
package/.env.example CHANGED
@@ -110,17 +110,11 @@ PDF_WORKER_NAME=your_pdf_worker_name_here
110
110
  BROWSER_API_TOKEN=your_cloudflare_browser_rendering_api_token_here
111
111
 
112
112
  # ================================
113
- # PRIMERSHEAR PDF FORMAT
114
- # ================================
115
- # Comma-separated list of email addresses that will receive the primershear PDF format.
116
- # Leave empty to disable the feature. Never commit this value to source control.
117
- # Example: PRIMERSHEAR_EMAILS=analyst@org.com,user2@org.com
118
- PRIMERSHEAR_EMAILS=
119
-
120
- # ================================
121
- # REGISTRATION EMAIL ALLOWLIST CONFIGURATION
122
- # ================================
123
- # Comma-separated list of email addresses that may register an account.
124
- # Leave empty to disable the feature. Never commit this value to source control.
125
- # Example: REGISTRATION_EMAILS=analyst@org.com,user2@org.com
126
- REGISTRATION_EMAILS=
113
+ # LISTS WORKER ENVIRONMENT VARIABLES
114
+ # ================================
115
+ # The lists-worker manages registration and PDF format allowlists via KV.
116
+ # STRIAE_LISTS_KV_ID is the KV namespace ID backing both lists.
117
+ # LISTS_ADMIN_SECRET guards write endpoints (POST/DELETE); use a strong random value.
118
+ LISTS_WORKER_NAME=your_lists_worker_name_here
119
+ STRIAE_LISTS_KV_ID=your_striae_lists_kv_id_here
120
+ LISTS_ADMIN_SECRET=your_lists_admin_secret_here
@@ -0,0 +1,39 @@
1
+ export type ListResult =
2
+ | { ok: true; list: string }
3
+ | { ok: false; error: string };
4
+
5
+ /**
6
+ * Client helper for reading email lists from the lists-worker via service binding.
7
+ * Returns a ListResult so callers can apply fail-open or fail-closed logic
8
+ * appropriate to their security context.
9
+ *
10
+ * - ok: true → list fetched successfully (may be empty string if list is empty)
11
+ * - ok: false → worker unreachable, auth failure, or unexpected response shape
12
+ */
13
+ export async function fetchListFromWorker(
14
+ binding: Fetcher,
15
+ list: 'members' | 'primershear',
16
+ secret: string
17
+ ): Promise<ListResult> {
18
+ try {
19
+ const response = await binding.fetch(`https://worker/${list}`, {
20
+ headers: { 'Authorization': `Bearer ${secret}` },
21
+ });
22
+ if (!response.ok) {
23
+ const msg = `lists-client: GET /${list} returned ${response.status}`;
24
+ console.error(msg);
25
+ return { ok: false, error: msg };
26
+ }
27
+ const data = await response.json() as { list?: unknown };
28
+ if (typeof data.list !== 'string') {
29
+ const msg = `lists-client: unexpected response shape for /${list}`;
30
+ console.error(msg);
31
+ return { ok: false, error: msg };
32
+ }
33
+ return { ok: true, list: data.list };
34
+ } catch (err) {
35
+ const msg = `lists-client: failed to fetch /${list}`;
36
+ console.error(msg, err);
37
+ return { ok: false, error: msg };
38
+ }
39
+ }
@@ -1,17 +1,18 @@
1
1
  /**
2
2
  * Checks whether the given email is permitted to register based on the
3
- * REGISTRATION_EMAILS secret (a comma-separated list of allowed entries).
3
+ * registration allowlist (a comma-separated list of allowed entries) sourced
4
+ * from the lists-worker KV (key: "allow").
4
5
  *
5
6
  * Each entry may be:
6
7
  * - An exact email address: user@example.com
7
8
  * - A domain wildcard: @example.com (matches any email from that domain)
8
9
  *
9
- * If registrationEmails is empty or unset, all registrations are allowed
10
- * (backward-compatible deploys without a members.emails file are unrestricted).
10
+ * If registrationEmails is empty or unset, registration is denied (fail closed).
11
+ * An empty list indicates the allowlist has not been populated, not that all are allowed.
11
12
  */
12
13
  export function isEmailAllowed(email: string, registrationEmails: string): boolean {
13
14
  if (!registrationEmails || registrationEmails.trim().length === 0) {
14
- return true;
15
+ return false;
15
16
  }
16
17
 
17
18
  const normalizedEmail = email.toLowerCase().trim();
@@ -1,4 +1,5 @@
1
1
  import { isEmailAllowed } from '../_shared/registration-allowlist';
2
+ import { fetchListFromWorker } from '../_shared/lists-client';
2
3
 
3
4
  interface CanRegisterContext {
4
5
  request: Request;
@@ -49,9 +50,13 @@ export const onRequest = async ({ request, env }: CanRegisterContext): Promise<R
49
50
  return textResponse('Missing required parameter: email', 400);
50
51
  }
51
52
 
52
- const registrationEmails = env.REGISTRATION_EMAILS ?? '';
53
+ const listResult = await fetchListFromWorker(env.LISTS_WORKER, 'members', env.LISTS_ADMIN_SECRET);
54
+ if (!listResult.ok) {
55
+ // Fail closed: cannot verify allowlist, deny to prevent bypass.
56
+ return textResponse('Unable to verify registration eligibility', 503);
57
+ }
53
58
 
54
- if (isEmailAllowed(email, registrationEmails)) {
59
+ if (isEmailAllowed(email, listResult.list)) {
55
60
  return jsonResponse({ allowed: true });
56
61
  }
57
62
 
@@ -1,4 +1,5 @@
1
1
  import { verifyFirebaseIdentityFromRequest } from '../_shared/firebase-auth';
2
+ import { fetchListFromWorker } from '../_shared/lists-client';
2
3
 
3
4
  interface PdfProxyContext {
4
5
  request: Request;
@@ -97,9 +98,11 @@ export const onRequest = async ({ request, env }: PdfProxyContext): Promise<Resp
97
98
 
98
99
  // Resolve the report format server-side based on the verified user email.
99
100
  // This prevents email lists from ever being exposed in the client bundle.
101
+ // Fail-open: if the lists-worker is unavailable, fall back to the default format.
102
+ const primershearResult = await fetchListFromWorker(env.LISTS_WORKER, 'primershear', env.LISTS_ADMIN_SECRET);
100
103
  const reportFormat = resolveReportFormat(
101
104
  identity.email,
102
- env.PRIMERSHEAR_EMAILS ?? ''
105
+ primershearResult.ok ? primershearResult.list : ''
103
106
  );
104
107
 
105
108
  let upstreamBody: BodyInit;
@@ -1,5 +1,6 @@
1
1
  import { verifyFirebaseIdentityFromRequest } from '../_shared/firebase-auth';
2
2
  import { isEmailAllowed } from '../_shared/registration-allowlist';
3
+ import { fetchListFromWorker } from '../_shared/lists-client';
3
4
 
4
5
  interface UserProxyContext {
5
6
  request: Request;
@@ -142,9 +143,14 @@ export const onRequest = async ({ request, env }: UserProxyContext): Promise<Res
142
143
  }
143
144
 
144
145
  // Registration gateway: for PUT requests, check if this is a new user creation.
145
- // If REGISTRATION_EMAILS is set and the user record does not yet exist, enforce the allowlist.
146
+ // Always enforce the allowlist for new users isEmailAllowed fails closed for empty lists.
146
147
  // This is defense-in-depth — the primary check runs client-side in the login flow.
147
- if (request.method === 'PUT' && env.REGISTRATION_EMAILS && env.REGISTRATION_EMAILS.trim().length > 0) {
148
+ if (request.method === 'PUT') {
149
+ const listResult = await fetchListFromWorker(env.LISTS_WORKER, 'members', env.LISTS_ADMIN_SECRET);
150
+ if (!listResult.ok) {
151
+ // Fail closed: cannot verify allowlist, reject to prevent bypass.
152
+ return textResponse('Unable to verify registration eligibility', 503);
153
+ }
148
154
  try {
149
155
  const existenceResponse = await env.USER_WORKER.fetch(
150
156
  `https://worker/${encodeURIComponent(requestedUserId)}`,
@@ -158,8 +164,8 @@ export const onRequest = async ({ request, env }: UserProxyContext): Promise<Res
158
164
 
159
165
  if (existenceResponse.status === 404) {
160
166
  // User does not exist yet — this is a registration PUT.
161
- // Enforce the email allowlist.
162
- if (!isEmailAllowed(identity.email ?? '', env.REGISTRATION_EMAILS)) {
167
+ // Enforce the email allowlist (isEmailAllowed returns false for empty list).
168
+ if (!isEmailAllowed(identity.email ?? '', listResult.list)) {
163
169
  return textResponse('Registration is not permitted for this email address', 403);
164
170
  }
165
171
  } else if (!existenceResponse.ok) {
@@ -169,7 +175,7 @@ export const onRequest = async ({ request, env }: UserProxyContext): Promise<Res
169
175
  }
170
176
  // If user already exists (200), proceed normally.
171
177
  } catch {
172
- // Fail closed: on network error with allowlist active, reject the request.
178
+ // Fail closed: on network error, reject the request.
173
179
  return textResponse('Unable to verify registration eligibility', 502);
174
180
  }
175
181
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@striae-org/striae",
3
- "version": "7.0.1",
3
+ "version": "7.1.0",
4
4
  "private": false,
5
5
  "description": "Striae is a specialized, cloud-native platform designed to streamline forensic firearms identification by providing an intuitive environment for digital comparison image annotation, authenticated confirmations, and automated report generation.",
6
6
  "license": "Apache-2.0",
@@ -89,16 +89,15 @@
89
89
  "deploy-config": "bash ./scripts/deploy-config.sh",
90
90
  "update-env": "bash ./scripts/deploy-config.sh --update-env",
91
91
  "install-workers": "bash ./scripts/install-workers.sh",
92
- "deploy-workers": "npm run deploy-workers:audit && npm run deploy-workers:data && npm run deploy-workers:image && npm run deploy-workers:pdf && npm run deploy-workers:user",
92
+ "deploy-workers": "npm run deploy-workers:audit && npm run deploy-workers:data && npm run deploy-workers:image && npm run deploy-workers:lists && npm run deploy-workers:pdf && npm run deploy-workers:user",
93
93
  "deploy-workers:secrets": "bash ./scripts/deploy-worker-secrets.sh",
94
94
  "deploy-pages:secrets": "bash ./scripts/deploy-pages-secrets.sh",
95
95
  "deploy-pages": "bash ./scripts/deploy-pages.sh",
96
- "deploy-primershear": "bash ./scripts/deploy-primershear-emails.sh",
97
- "deploy-members": "bash ./scripts/deploy-members-emails.sh",
98
96
  "deploy-workers:audit": "cd workers/audit-worker && npm run deploy",
99
97
  "deploy-workers:data": "cd workers/data-worker && npm run deploy",
100
98
  "deploy-workers:image": "cd workers/image-worker && npm run deploy",
101
99
  "deploy-workers:pdf": "cd workers/pdf-worker && npm run deploy",
100
+ "deploy-workers:lists": "cd workers/lists-worker && npm run deploy",
102
101
  "deploy-workers:user": "cd workers/user-worker && npm run deploy"
103
102
  },
104
103
  "dependencies": {
@@ -112,7 +111,7 @@
112
111
  "react-router": "^7.14.2"
113
112
  },
114
113
  "devDependencies": {
115
- "@cloudflare/vitest-pool-workers": "^0.14.9",
114
+ "@cloudflare/vitest-pool-workers": "^0.15.0",
116
115
  "@react-router/dev": "^7.14.2",
117
116
  "@react-router/fs-routes": "^7.14.2",
118
117
  "@types/qrcode": "^1.5.6",
@@ -130,12 +129,15 @@
130
129
  "firebase-admin": "^13.8.0",
131
130
  "modern-normalize": "^3.0.1",
132
131
  "typescript": "^6.0.3",
133
- "vite": "^8.0.9",
132
+ "vite": "^8.0.10",
134
133
  "vitest": "^4.1.5",
135
- "wrangler": "^4.84.1"
134
+ "wrangler": "^4.85.0"
136
135
  },
137
136
  "overrides": {
138
- "@tootallnate/once": "3.0.1"
137
+ "@tootallnate/once": "3.0.1",
138
+ "firebase-admin": {
139
+ "uuid": "^14.0.0"
140
+ }
139
141
  },
140
142
  "engines": {
141
143
  "node": ">=20.19.0"
@@ -110,7 +110,7 @@ if ! npx wrangler types; then
110
110
  echo -e "${RED}❌ Root wrangler types generation failed!${NC}"
111
111
  exit 1
112
112
  fi
113
- for WORKER in audit-worker data-worker image-worker pdf-worker user-worker; do
113
+ for WORKER in audit-worker data-worker image-worker lists-worker pdf-worker user-worker; do
114
114
  echo -e "${YELLOW} → Generating types for ${WORKER}...${NC}"
115
115
  if ! (cd "workers/$WORKER" && npx wrangler types); then
116
116
  echo -e "${RED}❌ wrangler types failed for ${WORKER}!${NC}"
@@ -123,7 +123,7 @@ echo ""
123
123
  # Step 4: Deploy Workers
124
124
  echo -e "${PURPLE}Step 4/7: Deploying Workers${NC}"
125
125
  echo "----------------------------"
126
- echo -e "${YELLOW}🔧 Deploying all 5 Cloudflare Workers...${NC}"
126
+ echo -e "${YELLOW}🔧 Deploying all 6 Cloudflare Workers...${NC}"
127
127
  if ! npm run deploy-workers; then
128
128
  echo -e "${RED}❌ Worker deployment failed!${NC}"
129
129
  exit 1
@@ -172,7 +172,7 @@ echo ""
172
172
  echo -e "${BLUE}Deployed Components:${NC}"
173
173
  echo " ✅ Worker dependencies (npm install)"
174
174
  echo " ✅ Wrangler types (root + all workers)"
175
- echo " ✅ 5 Cloudflare Workers"
175
+ echo " ✅ 6 Cloudflare Workers"
176
176
  echo " ✅ Worker environment variables"
177
177
  echo " ✅ Pages environment variables"
178
178
  echo " ✅ Cloudflare Pages frontend"
@@ -23,7 +23,7 @@ prompt_for_secrets() {
23
23
  is_auto_generated_secret_var() {
24
24
  local var_name=$1
25
25
  case "$var_name" in
26
- IMAGE_SIGNED_URL_SECRET)
26
+ IMAGE_SIGNED_URL_SECRET|LISTS_ADMIN_SECRET)
27
27
  return 0
28
28
  ;;
29
29
  *)
@@ -39,6 +39,9 @@ prompt_for_secrets() {
39
39
  IMAGE_SIGNED_URL_SECRET)
40
40
  [ "$value" = "your_image_signed_url_secret_here" ]
41
41
  ;;
42
+ LISTS_ADMIN_SECRET)
43
+ [ "$value" = "your_lists_admin_secret_here" ]
44
+ ;;
42
45
  *)
43
46
  return 1
44
47
  ;;
@@ -223,13 +226,44 @@ prompt_for_secrets() {
223
226
 
224
227
  echo -e "${BLUE}🔑 WORKER NAMES${NC}"
225
228
  echo "==============="
226
- echo -e "${YELLOW}Worker names are lowercased automatically and must use only letters, numbers, and dashes.${NC}"
229
+ echo -e "${YELLOW}Worker names are auto-generated and lowercased. Press Enter to keep the generated value or type a custom name.${NC}"
230
+
231
+ # Auto-generate each worker name if not yet set or still a placeholder.
232
+ _gen_worker_name() {
233
+ local var_name=$1
234
+ local current="${!var_name:-}"
235
+ if is_placeholder "$current" || [ -z "$current" ]; then
236
+ local suffix
237
+ suffix=$(openssl rand -base64 16 2>/dev/null | tr -dc 'a-z0-9' | head -c 10 || true)
238
+ if [ -n "$suffix" ]; then
239
+ printf '%s' "striae-dev-${suffix}"
240
+ fi
241
+ fi
242
+ }
243
+
244
+ _new=$( _gen_worker_name "USER_WORKER_NAME")
245
+ [ -n "$_new" ] && { USER_WORKER_NAME="$_new"; export USER_WORKER_NAME; }
246
+ prompt_for_var "USER_WORKER_NAME" "User worker name (auto-generated; change only if using an existing worker)"
247
+
248
+ _new=$(_gen_worker_name "DATA_WORKER_NAME")
249
+ [ -n "$_new" ] && { DATA_WORKER_NAME="$_new"; export DATA_WORKER_NAME; }
250
+ prompt_for_var "DATA_WORKER_NAME" "Data worker name (auto-generated; change only if using an existing worker)"
251
+
252
+ _new=$(_gen_worker_name "AUDIT_WORKER_NAME")
253
+ [ -n "$_new" ] && { AUDIT_WORKER_NAME="$_new"; export AUDIT_WORKER_NAME; }
254
+ prompt_for_var "AUDIT_WORKER_NAME" "Audit worker name (auto-generated; change only if using an existing worker)"
255
+
256
+ _new=$(_gen_worker_name "IMAGES_WORKER_NAME")
257
+ [ -n "$_new" ] && { IMAGES_WORKER_NAME="$_new"; export IMAGES_WORKER_NAME; }
258
+ prompt_for_var "IMAGES_WORKER_NAME" "Images worker name (auto-generated; change only if using an existing worker)"
259
+
260
+ _new=$(_gen_worker_name "PDF_WORKER_NAME")
261
+ [ -n "$_new" ] && { PDF_WORKER_NAME="$_new"; export PDF_WORKER_NAME; }
262
+ prompt_for_var "PDF_WORKER_NAME" "PDF worker name (auto-generated; change only if using an existing worker)"
227
263
 
228
- prompt_for_var "USER_WORKER_NAME" "User worker name"
229
- prompt_for_var "DATA_WORKER_NAME" "Data worker name"
230
- prompt_for_var "AUDIT_WORKER_NAME" "Audit worker name"
231
- prompt_for_var "IMAGES_WORKER_NAME" "Images worker name"
232
- prompt_for_var "PDF_WORKER_NAME" "PDF worker name"
264
+ _new=$(_gen_worker_name "LISTS_WORKER_NAME")
265
+ [ -n "$_new" ] && { LISTS_WORKER_NAME="$_new"; export LISTS_WORKER_NAME; }
266
+ prompt_for_var "LISTS_WORKER_NAME" "Lists worker name (auto-generated; change only if using an existing worker)"
233
267
  echo ""
234
268
 
235
269
  echo -e "${BLUE}🗄️ STORAGE CONFIGURATION${NC}"
@@ -238,6 +272,7 @@ prompt_for_secrets() {
238
272
  prompt_for_var "AUDIT_BUCKET_NAME" "Your R2 bucket name for audit logs (separate from data bucket)"
239
273
  prompt_for_var "FILES_BUCKET_NAME" "Your R2 bucket name for encrypted files storage"
240
274
  prompt_for_var "KV_STORE_ID" "Your KV namespace ID (UUID format)"
275
+ prompt_for_var "STRIAE_LISTS_KV_ID" "KV namespace ID for the lists-worker (UUID format; backs registration and primershear allowlists)"
241
276
 
242
277
  echo -e "${BLUE}🔐 SERVICE-SPECIFIC SECRETS${NC}"
243
278
  echo "============================"
@@ -255,6 +290,7 @@ prompt_for_secrets() {
255
290
  prompt_for_var "IMAGE_SIGNED_URL_BASE_URL" "Signed URL delivery base URL — routes signed image delivery through the Pages proxy (leave as-is unless using a non-standard domain)"
256
291
 
257
292
  prompt_for_var "BROWSER_API_TOKEN" "Cloudflare Browser Rendering API token (for PDF Worker)"
293
+ prompt_for_var "LISTS_ADMIN_SECRET" "Lists worker admin secret — guards write endpoints (auto-generated; guards POST/DELETE on the lists-worker)"
258
294
 
259
295
  configure_manifest_signing_credentials
260
296
  configure_export_encryption_credentials
@@ -106,6 +106,14 @@ copy_example_configs() {
106
106
  echo -e "${YELLOW} ⚠️ pdf-worker: wrangler.jsonc already exists, skipping copy${NC}"
107
107
  fi
108
108
 
109
+ cd ../lists-worker
110
+ if [ -f "wrangler.jsonc.example" ] && { [ "$update_env" = "true" ] || [ ! -f "wrangler.jsonc" ]; }; then
111
+ cp wrangler.jsonc.example wrangler.jsonc
112
+ echo -e "${GREEN} ✅ lists-worker: wrangler.jsonc created from example${NC}"
113
+ elif [ -f "wrangler.jsonc" ]; then
114
+ echo -e "${YELLOW} ⚠️ lists-worker: wrangler.jsonc already exists, skipping copy${NC}"
115
+ fi
116
+
109
117
  # Return to project root
110
118
  cd ../..
111
119
 
@@ -172,6 +180,16 @@ update_wrangler_configs() {
172
180
  echo -e "${GREEN} ✅ pdf-worker configuration updated${NC}"
173
181
  fi
174
182
 
183
+ if [ -f "workers/lists-worker/wrangler.jsonc" ]; then
184
+ echo -e "${YELLOW} Updating lists-worker/wrangler.jsonc...${NC}"
185
+ local escaped_striae_lists_kv_id
186
+ escaped_striae_lists_kv_id=$(escape_for_sed_replacement "$STRIAE_LISTS_KV_ID")
187
+ sed -i "s/\"LISTS_WORKER_NAME\"/\"$LISTS_WORKER_NAME\"/g" workers/lists-worker/wrangler.jsonc
188
+ sed -i "s/\"ACCOUNT_ID\"/\"$escaped_account_id\"/g" workers/lists-worker/wrangler.jsonc
189
+ sed -i "s/\"STRIAE_LISTS_KV_ID\"/\"$escaped_striae_lists_kv_id\"/g" workers/lists-worker/wrangler.jsonc
190
+ echo -e "${GREEN} ✅ lists-worker configuration updated${NC}"
191
+ fi
192
+
175
193
  if [ -f "workers/user-worker/wrangler.jsonc" ]; then
176
194
  echo -e "${YELLOW} Updating user-worker/wrangler.jsonc...${NC}"
177
195
  sed -i "s/\"USER_WORKER_NAME\"/\"$USER_WORKER_NAME\"/g" workers/user-worker/wrangler.jsonc
@@ -190,6 +208,7 @@ update_wrangler_configs() {
190
208
  sed -i "s/AUDIT_WORKER_NAME/$AUDIT_WORKER_NAME/g" wrangler.toml
191
209
  sed -i "s/IMAGES_WORKER_NAME/$IMAGES_WORKER_NAME/g" wrangler.toml
192
210
  sed -i "s/PDF_WORKER_NAME/$PDF_WORKER_NAME/g" wrangler.toml
211
+ sed -i "s/LISTS_WORKER_NAME/$LISTS_WORKER_NAME/g" wrangler.toml
193
212
  echo -e "${GREEN} ✅ main wrangler.toml configuration updated${NC}"
194
213
  fi
195
214
 
@@ -96,12 +96,14 @@ required_vars=(
96
96
  "AUDIT_WORKER_NAME"
97
97
  "IMAGES_WORKER_NAME"
98
98
  "PDF_WORKER_NAME"
99
+ "LISTS_WORKER_NAME"
99
100
 
100
101
  # Storage Configuration (required for config replacement)
101
102
  "DATA_BUCKET_NAME"
102
103
  "AUDIT_BUCKET_NAME"
103
104
  "FILES_BUCKET_NAME"
104
105
  "KV_STORE_ID"
106
+ "STRIAE_LISTS_KV_ID"
105
107
 
106
108
  # Worker-Specific Secrets (required for deployment)
107
109
  "IMAGE_SIGNED_URL_SECRET"
@@ -112,6 +114,7 @@ required_vars=(
112
114
  "EXPORT_ENCRYPTION_PRIVATE_KEY"
113
115
  "EXPORT_ENCRYPTION_KEY_ID"
114
116
  "EXPORT_ENCRYPTION_PUBLIC_KEY"
117
+ "LISTS_ADMIN_SECRET"
115
118
  )
116
119
 
117
120
  validate_required_vars() {
@@ -200,36 +200,6 @@ source "$DEPLOY_CONFIG_VALIDATION_MODULE"
200
200
  source "$DEPLOY_CONFIG_SCAFFOLDING_MODULE"
201
201
  source "$DEPLOY_CONFIG_PROMPT_MODULE"
202
202
 
203
- EMAIL_LIST_CONFIG_DIR="app/config"
204
- MEMBERS_EMAILS_FILE="$EMAIL_LIST_CONFIG_DIR/members.emails"
205
- PRIMERSHEAR_EMAILS_FILE="$EMAIL_LIST_CONFIG_DIR/primershear.emails"
206
-
207
- sync_env_var_from_email_list_file() {
208
- local env_var_name=$1
209
- local file_path=$2
210
- local loaded_values=""
211
-
212
- if [ ! -f "$file_path" ]; then
213
- echo -e "${YELLOW}⚠️ $file_path not found; keeping existing $env_var_name value in .env${NC}"
214
- return 0
215
- fi
216
-
217
- loaded_values=$(grep -v '^[[:space:]]*#' "$file_path" | grep -v '^[[:space:]]*$' | sed -e 's/\r$//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' | paste -sd ',' - || true)
218
-
219
- write_env_var "$env_var_name" "$loaded_values"
220
- export "$env_var_name=$loaded_values"
221
-
222
- local loaded_count
223
- loaded_count=$(echo "$loaded_values" | tr ',' '\n' | grep -c '[^[:space:]]' || true)
224
- echo -e "${GREEN}✅ Synced $env_var_name from $file_path ($loaded_count entry/entries)${NC}"
225
- }
226
-
227
- sync_email_list_env_vars_from_config() {
228
- echo -e "${YELLOW}📧 Syncing optional email list env vars from app/config...${NC}"
229
- sync_env_var_from_email_list_file "REGISTRATION_EMAILS" "$MEMBERS_EMAILS_FILE"
230
- sync_env_var_from_email_list_file "PRIMERSHEAR_EMAILS" "$PRIMERSHEAR_EMAILS_FILE"
231
- }
232
-
233
203
  if [ "$validate_only" = "true" ]; then
234
204
  echo -e "\n${BLUE}🧪 Validate-only mode enabled${NC}"
235
205
  run_validation_checkpoint
@@ -247,9 +217,6 @@ load_admin_service_credentials
247
217
  # Always prompt for secrets to ensure configuration
248
218
  prompt_for_secrets
249
219
 
250
- # Keep optional email list env vars aligned with app/config source files.
251
- sync_email_list_env_vars_from_config
252
-
253
220
  # Validate after secrets have been configured
254
221
  validate_required_vars
255
222
 
@@ -117,16 +117,6 @@ deploy_pages_secrets() {
117
117
  printf '%s' "$secret_value" | wrangler pages secret put "$secret" --project-name "$PAGES_PROJECT_NAME"
118
118
  done
119
119
 
120
- local optional_primershear_emails
121
- optional_primershear_emails=$(get_optional_value "PRIMERSHEAR_EMAILS")
122
- echo -e "${YELLOW} Setting PRIMERSHEAR_EMAILS...${NC}"
123
- printf '%s' "$optional_primershear_emails" | wrangler pages secret put "PRIMERSHEAR_EMAILS" --project-name "$PAGES_PROJECT_NAME"
124
-
125
- local optional_registration_emails
126
- optional_registration_emails=$(get_optional_value "REGISTRATION_EMAILS")
127
- echo -e "${YELLOW} Setting REGISTRATION_EMAILS...${NC}"
128
- printf '%s' "$optional_registration_emails" | wrangler pages secret put "REGISTRATION_EMAILS" --project-name "$PAGES_PROJECT_NAME"
129
-
130
120
  echo -e "${GREEN}✅ Pages secrets deployed to production${NC}"
131
121
  }
132
122
 
@@ -152,6 +142,7 @@ fi
152
142
 
153
143
  required_pages_secrets=(
154
144
  "PROJECT_ID"
145
+ "LISTS_ADMIN_SECRET"
155
146
  )
156
147
 
157
148
  echo -e "${YELLOW}🔍 Validating required Pages secret values...${NC}"
@@ -267,6 +267,13 @@ build_data_worker_secret_list() {
267
267
  printf '%s\n' "${secrets[@]}"
268
268
  }
269
269
 
270
+ build_lists_worker_secret_list() {
271
+ local secrets=(
272
+ "LISTS_ADMIN_SECRET"
273
+ )
274
+ printf '%s\n' "${secrets[@]}"
275
+ }
276
+
270
277
  build_images_worker_secret_list() {
271
278
  local secrets=(
272
279
  "DATA_AT_REST_ENCRYPTION_PUBLIC_KEY"
@@ -299,7 +306,7 @@ echo -e "\n${BLUE}🔐 Deploying secrets to workers...${NC}"
299
306
  # Check if workers are configured
300
307
  echo -e "${YELLOW}🔍 Checking worker configurations...${NC}"
301
308
  workers_configured=0
302
- total_workers=5
309
+ total_workers=6
303
310
 
304
311
  for worker_dir in workers/*/; do
305
312
  if [ -f "$worker_dir/wrangler.jsonc" ] || [ -f "$worker_dir/wrangler.toml" ]; then
@@ -363,6 +370,16 @@ if ! set_worker_secrets "PDF Worker" "workers/pdf-worker" \
363
370
  echo -e "${YELLOW}⚠️ Skipping PDF Worker (not configured)${NC}"
364
371
  fi
365
372
 
373
+ # Lists Worker
374
+ lists_worker_secrets=()
375
+ while IFS= read -r secret; do
376
+ lists_worker_secrets+=("$secret")
377
+ done < <(build_lists_worker_secret_list)
378
+
379
+ if ! set_worker_secrets "Lists Worker" "workers/lists-worker" "${lists_worker_secrets[@]}"; then
380
+ echo -e "${YELLOW}⚠️ Skipping Lists Worker (not configured)${NC}"
381
+ fi
382
+
366
383
  echo -e "\n${GREEN}🎉 Worker secrets deployment completed!${NC}"
367
384
 
368
385
  echo -e "\n${YELLOW}⚠️ WORKER CONFIGURATION REMINDERS:${NC}"
@@ -371,6 +388,7 @@ echo " - Configure KV namespace ID in workers/user-worker/wrangler.jsonc"
371
388
  echo " - Configure R2 bucket name in workers/data-worker/wrangler.jsonc"
372
389
  echo " - Configure R2 bucket name in workers/audit-worker/wrangler.jsonc"
373
390
  echo " - Configure R2 bucket name in workers/image-worker/wrangler.jsonc"
391
+ echo " - Configure KV namespace ID in workers/lists-worker/wrangler.jsonc"
374
392
  echo " - Update ACCOUNT_ID and custom domains in all worker configurations"
375
393
 
376
394
  echo -e "\n${BLUE}📝 For manual deployment, use these commands:${NC}"
@@ -7,8 +7,9 @@
7
7
  # 1. audit-worker
8
8
  # 2. data-worker
9
9
  # 3. image-worker
10
- # 4. pdf-worker
11
- # 5. user-worker
10
+ # 4. lists-worker
11
+ # 5. pdf-worker
12
+ # 6. user-worker
12
13
 
13
14
  # Colors for output
14
15
  RED='\033[0;31m'
@@ -34,7 +35,7 @@ if [ ! -d "$WORKERS_DIR" ]; then
34
35
  fi
35
36
 
36
37
  # List of workers
37
- WORKERS=("audit-worker" "data-worker" "image-worker" "pdf-worker" "user-worker")
38
+ WORKERS=("audit-worker" "data-worker" "image-worker" "lists-worker" "pdf-worker" "user-worker")
38
39
 
39
40
  echo -e "${PURPLE}Installing npm dependencies for all workers...${NC}"
40
41
  echo ""
@@ -11,6 +11,7 @@ const workerDirs = [
11
11
  'workers/audit-worker',
12
12
  'workers/data-worker',
13
13
  'workers/image-worker',
14
+ 'workers/lists-worker',
14
15
  'workers/pdf-worker',
15
16
  'workers/user-worker',
16
17
  ];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "audit-worker",
3
- "version": "7.0.1",
3
+ "version": "7.1.0",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "deploy": "wrangler deploy",
@@ -8,6 +8,6 @@
8
8
  "start": "wrangler dev"
9
9
  },
10
10
  "devDependencies": {
11
- "wrangler": "^4.84.1"
11
+ "wrangler": "^4.85.0"
12
12
  }
13
13
  }
@@ -3,7 +3,7 @@
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/audit-worker.ts",
5
5
  "workers_dev": false,
6
- "compatibility_date": "2026-04-22",
6
+ "compatibility_date": "2026-04-25",
7
7
  "compatibility_flags": [
8
8
  "nodejs_compat"
9
9
  ],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "data-worker",
3
- "version": "7.0.1",
3
+ "version": "7.1.0",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "deploy": "wrangler deploy",
@@ -9,6 +9,6 @@
9
9
  },
10
10
  "devDependencies": {
11
11
  "@cloudflare/vitest-pool-workers": "^0.14.9",
12
- "wrangler": "^4.84.1"
12
+ "wrangler": "^4.85.0"
13
13
  }
14
14
  }
@@ -3,7 +3,7 @@
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/data-worker.ts",
5
5
  "workers_dev": false,
6
- "compatibility_date": "2026-04-22",
6
+ "compatibility_date": "2026-04-25",
7
7
  "compatibility_flags": [
8
8
  "nodejs_compat"
9
9
  ],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "image-worker",
3
- "version": "7.0.1",
3
+ "version": "7.1.0",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "deploy": "wrangler deploy",
@@ -8,6 +8,6 @@
8
8
  "start": "wrangler dev"
9
9
  },
10
10
  "devDependencies": {
11
- "wrangler": "^4.84.1"
11
+ "wrangler": "^4.85.0"
12
12
  }
13
13
  }
@@ -3,7 +3,7 @@
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/image-worker.ts",
5
5
  "workers_dev": false,
6
- "compatibility_date": "2026-04-22",
6
+ "compatibility_date": "2026-04-25",
7
7
  "compatibility_flags": [
8
8
  "nodejs_compat"
9
9
  ],
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "lists-worker",
3
+ "version": "7.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "deploy": "wrangler deploy",
7
+ "dev": "wrangler dev",
8
+ "start": "wrangler dev"
9
+ },
10
+ "devDependencies": {
11
+ "wrangler": "^4.85.0"
12
+ }
13
+ }
@@ -0,0 +1,97 @@
1
+ import type { Env } from './types';
2
+
3
+ const JSON_HEADERS: HeadersInit = {
4
+ 'Content-Type': 'application/json',
5
+ 'Cache-Control': 'no-store',
6
+ 'Pragma': 'no-cache',
7
+ };
8
+
9
+ /** Routes map URL path segment to the KV key used in STRIAE_LISTS. */
10
+ const ROUTE_TO_KV_KEY: Record<string, string> = {
11
+ members: 'allow',
12
+ primershear: 'primershear',
13
+ };
14
+
15
+ function jsonResponse(data: Record<string, unknown>, status = 200): Response {
16
+ return new Response(JSON.stringify(data), { status, headers: JSON_HEADERS });
17
+ }
18
+
19
+ /**
20
+ * Constant-time string comparison to mitigate timing side-channels on auth checks.
21
+ * Both strings are encoded to bytes and compared with a full XOR pass.
22
+ */
23
+ function timingSafeEqual(a: string, b: string): boolean {
24
+ const encoder = new TextEncoder();
25
+ const aBytes = encoder.encode(a);
26
+ const bBytes = encoder.encode(b);
27
+ if (aBytes.length !== bBytes.length) return false;
28
+ let diff = 0;
29
+ for (let i = 0; i < aBytes.length; i++) {
30
+ diff |= aBytes[i] ^ bBytes[i];
31
+ }
32
+ return diff === 0;
33
+ }
34
+
35
+ function isAuthorized(request: Request, secret: string): boolean {
36
+ if (!secret) return false;
37
+ const auth = request.headers.get('Authorization');
38
+ if (!auth || !auth.startsWith('Bearer ')) return false;
39
+ return timingSafeEqual(auth.slice(7), secret);
40
+ }
41
+
42
+ export default {
43
+ async fetch(request: Request, env: Env): Promise<Response> {
44
+ const url = new URL(request.url);
45
+ const segment = url.pathname.replace(/^\/+|\/+$/g, '');
46
+ const kvKey = ROUTE_TO_KV_KEY[segment];
47
+
48
+ if (!kvKey) {
49
+ return jsonResponse({ error: 'Not found' }, 404);
50
+ }
51
+
52
+ if (request.method === 'GET') {
53
+ if (!isAuthorized(request, env.LISTS_ADMIN_SECRET)) {
54
+ return jsonResponse({ error: 'Unauthorized' }, 401);
55
+ }
56
+ const raw = (await env.STRIAE_LISTS.get(kvKey)) ?? '';
57
+ const list = raw ? raw.split(',').map(e => e.trim().toLowerCase()).filter(Boolean).join(',') : '';
58
+ return jsonResponse({ list });
59
+ }
60
+
61
+ if (request.method === 'POST' || request.method === 'DELETE') {
62
+ if (!isAuthorized(request, env.LISTS_ADMIN_SECRET)) {
63
+ return jsonResponse({ error: 'Unauthorized' }, 401);
64
+ }
65
+
66
+ let body: { entry?: unknown };
67
+ try {
68
+ body = await request.json() as { entry?: unknown };
69
+ } catch {
70
+ return jsonResponse({ error: 'Invalid JSON body' }, 400);
71
+ }
72
+
73
+ const entry = typeof body.entry === 'string' ? body.entry.trim().toLowerCase() : '';
74
+ if (!entry) {
75
+ return jsonResponse({ error: 'Missing or empty entry' }, 400);
76
+ }
77
+
78
+ const current = (await env.STRIAE_LISTS.get(kvKey)) ?? '';
79
+ const entries = current ? current.split(',').map(e => e.trim().toLowerCase()).filter(Boolean) : [];
80
+
81
+ if (request.method === 'POST') {
82
+ if (!entries.includes(entry)) {
83
+ entries.push(entry);
84
+ }
85
+ await env.STRIAE_LISTS.put(kvKey, entries.join(','));
86
+ return jsonResponse({ ok: true });
87
+ }
88
+
89
+ // DELETE
90
+ const filtered = entries.filter(e => e !== entry);
91
+ await env.STRIAE_LISTS.put(kvKey, filtered.join(','));
92
+ return jsonResponse({ ok: true });
93
+ }
94
+
95
+ return jsonResponse({ error: 'Method not allowed' }, 405);
96
+ },
97
+ };
@@ -0,0 +1,4 @@
1
+ export interface Env {
2
+ STRIAE_LISTS: KVNamespace;
3
+ LISTS_ADMIN_SECRET: string;
4
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "LISTS_WORKER_NAME",
3
+ "account_id": "ACCOUNT_ID",
4
+ "main": "src/lists-worker.ts",
5
+ "workers_dev": false,
6
+ "compatibility_date": "2026-04-25",
7
+ "compatibility_flags": [
8
+ "nodejs_compat"
9
+ ],
10
+
11
+ "observability": {
12
+ "enabled": true
13
+ },
14
+
15
+ "kv_namespaces": [
16
+ {
17
+ "binding": "STRIAE_LISTS",
18
+ "id": "STRIAE_LISTS_KV_ID"
19
+ }
20
+ ],
21
+
22
+ "placement": { "mode": "smart" }
23
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pdf-worker",
3
- "version": "7.0.1",
3
+ "version": "7.1.0",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "generate:assets": "node scripts/generate-assets.js",
@@ -9,6 +9,6 @@
9
9
  "start": "wrangler dev"
10
10
  },
11
11
  "devDependencies": {
12
- "wrangler": "^4.84.1"
12
+ "wrangler": "^4.85.0"
13
13
  }
14
14
  }
@@ -3,7 +3,7 @@
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/pdf-worker.ts",
5
5
  "workers_dev": false,
6
- "compatibility_date": "2026-04-22",
6
+ "compatibility_date": "2026-04-25",
7
7
  "compatibility_flags": [
8
8
  "nodejs_compat"
9
9
  ],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "user-worker",
3
- "version": "7.0.1",
3
+ "version": "7.1.0",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "deploy": "wrangler deploy",
@@ -8,6 +8,6 @@
8
8
  "start": "wrangler dev"
9
9
  },
10
10
  "devDependencies": {
11
- "wrangler": "^4.84.1"
11
+ "wrangler": "^4.85.0"
12
12
  }
13
13
  }
@@ -3,7 +3,7 @@
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/user-worker.ts",
5
5
  "workers_dev": false,
6
- "compatibility_date": "2026-04-22",
6
+ "compatibility_date": "2026-04-25",
7
7
  "compatibility_flags": [
8
8
  "nodejs_compat"
9
9
  ],
@@ -1,6 +1,6 @@
1
1
  #:schema node_modules/wrangler/config-schema.json
2
2
  name = "PAGES_PROJECT_NAME"
3
- compatibility_date = "2026-04-22"
3
+ compatibility_date = "2026-04-25"
4
4
  compatibility_flags = ["nodejs_compat"]
5
5
  pages_build_output_dir = "./build/client"
6
6
 
@@ -25,4 +25,8 @@ service = "IMAGES_WORKER_NAME"
25
25
 
26
26
  [[services]]
27
27
  binding = "PDF_WORKER"
28
- service = "PDF_WORKER_NAME"
28
+ service = "PDF_WORKER_NAME"
29
+
30
+ [[services]]
31
+ binding = "LISTS_WORKER"
32
+ service = "LISTS_WORKER_NAME"
@@ -1,11 +0,0 @@
1
- # Registration gateway - authorized email addresses
2
- # One entry per line. Lines starting with # are ignored.
3
- # This file is untracked. Run: npm run deploy-members to push changes.
4
- #
5
- # Supported formats:
6
- # Exact email: analyst@organization.com
7
- # Domain wildcard: @organization.com (allows all emails from that domain)
8
- #
9
- # Examples:
10
- # analyst@organization.com
11
- # @striae.org
@@ -1,6 +0,0 @@
1
- # PrimerShear PDF format - authorized email addresses
2
- # One email per line. Lines starting with # are ignored.
3
- # This file is untracked. Run: npm run deploy-primershear to push changes.
4
- #
5
- # Example:
6
- # analyst@organization.com
@@ -1,102 +0,0 @@
1
- #!/bin/bash
2
-
3
- # ============================================
4
- # MEMBERS EMAIL LIST DEPLOYMENT SCRIPT
5
- # ============================================
6
- # Reads app/config/members.emails, updates REGISTRATION_EMAILS in .env,
7
- # then deploys that secret directly to Cloudflare Pages (production).
8
-
9
- set -e
10
- set -o pipefail
11
-
12
- RED='\033[0;31m'
13
- GREEN='\033[0;32m'
14
- YELLOW='\033[1;33m'
15
- BLUE='\033[0;34m'
16
- NC='\033[0m'
17
-
18
- echo -e "${BLUE}👥 Members Email List Deployment${NC}"
19
- echo "=================================="
20
-
21
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
22
- PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
23
- cd "$PROJECT_ROOT"
24
-
25
- trap 'echo -e "\n${RED}❌ deploy-members-emails.sh failed near line ${LINENO}${NC}"' ERR
26
-
27
- # ── Read emails file ──────────────────────────────────────────────────────────
28
-
29
- EMAILS_FILE="$PROJECT_ROOT/app/config/members.emails"
30
-
31
- if [ ! -f "$EMAILS_FILE" ]; then
32
- echo -e "${RED}❌ members.emails not found at: $EMAILS_FILE${NC}"
33
- echo -e "${YELLOW} Create it with one email address or @domain.com wildcard per line.${NC}"
34
- echo -e "${YELLOW} See app/config-example/members.emails for the format.${NC}"
35
- exit 1
36
- fi
37
-
38
- # Strip comment lines and blank lines, then join with commas
39
- # Use || true to avoid failure if paste gets no input (handles empty file gracefully)
40
- REGISTRATION_EMAILS=$(grep -v '^[[:space:]]*#' "$EMAILS_FILE" | grep -v '^[[:space:]]*$' | paste -sd ',' - || true)
41
-
42
- if [ -z "$REGISTRATION_EMAILS" ]; then
43
- echo -e "${YELLOW}⚠️ members.emails contains no active entries.${NC}"
44
- echo -e "${YELLOW} The secret will be set to an empty string, disabling the gateway (open registration).${NC}"
45
- fi
46
-
47
- ENTRY_COUNT=$(echo "$REGISTRATION_EMAILS" | tr ',' '\n' | grep -c '[^[:space:]]' || true)
48
- echo -e "${GREEN}✅ Loaded $ENTRY_COUNT entry(ies) from app/config/members.emails${NC}"
49
-
50
- # ── Update .env ───────────────────────────────────────────────────────────────
51
-
52
- ENV_FILE="$PROJECT_ROOT/.env"
53
-
54
- if [ ! -f "$ENV_FILE" ]; then
55
- echo -e "${RED}❌ .env not found. Run deploy-config first.${NC}"
56
- exit 1
57
- fi
58
-
59
- # Replace the REGISTRATION_EMAILS= line in .env (handles both empty and populated values)
60
- if grep -q '^REGISTRATION_EMAILS=' "$ENV_FILE"; then
61
- # Use a temp file to avoid sed -i portability issues across macOS/Linux
62
- local_tmp=$(mktemp)
63
- sed "s|^REGISTRATION_EMAILS=.*|REGISTRATION_EMAILS=${REGISTRATION_EMAILS}|" "$ENV_FILE" > "$local_tmp"
64
- mv "$local_tmp" "$ENV_FILE"
65
- echo -e "${GREEN}✅ Updated REGISTRATION_EMAILS in .env${NC}"
66
- else
67
- echo "" >> "$ENV_FILE"
68
- echo "REGISTRATION_EMAILS=${REGISTRATION_EMAILS}" >> "$ENV_FILE"
69
- echo -e "${GREEN}✅ Appended REGISTRATION_EMAILS to .env${NC}"
70
- fi
71
-
72
- # ── Deploy to Cloudflare Pages ────────────────────────────────────────────────
73
-
74
- if ! command -v wrangler > /dev/null 2>&1; then
75
- echo -e "${RED}❌ wrangler is not installed or not in PATH${NC}"
76
- exit 1
77
- fi
78
-
79
- source "$ENV_FILE"
80
-
81
- PAGES_PROJECT_NAME=$(echo "$PAGES_PROJECT_NAME" | tr -d '\r')
82
- if [ -z "$PAGES_PROJECT_NAME" ]; then
83
- echo -e "${RED}❌ PAGES_PROJECT_NAME is missing from .env${NC}"
84
- exit 1
85
- fi
86
-
87
- echo -e "${YELLOW} Setting REGISTRATION_EMAILS for production...${NC}"
88
- printf '%s' "$REGISTRATION_EMAILS" | wrangler pages secret put REGISTRATION_EMAILS \
89
- --project-name "$PAGES_PROJECT_NAME"
90
-
91
- echo -e "${GREEN}✅ REGISTRATION_EMAILS deployed to production${NC}"
92
-
93
- # Deploy Pages so the new secret takes effect immediately
94
- echo -e "\n${YELLOW}🚀 Building and deploying Pages to activate new secret...${NC}"
95
-
96
- if ! npm run deploy-pages; then
97
- echo -e "${RED}❌ Pages deployment failed${NC}"
98
- exit 1
99
- fi
100
- echo -e "${GREEN}✅ Pages deployment complete${NC}"
101
-
102
- echo -e "\n${GREEN}🎉 Members email list deployment complete!${NC}"
@@ -1,101 +0,0 @@
1
- #!/bin/bash
2
-
3
- # ============================================
4
- # PRIMERSHEAR EMAIL LIST DEPLOYMENT SCRIPT
5
- # ============================================
6
- # Reads app/config/primershear.emails, updates PRIMERSHEAR_EMAILS in .env,
7
- # then deploys that secret directly to Cloudflare Pages (production).
8
-
9
- set -e
10
- set -o pipefail
11
-
12
- RED='\033[0;31m'
13
- GREEN='\033[0;32m'
14
- YELLOW='\033[1;33m'
15
- BLUE='\033[0;34m'
16
- NC='\033[0m'
17
-
18
- echo -e "${BLUE}📧 PrimerShear Email List Deployment${NC}"
19
- echo "======================================"
20
-
21
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
22
- PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
23
- cd "$PROJECT_ROOT"
24
-
25
- trap 'echo -e "\n${RED}❌ deploy-primershear-emails.sh failed near line ${LINENO}${NC}"' ERR
26
-
27
- # ── Read emails file ──────────────────────────────────────────────────────────
28
-
29
- EMAILS_FILE="$PROJECT_ROOT/app/config/primershear.emails"
30
-
31
- if [ ! -f "$EMAILS_FILE" ]; then
32
- echo -e "${RED}❌ primershear.emails not found at: $EMAILS_FILE${NC}"
33
- echo -e "${YELLOW} Create it with one email address per line.${NC}"
34
- exit 1
35
- fi
36
-
37
- # Strip comment lines and blank lines, then join with commas
38
- # Use || true to avoid failure if paste gets no input (handles empty file gracefully)
39
- PRIMERSHEAR_EMAILS=$(grep -v '^[[:space:]]*#' "$EMAILS_FILE" | grep -v '^[[:space:]]*$' | paste -sd ',' - || true)
40
-
41
- if [ -z "$PRIMERSHEAR_EMAILS" ]; then
42
- echo -e "${YELLOW}⚠️ primershear.emails contains no active email addresses.${NC}"
43
- echo -e "${YELLOW} The secret will be set to an empty string, disabling the feature.${NC}"
44
- fi
45
-
46
- EMAIL_COUNT=$(echo "$PRIMERSHEAR_EMAILS" | tr ',' '\n' | grep -c '[^[:space:]]' || true)
47
- echo -e "${GREEN}✅ Loaded $EMAIL_COUNT email address(es) from app/config/primershear.emails${NC}"
48
-
49
- # ── Update .env ───────────────────────────────────────────────────────────────
50
-
51
- ENV_FILE="$PROJECT_ROOT/.env"
52
-
53
- if [ ! -f "$ENV_FILE" ]; then
54
- echo -e "${RED}❌ .env not found. Run deploy-config first.${NC}"
55
- exit 1
56
- fi
57
-
58
- # Replace the PRIMERSHEAR_EMAILS= line in .env (handles both empty and populated values)
59
- if grep -q '^PRIMERSHEAR_EMAILS=' "$ENV_FILE"; then
60
- # Use a temp file to avoid sed -i portability issues across macOS/Linux
61
- local_tmp=$(mktemp)
62
- sed "s|^PRIMERSHEAR_EMAILS=.*|PRIMERSHEAR_EMAILS=${PRIMERSHEAR_EMAILS}|" "$ENV_FILE" > "$local_tmp"
63
- mv "$local_tmp" "$ENV_FILE"
64
- echo -e "${GREEN}✅ Updated PRIMERSHEAR_EMAILS in .env${NC}"
65
- else
66
- echo "" >> "$ENV_FILE"
67
- echo "PRIMERSHEAR_EMAILS=${PRIMERSHEAR_EMAILS}" >> "$ENV_FILE"
68
- echo -e "${GREEN}✅ Appended PRIMERSHEAR_EMAILS to .env${NC}"
69
- fi
70
-
71
- # ── Deploy to Cloudflare Pages ────────────────────────────────────────────────
72
-
73
- if ! command -v wrangler > /dev/null 2>&1; then
74
- echo -e "${RED}❌ wrangler is not installed or not in PATH${NC}"
75
- exit 1
76
- fi
77
-
78
- source "$ENV_FILE"
79
-
80
- PAGES_PROJECT_NAME=$(echo "$PAGES_PROJECT_NAME" | tr -d '\r')
81
- if [ -z "$PAGES_PROJECT_NAME" ]; then
82
- echo -e "${RED}❌ PAGES_PROJECT_NAME is missing from .env${NC}"
83
- exit 1
84
- fi
85
-
86
- echo -e "${YELLOW} Setting PRIMERSHEAR_EMAILS for production...${NC}"
87
- printf '%s' "$PRIMERSHEAR_EMAILS" | wrangler pages secret put PRIMERSHEAR_EMAILS \
88
- --project-name "$PAGES_PROJECT_NAME"
89
-
90
- echo -e "${GREEN}✅ PRIMERSHEAR_EMAILS deployed to production${NC}"
91
-
92
- # Deploy Pages so the new secret takes effect immediately
93
- echo -e "\n${YELLOW}🚀 Building and deploying Pages to activate new secret...${NC}"
94
-
95
- if ! npm run deploy-pages; then
96
- echo -e "${RED}❌ Pages deployment failed${NC}"
97
- exit 1
98
- fi
99
- echo -e "${GREEN}✅ Pages deployment complete${NC}"
100
-
101
- echo -e "\n${GREEN}🎉 PrimerShear email list deployment complete!${NC}"