Skip to content

Commit ce894a7

Browse files
authored
Merge branch 'main' into fix/analytics-label-scope-3602
2 parents ce75276 + f1a8114 commit ce894a7

32 files changed

Lines changed: 1449 additions & 30 deletions
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
"@objectstack/plugin-auth": patch
4+
---
5+
6+
fix(auth): provision the better-auth 1.7 columns `sys_team` / `sys_team_member` / `sys_two_factor` were missing (#3624)
7+
8+
better-auth 1.7.0-rc.1 added fields to three models that the platform objects
9+
never provisioned and `auth-schema-config.ts` never mapped. Because an unmapped
10+
field keeps its camelCase name, the adapter emitted columns no table had:
11+
12+
| model | field | column now provisioned |
13+
|:---|:---|:---|
14+
| `team` | `memberCount` | `sys_team.member_count` |
15+
| `teamMember` | `membershipKey` | `sys_team_member.membership_key` |
16+
| `twoFactor` | `failedVerificationCount` / `lockedUntil` | `sys_two_factor.failed_verification_count` / `locked_until` |
17+
18+
The team pair broke org creation outright. The organization plugin's team
19+
sub-feature is on by default, so `POST /api/v1/auth/organization/create`
20+
auto-creates a default team — and that insert died with `table sys_team has no
21+
column named memberCount` *after* the organization row had already committed.
22+
Callers got an HTTP 500 on top of a half-created org: a real org row with no
23+
default team behind it. Every multi-org deployment's create-org flow hit this.
24+
25+
The two-factor pair broke the 2FA lockout path the same way: better-auth
26+
guard-increments `failedVerificationCount` on each wrong code and stamps
27+
`lockedUntil` past the threshold, so a wrong code 500'd instead of being
28+
counted. All four columns are better-auth's own state — provisioned, readable,
29+
and never written from the ObjectStack side.
30+
31+
Existing environments pick the columns up through the driver's additive schema
32+
sync; no data migration is needed. `member_count` backfills to 0 and
33+
better-auth's own `syncTeamMemberCount` reconciles it on the next membership
34+
change, and `membership_key` stays null on pre-upgrade rows, which better-auth
35+
tolerates by falling back to the `(team_id, user_id)` pair.
36+
37+
A new drift gate (`better-auth-schema-parity.test.ts`) now asserts that every
38+
column the installed better-auth version can write exists on the platform
39+
object backing it, across the auth manager's whole model surface. The ADR-0092
40+
D7 guard only ever caught *collisions* between our extension fields and
41+
better-auth's, so a bump that adds a brand-new field passed the build and failed
42+
at runtime — twice now, counting the 1.7 `oauthAccessToken.authorizationCodeId`
43+
regression. The next one fails the build instead.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
---
4+
5+
feat(auth): `onInvitationAccepted` host seam — better-auth's
6+
`afterAcceptInvitation` forwarded to the host (ADR-0105 D8 prerequisite)
7+
8+
An invitation may carry placement intent (target business unit + positions,
9+
extension fields on `sys_invitation` per the ADR-0092 whitelist), but there
10+
was no server-side seam to apply it when the invitation is accepted —
11+
better-auth's org-plugin models don't fire core `databaseHooks` (framework
12+
#3541 D8 note).
13+
14+
`AuthManagerConfig.onInvitationAccepted` mirrors `onOrganizationCreated`:
15+
invoked from `organizationHooks.afterAcceptInvitation` with the mapped ids
16+
(`invitationId`, `organizationId`, `userId`, `memberId`, `role`, `email`)
17+
plus the RAW `invitation` / `member` rows so a host reads its own extension
18+
columns without a second query. Failure-isolated — acceptance never rolls
19+
back on a side-effect miss; hosts needing effectively-atomic placement
20+
should make the callback idempotent and reconcile on retry.
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).
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
feat(showcase): add the action `disabled`-predicate specimen (`showcase_archive_task`) — greyed until `record.done`, contrasting `visible` (hide) with `disabled` (grey). Examples-only; releases nothing.

.github/workflows/ci.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,35 @@ jobs:
247247
echo "::endgroup::"
248248
done
249249
250+
dogfood-gate:
251+
# Stable required-check name for a SHARDED job (#3622 follow-up).
252+
#
253+
# Branch protection requires the context "Dogfood Regression Gate". Once
254+
# the job became a 2-way matrix its checks publish as "Dogfood Regression
255+
# Gate (1/2)" / "(2/2)" — the bare context could never appear again, so
256+
# EVERY pull request in the repo sat permanently BLOCKED (mergeable, all
257+
# checks green, merge button dead). #3622's own comment called for updating
258+
# branch protection; keeping the contract HERE instead means a future
259+
# shard-count change cannot deadlock the repo a second time.
260+
#
261+
# `if: always()` + result inspection so a legitimately skipped matrix (the
262+
# `filter` job says no core paths changed) still satisfies the gate.
263+
name: Dogfood Regression Gate
264+
needs: dogfood
265+
if: always()
266+
runs-on: ubuntu-latest
267+
permissions:
268+
contents: read
269+
steps:
270+
- name: Verify dogfood shard results
271+
run: |
272+
result="${{ needs.dogfood.result }}"
273+
echo "dogfood matrix aggregate result: $result"
274+
case "$result" in
275+
success|skipped) echo "Dogfood gate satisfied." ;;
276+
*) echo "::error::Dogfood shards did not pass (aggregate result: $result)"; exit 1 ;;
277+
esac
278+
250279
build-core:
251280
name: Build Core
252281
needs: filter

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.

