|
| 1 | +--- |
| 2 | +"@objectstack/plugin-sharing": minor |
| 3 | +"@objectstack/runtime": minor |
| 4 | +--- |
| 5 | + |
| 6 | +fix(sharing): gate the share-link route probe on `publicSharing.enabled`, at both probe sites (#14637) |
| 7 | + |
| 8 | +**BREAKING** runtime behaviour change on a published HTTP path: |
| 9 | +`GET /api/v1/share-links/:token/resolve` answers `404 INVALID_OR_EXPIRED` where |
| 10 | +it used to answer `401 NEEDS_PASSWORD` / `401 WRONG_PASSWORD` / |
| 11 | +`401 SIGN_IN_REQUIRED` / `410 EXPIRED_OR_REVOKED`, for every link whose object |
| 12 | +has `publicSharing.enabled` switched off. Shipped as `minor` under the repo's |
| 13 | +launch-window convention (a breaking change does not burn a major while the |
| 14 | +stack is in lockstep). No published export is added, removed or re-shaped; the |
| 15 | +level carries the breaking banner, not a surface change. |
| 16 | + |
| 17 | +#14033 made `publicSharing.enabled` a standing policy: `resolveToken()` re-reads |
| 18 | +the object's current block on every redemption and refuses a switched-off link |
| 19 | +with the same undifferentiated `null` a revoked, expired, unknown or ineligible |
| 20 | +token gets — because, in that gate's own words, for a caller who may hold |
| 21 | +nothing but a token a distinguishable "sharing is off for this object" is an |
| 22 | +**existence oracle**. |
| 23 | + |
| 24 | +The HTTP layer above it then re-opened exactly that oracle. Both share-link |
| 25 | +surfaces run a row probe after `resolveToken()` returns null, to answer with a |
| 26 | +more useful status, and both answered from the `sys_share_link` row with no |
| 27 | +knowledge of the object's block. So an anonymous caller could still tell a |
| 28 | +real-but-switched-off token from an unknown one three ways: a row carrying |
| 29 | +`password_hash` drew `401 NEEDS_PASSWORD`, the same row with any password drew |
| 30 | +`401 WRONG_PASSWORD` — including a **correct** password, which is both an oracle |
| 31 | +and a lie, since that link can serve nothing — and a row with |
| 32 | +`audience: 'signed_in'` drew `401 SIGN_IN_REQUIRED`. A security property stated |
| 33 | +in one layer and defeated in the layer above it is worse than one never claimed, |
| 34 | +because the next reader believes the comment. |
| 35 | + |
| 36 | +**What changed.** Both probes read the object's standing policy before they |
| 37 | +answer from the row, and when the block is off every arm falls through to the |
| 38 | +generic `404 INVALID_OR_EXPIRED` that unknown, revoked, expired and ineligible |
| 39 | +tokens already give — byte-for-byte the answer a token that never existed |
| 40 | +receives. The `410 EXPIRED_OR_REVOKED` arm is included: gating only the two 401 |
| 41 | +arms would leave a third class of link answer and a rule about which arms are |
| 42 | +gated. An object whose schema the engine cannot answer for is `enabled: false` |
| 43 | +by `getPolicy`'s definition and is refused the same way — fail-closed, the same |
| 44 | +definition `createLink` and `resolveToken` already use. |
| 45 | + |
| 46 | +The fix lands at **both** sites in one change, because the probe exists twice: |
| 47 | +`plugin-sharing`'s REST routes, and the `/share-links` dispatcher domain in |
| 48 | +`@objectstack/runtime` that is the designed primary surface for cloud's |
| 49 | +per-environment kernels (`registerShareLinkRoutes: false`). Fixing one would |
| 50 | +have moved the oracle to whichever embedding uses the other. |
| 51 | + |
| 52 | +**Nothing else moves.** With the block ON, every refusal is exactly what it was: |
| 53 | +`NEEDS_PASSWORD`, `WRONG_PASSWORD`, `SIGN_IN_REQUIRED` and `EXPIRED_OR_REVOKED` |
| 54 | +are unchanged in status, code and message, and a correct password or a signed-in |
| 55 | +viewer still resolves the record. Mint-time behaviour is untouched, no |
| 56 | +`sys_share_link` row is written or read differently, and no error code is added |
| 57 | +or retired. |
| 58 | + |
| 59 | +**Consumer impact.** A viewer that branches on the refusal STATUS sees TWO |
| 60 | +changes, for links on a switched-off object only — and the measured consumer |
| 61 | +branches on status alone. On the objectui console at `67dadd6`, |
| 62 | +`apps/console/src/pages/SharedRecordPage.tsx` lines 70-85 dispatch on |
| 63 | +`res.status` and never on the body's error code, so: |
| 64 | + |
| 65 | +- all three 401 arms (`NEEDS_PASSWORD`, `WRONG_PASSWORD`, `SIGN_IN_REQUIRED`) |
| 66 | + rendered the password prompt and now render the 404 copy, "This link is |
| 67 | + invalid or no longer available."; |
| 68 | +- the 410 arm rendered "This link has expired or was revoked." and now renders |
| 69 | + that same 404 copy. |
| 70 | + |
| 71 | +Both shifts are the intended outcome and were accepted with the ruling: a |
| 72 | +correct password on such a link yields nothing, so prompting for one teaches the |
| 73 | +holder to open a door that is bricked up, and "expired or revoked" is a claim |
| 74 | +about a token whose existence the caller must not be able to confirm. Links on |
| 75 | +objects whose block is on are unaffected — prompt, 410 copy and 200 render |
| 76 | +included. |
| 77 | + |
| 78 | +Maintainer ruling 2026-09-03 (decision batch #17, item 1), verbatim 「同意」, |
| 79 | +adopting option A over option B (keep the 401 and document the accepted oracle) |
| 80 | +and option C (gate only the two 401 arms, rejected as proliferation). |
| 81 | + |
| 82 | +<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or re-shaped: `publicSharing.enabled` keeps its name, type, default and accept-set, and this change is only WHICH HTTP STATUS the route layer answers with while that switch is off. There is no tombstone for `objectstack migrate meta` to carry and no mechanical rewrite it could perform on any consumer — a deployment that wants the 401 affordance back enables the object's block, which is an authoring decision, not a migration. --> |
0 commit comments