Skip to content

fix(plugin-auth)!: a cloud-managed environment refuses local self-registration, invitations (resends included), user creation and import, and sets passwords only on local accounts - #25399

Merged
hotlong merged 8 commits into
mainfrom
claude/issue-25083-cloud-managed-doors
Oct 23, 2026
Merged

hotlong merged 8 commits into
mainfrom
claude/issue-25083-cloud-managed-doors

Conversation

@hotlong

@hotlong hotlong commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Fixes #25083

Clause-②: yes (narrowing)
Metadata-protocol: none

What this does

This is the services half of the maintainer's ruling 6341690661 on #25083 (objectstack-ai/cloud#3132: hosted environments are managed; self-hosted is unaffected). On a cloud-managed environment, isCloudManaged() from @objectstack/core (#25306) narrows plugin-auth's local identity doors. With the variable unset, every door behaves exactly as on main.

Door Managed (OS_CLOUD_MANAGED=1)
Audience posture Only invite_only, at construction and on every merged applyConfigPatch
Email sign-up Both disableSignUp arms are forced on and advertised. The bootstrap owner's first sign-up still enters, as under SSO-only
organization/invite-member 409 RESOURCE_CONFLICT, refused at the route in the before hook, resends included. The beforeCreateInvitation arm stays as the second line
admin/create-user, admin/import-users 409 after the admin check (a non-admin still hears 403). Nothing is written
admin/set-user-password Refuses an idp_provisioned target with 409. An env_native target is still answered, so the break-glass admin stays editable
set-initial-password Only a caller with platform-admin standing (isPlatformAdminUser). Another signed-in caller gets 409; an anonymous caller still gets 401

The four raw routes answer the ADR-0112 envelope through cloudManagedRefusal(door). invite-member answers better-auth's own { code, message } body, as every org-hook refusal in this package does.

For the cloud's twin (objectstack-ai/cloud#3169, the per-environment set-initial-password the cloud mounts): the package entry now exports cloudManagedRefusal, CLOUD_MANAGED_REFUSAL_CODE, CLOUD_MANAGED_REFUSAL_STATUS, CLOUD_MANAGED_REFUSAL_MESSAGES and CloudManagedDoor. isPlatformAdminUser was already exported. That twin takes the same arm with the same refusal. This export is the widening in Clause-②: yes (narrowing); the door changes are the narrowing.

Provenance

Contract review (narrowing): PASS, with one should-fix fixed in this PR

The review was run at CONTRACT_REVIEW_TIER on head 5a3d27f6. Its findings and dispositions:

  1. should-fix, fixed. better-auth answers invite-member { resend: true } for a pending invitee before it consults beforeCreateInvitation. It extends expiresAt and re-sends the e-mail, and the console reaches that branch through sys_invitation's resend_invitation. The before hook now refuses the route under the managed mode. Pinned by a resend leg (409, expiry unchanged) and its unmanaged control (200, expiry extended). Ablating the new arm reds only the resend leg.
  2. nit, kept. set-user-password refuses source === 'idp_provisioned' and admits other values. A null source is answered as local, which matches the ruling's wording ("refuses idp_provisioned targets"). sys_user.source defaults to env_native.
  3. nit, fixed. The changeset now names both body shapes instead of claiming error.code everywhere.
  4. nit, noted. The JIT path is pinned through decideAudienceAdmission only. This diff does not touch that path.

