diff --git a/docs/reference/domain-scoped-listing.md b/docs/reference/domain-scoped-listing.md index 20198efa..e0a4aa71 100644 --- a/docs/reference/domain-scoped-listing.md +++ b/docs/reference/domain-scoped-listing.md @@ -60,10 +60,29 @@ record can prevent a realization chain from resolving. Empty results or an exhausted page token therefore do not establish whole-store completeness. MCP `list_entities_by_domain` requires an explicit positive Domain version and -defaults `latest_versions_only` to true. Studio's current `/api/by-domain` -route selects Domain version `1`; this backend query contract does not add a -version selector to that route. `ValidateProject` has a separate scope path -and is unchanged by this correction. +defaults `latest_versions_only` to true. `ValidateProject` has a separate scope +path and is unchanged by this query contract. + +## Studio HTTP bridge + +Studio's `GET /api/by-domain/:namespace/:slug` forwards the selected Domain to +`ListEntitiesByDomain`. Its query parameters are: + +| Parameter | Accepted value | When omitted | +| --- | --- | --- | +| `version` | Positive decimal `uint64`, forwarded as a string without floating-point conversion. | Domain version `1`. | +| `latestVersionsOnly` | `true` or `false`. | `true`. | +| `kinds` | Comma-separated entity-kind tokens. | All kinds. | +| `pageSize` | Positive decimal `int32`. The backend applies its page-size limit. | Backend default. | +| `pageToken` | Opaque continuation token returned in `nextPageToken`. | First page. | + +Version, latest-only and pagination controls accept a single scalar value. +Malformed or repeated values return HTTP `400` before the listing RPC. Branch +and authentication context use the bridge's existing request handling. +Responses preserve the backend's `entities` and `nextPageToken`; the bridge +does not collect subsequent pages or resolve membership itself. + +These HTTP controls do not add a Domain version picker to Studio's overview. ## Executable coverage @@ -75,3 +94,7 @@ and owner visibility. They use supported writes and authenticated gRPC against a real NATS Store. Their presence alone is not a recorded passing run or proof of every strategic-model acceptance case. + +The [HTTP bridge tests](../../tsworkspace/webapps/studio/server/index.test.mjs) +exercise request forwarding and validation with a mocked upstream client. +They do not establish native persistence or browser acceptance. diff --git a/tsworkspace/webapps/studio/server/README.md b/tsworkspace/webapps/studio/server/README.md index 2b1ef3dc..d341784c 100644 --- a/tsworkspace/webapps/studio/server/README.md +++ b/tsworkspace/webapps/studio/server/README.md @@ -32,7 +32,7 @@ Studio is an observation surface. Agents make every change, including namespace | GET | `/api/validate-project` | Yes (when token configured) | Validate all EventModels in a project or domain | | GET | `/api/entity-kinds` | Yes (when token configured) | Static catalog of all EntityKind values | | GET | `/api/validation-rules` | Yes (when token configured) | Static catalog of all validation rules | -| GET | `/api/by-domain/:namespace/:slug` | Yes (when token configured) | Entities scoped to a Domain | +| GET | `/api/by-domain/:namespace/:slug` | Yes (when token configured) | Entities scoped to an exact Domain version, with [filter and pagination controls](../../../../docs/reference/domain-scoped-listing.md#studio-http-bridge) | | POST | `/api/type-libraries/compile` | Yes (when token configured) | Compiles a type library's source text without saving it; returns diagnostics and compatibility violations | | GET | `/api/type-libraries/messages` | Yes (when token configured) | Message full names the live type libraries in `?namespace=` declare | | GET | `/api/nats-auth` | Yes (when token configured) | Returns `{ token: string \| null, transport: "nats" \| "sse" }` naming the realtime channel the caller may use | diff --git a/tsworkspace/webapps/studio/server/index.mjs b/tsworkspace/webapps/studio/server/index.mjs index 07564c11..cc750bcb 100644 --- a/tsworkspace/webapps/studio/server/index.mjs +++ b/tsworkspace/webapps/studio/server/index.mjs @@ -1189,6 +1189,33 @@ app.get( "/api/by-domain/:namespace/:slug", branchHandler(async (req) => { const { ns: nsParam, slug: slugParam } = namespaceSlugParams(req); + /** @param {string} name */ + function scalarQuery(name) { + const value = req.query[name]; + if (Object.keys(req.query).some((key) => key.startsWith(`${name}[`))) { + throw badRequest(`${name} must be a scalar query value`); + } + if (typeof value === "string") return value; + if (value === undefined) return undefined; + throw badRequest(`${name} must be a scalar query value`); + } + const version = scalarQuery("version") ?? "1"; + if (!/^[1-9][0-9]*$/.test(version) || version.length > 20 || BigInt(version) > 18446744073709551615n) { + throw badRequest("version must be a positive uint64 decimal string"); + } + const latest = scalarQuery("latestVersionsOnly"); + if (latest !== undefined && latest !== "true" && latest !== "false") { + throw badRequest("latestVersionsOnly must be true or false"); + } + const rawPageSize = scalarQuery("pageSize"); + let pageSize; + if (rawPageSize !== undefined) { + if (!/^[1-9][0-9]*$/.test(rawPageSize) || rawPageSize.length > 10 || Number(rawPageSize) > 2147483647) { + throw badRequest("pageSize must be a positive int32"); + } + pageSize = Number(rawPageSize); + } + const pageToken = scalarQuery("pageToken"); const kinds = []; if (req.query.kinds) { for (const raw of String(req.query.kinds).split(",").filter(Boolean)) { @@ -1207,10 +1234,12 @@ app.get( domainId: { namespace: nsParam, slug: slugParam, - version: "1", + version, }, kinds, - latestVersionsOnly: true, + latestVersionsOnly: latest !== "false", + ...(pageSize !== undefined ? { pageSize } : {}), + ...(pageToken !== undefined ? { pageToken } : {}), }); }), ); diff --git a/tsworkspace/webapps/studio/server/index.test.mjs b/tsworkspace/webapps/studio/server/index.test.mjs index 7bd5d0f7..0dd1439a 100644 --- a/tsworkspace/webapps/studio/server/index.test.mjs +++ b/tsworkspace/webapps/studio/server/index.test.mjs @@ -921,6 +921,80 @@ describe('GET /api/by-domain kinds query parity', () => { }); }); +describe('GET /api/by-domain query controls', () => { + let invoke; + + beforeEach(() => { + invoke = vi.spyOn(fakeClient, 'listEntitiesByDomain').mockImplementation((_req, _meta, _opts, cb) => { + cb(null, { entities: [], nextPageToken: 'next+/= token' }); + }); + }); + + afterEach(() => { + invoke.mockRestore(); + }); + + it('preserves omitted defaults without adding page controls', async () => { + const res = await request(app).get('/api/by-domain/my-ns/checkout'); + expect(res.status).toBe(200); + expect(invoke).toHaveBeenCalledTimes(1); + expect(invoke.mock.calls[0][0]).toEqual({ + domainId: { namespace: 'my-ns', slug: 'checkout', version: '1' }, + kinds: [], + latestVersionsOnly: true, + }); + expect(res.body).toEqual({ entities: [], nextPageToken: 'next+/= token' }); + }); + + it.each(['9007199254740993', '18446744073709551615'])( + 'forwards exact version %s, false, page controls, kinds and branch metadata', async (version) => { + const res = await request(app).get('/api/by-domain/my-ns/checkout').query({ + version, latestVersionsOnly: 'false', pageSize: '2147483647', + pageToken: 'opaque+/= token&value', kinds: 'read-model,commandslice,ENTITY_KIND_EVENT', + branch: 'review/domain', + }); + expect(res.status).toBe(200); + expect(invoke).toHaveBeenCalledTimes(1); + expect(invoke.mock.calls[0][0]).toEqual({ + domainId: { namespace: 'my-ns', slug: 'checkout', version }, + kinds: [3, 8, 1], latestVersionsOnly: false, + pageSize: 2147483647, pageToken: 'opaque+/= token&value', + }); + expect(invoke.mock.calls[0][1].get('x-trogon-atlas-branch')).toEqual(['review/domain']); + expect(res.body).toEqual({ entities: [], nextPageToken: 'next+/= token' }); + }, + ); + + it('accepts explicit true, minimum numbers, an empty cursor and the branch header', async () => { + const res = await request(app).get('/api/by-domain/my-ns/checkout') + .set('x-trogon-atlas-branch', 'review/domain') + .query({ version: '1', latestVersionsOnly: 'true', pageSize: '1', pageToken: '' }); + expect(res.status).toBe(200); + expect(invoke.mock.calls[0][0]).toEqual({ + domainId: { namespace: 'my-ns', slug: 'checkout', version: '1' }, + kinds: [], latestVersionsOnly: true, pageSize: 1, pageToken: '', + }); + expect(invoke.mock.calls[0][1].get('x-trogon-atlas-branch')).toEqual(['review/domain']); + }); + + it.each([ + ...['', '0', '-1', '1.5', '1e2', '+1', ' 1', '01', 'Infinity', '18446744073709551616', '999999999999999999999'] + .map((value) => `version=${encodeURIComponent(value)}`), + ...['', '0', '-1', '1.5', '1e2', '+1', ' 1', '01', 'Infinity', '2147483648'] + .map((value) => `pageSize=${encodeURIComponent(value)}`), + ...['', 'TRUE', 'False', '1', '0', 'yes', ' false'] + .map((value) => `latestVersionsOnly=${encodeURIComponent(value)}`), + ...['version', 'latestVersionsOnly', 'pageSize', 'pageToken'].flatMap((key) => [ + `${key}=1&${key}=1`, `${key}[]=1`, `${key}[value]=1`, + ]), + ])('rejects invalid query %s before RPC', async (query) => { + const res = await request(app).get(`/api/by-domain/my-ns/checkout?${query}`); + expect(res.status).toBe(400); + expect(res.body.error).toEqual(expect.any(String)); + expect(invoke).not.toHaveBeenCalled(); + }); +}); + describe('GET /api/search branch context', () => { it('forwards the branch so a preview searches the branch index', async () => { // service.proto used to say SearchEntities ignored this header, and the @@ -1930,6 +2004,28 @@ describe('TROGON_ATLAS_AUTH_PASSTHROUGH', () => { expect(upstreamCalls.authorization).toBe('Bearer alice-key'); }); + it('forwards by-domain query controls with caller authorization and branch', async () => { + const invoke = vi.spyOn(fakeClient, 'listEntitiesByDomain').mockImplementation((_req, _meta, _opts, cb) => { + cb(null, { entities: [], nextPageToken: 'next-domain-page' }); + }); + try { + const res = await request(ptApp).get('/api/by-domain/acme/checkout') + .set('Authorization', 'Bearer domain-caller') + .query({ version: '9007199254740993', latestVersionsOnly: 'false', pageSize: '501', pageToken: 'cursor', branch: 'review/domain' }); + expect(res.status).toBe(200); + expect(invoke).toHaveBeenCalledTimes(1); + expect(invoke.mock.calls[0][0]).toEqual({ + domainId: { namespace: 'acme', slug: 'checkout', version: '9007199254740993' }, + kinds: [], latestVersionsOnly: false, pageSize: 501, pageToken: 'cursor', + }); + expect(invoke.mock.calls[0][1].get('authorization')).toEqual(['Bearer domain-caller']); + expect(invoke.mock.calls[0][1].get('x-trogon-atlas-branch')).toEqual(['review/domain']); + expect(res.body).toEqual({ entities: [], nextPageToken: 'next-domain-page' }); + } finally { + invoke.mockRestore(); + } + }); + it('never substitutes the bridge identity for a caller that has one', async () => { upstreamCalls.authorization = null; await request(ptApp).get('/api/namespaces').set('Authorization', 'Bearer bob-key');