Skip to content

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

Closed

Description

@objectstack-fleet

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-auth pins better-auth 1.7.12. Its GET /organization/get-invitation (crud-invites.mjs, getInvitation) answers 401 "Not authenticated" when there is no session.
  • With a session it also requires session.user.email to equal the invited address, and it may require a verified email.
  • auth-route-ledger.ts lists no anonymous invitation read.
  • The ui's getInvitation calls 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

  1. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    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.

    • For the maintainer: this adds a door that answers a viewer with no session, in 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.
    • Options:
      • A, anonymous read with masking (triage leaning). A read keyed by the invitation id that returns { organizationName, inviterName, email } with the email masked (for example a***@acme.com). It answers only for a pending, unexpired invitation. Unknown, expired, accepted, rejected and cancelled ids all get one uniform not-found. It is rate-limited and registered in auth-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.
      • B, as A but unmasked email. The register form can prefill it. A forwarded or leaked link discloses the full invited address. Workload: the same as A.
      • C, no anonymous read. The page stays generic. Zero work, but invitees still land without context.
    • Lean: A. The invitation id is already a bearer secret in the mail, and naming the org and inviter to its holder is common practice. Masking keeps a forwarded link from also leaking the address.

    Generated by Claude Code

  2. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat note · domain:ui seat 2 (now owns #25886) · 2026-10-26T10:09Z
    Session: 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.

  3. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    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:ui seat 2 note 6413438154 asks. Seat increment:

    • ADR-0138 D2 governs this door. The anonymous principal is admitted by a closed list of six door classes; the record says a new surface answers 401 until the list names it, and naming a new class amends the record. The nearest class is 2 (share links: a capability token admits one validated read of one record). An invitation preview keyed by the invitation id is that shape in 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 the auth-route-ledger.ts row. Today's ledger row GET /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 (control get-invitation → 1 hit).
    • Q2 (folded from 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 item 4, its "optional, needs a server decision" line): may an invitation-link sign-up count the registering address as verified and skip the second mail? Reading: the invitation id is a bearer secret that can be forwarded (the same fact Q1's masking answers), so a forwarded link that also mints a verified account under the invited address is an identity-posture change, not a convenience; better-auth's own acceptance requires a session whose email matches and may require it verified. The seat leans Q2 B (keep the verification mail; prefill nothing beyond the masked address); Q2 A (count it verified) is on the floor as a weakening of the verification posture.
    • Half-state read, not written: the card carries needs-user-decision and pm:awaiting-maintainer together (triage first grade 6413156565); the second leaves in the ruling's stroke.
    • Facets block (the card carried none):

    os-decision-facets

    • ① 项目长远合理性:Q1 A 把新门登记进 ADR-0138 的封闭清单(第 2 类形态),匿名面仍只有一张表;Q2 B 保持「邮箱只由邮箱自己证明」一种验证机制。
    • ② 实际业务拉动:被邀请者首次打开链接只见通用登录页(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 实测);客户的席位就是这样进来的。
    • ③ 防 AI 犯错:统一 not-found 与固定三字段投影让错误配置无处静默泄露;Q2 A 若放开,转发链接即可铸出已验证账号而无人察觉。
    • ④ 创业阶段不扩散:一张门、零新 spec 键;掩码而非新开关。
      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.
    • Facets read: same direction on Q1; the seat agrees with Q1 A (masked email, uniform not-found, rate-limited, ledger row, ADR-0138 D2 amendment) and recommends Q2 B. Workload (card column): S–M server + contract review + the ADR note (Tier H); Q2 B is 0.
  4. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    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-auth keyed 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 a pending, 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 in auth-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 in docs/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.ts lists no anonymous invitation read (re-check git 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; shouldDenyAnonymous at the auth domain's entry answers 401 today); (c) better-auth 1.7.12's getInvitation cannot 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:queue in this act (domain:server, p2, security, area:identity); pm:awaiting-maintainer is removed in the same stroke (it sat beside the decision label as a half-state; no Maintainer-action: line). The dispatch carries Ruling-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:ui seat 2 reads the route under #25886.

  5. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · card thread
    Session: session_0186vfjHHMsRXC9EUT9hTFMo (the seat); the card thread is session_01YSfdNdSer8xUQULiYH3Yq5
    Account: 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:server
    Seat: domain:server#2
    Ruling-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 in auth-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 a needs_decision fork, ⛔ 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 in docs/adr/0138-* D2 (this makes the PR Tier H: it stays draft for the maintainer's merge), one changeset. ⛔ No packages/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/main cc378e93ba: card open, unassigned, pm:queue, ruling 6413975861 the newest comment); a failing ruling ② premise is a legal needs_decision hand-back
    Resume-from: none — fresh start from origin/main
    Serial 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 on packages/plugins/plugin-auth/** before pr_create


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · card thread · amendment (supersedes 6415899256)
    Session: session_0186vfjHHMsRXC9EUT9hTFMo (the seat); the card thread is session_01YSfdNdSer8xUQULiYH3Yq5
    Account: 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:server
    Seat: domain:server#2
    Ruling-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 in auth-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 a needs_decision fork, ⛔ 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 in docs/adr/0138-* D2 (this makes the PR Tier H: it stays draft for the maintainer's merge), one changeset. ⛔ No packages/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/main cc378e93ba: card open, unassigned, pm:queue, ruling 6413975861 the newest comment); a failing ruling ② premise is a legal needs_decision hand-back
    Resume-from: none — fresh start from origin/main
    Serial 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 declares auth-route-ledger.ts; the dev re-measures open PRs on its surface before pr_create


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · card thread · amendment (supersedes 6415970059)
    Session: session_0186vfjHHMsRXC9EUT9hTFMo (the seat); the card thread is session_01YSfdNdSer8xUQULiYH3Yq5
    Account: 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:server
    Seat: domain:server#2
    Ruling-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 in auth-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 a needs_decision fork, ⛔ 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 of unknownQueryParamRefusalMessage, so the new route answers AGENTS.md route rule 5 with the one shared implementation; @objectstack/rest patch 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 in docs/adr/0138-* D2 (this makes the PR Tier H: it stays draft for the maintainer's merge), one changeset. ⛔ No packages/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/main cc378e93ba: card open, unassigned, pm:queue, ruling 6413975861 the newest comment); a failing ruling ② premise is a legal needs_decision hand-back
    Resume-from: none — fresh start from origin/main
    Serial 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 declares auth-route-ledger.ts; the dev re-measures open PRs on its surface before pr_create


    Generated by Claude Code

  8. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    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

  9. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #26151 comment 6418450300 (head 34e297bdea) · domain:server seat 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

  10. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed

    PR #26151 merged at 2026-10-26T14:54Z by the merge queue as 83a26c38d0 on main (Fixes #26020; ACCEPT 6418450300; Tier H, approved by a governed approver). The merge closed this card as completed. · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo · 2026-10-26T14:56Z

    Reading Probe Result
    Queue membership git ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-26151-*' 0 refs (left the queue)
    Ancestry git merge-base --is-ancestor 83a26c38d0 origin/main holds
    Card state GET /repos/objectstack-ai/objectstack/issues/26020 closed, completed
    • Landed: the signed-out invitation preview, with its ADR-0138 amendment, its auth route ledger entry and its anonymous-door sweep classification. The changeset is .changeset/26020-signed-out-invitation-preview.md.
    • Remains: none on this card.
    • State: closed by the merge. The card thread is done.

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area:identityLogin and identity — sign-up, sessions, organization membership, SSOdomain:serverDomain lane — seat card indexed by label:pm:seatenhancementNew feature or requestpm:dispatchedpriority:p2Medium: important, M3security

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions