fix(plugin-auth): bind the dev-admin seed's operator-provisioning ticket to more than the seed address (#14373) - #14730

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-14373-operator-ticket-binding
Sep 3, 2026
Merged

fix(plugin-auth): bind the dev-admin seed's operator-provisioning ticket to more than the seed address (#14373)#14730
os-project-manager merged 2 commits into
mainfrom
claude/issue-14373-operator-ticket-binding

Conversation

@os-sales

@os-salesos-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14373

What changed

Disposition 3 only, per triage's ruling (14373#issuecomment-5504337158) and the claim comment's carry-forward: bind the dev-admin seed's operator-provisioning ticket to more than the seed address.

AuthManager.stageOperatorProvisioning(email) staged its ticket keyed on email.trim().toLowerCase() alone. The address is not a secretadmin@objectos.ai is the documented default and the boot banner prints it (with the password) once the seed completes — so "the address is the operator's own" did not narrow the attacker set the way it would for an unguessable value. A stranger's own concurrent POST /sign-up/email for that same address, arriving while the ticket was staged, would satisfy an email-only peek at BOTH admission seams (the disableSignUp before-hook at auth-manager.ts:~2069 and validateAudienceAdmission's creationClass computation at ~3940) and be admitted as the operator class too — and since a unique-email constraint lets only one of the two concurrent signUpEmail calls actually land, a stranger who won that race would not merely read as the operator, their row would become the account at that address. The safety this rested on — "milliseconds, and dev-only" — was true today, but both are properties of the caller, not of what the ticket asserted.

