Skip to content

Commit f1a8114

Browse files
authored
fix(client,service-i18n): ledger the autonomous service-mount surface; two i18n SDK calls reached nothing (#3636) (#3646)
Tranche 3 of the #3563 route audit — the last un-audited server surface. service-storage and service-i18n mount straight on the host IHttpServer at kernel:ready, so neither the dispatcher ledger (#3563) nor the REST ledger (#3587) has ever seen them. Adds a per-package ledger next to each registrar, enumerated by driving the real registrar against a capturing mock IHttpServer, with the client half in packages/client (no service→client package edge). Fixes two wire-level 404s found by the audit: i18n.getTranslations and i18n.getFieldLabels spoke a `?locale=` dialect no serving surface mounts, while carrying green `sdk` rows since tranche 1 — that guard asks whether the client METHOD exists, not whether it speaks a URL anything mounts. Also aligns service-i18n's success bodies to the declared { success: true, data } envelope, which unwrapResponse keys on. Storage audited clean: 7 SDK-expressed, 3 reviewed server-only. Both ledgers ratchet gap and mismatch at 0. Filed, not fixed: #3641 (_local/file/:key built by three call sites, mounted by none) and #3642 (the cross-surface URL conformance capstone). Closes #3636.
1 parent bc17d39 commit f1a8114

14 files changed

Lines changed: 761 additions & 23 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/service-storage": patch
3+
"@objectstack/service-i18n": patch
4+
"@objectstack/client": patch
5+
---
6+
7+
fix(client,service-i18n): ledger the autonomously-mounted service routes, and repair the two i18n calls that reached nothing (#3636)
8+
9+
Tranche 3 of the #3563 route audit — the last un-audited server surface. The
10+
dispatcher ledger (#3563) and the REST ledger (#3587) each stop at their own
11+
package boundary, and two services mount routes outside both: they reach for
12+
the `http-server` service and register straight on `IHttpServer`, so neither
13+
`RouteManager` nor `RestServer.getRoutes()` has ever seen them. That left the
14+
SDK's entire storage surface, plus all of i18n, in the pre-#3563 posture:
15+
expressed, working, guarded by nothing.
16+
17+
**Ledgers + guards.** `storage-route-ledger.ts` (10 routes) and
18+
`i18n-route-ledger.ts` (3) sit next to the registrars that mount them, each
19+
enumerated for real — the registrar runs against a capturing mock
20+
`IHttpServer` and its registration calls *are* the route set, so a new route
21+
lands with a reviewed disposition or fails CI. The client half is
22+
`packages/client/src/service-route-ledger-coverage.test.ts`; ledgers cross the
23+
boundary as relative source imports, never a service→client package edge.
24+
25+
**Two wire-level 404s fixed.** `i18n.getTranslations` sent
26+
`/i18n/translations?locale=xx` and `i18n.getFieldLabels` sent
27+
`/i18n/labels/:object?locale=xx`, while every serving surface — service-i18n's
28+
mounts, the dispatcher's HTTP mounts, and the `plugin-rest-api.zod.ts`
29+
contract — mounts only the path form. Neither call could ever be answered.
30+
Both had carried a green `sdk` row in the dispatcher ledger since tranche 1,
31+
because that guard asks whether the client *method* exists, not whether it
32+
speaks a URL anything mounts. The client now sends the path dialect, the same
33+
resolution #3611 gave `meta.getView`, and a new suite drives the real client
34+
at a real router so a revert cannot pass quietly.
35+
36+
**One response-shape fix.** service-i18n's success bodies omitted the
37+
`success` flag that `ObjectStackClient.unwrapResponse` keys on, so the SDK
38+
returned the raw `{ data: … }` wrapper against that provider while returning
39+
the declared unwrapped shape against the dispatcher — one method, two shapes,
40+
decided by which plugin mounted the route. Its three handlers now emit the
41+
`{ success: true, data }` envelope the `i18n` route group declares. `data` did
42+
not move, so direct body readers are unaffected.
43+
44+
Storage audited clean: 7 routes SDK-expressed, 3 reviewed `server-only` (the
45+
browser capability URL objectql stamps into file-field payloads, and the two
46+
local-driver loopbacks). The chunked-upload family, flagged for triage, turned
47+
out fully expressed. Both ledgers ratchet `gap` and `mismatch` at zero.
48+
49+
Filed, not fixed: `GET {base}/_local/file/:key` is built by three call sites
50+
and mounted by none (#3641); the cross-surface URL conformance guard that would
51+
have caught all of the above mechanically is the capstone (#3642).

docs/audits/2026-07-dispatcher-client-route-coverage.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,49 @@ expression exists for: `GET /search`, `POST /email/send`, `forms/:slug`
157157
`external-datasource-routes.ts`). Auditing that surface with the same
158158
three-column method is the next tranche of #3563.
159159

160+
**Closed in #3587**`packages/rest/src/rest-route-ledger.ts` plus its
161+
conformance guard; the 43 audited gaps and 2 mismatches both ratchet at zero.
162+
163+
## 9. The third surface: autonomous service mounts (#3636)
164+
165+
Neither ledger sees a service that reaches for the `http-server` service and
166+
registers straight on `IHttpServer` — it bypasses `RouteManager` and
167+
`RestServer.getRoutes()` alike. Two do: `service-storage`
168+
(`storage-routes.ts`, 10 routes — the SDK's entire storage surface) and
169+
`service-i18n` (`i18n-service-plugin.ts`, 3 routes). Both now carry a
170+
per-package ledger next to the registrar, enumerated by driving the real
171+
registrar against a capturing mock `IHttpServer`, with the client half in
172+
`packages/client/src/service-route-ledger-coverage.test.ts` (no
173+
service→client package edge — the tranche-1 lesson).
174+
175+
Dispositions: storage audited clean at 7 `sdk` / 3 `server-only` (the
176+
browser-facing `/files/:fileId` redirect objectql stamps into file-field
177+
payloads, and the two `_local/raw/:token` local-driver loopbacks). The chunked
178+
upload family — flagged in #3636 as needing triage — turned out fully
179+
SDK-expressed (`initChunkedUpload` / `uploadPart` / `completeChunkedUpload` /
180+
`resumeUpload`), so no gap to close.
181+
182+
i18n audited at **two mismatches**, both fixed in the same PR:
183+
`i18n.getTranslations` sent `/translations?locale=xx` and
184+
`i18n.getFieldLabels` sent `/labels/:object?locale=xx`, while every serving
185+
surface — service-i18n's mounts, the dispatcher's HTTP mounts, and the
186+
`plugin-rest-api.zod.ts` contract — mounts only the path form. Both were
187+
wire-level 404s, and both had carried a green `sdk` row in
188+
`route-ledger.ts` since tranche 1: **§1 coverage rows assert the client method
189+
exists, not that it speaks a URL anything mounts.** The same audit found
190+
service-i18n omitting the `success` flag from its `{ data }` bodies, so
191+
`unwrapResponse` returned the raw wrapper against that provider while
192+
returning the declared shape against the dispatcher — one method, two shapes,
193+
decided by which plugin mounted the route.
194+
195+
Also filed, not fixed: `GET {base}/_local/file/:key` is built by three call
196+
sites and mounted by none (#3641).
197+
198+
**The gap all three ledgers still share** is the reverse direction — no guard
199+
compares the URL a client method *builds* against the patterns any surface
200+
*mounts*. Four instances of that class have now been found one at a time
201+
(#3584 ×2, #3611, #3636 ×2). Mechanizing it is the capstone, #3642.
202+
160203
## Follow-up slicing (proposed)
161204

162205
1. **`client.actions.invoke(...)`** — closes the largest hole (3 routes).
@@ -166,7 +209,9 @@ three-column method is the next tranche of #3563.
166209
5. **Mismatch reconciliation** (§4) — done in #3584: analytics client aligned to the dispatcher, storage protocol documented as canonical (see §4 Resolution).
167210
6. **Docs**: delete or regenerate README surface table + CLIENT_SPEC_COMPLIANCE.md; extend client-sdk.mdx.
168211
7. **Deprecate `DEFAULT_DISPATCHER_ROUTES`**; point at the ledger.
169-
8. **REST-surface tranche** (§8) with the same ledger+guard treatment.
212+
8. **REST-surface tranche** (§8) with the same ledger+guard treatment — done in #3587.
213+
9. **Autonomous service mounts** (§9) — done in #3636.
214+
10. **Cross-surface URL conformance** (§9, the reverse direction) — #3642.
170215

171216
Each gap closed must flip its ledger row to `sdk` and lower the ratchet bound
172217
in the conformance test — the guard enforces both directions from PR-1 onward.

packages/client/src/client.test.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -664,29 +664,42 @@ describe('i18n namespace', () => {
664664
expect(url).toContain('/api/v1/i18n/locales');
665665
});
666666

667-
it('should get translations', async () => {
667+
it('i18n.getTranslations speaks the path-param dialect every surface mounts (#3636)', async () => {
668668
const { client, fetchMock } = createMockClient({
669669
success: true,
670670
data: { locale: 'zh-CN', translations: { hello: '你好' } }
671671
});
672-
const result = await client.i18n.getTranslations('zh-CN', { namespace: 'common' });
672+
const result = await client.i18n.getTranslations('zh-CN');
673673
expect(result.locale).toBe('zh-CN');
674-
const url = fetchMock.mock.calls[0][0] as string;
675-
expect(url).toContain('/api/v1/i18n/translations');
676-
expect(url).toContain('locale=zh-CN');
677-
expect(url).toContain('namespace=common');
674+
// NOT `/translations?locale=zh-CN` — no server mounts a bare
675+
// /translations, so the old query dialect 404'd everywhere.
676+
expect(String(fetchMock.mock.calls[0][0])).toBe(
677+
'http://localhost:3000/api/v1/i18n/translations/zh-CN',
678+
);
679+
});
680+
681+
it('i18n.getTranslations keeps namespace/keys as query params on the path form', async () => {
682+
const { client, fetchMock } = createMockClient({
683+
success: true,
684+
data: { locale: 'zh-CN', translations: { hello: '你好' } }
685+
});
686+
await client.i18n.getTranslations('zh-CN', { namespace: 'common', keys: ['a', 'b'] });
687+
expect(String(fetchMock.mock.calls[0][0])).toBe(
688+
'http://localhost:3000/api/v1/i18n/translations/zh-CN?namespace=common&keys=a%2Cb',
689+
);
678690
});
679691

680-
it('should get field labels', async () => {
692+
it('i18n.getFieldLabels puts both object and locale on the path (#3636)', async () => {
681693
const { client, fetchMock } = createMockClient({
682694
success: true,
683695
data: { object: 'customer', labels: { name: '名前' } }
684696
});
685697
const result = await client.i18n.getFieldLabels('customer', 'ja');
686698
expect(result.object).toBe('customer');
687-
const url = fetchMock.mock.calls[0][0] as string;
688-
expect(url).toContain('/api/v1/i18n/labels/customer');
689-
expect(url).toContain('locale=ja');
699+
// NOT `/labels/customer?locale=ja` — the mount is /labels/:object/:locale.
700+
expect(String(fetchMock.mock.calls[0][0])).toBe(
701+
'http://localhost:3000/api/v1/i18n/labels/customer/ja',
702+
);
690703
});
691704
});
692705

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* i18n wire-dialect proof (#3636) — the SDK's i18n calls resolve against a
5+
* REAL router, not a pinned URL string.
6+
*
7+
* The URL pins in `client.test.ts` say what the client sends. They cannot say
8+
* whether anything answers: that is exactly how `i18n.getTranslations` and
9+
* `i18n.getFieldLabels` shipped a `?locale=` dialect no server has ever
10+
* mounted, passing every client-side test while 404-ing in production.
11+
*
12+
* So this suite mounts the two route patterns every serving surface declares —
13+
* `service-i18n`'s autonomous mounts, the dispatcher's HTTP mounts, and the
14+
* `plugin-rest-api.zod.ts` contract all agree on them — on a real Hono server,
15+
* and drives the real client at it. The old dialect is asserted DEAD in the
16+
* same suite, so a revert cannot pass quietly.
17+
*
18+
* service-i18n itself is deliberately not imported: it is not a dependency of
19+
* `@objectstack/client` and must not become one (the tranche-1 no-package-edge
20+
* lesson). The route PATTERNS are the contract under test, and they are
21+
* identical on both surfaces.
22+
*/
23+
24+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
25+
import { LiteKernel } from '@objectstack/core';
26+
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
27+
import type { IHttpServer, IHttpRequest, IHttpResponse } from '@objectstack/spec/contracts';
28+
import { ObjectStackClient } from './index';
29+
30+
describe('i18n SDK calls hit a real router (#3636)', () => {
31+
let baseUrl: string;
32+
let kernel: LiteKernel;
33+
let client: ObjectStackClient;
34+
35+
beforeAll(async () => {
36+
kernel = new LiteKernel();
37+
const hono = new HonoServerPlugin({ port: 0 });
38+
kernel.use(hono);
39+
await kernel.bootstrap();
40+
41+
// The same three patterns I18nServicePlugin.registerI18nRoutes mounts,
42+
// answering with the same `{ success: true, data }` envelope it now emits
43+
// — see packages/services/service-i18n/src/i18n-route-ledger.ts.
44+
const server = kernel.getService<IHttpServer>('http-server');
45+
server.get('/api/v1/i18n/locales', async (_req: IHttpRequest, res: IHttpResponse) => {
46+
res.json({ success: true, data: { locales: [{ code: 'zh-CN', label: 'zh-CN', isDefault: true }] } });
47+
});
48+
server.get('/api/v1/i18n/translations/:locale', async (req: IHttpRequest, res: IHttpResponse) => {
49+
res.json({ success: true, data: { locale: req.params.locale, translations: { hello: '你好' } } });
50+
});
51+
server.get('/api/v1/i18n/labels/:object/:locale', async (req: IHttpRequest, res: IHttpResponse) => {
52+
res.json({ success: true, data: { object: req.params.object, locale: req.params.locale, labels: { name: '名称' } } });
53+
});
54+
55+
baseUrl = `http://localhost:${(server as unknown as { getPort(): number }).getPort()}`;
56+
client = new ObjectStackClient({ baseUrl });
57+
});
58+
59+
afterAll(async () => {
60+
await kernel?.shutdown();
61+
});
62+
63+
it('getTranslations resolves — the locale arrives as a path param', async () => {
64+
const res = await client.i18n.getTranslations('zh-CN');
65+
expect(res.locale).toBe('zh-CN');
66+
expect(res.translations).toEqual({ hello: '你好' });
67+
});
68+
69+
it('getFieldLabels resolves — object AND locale arrive as path params', async () => {
70+
const res = await client.i18n.getFieldLabels('customer', 'zh-CN');
71+
expect(res.object).toBe('customer');
72+
expect(res.locale).toBe('zh-CN');
73+
expect(res.labels).toEqual({ name: '名称' });
74+
});
75+
76+
it('getLocales resolves', async () => {
77+
const res = await client.i18n.getLocales();
78+
expect(res.locales).toHaveLength(1);
79+
});
80+
81+
it('the abandoned query dialect is dead on the wire — 404, both shapes', async () => {
82+
// This is what the client sent before #3636. Nothing routes it.
83+
for (const dead of [
84+
'/api/v1/i18n/translations?locale=zh-CN',
85+
'/api/v1/i18n/labels/customer?locale=zh-CN',
86+
]) {
87+
const res = await fetch(`${baseUrl}${dead}`);
88+
expect(res.status, `${dead} should not resolve on any surface`).toBe(404);
89+
}
90+
});
91+
});

packages/client/src/index.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3494,24 +3494,39 @@ export class ObjectStackClient {
34943494
},
34953495

34963496
/**
3497-
* Get translations for a locale
3497+
* Get translations for a locale.
3498+
*
3499+
* Speaks the path-param dialect (`/translations/:locale`) — the shape
3500+
* `plugin-rest-api.zod.ts` declares and the ONLY shape any serving surface
3501+
* mounts (service-i18n's autonomous routes, the dispatcher's HTTP mounts).
3502+
* The `?locale=` query form this used to send matched no route anywhere
3503+
* and 404'd on the wire; the dispatcher's domain body accepts it, but
3504+
* nothing ever routes a bare `/translations` to that body (#3636).
34983505
*/
34993506
getTranslations: async (locale: string, options?: { namespace?: string; keys?: string[] }): Promise<GetTranslationsResponse> => {
35003507
const route = this.getRoute('i18n');
35013508
const params = new URLSearchParams();
3502-
params.set('locale', locale);
35033509
if (options?.namespace) params.set('namespace', options.namespace);
35043510
if (options?.keys) params.set('keys', options.keys.join(','));
3505-
const res = await this.fetch(`${this.baseUrl}${route}/translations?${params.toString()}`);
3511+
const query = params.toString();
3512+
const res = await this.fetch(
3513+
`${this.baseUrl}${route}/translations/${encodeURIComponent(locale)}${query ? `?${query}` : ''}`,
3514+
);
35063515
return this.unwrapResponse<GetTranslationsResponse>(res);
35073516
},
35083517

35093518
/**
3510-
* Get translated field labels for an object
3519+
* Get translated field labels for an object.
3520+
*
3521+
* Both the object and the locale ride the path (`/labels/:object/:locale`)
3522+
* — same reason as `getTranslations` above: the `?locale=` form could
3523+
* never match the two-path-param mount (#3636).
35113524
*/
35123525
getFieldLabels: async (object: string, locale: string): Promise<GetFieldLabelsResponse> => {
35133526
const route = this.getRoute('i18n');
3514-
const res = await this.fetch(`${this.baseUrl}${route}/labels/${encodeURIComponent(object)}?locale=${encodeURIComponent(locale)}`);
3527+
const res = await this.fetch(
3528+
`${this.baseUrl}${route}/labels/${encodeURIComponent(object)}/${encodeURIComponent(locale)}`,
3529+
);
35153530
return this.unwrapResponse<GetFieldLabelsResponse>(res);
35163531
}
35173532
};
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Service route-ledger ↔ client-surface conformance (#3636) — the client half
5+
* of the guards whose server halves live next to the registrars that mount the
6+
* routes:
7+
*
8+
* - `packages/services/service-storage/src/storage-route-ledger.conformance.test.ts`
9+
* - `packages/services/service-i18n/src/i18n-route-ledger.conformance.test.ts`
10+
*
11+
* Same contract as the two ledger guards next door
12+
* (`route-ledger-coverage.test.ts`, `rest-route-ledger-coverage.test.ts`):
13+
* every ledger entry that names a client method must resolve to a real
14+
* function on an instantiated client.
15+
*
16+
* Both ledgers are imported as relative SOURCE files deliberately: they are
17+
* pure data (no imports), and a client→service package edge for them would be
18+
* backwards — the services are where the routes are declared, so the ledgers
19+
* live there, and each package verifies its own half. That is the tranche-1
20+
* lesson (`no runtime→client package edge`) applied verbatim: CI's per-package
21+
* test tasks build only their own dependency closure, so a real package edge
22+
* here would be unbuildable.
23+
*
24+
* With these two, all THREE server surfaces the SDK reaches are ledgered —
25+
* dispatcher (#3563), REST (#3587), autonomous service mounts (#3636).
26+
*/
27+
28+
import { describe, it, expect } from 'vitest';
29+
import { ObjectStackClient } from './index';
30+
import { STORAGE_ROUTE_LEDGER } from '../../services/service-storage/src/storage-route-ledger';
31+
import { I18N_ROUTE_LEDGER } from '../../services/service-i18n/src/i18n-route-ledger';
32+
33+
describe('service route ledgers ↔ @objectstack/client surface', () => {
34+
const client = new ObjectStackClient({ baseUrl: 'http://localhost:9' });
35+
36+
const resolve = (path: string): unknown =>
37+
path.split('.').reduce<unknown>((o, k) => (o == null ? o : (o as Record<string, unknown>)[k]), client);
38+
39+
const brokenIn = (ledger: readonly { route: string; client?: string }[]): string[] =>
40+
ledger
41+
.filter((e) => e.client != null)
42+
.filter((e) => typeof resolve(e.client!) !== 'function')
43+
.map((e) => `${e.route} → client.${e.client}`);
44+
45+
it('every storage ledger entry naming a client method resolves to a real function', () => {
46+
const broken = brokenIn(STORAGE_ROUTE_LEDGER);
47+
expect(
48+
broken,
49+
`storage ledger entries claiming a client method that does not exist: ${broken.join('; ')}`,
50+
).toEqual([]);
51+
});
52+
53+
it('every i18n ledger entry naming a client method resolves to a real function', () => {
54+
const broken = brokenIn(I18N_ROUTE_LEDGER);
55+
expect(
56+
broken,
57+
`i18n ledger entries claiming a client method that does not exist: ${broken.join('; ')}`,
58+
).toEqual([]);
59+
});
60+
61+
it('the whole `storage` namespace is backed by a ledger row', () => {
62+
// The reverse direction, scoped to the namespace this tranche audited:
63+
// a client method reaching a storage route that no ledger row backs is
64+
// the pre-#3563 posture (expressed, working, unguarded) reappearing.
65+
const claimed = new Set(
66+
STORAGE_ROUTE_LEDGER.map((e) => e.client).filter((c): c is string => c != null),
67+
);
68+
const unbacked = Object.keys((client as unknown as { storage: object }).storage)
69+
.map((m) => `storage.${m}`)
70+
.filter((m) => !claimed.has(m));
71+
expect(
72+
unbacked,
73+
`client.storage methods no storage-route-ledger row backs: ${unbacked.join(', ')}. ` +
74+
'Either the route is unledgered or the method targets a surface that does not exist (#3636).',
75+
).toEqual([]);
76+
});
77+
78+
it('the whole `i18n` namespace is backed by a ledger row', () => {
79+
const claimed = new Set(
80+
I18N_ROUTE_LEDGER.map((e) => e.client).filter((c): c is string => c != null),
81+
);
82+
const unbacked = Object.keys((client as unknown as { i18n: object }).i18n)
83+
.map((m) => `i18n.${m}`)
84+
.filter((m) => !claimed.has(m));
85+
expect(
86+
unbacked,
87+
`client.i18n methods no i18n-route-ledger row backs: ${unbacked.join(', ')}.`,
88+
).toEqual([]);
89+
});
90+
});

0 commit comments

Comments
 (0)