11import { describe , it , expect } from "bun:test" ;
22import { Hono } from "hono" ;
33import { cors } from "hono/cors" ;
4- import { authCorsMiddleware } from "../../workers/api/src/auth/index.js" ;
4+ import {
5+ authCorsMiddleware ,
6+ CREDENTIALED_CORS_MOUNT_PATHS ,
7+ isCredentialedCorsPath ,
8+ } from "../../workers/api/src/auth/index.js" ;
59
610// Mirrors the index.ts CORS wiring: authCorsMiddleware owns credentialed CORS on
7- // /api/auth/*, /v1/api-keys/*, /v1/me/*, and /v1/workspaces/*; the wildcard public
8- // cors() runs on every OTHER path. Without the carve-out the wildcard cors overwrites
9- // the credentialed Access-Control-Allow-Origin on the actual response — which a
10- // browser rejects for `credentials: "include"` requests (shows as "Failed to fetch").
11+ // every CREDENTIALED_CORS_MOUNT_PATHS entry; the wildcard public cors() runs on
12+ // every OTHER path (guarded by isCredentialedCorsPath). Without the carve-out the
13+ // wildcard cors overwrites the credentialed Access-Control-Allow-Origin on the
14+ // actual response — which a browser rejects for `credentials: "include"` requests
15+ // (shows as "Failed to fetch" / CORS blocked).
1116function makeApp ( ) {
1217 const app = new Hono ( ) ;
13- app . use ( "/api/auth/*" , authCorsMiddleware ( ) ) ;
14- app . use ( "/v1/api-keys" , authCorsMiddleware ( ) ) ;
15- app . use ( "/v1/api-keys/*" , authCorsMiddleware ( ) ) ;
16- app . use ( "/v1/me/*" , authCorsMiddleware ( ) ) ;
17- app . use ( "/v1/workspaces" , authCorsMiddleware ( ) ) ;
18- app . use ( "/v1/workspaces/*" , authCorsMiddleware ( ) ) ;
18+ const credentialedCors = authCorsMiddleware ( ) ;
19+ for ( const path of CREDENTIALED_CORS_MOUNT_PATHS ) {
20+ app . use ( path , credentialedCors ) ;
21+ }
1922 const publicReadCors = cors ( ) ;
2023 app . use ( "*" , ( c , next ) =>
21- c . req . path . startsWith ( "/api/auth/" ) ||
22- c . req . path === "/v1/api-keys" ||
23- c . req . path . startsWith ( "/v1/api-keys/" ) ||
24- c . req . path . startsWith ( "/v1/me/" ) ||
25- c . req . path === "/v1/workspaces" ||
26- c . req . path . startsWith ( "/v1/workspaces/" )
27- ? next ( )
28- : publicReadCors ( c , next ) ,
24+ isCredentialedCorsPath ( c . req . path ) ? next ( ) : publicReadCors ( c , next ) ,
2925 ) ;
3026 app . get ( "/v1/api-keys" , ( c ) => c . json ( { apiKeys : [ ] } ) ) ;
3127 app . post ( "/v1/me/avatar" , ( c ) => c . json ( { avatarUrl : "https://media.test/u.png" } ) ) ;
3228 app . post ( "/v1/workspaces/:workspaceId/avatar" , ( c ) =>
3329 c . json ( { avatarUrl : "https://media.test/w.png" } ) ,
3430 ) ;
31+ app . post ( "/v1/listing/claim" , ( c ) => c . json ( { id : "clm_test" } ) ) ;
32+ app . post ( "/v1/listing/claim/verify" , ( c ) => c . json ( { verified : true } ) ) ;
33+ app . get ( "/v1/listing/claims" , ( c ) => c . json ( { claims : [ ] } ) ) ;
34+ app . post ( "/v1/listing/promote" , ( c ) => c . json ( { promoted : true } ) ) ;
35+ // Anonymous public-write listing routes stay on wildcard CORS.
36+ app . post ( "/v1/listing/validate" , ( c ) => c . json ( { ok : true } ) ) ;
3537 app . get ( "/v1/orgs" , ( c ) => c . json ( { ok : true } ) ) ;
3638 return app ;
3739}
@@ -104,6 +106,60 @@ describe("session-authed credentialed CORS", () => {
104106 expect ( res . headers . get ( "access-control-allow-origin" ) ) . toBe ( "*" ) ;
105107 } ) ;
106108
109+ it ( "keeps wildcard CORS on anonymous listing validate (not session-authed)" , async ( ) => {
110+ const res = await makeApp ( ) . request (
111+ "/v1/listing/validate" ,
112+ {
113+ method : "POST" ,
114+ headers : { Origin : "https://anything.example" } ,
115+ } ,
116+ { ENVIRONMENT : "production" } as never ,
117+ ) ;
118+ expect ( res . headers . get ( "access-control-allow-origin" ) ) . toBe ( "*" ) ;
119+ expect ( res . headers . get ( "access-control-allow-credentials" ) ) . toBeNull ( ) ;
120+ } ) ;
121+
122+ it ( "reflects the origin with credentials on listing claim/claims/promote" , async ( ) => {
123+ const app = makeApp ( ) ;
124+ for ( const path of [
125+ "/v1/listing/claim" ,
126+ "/v1/listing/claim/verify" ,
127+ "/v1/listing/claims" ,
128+ "/v1/listing/promote" ,
129+ ] ) {
130+ const res = await app . request (
131+ path ,
132+ {
133+ method : path === "/v1/listing/claims" ? "GET" : "POST" ,
134+ headers : { Origin : "https://releases.sh" } ,
135+ } ,
136+ { ENVIRONMENT : "production" } as never ,
137+ ) ;
138+ expect ( res . headers . get ( "access-control-allow-origin" ) ) . toBe ( "https://releases.sh" ) ;
139+ expect ( res . headers . get ( "access-control-allow-credentials" ) ) . toBe ( "true" ) ;
140+ }
141+ } ) ;
142+
143+ it ( "allows listing claim preflight with content-type (start claim body)" , async ( ) => {
144+ const res = await makeApp ( ) . request (
145+ "/v1/listing/claim" ,
146+ {
147+ method : "OPTIONS" ,
148+ headers : {
149+ Origin : "https://releases.sh" ,
150+ "Access-Control-Request-Method" : "POST" ,
151+ "Access-Control-Request-Headers" : "content-type" ,
152+ } ,
153+ } ,
154+ { ENVIRONMENT : "production" } as never ,
155+ ) ;
156+ expect ( res . headers . get ( "access-control-allow-origin" ) ) . toBe ( "https://releases.sh" ) ;
157+ expect ( res . headers . get ( "access-control-allow-credentials" ) ) . toBe ( "true" ) ;
158+ expect ( res . headers . get ( "access-control-allow-headers" ) ?. toLowerCase ( ) ) . toContain (
159+ "content-type" ,
160+ ) ;
161+ } ) ;
162+
107163 it ( "wildcard CORS on a mistaken double-/v1 path breaks credentialed uploads" , async ( ) => {
108164 const res = await makeApp ( ) . request (
109165 "/v1/v1/workspaces/org_abc/avatar" ,
@@ -117,3 +173,82 @@ describe("session-authed credentialed CORS", () => {
117173 expect ( res . headers . get ( "access-control-allow-credentials" ) ) . toBeNull ( ) ;
118174 } ) ;
119175} ) ;
176+
177+ describe ( "isCredentialedCorsPath" , ( ) => {
178+ it ( "matches every session-authed browser surface" , ( ) => {
179+ expect ( isCredentialedCorsPath ( "/api/auth/get-session" ) ) . toBe ( true ) ;
180+ expect ( isCredentialedCorsPath ( "/v1/api-keys" ) ) . toBe ( true ) ;
181+ expect ( isCredentialedCorsPath ( "/v1/api-keys/ak_1" ) ) . toBe ( true ) ;
182+ expect ( isCredentialedCorsPath ( "/v1/me/follows" ) ) . toBe ( true ) ;
183+ expect ( isCredentialedCorsPath ( "/v1/workspaces" ) ) . toBe ( true ) ;
184+ expect ( isCredentialedCorsPath ( "/v1/workspaces/ws_1/avatar" ) ) . toBe ( true ) ;
185+ expect ( isCredentialedCorsPath ( "/v1/listing/claim" ) ) . toBe ( true ) ;
186+ expect ( isCredentialedCorsPath ( "/v1/listing/claim/verify" ) ) . toBe ( true ) ;
187+ expect ( isCredentialedCorsPath ( "/v1/listing/claims" ) ) . toBe ( true ) ;
188+ expect ( isCredentialedCorsPath ( "/v1/listing/promote" ) ) . toBe ( true ) ;
189+ } ) ;
190+
191+ it ( "leaves anonymous public routes on wildcard" , ( ) => {
192+ expect ( isCredentialedCorsPath ( "/v1/orgs" ) ) . toBe ( false ) ;
193+ expect ( isCredentialedCorsPath ( "/v1/listing/validate" ) ) . toBe ( false ) ;
194+ expect ( isCredentialedCorsPath ( "/v1/listing/activate" ) ) . toBe ( false ) ;
195+ } ) ;
196+ } ) ;
197+
198+ /**
199+ * Drift gate: browser clients with `credentials: "include"` (or `meGet`) must
200+ * hit paths covered by CREDENTIALED_CORS_MOUNT_PATHS. Listing claim shipped
201+ * without the carve-out and failed in prod as CORS-blocked "Failed to fetch".
202+ *
203+ * Scans `web/src` for `apiBase()` / `meGet(...)` path templates. Same-origin
204+ * proxies and anonymous listing validate/activate are excluded.
205+ */
206+ describe ( "browser credentialed clients stay on credentialed CORS" , ( ) => {
207+ it ( "every web credentials:include client path is covered by isCredentialedCorsPath" , async ( ) => {
208+ const { readdir, readFile } = await import ( "node:fs/promises" ) ;
209+ const { join } = await import ( "node:path" ) ;
210+
211+ async function * walk ( dir : string ) : AsyncGenerator < string > {
212+ for ( const ent of await readdir ( dir , { withFileTypes : true } ) ) {
213+ const p = join ( dir , ent . name ) ;
214+ if ( ent . isDirectory ( ) ) {
215+ if ( ent . name === "node_modules" || ent . name === "__generated__" ) continue ;
216+ yield * walk ( p ) ;
217+ } else if ( / \. ( t s | t s x ) $ / . test ( ent . name ) && ! / \. t e s t \. ( t s | t s x ) $ / . test ( ent . name ) ) {
218+ yield p ;
219+ }
220+ }
221+ }
222+
223+ // `${apiBase()}/v1/me/follows` | meGet("/v1/me/settings/…") | … }/v1/listing/claim
224+ const PATH_RE = / (?: a p i B a s e \( \) \s * \} ? | m e G e t \( \s * [ " ' ` ] ) ( \/ (?: v 1 | a p i ) \/ [ A - Z a - z 0 - 9 _ . / $ { } ` - ] * ) / g;
225+ const ANONYMOUS_API_PATHS = new Set ( [ "/v1/listing/validate" , "/v1/listing/activate" ] ) ;
226+
227+ const uncovered : string [ ] = [ ] ;
228+ const seen = new Set < string > ( ) ;
229+
230+ for await ( const file of walk ( join ( import . meta. dir , "../../web/src" ) ) ) {
231+ const src = await readFile ( file , "utf8" ) ;
232+ // Only session-cookie clients — skip public apiBase() callers (listing validate).
233+ if ( ! / c r e d e n t i a l s : \s * [ " ' ] i n c l u d e [ " ' ] / . test ( src ) && ! / \b m e G e t \s * [ < ( ] / . test ( src ) ) continue ;
234+
235+ for ( const m of src . matchAll ( PATH_RE ) ) {
236+ const path = m [ 1 ] !
237+ . replace ( / \$ \{ [ ^ } ] + \} / g, "_" )
238+ . replace ( / [ ` ' " ] / g, "" )
239+ . split ( "?" ) [ 0 ] !
240+ . replace ( / \/ $ / , "" ) ;
241+ if ( ! path || ANONYMOUS_API_PATHS . has ( path ) ) continue ;
242+ if ( seen . has ( path ) ) continue ;
243+ seen . add ( path ) ;
244+ if ( ! isCredentialedCorsPath ( path ) ) {
245+ uncovered . push ( `${ path } (from ${ file . replace ( / .* \/ w e b \/ / , "web/" ) } )` ) ;
246+ }
247+ }
248+ }
249+
250+ // If this is 0 the regex bit-rotted and the gate is useless.
251+ expect ( seen . size ) . toBeGreaterThan ( 10 ) ;
252+ expect ( uncovered ) . toEqual ( [ ] ) ;
253+ } ) ;
254+ } ) ;
0 commit comments