examples/app-showcase/src/ui/actions/index.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,37 @@ export const ActionParamGalleryAction = defineAction({
235235
refreshAfter: false,
236236
});
237237

238+
/**
239+
* script — the `disabled` predicate specimen. Where `visible` HIDES an action
240+
* (see MarkDoneAction), `disabled` keeps it ON SCREEN but greyed until its
241+
* precondition holds: Archive stays visible on every task and only becomes
242+
* clickable once the task is done. Same authoring rules as `visible`
243+
* (`record.`-prefixed, single comparison; disabled when the CEL is TRUE).
244+
* Exercises the renderer-side wiring (objectui: DeclaredActionsBar +
245+
* action:button/group/icon/menu) that #1885's follow-through completed.
246+
*/
247+
export const ArchiveTaskAction = defineAction({
248+
name: 'showcase_archive_task',
249+
label: 'Archive',
250+
icon: 'archive',
251+
objectName: task,
252+
type: 'script',
253+
body: {
254+
language: 'js',
255+
// No destructive side effect — the specimen's value is the disabled
256+
// behavior itself; echo which record would be archived.
257+
source:
258+
"var id = ctx.recordId || (ctx.record && ctx.record.id) || input.recordId;" +
259+
"return { ok: true, archived: id };",
260+
capabilities: [],
261+
},
262+
successMessage: 'Task archived (demo — no data changed).',
263+
// Disabled while the task is not done — visible either way.
264+
disabled: 'record.done != true',
265+
locations: ['record_header', 'record_section'],
266+
refreshAfter: false,
267+
});
268+
238269
export const allActions = [
239270
MarkDoneAction,
240271
OpenDocsAction,
@@ -245,4 +276,5 @@ export const allActions = [
245276
NewTaskAction,
246277
SubmitForSignoffAction,
247278
ActionParamGalleryAction,
279+
ArchiveTaskAction,
248280
];

examples/app-showcase/src/ui/pages/task-detail.page.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ export const TaskDetailPage = definePage({
6161
properties: {
6262
location: 'record_section',
6363
align: 'start',
64-
actionNames: ['showcase_mark_done', 'showcase_log_time'],
64+
// showcase_archive_task is the `disabled`-predicate specimen:
65+
// visible on every task, greyed until `record.done` (contrast with
66+
// showcase_mark_done's `visible`, which HIDES once done).
67+
actionNames: ['showcase_mark_done', 'showcase_log_time', 'showcase_archive_task'],
6568
},
6669
},
6770
{

packages/adapters/hono/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,16 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
359359
// ─── Catch-all: delegate to dispatcher.dispatch() ─────────────────────────
360360
// Handles meta, data, packages, analytics, automation, i18n, ui, openapi,
361361
// custom API endpoints, and any future routes added to HttpDispatcher.
362+
//
363+
// DELIBERATE SHAPE — read before "improving" this into explicit per-prefix
364+
// mounts (ADR-0076 OQ#9 verdict, #3576 / #3608): dispatch() is a thin
365+
// gates+registry pipeline (env resolution → identity → auth gate →
366+
// membership → scope strip, then a first-match DomainHandlerRegistry
367+
// lookup — enumerable via registry.list()). A naive split into
368+
// `app.all(prefix + domain + '/*')` mounts bypasses those cross-cutting
369+
// gate stages (the #2852 RLS-leak class: handlers running without an
370+
// ExecutionContext). Revisit only if the gate stages are middleware-ized
371+
// for an independent reason — reopen conditions are archived on #3608.
362372
app.all(`${prefix}/*`, async (c) => {
363373
try {
364374
const subPath = c.req.path.substring(prefix.length);

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

0 commit comments

Comments
 (0)