You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 0ecac96
Browse filesBrowse the repository at this point in the historyBrowse files
fix(plugin-sharing, runtime): an email-audience share link answers every caller it does not open for with one refusal (#23471)
Fixes#23372
Clause-②: no — every request refused before is still refused, and every
request served before is still served; only which refusal code a caller
reads changes.
## What changes
The share-link resolve door (`GET /api/v1/share-links/:token/resolve`)
has two mounts: plugin-sharing's `registerShareLinkRoutes` and the
runtime dispatcher's share-links domain. When `resolveToken` refuses,
each mount probes the row to pick the refusal it answers. `resolveToken`
refuses an unsatisfied `email` audience before it reads any password,
but neither probe had an arm for the `email` audience. So that refusal
fell through to the password arm, which knows only whether a password
was SENT, and the holder of the correct password read `401
WRONG_PASSWORD`.
Both probes now carry an `email` arm after the `signed_in` arm and
before the password arm. A live `email`-audience link that did not
resolve answers the probe's existing generic refusal, `404
INVALID_OR_EXPIRED`, through the same `invalidOrExpired()` helper the
probe already uses for an unknown token. The arm reads neither the email
the caller sent nor whether the password is correct.
Landing points are as dispatched: the two probe files, the one table
that holds both mounts to one order, and one changeset. `resolveToken`
is unchanged.
## Before and after
The link is live, has the `email` audience with a one-address allowlist,
and has a password. Each cell is the status plus `error.code`, measured
in-process through both mounts' production handlers on SqlDriver. The
two mounts gave identical answers in every cell, before and after.
Before (`main` at `7b3d5585`):
| email sent / password sent | none | wrong | right |
|---|---|---|---|
| none | 401 `NEEDS_PASSWORD` | 401 `WRONG_PASSWORD` | 401
`WRONG_PASSWORD` |
| off-list | 401 `NEEDS_PASSWORD` | 401 `WRONG_PASSWORD` | 401
`WRONG_PASSWORD` |
| allowlisted | 401 `NEEDS_PASSWORD` | 401 `WRONG_PASSWORD` | 200,
record |
After (this branch):
| email sent / password sent | none | wrong | right |
|---|---|---|---|
| none | 404 `INVALID_OR_EXPIRED` | 404 `INVALID_OR_EXPIRED` | 404
`INVALID_OR_EXPIRED` |
| off-list | 404 `INVALID_OR_EXPIRED` | 404 `INVALID_OR_EXPIRED` | 404
`INVALID_OR_EXPIRED` |
| allowlisted | 404 `INVALID_OR_EXPIRED` | 404 `INVALID_OR_EXPIRED` |
200, record |
- A signed-in caller reads the same grid as an anonymous one, before and
after. `resolveToken` neither accepts sign-in for an `email` audience
nor reads the session's address.
- A password-less `email` link answered `404 INVALID_OR_EXPIRED` in
every unsatisfied cell before this change, and still does. It still
serves an allowlisted email.
- After the change, every refusal cell is byte-identical (status,
envelope and headers) to the answer for an unknown token.
## The code chosen, and why
The code is `404 INVALID_OR_EXPIRED`, which already exists in the
ledger. No new code, so Clause ② stays `no`.
- **What an allowlisted recipient with a mistyped password reads:**
"Share link is invalid, expired, or revoked". The console renders a 404
as "This link is invalid or no longer available." The recipient loses
the "Incorrect password" hint they read before. We did not choose that
loss; the argument below forces it. They can act on it the only way the
door can let them: retry with the email and the password they were
given.
- **What an off-list caller reads:** the same 404. A password-less
`email` link has always answered them with it, and an unknown token
answers with it too. Nothing in it points them at a password they cannot
use.
Alternatives considered and rejected:
- **`NEEDS_PASSWORD` for every unresolved cell, or only when no password
was sent.** It tells a caller who sent the correct password that a
password is required. That is the same misleading claim as the defect,
in a softer word. The console never sends `?email=`, so it would loop on
its password prompt forever. And it discloses that the link has a
password before the audience is satisfied.
- **`WRONG_PASSWORD`:** this is the defect itself.
- **`SIGN_IN_REQUIRED`:** false here. Signing in does not satisfy an
`email` audience.
- **`EXPIRED_OR_REVOKED`:** false for a live link.
- **A new code:** that would add a ledger entry, make it Clause ② `yes`,
and move it to the spec lane. Not taken.
## The oracle argument
- **Membership.** The email this audience checks is the caller's own
`?email=` claim, and nothing verifies it. Any answer that depended on
whether the claimed email is on the allowlist would disclose membership
to whoever holds the token. The probe cannot learn whether the audience
is satisfied without reading that claim, and the new arm never reads it.
So no answer can differ by email: this follows from the code's
structure, and two pins assert it.
- **Password correctness.** Before the change, the refusal cells already
did not differ by correctness; only the served cell did. So the defect
was a misleading claim, not an oracle. The before grid measures exactly
that. After the change, every refusal cell is one answer. Only the
served cell differs, and it needs an allowlisted email and the right
password together, which is the link working as intended.
- **Password sent or not.** That difference would have been allowed,
because it is the caller's own input. The chosen answer does not use it:
one refusal for every unresolved cell. That also hides whether the link
has a password at all. It is the shape the `signed_in` audience already
has: one answer before the audience is satisfied, whatever was sent.
- **Why not keep the password arms for an allowlisted email** (triage's
"until the audience is satisfied")? For this audience, "satisfied" is a
claim, not a fact. Answering an allowlisted email with `NEEDS_PASSWORD`
/ `WRONG_PASSWORD` while an off-list email reads 404 would be exactly
the membership oracle. Nothing here answers by email, so by triage's
rule no `[Decision]` card is needed.
## Pins
All pins live in
`packages/runtime/src/domains/share-links-refusal-order.test.ts`, the
one table that holds both mounts. Every case runs once per mount.
- **14 new rows:** the 9 cells of the anonymous grid, 3 signed-in cells,
and 2 cells for a password-less `email` link. Each asserts `status` and
`error.code`, or the record for a served cell.
- **3 new whole-answer pins:**
- no password oracle (no email or an off-list email reads one answer,
whatever password was sent);
- no allowlist-membership oracle (an allowlisted email without the right
password reads what an off-list email reads);
- the refusal is the answer an unknown token and a password-less `email`
link give.
- **Red on `main`'s behaviour** (pins committed before the probes
changed): 24 failed, 36 passed, 12 per mount. The membership pin was
green on `main`, as measured: today has no membership oracle.
- **Green:** 60 of 60.
## Ablations
Each leg went through `scripts/ablation-replace.mjs`: the anchor must
hit, and each restore is proven by the blob equalling HEAD and `git diff
HEAD` being empty. The runtime test resolves
`@objectstack/plugin-sharing` through `exports` to `dist/`. So the
plugin mount was rebuilt on both legs, and
`scripts/ablation-dist-preflight.mjs` proved the mutation was in `dist/`
and then gone from it.
| Mount | Mutation | Red | Read |
|---|---|---|---|
| dispatcher twin | A1: the `email` arm removed | 12 (10 rows, 2 pins);
plugin mount all green | no-password-oracle pin: one cell expected
`NEEDS_PASSWORD`, another received `WRONG_PASSWORD` |
| dispatcher twin | A2: the arm answers by email (the oracle) | 5 (3
allowlisted rows, membership pin, generic-refusal pin) | membership pin:
allowlisted with no password read 401 `NEEDS_PASSWORD`, off-list read
404 |
| plugin route | A1 | 12, all plugin route | same as the twin |
| plugin route | A2 | 5, all plugin route | same as the twin |
The predicted directions held. A1 leaves the membership pin green,
because removing the arm restores today's behaviour, which has no
membership oracle. A2 is the leg that proves the membership pin can
fail.
## Verification (head `33934882`)
- `@objectstack/plugin-sharing`: vitest 44 files, 1055 passed.
`typecheck` passed (including `check:test-typecheck`).
- `@objectstack/runtime`: vitest `local` 373 files, 5489 passed, 19
skipped. `repo` 3 files, 808 passed. `typecheck` passed. `tsc
--listFiles` counts the test file in the test program, with no errors
naming it.
- Gates: `dispatch-gates --ran` reconciles 68 derived, 67 run, 1 NOT
MEASURED, 0 UNRUN. The six roster families the derivation marks for
these paths also passed: `check:route-ledger-census`,
`check:tenant-chokepoint`, `check:authz-resolver`,
`check:error-code-casing`, `check:filter-alias-parity`,
`check-changeset-fixed`.
- NOT MEASURED: `check:dual-build-cjs-loads`. It refuses until every
workspace package has a `dist/`, and that full build is CI's. As a
narrowed stand-in, the CJS entries of the two packages this diff touches
load under `require()`.
- Lint, narrowed to the 3 changed TS files: eslint `--format json`
reports 3 files, 0 errors, 0 warnings. `eslint.config.mjs` enables no
type-aware linting, so this diff cannot change the verdict on any file
it does not touch. The repository-wide run is CI's.
## Acceptance notes
- **API and SDK callers** of an `email` link with a password now send
the email and the password together. The door no longer prompts for the
password first: an allowlisted email with no password used to read
`NEEDS_PASSWORD`. The changeset says so.
- **The console** never sends `?email=` and builds no `email` audience
in its share dialog. An `email` link opened there now shows the
"invalid" page at once, instead of a password prompt that could never
succeed.
- **Handed to the seat, not changed here:** the sibling `/messages` door
does not forward every audience input that `/resolve` honours. It was
measured in-process; the details are in the dev report on the card.
Related: #23328 / PR #23369 (the `signed_in` order this builds on).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01LxHPTb1LxL7a6WkQbALKnD)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(plugin-sharing, runtime): a share link limited to an email allowlist answers every caller it does not open for with `404 INVALID_OR_EXPIRED`, whatever email and password were sent
7
+
8
+
Clause-②: no — every request refused before is still refused, and every request served before is still served; only which refusal code a caller reads changes.
9
+
10
+
A share link can carry a password and the `email` audience together. For such a link, the share-link resolve door answered a caller it did not open for from the password, never the audience: `401 NEEDS_PASSWORD` with no password, and `401 WRONG_PASSWORD` with any password, the correct one included, when the email sent was missing or not on the allowlist. A caller with the right password read "Incorrect password".
11
+
12
+
-**An `email` link, any caller it does not open for:**`404 INVALID_OR_EXPIRED`, with or without an email, with no password, a wrong one or the correct one, signed in or not. It is the answer a password-less `email` link already gave and an unknown token gives, so it tells the caller neither whether the email is on the allowlist, nor whether the password is right, nor whether the link has a password. The door does not read the email to choose its answer.
13
+
-**Served, unchanged:** an allowlisted email with the correct password (or, on a link without a password, an allowlisted email alone) still opens the record. An API or SDK caller sends both together; the door no longer prompts for the password first.
14
+
-**Unchanged:** links with any other audience, including the `signed_in` order (`401 SIGN_IN_REQUIRED` before sign-in, the password arms after), a `public` link's `NEEDS_PASSWORD` / `WRONG_PASSWORD`, and the expired-or-revoked `410 EXPIRED_OR_REVOKED`.
15
+
- Both mounts of the resolve door answer alike: plugin-sharing's share-link routes and the runtime dispatcher's share-links domain.
it('[#23372] email audience, no allowlist-membership oracle: an allowlisted email without the right password reads what an off-list email reads',async()=>{
0 commit comments