@@ -178,35 +178,45 @@ async function creds(): Promise<{ url: string; instance: string; apiKey: string
178178// `workspace_api_error` (or `workspace_auth_expired` on 401/403) with the endpoint masked of
179179// ids; the link lifecycle functions below report linked / created / switched / unlinked on the
180180// success response and `workspace_link_failed` with a reason class otherwise.
181- /** Endpoint labels mask by ROUTE POSITION, not by spelling. The routers whose subpaths start
182- * with a static word are listed here; on every other base (``/skills``, ``/datamates/memory``)
183- * the first subpath segment is an identifier and is masked whatever it is spelled like — a
184- * public id equal to a route word must not reach telemetry. Segments after that, and the
185- * segments of the base itself, are kept only when they are known static words; an unknown
186- * static word is masked too, which only costs detail. A new router with a static first segment
187- * is added here; a new identifier-first router needs nothing. */
188- const STATIC_FIRST_SEGMENT_BASES = new Set ( [ "/datamate-project-bindings" , "/datamates" , "/users" ] )
189- const ROUTE_WORDS = new Set ( [
190- "datamate-project-bindings" , "bind" , "by-remote" , "by-path" , "datamates" , "users" , "me" ,
191- "memory" , "blocks" , "skills" , "files" , "list" , "attach" , "publish" , "sync" , "status" , "search" ,
192- "versions" , "lessons" , "learned-lessons" , "content" , "download" ,
193- ] )
194-
195- /** "METHOD /base/path" with every dynamic segment replaced by ":id" (see the note above). */
181+ /** Endpoint labels come from a ROUTE TABLE, never from how a segment is spelled: an identifier,
182+ * a memory id or a file path equal to a route word must not reach telemetry. Each router the
183+ * workspace clients call lists the shapes of its subpaths as regular expressions with the label
184+ * they map to; a subpath that matches no shape, or a router not listed, is labelled with every
185+ * segment masked. Identifiers and file paths therefore cannot leak whatever they contain; an
186+ * unlisted new route only costs detail until it is added here. */
187+ const ROUTES : Record < string , Array < [ RegExp , string ] > > = {
188+ "/datamate-project-bindings" : [
189+ [ / ^ \/ ? $ / , "/" ] ,
190+ [ / ^ \/ b i n d $ / , "/bind" ] ,
191+ [ / ^ \/ b y - r e m o t e $ / , "/by-remote" ] ,
192+ [ / ^ \/ b y - p a t h $ / , "/by-path" ] ,
193+ ] ,
194+ "/datamates" : [ [ / ^ \/ ? $ / , "/" ] ] ,
195+ "/users" : [ [ / ^ \/ m e $ / , "/me" ] ] ,
196+ "/skills" : [
197+ [ / ^ \/ [ ^ / ] + $ / , "/:id" ] ,
198+ [ / ^ \/ [ ^ / ] + \/ d a t a m a t e s $ / , "/:id/datamates" ] ,
199+ [ / ^ \/ [ ^ / ] + \/ f i l e s ( \/ .* ) ? $ / , "/:id/files/:path" ] ,
200+ ] ,
201+ "/datamates/memory" : [
202+ [ / ^ \/ ? $ / , "/" ] ,
203+ [ / ^ \/ l i s t $ / , "/list" ] ,
204+ [ / ^ \/ [ ^ / ] + $ / , "/:id" ] ,
205+ ] ,
206+ }
207+
208+ /** "METHOD /base/path" with every dynamic segment replaced from the route table above. */
196209export function endpointOf ( method : string , basePath : string , subpath : string ) : string {
197- const word = ( seg : string ) => ( seg === "" || ROUTE_WORDS . has ( seg ) ? seg : ":id" )
198- const base = basePath . split ( "/" ) . map ( word ) . join ( "/" )
199- let first = true
200- const sub = subpath
201- . split ( "/" )
202- . map ( ( seg ) => {
203- if ( seg === "" ) return seg
204- const idPosition = first && ! STATIC_FIRST_SEGMENT_BASES . has ( basePath )
205- first = false
206- return idPosition ? ":id" : word ( seg )
207- } )
208- . join ( "/" )
209- return `${ method } ${ base } ${ sub } `
210+ const shapes = ROUTES [ basePath ]
211+ const maskAll = ( p : string ) =>
212+ p
213+ . split ( "/" )
214+ . map ( ( seg ) => ( seg === "" ? seg : ":id" ) )
215+ . join ( "/" )
216+ if ( ! shapes ) return `${ method } ${ maskAll ( basePath ) } ${ maskAll ( subpath ) } `
217+ const query = subpath . split ( "?" ) [ 0 ]
218+ for ( const [ shape , label ] of shapes ) if ( shape . test ( query ) ) return `${ method } ${ basePath } ${ label } `
219+ return `${ method } ${ basePath } ${ maskAll ( query ) } `
210220}
211221
212222function workspaceIdIn ( body : unknown ) : number | undefined {
@@ -253,7 +263,7 @@ export function linkFailureReason(err: unknown): "auth" | "not_found" | "network
253263 if ( err instanceof ConflictError || err instanceof PreconditionFailedError ) return "conflict"
254264 if ( err instanceof WorkspaceApiError ) {
255265 if ( err . status === 401 ) return "auth"
256- if ( err . status === undefined ) return "network" // unreachable or timed out: req() sets no status
266+ if ( err . status === undefined ) return "network" // unreachable or timed out: req() sets no status
257267 }
258268 return "other"
259269}
@@ -405,9 +415,7 @@ async function req<T>(
405415 kind : answered === undefined ? "timeout" : answered >= 500 ? "5xx" : answered >= 400 ? "4xx" : "timeout" ,
406416 workspaceId : bodyWorkspaceId ,
407417 } )
408- throw new WorkspaceApiError (
409- `Request to ${ target } timed out after ${ Math . round ( timeoutMs / 1000 ) } s` ,
410- )
418+ throw new WorkspaceApiError ( `Request to ${ target } timed out after ${ Math . round ( timeoutMs / 1000 ) } s` )
411419 }
412420 // The server answered and the body failed afterwards (readBounded's cap, a cut stream):
413421 // classify by the status it answered with, so an oversized 500 is still a 5xx row; only a
@@ -477,10 +485,7 @@ async function req<T>(
477485 // and .foo throws in a way the typed switches can't classify. (m7 + CR)
478486 if ( json == null && ! opts . allowEmptyBody ) {
479487 reportApiError ( { endpoint, status : res . status , kind : "response" , workspaceId : bodyWorkspaceId } )
480- throw new WorkspaceApiError (
481- `Empty ${ res . status } body from ${ target } — expected JSON payload` ,
482- res . status ,
483- )
488+ throw new WorkspaceApiError ( `Empty ${ res . status } body from ${ target } — expected JSON payload` , res . status )
484489 }
485490 return json as T
486491}
@@ -507,9 +512,7 @@ async function readBounded(res: Response, target: string): Promise<string> {
507512 if ( ! res . body ) {
508513 const whole = await res . text ( )
509514 if ( Buffer . byteLength ( whole , "utf8" ) > MAX_RESPONSE_BYTES ) {
510- throw new WorkspaceApiError (
511- `Response from ${ target } exceeded the ${ MAX_RESPONSE_BYTES } byte limit` ,
512- )
515+ throw new WorkspaceApiError ( `Response from ${ target } exceeded the ${ MAX_RESPONSE_BYTES } byte limit` )
513516 }
514517 return whole
515518 }
@@ -523,9 +526,7 @@ async function readBounded(res: Response, target: string): Promise<string> {
523526 if ( ! value ) continue
524527 total += value . byteLength
525528 if ( total > MAX_RESPONSE_BYTES ) {
526- throw new WorkspaceApiError (
527- `Response from ${ target } exceeded the ${ MAX_RESPONSE_BYTES } byte limit` ,
528- )
529+ throw new WorkspaceApiError ( `Response from ${ target } exceeded the ${ MAX_RESPONSE_BYTES } byte limit` )
529530 }
530531 chunks . push ( value )
531532 }
@@ -541,7 +542,10 @@ export namespace WorkspaceApi {
541542 /** Server-authoritative pre-check by git remote. Returns null on 404. */
542543 export async function getBindingForRemote ( remote : string , actAs ?: ActAs ) : Promise < GetBindingResponse | null > {
543544 try {
544- return await req < GetBindingResponse > ( "GET" , "/by-remote" , { query : { repo_remote : remote } , ...( actAs ? { actAs } : { } ) } )
545+ return await req < GetBindingResponse > ( "GET" , "/by-remote" , {
546+ query : { repo_remote : remote } ,
547+ ...( actAs ? { actAs } : { } ) ,
548+ } )
545549 } catch ( err ) {
546550 if ( err instanceof NotFoundError ) return null
547551 throw err
@@ -552,7 +556,10 @@ export namespace WorkspaceApi {
552556 * without a git remote). Returns null on 404. */
553557 export async function getBindingForPath ( projectPath : string , actAs ?: ActAs ) : Promise < GetBindingResponse | null > {
554558 try {
555- return await req < GetBindingResponse > ( "GET" , "/by-path" , { query : { project_path : projectPath } , ...( actAs ? { actAs } : { } ) } )
559+ return await req < GetBindingResponse > ( "GET" , "/by-path" , {
560+ query : { project_path : projectPath } ,
561+ ...( actAs ? { actAs } : { } ) ,
562+ } )
556563 } catch ( err ) {
557564 if ( err instanceof NotFoundError ) return null
558565 throw err
@@ -564,7 +571,10 @@ export namespace WorkspaceApi {
564571 * picks the right endpoint even if the current identifier's remote has
565572 * changed since the binding was created (M3). Both fields on the
566573 * identifier are optional but at least one must be present. */
567- export async function getBindingForProject ( id : ProjectIdentifier , actAs ?: ActAs ) : Promise < ProjectBindingLookup | null > {
574+ export async function getBindingForProject (
575+ id : ProjectIdentifier ,
576+ actAs ?: ActAs ,
577+ ) : Promise < ProjectBindingLookup | null > {
568578 if ( id . repoRemote ) {
569579 const hit = await getBindingForRemote ( id . repoRemote , actAs )
570580 if ( hit ) return { ...hit , matchedBy : "remote" }
@@ -824,11 +834,7 @@ export namespace WorkspaceApi {
824834 // ``datamates`` or ``data`` value (object / string / null) would
825835 // otherwise slip through and throw on ``.map`` below, taking the
826836 // picker down before it renders. (cubic round 4.)
827- rows = Array . isArray ( body . datamates )
828- ? body . datamates
829- : Array . isArray ( body . data )
830- ? body . data
831- : [ ]
837+ rows = Array . isArray ( body . datamates ) ? body . datamates : Array . isArray ( body . data ) ? body . data : [ ]
832838 } else {
833839 rows = [ ]
834840 }
0 commit comments