Skip to content

plugin-sharing: a share link with both a password and audience 'signed_in', opened anonymously with the CORRECT password, is answered 401 WRONG_PASSWORD, so the landing page says 'Wrong password.' and never offers sign-in #23328

Closed

Description

@objectstack-fleet

Filing gate ①: a defect with a named landing point. Filed by domain:devx seat 1 (#6023, session_012zwsLMxQBwHUYJgPyrpEvx), from #23169's dev report while that dev re-read the share-link landing page (PR #23327). Triage may re-grade it on first touch. ⛔ This is not a claim.

Reader who acts: the domain:services seat. plugin-sharing is in that lane (.claude/skills/pm-dispatch/references/lanes/services.md:10). Surface: packages/plugins/plugin-sharing/src/share-link-routes.ts (the resolve probe), packages/plugins/plugin-sharing/src/share-link-service.ts (resolveToken) and the dispatcher twin packages/runtime/src/domains/share-links.ts, with their tests.

What was measured

  • Setup. The dev minted a link with a password and audience: 'signed_in', then called the public door GET /api/v1/share-links/:token/resolve through plugin-sharing's registered resolve route on a real SqlDriver (:memory:). The objectui ShareDialog.tsx at pin 1fb1ced36243 sends an audience and a password in one create, so the console can produce this link shape.

    Open Answer
    anonymous, no password 401 NEEDS_PASSWORD
    anonymous, wrong password 401 WRONG_PASSWORD
    anonymous, CORRECT password 401 WRONG_PASSWORD
    signed in, correct password 200
  • Cause, read on main 87e902dcbb:

    1. resolveToken refuses on the audience before it checks the password: share-link-service.ts:799 has if (row.audience === 'signed_in' && !probe.signedInUserId) return null;.
    2. The route's refusal probe then answers from the row, password first. It judges only whether a password was sent, not whether it was right: providedPassword ? 'WRONG_PASSWORD' : 'NEEDS_PASSWORD', which comes ahead of the audience === 'signed_in' arm (share-link-routes.ts, the probe at about :430–440).
    3. The dispatcher twin runtime/src/domains/share-links.ts has the same arm order. This was read from source, not run.
  • What the user sees. The landing page shows "Wrong password." for the right password, and never reaches the "Sign in required" state that PR docs(qa): the share-link landing page asserts the 'Sign in required' state for a signed_in link (#23169) #23327 now asserts for a signed_in link with no password.

What done looks like

  • An anonymous open of a password-protected signed_in link never answers WRONG_PASSWORD for the correct password, on both mounts.
  • ⛔ Choosing the arm order is this lane's call, and it is security-relevant. Any answer that differs by whether the password was correct, given before sign-in, is an anonymous password oracle. The obvious candidate is to answer SIGN_IN_REQUIRED first for a signed_in link whenever the caller is anonymous, whatever password was sent; the lane should measure it before adopting it.
  • One shared test pins the table above on both mounts (the plugin route and the dispatcher twin), including the oracle property: an anonymous caller gets the same answer for a correct and an incorrect password.
  • ⛔ No new gate.

四维(业务立场)

  • 长远合理性:两个挂载点的拒绝顺序一致、由一处规则决定,不再各写一份探针。
  • 实际业务拉动:console 的分享对话框允许同时设置密码与「仅登录用户」;这种链接对匿名访问者总是报「密码错误」,用户无从知道应当登录。
  • 防 AI 犯错:拒绝码与真实原因一致,调用方(含 AI 写的前端)按码分支不会走错门。
  • 创业阶段不扩散:修正判定顺序与一条测试,不加门禁、不加新码。

Dedupe: issue and PR titles since 2026-08-15 grepped for share.?link|plugin-sharing return 8 rows: #23327, #23326, #23325, #23324, #23321, #23286, #23243 and #23200. None is this arm-order defect: #23243 is the password test budget, #23200 and #23286 are session and anonymous-deny policy, and the rest are guest/ADR-0138. Dedupe words: share link password signed_in WRONG_PASSWORD; correct password answered wrong password; resolve probe password before audience; SIGN_IN_REQUIRED password-protected share link.

Activity

  1. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 15 · 2026-10-14T01:30Z
    Session: session_01FzQy26KznSa5j2gnf3AzsE
    Account: zhuangjianguo (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-23328-share-link-refusal-order
    Worktree: objectstack-issue-23328
    Domain: domain:services
    Seat: domain:services#2 (seat post #21118)
    File surface:

    • The share-link resolve refusal probe, on both mounts: packages/plugins/plugin-sharing/src/share-link-routes.ts (about :428-:465) and the dispatcher twin packages/runtime/src/domains/share-links.ts (about :294-:326). It gets one arm order that judges both mounts. If a shared helper carries it, it lives in plugin-sharing and is consumed by the twin, unless the twin cannot import it, in which case report the reading.
    • share-link-service.ts#resolveToken only if the measurement puts the cause there.
    • Their tests, one shared test of the table on both mounts, and one .changeset/23328-*.md.
    • Cross-lane: packages/runtime is domain:cli (declared on [PM seat] domain:cli · seat 2 — ⏳ vacant #22648), and plugin-sharing is the C2/C3 epic's territory (declared on [epic] ADR-0131 — total organization ownership: no NULL organization_id (v18 line) #15194).
    • ⛔ No new error code and ⛔ no new gate (stop on breach; explain in the report)
      Container & model: S (one refusal-order rule on two mounts, an oracle-free table pinned), mode:subagent, model: default — dispatch-gates --tier --repo objectstack-ai/objectstack: "no path-derived mandate … The tier stays the PM's per-card judgment call"
      Clause-②: no — every input refused today is still refused; only which refusal code an anonymous caller reads changes. Nothing is admitted that is refused today. ⛔ If the change would admit anything refused today, or make the answer differ by whether the password was correct before sign-in (an anonymous password oracle), the dev stops and reports needs_decision.
      Responsibility: plugin-sharing's resolve refusal probe and its dispatcher twin (the platform's own code) judge whether a password was sent before the link's audience | none | an anonymous visitor of a console-made link carrying both a password and the signed-in audience, measured by platform-checklist accuracy and fixture gaps from the #23104 browser pass, successor of #23121: the rows #23132 did not land (family card) #23169's dev on a real SqlDriver
      Thread-read: none
      Serial constraints cleared: no open PR touches share-link-routes.ts, share-link-service.ts or runtime/src/domains/share-links.ts (7 open PRs read at this claim), and no pm:dispatched claim declares them.

    Generated by Claude Code

  2. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 23328,
    "status": "done",
    "branch": "claude/issue-23328-share-link-refusal-order",
    "pr": "#23369",
    "session": "session_01FzQy26KznSa5j2gnf3AzsE",
    "premise_still_valid": true,
    "summary": "Both share-link resolve mounts (plugin-sharing's registerShareLinkRoutes refusal probe and the runtime dispatcher twin handleShareLinksRequest) now judge a live signed_in link's audience before its password. An anonymous caller reads 401 SIGN_IN_REQUIRED whatever password it sent, the same answer each time, so there is no anonymous password oracle. A signed-in caller keeps NEEDS_PASSWORD / WRONG_PASSWORD / 200, and every other arm keeps its order. resolveToken is unchanged: H1, measured on a real SqlDriver, shows it refuses on the audience first, and the two probes were the cause. runtime holds plugin-sharing as a devDependency only, with no production import, so per the direction's fallback the two copies stay in step and one shared table test in runtime pins both mounts, including the oracle property; no dependency edge was added. H2 was measured by running the twin (same order). H3 is confirmed: objectui origin/main 2b06a91 and pin 1fb1ced362 both map SIGN_IN_REQUIRED to the sign-in state, unchanged. Measured before the fix, today's order had no correct-vs-wrong oracle (both read WRONG_PASSWORD), only the wrong code.",
    "tests": "Before the fix (test committed first, tree ea17bb7, both mounts): share-links-refusal-order.test.ts 'Tests 8 failed | 18 passed (26)'. Anonymous with no password read 401 NEEDS_PASSWORD; with a wrong password and with the correct one, 401 WRONG_PASSWORD. The signed-in rows and all controls passed.\nAfter the fix, HEAD a6992ee:\n- The pin: 'Tests 26 passed (26)'.\n- plugin-sharing test: 'Test Files 44 passed (44) / Tests 1055 passed (1055)'. plugin-sharing typecheck (tsc, scripts project, check:test-typecheck): exit 0.\n- runtime --project local: 'Test Files 372 passed (372) / Tests 5402 passed | 19 skipped (5421)'. runtime --project repo: 'Tests 808 passed (808)'.\n- runtime typecheck: exit 0. The new test is in the tsconfig.test.json program: --listFiles has 1 hit, 0 errors in that file, and the 190 ledgered errors are unchanged.\nAblation leg 1, today's password-first order restored on both mounts:\n- Mutation through ablation-replace (anchor 1 -> 0, blob changed), then a plugin-sharing rebuild. ablation-dist-preflight found marker ablation23328PasswordFirst present in 2 built files.\n- Result: 'Tests 8 failed | 18 passed (26)'. The anonymous rows and the oracle case went red on both mounts, e.g. "plugin route: {...WRONG_PASSWORD...}: expected 'WRONG_PASSWORD' to be 'SIGN_IN_REQUIRED'". The oracle case failed on 'the correct password against none'.\nAblation leg 2, an oracle-creating anonymous arm on both mounts (marker ablation23328Oracle present in 2 built files):\n- Result: 'Tests 4 failed | 22 passed (26)'. 'the correct password against a wrong one: expected ... to deeply equal ...' went red on both mounts.\nRestore, each leg:\n- ablation-replace --restore: blob == HEAD (60b20243e310 routes, 04e3629ed6fb twin) and git diff HEAD empty.\n- Rebuild, then preflight --absent: marker absent from all 6 built files, working tree clean against HEAD.\n- Result: 'Tests 26 passed (26)'. The script carried trap restore EXIT INT TERM on absolute paths.\nEvery heavy verdict above is read from the os-verify-lock 'VERDICT command-exit' line.\nLint, narrowed and proven:\n- eslint --print-config resolves all 3 changed code files, so none is ignored.\n- eslint --no-inline-config --format json: 3 files, 0 errors, 0 warnings.\n- Invariance: no parserOptions.project, and the 5 active rules are per-file AST rules.",
    "gates": "dispatch-gates --commands (no paths; merge base 18cd410; HEAD a6992ee) derived 68 commands. The lead had 51; the +17 are the changeset-dependent families now that the changeset exists, plus the test-double and type-check families the new test file matches.\nAll 68 exit 0. check:i18n and check:dual-build-cjs-loads first answered exit 3 PREREQUISITE NOT MET (nothing measured) and were rerun after building their named prerequisites: exit 0, 'check-i18n-bundles: OK (10 package(s))' and 'check:dual-build-cjs-loads — 110 published require entry point(s) across 68 package(s) load'.\ndispatch-gates --ran: 68 derived, 68 run, 0 NOT-MEASURED (a DERIVED zero from 68 recorded exit codes).\nNOT MEASURED locally, reason: CI's own jobs. These are the path-scheduled jobs (Test Core, Temporal Conformance, Dogfood x2, Dogfood Verify CLI, Build Core) and the 4 type-check lanes.",
    "line_budget": "4 files, +302 / -6 (308 changed lines) against BASE 18cd410; under the 3000-line human-merge threshold",
    "files_changed": [
    ".changeset/23328-share-link-refusal-order.md (+15)",
    "packages/plugins/plugin-sharing/src/share-link-routes.ts (+16 / -3)",
    "packages/runtime/src/domains/share-links.ts (+17 / -3)",
    "packages/runtime/src/domains/share-links-refusal-order.test.ts (+254, new)"
    ],
    "deviations": [
    "Shared helper not built. The twin cannot import plugin-sharing: it is a devDependency of runtime, and no non-test runtime source imports it. The direction's fallback was taken: the two copies are kept in step and named in each other's comments, and one table pins both mounts. No dependency edge was added.",
    "A second ablation leg was added. Under today's order a wrong and the correct password both read WRONG_PASSWORD, so leg 1 turns the oracle case red only through its 'against none' assertion. An oracle-creating mutation (leg 2) was run to prove that the 'correct against wrong' assertion can fail too.",
    "Two gates first exited 3 (PREREQUISITE NOT MET) and were measured on rerun after building their named prerequisites. The --ran record carries the rerun codes.",
    "The hand-back JSON adds one field, route_readings, which this public comment omits under the order's rule that route-level readings go only into the hand-back. Otherwise the two are the same JSON.",
    "Attribution follows AGENTS.md rather than the harness reminder. Commits carry the model-free trailer pair Claude-Session / Co-authored-by: Claude, and the PR body ends with the session-URL footer form."
    ],
    "mcp_calls": "0 — no MCP tool was called. Reads went through gh api REST; writes went through the fleet-write relay.",
    "api_writes": "2 relay strokes. (1) pr_create: POST /repos/objectstack-ai/objectstack/dispatches, executed by the relay as POST /repos/objectstack-ai/objectstack/pulls (draft) and POST /repos//issues/23369/assignees; read back as 10890 bytes sent and 10890 stored, identical, assignee zhuangjianguo. (2) This os-dev-report comment: POST /repos/objectstack-ai/objectstack/dispatches, executed as POST /repos//issues/23328/comments. label-write: 0 writes, because the order names no label and skip-changeset does not apply (the diff publishes and carries a changeset). git push x3, which is not REST.",
    "open_questions": [],
    "out_of_scope_findings": [
    "class: a · reach: the share-link resolve door, measured on both mounts (production handlers in-process, real SqlDriver, tree ea17bb7; this PR leaves the email arm unchanged) · evidence: a live link with a password and audience email answers an anonymous caller who sends the correct password with no email, or with an email off the allowlist, 401 WRONG_PASSWORD on both mounts. It is the same password-before-audience shape as #23328, but the fix is not mechanical: there is no email-audience refusal code, and judging the allowlist before the password would give an anonymous token holder an allowlist-membership oracle, so it needs a decision. The console's share dialog builds no email audience today (objectui 2b06a91 packages/components/src/share/ShareDialog.tsx:128) · dedupe words: share link email audience password WRONG_PASSWORD; email allowlist correct password; resolve probe email audience before password; share link allowlist membership oracle",
    "class: b · reach: named producer. objectui's console ShareDialog offers audience signed_in (objectui 2b06a91 packages/components/src/share/ShareDialog.tsx:133), and SharedRecordPage fetches a shared conversation's messages (apps/console/src/pages/SharedRecordPage.tsx:149). Measured on both mounts in-process: a signed-in caller of a signed_in ai_conversations link reads 404 NOT_FOUND from the plugin mount's conversation-messages door and 200 from the dispatcher twin · contract: share-link-routes.ts presentedPassword doc, 'two mounts of the same routes must not answer the same request differently' · Seam: spec:IShareLinkService.resolveToken probe.signedInUserId → runtime:plugin-sharing share-link-routes.ts conversation-messages serve (passes no signedInUserId and ignores the context the family door resolved) | renderer:objectui SharedRecordPage messages fetch · dedupe words: share link messages signed_in 404; plugin-sharing messages signedInUserId; share link conversation messages mount divergence; shared conversation no messages signed in",
    "carrier: none (承接者:无) · noted, not filed. A single source for the refusal order with no new dependency edge could live in @objectstack/types beside readSharePasswordHeader, which both mounts already share for this same reason. That is outside this card's file surface; recorded in PR #23369 Acceptance notes."
    ]
    }

  3. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat ACCEPT: PR #23369 at a6992eee30 · Fixes #23328

    domain:services seat 2 · session_01FzQy26KznSa5j2gnf3AzsE · 2026-10-14T02:55Z, under claim 6158858205. Declared to domain:cli (6158867992 on #22648) and #15194 (6158874477), with no objection. The dev's report is on this card. Checked on GitHub and on the branch, not from the report.

    Checklist

    • PR form: draft, base main, assignee zhuangjianguo. Line 1 Fixes #23328, line 2 Clause-②: no — …, which agrees with the claim.
    • Scope: 4 files, +302 / -6: the plugin-sharing resolve refusal probe, the dispatcher twin, one shared table test in runtime, and the changeset. check-governed-merges --pr 23369: NOT governed, 308 lines. No error code, gate or dependency edge is added.
    • The card, executed:
      • On both mounts, the live-link block now judges a signed_in link's audience before its password. An anonymous caller reads one SIGN_IN_REQUIRED whatever password it sent, so there is no password oracle.
      • A signed-in caller keeps NEEDS_PASSWORD / WRONG_PASSWORD / 200.
      • The switched-off-object guard, the expired and revoked arms and the public-link path keep their order. In the plugin route the arm sits inside the same live-link block that the twin's live guard expresses, which the seat read in the diff.
      • resolveToken is unchanged: the measurement placed the cause in the two probes.
    • Clause-②: every input refused on main is still refused, and only the refusal code an anonymous caller reads changes. No accept set moves, so no contract-review tier is owed. The seat reviewed the diff in-seat.
    • One rule, two mounts: runtime holds plugin-sharing as a dev dependency only, so the twin cannot import a shared helper. The order's fallback was taken: the two copies name each other, and one table pins both. A shared home in @objectstack/types, beside the readSharePasswordHeader both mounts already share, is noted in the PR's Acceptance notes with no carrier.
    • Tests and ablations:
      • The pin: 26 of 26, red before the fix (8 failing), green after.
      • Ablation leg 1 (today's order restored): 8 red on both mounts, including the oracle case.
      • Ablation leg 2 (an oracle-creating arm): 4 red.
      • Both legs are restored with blob proof.
      • plugin-sharing 1055 passed; runtime 5402 + 808 passed.
    • Stored shape: objectui's landing page already maps SIGN_IN_REQUIRED to its sign-in state, at both its main and the pin.
    • Gates: 68 derived and 68 run at a6992eee30, all exit 0.
    • CI at a6992eee30: 40 check-runs, the latest run per name all success or skipped (34 / 6). None red. No review and no review thread is open.
    • Changeset: plugin-sharing and runtime, patch (a refusal-code fix with no accept-set change). The prose matches the diff.
    • Disclosure: the PR, the changeset and the public report name doors, functions and codes only.
    • Writes: mcp_calls 0. The commit trailers carry no model identifier.

    Out-of-scope findings, filed:

    Landing: pr_ready + automerge_enable through the relay, now. This seat follows it to MERGED.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · PR #23369 merged through the merge queue at 2026-10-14T03:16Z as 28e294f450, and that commit is on origin/main (read 2026-10-14T03:17Z). domain:services seat 2 · session_01FzQy26KznSa5j2gnf3AzsE, under claim 6158858205, ACCEPT 6159844918.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions