@@ -702,6 +702,11 @@ export class HttpDispatcher {
702702 const skipPaths = [ '/auth' , '/cloud' , '/health' , '/discovery' ] ;
703703 if ( skipPaths . some ( p => path . startsWith ( p ) ) ) return null ;
704704
705+ // Public share-link resolve/messages — the token IS the authorisation,
706+ // so never gate them on project membership (a signed-in non-member
707+ // opening a public link must not be 403'd before the token handler runs).
708+ if ( / ( ^ | \/ ) s h a r e - l i n k s \/ [ ^ / ] + \/ ( r e s o l v e | m e s s a g e s ) $ / . test ( path ) ) return null ;
709+
705710 const environmentId = context . environmentId ;
706711 if ( ! environmentId ) return null ; // Unscoped legacy routes fall through.
707712
@@ -2782,6 +2787,213 @@ export class HttpDispatcher {
27822787 } ;
27832788 }
27842789
2790+ /**
2791+ * Share-link capability tokens — "anyone with the link" publication of a
2792+ * single record (ADR-0047). Mirrors the per-env service-dispatch pattern
2793+ * used by {@link handleI18n} / {@link handleAI }: the `shareLinks` service
2794+ * is resolved from the request's environment kernel, so links live in (and
2795+ * resolve against) the same per-environment database that owns the record.
2796+ * This branch owns URL parsing and the auth/public split.
2797+ *
2798+ * POST /share-links → create a link (authenticated)
2799+ * GET /share-links?object&recordId → list the caller's links (authenticated)
2800+ * DELETE /share-links/:idOrToken → revoke (authenticated)
2801+ * GET /share-links/:token/resolve → resolve token → record (PUBLIC)
2802+ * GET /share-links/:token/messages → ai_conversations messages (PUBLIC)
2803+ *
2804+ * The resolve / messages routes are intentionally public — the token IS
2805+ * the authorisation. The underlying record is fetched with a SYSTEM
2806+ * context (per-env RLS is bypassed because the token gates access), and
2807+ * `redactFields` are stripped before the record leaves the server.
2808+ */
2809+ async handleShareLinks (
2810+ subPath : string ,
2811+ method : string ,
2812+ body : any ,
2813+ query : any ,
2814+ context : HttpProtocolContext ,
2815+ ) : Promise < HttpDispatcherResult > {
2816+ const svc : any = await this . resolveService ( 'shareLinks' , context . environmentId ) ;
2817+ if ( ! svc ) {
2818+ return { handled : true , response : this . error ( 'Sharing is not configured for this environment' , 501 ) } ;
2819+ }
2820+
2821+ const SYSTEM_CTX = { isSystem : true , roles : [ ] , permissions : [ ] } as const ;
2822+ const m = method . toUpperCase ( ) ;
2823+ const parts = subPath . replace ( / ^ \/ + / , '' ) . split ( '/' ) . filter ( Boolean ) ;
2824+ const ec : any = context . executionContext ;
2825+ const callerCtx = { userId : ec ?. userId as string | undefined , tenantId : ec ?. tenantId as string | undefined } ;
2826+
2827+ const headerOf = ( name : string ) : string | undefined => {
2828+ const h = context . request ?. headers ;
2829+ if ( ! h ) return undefined ;
2830+ const v = typeof h . get === 'function' ? h . get ( name ) : ( h [ name ] ?? h [ name . toLowerCase ( ) ] ) ;
2831+ return Array . isArray ( v ) ? v [ 0 ] : ( v ?? undefined ) ;
2832+ } ;
2833+ const sendErr = ( status : number , code : string , msg : string ) : HttpDispatcherResult => ( {
2834+ handled : true ,
2835+ response : this . error ( msg , status , { code } ) ,
2836+ } ) ;
2837+ // Engine for fetching the shared record + token probes — the same
2838+ // per-env ObjectQL the shareLinks service is bound to.
2839+ const getEngine = async ( ) : Promise < any > => {
2840+ // Read objectql from the request's RESOLVED (per-env) kernel — the
2841+ // same engine SharingServicePlugin bound the shareLinks service to,
2842+ // so the shared record + messages live in the SAME store as
2843+ // sys_share_link. `resolveService('objectql', env)` can hand back a
2844+ // different (host/scoped) engine that lacks the per-env rows.
2845+ try {
2846+ const k : any = this . kernel ;
2847+ const e = typeof k ?. getServiceAsync === 'function'
2848+ ? await k . getServiceAsync ( 'objectql' )
2849+ : k ?. getService ?.( 'objectql' ) ;
2850+ if ( e ) return e ;
2851+ } catch { /* fall through to scoped resolution */ }
2852+ return this . resolveService ( 'objectql' , context . environmentId ) ;
2853+ } ;
2854+ const asArray = ( rows : any ) : any [ ] => ( Array . isArray ( rows ) ? rows : Array . isArray ( rows ?. value ) ? rows . value : [ ] ) ;
2855+ const applyRedaction = ( record : any , redactFields : string [ ] ) : any => {
2856+ if ( ! record || typeof record !== 'object' || redactFields . length === 0 ) return record ;
2857+ const out : any = { } ;
2858+ for ( const [ k , v ] of Object . entries ( record ) ) {
2859+ if ( redactFields . includes ( k ) ) continue ;
2860+ out [ k ] = v ;
2861+ }
2862+ return out ;
2863+ } ;
2864+
2865+ try {
2866+ // ── PUBLIC: resolve a token → record ──────────────────────────
2867+ if ( parts . length === 2 && parts [ 1 ] === 'resolve' && m === 'GET' ) {
2868+ const token = decodeURIComponent ( parts [ 0 ] ) ;
2869+ const signedInUserId = ec ?. userId ;
2870+ const recipientEmail = typeof query ?. email === 'string' ? query . email : undefined ;
2871+ const providedPassword =
2872+ typeof query ?. password === 'string' ? ( query . password as string ) : headerOf ( 'x-share-password' ) ;
2873+
2874+ const resolved = await svc . resolveToken ( token , { signedInUserId, recipientEmail, providedPassword } ) ;
2875+ if ( ! resolved ) {
2876+ // Probe the row to return a more useful status (401 vs 410 vs 404).
2877+ const engine = await getEngine ( ) ;
2878+ const probe = engine
2879+ ? asArray ( await engine . find ( 'sys_share_link' , { where : { token } , limit : 1 , context : SYSTEM_CTX } as any ) )
2880+ : [ ] ;
2881+ const row = probe [ 0 ] ?? null ;
2882+ const live = row && ! row . revoked_at && ( ! row . expires_at || Date . parse ( row . expires_at ) > Date . now ( ) ) ;
2883+ if ( live && row . password_hash ) {
2884+ return sendErr ( 401 , providedPassword ? 'WRONG_PASSWORD' : 'NEEDS_PASSWORD' ,
2885+ providedPassword ? 'Incorrect password' : 'This link requires a password' ) ;
2886+ }
2887+ if ( live && row . audience === 'signed_in' && ! signedInUserId ) {
2888+ return sendErr ( 401 , 'SIGN_IN_REQUIRED' , 'Please sign in to view this link' ) ;
2889+ }
2890+ if ( row && ( row . revoked_at || ( row . expires_at && Date . parse ( row . expires_at ) <= Date . now ( ) ) ) ) {
2891+ return sendErr ( 410 , 'EXPIRED_OR_REVOKED' , 'Share link has expired or been revoked' ) ;
2892+ }
2893+ return sendErr ( 404 , 'INVALID_OR_EXPIRED' , 'Share link is invalid, expired, or revoked' ) ;
2894+ }
2895+
2896+ const engine = await getEngine ( ) ;
2897+ const rows = engine
2898+ ? asArray ( await engine . find ( resolved . link . object_name , { where : { id : resolved . link . record_id } , limit : 1 , context : SYSTEM_CTX } as any ) )
2899+ : [ ] ;
2900+ const record = rows [ 0 ] ?? null ;
2901+ if ( ! record ) return sendErr ( 410 , 'RECORD_GONE' , 'The shared record no longer exists' ) ;
2902+
2903+ return {
2904+ handled : true ,
2905+ response : this . success ( {
2906+ record : applyRedaction ( record , resolved . redactFields ) ,
2907+ link : {
2908+ id : resolved . link . id ,
2909+ token : resolved . link . token ,
2910+ object_name : resolved . link . object_name ,
2911+ record_id : resolved . link . record_id ,
2912+ permission : resolved . link . permission ,
2913+ audience : resolved . link . audience ,
2914+ expires_at : resolved . link . expires_at ,
2915+ label : resolved . link . label ,
2916+ created_at : resolved . link . created_at ,
2917+ } ,
2918+ redactFields : resolved . redactFields ,
2919+ } ) ,
2920+ } ;
2921+ }
2922+
2923+ // ── PUBLIC: ai_conversations messages for a resolved token ────
2924+ if ( parts . length === 2 && parts [ 1 ] === 'messages' && m === 'GET' ) {
2925+ const token = decodeURIComponent ( parts [ 0 ] ) ;
2926+ const providedPassword =
2927+ typeof query ?. password === 'string' ? ( query . password as string ) : headerOf ( 'x-share-password' ) ;
2928+ const resolved = await svc . resolveToken ( token , { signedInUserId : ec ?. userId , providedPassword } ) ;
2929+ if ( ! resolved ) return sendErr ( 404 , 'NOT_FOUND' , 'Share link not found' ) ;
2930+ if ( resolved . link . object_name !== 'ai_conversations' ) {
2931+ return sendErr ( 400 , 'UNSUPPORTED' , 'This share link does not expose messages' ) ;
2932+ }
2933+ const engine = await getEngine ( ) ;
2934+ const rows = engine
2935+ ? asArray ( await engine . find ( 'ai_messages' , {
2936+ where : { conversation_id : resolved . link . record_id } ,
2937+ sort : [ { field : 'created_at' , order : 'asc' } ] ,
2938+ limit : 500 ,
2939+ context : SYSTEM_CTX ,
2940+ } as any ) )
2941+ : [ ] ;
2942+ return { handled : true , response : this . success ( rows ) } ;
2943+ }
2944+
2945+ // ── AUTHENTICATED: create / list / revoke ─────────────────────
2946+ if ( ! callerCtx . userId ) return sendErr ( 401 , 'UNAUTHENTICATED' , 'Sign in to manage share links' ) ;
2947+
2948+ // POST /share-links → create
2949+ if ( parts . length === 0 && m === 'POST' ) {
2950+ const b : any = body ?? { } ;
2951+ if ( ! b . object || ! b . recordId ) return sendErr ( 400 , 'VALIDATION_FAILED' , 'object and recordId are required' ) ;
2952+ const link = await svc . createLink (
2953+ {
2954+ object : b . object ,
2955+ recordId : b . recordId ,
2956+ permission : b . permission ,
2957+ audience : b . audience ,
2958+ expiresAt : b . expiresAt ?? null ,
2959+ emailAllowlist : b . emailAllowlist ,
2960+ password : b . password ,
2961+ redactFields : b . redactFields ,
2962+ label : b . label ,
2963+ } ,
2964+ callerCtx ,
2965+ ) ;
2966+ return { handled : true , response : { status : 201 , body : { success : true , data : link , link } } } ;
2967+ }
2968+
2969+ // GET /share-links?object&recordId → list the caller's own links
2970+ if ( parts . length === 0 && m === 'GET' ) {
2971+ const links = await svc . listLinks (
2972+ {
2973+ object : typeof query ?. object === 'string' ? query . object : undefined ,
2974+ recordId : typeof query ?. recordId === 'string' ? query . recordId : undefined ,
2975+ // Constrain to links the caller created so a guessed
2976+ // recordId can never enumerate another user's tokens.
2977+ createdBy : callerCtx . userId ,
2978+ includeRevoked : query ?. includeRevoked === 'true' || query ?. includeRevoked === '1' ,
2979+ } ,
2980+ callerCtx ,
2981+ ) ;
2982+ return { handled : true , response : { status : 200 , body : { success : true , data : links , links } } } ;
2983+ }
2984+
2985+ // DELETE /share-links/:idOrToken → revoke
2986+ if ( parts . length === 1 && m === 'DELETE' ) {
2987+ await svc . revokeLink ( decodeURIComponent ( parts [ 0 ] ) , callerCtx ) ;
2988+ return { handled : true , response : this . success ( { ok : true } ) } ;
2989+ }
2990+
2991+ return { handled : true , response : this . routeNotFound ( `/share-links${ subPath } ` ) } ;
2992+ } catch ( err : any ) {
2993+ return sendErr ( err ?. status ?? 500 , err ?. code ?? 'INTERNAL' , err ?. message ?? 'Share link request failed' ) ;
2994+ }
2995+ }
2996+
27852997 /**
27862998 * Main Dispatcher Entry Point
27872999 * Routes the request to the appropriate handler based on path and precedence
@@ -2932,6 +3144,12 @@ export class HttpDispatcher {
29323144 return this . handleAI ( cleanPath , method , body , query , context ) ;
29333145 }
29343146
3147+ // Share links — capability-token record sharing, dispatched to the
3148+ // per-env `shareLinks` service (links + record live in the same kernel).
3149+ if ( cleanPath === '/share-links' || cleanPath . startsWith ( '/share-links/' ) ) {
3150+ return this . handleShareLinks ( cleanPath . substring ( '/share-links' . length ) , method , body , query , context ) ;
3151+ }
3152+
29353153 // OpenAPI Specification
29363154 if ( cleanPath === '/openapi.json' && method === 'GET' ) {
29373155 try {
0 commit comments