Skip to content

Commit e0733ce

Browse files
committed
fix(plugin-sharing)!: the share-link routes emit the declared envelope, and the last ratchet retires (#3983)
The fifth and final drifting route module. Unlike the four in #3843 this one was not found by reading — `scripts/check-route-envelope.mjs` surfaced it the moment that scan went repo-wide, which is the argument for a repo-wide guard over per-package copies. It also turned out to be the one where the drift had broken shipped SDK methods, not merely mis-shaped a body. Three of these routes are `disposition: 'sdk'` in `runtime/src/route-ledger.ts`, and `unwrapResponse` decides a body is an envelope by finding a boolean `success`. With no flag it returns the body verbatim, so against this surface `shareLinks.create()` — documented "Returns the link row (incl. token)" — handed back `{ link: … }` and `.token` was undefined, while `shareLinks.list()`, typed `Promise<any[]>`, handed back `{ links: [] }` and any `.map()` on it threw. `packages/client/src/admin-surfaces.test.ts` mocks all three as `{ success: true, data: <payload> }`: the SDK was written and tested against the DISPATCHER's shape and only ever worked there. So the target shape was not a design decision. `runtime/src/domains/share-links.ts` serves these same five paths — for cloud's per-environment kernels it is the designed PRIMARY surface — and has always answered in the declared envelope with `data` carrying the payload directly. This module now answers identically, which is what makes `unwrapResponse` return the same value either way: POST /share-links { link } -> data: link GET /share-links { links } -> data: link[] DELETE /share-links/:id { ok: true } -> data: { ok: true } GET /:token/resolve { record, link, redact… } -> data: { … } GET /:token/messages { data: rows } -> data: rows errors { error: { code, msg } } -> + success: false `{ ok: true }` survives on revoke, but as the payload rather than as the body: at the top level it was a second word for `success` (#3689 retired that from storage); under `data` it is what the dispatcher already returned. The error half was already nested `{ code, message }` — #3675's changeset cited this module as the good example — so only the flag is new there, and all eleven codes were already SCREAMING_SNAKE and registered, so ADR-0112 needs nothing. Consumers swept: the framework has ZERO. In objectui, ShareDialog was already dual-shape tolerant on all three routes it calls, and carried that tolerance precisely BECAUSE both shapes existed in the fleet. SharedRecordPage needed one fix of the kind a shape-swap would have missed — it renamed the wire's `redactFields` to `redactedFields` only on the bare branch, so on the enveloped dispatcher path the "fields are hidden by the owner" notice never rendered. Converting this surface would have spread that to every share page; fixed in objectui#2980, which merges first. The module had no test file at all (only `share-link-service.test.ts`), so it gains a driven conformance suite: 26 cases over all five routes and thirteen error branches, every body parsed against the real spec schemas, plus the `unwrapResponse` decision restated so the two broken SDK methods are pinned as working. Reverting the two emitters fails 22 of the 26. Guard: 7 conformant / 0 ratcheted / 1 exempt, from 6 / 1 / 1. `privateOk` is also narrowed to what its doc always claimed — a literal `ok` at the TOP of a body, where it competes with `success`; the same literal inside `data` is payload, which is what a conformant revoke returns. Four self-test assertions pin both readings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYbS3kS8xzsHNXFTzp4e2z
1 parent c35cfdb commit e0733ce

4 files changed

Lines changed: 710 additions & 43 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
---
2+
"@objectstack/plugin-sharing": patch
3+
---
4+
5+
fix(plugin-sharing)!: the share-link routes emit the declared envelope, and the last ratchet retires (#3983)
6+
7+
The fifth and final drifting route module. Unlike the four in #3843, this one was
8+
not found by reading — `scripts/check-route-envelope.mjs` surfaced it the moment
9+
that scan went repo-wide, which is the whole argument for a repo-wide guard over
10+
per-package copies. It also turned out to be the one where the drift had actually
11+
**broken shipped SDK methods**, not merely mis-shaped a body.
12+
13+
## Two SDK methods did not work on this surface
14+
15+
Three of these routes are `disposition: 'sdk'` in `runtime/src/route-ledger.ts`,
16+
and `ObjectStackClient.unwrapResponse` decides a body is an envelope by finding a
17+
boolean `success`. With no flag it hands back the body verbatim:
18+
19+
| method | documented / typed as | actually returned |
20+
|---|---|---|
21+
| `shareLinks.create()` | "the link row (incl. `token`)" | `{ link: … }` — so `.token` was `undefined` |
22+
| `shareLinks.list()` | `Promise<any[]>` | `{ links: [] }` — so `.map()` threw |
23+
24+
`packages/client/src/admin-surfaces.test.ts` mocks all three as
25+
`{ success: true, data: <payload> }`. The SDK was written and tested against the
26+
**dispatcher's** shape and only ever worked there.
27+
28+
## This is a convergence, not a redesign
29+
30+
`runtime/src/domains/share-links.ts` serves the same five paths, and for cloud's
31+
per-environment kernels it is the *designed primary* surface
32+
(`registerShareLinkRoutes: false`). It has always answered in the declared
33+
envelope. The plugin now answers identically:
34+
35+
| route | was | now |
36+
|---|---|---|
37+
| `POST /share-links` | `{ link }` | `{ success: true, data: link }` |
38+
| `GET /share-links` | `{ links }` | `{ success: true, data: link[] }` |
39+
| `DELETE /share-links/:idOrToken` | `{ ok: true }` | `{ success: true, data: { ok: true } }` |
40+
| `GET /:token/resolve` | `{ record, link, redactFields }` | `{ success: true, data: { … } }` |
41+
| `GET /:token/messages` | `{ data: rows }` | `{ success: true, data: rows }` |
42+
| errors | `{ error: { code, message } }` | `{ success: false, error: { … } }` |
43+
44+
`data` carries each payload **directly**`data: links`, not `data: { links }`.
45+
That is what makes `unwrapResponse` return the same value on both surfaces, and
46+
it is what the SDK already expected.
47+
48+
## Breaking: raw `fetch` callers add one hop
49+
50+
SDK callers get the fix for free (two of them go from broken to working). Direct
51+
body readers add `.data`:
52+
53+
```diff
54+
- const { links } = await (await fetch('/api/v1/share-links')).json();
55+
+ const { data: links } = await (await fetch('/api/v1/share-links')).json();
56+
```
57+
58+
`{ ok: true }` on revoke survives, but as the payload rather than as the body: at
59+
the top level it was a second word for `success`, which #3689 retired from
60+
storage; under `data` it is what the dispatcher already returned.
61+
62+
The `error` half was already nested `{ code, message }`#3675's changeset cited
63+
this module as the good example of that — so only the `success` flag is new there.
64+
All eleven codes were already SCREAMING_SNAKE and registered, so ADR-0112 needs
65+
nothing.
66+
67+
## Consumers
68+
69+
Swept, and the result is smaller than #3983 assumed. The framework has **zero**
70+
consumers of these routes. In objectui, `ShareDialog` was already dual-shape
71+
tolerant on all three routes it calls (`body.links ?? body.data`,
72+
`created.link ?? created.data`, and revoke never reads the body) — it needs no
73+
change, and it carried that tolerance precisely *because* both shapes existed in
74+
the fleet.
75+
76+
`SharedRecordPage` did need one fix, and it is the kind a shape-swap would have
77+
missed: it renamed the wire's `redactFields` to `redactedFields` only on the
78+
*bare* branch, so on the already-enveloped dispatcher path the "fields are hidden
79+
by the owner" notice never rendered. Converting this surface would have spread
80+
that to every share page. Fixed in objectui#2980, which merges first.
81+
82+
## Guard
83+
84+
**7 conformant / 0 ratcheted / 1 exempt**, from 6 / 1 / 1. The ratchet mechanism
85+
stays for the next module that needs it.
86+
87+
`privateOk` also got narrowed to what its own doc always claimed — a literal `ok`
88+
at the **top** of a body, where it competes with `success`. The same literal
89+
inside `data` is payload, which is what a conformant revoke returns. Four
90+
self-test assertions pin both readings.

0 commit comments

Comments
 (0)