@@ -8,25 +8,28 @@ import { OrchestrationError } from '@/lib/core/orchestration/types'
88 *
99 * They are not an authorization boundary: every read re-resolves the account among the reader's
1010 * own accounts in the request's scope, then re-verifies the document with the provider and the
11- * admin's source policy. So the ID carries only what locates the document, and nothing the
12- * request already knows (the reader, the scope).
11+ * admin's source policy. An ID names its reader and scope only by a short {@link liveReferenceOwner}
12+ * fingerprint, so a read in another scope or by another member is refused as not found in this
13+ * scope rather than reaching account resolution.
1314 *
1415 * The current form is `live2:` followed by `~`-separated fields, trailing empty fields dropped,
15- * then a checksum: `live2:slack~<account>~<ts>~<channel>~~<thread ts>~<checksum>`. Clients copy
16- * IDs verbatim into later calls, so the form stays readable (provider IDs and UUIDs tokenize far
17- * better than base64 of them) and uses only characters a model reproduces reliably. Earlier IDs
18- * (`live:` + base64url JSON naming the reader and scope) still read, with their reader and scope
19- * checked as before .
16+ * then a checksum: `live2:slack~<owner>~< account>~<ts>~<channel>~~<thread ts>~<checksum>`.
17+ * Clients copy IDs verbatim into later calls, so the form stays readable (provider IDs and UUIDs
18+ * tokenize far better than base64 of them) and uses only characters a model reproduces reliably.
19+ * Earlier IDs (`live:` + base64url JSON naming the reader and scope) still read, checked against
20+ * the same fingerprint .
2021 */
2122const LIVE_V1_PREFIX = 'live:'
2223const LIVE_V2_PREFIX = 'live2:'
2324const MAX_REFERENCE_LENGTH = 4000
2425const FIELD_SEPARATOR = '~'
2526const CHECKSUM_LENGTH = 6
27+ const OWNER_LENGTH = 8
2628
2729const fieldsSchema = z
2830 . object ( {
2931 provider : liveSearchProviderSchema ,
32+ owner : z . string ( ) . regex ( new RegExp ( `^[0-9a-f]{${ OWNER_LENGTH } }$` ) ) ,
3033 account : z . string ( ) . min ( 1 ) . max ( 500 ) ,
3134 id : z . string ( ) . min ( 1 ) . max ( 1000 ) ,
3235 container : z . string ( ) . min ( 1 ) . max ( 500 ) . optional ( ) ,
@@ -43,6 +46,7 @@ const fieldsSchema = z
4346/** Field order of the v2 form; optional fields last so absent ones cost nothing. */
4447const FIELD_ORDER = [
4548 'provider' ,
49+ 'owner' ,
4650 'account' ,
4751 'id' ,
4852 'container' ,
@@ -73,9 +77,9 @@ const legacySchema = z
7377
7478export type LiveReference = z . output < typeof fieldsSchema >
7579
76- /** A decoded ID; `legacy` holds the reader and scope a v1 ID was minted for. */
77- export type DecodedLiveReference = LiveReference & {
78- legacy ?: { user : string ; scope : string }
80+ /** The reader and scope an ID was minted for, as the short fingerprint the ID carries . */
81+ export function liveReferenceOwner ( userId : string , scope : string ) : string {
82+ return sha256Hex ( ` ${ userId } \u0000 ${ scope } ` ) . slice ( 0 , OWNER_LENGTH )
7983}
8084
8185/**
@@ -131,7 +135,7 @@ function decodeV2(value: string): LiveReference {
131135 if ( separator < 0 || value . slice ( separator + 1 ) !== checksum ( body ) )
132136 throw new Error ( 'Checksum mismatch' )
133137 const parts = body . split ( FIELD_SEPARATOR )
134- if ( parts . length < 3 || parts . length > FIELD_ORDER . length ) throw new Error ( 'Invalid field count' )
138+ if ( parts . length < 4 || parts . length > FIELD_ORDER . length ) throw new Error ( 'Invalid field count' )
135139 const fields : Record < string , string > = { }
136140 FIELD_ORDER . forEach ( ( name , index ) => {
137141 const part = parts [ index ]
@@ -140,20 +144,20 @@ function decodeV2(value: string): LiveReference {
140144 return fieldsSchema . parse ( fields )
141145}
142146
143- function decodeV1 ( body : string ) : DecodedLiveReference {
147+ function decodeV1 ( body : string ) : LiveReference {
144148 const { v, user, scope, ...rest } = legacySchema . parse (
145149 JSON . parse ( Buffer . from ( body , 'base64url' ) . toString ( 'utf8' ) )
146150 )
147151 const fields = Object . fromEntries (
148152 Object . entries ( rest ) . filter ( ( [ , value ] ) => value !== undefined && value !== '' )
149153 )
150- return { ...fieldsSchema . parse ( fields ) , legacy : { user, scope } }
154+ return fieldsSchema . parse ( { ...fields , owner : liveReferenceOwner ( user , scope ) } )
151155}
152156
153157const SEARCH_AGAIN = 'Invalid live document reference. Search again to select a live document'
154158
155159/** Decodes a current or earlier live document ID; anything else asks the caller to search again. */
156- export function decodeLiveReference ( value : string ) : DecodedLiveReference {
160+ export function decodeLiveReference ( value : string ) : LiveReference {
157161 if ( value . length > MAX_REFERENCE_LENGTH ) throw new OrchestrationError ( 'not_found' , SEARCH_AGAIN )
158162 try {
159163 if ( value . startsWith ( LIVE_V2_PREFIX ) ) return decodeV2 ( value . slice ( LIVE_V2_PREFIX . length ) )
@@ -172,8 +176,7 @@ export function liveDocumentKey(documentId: string): string {
172176 if ( ! documentId . startsWith ( LIVE_V1_PREFIX ) && ! documentId . startsWith ( LIVE_V2_PREFIX ) )
173177 return documentId
174178 try {
175- const { legacy, ...reference } = decodeLiveReference ( documentId )
176- return encodeLiveReference ( reference )
179+ return encodeLiveReference ( decodeLiveReference ( documentId ) )
177180 } catch {
178181 return documentId
179182 }
0 commit comments