Verification (head f37350ae)

  • @objectstack/plugin-auth:

    • test: Test Files 188 passed, Tests 3560 passed | 10 skipped;
    • typecheck (incl. check:test-typecheck): exit 0;
    • build: exit 0;
    • src/cloud-managed.test.ts: 32/32.
  • Ablations: each was committed first, run under a trap restore, and restored to HEAD.

    Ablation Result
    set-initial-password arm off 1 failed
    memo reset removed 5 failed
    package export removed 1 failed
    invite-member route arm off 1 failed
  • Full dogfood (owed by the (narrowing) arm), on the final head after building the @objectstack/dogfood... closure (66/66 tasks): Test Files 289 passed | 3 skipped, Tests 2491 passed | 18 skipped.

  • dispatch-gates.mjs derived 135 commands from this diff; 129 can run locally. 120 exit 0. The other 9 are not measured here and are left to CI:

    • 4 need a PR context: check-closing-target-claim, check-partof-closing-keyword, check-single-claim-paths, check-governed-queue-guard. check-partof-closing-keyword was also run locally against this body; see below.
    • 2 need a whole-workspace build: check:dual-build-cjs-loads, check:published-readme-exports.
    • 2 are check-issue-citations runs (plain and --census) whose board read was refused with HTTP 403. The plain run was measured green on the previous head, and this head adds no new issue citation.
    • 1 is select-shard-timings-run --self-test, which needs bash mapfile; macOS bash 3.2 lacks it.
  • app-shell, auth: on a cloud-managed environment (features.cloudManaged), Setup → Users says where to invite and marks an unauthorized user, and the console hides invite, import and the recovery-password reminder (objectstack#25083 ruling) objectui#13330 (the console half) remains open and is being built separately. objectstack-ai/cloud#3169 remains open until this lands in the cloud's pin.

🤖 Generated with Claude Code

claude and others added 6 commits October 22, 2026 19:50
… admin user and password doors (#25083)

Services half of ruling 6341690661, prepared before the engine lane's
isCloudManaged() lands in @objectstack/core. Not buildable on main yet.

Claude-Session: https://claude.ai/code/session_017XVJLoyGccDY1WPoqow5Ff
Co-authored-by: Claude <noreply@anthropic.com>
…oud-managed-doors

# Conflicts:
#	packages/plugins/plugin-auth/src/auth-manager.ts
…() after the merge, and the package entry exports the managed refusal for a host twin

After merging main (#25306 landed `isCloudManaged()` and its memoization,
#25273 moved the actor guards out of auth-manager.ts):

- auth-manager.ts no longer imports `hasPlatformAdminStanding`, which moved
  with the actor guards and is unused here now.
- The cloud-managed suite resets the memoized accessor on every switch
  (`resetCloudManagedForTesting()`), so each case reads its own setting.
- The package entry exports `cloud-managed.js` (`cloudManagedRefusal` and its
  constants), so the cloud's per-environment twin of `set-initial-password`
  answers with the same status, code and message; pinned with
  `isPlatformAdminUser` from the same entry.
- The changeset declares the widening beside the narrowing:
  `Clause-②: yes (narrowing)`.

Co-authored-by: Claude <noreply@anthropic.com>
…s at the route, so a resend of a pending invitation is refused too

better-auth answers `resend: true` for an invitee already pending before it
consults `beforeCreateInvitation`: it extends the row's `expiresAt` and sends
the invitation e-mail again. The console reaches that branch through
`sys_invitation`'s `resend_invitation` action. The `before` hook now refuses
`/organization/invite-member` under `isCloudManaged()` with the same
409 RESOURCE_CONFLICT; the hook arm stays as the second line.

Pinned with a resend leg (409, expiry unchanged) and its unmanaged control
(200, expiry extended). The changeset names the resend and the two body
shapes (ADR-0112 envelope on the raw routes, better-auth's own body on
invite-member).

Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 27 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 3306cada26d05160dadd704ea31105b2db48a884.

⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 42 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 19 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 3306cada26d05160dadd704ea31105b2db48a884 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 38b5e2c062f3076b268b5213d5d45c886572fefa — the merge of head e5f87a19ca1158715631528b15e9fe3c81647229 into base 3306cada26d05160dadd704ea31105b2db48a884, 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 38b5e2c062f3076b268b5213d5d45c886572fefa && git checkout 38b5e2c062f3076b268b5213d5d45c886572fefa
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3306cada26d05160dadd704ea31105b2db48a884 e5f87a19ca1158715631528b15e9fe3c81647229 && git checkout -B drift-repro 3306cada26d05160dadd704ea31105b2db48a884 && git merge --no-ff e5f87a19ca1158715631528b15e9fe3c81647229

node scripts/docs-audit/affected-docs.mjs --json 3306cada26d05160dadd704ea31105b2db48a884

⚠️ 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 3306cada26d05160dadd704ea31105b2db48a884 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…idation and handler, not its body parse

Co-authored-by: Claude <noreply@anthropic.com>
@hotlong

hotlong commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

Contract review

Head: 448de8ca
Served-tier: CONTRACT_REVIEW_TIER
Verdict: PASS
Reviewer: an at-tier subagent of the cloud ops seat (the dispatching seat, takeover claim 6359752007 on #25083). It was read-only.

Coverage:

  • Full review of the diff against the merge base at 5a3d27f6: PASS, one should-fix and four nits.
  • Delta review of 5a3d27f6..f37350ae: PASS, two nits and one observation.
  • f37350ae..448de8ca touches only the changeset's prose (delta nit 1).

Dispositions:

  • should-fix: invite-member resend: true ran ahead of beforeCreateInvitation. Fixed in f37350ae: a before-hook arm under isCloudManaged(), with a resend leg and its unmanaged control. The delta review confirmed its placement: nothing earlier in the hook returns or throws for that path, it runs before the vendor's validation, session and permission checks, and it discloses no more than the public features.cloudManaged.
  • nit: a null sys_user.source is answered as local by set-user-password. Kept. It follows the ruling's wording, and the column defaults to env_native.
  • nit: the changeset claimed error.code for every door. Fixed; it now names both body shapes.
  • nit: the changeset said "before better-auth reads the body". Fixed in 448de8ca.
  • nit: the JIT path is pinned through decideAudienceAdmission only. Noted; this diff does not touch that path.
  • nit: the new control runs unmanaged without SSO-only. Noted. The arm never reads SSO-only, and the existing SSO-only contrast leg still passes.

Declarations checked:

  • Clause-②: yes (narrowing): five added exports (the widening) and the managed-mode accept-set narrowing.
  • Metadata-protocol: none: no authorable surface moves.
  • patch level is consistent with check-changeset-no-major.mjs under ruling A.
  • check-adr-0087-registration passes with not-required (no-migration-prescription).

…oud-managed-doors

# Conflicts:
#	packages/plugins/plugin-auth/src/admin-user-endpoints.ts
#	packages/plugins/plugin-auth/src/auth-plugin.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants