@auth-craft/aws-cf-stack 1.3.0 → 1.5.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.
package/.env.example CHANGED
@@ -46,6 +46,13 @@ LAMBDA_APP_BASE_PATH= # recommended — set a stable, non-guessable valu
46
46
  # changes between deploys, so clients/workers don't break.
47
47
 
48
48
  # ─── Cloudflare gateway workers ──────────────────────────────────────────────
49
+ # Deploy model: 'multi' (default — 3 workers, one scope each; AUTH_SCOPE fixed per worker,
50
+ # so scope is ENFORCED) or 'shared' (1 worker that derives scope from the path's first
51
+ # segment). Shared is simpler (1 deploy/binding) but does NOT enforce scope on its own —
52
+ # pair it with a scope-locked Pages proxy (@auth-craft/pages-auth-proxy `scope` option) if
53
+ # customer apps must never reach system/tenant. In shared mode CORS unions all scopes and
54
+ # the CORS_KV uses CF_CORS_KV_ID_SHARED (or falls back to CF_CORS_KV_ID_CUSTOMER).
55
+ CF_GATEWAY_MODE=multi # multi | shared
49
56
  CLOUDFLARE_API_TOKEN= # required for gateway (flag --cf-api-token)
50
57
  CLOUDFLARE_ACCOUNT_ID= # required for gateway (flag --cf-account-id)
51
58
  CF_EDGE_GATE_SECRET= # required for gateway (flag --edge-gate-secret) — Client → Worker gate
@@ -84,6 +91,15 @@ CF_WORKER_DISABLE_PUBLIC_URL= # shared default (e.g. true to disable al
84
91
  CF_WORKER_DISABLE_PUBLIC_URL_SYSTEM= # e.g. true — admin app via Pages proxy
85
92
  CF_WORKER_DISABLE_PUBLIC_URL_TENANT=
86
93
  CF_WORKER_DISABLE_PUBLIC_URL_CUSTOMER=
94
+ # Skip the X-Edge-Gate (Client → Worker) check. Redundant when the worker has NO public
95
+ # URL and is reached only via a Service Binding (Pages proxy) — so it's ONLY honored when
96
+ # CF_WORKER_DISABLE_PUBLIC_URL is also set for that scope (else ignored with a warning).
97
+ # Does NOT affect the Worker → Backend secret (X-Gateway-Secret stays required). Truthy:
98
+ # 1/true/yes. When skipped, EDGE_GATE_SECRET is not needed.
99
+ CF_WORKER_SKIP_EDGE_GATE= # shared default
100
+ CF_WORKER_SKIP_EDGE_GATE_SYSTEM= # e.g. true (pairs with DISABLE_PUBLIC_URL_SYSTEM)
101
+ CF_WORKER_SKIP_EDGE_GATE_TENANT=
102
+ CF_WORKER_SKIP_EDGE_GATE_CUSTOMER=
87
103
 
88
104
  # ─── Internal service JWT (optional — enables /internal/* + service worker verify) ─
89
105
  LAMBDA_INTERNAL_JWT_PUBLIC_KEY= # optional — also fed to workers as SERVICE_JWT_PUBLIC_KEY
package/README.md CHANGED
@@ -17,7 +17,9 @@ or building the auth-craft repo** — just pass env and run.
17
17
 
18
18
  1. **DynamoDB + Lambda** (CDK) — the auth backend. Emits a Function URL + a base path.
19
19
  2. **Super-admin** (idempotent) — bootstraps the `__system__` tenant owner.
