Skip to content

Commit e463fa7

Browse files
fix: label endpoints from a route table
Each router lists the shapes of its subpaths; ids and file paths are masked whatever they contain, and an unlisted shape or router is masked entirely. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_011AhtJPr3nmT1Po92ZQk4VE
1 parent 9109062 commit e463fa7

2 files changed

Lines changed: 68 additions & 56 deletions

File tree

‎packages/opencode/src/altimate/workspace/api-client.ts‎

Lines changed: 56 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
[/^\/bind$/, "/bind"],
191+
[/^\/by-remote$/, "/by-remote"],
192+
[/^\/by-path$/, "/by-path"],
193+
],
194+
"/datamates": [[/^\/?$/, "/"]],
195+
"/users": [[/^\/me$/, "/me"]],
196+
"/skills": [
197+
[/^\/[^/]+$/, "/:id"],
198+
[/^\/[^/]+\/datamates$/, "/:id/datamates"],
199+
[/^\/[^/]+\/files(\/.*)?$/, "/:id/files/:path"],
200+
],
201+
"/datamates/memory": [
202+
[/^\/?$/, "/"],
203+
[/^\/list$/, "/list"],
204+
[/^\/[^/]+$/, "/:id"],
205+
],
206+
}
207+
208+
/** "METHOD /base/path" with every dynamic segment replaced from the route table above. */
196209
export 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

212222
function 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
}

‎packages/opencode/test/altimate/workspace/link-telemetry.test.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -217,23 +217,29 @@ describe("workspace link telemetry", () => {
217217
expect(endpointOf("GET", "/datamate-project-bindings", "/by-remote")).toBe(
218218
"GET /datamate-project-bindings/by-remote",
219219
)
220+
// The datamates router only lists its root here: any other shape is masked whole.
220221
expect(endpointOf("GET", "/datamates", "/123/memory/8f2c0a1e-9b7d-4e11-a0c2-5d6e7f8a9b0c")).toBe(
221-
"GET /datamates/:id/memory/:id",
222+
"GET /datamates/:id/:id/:id",
222223
)
223224
expect(endpointOf("POST", "/datamates", "/42/skills/pub_Ab12Cd/attach")).toBe(
224-
"POST /datamates/:id/skills/:id/attach",
225+
"POST /datamates/:id/:id/:id/:id",
225226
)
226-
// Position, not spelling: on an identifier-first router the first segment is an id whatever
227-
// it looks like, including a public id that equals a route word.
227+
// A route table, not spelling: ids and file paths are masked whatever they contain, including
228+
// a public id or a file named after a route word.
228229
expect(endpointOf("GET", "/skills", "/pub-many")).toBe("GET /skills/:id")
229230
expect(endpointOf("GET", "/skills", "/status")).toBe("GET /skills/:id")
230231
expect(endpointOf("PUT", "/skills", "/status/datamates")).toBe("PUT /skills/:id/datamates")
231-
expect(endpointOf("PATCH", "/datamates/memory", "/list")).toBe("PATCH /datamates/memory/:id")
232+
expect(endpointOf("GET", "/skills", "/pub_x/files/status")).toBe("GET /skills/:id/files/:path")
233+
expect(endpointOf("GET", "/skills", "/pub_x/files/docs/readme.md")).toBe("GET /skills/:id/files/:path")
234+
expect(endpointOf("GET", "/skills", "/pub_x/unexpected/route")).toBe("GET /skills/:id/:id/:id")
235+
expect(endpointOf("PATCH", "/datamates/memory", "/list")).toBe("PATCH /datamates/memory/list")
236+
expect(endpointOf("PATCH", "/datamates/memory", "/mem_status")).toBe("PATCH /datamates/memory/:id")
232237
expect(endpointOf("POST", "/datamate-project-bindings", "/bind")).toBe("POST /datamate-project-bindings/bind")
238+
expect(endpointOf("DELETE", "/datamate-project-bindings", "/")).toBe("DELETE /datamate-project-bindings/")
233239
expect(endpointOf("GET", "/users", "/me")).toBe("GET /users/me")
234240
expect(endpointOf("GET", "/unknown-router", "/anything/else")).toBe("GET /:id/:id/:id")
235241
expect(endpointOf("GET", "/datamates", "/42/skills/pub_Ab12Cd/files/readme.md")).toBe(
236-
"GET /datamates/:id/skills/:id/files/:id",
242+
"GET /datamates/:id/:id/:id/:id/:id",
237243
)
238244

239245
replies = [{ status: 200 }] // 2xx with an empty body where JSON was required

0 commit comments

Comments
 (0)