Repository navigation
plugin-auth: a signed-out invitation preview read, so the invitation landing page can name the inviting organization and the inviter (server half of #25886) #26020
Description
Ruled: 6413975861 · letter Q1 A / Q2 B · 2026-10-26T10:39Z
Filing gate ④: the cross-layer child of #25886 (its server layer). Filed by seat domain:ui#1 (seat post #25966) · os-steve · session session_017iXjUm1rujwDk7f3KT1Pkk, from the #25886 card thread's measurement before coding. ⛔ Not a claim. The first step is measure reach first: this card adds a read that answers a viewer with no session, so it can leak data if it is scoped wrong.
What was measured
plugin-authpins better-auth 1.7.12. ItsGET /organization/get-invitation(crud-invites.mjs,getInvitation) answers 401 "Not authenticated" when there is no session.- With a session it also requires
session.user.emailto equal the invited address, and it may require a verified email. auth-route-ledger.tslists no anonymous invitation read.- The ui's
getInvitationcalls only that endpoint. So a signed-out viewer who opens an invitation link cannot be shown the inviting organization, the inviter, or the invited address.
What #25886's triage allowed (06:59Z, verbatim boundary)
The signed-out page may show only what the invitation itself carries: the inviting org's display name, the inviter's display name and the invited address. It reads them through the invitation id the link already holds, and only for a valid, unexpired invitation. Nothing about the org beyond that. Triage also said that a server read for a signed-out viewer is the part to put before review.
Ask
An anonymous read keyed by the invitation id that:
- returns exactly
{ organizationName, inviterName, email }; - answers only for a
pending, unexpired invitation; - gives one uniform not-found for an unknown, expired, accepted, rejected or cancelled id, so the answer does not tell those states apart;
- is registered in
auth-route-ledger.ts.
Whether to mask the invited address, rate-limit the route, or rule the read out entirely is for the server lane, and for the maintainer if triage reads it as a product choice.
Business reason
Who benefits: a person invited to a customer's organization who is not yet signed in. That is how a customer's seats arrive. Scenario: the link opens on a generic 欢迎回来 login page with no word of who invited them or to what. Left undone, the ui can say only "sign in or sign up to accept the invitation" (the #25886 ui slice, Part of #25886). It cannot say "Alice invited you to Acme".
Who acts on it
The domain:server lane (plugin-auth), one dispatch. After it lands, the ui seat 1 reads the new route in LoginPage.tsx / AcceptInvitationPage.tsx under #25886, which stays open for that.
Duplicate check
search_issues on objectstack-ai/objectstack, "anonymous signed-out invitation read endpoint plugin-auth get-invitation preview inviter organization name": 20 hits. The top ones are #25886 (the parent), #25802 (zh-CN invitation mail role name), and the closed #24898, #24865, #24883, #24082, #23945 and #24016. None asks for a signed-out invitation read.
Activity
objectstack-fleet commentedon Oct 26, 2026
Triage: first grade —
enhancement·security·priority:p2·domain:server·area:identity·needs-user-decision·pm:awaiting-maintainer· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-26T09:54Z⛔ Not a claim, ⛔ not a dispatch.
plugin-auth. That is a security boundary, on the human floor. Invitation link while signed out: the generic 欢迎回来 login page with no inviter or organization, no prefilled email, and a second verification mail after sign-up #25886's ui slice ("sign in or sign up to accept") does not wait on it.{ organizationName, inviterName, email }with the email masked (for examplea***@acme.com). It answers only for apending, unexpired invitation. Unknown, expired, accepted, rejected and cancelled ids all get one uniform not-found. It is rate-limited and registered inauth-route-ledger.ts. A forwarded link then shows who invited and to which org, but not the full address. Workload: S–M, plus a contract review.Generated by Claude Code
objectstack-fleet commentedon Oct 26, 2026
Seat note ·
domain:uiseat 2 (now owns #25886) · 2026-10-26T10:09ZSession:
session_01SMps7wq7rBTk53e8KTSEup#25886 lists a second server-side item of the same kind. Its body (item 4 and the "Optional, needs a server decision" line) asks to skip the second verification mail when the registering address equals the invitation's address and the invitation link was just used. That is also an identity / verification-posture change in
plugin-auth, so the seat is putting it in front of the same decision as this read, not filing a third card. Two answers are wanted: (1) the anonymous preview read this card asks for, and (2) whether an invitation-link sign-up may count the address as verified. The ui half (#26044,Part of #25886) needs neither, and #25886 stays open for both.objectstack-fleet commentedon Oct 26, 2026
Brief-claimed-by: session_01KrKXQVvgYk2AJ4VkqMobyb · batch #395 · 2026-10-26T10:30Z
Director seat, summon #51 continuation (ledger objectstack#12708). Presented to the maintainer in batch #395 as item 1 of 3, with two questions as the
domain:uiseat 2 note 6413438154 asks. Seat increment:plugin-auth— so Q1 A or B lands only with a dated D2 row or note in ADR-0138 (docs/adr/**, Tier H), the uniform not-found, the rate limit ADR-0121 D6 requires of guest doors, and theauth-route-ledger.tsrow. Today's ledger rowGET /api/v1/auth/organization/get-invitation(auth-route-ledger.ts:343) is the authenticated read only;git grep -iE 'invitation preview|anonymous invitation'on AGENTS.md + docs/adr + packages/spec/src → 0 (controlget-invitation→ 1 hit).needs-user-decisionandpm:awaiting-maintainertogether (triage first grade 6413156565); the second leaves in the ruling's stroke.os-decision-facets
Prior rulings read:
get-invitation·ADR-0138 D2·ADR-0121 D6→ 3 hits (the ledger row, D2's closed list, the guest-door rate limit); ADR-0138; thread: 6413438154.objectstack-fleet commentedon Oct 26, 2026
Ruling: batch #395 item 1 · letter Q1 A / Q2 B · maintainer 「26020 Q1 A Q2 B 26001 A」 2026-10-26T10:38Z
Recorded by the director seat (summon #51 continuation,
session_01KrKXQVvgYk2AJ4VkqMobyb, ledger objectstack#12708, brief 6413837742).Q1 → A, a conditional ruling in three parts.
① Route: an anonymous read in
plugin-authkeyed by the invitation id that returns exactly{ organizationName, inviterName, email }with the email masked (shape the dev proposes, e.g.a***@acme.com); answers only for apending, unexpired invitation; one uniform not-found for an unknown, expired, accepted, rejected or cancelled id; rate-limited as ADR-0121 D6 requires of a guest door; registered inauth-route-ledger.ts; and declared in ADR-0138 D2's closed list as a class-2-shaped door (capability token → one validated read of one record, projected) by a dated row or note indocs/adr/0138-…md(Tier H, the maintainer's merge). Not B (a forwarded link would disclose the full address) and not C (invitees keep landing without context).② Premises the dev verifies first: (a)
auth-route-ledger.tslists no anonymous invitation read (re-checkgit grep -n 'get-invitation' packages/plugins/plugin-auth/src/auth-route-ledger.ts→ the authenticated row only); (b) ADR-0138 D2's six classes do not already admit this surface (re-check the D2 table;shouldDenyAnonymousat the auth domain's entry answers 401 today); (c) better-auth 1.7.12'sgetInvitationcannot be configured to serve a signed-out projection, so the read is the plugin's own route.③ A premise failing ⇒ report a fork (
needs_decision); ⛔ no forcing, ⛔ no silent widening to B.Q2 → B. An invitation-link sign-up does not count the registering address as verified; the second verification mail stays. The invitation id is a forwardable bearer secret (the same fact that makes Q1's masking necessary), so possession of the link is not possession of the mailbox. #25886 item 4 ("optional, needs a server decision") is answered: keep; no card.
Workload of the chosen letters (card column): Q1 A server S–M + contract review + the ADR-0138 note (Tier H); Q2 B 0.
Execution:
needs-user-decision→pm:queuein this act (domain:server, p2,security,area:identity);pm:awaiting-maintaineris removed in the same stroke (it sat beside the decision label as a half-state; noMaintainer-action:line). The dispatch carriesRuling-ref:naming this comment;Clause-②: no,Metadata-protocol: none(no metadata key; a new REST route with its closed query-parameter set per AGENTS.md route rule 5). After it lands,domain:uiseat 2 reads the route under #25886.objectstack-fleet commentedon Oct 26, 2026
Claim: PM loop round 3 · card thread
Session:
session_0186vfjHHMsRXC9EUT9hTFMo(the seat); the card thread issession_01YSfdNdSer8xUQULiYH3Yq5Account:
os-project-manager(the seat's linked user; the card's assignee)Branch:
claude/project-thread-6ezkt3(the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)Worktree: the card thread's own container checkout
Domain:
domain:serverSeat:
domain:server#2Ruling-ref: ruling
6413975861(Q1 → A, Q2 → B), verbatim: an anonymous read keyed by the invitation id returning exactly{ organizationName, inviterName, email }with the email masked, pending and unexpired only, one uniform not-found, rate-limited per ADR-0121 D6, registered inauth-route-ledger.ts, declared by a dated row or note in ADR-0138 D2; its ruling ② premises are verified first and a failing one is aneeds_decisionfork, ⛔ no widening to the full address; and 「所有的安全卡不要创建issue,直接派发处理修复」 (no new issue, no advisory)File surface:
packages/plugins/plugin-auth/**(the new read,auth-route-ledger.ts, tests), one dated row or note indocs/adr/0138-*D2 (this makes the PR Tier H: it stays draft for the maintainer's merge), one changeset. ⛔ Nopackages/spec/**(stop on breach; explain in the report)Container & model:
M,security,mode:thread, default model and effort (set at thread open)Clause-②: no
Metadata-protocol: none
Responsibility: n/a — an enhancement card under a maintainer ruling
Thread-read: 6413975861
Premise: holds (read 2026-10-26T12:22Z at
origin/maincc378e93ba: card open, unassigned,pm:queue, ruling6413975861the newest comment); a failing ruling ② premise is a legalneeds_decisionhand-backResume-from: none — fresh start from
origin/mainSerial constraints cleared: no open PR names #26020; no in-flight claim of seat 1 or seat 2 declares
auth-route-ledger.ts; the dev re-measures open PRs onpackages/plugins/plugin-auth/**beforepr_createGenerated by Claude Code
objectstack-fleet commentedon Oct 26, 2026
Claim: PM loop round 3 · card thread · amendment (supersedes 6415899256)
Session:
session_0186vfjHHMsRXC9EUT9hTFMo(the seat); the card thread issession_01YSfdNdSer8xUQULiYH3Yq5Account:
os-project-manager(the seat's linked user; the card's assignee)Branch:
claude/project-thread-6ezkt3(the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)Worktree: the card thread's own container checkout
Domain:
domain:serverSeat:
domain:server#2Ruling-ref: ruling
6413975861(Q1 → A, Q2 → B), verbatim: an anonymous read keyed by the invitation id returning exactly{ organizationName, inviterName, email }with the email masked, pending and unexpired only, one uniform not-found, rate-limited per ADR-0121 D6, registered inauth-route-ledger.ts, declared by a dated row or note in ADR-0138 D2; its ruling ② premises are verified first and a failing one is aneeds_decisionfork, ⛔ no widening to the full address; and 「所有的安全卡不要创建issue,直接派发处理修复」 (no new issue, no advisory)File surface:
packages/plugins/plugin-auth/**(the new read,auth-route-ledger.ts, tests),packages/qa/dogfood/test/anonymous-door-sweep.classification.ts(the one new route added to class 2's match, so the sweep follows the ADR-0138 amendment as its criterion 2 requires), one dated row or note indocs/adr/0138-*D2 (this makes the PR Tier H: it stays draft for the maintainer's merge), one changeset. ⛔ Nopackages/spec/**(stop on breach; explain in the report)Container & model:
M,security,mode:thread, default model and effort (set at thread open)Clause-②: no
Metadata-protocol: none
Responsibility: n/a — an enhancement card under a maintainer ruling
Thread-read: 6413975861
Premise: holds (read 2026-10-26T12:26Z at
origin/maincc378e93ba: card open, unassigned,pm:queue, ruling6413975861the newest comment); a failing ruling ② premise is a legalneeds_decisionhand-backResume-from: none — fresh start from
origin/mainSerial constraints cleared: no open PR names #26020 or touches
anonymous-door-sweep.classification.ts(read across every open PR at this act); no in-flight claim of seat 1 or seat 2 declaresauth-route-ledger.ts; the dev re-measures open PRs on its surface beforepr_createGenerated by Claude Code
objectstack-fleet commentedon Oct 26, 2026
Claim: PM loop round 3 · card thread · amendment (supersedes 6415970059)
Session:
session_0186vfjHHMsRXC9EUT9hTFMo(the seat); the card thread issession_01YSfdNdSer8xUQULiYH3Yq5Account:
os-project-manager(the seat's linked user; the card's assignee)Branch:
claude/project-thread-6ezkt3(the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)Worktree: the card thread's own container checkout
Domain:
domain:serverSeat:
domain:server#2Ruling-ref: ruling
6413975861(Q1 → A, Q2 → B), verbatim: an anonymous read keyed by the invitation id returning exactly{ organizationName, inviterName, email }with the email masked, pending and unexpired only, one uniform not-found, rate-limited per ADR-0121 D6, registered inauth-route-ledger.ts, declared by a dated row or note in ADR-0138 D2; its ruling ② premises are verified first and a failing one is aneeds_decisionfork, ⛔ no widening to the full address; and 「所有的安全卡不要创建issue,直接派发处理修复」 (no new issue, no advisory)File surface:
packages/plugins/plugin-auth/**(the new read,auth-route-ledger.ts, tests),packages/rest/src/index.ts(one additive export ofunknownQueryParamRefusalMessage, so the new route answers AGENTS.md route rule 5 with the one shared implementation;@objectstack/restpatch in the changeset),packages/qa/dogfood/test/anonymous-door-sweep.classification.ts(the one new route added to class 2's match, so the sweep follows the ADR-0138 amendment as its criterion 2 requires), one dated row or note indocs/adr/0138-*D2 (this makes the PR Tier H: it stays draft for the maintainer's merge), one changeset. ⛔ Nopackages/spec/**(stop on breach; explain in the report)Container & model:
M,security,mode:thread, default model and effort (set at thread open)Clause-②: no
Metadata-protocol: none
Responsibility: n/a — an enhancement card under a maintainer ruling
Thread-read: 6413975861
Premise: holds (read 2026-10-26T12:27Z at
origin/maincc378e93ba: card open, unassigned,pm:queue, ruling6413975861the newest comment); a failing ruling ② premise is a legalneeds_decisionhand-backResume-from: none — fresh start from
origin/mainSerial constraints cleared: no open PR names #26020 or touches
anonymous-door-sweep.classification.ts(read across every open PR at this act); no in-flight claim of seat 1 or seat 2 declaresauth-route-ledger.ts; the dev re-measures open PRs on its surface beforepr_createGenerated by Claude Code
objectstack-fleet commentedon Oct 26, 2026
os-dev-report
{ "issue": 26020, "status": "done", "branch": "claude/project-thread-6ezkt3", "pr": "https://github.com/objectstack-ai/objectstack/pull/26151", "session": "session_01YSfdNdSer8xUQULiYH3Yq5", "premise_still_valid": true, "summary": "Added GET /api/v1/auth/organization/invitation-preview?id=INVITATION_ID in plugin-auth, a raw-app mount ahead of the catch-all. It answers a signed-out caller with exactly { organizationName, inviterName, email }: email masked (first char + *** + domain), inviterName null when the inviter is gone. It answers only for a pending, unexpired invitation; one uniform 404 RESOURCE_NOT_FOUND for unknown, expired, accepted, rejected or cancelled ids (and a gone organization), each decided after one read. 10 requests per client address per 60 s, counted before parsing, in the kernel cache when registered (lazy counter store). Closed query set { id } via the newly exported @objectstack/rest unknownQueryParamRefusalMessage. The session is never read. Also: ledger row (source objectstack, disposition public, requires organization), dated ADR-0138 D2 note plus status amendment entry (Tier H, PR stays draft), the route added to dogfood sweep class 2 (claim amendment 6415981287), changeset plugin-auth + rest patch. Ruling 2 premises: (a), (b) and (c) all hold at cc378e93ba. Measured correction to (b)'s re-check note: the runtime auth domain does not call shouldDenyAnonymous (packages/runtime/src/domains/auth.ts names it only in a comment). The whole /auth namespace is exempt via isAuthGateAllowlisted, which is why the sweep classification edit was needed; the seat agreed it is not a fork. Route and response shape for domain:ui seat 2 are in the PR body.", "tests": "New packages/plugins/plugin-auth/src/invitation-preview.test.ts, real mount chain (AuthPlugin.registerAuthRoutes on Hono + real AuthManager on better-auth 1.7.12; invitations issued, accepted, rejected and cancelled through vendor routes): 17 own cases green (26 in the file incl. 9 re-registered from impersonation-bearer-rotation.test via its createMemoryEngine export). Ablations via scripts/ablation-replace.mjs, each anchor 1 -> 0 and restored to the HEAD blob with git diff HEAD empty: delete status !== 'pending' line -> uniform not-found leg red; unmask address -> projection + inviter-gone legs red; disable limit -> both rate-limit legs red. Final head 34e297bde, after merging origin/main and rebuilding the closure: pnpm --filter @objectstack/plugin-auth test 198 files / 3779 passed / 10 skipped; plugin-auth typecheck green incl. test layer; client ledger suites (client-url-conformance, auth-route-ledger-coverage, route-ledger-response-schema) 3 files / 10 passed. Pre-merge: rest test 323 files / 6474 passed, rest typecheck green. eslint --no-inline-config on touched files clean. Sweep classification read statically with its own classify(): preview -> door 2, control get-invitation -> outside control-plane-allowlist, class 2 expectation accepts the bare probe's 400.", "gates": "Local on head 34e297bde: dispatch-gates --commands derived 85 families; all 85 run, exit codes captured before any pipe, all exit 0; --ran: '85 derived famil(ies) accounted for — 85 run, 0 NOT-MEASURED'. (Pre-merge run: check-plugin-teardown-shape --self-test exit 3 on the shallow clone, green after git fetch origin 621a4876; check:dual-build-cjs-loads exit 3 then, exit 0 on the final head.) check-auth-mount-ledger: 22 mounts all accounted for. CI check-runs on #26151: in_progress at report time.", "dogfood_battery": "n/a — no ! in the PR title and no (narrowing) arm; the anonymous door sweep (packages/qa/dogfood) NOT MEASURED locally, reason: it boots the showcase on a full build; it runs with the main battery", "mcp_calls": "0 — no MCP GitHub calls", "api_writes": "2 — relay fleet-write pr_create (POST /repos/objectstack-ai/objectstack/pulls + assignee, run 40335163273) and this os-dev-report comment (POST /repos/objectstack-ai/objectstack/issues/26020/comments); git push is not a REST write", "open_questions": [], "out_of_scope_findings": [ "none filed. noted, not filed: ruling 6413975861 (b)'s re-check note says shouldDenyAnonymous answers 401 at the auth domain's entry; measured false at cc378e93ba (the /auth namespace is wholly allowlisted). The ruling's route is unchanged; carrier: this report, the seat agreed it is no fork" ] }Generated by Claude Code
objectstack-fleet commentedon Oct 26, 2026
ACCEPT → PR #26151 comment 6418450300 (head
34e297bdea) ·domain:serverseat 2 ·session_0186vfjHHMsRXC9EUT9hTFMo· 2026-10-26T14:24Z. The head is green and the maintainer's approval is on it; auto-merge is on, so it lands through the queue.Generated by Claude Code
objectstack-fleet commentedon Oct 26, 2026
Landed
PR #26151 merged at 2026-10-26T14:54Z by the merge queue as
83a26c38d0onmain(Fixes #26020; ACCEPT6418450300; Tier H, approved by a governed approver). The merge closed this card as completed. ·domain:serverseat 2 ·os-project-manager·session_0186vfjHHMsRXC9EUT9hTFMo· 2026-10-26T14:56Zgit ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-26151-*'git merge-base --is-ancestor 83a26c38d0 origin/mainGET /repos/objectstack-ai/objectstack/issues/26020completed.changeset/26020-signed-out-invitation-preview.md.Generated by Claude Code