20
- 3. **3 gateway workers** (system / tenant / customer) on Cloudflare, via wrangler.
20
+ 3. **Gateway worker(s)** on Cloudflare, via wrangler. Default `multi` = **3 workers**
21
+ (system / tenant / customer); `CF_GATEWAY_MODE=shared` = **1 worker**. See
22
+ [Deploy model](#deploy-model-multi-vs-shared).
21
23
 
22
24
  The gateway's `BACKEND_URL` is computed as `FunctionURL + ApiBasePath` read back from the
23
25
  **live CDK outputs** of the same deploy — so the worker config can never drift from the
@@ -33,18 +35,38 @@ Everything is named from `--stage` (`dev|staging|prod`) and `--project` (default
33
35
  |----------|------|
34
36
  | CDK stacks | `<app>-<stage>-data`, `<app>-<stage>-api` |
35
37
  | Lambda fn + DynamoDB table | `<app>-<stage>` |
36
- | Gateway workers | `<app>-<stage>-system` / `-tenant` / `-customer` |
38
+ | Gateway workers (`multi`) | `<app>-<stage>-system` / `-tenant` / `-customer` |
39
+ | Gateway worker (`shared`) | `<app>-<stage>` |
37
40
 
38
41
  So `outputs`/`gateway` look up the api stack `<app>-<stage>-api`. The CDK output keys are
39
42
  `LambdaFunctionUrl`, `ApiBasePath`, `AuthSystemName`, `DynamoDBTableName`.
40
43
 
44
+ ### Deploy model (multi vs shared)
45
+
46
+ `CF_GATEWAY_MODE` picks how the gateway is deployed:
47
+
48
+ - **`multi`** (default) — 3 workers, one scope each. Each worker has a fixed `AUTH_SCOPE`,
49
+ so the scope is **enforced server-side**: a customer worker can never act as system,
50
+ whatever path the client sends. Strongest isolation; 3 deploys + 3 Service Bindings.
51
+ - **`shared`** — 1 worker. Scope is derived from the path's first segment
52
+ (`/system/...` → `system`), so the worker by itself does **not** enforce scope (a client
53
+ could send `/system/...`). Simpler: 1 deploy, 1 binding, unioned CORS. **Pair it with a
54
+ scope-locked Pages proxy** (`createPagesAuthProxy({ scope: 'customer' })` from
55
+ [@auth-craft/pages-auth-proxy](../pages-auth-proxy/README.md)) so the *proxy* prepends
56
+ the scope and the client never picks it — that restores the "customer can't reach
57
+ system" guarantee at the edge.
58
+
59
+ In `shared` mode CORS unions all per-scope origins/suffixes, and the dynamic-CORS KV uses
60
+ `CF_CORS_KV_ID_SHARED` (falling back to `CF_CORS_KV_ID_CUSTOMER`). `outputs.workerNames.*`
61
+ all point at the single worker.
62
+
41
63
  ## Commands
42
64
 
43
65
  | Command | Does |
44
66
  |---------|------|
45
67
  | `lambda` | Deploy the DynamoDB + Lambda CDK stacks. `--outputs-file <p>` writes the raw CDK outputs JSON. Runs a best-effort `/health` check. |
46
68
  | `admin` | Create the super-admin (idempotent; **skips** if `LAMBDA_SUPER_ADMIN_*` / `LAMBDA_AUTH_SETUP_TOKEN` unset; "already exists" = success). |
47
- | `gateway` | Deploy the 3 gateway workers. Re-reads the Lambda outputs from CloudFormation if run as a separate invocation. |
69
+ | `gateway` | Deploy the gateway worker(s) — 3 (`multi`, default) or 1 (`CF_GATEWAY_MODE=shared`). Re-reads the Lambda outputs from CloudFormation if run as a separate invocation. |
48
70
  | `all` | `lambda` → `admin` → `gateway`, one run. |
49
71
  | `outputs` | Print the live api-stack outputs as JSON (no deploy): `backendUrl`, `apiBasePath`, `dynamodbTable`, and the auth keys read **from the live Lambda config** (`jwtPublicKey`, `jwtIssuer`, `jwtAlgorithm`, `serviceJwtPublicKey`) + `gatewayUrls`/`routePrefixes`. Self-contained — works standalone (no env), so an orchestrator can auto-fill its `*_AUTH_*` vars. |
50
72
 
@@ -230,6 +252,16 @@ domain). This applies only when no `CF_WORKER_CUSTOM_DOMAIN_<SCOPE>` is set for
230
252
  if you're using `AUTH_GATEWAY_URL` (no binding) leave it off, since that path needs the
231
253
  public URL.
232
254
 
255
+ **Skip the edge gate (optional).** The `X-Edge-Gate` secret exists to stop the public
256
+ internet from hitting the worker. Once the public URL is disabled and the worker is
257
+ reached only via a Service Binding, that gate is redundant — set
258
+ `CF_WORKER_SKIP_EDGE_GATE=true` (or per scope) to drop it; the Pages proxy then needs no
259
+ `EDGE_GATE_SECRET`. For safety it's **only honored when the public URL is also disabled**
260
+ for that scope (otherwise ignored with a warning). It does **not** touch the
261
+ Worker → Backend secret: `X-Gateway-Secret` stays required because the backend (Lambda
262
+ Function URL) is still on the public internet — without it, anyone could call the Lambda
263
+ directly.
264
+
233
265
  ### Per-scope token transport
234
266
 
235
267
  `REFRESH_TOKEN_STRATEGY` sets the transport for every scope. To mix, use the per-scope
@@ -1 +1 @@
1
- var Et=typeof process<"u"&&!1;function _(t){return Et?Object.freeze(t):t}function h(t,e){if(arguments.length===0)return _({ok:!0,data:void 0});let n={ok:!0,data:t};return e!==void 0&&(n.meta=e),_(n)}function a(t,e){let r={ok:!1,errors:_(Array.isArray(t)?[...t]:[t])};return e!==void 0&&(r.meta=e),_(r)}function d(t,e){return n=>{let r={code:t,message:n?.message??e};return n?.field&&(r.field=n.field),r}}function N(t){return t.ok?t.data===void 0?t.meta?{ok:!0,meta:t.meta}:{ok:!0}:t.meta?{ok:!0,data:t.data,meta:t.meta}:{ok:!0,data:t.data}:t.meta?{ok:!1,errors:t.errors,meta:t.meta}:{ok:!1,errors:t.errors}}function M(t){let n=["BACKEND_URL","BACKEND_GATEWAY_SECRET","EDGE_GATE_SECRET","JWT_PUBLIC_KEY","SERVICE_JWT_PUBLIC_KEY"].filter(r=>!t[r]);if(n.length>0)throw new Error(`Missing required environment variables: ${n.join(", ")}`)}function pt(t){return t.ENVIRONMENT||"development"}function S(t){return pt(t)==="development"}function U(t){return t.ALLOWED_ORIGINS?t.ALLOWED_ORIGINS.split(",").map(e=>e.trim()).filter(Boolean):["*"]}function gt(t){return t.ALLOWED_ORIGIN_SUFFIXES?t.ALLOWED_ORIGIN_SUFFIXES.split(",").map(e=>e.trim().toLowerCase()).filter(Boolean):[]}function P(t){return`origin:${t}`}async function v(t,e){if(!t)return!1;if(U(e).includes(t))return!0;let r=gt(e);if(r.length>0){let o;try{o=new URL(t).host.toLowerCase()}catch{return!1}if(r.some(i=>o===i.replace(/^\./,"")||o.endsWith(i)))return!0}return!!(e.CORS_KV&&await e.CORS_KV.get(P(t))!==null)}function I(t){let e=t.ROUTE_PREFIX||"";if(!e)return"";let n=e.startsWith("/")?e:`/${e}`;return n.endsWith("/")?n.slice(0,-1):n}function C(t,e){let n=I(e);return n?t===n?"/":t.startsWith(`${n}/`)?t.slice(n.length):t:t}var At=new Set(["system","tenant","customer"]);function L(t){return t&&At.has(t)?t:void 0}function H(t,e){let n=L(t.AUTH_SCOPE);if(n)return{scope:n,forwardPath:e};let r=L(I(t).replace(/^\//,""));if(r)return{scope:r,forwardPath:e};let o=e.split("/").filter(Boolean),i=L(o[0]);if(i){let s=e.slice(e.indexOf(o[0])+o[0].length);return{scope:i,forwardPath:s||"/"}}return{scope:void 0,forwardPath:e}}function F(t,e){let n=I(e);return n?t===n||t.startsWith(`${n}/`):!0}function X(t){if(!t)return new Set(["EdDSA"]);let e=t.split(",").map(n=>n.trim()).filter(Boolean);return e.length>0?new Set(e):new Set(["EdDSA"])}function $(t){return X(t.JWT_ALLOWED_ALGORITHMS)}function Y(t){return X(t.SERVICE_JWT_ALLOWED_ALGORITHMS)}function ht(t){let e=t.SERVICE_ROUTE_PREFIX||"/internal",n=e.startsWith("/")?e:`/${e}`;return n.endsWith("/")?n.slice(0,-1):n}function j(t){return t.SERVICE_JWT_AUDIENCE||"auth-craft:internal"}function z(t){return t.JWT_ISSUER||void 0}function Q(t){return t.SERVICE_JWT_ISSUER||void 0}function Z(t,e){let n=t.split("?")[0].replace(/\/$/,"")||"/",r=ht(e);return n===r||n.startsWith(`${r}/`)}var mt=new Set(["authenticate","login","register","refresh","logout","complete-challenge","complete-mfa-challenge","issue-mfa-challenge","forgot-password","reset-password","verify-email","resend-verification"]),B=new Set(["/verification/resend","/verification/verify","/password/forgot","/password/reset"]);function q(t){let e=t.split("?")[0].replace(/\/$/,"")||"/";if(e==="/"||e.startsWith("/oauth/")||e==="/oauth"||B.has(e))return!0;let n=e.match(/^\/[^/]+(\/(?:verification|password)\/[^/]+)$/);if(n?.[1]&&B.has(n[1]))return!0;let r=e.match(/^(?:\/[^/]+)?\/auth\/([^/]+)/);if(r){let o=r[1];if(o&&mt.has(o)||o?.startsWith("oauth"))return!0}return!1}function tt(t){return t.GATEWAY_JWT_PRIVATE_KEY||void 0}function et(t){return t.TID_VALUE||void 0}function rt(t){let e=t.split("?")[0].replace(/\/$/,"");return e.endsWith("/auth/login")||e.endsWith("/auth/authenticate")}function Rt(){let t=Date.now(),e=new Uint8Array(16);e[0]=t/1099511627776&255,e[1]=t/4294967296&255,e[2]=t/16777216&255,e[3]=t/65536&255,e[4]=t/256&255,e[5]=t&255;let n=new Uint8Array(10);crypto.getRandomValues(n),e.set(n,6),e[6]=e[6]&15|112,e[8]=e[8]&63|128;let r="0123456789ABCDEFGHJKMNPQRSTVWXYZ",o="",i=0,s=0;for(let c=0;c<e.length;c++)for(s=s<<8|e[c],i+=8;i>=5;)o+=r[s>>>i-5&31],i-=5;return i>0&&(o+=r[s<<5-i&31]),o}var w={BAD_REQUEST:d("BAD_REQUEST","Bad request"),UNAUTHORIZED:d("UNAUTHORIZED","Authentication required"),FORBIDDEN:d("FORBIDDEN","Access denied"),NOT_FOUND:d("NOT_FOUND","Resource not found"),RATE_LIMIT_EXCEEDED:d("RATE_LIMIT_EXCEEDED","Too many requests"),INTERNAL_ERROR:d("INTERNAL_ERROR","Internal server error"),BAD_GATEWAY:d("BAD_GATEWAY","Gateway error"),SERVICE_UNAVAILABLE:d("SERVICE_UNAVAILABLE","Service temporarily unavailable"),GATEWAY_TIMEOUT:d("GATEWAY_TIMEOUT","Gateway timeout")},f={INVALID_FORMAT:d("JWT_INVALID_FORMAT","Invalid JWT format"),UNSUPPORTED_ALGORITHM:d("JWT_UNSUPPORTED_ALGORITHM","Unsupported JWT algorithm"),INVALID_TOKEN_TYPE:d("JWT_INVALID_TOKEN_TYPE","Invalid token type: expected access token"),TOKEN_EXPIRED:d("JWT_TOKEN_EXPIRED","JWT token expired"),SIGNATURE_INVALID:d("JWT_SIGNATURE_INVALID","JWT signature verification failed"),MISSING_TOKEN:d("JWT_MISSING_TOKEN","Missing access token"),INVALID_ISSUER:d("JWT_INVALID_ISSUER","Invalid JWT issuer")},nt={BAD_REQUEST:400,UNAUTHORIZED:401,FORBIDDEN:403,NOT_FOUND:404,RATE_LIMIT_EXCEEDED:429,INTERNAL_ERROR:500,BAD_GATEWAY:502,SERVICE_UNAVAILABLE:503,GATEWAY_TIMEOUT:504,JWT_INVALID_FORMAT:401,JWT_UNSUPPORTED_ALGORITHM:401,JWT_INVALID_TOKEN_TYPE:401,JWT_TOKEN_EXPIRED:401,JWT_SIGNATURE_INVALID:401,JWT_MISSING_TOKEN:401,JWT_INVALID_ISSUER:401};function St(t){if(t.ok)return t.data===void 0?204:200;let e=t.errors?.[0];return e?.code&&e.code in nt?nt[e.code]:500}function l(t,e){let n=new Date().toISOString(),r=e||Rt(),o={...t.meta,timestamp:n,traceId:r},i=t.ok?h(t.data,o):a(t.errors,o),s=St(i),c=N(i);return new Response(JSON.stringify(c),{status:s,headers:{"Content-Type":"application/json"}})}var wt=new Set(["EdDSA","ES256","RS256","PS256"]);function yt(t){return wt.has(t)}function _t(t){switch(t){case"EdDSA":return{name:"Ed25519"};case"ES256":return{name:"ECDSA",namedCurve:"P-256"};case"RS256":return{name:"RSASSA-PKCS1-v1_5",hash:"SHA-256"};case"PS256":return{name:"RSA-PSS",hash:"SHA-256"}}}function It(t){switch(t){case"EdDSA":return"Ed25519";case"ES256":return{name:"ECDSA",hash:"SHA-256"};case"RS256":return{name:"RSASSA-PKCS1-v1_5"};case"PS256":return{name:"RSA-PSS",saltLength:32}}}function x(t){let e=t.replace(/-/g,"+").replace(/_/g,"/"),n=e+"==".slice(0,(4-e.length%4)%4),r=atob(n),o=new Uint8Array(r.length);for(let i=0;i<r.length;i++)o[i]=r.charCodeAt(i);return o}function ot(t){return atob(t)}var Ot=/^[0-9a-fA-F]{64}$/;async function Dt(t,e){if(Ot.test(t)){if(e!=="EdDSA")throw new Error(`Hex key format is only supported for EdDSA, got ${e}`);let r=new Uint8Array(32);for(let o=0;o<32;o++)r[o]=parseInt(t.slice(o*2,o*2+2),16);return crypto.subtle.importKey("raw",r,{name:"Ed25519"},!1,["verify"])}let n;try{n=JSON.parse(ot(t))}catch{throw new Error(`Invalid JWK base64 for algorithm ${e}`)}return await crypto.subtle.importKey("jwk",n,_t(e),!1,["verify"])}function it(t){let e=t.split(".");if(e.length!==3)return null;let n=e[1];if(!n)return null;try{let r=x(n);return JSON.parse(new TextDecoder().decode(r))}catch{return null}}function Pt(t){let n=t.split(".")[0];if(!n)return a(f.INVALID_FORMAT());try{let r=x(n);return h(JSON.parse(new TextDecoder().decode(r)))}catch{return a(f.INVALID_FORMAT())}}function Ct(t){let n=t.split(".")[1];if(!n)return a(f.INVALID_FORMAT());try{let r=x(n);return h(JSON.parse(new TextDecoder().decode(r)))}catch{return a(f.INVALID_FORMAT())}}async function xt(t,e,n){let r=t.split("."),o=r[0],i=r[1],s=r[2];if(!o||!i||!s)return!1;let c=new TextEncoder().encode(`${o}.${i}`),u=x(s);try{return await crypto.subtle.verify(It(n),e,u,c)}catch{return!1}}async function b(t,e,n=1,r=new Set(["EdDSA"]),o){let i=Pt(t);if(!i.ok)return a(i.errors,i.meta);let s=i.data.alg;if(!r.has(s))return a(f.UNSUPPORTED_ALGORITHM());if(!yt(s))return a(f.UNSUPPORTED_ALGORITHM());let c=Ct(t);if(!c.ok)return a(c.errors,c.meta);let u=c.data;if(u.typ!==n)return a(f.INVALID_TOKEN_TYPE());if(o!==void 0&&u.iss!==o)return a(f.INVALID_ISSUER());if(u.exp&&Math.floor(Date.now()/1e3)>=u.exp)return a(f.TOKEN_EXPIRED());let E;try{E=await Dt(e,s)}catch{return a(f.INVALID_FORMAT())}return await xt(t,E,s)?h(u):a(f.SIGNATURE_INVALID())}var W=null;async function Nt(t){let e;try{e=JSON.parse(ot(t))}catch{throw new Error("Invalid Gateway JWT private key: expected base64-encoded JWK")}return await crypto.subtle.importKey("jwk",e,{name:"Ed25519"},!1,["sign"])}function J(t){let e="";for(let n=0;n<t.length;n++)e+=String.fromCharCode(t[n]);return btoa(e).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}async function st(t,e,n,r,o=30){W||(W=Nt(t));let i=await W,s=Math.floor(Date.now()/1e3),c={alg:"EdDSA",typ:"JWT"},u={iss:e,aud:n,sub:r,iat:s,exp:s+o,jti:crypto.randomUUID()},E=J(new TextEncoder().encode(JSON.stringify(c))),g=J(new TextEncoder().encode(JSON.stringify(u))),m=new TextEncoder().encode(`${E}.${g}`),R=await crypto.subtle.sign("Ed25519",i,m),T=J(new Uint8Array(R));return`${E}.${g}.${T}`}var Lt=["content-type","accept","accept-encoding","accept-language","user-agent","cookie"];function Ut(t){return t.getSetCookie?.()??[]}async function Wt(t,e){if(!(t.headers.get("content-type")||"").includes("application/json"))return null;let r;try{r=await t.clone().json()}catch{return null}return delete r.tid,r.tid=e,{body:JSON.stringify(r),contentType:"application/json"}}function Jt(t,e,n,r,o,i){let s=new Headers;for(let m of Lt){let R=t.headers.get(m);R&&s.set(m,R)}o&&s.set("content-type",o);let c=t.headers.get("Authorization");c&&s.set("Authorization",c),r&&s.set("X-Auth-Context",r),s.set("X-Gateway-Secret",e),i&&s.set("x-gateway-auth",`Bearer ${i}`),s.set("X-Request-Id",n);let u=t.headers.get("cf-connecting-ip");u&&(s.set("X-Forwarded-For",u),s.set("X-Real-IP",u)),s.set("X-Forwarded-Proto","https");let E=new URL(t.url);s.set("X-Forwarded-Host",E.hostname);let g=t.headers.get("cf-ipcountry");return g&&s.set("X-Cloudflare-Country",g),s}function bt(t,e,n){let r=new URL(t.url),o=e.BACKEND_URL.endsWith("/")?e.BACKEND_URL.slice(0,-1):e.BACKEND_URL;S(e)&&console.log("[Gateway] Path transform:",JSON.stringify({original:r.pathname,forwarded:n}));let i=new URL(`${o}${n}`);return i.search=r.search,i.toString()}function kt(t,e,n){let r=new Headers(t.headers);r.set("X-Request-Id",e);let o=Ut(t.headers);if(o.length>0){r.delete("Set-Cookie");for(let i of o)r.append("Set-Cookie",i);S(n)&&console.log("[Gateway] Forwarding Set-Cookie:",o.length,"cookie(s)")}return new Response(t.body,{status:t.status,statusText:t.statusText,headers:r})}async function k(t,e){let n=crypto.randomUUID();try{let r=new URL(t.url),o=C(r.pathname,e),{scope:i,forwardPath:s}=H(e,o),c=bt(t,e,s),u,E,g=et(e);if(g&&rt(s)&&t.method==="POST"){let y=await Wt(t,g);y&&(u=y.body,E=y.contentType,S(e)&&console.log("[Gateway] Injected tid:",g,"into",s))}let m,R=tt(e);R&&(m=await st(R,"bff-worker","auth-craft:backend","cloudflare-auth-gateway"));let T=Jt(t,e.BACKEND_GATEWAY_SECRET,n,i,E,m),D=u!==void 0?u:t.body?await t.clone().arrayBuffer():void 0,K=await fetch(c,{method:t.method,headers:T,body:D});return kt(K,n,e)}catch(r){console.error("[Gateway] Proxy error:",r);let o=r instanceof Error?r.message:"Unknown error";return l(a(d("BAD_GATEWAY",`Failed to reach backend: ${o}`)()))}}var O=new Map,at=0,Gt=[{match:t=>t==="/auth/authenticate",limit:15,windowMs:6e4,prefix:"login"},{match:t=>t==="/auth/register",limit:8,windowMs:6e4,prefix:"register"},{match:t=>t==="/password/forgot",limit:8,windowMs:3e5,prefix:"forgot"},{match:t=>t.startsWith("/oauth/"),limit:30,windowMs:6e4,prefix:"oauth"},{match:t=>t.startsWith("/auth/complete-"),limit:30,windowMs:6e4,prefix:"complete"},{match:t=>t==="/auth/issue-mfa-challenge",limit:15,windowMs:6e4,prefix:"issue-mfa"}];function Kt(t){let e=Date.now();if(e-at<3e4)return;at=e;let n=e-t;for(let[r,o]of O){let i=o.hits.filter(s=>s>n);i.length===0?O.delete(r):o.hits=i}}function ct(t,e,n){if(n!=="POST")return{allowed:!0,retryAfterSec:0};let r=Gt.find(E=>E.match(e));if(!r)return{allowed:!0,retryAfterSec:0};Kt(3e5);let o=`${r.prefix}:${t}`,i=Date.now(),s=i-r.windowMs,c=O.get(o)??{hits:[]},u=c.hits.filter(E=>E>s);if(u.length>=r.limit){let E=u[0]??i;return c.hits=u,O.set(o,c),{allowed:!1,retryAfterSec:Math.max(1,Math.ceil((E+r.windowMs-i)/1e3))}}return u.push(i),c.hits=u,O.set(o,c),{allowed:!0,retryAfterSec:0}}function ut(t,e){if(t.length!==e.length)return!1;let n=0;for(let r=0;r<t.length;r++)n|=t.charCodeAt(r)^e.charCodeAt(r);return n===0}async function lt(t,e){return t&&await v(t,e)?{"Access-Control-Allow-Origin":t,"Access-Control-Allow-Credentials":"true",Vary:"Origin"}:U(e).includes("*")?{"Access-Control-Allow-Origin":"*"}:{Vary:"Origin"}}async function Vt(t,e){let n=t.headers.get("Origin")??"*",r=t.headers.get("Access-Control-Request-Headers")??"";return new Response(null,{status:204,headers:{...await lt(n,e),"Access-Control-Allow-Methods":"GET, POST, PUT, DELETE, OPTIONS, PATCH","Access-Control-Allow-Headers":r||"Content-Type, Authorization, X-Requested-With, X-Edge-Gate","Access-Control-Max-Age":"86400"}})}async function p(t,e,n){let r=e.headers.get("Origin")??"*",o=new Response(t.body,t),i=t.headers.getSetCookie?.()??[];if(i.length>0){o.headers.delete("Set-Cookie");for(let c of i)o.headers.append("Set-Cookie",c)}let s=await lt(r,n);for(let[c,u]of Object.entries(s))o.headers.set(c,u);return o.headers.set("Access-Control-Expose-Headers","Content-Length, X-Request-Id, Set-Cookie"),o}var Bt="/internal/cors-origins";function Mt(t){return t.split("?")[0].replace(/\/$/,"")===Bt}async function vt(t,e){if(!e.CORS_KV)return G();if(t.method==="GET"){let r=(await e.CORS_KV.list({prefix:"origin:"})).keys.map(o=>o.name.slice(7));return l(h({origins:r}))}if(t.method==="PUT"||t.method==="DELETE"){let n;try{n=(await t.json()).origin}catch{return l(a(w.BAD_REQUEST()))}if(typeof n!="string"||!n)return l(a(w.BAD_REQUEST()));let r;try{if(r=new URL(n).origin,r==="null"||r!==n.replace(/\/$/,""))return l(a(w.BAD_REQUEST()))}catch{return l(a(w.BAD_REQUEST()))}return t.method==="PUT"?await e.CORS_KV.put(P(r),"1"):await e.CORS_KV.delete(P(r)),l(h({origin:r}))}return l(a(w.BAD_REQUEST()))}function G(){return new Response(null,{status:404})}var Pe={async fetch(t,e,n){try{M(e);let r=new URL(t.url);if(!F(r.pathname,e))return G();if(t.method==="OPTIONS")return await Vt(t,e);let o=t.headers.get("X-Edge-Gate");if(!o||!e.EDGE_GATE_SECRET||!ut(o,e.EDGE_GATE_SECRET))return S(e)&&console.warn("[Gateway] X-Edge-Gate missing or invalid"),G();let i=I(e),s=i?`${i}/health`:"/health";if(r.pathname===s)return await p(l(h({status:"healthy",timestamp:new Date().toISOString()})),t,e);let c=C(r.pathname,e);S(e)&&console.log("[Gateway] Request:",t.method,r.pathname,"\u2192",c);let u=t.headers.get("cf-connecting-ip")??"unknown",E=ct(u,c,t.method);if(!E.allowed){let A=l(a(w.RATE_LIMIT_EXCEEDED()));return A.headers.set("Retry-After",String(E.retryAfterSec)),await p(A,t,e)}let g=Z(c,e);if(!g&&q(c))return await p(await k(t,e),t,e);let m=t.headers.get("Authorization");if(!m)return await p(l(a(f.MISSING_TOKEN())),t,e);let R=m.match(/^Bearer\s+(.+)$/i);if(!R)return await p(l(a(f.INVALID_FORMAT())),t,e);let T=R[1];if(T.length>4096)return await p(l(a(f.INVALID_FORMAT())),t,e);let D=T.split(".");if(D.length!==3)return await p(l(a(f.INVALID_FORMAT())),t,e);if(D[1].length>2048)return await p(l(a(f.INVALID_FORMAT())),t,e);let y=it(T);if(!y)return await p(l(a(f.INVALID_FORMAT())),t,e);let ft=y.typ,dt=g?3:1;if(ft!==dt)return await p(l(a(f.INVALID_TOKEN_TYPE())),t,e);if(g&&y.aud!==j(e))return await p(l(a(f.INVALID_TOKEN_TYPE())),t,e);let V=y.exp;if(typeof V=="number"&&Math.floor(Date.now()/1e3)>=V)return await p(l(a(f.TOKEN_EXPIRED())),t,e);if(g){if(!e.SERVICE_JWT_PUBLIC_KEY)return await p(l(a(w.INTERNAL_ERROR())),t,e);let A=await b(T,e.SERVICE_JWT_PUBLIC_KEY,3,Y(e),Q(e));if(!A.ok)return S(e)&&console.error("[Gateway] Service JWT failed:",A.errors),await p(l(a(A.errors,A.meta)),t,e)}else{let A=await b(T,e.JWT_PUBLIC_KEY,1,$(e),z(e));if(!A.ok)return S(e)&&console.error("[Gateway] User JWT failed:",A.errors),await p(l(a(A.errors,A.meta)),t,e)}return g&&Mt(c)?await p(await vt(t,e),t,e):await p(await k(t,e),t,e)}catch(r){return S(e)&&console.error("[Gateway] Unhandled error:",r),l(a(w.INTERNAL_ERROR()))}}};export{Pe as default};
1
+ var Et=typeof process<"u"&&!1;function y(t){return Et?Object.freeze(t):t}function m(t,e){if(arguments.length===0)return y({ok:!0,data:void 0});let n={ok:!0,data:t};return e!==void 0&&(n.meta=e),y(n)}function a(t,e){let r={ok:!1,errors:y(Array.isArray(t)?[...t]:[t])};return e!==void 0&&(r.meta=e),y(r)}function E(t,e){return n=>{let r={code:t,message:n?.message??e};return n?.field&&(r.field=n.field),r}}function N(t){return t.ok?t.data===void 0?t.meta?{ok:!0,meta:t.meta}:{ok:!0}:t.meta?{ok:!0,data:t.data,meta:t.meta}:{ok:!0,data:t.data}:t.meta?{ok:!1,errors:t.errors,meta:t.meta}:{ok:!1,errors:t.errors}}function v(t){let e=["BACKEND_URL","BACKEND_GATEWAY_SECRET","JWT_PUBLIC_KEY","SERVICE_JWT_PUBLIC_KEY"];U(t)||e.push("EDGE_GATE_SECRET");let n=e.filter(r=>!t[r]);if(n.length>0)throw new Error(`Missing required environment variables: ${n.join(", ")}`)}function U(t){return t.SKIP_EDGE_GATE==="true"}function pt(t){return t.ENVIRONMENT||"development"}function S(t){return pt(t)==="development"}function W(t){return t.ALLOWED_ORIGINS?t.ALLOWED_ORIGINS.split(",").map(e=>e.trim()).filter(Boolean):["*"]}function gt(t){return t.ALLOWED_ORIGIN_SUFFIXES?t.ALLOWED_ORIGIN_SUFFIXES.split(",").map(e=>e.trim().toLowerCase()).filter(Boolean):[]}function P(t){return`origin:${t}`}async function H(t,e){if(!t)return!1;if(W(e).includes(t))return!0;let r=gt(e);if(r.length>0){let o;try{o=new URL(t).host.toLowerCase()}catch{return!1}if(r.some(i=>o===i.replace(/^\./,"")||o.endsWith(i)))return!0}return!!(e.CORS_KV&&await e.CORS_KV.get(P(t))!==null)}function O(t){let e=t.ROUTE_PREFIX||"";if(!e)return"";let n=e.startsWith("/")?e:`/${e}`;return n.endsWith("/")?n.slice(0,-1):n}function C(t,e){let n=O(e);return n?t===n?"/":t.startsWith(`${n}/`)?t.slice(n.length):t:t}var At=new Set(["system","tenant","customer"]);function L(t){return t&&At.has(t)?t:void 0}function F(t,e){let n=L(t.AUTH_SCOPE);if(n)return{scope:n,forwardPath:e};let r=L(O(t).replace(/^\//,""));if(r)return{scope:r,forwardPath:e};let o=e.split("/").filter(Boolean),i=L(o[0]);if(i){let s=e.slice(e.indexOf(o[0])+o[0].length);return{scope:i,forwardPath:s||"/"}}return{scope:void 0,forwardPath:e}}function X(t,e){let n=O(e);return n?t===n||t.startsWith(`${n}/`):!0}function $(t){if(!t)return new Set(["EdDSA"]);let e=t.split(",").map(n=>n.trim()).filter(Boolean);return e.length>0?new Set(e):new Set(["EdDSA"])}function Y(t){return $(t.JWT_ALLOWED_ALGORITHMS)}function j(t){return $(t.SERVICE_JWT_ALLOWED_ALGORITHMS)}function ht(t){let e=t.SERVICE_ROUTE_PREFIX||"/internal",n=e.startsWith("/")?e:`/${e}`;return n.endsWith("/")?n.slice(0,-1):n}function z(t){return t.SERVICE_JWT_AUDIENCE||"auth-craft:internal"}function Q(t){return t.JWT_ISSUER||void 0}function Z(t){return t.SERVICE_JWT_ISSUER||void 0}function q(t,e){let n=t.split("?")[0].replace(/\/$/,"")||"/",r=ht(e);return n===r||n.startsWith(`${r}/`)}var mt=new Set(["authenticate","login","register","refresh","logout","complete-challenge","complete-mfa-challenge","issue-mfa-challenge","forgot-password","reset-password","verify-email","resend-verification"]),M=new Set(["/verification/resend","/verification/verify","/password/forgot","/password/reset"]);function tt(t){let e=t.split("?")[0].replace(/\/$/,"")||"/";if(e==="/"||e.startsWith("/oauth/")||e==="/oauth"||M.has(e))return!0;let n=e.match(/^\/[^/]+(\/(?:verification|password)\/[^/]+)$/);if(n?.[1]&&M.has(n[1]))return!0;let r=e.match(/^(?:\/[^/]+)?\/auth\/([^/]+)/);if(r){let o=r[1];if(o&&mt.has(o)||o?.startsWith("oauth"))return!0}return!1}function et(t){return t.GATEWAY_JWT_PRIVATE_KEY||void 0}function rt(t){return t.TID_VALUE||void 0}function nt(t){let e=t.split("?")[0].replace(/\/$/,"");return e.endsWith("/auth/login")||e.endsWith("/auth/authenticate")}function Rt(){let t=Date.now(),e=new Uint8Array(16);e[0]=t/1099511627776&255,e[1]=t/4294967296&255,e[2]=t/16777216&255,e[3]=t/65536&255,e[4]=t/256&255,e[5]=t&255;let n=new Uint8Array(10);crypto.getRandomValues(n),e.set(n,6),e[6]=e[6]&15|112,e[8]=e[8]&63|128;let r="0123456789ABCDEFGHJKMNPQRSTVWXYZ",o="",i=0,s=0;for(let c=0;c<e.length;c++)for(s=s<<8|e[c],i+=8;i>=5;)o+=r[s>>>i-5&31],i-=5;return i>0&&(o+=r[s<<5-i&31]),o}var T={BAD_REQUEST:E("BAD_REQUEST","Bad request"),UNAUTHORIZED:E("UNAUTHORIZED","Authentication required"),FORBIDDEN:E("FORBIDDEN","Access denied"),NOT_FOUND:E("NOT_FOUND","Resource not found"),RATE_LIMIT_EXCEEDED:E("RATE_LIMIT_EXCEEDED","Too many requests"),INTERNAL_ERROR:E("INTERNAL_ERROR","Internal server error"),BAD_GATEWAY:E("BAD_GATEWAY","Gateway error"),SERVICE_UNAVAILABLE:E("SERVICE_UNAVAILABLE","Service temporarily unavailable"),GATEWAY_TIMEOUT:E("GATEWAY_TIMEOUT","Gateway timeout")},d={INVALID_FORMAT:E("JWT_INVALID_FORMAT","Invalid JWT format"),UNSUPPORTED_ALGORITHM:E("JWT_UNSUPPORTED_ALGORITHM","Unsupported JWT algorithm"),INVALID_TOKEN_TYPE:E("JWT_INVALID_TOKEN_TYPE","Invalid token type: expected access token"),TOKEN_EXPIRED:E("JWT_TOKEN_EXPIRED","JWT token expired"),SIGNATURE_INVALID:E("JWT_SIGNATURE_INVALID","JWT signature verification failed"),MISSING_TOKEN:E("JWT_MISSING_TOKEN","Missing access token"),INVALID_ISSUER:E("JWT_INVALID_ISSUER","Invalid JWT issuer")},ot={BAD_REQUEST:400,UNAUTHORIZED:401,FORBIDDEN:403,NOT_FOUND:404,RATE_LIMIT_EXCEEDED:429,INTERNAL_ERROR:500,BAD_GATEWAY:502,SERVICE_UNAVAILABLE:503,GATEWAY_TIMEOUT:504,JWT_INVALID_FORMAT:401,JWT_UNSUPPORTED_ALGORITHM:401,JWT_INVALID_TOKEN_TYPE:401,JWT_TOKEN_EXPIRED:401,JWT_SIGNATURE_INVALID:401,JWT_MISSING_TOKEN:401,JWT_INVALID_ISSUER:401};function St(t){if(t.ok)return t.data===void 0?204:200;let e=t.errors?.[0];return e?.code&&e.code in ot?ot[e.code]:500}function f(t,e){let n=new Date().toISOString(),r=e||Rt(),o={...t.meta,timestamp:n,traceId:r},i=t.ok?m(t.data,o):a(t.errors,o),s=St(i),c=N(i);return new Response(JSON.stringify(c),{status:s,headers:{"Content-Type":"application/json"}})}var wt=new Set(["EdDSA","ES256","RS256","PS256"]);function _t(t){return wt.has(t)}function yt(t){switch(t){case"EdDSA":return{name:"Ed25519"};case"ES256":return{name:"ECDSA",namedCurve:"P-256"};case"RS256":return{name:"RSASSA-PKCS1-v1_5",hash:"SHA-256"};case"PS256":return{name:"RSA-PSS",hash:"SHA-256"}}}function It(t){switch(t){case"EdDSA":return"Ed25519";case"ES256":return{name:"ECDSA",hash:"SHA-256"};case"RS256":return{name:"RSASSA-PKCS1-v1_5"};case"PS256":return{name:"RSA-PSS",saltLength:32}}}function x(t){let e=t.replace(/-/g,"+").replace(/_/g,"/"),n=e+"==".slice(0,(4-e.length%4)%4),r=atob(n),o=new Uint8Array(r.length);for(let i=0;i<r.length;i++)o[i]=r.charCodeAt(i);return o}function it(t){return atob(t)}var Ot=/^[0-9a-fA-F]{64}$/;async function Dt(t,e){if(Ot.test(t)){if(e!=="EdDSA")throw new Error(`Hex key format is only supported for EdDSA, got ${e}`);let r=new Uint8Array(32);for(let o=0;o<32;o++)r[o]=parseInt(t.slice(o*2,o*2+2),16);return crypto.subtle.importKey("raw",r,{name:"Ed25519"},!1,["verify"])}let n;try{n=JSON.parse(it(t))}catch{throw new Error(`Invalid JWK base64 for algorithm ${e}`)}return await crypto.subtle.importKey("jwk",n,yt(e),!1,["verify"])}function st(t){let e=t.split(".");if(e.length!==3)return null;let n=e[1];if(!n)return null;try{let r=x(n);return JSON.parse(new TextDecoder().decode(r))}catch{return null}}function Pt(t){let n=t.split(".")[0];if(!n)return a(d.INVALID_FORMAT());try{let r=x(n);return m(JSON.parse(new TextDecoder().decode(r)))}catch{return a(d.INVALID_FORMAT())}}function Ct(t){let n=t.split(".")[1];if(!n)return a(d.INVALID_FORMAT());try{let r=x(n);return m(JSON.parse(new TextDecoder().decode(r)))}catch{return a(d.INVALID_FORMAT())}}async function xt(t,e,n){let r=t.split("."),o=r[0],i=r[1],s=r[2];if(!o||!i||!s)return!1;let c=new TextEncoder().encode(`${o}.${i}`),u=x(s);try{return await crypto.subtle.verify(It(n),e,u,c)}catch{return!1}}async function b(t,e,n=1,r=new Set(["EdDSA"]),o){let i=Pt(t);if(!i.ok)return a(i.errors,i.meta);let s=i.data.alg;if(!r.has(s))return a(d.UNSUPPORTED_ALGORITHM());if(!_t(s))return a(d.UNSUPPORTED_ALGORITHM());let c=Ct(t);if(!c.ok)return a(c.errors,c.meta);let u=c.data;if(u.typ!==n)return a(d.INVALID_TOKEN_TYPE());if(o!==void 0&&u.iss!==o)return a(d.INVALID_ISSUER());if(u.exp&&Math.floor(Date.now()/1e3)>=u.exp)return a(d.TOKEN_EXPIRED());let l;try{l=await Dt(e,s)}catch{return a(d.INVALID_FORMAT())}return await xt(t,l,s)?m(u):a(d.SIGNATURE_INVALID())}var J=null;async function Nt(t){let e;try{e=JSON.parse(it(t))}catch{throw new Error("Invalid Gateway JWT private key: expected base64-encoded JWK")}return await crypto.subtle.importKey("jwk",e,{name:"Ed25519"},!1,["sign"])}function k(t){let e="";for(let n=0;n<t.length;n++)e+=String.fromCharCode(t[n]);return btoa(e).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}async function at(t,e,n,r,o=30){J||(J=Nt(t));let i=await J,s=Math.floor(Date.now()/1e3),c={alg:"EdDSA",typ:"JWT"},u={iss:e,aud:n,sub:r,iat:s,exp:s+o,jti:crypto.randomUUID()},l=k(new TextEncoder().encode(JSON.stringify(c))),A=k(new TextEncoder().encode(JSON.stringify(u))),R=new TextEncoder().encode(`${l}.${A}`),h=await crypto.subtle.sign("Ed25519",i,R),w=k(new Uint8Array(h));return`${l}.${A}.${w}`}var Lt=["content-type","accept","accept-encoding","accept-language","user-agent","cookie"];function Ut(t){return t.getSetCookie?.()??[]}async function Wt(t,e){if(!(t.headers.get("content-type")||"").includes("application/json"))return null;let r;try{r=await t.clone().json()}catch{return null}return delete r.tid,r.tid=e,{body:JSON.stringify(r),contentType:"application/json"}}function Jt(t,e,n,r,o,i){let s=new Headers;for(let R of Lt){let h=t.headers.get(R);h&&s.set(R,h)}o&&s.set("content-type",o);let c=t.headers.get("Authorization");c&&s.set("Authorization",c),r&&s.set("X-Auth-Context",r),s.set("X-Gateway-Secret",e),i&&s.set("x-gateway-auth",`Bearer ${i}`),s.set("X-Request-Id",n);let u=t.headers.get("cf-connecting-ip");u&&(s.set("X-Forwarded-For",u),s.set("X-Real-IP",u)),s.set("X-Forwarded-Proto","https");let l=new URL(t.url);s.set("X-Forwarded-Host",l.hostname);let A=t.headers.get("cf-ipcountry");return A&&s.set("X-Cloudflare-Country",A),s}function kt(t,e,n){let r=new URL(t.url),o=e.BACKEND_URL.endsWith("/")?e.BACKEND_URL.slice(0,-1):e.BACKEND_URL;S(e)&&console.log("[Gateway] Path transform:",JSON.stringify({original:r.pathname,forwarded:n}));let i=new URL(`${o}${n}`);return i.search=r.search,i.toString()}function bt(t,e,n){let r=new Headers(t.headers);r.set("X-Request-Id",e);let o=Ut(t.headers);if(o.length>0){r.delete("Set-Cookie");for(let i of o)r.append("Set-Cookie",i);S(n)&&console.log("[Gateway] Forwarding Set-Cookie:",o.length,"cookie(s)")}return new Response(t.body,{status:t.status,statusText:t.statusText,headers:r})}async function G(t,e){let n=crypto.randomUUID();try{let r=new URL(t.url),o=C(r.pathname,e),{scope:i,forwardPath:s}=F(e,o),c=kt(t,e,s),u,l,A=rt(e);if(A&&nt(s)&&t.method==="POST"){let I=await Wt(t,A);I&&(u=I.body,l=I.contentType,S(e)&&console.log("[Gateway] Injected tid:",A,"into",s))}let R,h=et(e);h&&(R=await at(h,"bff-worker","auth-craft:backend","cloudflare-auth-gateway"));let w=Jt(t,e.BACKEND_GATEWAY_SECRET,n,i,l,R),V=u!==void 0?u:t.body?await t.clone().arrayBuffer():void 0,_=await fetch(c,{method:t.method,headers:w,body:V});return bt(_,n,e)}catch(r){console.error("[Gateway] Proxy error:",r);let o=r instanceof Error?r.message:"Unknown error";return f(a(E("BAD_GATEWAY",`Failed to reach backend: ${o}`)()))}}var D=new Map,ct=0,Gt=[{match:t=>t==="/auth/authenticate",limit:15,windowMs:6e4,prefix:"login"},{match:t=>t==="/auth/register",limit:8,windowMs:6e4,prefix:"register"},{match:t=>t==="/password/forgot",limit:8,windowMs:3e5,prefix:"forgot"},{match:t=>t.startsWith("/oauth/"),limit:30,windowMs:6e4,prefix:"oauth"},{match:t=>t.startsWith("/auth/complete-"),limit:30,windowMs:6e4,prefix:"complete"},{match:t=>t==="/auth/issue-mfa-challenge",limit:15,windowMs:6e4,prefix:"issue-mfa"}];function Kt(t){let e=Date.now();if(e-ct<3e4)return;ct=e;let n=e-t;for(let[r,o]of D){let i=o.hits.filter(s=>s>n);i.length===0?D.delete(r):o.hits=i}}function ut(t,e,n){if(n!=="POST")return{allowed:!0,retryAfterSec:0};let r=Gt.find(l=>l.match(e));if(!r)return{allowed:!0,retryAfterSec:0};Kt(3e5);let o=`${r.prefix}:${t}`,i=Date.now(),s=i-r.windowMs,c=D.get(o)??{hits:[]},u=c.hits.filter(l=>l>s);if(u.length>=r.limit){let l=u[0]??i;return c.hits=u,D.set(o,c),{allowed:!1,retryAfterSec:Math.max(1,Math.ceil((l+r.windowMs-i)/1e3))}}return u.push(i),c.hits=u,D.set(o,c),{allowed:!0,retryAfterSec:0}}function ft(t,e){if(t.length!==e.length)return!1;let n=0;for(let r=0;r<t.length;r++)n|=t.charCodeAt(r)^e.charCodeAt(r);return n===0}async function lt(t,e){return t&&await H(t,e)?{"Access-Control-Allow-Origin":t,"Access-Control-Allow-Credentials":"true",Vary:"Origin"}:W(e).includes("*")?{"Access-Control-Allow-Origin":"*"}:{Vary:"Origin"}}async function Vt(t,e){let n=t.headers.get("Origin")??"*",r=t.headers.get("Access-Control-Request-Headers")??"";return new Response(null,{status:204,headers:{...await lt(n,e),"Access-Control-Allow-Methods":"GET, POST, PUT, DELETE, OPTIONS, PATCH","Access-Control-Allow-Headers":r||"Content-Type, Authorization, X-Requested-With, X-Edge-Gate","Access-Control-Max-Age":"86400"}})}async function p(t,e,n){let r=e.headers.get("Origin")??"*",o=new Response(t.body,t),i=t.headers.getSetCookie?.()??[];if(i.length>0){o.headers.delete("Set-Cookie");for(let c of i)o.headers.append("Set-Cookie",c)}let s=await lt(r,n);for(let[c,u]of Object.entries(s))o.headers.set(c,u);return o.headers.set("Access-Control-Expose-Headers","Content-Length, X-Request-Id, Set-Cookie"),o}var Bt="/internal/cors-origins";function Mt(t){return t.split("?")[0].replace(/\/$/,"")===Bt}async function vt(t,e){if(!e.CORS_KV)return K();if(t.method==="GET"){let r=(await e.CORS_KV.list({prefix:"origin:"})).keys.map(o=>o.name.slice(7));return f(m({origins:r}))}if(t.method==="PUT"||t.method==="DELETE"){let n;try{n=(await t.json()).origin}catch{return f(a(T.BAD_REQUEST()))}if(typeof n!="string"||!n)return f(a(T.BAD_REQUEST()));let r;try{if(r=new URL(n).origin,r==="null"||r!==n.replace(/\/$/,""))return f(a(T.BAD_REQUEST()))}catch{return f(a(T.BAD_REQUEST()))}return t.method==="PUT"?await e.CORS_KV.put(P(r),"1"):await e.CORS_KV.delete(P(r)),f(m({origin:r}))}return f(a(T.BAD_REQUEST()))}function K(){return new Response(null,{status:404})}var Pe={async fetch(t,e,n){try{v(e);let r=new URL(t.url);if(!X(r.pathname,e))return K();if(t.method==="OPTIONS")return await Vt(t,e);if(!U(e)){let g=t.headers.get("X-Edge-Gate");if(!g||!e.EDGE_GATE_SECRET||!ft(g,e.EDGE_GATE_SECRET))return S(e)&&console.warn("[Gateway] X-Edge-Gate missing or invalid"),K()}let o=O(e),i=o?`${o}/health`:"/health";if(r.pathname===i)return await p(f(m({status:"healthy",timestamp:new Date().toISOString()})),t,e);let s=C(r.pathname,e);S(e)&&console.log("[Gateway] Request:",t.method,r.pathname,"\u2192",s);let c=t.headers.get("cf-connecting-ip")??"unknown",u=ut(c,s,t.method);if(!u.allowed){let g=f(a(T.RATE_LIMIT_EXCEEDED()));return g.headers.set("Retry-After",String(u.retryAfterSec)),await p(g,t,e)}let l=q(s,e);if(!l&&tt(s))return await p(await G(t,e),t,e);let A=t.headers.get("Authorization");if(!A)return await p(f(a(d.MISSING_TOKEN())),t,e);let R=A.match(/^Bearer\s+(.+)$/i);if(!R)return await p(f(a(d.INVALID_FORMAT())),t,e);let h=R[1];if(h.length>4096)return await p(f(a(d.INVALID_FORMAT())),t,e);let w=h.split(".");if(w.length!==3)return await p(f(a(d.INVALID_FORMAT())),t,e);if(w[1].length>2048)return await p(f(a(d.INVALID_FORMAT())),t,e);let _=st(h);if(!_)return await p(f(a(d.INVALID_FORMAT())),t,e);let I=_.typ,dt=l?3:1;if(I!==dt)return await p(f(a(d.INVALID_TOKEN_TYPE())),t,e);if(l&&_.aud!==z(e))return await p(f(a(d.INVALID_TOKEN_TYPE())),t,e);let B=_.exp;if(typeof B=="number"&&Math.floor(Date.now()/1e3)>=B)return await p(f(a(d.TOKEN_EXPIRED())),t,e);if(l){if(!e.SERVICE_JWT_PUBLIC_KEY)return await p(f(a(T.INTERNAL_ERROR())),t,e);let g=await b(h,e.SERVICE_JWT_PUBLIC_KEY,3,j(e),Z(e));if(!g.ok)return S(e)&&console.error("[Gateway] Service JWT failed:",g.errors),await p(f(a(g.errors,g.meta)),t,e)}else{let g=await b(h,e.JWT_PUBLIC_KEY,1,Y(e),Q(e));if(!g.ok)return S(e)&&console.error("[Gateway] User JWT failed:",g.errors),await p(f(a(g.errors,g.meta)),t,e)}return l&&Mt(s)?await p(await vt(t,e),t,e):await p(await G(t,e),t,e)}catch(r){return S(e)&&console.error("[Gateway] Unhandled error:",r),f(a(T.INTERNAL_ERROR()))}}};export{Pe as default};
package/lib/common.sh CHANGED
@@ -177,16 +177,32 @@ lambda_env() {
177
177
  --query "Environment.Variables.${key}" --output text 2>/dev/null | sed 's/^None$//' || true
178
178
  }
179
179
 
180
+ # Resolve the gateway deploy mode: 'multi' (3 workers, one scope each — default) or
181
+ # 'shared' (1 worker that derives scope from the path's first segment). Accepts
182
+ # CF_GATEWAY_MODE = multi | shared (case-insensitive); anything else → multi.
183
+ gateway_mode() {
184
+ case "$(echo "${CF_GATEWAY_MODE:-multi}" | tr '[:upper:]' '[:lower:]')" in
185
+ shared | single | one) echo shared ;;
186
+ *) echo multi ;;
187
+ esac
188
+ }
189
+
180
190
  # Derive a gateway worker URL for a scope (custom domain > workers.dev > empty).
191
+ # In shared mode all scopes resolve to the single worker ${APP_NAME}-${STAGE}.
181
192
  gateway_url_for() {
182
- local scope="$1" upper dom_var custom
193
+ local scope="$1" upper dom_var custom worker_name
183
194
  upper="$(echo "$scope" | tr '[:lower:]' '[:upper:]')"
184
195
  dom_var="CF_WORKER_CUSTOM_DOMAIN_${upper}"
185
196
  custom="${!dom_var:-}"
197
+ if [[ "$(gateway_mode)" == "shared" ]]; then
198
+ worker_name="${APP_NAME}-${STAGE}"
199
+ else
200
+ worker_name="${APP_NAME}-${STAGE}-${scope}"
201
+ fi
186
202
  if [[ -n "$custom" ]]; then
187
203
  echo "https://$custom"
188
204
  elif [[ -n "${CF_WORKERS_SUBDOMAIN:-}" ]]; then
189
- echo "https://${APP_NAME}-${STAGE}-${scope}.${CF_WORKERS_SUBDOMAIN}.workers.dev"
205
+ echo "https://${worker_name}.${CF_WORKERS_SUBDOMAIN}.workers.dev"
190
206
  else
191
207
  echo ""
192
208
  fi
@@ -227,16 +243,25 @@ print_outputs_json() {
227
243
  jwtAlg="$(lambda_env "$fn" JWT_ALGORITHM)"; jwtAlg="${jwtAlg:-${LAMBDA_JWT_ALGORITHM:-EdDSA}}"
228
244
  svcPub="$(lambda_env "$fn" INTERNAL_JWT_PUBLIC_KEY)"; svcPub="${svcPub:-${LAMBDA_INTERNAL_JWT_PUBLIC_KEY:-}}"
229
245
 
230
- # Worker names per scope (mirror deploy-gateway.sh: ${APP_NAME}-${STAGE}-${scope}).
231
- # These are what a consumer's Cloudflare Pages project references in a Service Binding
232
- # (`[[services]] service = "<worker name>"`) to front /{scope}/auth/* same-origin via
233
- # @auth-craft/pages-auth-proxy. The binding lives in the consumer's Pages project, not
234
- # here — `serviceBindings` is a ready-to-paste snippet they drop into their wrangler.toml.
235
- local wnSystem="${APP_NAME}-${STAGE}-system"
236
- local wnTenant="${APP_NAME}-${STAGE}-tenant"
237
- local wnCustomer="${APP_NAME}-${STAGE}-customer"
246
+ # Worker names per scope. In multi mode (default) each scope is its own worker
247
+ # (${APP_NAME}-${STAGE}-${scope}); in shared mode all scopes are the single worker
248
+ # ${APP_NAME}-${STAGE}. These are what a consumer's Cloudflare Pages project references
249
+ # in a Service Binding (`[[services]] service = "<worker name>"`) to front auth
250
+ # same-origin via @auth-craft/pages-auth-proxy. The binding lives in the consumer's
251
+ # Pages project, not here — `serviceBindings` is a ready-to-paste snippet.
252
+ local mode wnSystem wnTenant wnCustomer bindingService
253
+ mode="$(gateway_mode)"
254
+ if [[ "$mode" == "shared" ]]; then
255
+ wnSystem="${APP_NAME}-${STAGE}"; wnTenant="$wnSystem"; wnCustomer="$wnSystem"
256
+ bindingService="$wnSystem"
257
+ else
258
+ wnSystem="${APP_NAME}-${STAGE}-system"
259
+ wnTenant="${APP_NAME}-${STAGE}-tenant"
260
+ wnCustomer="${APP_NAME}-${STAGE}-customer"
261
+ bindingService="$wnCustomer"
262
+ fi
238
263
  local bindingSnippet
239
- bindingSnippet=$(printf '[[services]]\nbinding = "AUTH_GATEWAY"\nservice = "%s"\n' "$wnCustomer")
264
+ bindingSnippet=$(printf '[[services]]\nbinding = "AUTH_GATEWAY"\nservice = "%s"\n' "$bindingService")
240
265
 
241
266
  jq -n \
242
267
  --arg url "$url" --arg base "$base" --arg name "$name" \
@@ -45,6 +45,22 @@ disable_public_url_for() {
45
45
  esac
46
46
  }
47
47
 
48
+ # Resolve whether to skip the X-Edge-Gate (Client → Worker) check for a scope. Safe ONLY
49
+ # when the worker has no public URL and is reached solely via a Service Binding — so this
50
+ # defaults to following disable_public_url and the caller additionally guards on it.
51
+ # Precedence: CF_WORKER_SKIP_EDGE_GATE_<SCOPE> > CF_WORKER_SKIP_EDGE_GATE > '' (off).
52
+ # Truthy: 1 / true / yes. Does NOT affect the Worker → Backend secret (X-Gateway-Secret).
53
+ skip_edge_gate_for() {
54
+ local scope="$1" upper var val
55
+ upper="$(echo "$scope" | tr '[:lower:]' '[:upper:]')"
56
+ var="CF_WORKER_SKIP_EDGE_GATE_${upper}"
57
+ val="${!var:-${CF_WORKER_SKIP_EDGE_GATE:-}}"
58
+ case "$(echo "$val" | tr '[:upper:]' '[:lower:]')" in
59
+ 1 | true | yes) echo 1 ;;
60
+ *) echo '' ;;
61
+ esac
62
+ }
63
+
48
64
  # Resolve per-scope static CORS origins. Precedence:
49
65
  # CF_ALLOWED_ORIGINS_<SCOPE> > CF_ALLOWED_ORIGINS > LAMBDA_CORS_ORIGINS > '*'.
50
66
  allowed_origins_for() {
@@ -90,8 +106,161 @@ enable_workers_dev() {
90
106
  fi
91
107
  }
92
108
 
109
+ # Merge per-scope CORS values for the shared worker (one worker serves all scopes).
110
+ # Unions the comma lists across system/tenant/customer, de-duped, '*' wins (collapses
111
+ # to '*' since it already allows everything).
112
+ merge_cors_for_shared() {
113
+ local resolver="$1" scope merged piece
114
+ merged=""
115
+ for scope in system tenant customer; do
116
+ piece="$("$resolver" "$scope")"
117
+ [[ -z "$piece" ]] && continue
118
+ merged="${merged:+$merged,}$piece"
119
+ done
120
+ # Normalize: split on comma, trim, drop blanks/dups; if any '*' present → just '*'.
121
+ echo "$merged" | tr ',' '\n' | sed 's/^ *//;s/ *$//' | awk '
122
+ /^\*$/ { star = 1 }
123
+ NF && !seen[$0]++ { order[++n] = $0 }
124
+ END {
125
+ if (star) { print "*"; exit }
126
+ for (i = 1; i <= n; i++) printf "%s%s", (i > 1 ? "," : ""), order[i]
127
+ print ""
128
+ }'
129
+ }
130
+
131
+ # Deploy a single gateway worker. All inputs are passed explicitly so the same code path
132
+ # serves both the multi-worker loop and the shared-worker mode. The shared config
133
+ # (backend_url, environment, jwt/svc values) is read from deploy_gateway's locals via the
134
+ # caller's scope — so this stays a nested helper invoked only from deploy_gateway.
135
+ #
136
+ # Args: 1=worker_name 2=label 3=auth_scope('' = path-derived) 4=route_prefix
137
+ # 5=custom_domain 6=allowed_origins 7=origin_suffixes 8=cors_kv_id
138
+ # 9=disable_public_url 10=skip_edge_gate
139
+ deploy_one_worker() {
140
+ local worker_name="$1" label="$2" auth_scope="$3" route_prefix="$4" custom_domain="$5"
141
+ local allowed_origins="$6" origin_suffixes="$7" cors_kv_id="$8"
142
+ local disable_public_url="$9" skip_edge_gate="${10}"
143
+ local config_file url
144
+
145
+ info "Deploying $worker_name (${label}, route_prefix='${route_prefix}')…"
146
+
147
+ # A temp config is needed when either (a) a CORS KV id is set (a binding can't be
148
+ # set via --var) or (b) the public URL must be disabled (workers_dev/preview_urls are
149
+ # top-level config keys, not vars, and CF won't disable preview URLs just because
150
+ # workers.dev is off — both must be set explicitly). Otherwise use the shipped config.
151
+ config_file="$WRANGLER_DIR/wrangler.toml"
152
+ if [[ -n "$cors_kv_id" || -n "$disable_public_url" ]]; then
153
+ config_file="$(mktemp "${TMPDIR:-/tmp}/wrangler-${label}.XXXXXX.toml")"
154
+ if [[ -n "$disable_public_url" ]]; then
155
+ # Turn off the workers.dev URL *and* the per-version preview URLs in a single
156
+ # deploy (no REST API round-trip; CF won't drop preview URLs just for workers_dev
157
+ # being off, so both are set explicitly). These are ROOT keys: they must precede
158
+ # any [table] header or TOML scopes them under it (e.g. [build]). We drop the
159
+ # shipped `workers_dev = true` (TOML forbids duplicate keys) and inject both flags
160
+ # immediately after the first root key (`name = …`), before [build].
161
+ awk '
162
+ /^[[:space:]]*workers_dev[[:space:]]*=/ { next } # drop original (anywhere)
163
+ { print }
164
+ !injected && /^[[:space:]]*name[[:space:]]*=/ {
165
+ print "workers_dev = false"; print "preview_urls = false"; injected = 1
166
+ }
167
+ ' "$WRANGLER_DIR/wrangler.toml" >"$config_file"
168
+ info " public URL disabled: workers.dev + preview URLs off (Pages-proxy / Service Binding only)"
169
+ else
170
+ cat "$WRANGLER_DIR/wrangler.toml" >"$config_file"
171
+ fi
172
+ if [[ -n "$cors_kv_id" ]]; then
173
+ {
174
+ echo ''
175
+ echo '[[kv_namespaces]]'
176
+ echo 'binding = "CORS_KV"'
177
+ echo "id = \"$cors_kv_id\""
178
+ } >>"$config_file"
179
+ info " dynamic CORS: KV binding CORS_KV → $cors_kv_id"
180
+ fi
181
+ fi
182
+
183
+ # Per-worker vars (flags override the toml). AUTH_SCOPE is set only in multi mode; in
184
+ # shared mode it's omitted so the worker derives scope from the path's first segment.
185
+ local -a deploy_vars=(
186
+ --var "ENVIRONMENT:$environment"
187
+ --var "ROUTE_PREFIX:$route_prefix"
188
+ --var "ALLOWED_ORIGINS:$allowed_origins"
189
+ --var "ALLOWED_ORIGIN_SUFFIXES:$origin_suffixes"
190
+ --var "JWT_ISSUER:$jwt_issuer"
191
+ --var "JWT_ALLOWED_ALGORITHMS:$jwt_alg"
192
+ --var "SERVICE_ROUTE_PREFIX:$svc_route"
193
+ --var "SERVICE_JWT_AUDIENCE:$svc_aud"
194
+ --var "SERVICE_JWT_ISSUER:$svc_iss"
195
+ --var "SERVICE_JWT_ALLOWED_ALGORITHMS:$svc_alg"
196
+ )
197
+ [[ -n "$auth_scope" ]] && deploy_vars+=(--var "AUTH_SCOPE:$auth_scope")
198
+ if [[ -n "$skip_edge_gate" ]]; then
199
+ deploy_vars+=(--var "SKIP_EDGE_GATE:true")
200
+ info " edge gate SKIPPED (binding-only worker; X-Edge-Gate not required)"
201
+ fi
202
+
203
+ # Deploy the prebuilt bundle.
204
+ wrangler deploy \
205
+ --name "$worker_name" \
206
+ --config "$config_file" \
207
+ --no-bundle \
208
+ "${deploy_vars[@]}" >/dev/null
209
+
210
+ # Clean up the temp config (only created when KV was bound or public URL disabled).
211
+ [[ "$config_file" != "$WRANGLER_DIR/wrangler.toml" ]] && rm -f "$config_file"
212
+
213
+ # Secrets (piped via stdin).
214
+ put_secret "$worker_name" BACKEND_URL "$backend_url"
215
+ put_secret "$worker_name" BACKEND_GATEWAY_SECRET "${LAMBDA_GATEWAY_SECRET:-}"
216
+ # Edge-gate secret only when the gate is enforced (skipped workers don't need it).
217
+ [[ -z "$skip_edge_gate" ]] && put_secret "$worker_name" EDGE_GATE_SECRET "${CF_EDGE_GATE_SECRET:-}"
218
+ put_secret "$worker_name" JWT_PUBLIC_KEY "${LAMBDA_JWT_PUBLIC_KEY:-}"
219
+ put_secret "$worker_name" SERVICE_JWT_PUBLIC_KEY "${LAMBDA_INTERNAL_JWT_PUBLIC_KEY:-}"
220
+ put_secret "$worker_name" GATEWAY_JWT_PRIVATE_KEY "${CF_GATEWAY_JWT_PRIVATE_KEY:-}"
221
+
222
+ if [[ -n "$custom_domain" ]]; then
223
+ url="https://$custom_domain"
224
+ info " custom domain: $custom_domain (configure DNS/route separately)"
225
+ elif [[ -n "$disable_public_url" ]]; then
226
+ # workers.dev + preview URLs were turned off in the deploy config above; the worker
227
+ # is reachable only via its Service Binding (Pages proxy) — no public URL to print.
228
+ url="(no public URL — reached via Pages proxy / Service Binding)"
229
+ else
230
+ enable_workers_dev "$worker_name"
231
+ if [[ -n "${CF_WORKERS_SUBDOMAIN:-}" ]]; then
232
+ url="https://${worker_name}.${CF_WORKERS_SUBDOMAIN}.workers.dev"
233
+ else
234
+ url="(workers.dev — set CF_WORKERS_SUBDOMAIN to print the URL)"
235
+ fi
236
+ fi
237
+ success " $label gateway: $url"
238
+ DEPLOYED_WORKER_URL="$url"
239
+ }
240
+
241
+ # Resolve the disable-public-url / skip-edge-gate flags for a worker, applying the safety
242
+ # rule that skip is only honored when the public URL is disabled. Sets the globals
243
+ # RESOLVED_DISABLE_PUBLIC_URL and RESOLVED_SKIP_EDGE_GATE. `key` is the resolver scope key
244
+ # ('system'|'tenant'|'customer', or 'shared' for the merged worker); `has_custom_domain`
245
+ # is non-empty when a custom domain forces the worker to stay reachable.
246
+ resolve_url_flags() {
247
+ local key="$1" has_custom_domain="$2"
248
+ RESOLVED_DISABLE_PUBLIC_URL=""
249
+ [[ -z "$has_custom_domain" ]] && RESOLVED_DISABLE_PUBLIC_URL="$(disable_public_url_for "$key")"
250
+ RESOLVED_SKIP_EDGE_GATE=""
251
+ if [[ -n "$(skip_edge_gate_for "$key")" ]]; then
252
+ if [[ -n "$RESOLVED_DISABLE_PUBLIC_URL" ]]; then
253
+ RESOLVED_SKIP_EDGE_GATE=1
254
+ else
255
+ warn " $key: SKIP_EDGE_GATE ignored — only allowed when the public URL is disabled"
256
+ fi
257
+ fi
258
+ }
259
+
93
260
  deploy_gateway() {
94
- header "Deploy Gateway Workers (stage=$STAGE)"
261
+ local mode
262
+ mode="$(gateway_mode)"
263
+ header "Deploy Gateway Worker(s) (stage=$STAGE, mode=$mode)"
95
264
 
96
265
  require_cmd npx
97
266
  require_env CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID
@@ -100,13 +269,11 @@ deploy_gateway() {
100
269
  || die "Prebuilt gateway bundle missing: $GATEWAY_ASSET_DIR/worker.js (was the package built?)"
101
270
 
102
271
  # Anti-drift: BACKEND_URL the worker forwards onto = Function URL + API base path.
272
+ # These shared values are read by deploy_one_worker (nested helper).
103
273
  local backend_url="${LAMBDA_FUNCTION_URL%/}${API_BASE_PATH}"
104
-
105
- # Shared config across the 3 workers (CORS is now resolved PER SCOPE below).
106
274
  local environment jwt_issuer
107
275
  environment="$RUNTIME_NODE_ENV"
108
276
  jwt_issuer="${LAMBDA_JWT_ISSUER:-${AUTH_SYSTEM_NAME:-$RESOURCE_NAME}}"
109
-
110
277
  local jwt_alg="${LAMBDA_JWT_ALGORITHM:-EdDSA}"
111
278
  local svc_route="${LAMBDA_SERVICE_ROUTE_PATH:-/internal}"
112
279
  local svc_aud="${LAMBDA_INTERNAL_JWT_AUDIENCE:-auth-craft:internal}"
@@ -114,8 +281,39 @@ deploy_gateway() {
114
281
  local svc_alg="${LAMBDA_INTERNAL_JWT_ALG:-EdDSA}"
115
282
 
116
283
  GATEWAY_URLS=()
117
- local scope worker_name route_prefix custom_domain url
118
- local allowed_origins origin_suffixes cors_kv_id config_file disable_public_url
284
+
285
+ if [[ "$mode" == "shared" ]]; then
286
+ # ── Shared worker: one worker serves all scopes; scope comes from the path's first
287
+ # segment (worker AUTH_SCOPE unset). CORS unions all scopes; KV uses the customer id.
288
+ local worker_name custom_domain allowed_origins origin_suffixes cors_kv_id
289
+ worker_name="${APP_NAME}-${STAGE}"
290
+ custom_domain="$(custom_domain_for shared)"
291
+ allowed_origins="$(merge_cors_for_shared allowed_origins_for)"
292
+ origin_suffixes="$(merge_cors_for_shared origin_suffixes_for)"
293
+ cors_kv_id="$(cors_kv_id_for shared)"
294
+ [[ -z "$cors_kv_id" ]] && cors_kv_id="$(cors_kv_id_for customer)"
295
+
296
+ resolve_url_flags shared "$custom_domain"
297
+
298
+ # ROUTE_PREFIX='' and AUTH_SCOPE='' → worker derives scope from /{scope}/... segment.
299
+ # NOTE: a shared worker does NOT enforce scope by itself (any client may send
300
+ # /system/...). Pair it with a scope-locked Pages proxy (@auth-craft/pages-auth-proxy
301
+ # `scope` option) if customer apps must never reach system/tenant context.
302
+ deploy_one_worker "$worker_name" "shared" "" "" "$custom_domain" \
303
+ "$allowed_origins" "$origin_suffixes" "$cors_kv_id" \
304
+ "$RESOLVED_DISABLE_PUBLIC_URL" "$RESOLVED_SKIP_EDGE_GATE"
305
+ GATEWAY_URLS+=("shared=$DEPLOYED_WORKER_URL")
306
+
307
+ export GATEWAY_URLS
308
+ success "Shared gateway worker deployed"
309
+ info " BACKEND_URL wired to: $backend_url"
310
+ warn " Shared mode does NOT enforce scope — use a scope-locked Pages proxy if needed."
311
+ return 0
312
+ fi
313
+
314
+ # ── Multi-worker: 3 workers, one scope each (AUTH_SCOPE fixed per worker = enforced).
315
+ local scope worker_name route_prefix custom_domain
316
+ local allowed_origins origin_suffixes cors_kv_id
119
317
  for scope in system tenant customer; do
120
318
  worker_name="${APP_NAME}-${STAGE}-${scope}"
121
319
  route_prefix="$(route_prefix_for "$scope")"
@@ -123,93 +321,12 @@ deploy_gateway() {
123
321
  allowed_origins="$(allowed_origins_for "$scope")"
124
322
  origin_suffixes="$(origin_suffixes_for "$scope")"
125
323
  cors_kv_id="$(cors_kv_id_for "$scope")"
126
- # A custom domain always needs the worker reachable, so the disable flag is ignored there.
127
- disable_public_url=""
128
- [[ -z "$custom_domain" ]] && disable_public_url="$(disable_public_url_for "$scope")"
129
-
130
- info "Deploying $worker_name (scope=$scope route_prefix=$route_prefix)…"
131
-
132
- # A temp config is needed when either (a) a CORS KV id is set (a binding can't be
133
- # set via --var) or (b) the public URL must be disabled (workers_dev/preview_urls are
134
- # top-level config keys, not vars, and CF won't disable preview URLs just because
135
- # workers.dev is off — both must be set explicitly). Otherwise use the shipped config.
136
- config_file="$WRANGLER_DIR/wrangler.toml"
137
- if [[ -n "$cors_kv_id" || -n "$disable_public_url" ]]; then
138
- config_file="$(mktemp "${TMPDIR:-/tmp}/wrangler-${scope}.XXXXXX.toml")"
139
- if [[ -n "$disable_public_url" ]]; then
140
- # Turn off the workers.dev URL *and* the per-version preview URLs in a single
141
- # deploy (no REST API round-trip; CF won't drop preview URLs just for workers_dev
142
- # being off, so both are set explicitly). These are ROOT keys: they must precede
143
- # any [table] header or TOML scopes them under it (e.g. [build]). We drop the
144
- # shipped `workers_dev = true` (TOML forbids duplicate keys) and inject both flags
145
- # immediately after the first root key (`name = …`), before [build].
146
- awk '
147
- /^[[:space:]]*workers_dev[[:space:]]*=/ { next } # drop original (anywhere)
148
- { print }
149
- !injected && /^[[:space:]]*name[[:space:]]*=/ {
150
- print "workers_dev = false"; print "preview_urls = false"; injected = 1
151
- }
152
- ' "$WRANGLER_DIR/wrangler.toml" >"$config_file"
153
- info " public URL disabled: workers.dev + preview URLs off (Pages-proxy / Service Binding only)"
154
- else
155
- cat "$WRANGLER_DIR/wrangler.toml" >"$config_file"
156
- fi
157
- if [[ -n "$cors_kv_id" ]]; then
158
- {
159
- echo ''
160
- echo '[[kv_namespaces]]'
161
- echo 'binding = "CORS_KV"'
162
- echo "id = \"$cors_kv_id\""
163
- } >>"$config_file"
164
- info " dynamic CORS: KV binding CORS_KV → $cors_kv_id"
165
- fi
166
- fi
324
+ resolve_url_flags "$scope" "$custom_domain"
167
325
 
168
- # Deploy the prebuilt bundle with per-scope vars (flags override the toml).
169
- wrangler deploy \
170
- --name "$worker_name" \
171
- --config "$config_file" \
172
- --no-bundle \
173
- --var "ENVIRONMENT:$environment" \
174
- --var "AUTH_SCOPE:$scope" \
175
- --var "ROUTE_PREFIX:$route_prefix" \
176
- --var "ALLOWED_ORIGINS:$allowed_origins" \
177
- --var "ALLOWED_ORIGIN_SUFFIXES:$origin_suffixes" \
178
- --var "JWT_ISSUER:$jwt_issuer" \
179
- --var "JWT_ALLOWED_ALGORITHMS:$jwt_alg" \
180
- --var "SERVICE_ROUTE_PREFIX:$svc_route" \
181
- --var "SERVICE_JWT_AUDIENCE:$svc_aud" \
182
- --var "SERVICE_JWT_ISSUER:$svc_iss" \
183
- --var "SERVICE_JWT_ALLOWED_ALGORITHMS:$svc_alg" >/dev/null
184
-
185
- # Clean up the temp config (only created when KV was bound or public URL disabled).
186
- [[ "$config_file" != "$WRANGLER_DIR/wrangler.toml" ]] && rm -f "$config_file"
187
-
188
- # Secrets (piped via stdin).
189
- put_secret "$worker_name" BACKEND_URL "$backend_url"
190
- put_secret "$worker_name" BACKEND_GATEWAY_SECRET "${LAMBDA_GATEWAY_SECRET:-}"
191
- put_secret "$worker_name" EDGE_GATE_SECRET "${CF_EDGE_GATE_SECRET:-}"
192
- put_secret "$worker_name" JWT_PUBLIC_KEY "${LAMBDA_JWT_PUBLIC_KEY:-}"
193
- put_secret "$worker_name" SERVICE_JWT_PUBLIC_KEY "${LAMBDA_INTERNAL_JWT_PUBLIC_KEY:-}"
194
- put_secret "$worker_name" GATEWAY_JWT_PRIVATE_KEY "${CF_GATEWAY_JWT_PRIVATE_KEY:-}"
195
-
196
- if [[ -n "$custom_domain" ]]; then
197
- url="https://$custom_domain"
198
- info " custom domain: $custom_domain (configure DNS/route separately)"
199
- elif [[ -n "$disable_public_url" ]]; then
200
- # workers.dev + preview URLs were turned off in the deploy config above; the worker
201
- # is reachable only via its Service Binding (Pages proxy) — no public URL to print.
202
- url="(no public URL — reached via Pages proxy / Service Binding)"
203
- else
204
- enable_workers_dev "$worker_name"
205
- if [[ -n "${CF_WORKERS_SUBDOMAIN:-}" ]]; then
206
- url="https://${worker_name}.${CF_WORKERS_SUBDOMAIN}.workers.dev"
207
- else
208
- url="(workers.dev — set CF_WORKERS_SUBDOMAIN to print the URL)"
209
- fi
210
- fi
211
- success " $scope gateway: $url"
212
- GATEWAY_URLS+=("$scope=$url")
326
+ deploy_one_worker "$worker_name" "$scope" "$scope" "$route_prefix" "$custom_domain" \
327
+ "$allowed_origins" "$origin_suffixes" "$cors_kv_id" \
328
+ "$RESOLVED_DISABLE_PUBLIC_URL" "$RESOLVED_SKIP_EDGE_GATE"
329
+ GATEWAY_URLS+=("$scope=$DEPLOYED_WORKER_URL")
213
330
  done
214
331
  export GATEWAY_URLS
215
332
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@auth-craft/aws-cf-stack",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Self-contained, versioned distribution of the Auth Craft AWS (DynamoDB + Lambda) + Cloudflare gateway stack. Bundles prebuilt Lambda/worker artifacts + CDK app so consumers deploy without cloning auth-craft.",
5
5
  "type": "module",
6
6
  "bin": {