stageOperatorProvisioning(email) now also generates a random, unguessable ticket value (128 bits from WebCrypto's getRandomValues, matching the existing resolvePasswordHasher WebContainer-salt convention) and returns it. AuthPlugin.maybeSeedDevAdmin threads that value into the SAME signUpEmail call's body, under a new AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD key — an unrecognized key that better-auth's signUpEmailBodySchema (z.object({...}).and(z.record(z.string(), z.any()))) lets ride through untouched, so it never becomes a declared sys_user field or a DB column. isOperatorProvisioning(email, ticket) now requires an exact match on both; a missing, wrong-typed, or mismatched ticket reads as "not provisioning" — same as no ticket at all, no email-only fallback.

This converts a timing argument into a structural one: admission now asks "did THIS process's own boot command make THIS exact call", not "does the address match". A stranger's request carries no value that was ever transmitted anywhere for them to replay, however precisely they time the window.

Why the nonce, not name

Triage offered two implementation routes: bind to the seed's own name, or thread a nonce through the sign-up body. I measured both against the actual threat (a concurrent stranger, not merely "harder to guess"):

  • name defaults to 'Dev Admin' (OS_SEED_ADMIN_NAME env override), and — unlike email/password — is not printed by the boot banner (AuthManager.devSeedResult only carries {email, password}; confirmed by reading serve.ts's banner code and format.ts). So it is not observable from a live deployment's own output. But this is an open-source codebase: 'Dev Admin' is exactly as discoverable by reading auth-plugin.ts as admin@objectos.ai is by reading walled-owner-verification-path.ts. Binding to name alone would narrow the window against an attacker who knows the printed email but hasn't read the source — a real but narrow-obscurity improvement, not a structural one. Per the card's own instruction, I did not write "harder to guess" as "impossible."
  • The random ticket value is generated in-process via WebCrypto and never transmitted anywhere — not printed, not logged, not derivable from source (it's per-boot, not a fixed default). No amount of source-reading recovers it. That is the structural fix the triage comment was pointing at ("converts a timing argument into a structural one").

Is the binding value a secret? (asked directly)

Yes, and that is the load-bearing difference from the seed address. Traced end to end, what it travels with and where it can surface:

  • Minted: AuthManager.generateOperatorProvisioningTicket() — 16 bytes from crypto.getRandomValues, hex-encoded, held only in the local ticket variable and in the pendingOperatorProvisioning in-memory Map (keyed by email, TTL-pruned, cleared in a finally). Nothing durable — no table, no file, no env var.
  • Carried: as one extra JSON field, AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD (__osOperatorProvisioningTicket), riding inside the same POST /sign-up/email request bodyAuthPlugin.maybeSeedDevAdmin was always going to send — alongside email, password, and name. It does not get its own call or its own channel.
  • Consumed: better-auth's signUpEmailBodySchema is z.object({...}).and(z.record(z.string(), z.any())) — the declared fields get validated and become the sys_user/sys_account rows; the catch-all lets the ticket field ride through the parse untouched, but it is read ONLY by isOperatorProvisioning's comparison and is never assigned to any column, never passed to internalAdapter.createUser.
  • Printed? No. AuthManager.devSeedResult — the only thing the boot banner reads (see serve.ts's banner code, format.ts) — carries exactly {email, password}. The ticket is never part of that struct, so it cannot reach stdout, a log line, or any other deployment-observable output by any path this diff touches.

So the fix does not make the window narrower (a better guess still fails against the seed address) — it removes the window's public observable entirely: there is nothing about this deployment's own output that a stranger could read to learn the value, at any point in its lifetime. Contrast with binding to name (above), which would only have been "harder to guess," since name defaults to a fixed, source-readable string.

Region split held

Per the claim comment's declared region (origin/main line numbers): auth-manager.ts:2070/:3810/:3912-3914/:4233/:4238/:4251; auth-plugin.ts:1874.

Corrected measurement (git diff -U0 origin/main...HEAD, exact changed old-side lines, zero context padding — the first PR-body draft measured this with -U3-style context bleed and understated the gap at "22 lines"; this is the precise figure and it supersedes that one): auth-manager.ts touches old-side lines 2068,2070 / 3811,3813,3821 / 3890-3893,3900,3914 / 4231,4233,4235,4242,4249,4251,4254; auth-plugin.ts touches 1874,1876. Against PR #14600's declared hunks (auth-manager.ts 14/990/1336/1368/1399/1425/2105/2921/3067/3089/4327-4337/4340/4354/4554-4591; auth-plugin.ts 784-788 only): closest pairs are 2070 vs 2105 = 35 lines and 4254 vs 4327 = 73 lines in auth-manager.ts, and ~1090 lines apart in auth-plugin.ts (784-788 vs 1874-1876). This matches the PM seat's independent re-measurement exactly. Disjoint throughout; split holds.

Not in this PR (per triage's ruling)

  • Disposition 1 (document the trust assumption) — already landed on main; the JSDoc above stageOperatorProvisioning states it in full. No-op, not touched.
  • Disposition 2 (move the method off the barrel-public surface) — a published-surface removal, the human floor (删除已发布能力) with ADR-0049 enforce-or-remove discipline owed. Not a dev-agent edit.
  • Whether the bootstrap window should count humans or logins is #14349's question, in the decision inbox.

Public surface (Clause ②: yes) — exact signature deltas, and why grep export cannot see them

AuthManager is barrel-public (packages/plugins/plugin-auth/src/index.ts: export * from './auth-manager.js'), so every public member of the class is on the package's public surface even though nothing in this diff adds a new top-level export statement — git diff -U0 origin/main... | grep -E '^\+\s*export ' returns zero output, and reading only that grep would wrongly conclude Clause ② is no. The actual surface change is on the SIGNATURES of two already-exported class members, which a line-prefix grep for the export keyword structurally cannot see (the keyword lives once, on the class AuthManager declaration itself, not repeated per member):

MemberBefore (origin/main)After (this PR)
stageOperatorProvisioningstageOperatorProvisioning(email: string): voidstageOperatorProvisioning(email: string): string
isOperatorProvisioningisOperatorProvisioning(email: unknown): booleanisOperatorProvisioning(email: unknown, ticket?: unknown): boolean
(new member)static readonly OPERATOR_PROVISIONING_TICKET_FIELD: string
  • stageOperatorProvisioning's return type widens voidstring — additive; any existing caller that ignores the return value is unaffected, but a caller that TYPE-CHECKS against void (unusual, but possible in a strict wrapper) would need to update.
  • isOperatorProvisioning gains an optional second parameter — additive at the call site (omitting it now always reads "not provisioning," the safe default), but it is a signature widening on a public method, which is exactly what Clause ② asks about regardless of backward-compatibility.
  • One new static member is added to the public class surface.

Graded minor in the changeset (confirmed: .changeset/operator-provisioning-ticket-binding.md frontmatter is "@objectstack/plugin-auth": minor — this repo's convention for a barrel-public signature change, not patch). needs:contract-review is on both carriers: issue #14373 (pre-hung by the claim comment) and this PR (confirmed present via a read-back after adding it).

Docs drift — three pages checked, none touched

Per PM's pointer, checked whether this diff falsifies any sentence on the three pages that reach AuthManager as a symbol:

  • content/docs/kernel/contracts/auth-service.mdx — grepped for AuthManager/stageOperatorProvisioning/isOperatorProvisioning/operator provisioning/dev-admin/seed. One hit: a line contrasting api vs getApi() naming AuthManager generically ("the shipped plugin-auth registers an AuthManager, which has no api member at all"). Still true — this diff adds no api member and does not touch that contrast. No change needed.
  • content/docs/kernel/services-checklist.mdx — one hit, a table row: "Implementation: AuthPluginAuthManager (@objectstack/plugin-auth, built on better-auth)". Still true — AuthManager is still the implementation, still built on better-auth; this diff changes neither fact. No change needed.
  • content/docs/permissions/authentication.mdx — the page that documents the public auth surface in the most detail (bootstrap-status, sign-up/email, etc.). Read the full sign-up/email entry and the "First-run bootstrap status" section (#first-run-bootstrap-status): both describe the PUBLIC bootstrap probe (isBootstrapCreation, "does this environment have no human user yet") and the generic sign-up/email endpoint description ("Register new user"). Neither mentions the dev-admin seed's internal operator-provisioning ticket mechanism (stageOperatorProvisioning/isOperatorProvisioning) at all — that mechanism is deliberately a SEPARATE, undocumented-by-design internal seam (see the [#14157] docblock: "moves no public door"), distinct from the bootstrap probe this page does document. No sentence on this page asserts anything about ticket keying that this diff changes. No change needed.

Verdict for all three: the change is internal to the dev-admin seed's ticket-keying implementation detail: none of the three pages' contracts (service implementation identity, public bootstrap probe semantics, public endpoint list) assert anything this diff makes false.

Tests

Real end-to-end suite (dev-admin-seed-credential-gate.test.ts, real ObjectQL engine + real better-auth over :memory: SQLite). Existing cases ⓪–⑧ stay green unmodified; two new cases pin the fix:

  • : stages a ticket directly (opening the exact window maybeSeedDevAdmin opens), fires a stranger's POST /sign-up/email for the SAME address with NO ticket field while the window is open — asserts 403 SELF_REGISTRATION_CLOSED and zero accounts created — then, in the SAME still-open window, fires the correctly-bound call as a positive control and asserts it IS admitted, its credential actually authenticates via sign-in/email.
  • ⑨(b): same setup, but the stranger supplies a wrong-but-present ticket value — asserts the same refusal (not merely "missing" but "wrong" is refused).

pnpm --filter @objectstack/plugin-auth test: 91 test files passed, 1864 tests passed, on the merged tree at 4a07e99c4. pnpm --filter @objectstack/plugin-auth typecheck: clean.

Ablation (proves the pin actually depends on the binding)

Reverted isOperatorProvisioning to email-only matching. Mutation proof, redone with a non-comment marker (a globalThis write, since a // comment is stripped by esbuild and cannot prove the mutation reached the built artifact):

  • Source: blob hash 66ba6e5c…6b477b29…; injected marker token present at 2 occurrences (write + implicit read via grep), 0 remaining occurrences of the original ticket === entry.ticket comparison line.
  • Rebuilt dist/index.mjs; the marker token is present in the BUILT output (DIST_MARKER_COUNT=1) — confirms the mutation reached the code the tests actually load, not just the source.
  • Reran the file's suite: Tests 2 failed | 13 passed (15), with ⑨ and ⑨(b) specifically red (the stranger is admitted once the ticket check is gone).
  • Restore via trap ... EXIT INT TERM: git diff HEAD -- auth-manager.ts byte count 0 after restore. Rebuilt again afterward: marker count 0 in the rebuilt dist/index.mjs, and the file's suite is back to 15 passed (15)dist/ is confirmed returned to the real implementation, not left mutated.

Gate family (re-derived from the actual diff at HEAD 4a07e99c4, scripts/pm/dispatch-gates.mjs)

38 commands derived. 35 exit 0. The remaining 3 are explicitly NOT MEASURED (their own output says so, exit code 3, distinct from a finding's exit 1), each requiring a full-monorepo dist/ closure this scoped local run does not build — CI's lint.yml builds that closure and runs these for real:

  • check-test-completeness — no local turbo run test log to parse.
  • check:dual-build-cjs-loads — PREREQUISITE NOT MET, 38 unrelated packages have no local dist/.
  • check:type-check-debt --re-measure — PREREQUISITE NOT MET, 28 workspace deps have no built type entry point locally.

None of the 3 touch plugin-auth, auth-manager.ts, or auth-plugin.ts in their own diagnostics.


🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

…ket to more than the seed address (#14373)
`stageOperatorProvisioning`/`isOperatorProvisioning` peeked the ticket by
email alone. The address is not a secret (documented default, printed on the
boot banner), so a stranger's own concurrent sign-up for the same address,
arriving while the ticket was staged, could satisfy an email-only peek at
both admission seams and be admitted as the operator class — and since a
unique-email constraint lets only one concurrent signUpEmail land, the
stranger's row could become the account at that address.
`stageOperatorProvisioning` now also generates a random ticket value and
returns it; the caller threads it into the same signUpEmail call's body
under the new `AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD` key.
`isOperatorProvisioning` now requires an exact match on both email and
ticket — a missing or mismatched ticket reads as "not provisioning", no
email-only fallback.
Disposition 3 only, per triage's ruling (14373#issuecomment-5504337158):
disposition 1 (document the trust assumption) is already landed and
untouched; disposition 2 (move the method off the barrel-public surface) is
a published-surface removal, the human floor, not touched here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 9 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx(via AuthManager (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via AuthManager (symbol, a top-level class))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2dc527df332cfdee110aa7ce0d5505506e2ea0ca — the merge of head 4a07e99c455a23037cb81900bc7fbeacebd214d8 into base 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2dc527df332cfdee110aa7ce0d5505506e2ea0ca && git checkout 2dc527df332cfdee110aa7ce0d5505506e2ea0ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 4a07e99c455a23037cb81900bc7fbeacebd214d8 && git checkout -B drift-repro 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 && git merge --no-ff 4a07e99c455a23037cb81900bc7fbeacebd214d8
node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review September 2, 2026 23:50
@os-project-manager
os-project-manager added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 7c342f4Sep 3, 2026
48 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14373-operator-ticket-binding branch September 3, 2026 01:32
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

3 participants

@os-sales@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(plugin-auth): bind the dev-admin seed's operator-provisioning ticket to more than the seed address (#14373) - #14730

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-14373-operator-ticket-binding
Sep 3, 2026
Merged

fix(plugin-auth): bind the dev-admin seed's operator-provisioning ticket to more than the seed address (#14373)#14730
os-project-manager merged 2 commits into
mainfrom
claude/issue-14373-operator-ticket-binding

Conversation

@os-sales

@os-salesos-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14373

What changed

Disposition 3 only, per triage's ruling (14373#issuecomment-5504337158) and the claim comment's carry-forward: bind the dev-admin seed's operator-provisioning ticket to more than the seed address.

AuthManager.stageOperatorProvisioning(email) staged its ticket keyed on email.trim().toLowerCase() alone. The address is not a secretadmin@objectos.ai is the documented default and the boot banner prints it (with the password) once the seed completes — so "the address is the operator's own" did not narrow the attacker set the way it would for an unguessable value. A stranger's own concurrent POST /sign-up/email for that same address, arriving while the ticket was staged, would satisfy an email-only peek at BOTH admission seams (the disableSignUp before-hook at auth-manager.ts:~2069 and validateAudienceAdmission's creationClass computation at ~3940) and be admitted as the operator class too — and since a unique-email constraint lets only one of the two concurrent signUpEmail calls actually land, a stranger who won that race would not merely read as the operator, their row would become the account at that address. The safety this rested on — "milliseconds, and dev-only" — was true today, but both are properties of the caller, not of what the ticket asserted.

stageOperatorProvisioning(email) now also generates a random, unguessable ticket value (128 bits from WebCrypto's getRandomValues, matching the existing resolvePasswordHasher WebContainer-salt convention) and returns it. AuthPlugin.maybeSeedDevAdmin threads that value into the SAME signUpEmail call's body, under a new AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD key — an unrecognized key that better-auth's signUpEmailBodySchema (z.object({...}).and(z.record(z.string(), z.any()))) lets ride through untouched, so it never becomes a declared sys_user field or a DB column. isOperatorProvisioning(email, ticket) now requires an exact match on both; a missing, wrong-typed, or mismatched ticket reads as "not provisioning" — same as no ticket at all, no email-only fallback.

This converts a timing argument into a structural one: admission now asks "did THIS process's own boot command make THIS exact call", not "does the address match". A stranger's request carries no value that was ever transmitted anywhere for them to replay, however precisely they time the window.

Why the nonce, not name

Triage offered two implementation routes: bind to the seed's own name, or thread a nonce through the sign-up body. I measured both against the actual threat (a concurrent stranger, not merely "harder to guess"):

  • name defaults to 'Dev Admin' (OS_SEED_ADMIN_NAME env override), and — unlike email/password — is not printed by the boot banner (AuthManager.devSeedResult only carries {email, password}; confirmed by reading serve.ts's banner code and format.ts). So it is not observable from a live deployment's own output. But this is an open-source codebase: 'Dev Admin' is exactly as discoverable by reading auth-plugin.ts as admin@objectos.ai is by reading walled-owner-verification-path.ts. Binding to name alone would narrow the window against an attacker who knows the printed email but hasn't read the source — a real but narrow-obscurity improvement, not a structural one. Per the card's own instruction, I did not write "harder to guess" as "impossible."
  • The random ticket value is generated in-process via WebCrypto and never transmitted anywhere — not printed, not logged, not derivable from source (it's per-boot, not a fixed default). No amount of source-reading recovers it. That is the structural fix the triage comment was pointing at ("converts a timing argument into a structural one").

Is the binding value a secret? (asked directly)

Yes, and that is the load-bearing difference from the seed address. Traced end to end, what it travels with and where it can surface:

  • Minted: AuthManager.generateOperatorProvisioningTicket() — 16 bytes from crypto.getRandomValues, hex-encoded, held only in the local ticket variable and in the pendingOperatorProvisioning in-memory Map (keyed by email, TTL-pruned, cleared in a finally). Nothing durable — no table, no file, no env var.
  • Carried: as one extra JSON field, AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD (__osOperatorProvisioningTicket), riding inside the same POST /sign-up/email request bodyAuthPlugin.maybeSeedDevAdmin was always going to send — alongside email, password, and name. It does not get its own call or its own channel.
  • Consumed: better-auth's signUpEmailBodySchema is z.object({...}).and(z.record(z.string(), z.any())) — the declared fields get validated and become the sys_user/sys_account rows; the catch-all lets the ticket field ride through the parse untouched, but it is read ONLY by isOperatorProvisioning's comparison and is never assigned to any column, never passed to internalAdapter.createUser.
  • Printed? No. AuthManager.devSeedResult — the only thing the boot banner reads (see serve.ts's banner code, format.ts) — carries exactly {email, password}. The ticket is never part of that struct, so it cannot reach stdout, a log line, or any other deployment-observable output by any path this diff touches.

So the fix does not make the window narrower (a better guess still fails against the seed address) — it removes the window's public observable entirely: there is nothing about this deployment's own output that a stranger could read to learn the value, at any point in its lifetime. Contrast with binding to name (above), which would only have been "harder to guess," since name defaults to a fixed, source-readable string.

Region split held

Per the claim comment's declared region (origin/main line numbers): auth-manager.ts:2070/:3810/:3912-3914/:4233/:4238/:4251; auth-plugin.ts:1874.

Corrected measurement (git diff -U0 origin/main...HEAD, exact changed old-side lines, zero context padding — the first PR-body draft measured this with -U3-style context bleed and understated the gap at "22 lines"; this is the precise figure and it supersedes that one): auth-manager.ts touches old-side lines 2068,2070 / 3811,3813,3821 / 3890-3893,3900,3914 / 4231,4233,4235,4242,4249,4251,4254; auth-plugin.ts touches 1874,1876. Against PR #14600's declared hunks (auth-manager.ts 14/990/1336/1368/1399/1425/2105/2921/3067/3089/4327-4337/4340/4354/4554-4591; auth-plugin.ts 784-788 only): closest pairs are 2070 vs 2105 = 35 lines and 4254 vs 4327 = 73 lines in auth-manager.ts, and ~1090 lines apart in auth-plugin.ts (784-788 vs 1874-1876). This matches the PM seat's independent re-measurement exactly. Disjoint throughout; split holds.

Not in this PR (per triage's ruling)

  • Disposition 1 (document the trust assumption) — already landed on main; the JSDoc above stageOperatorProvisioning states it in full. No-op, not touched.
  • Disposition 2 (move the method off the barrel-public surface) — a published-surface removal, the human floor (删除已发布能力) with ADR-0049 enforce-or-remove discipline owed. Not a dev-agent edit.
  • Whether the bootstrap window should count humans or logins is #14349's question, in the decision inbox.

Public surface (Clause ②: yes) — exact signature deltas, and why grep export cannot see them

AuthManager is barrel-public (packages/plugins/plugin-auth/src/index.ts: export * from './auth-manager.js'), so every public member of the class is on the package's public surface even though nothing in this diff adds a new top-level export statement — git diff -U0 origin/main... | grep -E '^\+\s*export ' returns zero output, and reading only that grep would wrongly conclude Clause ② is no. The actual surface change is on the SIGNATURES of two already-exported class members, which a line-prefix grep for the export keyword structurally cannot see (the keyword lives once, on the class AuthManager declaration itself, not repeated per member):

MemberBefore (origin/main)After (this PR)
stageOperatorProvisioningstageOperatorProvisioning(email: string): voidstageOperatorProvisioning(email: string): string
isOperatorProvisioningisOperatorProvisioning(email: unknown): booleanisOperatorProvisioning(email: unknown, ticket?: unknown): boolean
(new member)static readonly OPERATOR_PROVISIONING_TICKET_FIELD: string
  • stageOperatorProvisioning's return type widens voidstring — additive; any existing caller that ignores the return value is unaffected, but a caller that TYPE-CHECKS against void (unusual, but possible in a strict wrapper) would need to update.
  • isOperatorProvisioning gains an optional second parameter — additive at the call site (omitting it now always reads "not provisioning," the safe default), but it is a signature widening on a public method, which is exactly what Clause ② asks about regardless of backward-compatibility.
  • One new static member is added to the public class surface.

Graded minor in the changeset (confirmed: .changeset/operator-provisioning-ticket-binding.md frontmatter is "@objectstack/plugin-auth": minor — this repo's convention for a barrel-public signature change, not patch). needs:contract-review is on both carriers: issue #14373 (pre-hung by the claim comment) and this PR (confirmed present via a read-back after adding it).

Docs drift — three pages checked, none touched

Per PM's pointer, checked whether this diff falsifies any sentence on the three pages that reach AuthManager as a symbol:

  • content/docs/kernel/contracts/auth-service.mdx — grepped for AuthManager/stageOperatorProvisioning/isOperatorProvisioning/operator provisioning/dev-admin/seed. One hit: a line contrasting api vs getApi() naming AuthManager generically ("the shipped plugin-auth registers an AuthManager, which has no api member at all"). Still true — this diff adds no api member and does not touch that contrast. No change needed.
  • content/docs/kernel/services-checklist.mdx — one hit, a table row: "Implementation: AuthPluginAuthManager (@objectstack/plugin-auth, built on better-auth)". Still true — AuthManager is still the implementation, still built on better-auth; this diff changes neither fact. No change needed.
  • content/docs/permissions/authentication.mdx — the page that documents the public auth surface in the most detail (bootstrap-status, sign-up/email, etc.). Read the full sign-up/email entry and the "First-run bootstrap status" section (#first-run-bootstrap-status): both describe the PUBLIC bootstrap probe (isBootstrapCreation, "does this environment have no human user yet") and the generic sign-up/email endpoint description ("Register new user"). Neither mentions the dev-admin seed's internal operator-provisioning ticket mechanism (stageOperatorProvisioning/isOperatorProvisioning) at all — that mechanism is deliberately a SEPARATE, undocumented-by-design internal seam (see the [#14157] docblock: "moves no public door"), distinct from the bootstrap probe this page does document. No sentence on this page asserts anything about ticket keying that this diff changes. No change needed.

Verdict for all three: the change is internal to the dev-admin seed's ticket-keying implementation detail: none of the three pages' contracts (service implementation identity, public bootstrap probe semantics, public endpoint list) assert anything this diff makes false.

Tests

Real end-to-end suite (dev-admin-seed-credential-gate.test.ts, real ObjectQL engine + real better-auth over :memory: SQLite). Existing cases ⓪–⑧ stay green unmodified; two new cases pin the fix:

  • : stages a ticket directly (opening the exact window maybeSeedDevAdmin opens), fires a stranger's POST /sign-up/email for the SAME address with NO ticket field while the window is open — asserts 403 SELF_REGISTRATION_CLOSED and zero accounts created — then, in the SAME still-open window, fires the correctly-bound call as a positive control and asserts it IS admitted, its credential actually authenticates via sign-in/email.
  • ⑨(b): same setup, but the stranger supplies a wrong-but-present ticket value — asserts the same refusal (not merely "missing" but "wrong" is refused).

pnpm --filter @objectstack/plugin-auth test: 91 test files passed, 1864 tests passed, on the merged tree at 4a07e99c4. pnpm --filter @objectstack/plugin-auth typecheck: clean.

Ablation (proves the pin actually depends on the binding)

Reverted isOperatorProvisioning to email-only matching. Mutation proof, redone with a non-comment marker (a globalThis write, since a // comment is stripped by esbuild and cannot prove the mutation reached the built artifact):

  • Source: blob hash 66ba6e5c…6b477b29…; injected marker token present at 2 occurrences (write + implicit read via grep), 0 remaining occurrences of the original ticket === entry.ticket comparison line.
  • Rebuilt dist/index.mjs; the marker token is present in the BUILT output (DIST_MARKER_COUNT=1) — confirms the mutation reached the code the tests actually load, not just the source.
  • Reran the file's suite: Tests 2 failed | 13 passed (15), with ⑨ and ⑨(b) specifically red (the stranger is admitted once the ticket check is gone).
  • Restore via trap ... EXIT INT TERM: git diff HEAD -- auth-manager.ts byte count 0 after restore. Rebuilt again afterward: marker count 0 in the rebuilt dist/index.mjs, and the file's suite is back to 15 passed (15)dist/ is confirmed returned to the real implementation, not left mutated.

Gate family (re-derived from the actual diff at HEAD 4a07e99c4, scripts/pm/dispatch-gates.mjs)

38 commands derived. 35 exit 0. The remaining 3 are explicitly NOT MEASURED (their own output says so, exit code 3, distinct from a finding's exit 1), each requiring a full-monorepo dist/ closure this scoped local run does not build — CI's lint.yml builds that closure and runs these for real:

  • check-test-completeness — no local turbo run test log to parse.
  • check:dual-build-cjs-loads — PREREQUISITE NOT MET, 38 unrelated packages have no local dist/.
  • check:type-check-debt --re-measure — PREREQUISITE NOT MET, 28 workspace deps have no built type entry point locally.

None of the 3 touch plugin-auth, auth-manager.ts, or auth-plugin.ts in their own diagnostics.


🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

…ket to more than the seed address (#14373)
`stageOperatorProvisioning`/`isOperatorProvisioning` peeked the ticket by
email alone. The address is not a secret (documented default, printed on the
boot banner), so a stranger's own concurrent sign-up for the same address,
arriving while the ticket was staged, could satisfy an email-only peek at
both admission seams and be admitted as the operator class — and since a
unique-email constraint lets only one concurrent signUpEmail land, the
stranger's row could become the account at that address.
`stageOperatorProvisioning` now also generates a random ticket value and
returns it; the caller threads it into the same signUpEmail call's body
under the new `AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD` key.
`isOperatorProvisioning` now requires an exact match on both email and
ticket — a missing or mismatched ticket reads as "not provisioning", no
email-only fallback.
Disposition 3 only, per triage's ruling (14373#issuecomment-5504337158):
disposition 1 (document the trust assumption) is already landed and
untouched; disposition 2 (move the method off the barrel-public surface) is
a published-surface removal, the human floor, not touched here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 9 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx(via AuthManager (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via AuthManager (symbol, a top-level class))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2dc527df332cfdee110aa7ce0d5505506e2ea0ca — the merge of head 4a07e99c455a23037cb81900bc7fbeacebd214d8 into base 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2dc527df332cfdee110aa7ce0d5505506e2ea0ca && git checkout 2dc527df332cfdee110aa7ce0d5505506e2ea0ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 4a07e99c455a23037cb81900bc7fbeacebd214d8 && git checkout -B drift-repro 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 && git merge --no-ff 4a07e99c455a23037cb81900bc7fbeacebd214d8
node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review September 2, 2026 23:50
@os-project-manager
os-project-manager added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 7c342f4Sep 3, 2026
48 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14373-operator-ticket-binding branch September 3, 2026 01:32
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

3 participants

@os-sales@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-auth): bind the dev-admin seed's operator-provisioning ticket to more than the seed address (#14373) - #14730

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-14373-operator-ticket-binding
Sep 3, 2026
Merged

fix(plugin-auth): bind the dev-admin seed's operator-provisioning ticket to more than the seed address (#14373)#14730
os-project-manager merged 2 commits into
mainfrom
claude/issue-14373-operator-ticket-binding

Conversation

@os-sales

@os-salesos-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14373

What changed

Disposition 3 only, per triage's ruling (14373#issuecomment-5504337158) and the claim comment's carry-forward: bind the dev-admin seed's operator-provisioning ticket to more than the seed address.

AuthManager.stageOperatorProvisioning(email) staged its ticket keyed on email.trim().toLowerCase() alone. The address is not a secretadmin@objectos.ai is the documented default and the boot banner prints it (with the password) once the seed completes — so "the address is the operator's own" did not narrow the attacker set the way it would for an unguessable value. A stranger's own concurrent POST /sign-up/email for that same address, arriving while the ticket was staged, would satisfy an email-only peek at BOTH admission seams (the disableSignUp before-hook at auth-manager.ts:~2069 and validateAudienceAdmission's creationClass computation at ~3940) and be admitted as the operator class too — and since a unique-email constraint lets only one of the two concurrent signUpEmail calls actually land, a stranger who won that race would not merely read as the operator, their row would become the account at that address. The safety this rested on — "milliseconds, and dev-only" — was true today, but both are properties of the caller, not of what the ticket asserted.

stageOperatorProvisioning(email) now also generates a random, unguessable ticket value (128 bits from WebCrypto's getRandomValues, matching the existing resolvePasswordHasher WebContainer-salt convention) and returns it. AuthPlugin.maybeSeedDevAdmin threads that value into the SAME signUpEmail call's body, under a new AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD key — an unrecognized key that better-auth's signUpEmailBodySchema (z.object({...}).and(z.record(z.string(), z.any()))) lets ride through untouched, so it never becomes a declared sys_user field or a DB column. isOperatorProvisioning(email, ticket) now requires an exact match on both; a missing, wrong-typed, or mismatched ticket reads as "not provisioning" — same as no ticket at all, no email-only fallback.

This converts a timing argument into a structural one: admission now asks "did THIS process's own boot command make THIS exact call", not "does the address match". A stranger's request carries no value that was ever transmitted anywhere for them to replay, however precisely they time the window.

Why the nonce, not name

Triage offered two implementation routes: bind to the seed's own name, or thread a nonce through the sign-up body. I measured both against the actual threat (a concurrent stranger, not merely "harder to guess"):

  • name defaults to 'Dev Admin' (OS_SEED_ADMIN_NAME env override), and — unlike email/password — is not printed by the boot banner (AuthManager.devSeedResult only carries {email, password}; confirmed by reading serve.ts's banner code and format.ts). So it is not observable from a live deployment's own output. But this is an open-source codebase: 'Dev Admin' is exactly as discoverable by reading auth-plugin.ts as admin@objectos.ai is by reading walled-owner-verification-path.ts. Binding to name alone would narrow the window against an attacker who knows the printed email but hasn't read the source — a real but narrow-obscurity improvement, not a structural one. Per the card's own instruction, I did not write "harder to guess" as "impossible."
  • The random ticket value is generated in-process via WebCrypto and never transmitted anywhere — not printed, not logged, not derivable from source (it's per-boot, not a fixed default). No amount of source-reading recovers it. That is the structural fix the triage comment was pointing at ("converts a timing argument into a structural one").

Is the binding value a secret? (asked directly)

Yes, and that is the load-bearing difference from the seed address. Traced end to end, what it travels with and where it can surface:

  • Minted: AuthManager.generateOperatorProvisioningTicket() — 16 bytes from crypto.getRandomValues, hex-encoded, held only in the local ticket variable and in the pendingOperatorProvisioning in-memory Map (keyed by email, TTL-pruned, cleared in a finally). Nothing durable — no table, no file, no env var.
  • Carried: as one extra JSON field, AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD (__osOperatorProvisioningTicket), riding inside the same POST /sign-up/email request bodyAuthPlugin.maybeSeedDevAdmin was always going to send — alongside email, password, and name. It does not get its own call or its own channel.
  • Consumed: better-auth's signUpEmailBodySchema is z.object({...}).and(z.record(z.string(), z.any())) — the declared fields get validated and become the sys_user/sys_account rows; the catch-all lets the ticket field ride through the parse untouched, but it is read ONLY by isOperatorProvisioning's comparison and is never assigned to any column, never passed to internalAdapter.createUser.
  • Printed? No. AuthManager.devSeedResult — the only thing the boot banner reads (see serve.ts's banner code, format.ts) — carries exactly {email, password}. The ticket is never part of that struct, so it cannot reach stdout, a log line, or any other deployment-observable output by any path this diff touches.

So the fix does not make the window narrower (a better guess still fails against the seed address) — it removes the window's public observable entirely: there is nothing about this deployment's own output that a stranger could read to learn the value, at any point in its lifetime. Contrast with binding to name (above), which would only have been "harder to guess," since name defaults to a fixed, source-readable string.

Region split held

Per the claim comment's declared region (origin/main line numbers): auth-manager.ts:2070/:3810/:3912-3914/:4233/:4238/:4251; auth-plugin.ts:1874.

Corrected measurement (git diff -U0 origin/main...HEAD, exact changed old-side lines, zero context padding — the first PR-body draft measured this with -U3-style context bleed and understated the gap at "22 lines"; this is the precise figure and it supersedes that one): auth-manager.ts touches old-side lines 2068,2070 / 3811,3813,3821 / 3890-3893,3900,3914 / 4231,4233,4235,4242,4249,4251,4254; auth-plugin.ts touches 1874,1876. Against PR #14600's declared hunks (auth-manager.ts 14/990/1336/1368/1399/1425/2105/2921/3067/3089/4327-4337/4340/4354/4554-4591; auth-plugin.ts 784-788 only): closest pairs are 2070 vs 2105 = 35 lines and 4254 vs 4327 = 73 lines in auth-manager.ts, and ~1090 lines apart in auth-plugin.ts (784-788 vs 1874-1876). This matches the PM seat's independent re-measurement exactly. Disjoint throughout; split holds.

Not in this PR (per triage's ruling)

  • Disposition 1 (document the trust assumption) — already landed on main; the JSDoc above stageOperatorProvisioning states it in full. No-op, not touched.
  • Disposition 2 (move the method off the barrel-public surface) — a published-surface removal, the human floor (删除已发布能力) with ADR-0049 enforce-or-remove discipline owed. Not a dev-agent edit.
  • Whether the bootstrap window should count humans or logins is #14349's question, in the decision inbox.

Public surface (Clause ②: yes) — exact signature deltas, and why grep export cannot see them

AuthManager is barrel-public (packages/plugins/plugin-auth/src/index.ts: export * from './auth-manager.js'), so every public member of the class is on the package's public surface even though nothing in this diff adds a new top-level export statement — git diff -U0 origin/main... | grep -E '^\+\s*export ' returns zero output, and reading only that grep would wrongly conclude Clause ② is no. The actual surface change is on the SIGNATURES of two already-exported class members, which a line-prefix grep for the export keyword structurally cannot see (the keyword lives once, on the class AuthManager declaration itself, not repeated per member):

MemberBefore (origin/main)After (this PR)
stageOperatorProvisioningstageOperatorProvisioning(email: string): voidstageOperatorProvisioning(email: string): string
isOperatorProvisioningisOperatorProvisioning(email: unknown): booleanisOperatorProvisioning(email: unknown, ticket?: unknown): boolean
(new member)static readonly OPERATOR_PROVISIONING_TICKET_FIELD: string
  • stageOperatorProvisioning's return type widens voidstring — additive; any existing caller that ignores the return value is unaffected, but a caller that TYPE-CHECKS against void (unusual, but possible in a strict wrapper) would need to update.
  • isOperatorProvisioning gains an optional second parameter — additive at the call site (omitting it now always reads "not provisioning," the safe default), but it is a signature widening on a public method, which is exactly what Clause ② asks about regardless of backward-compatibility.
  • One new static member is added to the public class surface.

Graded minor in the changeset (confirmed: .changeset/operator-provisioning-ticket-binding.md frontmatter is "@objectstack/plugin-auth": minor — this repo's convention for a barrel-public signature change, not patch). needs:contract-review is on both carriers: issue #14373 (pre-hung by the claim comment) and this PR (confirmed present via a read-back after adding it).

Docs drift — three pages checked, none touched

Per PM's pointer, checked whether this diff falsifies any sentence on the three pages that reach AuthManager as a symbol:

  • content/docs/kernel/contracts/auth-service.mdx — grepped for AuthManager/stageOperatorProvisioning/isOperatorProvisioning/operator provisioning/dev-admin/seed. One hit: a line contrasting api vs getApi() naming AuthManager generically ("the shipped plugin-auth registers an AuthManager, which has no api member at all"). Still true — this diff adds no api member and does not touch that contrast. No change needed.
  • content/docs/kernel/services-checklist.mdx — one hit, a table row: "Implementation: AuthPluginAuthManager (@objectstack/plugin-auth, built on better-auth)". Still true — AuthManager is still the implementation, still built on better-auth; this diff changes neither fact. No change needed.
  • content/docs/permissions/authentication.mdx — the page that documents the public auth surface in the most detail (bootstrap-status, sign-up/email, etc.). Read the full sign-up/email entry and the "First-run bootstrap status" section (#first-run-bootstrap-status): both describe the PUBLIC bootstrap probe (isBootstrapCreation, "does this environment have no human user yet") and the generic sign-up/email endpoint description ("Register new user"). Neither mentions the dev-admin seed's internal operator-provisioning ticket mechanism (stageOperatorProvisioning/isOperatorProvisioning) at all — that mechanism is deliberately a SEPARATE, undocumented-by-design internal seam (see the [#14157] docblock: "moves no public door"), distinct from the bootstrap probe this page does document. No sentence on this page asserts anything about ticket keying that this diff changes. No change needed.

Verdict for all three: the change is internal to the dev-admin seed's ticket-keying implementation detail: none of the three pages' contracts (service implementation identity, public bootstrap probe semantics, public endpoint list) assert anything this diff makes false.

Tests

Real end-to-end suite (dev-admin-seed-credential-gate.test.ts, real ObjectQL engine + real better-auth over :memory: SQLite). Existing cases ⓪–⑧ stay green unmodified; two new cases pin the fix:

  • : stages a ticket directly (opening the exact window maybeSeedDevAdmin opens), fires a stranger's POST /sign-up/email for the SAME address with NO ticket field while the window is open — asserts 403 SELF_REGISTRATION_CLOSED and zero accounts created — then, in the SAME still-open window, fires the correctly-bound call as a positive control and asserts it IS admitted, its credential actually authenticates via sign-in/email.
  • ⑨(b): same setup, but the stranger supplies a wrong-but-present ticket value — asserts the same refusal (not merely "missing" but "wrong" is refused).

pnpm --filter @objectstack/plugin-auth test: 91 test files passed, 1864 tests passed, on the merged tree at 4a07e99c4. pnpm --filter @objectstack/plugin-auth typecheck: clean.

Ablation (proves the pin actually depends on the binding)

Reverted isOperatorProvisioning to email-only matching. Mutation proof, redone with a non-comment marker (a globalThis write, since a // comment is stripped by esbuild and cannot prove the mutation reached the built artifact):

  • Source: blob hash 66ba6e5c…6b477b29…; injected marker token present at 2 occurrences (write + implicit read via grep), 0 remaining occurrences of the original ticket === entry.ticket comparison line.
  • Rebuilt dist/index.mjs; the marker token is present in the BUILT output (DIST_MARKER_COUNT=1) — confirms the mutation reached the code the tests actually load, not just the source.
  • Reran the file's suite: Tests 2 failed | 13 passed (15), with ⑨ and ⑨(b) specifically red (the stranger is admitted once the ticket check is gone).
  • Restore via trap ... EXIT INT TERM: git diff HEAD -- auth-manager.ts byte count 0 after restore. Rebuilt again afterward: marker count 0 in the rebuilt dist/index.mjs, and the file's suite is back to 15 passed (15)dist/ is confirmed returned to the real implementation, not left mutated.

Gate family (re-derived from the actual diff at HEAD 4a07e99c4, scripts/pm/dispatch-gates.mjs)

38 commands derived. 35 exit 0. The remaining 3 are explicitly NOT MEASURED (their own output says so, exit code 3, distinct from a finding's exit 1), each requiring a full-monorepo dist/ closure this scoped local run does not build — CI's lint.yml builds that closure and runs these for real:

  • check-test-completeness — no local turbo run test log to parse.
  • check:dual-build-cjs-loads — PREREQUISITE NOT MET, 38 unrelated packages have no local dist/.
  • check:type-check-debt --re-measure — PREREQUISITE NOT MET, 28 workspace deps have no built type entry point locally.

None of the 3 touch plugin-auth, auth-manager.ts, or auth-plugin.ts in their own diagnostics.


🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

…ket to more than the seed address (#14373)
`stageOperatorProvisioning`/`isOperatorProvisioning` peeked the ticket by
email alone. The address is not a secret (documented default, printed on the
boot banner), so a stranger's own concurrent sign-up for the same address,
arriving while the ticket was staged, could satisfy an email-only peek at
both admission seams and be admitted as the operator class — and since a
unique-email constraint lets only one concurrent signUpEmail land, the
stranger's row could become the account at that address.
`stageOperatorProvisioning` now also generates a random ticket value and
returns it; the caller threads it into the same signUpEmail call's body
under the new `AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD` key.
`isOperatorProvisioning` now requires an exact match on both email and
ticket — a missing or mismatched ticket reads as "not provisioning", no
email-only fallback.
Disposition 3 only, per triage's ruling (14373#issuecomment-5504337158):
disposition 1 (document the trust assumption) is already landed and
untouched; disposition 2 (move the method off the barrel-public surface) is
a published-surface removal, the human floor, not touched here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 9 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx(via AuthManager (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via AuthManager (symbol, a top-level class))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2dc527df332cfdee110aa7ce0d5505506e2ea0ca — the merge of head 4a07e99c455a23037cb81900bc7fbeacebd214d8 into base 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2dc527df332cfdee110aa7ce0d5505506e2ea0ca && git checkout 2dc527df332cfdee110aa7ce0d5505506e2ea0ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 4a07e99c455a23037cb81900bc7fbeacebd214d8 && git checkout -B drift-repro 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 && git merge --no-ff 4a07e99c455a23037cb81900bc7fbeacebd214d8
node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review September 2, 2026 23:50
@os-project-manager
os-project-manager added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 7c342f4Sep 3, 2026
48 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14373-operator-ticket-binding branch September 3, 2026 01:32
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

3 participants

@os-sales@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-auth): bind the dev-admin seed's operator-provisioning ticket to more than the seed address (#14373) - #14730

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-14373-operator-ticket-binding
Sep 3, 2026
Merged

fix(plugin-auth): bind the dev-admin seed's operator-provisioning ticket to more than the seed address (#14373)#14730
os-project-manager merged 2 commits into
mainfrom
claude/issue-14373-operator-ticket-binding

Conversation

@os-sales

@os-salesos-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14373

What changed

Disposition 3 only, per triage's ruling (14373#issuecomment-5504337158) and the claim comment's carry-forward: bind the dev-admin seed's operator-provisioning ticket to more than the seed address.

AuthManager.stageOperatorProvisioning(email) staged its ticket keyed on email.trim().toLowerCase() alone. The address is not a secretadmin@objectos.ai is the documented default and the boot banner prints it (with the password) once the seed completes — so "the address is the operator's own" did not narrow the attacker set the way it would for an unguessable value. A stranger's own concurrent POST /sign-up/email for that same address, arriving while the ticket was staged, would satisfy an email-only peek at BOTH admission seams (the disableSignUp before-hook at auth-manager.ts:~2069 and validateAudienceAdmission's creationClass computation at ~3940) and be admitted as the operator class too — and since a unique-email constraint lets only one of the two concurrent signUpEmail calls actually land, a stranger who won that race would not merely read as the operator, their row would become the account at that address. The safety this rested on — "milliseconds, and dev-only" — was true today, but both are properties of the caller, not of what the ticket asserted.

stageOperatorProvisioning(email) now also generates a random, unguessable ticket value (128 bits from WebCrypto's getRandomValues, matching the existing resolvePasswordHasher WebContainer-salt convention) and returns it. AuthPlugin.maybeSeedDevAdmin threads that value into the SAME signUpEmail call's body, under a new AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD key — an unrecognized key that better-auth's signUpEmailBodySchema (z.object({...}).and(z.record(z.string(), z.any()))) lets ride through untouched, so it never becomes a declared sys_user field or a DB column. isOperatorProvisioning(email, ticket) now requires an exact match on both; a missing, wrong-typed, or mismatched ticket reads as "not provisioning" — same as no ticket at all, no email-only fallback.

This converts a timing argument into a structural one: admission now asks "did THIS process's own boot command make THIS exact call", not "does the address match". A stranger's request carries no value that was ever transmitted anywhere for them to replay, however precisely they time the window.

Why the nonce, not name

Triage offered two implementation routes: bind to the seed's own name, or thread a nonce through the sign-up body. I measured both against the actual threat (a concurrent stranger, not merely "harder to guess"):

  • name defaults to 'Dev Admin' (OS_SEED_ADMIN_NAME env override), and — unlike email/password — is not printed by the boot banner (AuthManager.devSeedResult only carries {email, password}; confirmed by reading serve.ts's banner code and format.ts). So it is not observable from a live deployment's own output. But this is an open-source codebase: 'Dev Admin' is exactly as discoverable by reading auth-plugin.ts as admin@objectos.ai is by reading walled-owner-verification-path.ts. Binding to name alone would narrow the window against an attacker who knows the printed email but hasn't read the source — a real but narrow-obscurity improvement, not a structural one. Per the card's own instruction, I did not write "harder to guess" as "impossible."
  • The random ticket value is generated in-process via WebCrypto and never transmitted anywhere — not printed, not logged, not derivable from source (it's per-boot, not a fixed default). No amount of source-reading recovers it. That is the structural fix the triage comment was pointing at ("converts a timing argument into a structural one").

Is the binding value a secret? (asked directly)

Yes, and that is the load-bearing difference from the seed address. Traced end to end, what it travels with and where it can surface:

  • Minted: AuthManager.generateOperatorProvisioningTicket() — 16 bytes from crypto.getRandomValues, hex-encoded, held only in the local ticket variable and in the pendingOperatorProvisioning in-memory Map (keyed by email, TTL-pruned, cleared in a finally). Nothing durable — no table, no file, no env var.
  • Carried: as one extra JSON field, AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD (__osOperatorProvisioningTicket), riding inside the same POST /sign-up/email request bodyAuthPlugin.maybeSeedDevAdmin was always going to send — alongside email, password, and name. It does not get its own call or its own channel.
  • Consumed: better-auth's signUpEmailBodySchema is z.object({...}).and(z.record(z.string(), z.any())) — the declared fields get validated and become the sys_user/sys_account rows; the catch-all lets the ticket field ride through the parse untouched, but it is read ONLY by isOperatorProvisioning's comparison and is never assigned to any column, never passed to internalAdapter.createUser.
  • Printed? No. AuthManager.devSeedResult — the only thing the boot banner reads (see serve.ts's banner code, format.ts) — carries exactly {email, password}. The ticket is never part of that struct, so it cannot reach stdout, a log line, or any other deployment-observable output by any path this diff touches.

So the fix does not make the window narrower (a better guess still fails against the seed address) — it removes the window's public observable entirely: there is nothing about this deployment's own output that a stranger could read to learn the value, at any point in its lifetime. Contrast with binding to name (above), which would only have been "harder to guess," since name defaults to a fixed, source-readable string.

Region split held

Per the claim comment's declared region (origin/main line numbers): auth-manager.ts:2070/:3810/:3912-3914/:4233/:4238/:4251; auth-plugin.ts:1874.

Corrected measurement (git diff -U0 origin/main...HEAD, exact changed old-side lines, zero context padding — the first PR-body draft measured this with -U3-style context bleed and understated the gap at "22 lines"; this is the precise figure and it supersedes that one): auth-manager.ts touches old-side lines 2068,2070 / 3811,3813,3821 / 3890-3893,3900,3914 / 4231,4233,4235,4242,4249,4251,4254; auth-plugin.ts touches 1874,1876. Against PR #14600's declared hunks (auth-manager.ts 14/990/1336/1368/1399/1425/2105/2921/3067/3089/4327-4337/4340/4354/4554-4591; auth-plugin.ts 784-788 only): closest pairs are 2070 vs 2105 = 35 lines and 4254 vs 4327 = 73 lines in auth-manager.ts, and ~1090 lines apart in auth-plugin.ts (784-788 vs 1874-1876). This matches the PM seat's independent re-measurement exactly. Disjoint throughout; split holds.

Not in this PR (per triage's ruling)

  • Disposition 1 (document the trust assumption) — already landed on main; the JSDoc above stageOperatorProvisioning states it in full. No-op, not touched.
  • Disposition 2 (move the method off the barrel-public surface) — a published-surface removal, the human floor (删除已发布能力) with ADR-0049 enforce-or-remove discipline owed. Not a dev-agent edit.
  • Whether the bootstrap window should count humans or logins is #14349's question, in the decision inbox.

Public surface (Clause ②: yes) — exact signature deltas, and why grep export cannot see them

AuthManager is barrel-public (packages/plugins/plugin-auth/src/index.ts: export * from './auth-manager.js'), so every public member of the class is on the package's public surface even though nothing in this diff adds a new top-level export statement — git diff -U0 origin/main... | grep -E '^\+\s*export ' returns zero output, and reading only that grep would wrongly conclude Clause ② is no. The actual surface change is on the SIGNATURES of two already-exported class members, which a line-prefix grep for the export keyword structurally cannot see (the keyword lives once, on the class AuthManager declaration itself, not repeated per member):

MemberBefore (origin/main)After (this PR)
stageOperatorProvisioningstageOperatorProvisioning(email: string): voidstageOperatorProvisioning(email: string): string
isOperatorProvisioningisOperatorProvisioning(email: unknown): booleanisOperatorProvisioning(email: unknown, ticket?: unknown): boolean
(new member)static readonly OPERATOR_PROVISIONING_TICKET_FIELD: string
  • stageOperatorProvisioning's return type widens voidstring — additive; any existing caller that ignores the return value is unaffected, but a caller that TYPE-CHECKS against void (unusual, but possible in a strict wrapper) would need to update.
  • isOperatorProvisioning gains an optional second parameter — additive at the call site (omitting it now always reads "not provisioning," the safe default), but it is a signature widening on a public method, which is exactly what Clause ② asks about regardless of backward-compatibility.
  • One new static member is added to the public class surface.

Graded minor in the changeset (confirmed: .changeset/operator-provisioning-ticket-binding.md frontmatter is "@objectstack/plugin-auth": minor — this repo's convention for a barrel-public signature change, not patch). needs:contract-review is on both carriers: issue #14373 (pre-hung by the claim comment) and this PR (confirmed present via a read-back after adding it).

Docs drift — three pages checked, none touched

Per PM's pointer, checked whether this diff falsifies any sentence on the three pages that reach AuthManager as a symbol:

  • content/docs/kernel/contracts/auth-service.mdx — grepped for AuthManager/stageOperatorProvisioning/isOperatorProvisioning/operator provisioning/dev-admin/seed. One hit: a line contrasting api vs getApi() naming AuthManager generically ("the shipped plugin-auth registers an AuthManager, which has no api member at all"). Still true — this diff adds no api member and does not touch that contrast. No change needed.
  • content/docs/kernel/services-checklist.mdx — one hit, a table row: "Implementation: AuthPluginAuthManager (@objectstack/plugin-auth, built on better-auth)". Still true — AuthManager is still the implementation, still built on better-auth; this diff changes neither fact. No change needed.
  • content/docs/permissions/authentication.mdx — the page that documents the public auth surface in the most detail (bootstrap-status, sign-up/email, etc.). Read the full sign-up/email entry and the "First-run bootstrap status" section (#first-run-bootstrap-status): both describe the PUBLIC bootstrap probe (isBootstrapCreation, "does this environment have no human user yet") and the generic sign-up/email endpoint description ("Register new user"). Neither mentions the dev-admin seed's internal operator-provisioning ticket mechanism (stageOperatorProvisioning/isOperatorProvisioning) at all — that mechanism is deliberately a SEPARATE, undocumented-by-design internal seam (see the [#14157] docblock: "moves no public door"), distinct from the bootstrap probe this page does document. No sentence on this page asserts anything about ticket keying that this diff changes. No change needed.

Verdict for all three: the change is internal to the dev-admin seed's ticket-keying implementation detail: none of the three pages' contracts (service implementation identity, public bootstrap probe semantics, public endpoint list) assert anything this diff makes false.

Tests

Real end-to-end suite (dev-admin-seed-credential-gate.test.ts, real ObjectQL engine + real better-auth over :memory: SQLite). Existing cases ⓪–⑧ stay green unmodified; two new cases pin the fix:

  • : stages a ticket directly (opening the exact window maybeSeedDevAdmin opens), fires a stranger's POST /sign-up/email for the SAME address with NO ticket field while the window is open — asserts 403 SELF_REGISTRATION_CLOSED and zero accounts created — then, in the SAME still-open window, fires the correctly-bound call as a positive control and asserts it IS admitted, its credential actually authenticates via sign-in/email.
  • ⑨(b): same setup, but the stranger supplies a wrong-but-present ticket value — asserts the same refusal (not merely "missing" but "wrong" is refused).

pnpm --filter @objectstack/plugin-auth test: 91 test files passed, 1864 tests passed, on the merged tree at 4a07e99c4. pnpm --filter @objectstack/plugin-auth typecheck: clean.

Ablation (proves the pin actually depends on the binding)

Reverted isOperatorProvisioning to email-only matching. Mutation proof, redone with a non-comment marker (a globalThis write, since a // comment is stripped by esbuild and cannot prove the mutation reached the built artifact):

  • Source: blob hash 66ba6e5c…6b477b29…; injected marker token present at 2 occurrences (write + implicit read via grep), 0 remaining occurrences of the original ticket === entry.ticket comparison line.
  • Rebuilt dist/index.mjs; the marker token is present in the BUILT output (DIST_MARKER_COUNT=1) — confirms the mutation reached the code the tests actually load, not just the source.
  • Reran the file's suite: Tests 2 failed | 13 passed (15), with ⑨ and ⑨(b) specifically red (the stranger is admitted once the ticket check is gone).
  • Restore via trap ... EXIT INT TERM: git diff HEAD -- auth-manager.ts byte count 0 after restore. Rebuilt again afterward: marker count 0 in the rebuilt dist/index.mjs, and the file's suite is back to 15 passed (15)dist/ is confirmed returned to the real implementation, not left mutated.

Gate family (re-derived from the actual diff at HEAD 4a07e99c4, scripts/pm/dispatch-gates.mjs)

38 commands derived. 35 exit 0. The remaining 3 are explicitly NOT MEASURED (their own output says so, exit code 3, distinct from a finding's exit 1), each requiring a full-monorepo dist/ closure this scoped local run does not build — CI's lint.yml builds that closure and runs these for real:

  • check-test-completeness — no local turbo run test log to parse.
  • check:dual-build-cjs-loads — PREREQUISITE NOT MET, 38 unrelated packages have no local dist/.
  • check:type-check-debt --re-measure — PREREQUISITE NOT MET, 28 workspace deps have no built type entry point locally.

None of the 3 touch plugin-auth, auth-manager.ts, or auth-plugin.ts in their own diagnostics.


🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

…ket to more than the seed address (#14373)
`stageOperatorProvisioning`/`isOperatorProvisioning` peeked the ticket by
email alone. The address is not a secret (documented default, printed on the
boot banner), so a stranger's own concurrent sign-up for the same address,
arriving while the ticket was staged, could satisfy an email-only peek at
both admission seams and be admitted as the operator class — and since a
unique-email constraint lets only one concurrent signUpEmail land, the
stranger's row could become the account at that address.
`stageOperatorProvisioning` now also generates a random ticket value and
returns it; the caller threads it into the same signUpEmail call's body
under the new `AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD` key.
`isOperatorProvisioning` now requires an exact match on both email and
ticket — a missing or mismatched ticket reads as "not provisioning", no
email-only fallback.
Disposition 3 only, per triage's ruling (14373#issuecomment-5504337158):
disposition 1 (document the trust assumption) is already landed and
untouched; disposition 2 (move the method off the barrel-public surface) is
a published-surface removal, the human floor, not touched here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 9 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx(via AuthManager (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via AuthManager (symbol, a top-level class))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2dc527df332cfdee110aa7ce0d5505506e2ea0ca — the merge of head 4a07e99c455a23037cb81900bc7fbeacebd214d8 into base 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2dc527df332cfdee110aa7ce0d5505506e2ea0ca && git checkout 2dc527df332cfdee110aa7ce0d5505506e2ea0ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 4a07e99c455a23037cb81900bc7fbeacebd214d8 && git checkout -B drift-repro 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 && git merge --no-ff 4a07e99c455a23037cb81900bc7fbeacebd214d8
node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review September 2, 2026 23:50
@os-project-manager
os-project-manager added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 7c342f4Sep 3, 2026
48 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14373-operator-ticket-binding branch September 3, 2026 01:32
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

3 participants

@os-sales@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(plugin-auth): bind the dev-admin seed's operator-provisioning ticket to more than the seed address (#14373) - #14730

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-14373-operator-ticket-binding
Sep 3, 2026
Merged

fix(plugin-auth): bind the dev-admin seed's operator-provisioning ticket to more than the seed address (#14373)#14730
os-project-manager merged 2 commits into
mainfrom
claude/issue-14373-operator-ticket-binding

Conversation

@os-sales

@os-salesos-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14373

What changed

Disposition 3 only, per triage's ruling (14373#issuecomment-5504337158) and the claim comment's carry-forward: bind the dev-admin seed's operator-provisioning ticket to more than the seed address.

AuthManager.stageOperatorProvisioning(email) staged its ticket keyed on email.trim().toLowerCase() alone. The address is not a secretadmin@objectos.ai is the documented default and the boot banner prints it (with the password) once the seed completes — so "the address is the operator's own" did not narrow the attacker set the way it would for an unguessable value. A stranger's own concurrent POST /sign-up/email for that same address, arriving while the ticket was staged, would satisfy an email-only peek at BOTH admission seams (the disableSignUp before-hook at auth-manager.ts:~2069 and validateAudienceAdmission's creationClass computation at ~3940) and be admitted as the operator class too — and since a unique-email constraint lets only one of the two concurrent signUpEmail calls actually land, a stranger who won that race would not merely read as the operator, their row would become the account at that address. The safety this rested on — "milliseconds, and dev-only" — was true today, but both are properties of the caller, not of what the ticket asserted.

stageOperatorProvisioning(email) now also generates a random, unguessable ticket value (128 bits from WebCrypto's getRandomValues, matching the existing resolvePasswordHasher WebContainer-salt convention) and returns it. AuthPlugin.maybeSeedDevAdmin threads that value into the SAME signUpEmail call's body, under a new AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD key — an unrecognized key that better-auth's signUpEmailBodySchema (z.object({...}).and(z.record(z.string(), z.any()))) lets ride through untouched, so it never becomes a declared sys_user field or a DB column. isOperatorProvisioning(email, ticket) now requires an exact match on both; a missing, wrong-typed, or mismatched ticket reads as "not provisioning" — same as no ticket at all, no email-only fallback.

This converts a timing argument into a structural one: admission now asks "did THIS process's own boot command make THIS exact call", not "does the address match". A stranger's request carries no value that was ever transmitted anywhere for them to replay, however precisely they time the window.

Why the nonce, not name

Triage offered two implementation routes: bind to the seed's own name, or thread a nonce through the sign-up body. I measured both against the actual threat (a concurrent stranger, not merely "harder to guess"):

  • name defaults to 'Dev Admin' (OS_SEED_ADMIN_NAME env override), and — unlike email/password — is not printed by the boot banner (AuthManager.devSeedResult only carries {email, password}; confirmed by reading serve.ts's banner code and format.ts). So it is not observable from a live deployment's own output. But this is an open-source codebase: 'Dev Admin' is exactly as discoverable by reading auth-plugin.ts as admin@objectos.ai is by reading walled-owner-verification-path.ts. Binding to name alone would narrow the window against an attacker who knows the printed email but hasn't read the source — a real but narrow-obscurity improvement, not a structural one. Per the card's own instruction, I did not write "harder to guess" as "impossible."
  • The random ticket value is generated in-process via WebCrypto and never transmitted anywhere — not printed, not logged, not derivable from source (it's per-boot, not a fixed default). No amount of source-reading recovers it. That is the structural fix the triage comment was pointing at ("converts a timing argument into a structural one").

Is the binding value a secret? (asked directly)

Yes, and that is the load-bearing difference from the seed address. Traced end to end, what it travels with and where it can surface:

  • Minted: AuthManager.generateOperatorProvisioningTicket() — 16 bytes from crypto.getRandomValues, hex-encoded, held only in the local ticket variable and in the pendingOperatorProvisioning in-memory Map (keyed by email, TTL-pruned, cleared in a finally). Nothing durable — no table, no file, no env var.
  • Carried: as one extra JSON field, AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD (__osOperatorProvisioningTicket), riding inside the same POST /sign-up/email request bodyAuthPlugin.maybeSeedDevAdmin was always going to send — alongside email, password, and name. It does not get its own call or its own channel.
  • Consumed: better-auth's signUpEmailBodySchema is z.object({...}).and(z.record(z.string(), z.any())) — the declared fields get validated and become the sys_user/sys_account rows; the catch-all lets the ticket field ride through the parse untouched, but it is read ONLY by isOperatorProvisioning's comparison and is never assigned to any column, never passed to internalAdapter.createUser.
  • Printed? No. AuthManager.devSeedResult — the only thing the boot banner reads (see serve.ts's banner code, format.ts) — carries exactly {email, password}. The ticket is never part of that struct, so it cannot reach stdout, a log line, or any other deployment-observable output by any path this diff touches.

So the fix does not make the window narrower (a better guess still fails against the seed address) — it removes the window's public observable entirely: there is nothing about this deployment's own output that a stranger could read to learn the value, at any point in its lifetime. Contrast with binding to name (above), which would only have been "harder to guess," since name defaults to a fixed, source-readable string.

Region split held

Per the claim comment's declared region (origin/main line numbers): auth-manager.ts:2070/:3810/:3912-3914/:4233/:4238/:4251; auth-plugin.ts:1874.

Corrected measurement (git diff -U0 origin/main...HEAD, exact changed old-side lines, zero context padding — the first PR-body draft measured this with -U3-style context bleed and understated the gap at "22 lines"; this is the precise figure and it supersedes that one): auth-manager.ts touches old-side lines 2068,2070 / 3811,3813,3821 / 3890-3893,3900,3914 / 4231,4233,4235,4242,4249,4251,4254; auth-plugin.ts touches 1874,1876. Against PR #14600's declared hunks (auth-manager.ts 14/990/1336/1368/1399/1425/2105/2921/3067/3089/4327-4337/4340/4354/4554-4591; auth-plugin.ts 784-788 only): closest pairs are 2070 vs 2105 = 35 lines and 4254 vs 4327 = 73 lines in auth-manager.ts, and ~1090 lines apart in auth-plugin.ts (784-788 vs 1874-1876). This matches the PM seat's independent re-measurement exactly. Disjoint throughout; split holds.

Not in this PR (per triage's ruling)

  • Disposition 1 (document the trust assumption) — already landed on main; the JSDoc above stageOperatorProvisioning states it in full. No-op, not touched.
  • Disposition 2 (move the method off the barrel-public surface) — a published-surface removal, the human floor (删除已发布能力) with ADR-0049 enforce-or-remove discipline owed. Not a dev-agent edit.
  • Whether the bootstrap window should count humans or logins is #14349's question, in the decision inbox.

Public surface (Clause ②: yes) — exact signature deltas, and why grep export cannot see them

AuthManager is barrel-public (packages/plugins/plugin-auth/src/index.ts: export * from './auth-manager.js'), so every public member of the class is on the package's public surface even though nothing in this diff adds a new top-level export statement — git diff -U0 origin/main... | grep -E '^\+\s*export ' returns zero output, and reading only that grep would wrongly conclude Clause ② is no. The actual surface change is on the SIGNATURES of two already-exported class members, which a line-prefix grep for the export keyword structurally cannot see (the keyword lives once, on the class AuthManager declaration itself, not repeated per member):

MemberBefore (origin/main)After (this PR)
stageOperatorProvisioningstageOperatorProvisioning(email: string): voidstageOperatorProvisioning(email: string): string
isOperatorProvisioningisOperatorProvisioning(email: unknown): booleanisOperatorProvisioning(email: unknown, ticket?: unknown): boolean
(new member)static readonly OPERATOR_PROVISIONING_TICKET_FIELD: string
  • stageOperatorProvisioning's return type widens voidstring — additive; any existing caller that ignores the return value is unaffected, but a caller that TYPE-CHECKS against void (unusual, but possible in a strict wrapper) would need to update.
  • isOperatorProvisioning gains an optional second parameter — additive at the call site (omitting it now always reads "not provisioning," the safe default), but it is a signature widening on a public method, which is exactly what Clause ② asks about regardless of backward-compatibility.
  • One new static member is added to the public class surface.

Graded minor in the changeset (confirmed: .changeset/operator-provisioning-ticket-binding.md frontmatter is "@objectstack/plugin-auth": minor — this repo's convention for a barrel-public signature change, not patch). needs:contract-review is on both carriers: issue #14373 (pre-hung by the claim comment) and this PR (confirmed present via a read-back after adding it).

Docs drift — three pages checked, none touched

Per PM's pointer, checked whether this diff falsifies any sentence on the three pages that reach AuthManager as a symbol:

  • content/docs/kernel/contracts/auth-service.mdx — grepped for AuthManager/stageOperatorProvisioning/isOperatorProvisioning/operator provisioning/dev-admin/seed. One hit: a line contrasting api vs getApi() naming AuthManager generically ("the shipped plugin-auth registers an AuthManager, which has no api member at all"). Still true — this diff adds no api member and does not touch that contrast. No change needed.
  • content/docs/kernel/services-checklist.mdx — one hit, a table row: "Implementation: AuthPluginAuthManager (@objectstack/plugin-auth, built on better-auth)". Still true — AuthManager is still the implementation, still built on better-auth; this diff changes neither fact. No change needed.
  • content/docs/permissions/authentication.mdx — the page that documents the public auth surface in the most detail (bootstrap-status, sign-up/email, etc.). Read the full sign-up/email entry and the "First-run bootstrap status" section (#first-run-bootstrap-status): both describe the PUBLIC bootstrap probe (isBootstrapCreation, "does this environment have no human user yet") and the generic sign-up/email endpoint description ("Register new user"). Neither mentions the dev-admin seed's internal operator-provisioning ticket mechanism (stageOperatorProvisioning/isOperatorProvisioning) at all — that mechanism is deliberately a SEPARATE, undocumented-by-design internal seam (see the [#14157] docblock: "moves no public door"), distinct from the bootstrap probe this page does document. No sentence on this page asserts anything about ticket keying that this diff changes. No change needed.

Verdict for all three: the change is internal to the dev-admin seed's ticket-keying implementation detail: none of the three pages' contracts (service implementation identity, public bootstrap probe semantics, public endpoint list) assert anything this diff makes false.

Tests

Real end-to-end suite (dev-admin-seed-credential-gate.test.ts, real ObjectQL engine + real better-auth over :memory: SQLite). Existing cases ⓪–⑧ stay green unmodified; two new cases pin the fix:

  • : stages a ticket directly (opening the exact window maybeSeedDevAdmin opens), fires a stranger's POST /sign-up/email for the SAME address with NO ticket field while the window is open — asserts 403 SELF_REGISTRATION_CLOSED and zero accounts created — then, in the SAME still-open window, fires the correctly-bound call as a positive control and asserts it IS admitted, its credential actually authenticates via sign-in/email.
  • ⑨(b): same setup, but the stranger supplies a wrong-but-present ticket value — asserts the same refusal (not merely "missing" but "wrong" is refused).

pnpm --filter @objectstack/plugin-auth test: 91 test files passed, 1864 tests passed, on the merged tree at 4a07e99c4. pnpm --filter @objectstack/plugin-auth typecheck: clean.

Ablation (proves the pin actually depends on the binding)

Reverted isOperatorProvisioning to email-only matching. Mutation proof, redone with a non-comment marker (a globalThis write, since a // comment is stripped by esbuild and cannot prove the mutation reached the built artifact):

  • Source: blob hash 66ba6e5c…6b477b29…; injected marker token present at 2 occurrences (write + implicit read via grep), 0 remaining occurrences of the original ticket === entry.ticket comparison line.
  • Rebuilt dist/index.mjs; the marker token is present in the BUILT output (DIST_MARKER_COUNT=1) — confirms the mutation reached the code the tests actually load, not just the source.
  • Reran the file's suite: Tests 2 failed | 13 passed (15), with ⑨ and ⑨(b) specifically red (the stranger is admitted once the ticket check is gone).
  • Restore via trap ... EXIT INT TERM: git diff HEAD -- auth-manager.ts byte count 0 after restore. Rebuilt again afterward: marker count 0 in the rebuilt dist/index.mjs, and the file's suite is back to 15 passed (15)dist/ is confirmed returned to the real implementation, not left mutated.

Gate family (re-derived from the actual diff at HEAD 4a07e99c4, scripts/pm/dispatch-gates.mjs)

38 commands derived. 35 exit 0. The remaining 3 are explicitly NOT MEASURED (their own output says so, exit code 3, distinct from a finding's exit 1), each requiring a full-monorepo dist/ closure this scoped local run does not build — CI's lint.yml builds that closure and runs these for real:

  • check-test-completeness — no local turbo run test log to parse.
  • check:dual-build-cjs-loads — PREREQUISITE NOT MET, 38 unrelated packages have no local dist/.
  • check:type-check-debt --re-measure — PREREQUISITE NOT MET, 28 workspace deps have no built type entry point locally.

None of the 3 touch plugin-auth, auth-manager.ts, or auth-plugin.ts in their own diagnostics.


🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

…ket to more than the seed address (#14373)
`stageOperatorProvisioning`/`isOperatorProvisioning` peeked the ticket by
email alone. The address is not a secret (documented default, printed on the
boot banner), so a stranger's own concurrent sign-up for the same address,
arriving while the ticket was staged, could satisfy an email-only peek at
both admission seams and be admitted as the operator class — and since a
unique-email constraint lets only one concurrent signUpEmail land, the
stranger's row could become the account at that address.
`stageOperatorProvisioning` now also generates a random ticket value and
returns it; the caller threads it into the same signUpEmail call's body
under the new `AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD` key.
`isOperatorProvisioning` now requires an exact match on both email and
ticket — a missing or mismatched ticket reads as "not provisioning", no
email-only fallback.
Disposition 3 only, per triage's ruling (14373#issuecomment-5504337158):
disposition 1 (document the trust assumption) is already landed and
untouched; disposition 2 (move the method off the barrel-public surface) is
a published-surface removal, the human floor, not touched here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 9 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx(via AuthManager (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via AuthManager (symbol, a top-level class))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2dc527df332cfdee110aa7ce0d5505506e2ea0ca — the merge of head 4a07e99c455a23037cb81900bc7fbeacebd214d8 into base 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2dc527df332cfdee110aa7ce0d5505506e2ea0ca && git checkout 2dc527df332cfdee110aa7ce0d5505506e2ea0ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 4a07e99c455a23037cb81900bc7fbeacebd214d8 && git checkout -B drift-repro 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 && git merge --no-ff 4a07e99c455a23037cb81900bc7fbeacebd214d8
node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review September 2, 2026 23:50
@os-project-manager
os-project-manager added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 7c342f4Sep 3, 2026
48 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14373-operator-ticket-binding branch September 3, 2026 01:32
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

3 participants

@os-sales@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-auth): bind the dev-admin seed's operator-provisioning ticket to more than the seed address (#14373) - #14730

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-14373-operator-ticket-binding
Sep 3, 2026
Merged

fix(plugin-auth): bind the dev-admin seed's operator-provisioning ticket to more than the seed address (#14373)#14730
os-project-manager merged 2 commits into
mainfrom
claude/issue-14373-operator-ticket-binding

Conversation

@os-sales

@os-salesos-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14373

What changed

Disposition 3 only, per triage's ruling (14373#issuecomment-5504337158) and the claim comment's carry-forward: bind the dev-admin seed's operator-provisioning ticket to more than the seed address.

AuthManager.stageOperatorProvisioning(email) staged its ticket keyed on email.trim().toLowerCase() alone. The address is not a secretadmin@objectos.ai is the documented default and the boot banner prints it (with the password) once the seed completes — so "the address is the operator's own" did not narrow the attacker set the way it would for an unguessable value. A stranger's own concurrent POST /sign-up/email for that same address, arriving while the ticket was staged, would satisfy an email-only peek at BOTH admission seams (the disableSignUp before-hook at auth-manager.ts:~2069 and validateAudienceAdmission's creationClass computation at ~3940) and be admitted as the operator class too — and since a unique-email constraint lets only one of the two concurrent signUpEmail calls actually land, a stranger who won that race would not merely read as the operator, their row would become the account at that address. The safety this rested on — "milliseconds, and dev-only" — was true today, but both are properties of the caller, not of what the ticket asserted.

stageOperatorProvisioning(email) now also generates a random, unguessable ticket value (128 bits from WebCrypto's getRandomValues, matching the existing resolvePasswordHasher WebContainer-salt convention) and returns it. AuthPlugin.maybeSeedDevAdmin threads that value into the SAME signUpEmail call's body, under a new AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD key — an unrecognized key that better-auth's signUpEmailBodySchema (z.object({...}).and(z.record(z.string(), z.any()))) lets ride through untouched, so it never becomes a declared sys_user field or a DB column. isOperatorProvisioning(email, ticket) now requires an exact match on both; a missing, wrong-typed, or mismatched ticket reads as "not provisioning" — same as no ticket at all, no email-only fallback.

This converts a timing argument into a structural one: admission now asks "did THIS process's own boot command make THIS exact call", not "does the address match". A stranger's request carries no value that was ever transmitted anywhere for them to replay, however precisely they time the window.

Why the nonce, not name

Triage offered two implementation routes: bind to the seed's own name, or thread a nonce through the sign-up body. I measured both against the actual threat (a concurrent stranger, not merely "harder to guess"):

  • name defaults to 'Dev Admin' (OS_SEED_ADMIN_NAME env override), and — unlike email/password — is not printed by the boot banner (AuthManager.devSeedResult only carries {email, password}; confirmed by reading serve.ts's banner code and format.ts). So it is not observable from a live deployment's own output. But this is an open-source codebase: 'Dev Admin' is exactly as discoverable by reading auth-plugin.ts as admin@objectos.ai is by reading walled-owner-verification-path.ts. Binding to name alone would narrow the window against an attacker who knows the printed email but hasn't read the source — a real but narrow-obscurity improvement, not a structural one. Per the card's own instruction, I did not write "harder to guess" as "impossible."
  • The random ticket value is generated in-process via WebCrypto and never transmitted anywhere — not printed, not logged, not derivable from source (it's per-boot, not a fixed default). No amount of source-reading recovers it. That is the structural fix the triage comment was pointing at ("converts a timing argument into a structural one").

Is the binding value a secret? (asked directly)

Yes, and that is the load-bearing difference from the seed address. Traced end to end, what it travels with and where it can surface:

  • Minted: AuthManager.generateOperatorProvisioningTicket() — 16 bytes from crypto.getRandomValues, hex-encoded, held only in the local ticket variable and in the pendingOperatorProvisioning in-memory Map (keyed by email, TTL-pruned, cleared in a finally). Nothing durable — no table, no file, no env var.
  • Carried: as one extra JSON field, AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD (__osOperatorProvisioningTicket), riding inside the same POST /sign-up/email request bodyAuthPlugin.maybeSeedDevAdmin was always going to send — alongside email, password, and name. It does not get its own call or its own channel.
  • Consumed: better-auth's signUpEmailBodySchema is z.object({...}).and(z.record(z.string(), z.any())) — the declared fields get validated and become the sys_user/sys_account rows; the catch-all lets the ticket field ride through the parse untouched, but it is read ONLY by isOperatorProvisioning's comparison and is never assigned to any column, never passed to internalAdapter.createUser.
  • Printed? No. AuthManager.devSeedResult — the only thing the boot banner reads (see serve.ts's banner code, format.ts) — carries exactly {email, password}. The ticket is never part of that struct, so it cannot reach stdout, a log line, or any other deployment-observable output by any path this diff touches.

So the fix does not make the window narrower (a better guess still fails against the seed address) — it removes the window's public observable entirely: there is nothing about this deployment's own output that a stranger could read to learn the value, at any point in its lifetime. Contrast with binding to name (above), which would only have been "harder to guess," since name defaults to a fixed, source-readable string.

Region split held

Per the claim comment's declared region (origin/main line numbers): auth-manager.ts:2070/:3810/:3912-3914/:4233/:4238/:4251; auth-plugin.ts:1874.

Corrected measurement (git diff -U0 origin/main...HEAD, exact changed old-side lines, zero context padding — the first PR-body draft measured this with -U3-style context bleed and understated the gap at "22 lines"; this is the precise figure and it supersedes that one): auth-manager.ts touches old-side lines 2068,2070 / 3811,3813,3821 / 3890-3893,3900,3914 / 4231,4233,4235,4242,4249,4251,4254; auth-plugin.ts touches 1874,1876. Against PR #14600's declared hunks (auth-manager.ts 14/990/1336/1368/1399/1425/2105/2921/3067/3089/4327-4337/4340/4354/4554-4591; auth-plugin.ts 784-788 only): closest pairs are 2070 vs 2105 = 35 lines and 4254 vs 4327 = 73 lines in auth-manager.ts, and ~1090 lines apart in auth-plugin.ts (784-788 vs 1874-1876). This matches the PM seat's independent re-measurement exactly. Disjoint throughout; split holds.

Not in this PR (per triage's ruling)

  • Disposition 1 (document the trust assumption) — already landed on main; the JSDoc above stageOperatorProvisioning states it in full. No-op, not touched.
  • Disposition 2 (move the method off the barrel-public surface) — a published-surface removal, the human floor (删除已发布能力) with ADR-0049 enforce-or-remove discipline owed. Not a dev-agent edit.
  • Whether the bootstrap window should count humans or logins is #14349's question, in the decision inbox.

Public surface (Clause ②: yes) — exact signature deltas, and why grep export cannot see them

AuthManager is barrel-public (packages/plugins/plugin-auth/src/index.ts: export * from './auth-manager.js'), so every public member of the class is on the package's public surface even though nothing in this diff adds a new top-level export statement — git diff -U0 origin/main... | grep -E '^\+\s*export ' returns zero output, and reading only that grep would wrongly conclude Clause ② is no. The actual surface change is on the SIGNATURES of two already-exported class members, which a line-prefix grep for the export keyword structurally cannot see (the keyword lives once, on the class AuthManager declaration itself, not repeated per member):

MemberBefore (origin/main)After (this PR)
stageOperatorProvisioningstageOperatorProvisioning(email: string): voidstageOperatorProvisioning(email: string): string
isOperatorProvisioningisOperatorProvisioning(email: unknown): booleanisOperatorProvisioning(email: unknown, ticket?: unknown): boolean
(new member)static readonly OPERATOR_PROVISIONING_TICKET_FIELD: string
  • stageOperatorProvisioning's return type widens voidstring — additive; any existing caller that ignores the return value is unaffected, but a caller that TYPE-CHECKS against void (unusual, but possible in a strict wrapper) would need to update.
  • isOperatorProvisioning gains an optional second parameter — additive at the call site (omitting it now always reads "not provisioning," the safe default), but it is a signature widening on a public method, which is exactly what Clause ② asks about regardless of backward-compatibility.
  • One new static member is added to the public class surface.

Graded minor in the changeset (confirmed: .changeset/operator-provisioning-ticket-binding.md frontmatter is "@objectstack/plugin-auth": minor — this repo's convention for a barrel-public signature change, not patch). needs:contract-review is on both carriers: issue #14373 (pre-hung by the claim comment) and this PR (confirmed present via a read-back after adding it).

Docs drift — three pages checked, none touched

Per PM's pointer, checked whether this diff falsifies any sentence on the three pages that reach AuthManager as a symbol:

  • content/docs/kernel/contracts/auth-service.mdx — grepped for AuthManager/stageOperatorProvisioning/isOperatorProvisioning/operator provisioning/dev-admin/seed. One hit: a line contrasting api vs getApi() naming AuthManager generically ("the shipped plugin-auth registers an AuthManager, which has no api member at all"). Still true — this diff adds no api member and does not touch that contrast. No change needed.
  • content/docs/kernel/services-checklist.mdx — one hit, a table row: "Implementation: AuthPluginAuthManager (@objectstack/plugin-auth, built on better-auth)". Still true — AuthManager is still the implementation, still built on better-auth; this diff changes neither fact. No change needed.
  • content/docs/permissions/authentication.mdx — the page that documents the public auth surface in the most detail (bootstrap-status, sign-up/email, etc.). Read the full sign-up/email entry and the "First-run bootstrap status" section (#first-run-bootstrap-status): both describe the PUBLIC bootstrap probe (isBootstrapCreation, "does this environment have no human user yet") and the generic sign-up/email endpoint description ("Register new user"). Neither mentions the dev-admin seed's internal operator-provisioning ticket mechanism (stageOperatorProvisioning/isOperatorProvisioning) at all — that mechanism is deliberately a SEPARATE, undocumented-by-design internal seam (see the [#14157] docblock: "moves no public door"), distinct from the bootstrap probe this page does document. No sentence on this page asserts anything about ticket keying that this diff changes. No change needed.

Verdict for all three: the change is internal to the dev-admin seed's ticket-keying implementation detail: none of the three pages' contracts (service implementation identity, public bootstrap probe semantics, public endpoint list) assert anything this diff makes false.

Tests

Real end-to-end suite (dev-admin-seed-credential-gate.test.ts, real ObjectQL engine + real better-auth over :memory: SQLite). Existing cases ⓪–⑧ stay green unmodified; two new cases pin the fix:

  • : stages a ticket directly (opening the exact window maybeSeedDevAdmin opens), fires a stranger's POST /sign-up/email for the SAME address with NO ticket field while the window is open — asserts 403 SELF_REGISTRATION_CLOSED and zero accounts created — then, in the SAME still-open window, fires the correctly-bound call as a positive control and asserts it IS admitted, its credential actually authenticates via sign-in/email.
  • ⑨(b): same setup, but the stranger supplies a wrong-but-present ticket value — asserts the same refusal (not merely "missing" but "wrong" is refused).

pnpm --filter @objectstack/plugin-auth test: 91 test files passed, 1864 tests passed, on the merged tree at 4a07e99c4. pnpm --filter @objectstack/plugin-auth typecheck: clean.

Ablation (proves the pin actually depends on the binding)

Reverted isOperatorProvisioning to email-only matching. Mutation proof, redone with a non-comment marker (a globalThis write, since a // comment is stripped by esbuild and cannot prove the mutation reached the built artifact):

  • Source: blob hash 66ba6e5c…6b477b29…; injected marker token present at 2 occurrences (write + implicit read via grep), 0 remaining occurrences of the original ticket === entry.ticket comparison line.
  • Rebuilt dist/index.mjs; the marker token is present in the BUILT output (DIST_MARKER_COUNT=1) — confirms the mutation reached the code the tests actually load, not just the source.
  • Reran the file's suite: Tests 2 failed | 13 passed (15), with ⑨ and ⑨(b) specifically red (the stranger is admitted once the ticket check is gone).
  • Restore via trap ... EXIT INT TERM: git diff HEAD -- auth-manager.ts byte count 0 after restore. Rebuilt again afterward: marker count 0 in the rebuilt dist/index.mjs, and the file's suite is back to 15 passed (15)dist/ is confirmed returned to the real implementation, not left mutated.

Gate family (re-derived from the actual diff at HEAD 4a07e99c4, scripts/pm/dispatch-gates.mjs)

38 commands derived. 35 exit 0. The remaining 3 are explicitly NOT MEASURED (their own output says so, exit code 3, distinct from a finding's exit 1), each requiring a full-monorepo dist/ closure this scoped local run does not build — CI's lint.yml builds that closure and runs these for real:

  • check-test-completeness — no local turbo run test log to parse.
  • check:dual-build-cjs-loads — PREREQUISITE NOT MET, 38 unrelated packages have no local dist/.
  • check:type-check-debt --re-measure — PREREQUISITE NOT MET, 28 workspace deps have no built type entry point locally.

None of the 3 touch plugin-auth, auth-manager.ts, or auth-plugin.ts in their own diagnostics.


🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

…ket to more than the seed address (#14373)
`stageOperatorProvisioning`/`isOperatorProvisioning` peeked the ticket by
email alone. The address is not a secret (documented default, printed on the
boot banner), so a stranger's own concurrent sign-up for the same address,
arriving while the ticket was staged, could satisfy an email-only peek at
both admission seams and be admitted as the operator class — and since a
unique-email constraint lets only one concurrent signUpEmail land, the
stranger's row could become the account at that address.
`stageOperatorProvisioning` now also generates a random ticket value and
returns it; the caller threads it into the same signUpEmail call's body
under the new `AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD` key.
`isOperatorProvisioning` now requires an exact match on both email and
ticket — a missing or mismatched ticket reads as "not provisioning", no
email-only fallback.
Disposition 3 only, per triage's ruling (14373#issuecomment-5504337158):
disposition 1 (document the trust assumption) is already landed and
untouched; disposition 2 (move the method off the barrel-public surface) is
a published-surface removal, the human floor, not touched here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 9 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx(via AuthManager (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via AuthManager (symbol, a top-level class))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2dc527df332cfdee110aa7ce0d5505506e2ea0ca — the merge of head 4a07e99c455a23037cb81900bc7fbeacebd214d8 into base 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2dc527df332cfdee110aa7ce0d5505506e2ea0ca && git checkout 2dc527df332cfdee110aa7ce0d5505506e2ea0ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 4a07e99c455a23037cb81900bc7fbeacebd214d8 && git checkout -B drift-repro 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 && git merge --no-ff 4a07e99c455a23037cb81900bc7fbeacebd214d8
node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review September 2, 2026 23:50
@os-project-manager
os-project-manager added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 7c342f4Sep 3, 2026
48 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14373-operator-ticket-binding branch September 3, 2026 01:32
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

3 participants

@os-sales@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-auth): bind the dev-admin seed's operator-provisioning ticket to more than the seed address (#14373) - #14730

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-14373-operator-ticket-binding
Sep 3, 2026
Merged

fix(plugin-auth): bind the dev-admin seed's operator-provisioning ticket to more than the seed address (#14373)#14730
os-project-manager merged 2 commits into
mainfrom
claude/issue-14373-operator-ticket-binding

Conversation

@os-sales

@os-salesos-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14373

What changed

Disposition 3 only, per triage's ruling (14373#issuecomment-5504337158) and the claim comment's carry-forward: bind the dev-admin seed's operator-provisioning ticket to more than the seed address.

AuthManager.stageOperatorProvisioning(email) staged its ticket keyed on email.trim().toLowerCase() alone. The address is not a secretadmin@objectos.ai is the documented default and the boot banner prints it (with the password) once the seed completes — so "the address is the operator's own" did not narrow the attacker set the way it would for an unguessable value. A stranger's own concurrent POST /sign-up/email for that same address, arriving while the ticket was staged, would satisfy an email-only peek at BOTH admission seams (the disableSignUp before-hook at auth-manager.ts:~2069 and validateAudienceAdmission's creationClass computation at ~3940) and be admitted as the operator class too — and since a unique-email constraint lets only one of the two concurrent signUpEmail calls actually land, a stranger who won that race would not merely read as the operator, their row would become the account at that address. The safety this rested on — "milliseconds, and dev-only" — was true today, but both are properties of the caller, not of what the ticket asserted.

stageOperatorProvisioning(email) now also generates a random, unguessable ticket value (128 bits from WebCrypto's getRandomValues, matching the existing resolvePasswordHasher WebContainer-salt convention) and returns it. AuthPlugin.maybeSeedDevAdmin threads that value into the SAME signUpEmail call's body, under a new AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD key — an unrecognized key that better-auth's signUpEmailBodySchema (z.object({...}).and(z.record(z.string(), z.any()))) lets ride through untouched, so it never becomes a declared sys_user field or a DB column. isOperatorProvisioning(email, ticket) now requires an exact match on both; a missing, wrong-typed, or mismatched ticket reads as "not provisioning" — same as no ticket at all, no email-only fallback.

This converts a timing argument into a structural one: admission now asks "did THIS process's own boot command make THIS exact call", not "does the address match". A stranger's request carries no value that was ever transmitted anywhere for them to replay, however precisely they time the window.

Why the nonce, not name

Triage offered two implementation routes: bind to the seed's own name, or thread a nonce through the sign-up body. I measured both against the actual threat (a concurrent stranger, not merely "harder to guess"):

  • name defaults to 'Dev Admin' (OS_SEED_ADMIN_NAME env override), and — unlike email/password — is not printed by the boot banner (AuthManager.devSeedResult only carries {email, password}; confirmed by reading serve.ts's banner code and format.ts). So it is not observable from a live deployment's own output. But this is an open-source codebase: 'Dev Admin' is exactly as discoverable by reading auth-plugin.ts as admin@objectos.ai is by reading walled-owner-verification-path.ts. Binding to name alone would narrow the window against an attacker who knows the printed email but hasn't read the source — a real but narrow-obscurity improvement, not a structural one. Per the card's own instruction, I did not write "harder to guess" as "impossible."
  • The random ticket value is generated in-process via WebCrypto and never transmitted anywhere — not printed, not logged, not derivable from source (it's per-boot, not a fixed default). No amount of source-reading recovers it. That is the structural fix the triage comment was pointing at ("converts a timing argument into a structural one").

Is the binding value a secret? (asked directly)

Yes, and that is the load-bearing difference from the seed address. Traced end to end, what it travels with and where it can surface:

  • Minted: AuthManager.generateOperatorProvisioningTicket() — 16 bytes from crypto.getRandomValues, hex-encoded, held only in the local ticket variable and in the pendingOperatorProvisioning in-memory Map (keyed by email, TTL-pruned, cleared in a finally). Nothing durable — no table, no file, no env var.
  • Carried: as one extra JSON field, AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD (__osOperatorProvisioningTicket), riding inside the same POST /sign-up/email request bodyAuthPlugin.maybeSeedDevAdmin was always going to send — alongside email, password, and name. It does not get its own call or its own channel.
  • Consumed: better-auth's signUpEmailBodySchema is z.object({...}).and(z.record(z.string(), z.any())) — the declared fields get validated and become the sys_user/sys_account rows; the catch-all lets the ticket field ride through the parse untouched, but it is read ONLY by isOperatorProvisioning's comparison and is never assigned to any column, never passed to internalAdapter.createUser.
  • Printed? No. AuthManager.devSeedResult — the only thing the boot banner reads (see serve.ts's banner code, format.ts) — carries exactly {email, password}. The ticket is never part of that struct, so it cannot reach stdout, a log line, or any other deployment-observable output by any path this diff touches.

So the fix does not make the window narrower (a better guess still fails against the seed address) — it removes the window's public observable entirely: there is nothing about this deployment's own output that a stranger could read to learn the value, at any point in its lifetime. Contrast with binding to name (above), which would only have been "harder to guess," since name defaults to a fixed, source-readable string.

Region split held

Per the claim comment's declared region (origin/main line numbers): auth-manager.ts:2070/:3810/:3912-3914/:4233/:4238/:4251; auth-plugin.ts:1874.

Corrected measurement (git diff -U0 origin/main...HEAD, exact changed old-side lines, zero context padding — the first PR-body draft measured this with -U3-style context bleed and understated the gap at "22 lines"; this is the precise figure and it supersedes that one): auth-manager.ts touches old-side lines 2068,2070 / 3811,3813,3821 / 3890-3893,3900,3914 / 4231,4233,4235,4242,4249,4251,4254; auth-plugin.ts touches 1874,1876. Against PR #14600's declared hunks (auth-manager.ts 14/990/1336/1368/1399/1425/2105/2921/3067/3089/4327-4337/4340/4354/4554-4591; auth-plugin.ts 784-788 only): closest pairs are 2070 vs 2105 = 35 lines and 4254 vs 4327 = 73 lines in auth-manager.ts, and ~1090 lines apart in auth-plugin.ts (784-788 vs 1874-1876). This matches the PM seat's independent re-measurement exactly. Disjoint throughout; split holds.

Not in this PR (per triage's ruling)

  • Disposition 1 (document the trust assumption) — already landed on main; the JSDoc above stageOperatorProvisioning states it in full. No-op, not touched.
  • Disposition 2 (move the method off the barrel-public surface) — a published-surface removal, the human floor (删除已发布能力) with ADR-0049 enforce-or-remove discipline owed. Not a dev-agent edit.
  • Whether the bootstrap window should count humans or logins is #14349's question, in the decision inbox.

Public surface (Clause ②: yes) — exact signature deltas, and why grep export cannot see them

AuthManager is barrel-public (packages/plugins/plugin-auth/src/index.ts: export * from './auth-manager.js'), so every public member of the class is on the package's public surface even though nothing in this diff adds a new top-level export statement — git diff -U0 origin/main... | grep -E '^\+\s*export ' returns zero output, and reading only that grep would wrongly conclude Clause ② is no. The actual surface change is on the SIGNATURES of two already-exported class members, which a line-prefix grep for the export keyword structurally cannot see (the keyword lives once, on the class AuthManager declaration itself, not repeated per member):

MemberBefore (origin/main)After (this PR)
stageOperatorProvisioningstageOperatorProvisioning(email: string): voidstageOperatorProvisioning(email: string): string
isOperatorProvisioningisOperatorProvisioning(email: unknown): booleanisOperatorProvisioning(email: unknown, ticket?: unknown): boolean
(new member)static readonly OPERATOR_PROVISIONING_TICKET_FIELD: string
  • stageOperatorProvisioning's return type widens voidstring — additive; any existing caller that ignores the return value is unaffected, but a caller that TYPE-CHECKS against void (unusual, but possible in a strict wrapper) would need to update.
  • isOperatorProvisioning gains an optional second parameter — additive at the call site (omitting it now always reads "not provisioning," the safe default), but it is a signature widening on a public method, which is exactly what Clause ② asks about regardless of backward-compatibility.
  • One new static member is added to the public class surface.

Graded minor in the changeset (confirmed: .changeset/operator-provisioning-ticket-binding.md frontmatter is "@objectstack/plugin-auth": minor — this repo's convention for a barrel-public signature change, not patch). needs:contract-review is on both carriers: issue #14373 (pre-hung by the claim comment) and this PR (confirmed present via a read-back after adding it).

Docs drift — three pages checked, none touched

Per PM's pointer, checked whether this diff falsifies any sentence on the three pages that reach AuthManager as a symbol:

  • content/docs/kernel/contracts/auth-service.mdx — grepped for AuthManager/stageOperatorProvisioning/isOperatorProvisioning/operator provisioning/dev-admin/seed. One hit: a line contrasting api vs getApi() naming AuthManager generically ("the shipped plugin-auth registers an AuthManager, which has no api member at all"). Still true — this diff adds no api member and does not touch that contrast. No change needed.
  • content/docs/kernel/services-checklist.mdx — one hit, a table row: "Implementation: AuthPluginAuthManager (@objectstack/plugin-auth, built on better-auth)". Still true — AuthManager is still the implementation, still built on better-auth; this diff changes neither fact. No change needed.
  • content/docs/permissions/authentication.mdx — the page that documents the public auth surface in the most detail (bootstrap-status, sign-up/email, etc.). Read the full sign-up/email entry and the "First-run bootstrap status" section (#first-run-bootstrap-status): both describe the PUBLIC bootstrap probe (isBootstrapCreation, "does this environment have no human user yet") and the generic sign-up/email endpoint description ("Register new user"). Neither mentions the dev-admin seed's internal operator-provisioning ticket mechanism (stageOperatorProvisioning/isOperatorProvisioning) at all — that mechanism is deliberately a SEPARATE, undocumented-by-design internal seam (see the [#14157] docblock: "moves no public door"), distinct from the bootstrap probe this page does document. No sentence on this page asserts anything about ticket keying that this diff changes. No change needed.

Verdict for all three: the change is internal to the dev-admin seed's ticket-keying implementation detail: none of the three pages' contracts (service implementation identity, public bootstrap probe semantics, public endpoint list) assert anything this diff makes false.

Tests

Real end-to-end suite (dev-admin-seed-credential-gate.test.ts, real ObjectQL engine + real better-auth over :memory: SQLite). Existing cases ⓪–⑧ stay green unmodified; two new cases pin the fix:

  • : stages a ticket directly (opening the exact window maybeSeedDevAdmin opens), fires a stranger's POST /sign-up/email for the SAME address with NO ticket field while the window is open — asserts 403 SELF_REGISTRATION_CLOSED and zero accounts created — then, in the SAME still-open window, fires the correctly-bound call as a positive control and asserts it IS admitted, its credential actually authenticates via sign-in/email.
  • ⑨(b): same setup, but the stranger supplies a wrong-but-present ticket value — asserts the same refusal (not merely "missing" but "wrong" is refused).

pnpm --filter @objectstack/plugin-auth test: 91 test files passed, 1864 tests passed, on the merged tree at 4a07e99c4. pnpm --filter @objectstack/plugin-auth typecheck: clean.

Ablation (proves the pin actually depends on the binding)

Reverted isOperatorProvisioning to email-only matching. Mutation proof, redone with a non-comment marker (a globalThis write, since a // comment is stripped by esbuild and cannot prove the mutation reached the built artifact):

  • Source: blob hash 66ba6e5c…6b477b29…; injected marker token present at 2 occurrences (write + implicit read via grep), 0 remaining occurrences of the original ticket === entry.ticket comparison line.
  • Rebuilt dist/index.mjs; the marker token is present in the BUILT output (DIST_MARKER_COUNT=1) — confirms the mutation reached the code the tests actually load, not just the source.
  • Reran the file's suite: Tests 2 failed | 13 passed (15), with ⑨ and ⑨(b) specifically red (the stranger is admitted once the ticket check is gone).
  • Restore via trap ... EXIT INT TERM: git diff HEAD -- auth-manager.ts byte count 0 after restore. Rebuilt again afterward: marker count 0 in the rebuilt dist/index.mjs, and the file's suite is back to 15 passed (15)dist/ is confirmed returned to the real implementation, not left mutated.

Gate family (re-derived from the actual diff at HEAD 4a07e99c4, scripts/pm/dispatch-gates.mjs)

38 commands derived. 35 exit 0. The remaining 3 are explicitly NOT MEASURED (their own output says so, exit code 3, distinct from a finding's exit 1), each requiring a full-monorepo dist/ closure this scoped local run does not build — CI's lint.yml builds that closure and runs these for real:

  • check-test-completeness — no local turbo run test log to parse.
  • check:dual-build-cjs-loads — PREREQUISITE NOT MET, 38 unrelated packages have no local dist/.
  • check:type-check-debt --re-measure — PREREQUISITE NOT MET, 28 workspace deps have no built type entry point locally.

None of the 3 touch plugin-auth, auth-manager.ts, or auth-plugin.ts in their own diagnostics.


🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

…ket to more than the seed address (#14373)
`stageOperatorProvisioning`/`isOperatorProvisioning` peeked the ticket by
email alone. The address is not a secret (documented default, printed on the
boot banner), so a stranger's own concurrent sign-up for the same address,
arriving while the ticket was staged, could satisfy an email-only peek at
both admission seams and be admitted as the operator class — and since a
unique-email constraint lets only one concurrent signUpEmail land, the
stranger's row could become the account at that address.
`stageOperatorProvisioning` now also generates a random ticket value and
returns it; the caller threads it into the same signUpEmail call's body
under the new `AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD` key.
`isOperatorProvisioning` now requires an exact match on both email and
ticket — a missing or mismatched ticket reads as "not provisioning", no
email-only fallback.
Disposition 3 only, per triage's ruling (14373#issuecomment-5504337158):
disposition 1 (document the trust assumption) is already landed and
untouched; disposition 2 (move the method off the barrel-public surface) is
a published-surface removal, the human floor, not touched here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 9 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx(via AuthManager (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via AuthManager (symbol, a top-level class))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2dc527df332cfdee110aa7ce0d5505506e2ea0ca — the merge of head 4a07e99c455a23037cb81900bc7fbeacebd214d8 into base 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2dc527df332cfdee110aa7ce0d5505506e2ea0ca && git checkout 2dc527df332cfdee110aa7ce0d5505506e2ea0ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 4a07e99c455a23037cb81900bc7fbeacebd214d8 && git checkout -B drift-repro 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 && git merge --no-ff 4a07e99c455a23037cb81900bc7fbeacebd214d8
node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review September 2, 2026 23:50
@os-project-manager
os-project-manager added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 7c342f4Sep 3, 2026
48 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14373-operator-ticket-binding branch September 3, 2026 01:32
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

3 participants

@os-sales@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(plugin-auth): bind the dev-admin seed's operator-provisioning ticket to more than the seed address (#14373) - #14730

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-14373-operator-ticket-binding
Sep 3, 2026
Merged

fix(plugin-auth): bind the dev-admin seed's operator-provisioning ticket to more than the seed address (#14373)#14730
os-project-manager merged 2 commits into
mainfrom
claude/issue-14373-operator-ticket-binding

Conversation

@os-sales

@os-salesos-sales commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14373

What changed

Disposition 3 only, per triage's ruling (14373#issuecomment-5504337158) and the claim comment's carry-forward: bind the dev-admin seed's operator-provisioning ticket to more than the seed address.

AuthManager.stageOperatorProvisioning(email) staged its ticket keyed on email.trim().toLowerCase() alone. The address is not a secretadmin@objectos.ai is the documented default and the boot banner prints it (with the password) once the seed completes — so "the address is the operator's own" did not narrow the attacker set the way it would for an unguessable value. A stranger's own concurrent POST /sign-up/email for that same address, arriving while the ticket was staged, would satisfy an email-only peek at BOTH admission seams (the disableSignUp before-hook at auth-manager.ts:~2069 and validateAudienceAdmission's creationClass computation at ~3940) and be admitted as the operator class too — and since a unique-email constraint lets only one of the two concurrent signUpEmail calls actually land, a stranger who won that race would not merely read as the operator, their row would become the account at that address. The safety this rested on — "milliseconds, and dev-only" — was true today, but both are properties of the caller, not of what the ticket asserted.

stageOperatorProvisioning(email) now also generates a random, unguessable ticket value (128 bits from WebCrypto's getRandomValues, matching the existing resolvePasswordHasher WebContainer-salt convention) and returns it. AuthPlugin.maybeSeedDevAdmin threads that value into the SAME signUpEmail call's body, under a new AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD key — an unrecognized key that better-auth's signUpEmailBodySchema (z.object({...}).and(z.record(z.string(), z.any()))) lets ride through untouched, so it never becomes a declared sys_user field or a DB column. isOperatorProvisioning(email, ticket) now requires an exact match on both; a missing, wrong-typed, or mismatched ticket reads as "not provisioning" — same as no ticket at all, no email-only fallback.

This converts a timing argument into a structural one: admission now asks "did THIS process's own boot command make THIS exact call", not "does the address match". A stranger's request carries no value that was ever transmitted anywhere for them to replay, however precisely they time the window.

Why the nonce, not name

Triage offered two implementation routes: bind to the seed's own name, or thread a nonce through the sign-up body. I measured both against the actual threat (a concurrent stranger, not merely "harder to guess"):

  • name defaults to 'Dev Admin' (OS_SEED_ADMIN_NAME env override), and — unlike email/password — is not printed by the boot banner (AuthManager.devSeedResult only carries {email, password}; confirmed by reading serve.ts's banner code and format.ts). So it is not observable from a live deployment's own output. But this is an open-source codebase: 'Dev Admin' is exactly as discoverable by reading auth-plugin.ts as admin@objectos.ai is by reading walled-owner-verification-path.ts. Binding to name alone would narrow the window against an attacker who knows the printed email but hasn't read the source — a real but narrow-obscurity improvement, not a structural one. Per the card's own instruction, I did not write "harder to guess" as "impossible."
  • The random ticket value is generated in-process via WebCrypto and never transmitted anywhere — not printed, not logged, not derivable from source (it's per-boot, not a fixed default). No amount of source-reading recovers it. That is the structural fix the triage comment was pointing at ("converts a timing argument into a structural one").

Is the binding value a secret? (asked directly)

Yes, and that is the load-bearing difference from the seed address. Traced end to end, what it travels with and where it can surface:

  • Minted: AuthManager.generateOperatorProvisioningTicket() — 16 bytes from crypto.getRandomValues, hex-encoded, held only in the local ticket variable and in the pendingOperatorProvisioning in-memory Map (keyed by email, TTL-pruned, cleared in a finally). Nothing durable — no table, no file, no env var.
  • Carried: as one extra JSON field, AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD (__osOperatorProvisioningTicket), riding inside the same POST /sign-up/email request bodyAuthPlugin.maybeSeedDevAdmin was always going to send — alongside email, password, and name. It does not get its own call or its own channel.
  • Consumed: better-auth's signUpEmailBodySchema is z.object({...}).and(z.record(z.string(), z.any())) — the declared fields get validated and become the sys_user/sys_account rows; the catch-all lets the ticket field ride through the parse untouched, but it is read ONLY by isOperatorProvisioning's comparison and is never assigned to any column, never passed to internalAdapter.createUser.
  • Printed? No. AuthManager.devSeedResult — the only thing the boot banner reads (see serve.ts's banner code, format.ts) — carries exactly {email, password}. The ticket is never part of that struct, so it cannot reach stdout, a log line, or any other deployment-observable output by any path this diff touches.

So the fix does not make the window narrower (a better guess still fails against the seed address) — it removes the window's public observable entirely: there is nothing about this deployment's own output that a stranger could read to learn the value, at any point in its lifetime. Contrast with binding to name (above), which would only have been "harder to guess," since name defaults to a fixed, source-readable string.

Region split held

Per the claim comment's declared region (origin/main line numbers): auth-manager.ts:2070/:3810/:3912-3914/:4233/:4238/:4251; auth-plugin.ts:1874.

Corrected measurement (git diff -U0 origin/main...HEAD, exact changed old-side lines, zero context padding — the first PR-body draft measured this with -U3-style context bleed and understated the gap at "22 lines"; this is the precise figure and it supersedes that one): auth-manager.ts touches old-side lines 2068,2070 / 3811,3813,3821 / 3890-3893,3900,3914 / 4231,4233,4235,4242,4249,4251,4254; auth-plugin.ts touches 1874,1876. Against PR #14600's declared hunks (auth-manager.ts 14/990/1336/1368/1399/1425/2105/2921/3067/3089/4327-4337/4340/4354/4554-4591; auth-plugin.ts 784-788 only): closest pairs are 2070 vs 2105 = 35 lines and 4254 vs 4327 = 73 lines in auth-manager.ts, and ~1090 lines apart in auth-plugin.ts (784-788 vs 1874-1876). This matches the PM seat's independent re-measurement exactly. Disjoint throughout; split holds.

Not in this PR (per triage's ruling)

  • Disposition 1 (document the trust assumption) — already landed on main; the JSDoc above stageOperatorProvisioning states it in full. No-op, not touched.
  • Disposition 2 (move the method off the barrel-public surface) — a published-surface removal, the human floor (删除已发布能力) with ADR-0049 enforce-or-remove discipline owed. Not a dev-agent edit.
  • Whether the bootstrap window should count humans or logins is #14349's question, in the decision inbox.

Public surface (Clause ②: yes) — exact signature deltas, and why grep export cannot see them

AuthManager is barrel-public (packages/plugins/plugin-auth/src/index.ts: export * from './auth-manager.js'), so every public member of the class is on the package's public surface even though nothing in this diff adds a new top-level export statement — git diff -U0 origin/main... | grep -E '^\+\s*export ' returns zero output, and reading only that grep would wrongly conclude Clause ② is no. The actual surface change is on the SIGNATURES of two already-exported class members, which a line-prefix grep for the export keyword structurally cannot see (the keyword lives once, on the class AuthManager declaration itself, not repeated per member):

MemberBefore (origin/main)After (this PR)
stageOperatorProvisioningstageOperatorProvisioning(email: string): voidstageOperatorProvisioning(email: string): string
isOperatorProvisioningisOperatorProvisioning(email: unknown): booleanisOperatorProvisioning(email: unknown, ticket?: unknown): boolean
(new member)static readonly OPERATOR_PROVISIONING_TICKET_FIELD: string
  • stageOperatorProvisioning's return type widens voidstring — additive; any existing caller that ignores the return value is unaffected, but a caller that TYPE-CHECKS against void (unusual, but possible in a strict wrapper) would need to update.
  • isOperatorProvisioning gains an optional second parameter — additive at the call site (omitting it now always reads "not provisioning," the safe default), but it is a signature widening on a public method, which is exactly what Clause ② asks about regardless of backward-compatibility.
  • One new static member is added to the public class surface.

Graded minor in the changeset (confirmed: .changeset/operator-provisioning-ticket-binding.md frontmatter is "@objectstack/plugin-auth": minor — this repo's convention for a barrel-public signature change, not patch). needs:contract-review is on both carriers: issue #14373 (pre-hung by the claim comment) and this PR (confirmed present via a read-back after adding it).

Docs drift — three pages checked, none touched

Per PM's pointer, checked whether this diff falsifies any sentence on the three pages that reach AuthManager as a symbol:

  • content/docs/kernel/contracts/auth-service.mdx — grepped for AuthManager/stageOperatorProvisioning/isOperatorProvisioning/operator provisioning/dev-admin/seed. One hit: a line contrasting api vs getApi() naming AuthManager generically ("the shipped plugin-auth registers an AuthManager, which has no api member at all"). Still true — this diff adds no api member and does not touch that contrast. No change needed.
  • content/docs/kernel/services-checklist.mdx — one hit, a table row: "Implementation: AuthPluginAuthManager (@objectstack/plugin-auth, built on better-auth)". Still true — AuthManager is still the implementation, still built on better-auth; this diff changes neither fact. No change needed.
  • content/docs/permissions/authentication.mdx — the page that documents the public auth surface in the most detail (bootstrap-status, sign-up/email, etc.). Read the full sign-up/email entry and the "First-run bootstrap status" section (#first-run-bootstrap-status): both describe the PUBLIC bootstrap probe (isBootstrapCreation, "does this environment have no human user yet") and the generic sign-up/email endpoint description ("Register new user"). Neither mentions the dev-admin seed's internal operator-provisioning ticket mechanism (stageOperatorProvisioning/isOperatorProvisioning) at all — that mechanism is deliberately a SEPARATE, undocumented-by-design internal seam (see the [#14157] docblock: "moves no public door"), distinct from the bootstrap probe this page does document. No sentence on this page asserts anything about ticket keying that this diff changes. No change needed.

Verdict for all three: the change is internal to the dev-admin seed's ticket-keying implementation detail: none of the three pages' contracts (service implementation identity, public bootstrap probe semantics, public endpoint list) assert anything this diff makes false.

Tests

Real end-to-end suite (dev-admin-seed-credential-gate.test.ts, real ObjectQL engine + real better-auth over :memory: SQLite). Existing cases ⓪–⑧ stay green unmodified; two new cases pin the fix:

  • : stages a ticket directly (opening the exact window maybeSeedDevAdmin opens), fires a stranger's POST /sign-up/email for the SAME address with NO ticket field while the window is open — asserts 403 SELF_REGISTRATION_CLOSED and zero accounts created — then, in the SAME still-open window, fires the correctly-bound call as a positive control and asserts it IS admitted, its credential actually authenticates via sign-in/email.
  • ⑨(b): same setup, but the stranger supplies a wrong-but-present ticket value — asserts the same refusal (not merely "missing" but "wrong" is refused).

pnpm --filter @objectstack/plugin-auth test: 91 test files passed, 1864 tests passed, on the merged tree at 4a07e99c4. pnpm --filter @objectstack/plugin-auth typecheck: clean.

Ablation (proves the pin actually depends on the binding)

Reverted isOperatorProvisioning to email-only matching. Mutation proof, redone with a non-comment marker (a globalThis write, since a // comment is stripped by esbuild and cannot prove the mutation reached the built artifact):

  • Source: blob hash 66ba6e5c…6b477b29…; injected marker token present at 2 occurrences (write + implicit read via grep), 0 remaining occurrences of the original ticket === entry.ticket comparison line.
  • Rebuilt dist/index.mjs; the marker token is present in the BUILT output (DIST_MARKER_COUNT=1) — confirms the mutation reached the code the tests actually load, not just the source.
  • Reran the file's suite: Tests 2 failed | 13 passed (15), with ⑨ and ⑨(b) specifically red (the stranger is admitted once the ticket check is gone).
  • Restore via trap ... EXIT INT TERM: git diff HEAD -- auth-manager.ts byte count 0 after restore. Rebuilt again afterward: marker count 0 in the rebuilt dist/index.mjs, and the file's suite is back to 15 passed (15)dist/ is confirmed returned to the real implementation, not left mutated.

Gate family (re-derived from the actual diff at HEAD 4a07e99c4, scripts/pm/dispatch-gates.mjs)

38 commands derived. 35 exit 0. The remaining 3 are explicitly NOT MEASURED (their own output says so, exit code 3, distinct from a finding's exit 1), each requiring a full-monorepo dist/ closure this scoped local run does not build — CI's lint.yml builds that closure and runs these for real:

  • check-test-completeness — no local turbo run test log to parse.
  • check:dual-build-cjs-loads — PREREQUISITE NOT MET, 38 unrelated packages have no local dist/.
  • check:type-check-debt --re-measure — PREREQUISITE NOT MET, 28 workspace deps have no built type entry point locally.

None of the 3 touch plugin-auth, auth-manager.ts, or auth-plugin.ts in their own diagnostics.


🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

…ket to more than the seed address (#14373)
`stageOperatorProvisioning`/`isOperatorProvisioning` peeked the ticket by
email alone. The address is not a secret (documented default, printed on the
boot banner), so a stranger's own concurrent sign-up for the same address,
arriving while the ticket was staged, could satisfy an email-only peek at
both admission seams and be admitted as the operator class — and since a
unique-email constraint lets only one concurrent signUpEmail land, the
stranger's row could become the account at that address.
`stageOperatorProvisioning` now also generates a random ticket value and
returns it; the caller threads it into the same signUpEmail call's body
under the new `AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD` key.
`isOperatorProvisioning` now requires an exact match on both email and
ticket — a missing or mismatched ticket reads as "not provisioning", no
email-only fallback.
Disposition 3 only, per triage's ruling (14373#issuecomment-5504337158):
disposition 1 (document the trust assumption) is already landed and
untouched; disposition 2 (move the method off the barrel-public surface) is
a published-surface removal, the human floor, not touched here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 9 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx(via AuthManager (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via AuthManager (symbol, a top-level class))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2dc527df332cfdee110aa7ce0d5505506e2ea0ca — the merge of head 4a07e99c455a23037cb81900bc7fbeacebd214d8 into base 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2dc527df332cfdee110aa7ce0d5505506e2ea0ca && git checkout 2dc527df332cfdee110aa7ce0d5505506e2ea0ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 4a07e99c455a23037cb81900bc7fbeacebd214d8 && git checkout -B drift-repro 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 && git merge --no-ff 4a07e99c455a23037cb81900bc7fbeacebd214d8
node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review September 2, 2026 23:50
@os-project-manager
os-project-manager added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 7c342f4Sep 3, 2026
48 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14373-operator-ticket-binding branch September 3, 2026 01:32
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

3 participants

@os-sales@os-project-manager@claude