Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions docs/reference/domain-scoped-listing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
2 changes: 1 addition & 1 deletion tsworkspace/webapps/studio/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
33 changes: 31 additions & 2 deletions tsworkspace/webapps/studio/server/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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) {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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)) {
Expand All @@ -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 } : {}),
});
}),
);
Expand Down
96 changes: 96 additions & 0 deletions tsworkspace/webapps/studio/server/index.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand Down
Loading