Skip to content

Commit e47355b

Browse files
fix(auth, webhooks, messaging): runtime strings state each decision in words instead of a tracker number (stage 1) (#21218)
Part of #20751 Clause-②: no **Stage 1 of the `domain:services` lane under the maintainer's A / A ruling (5902360492): the `plugin-auth`, `plugin-webhooks` and `service-messaging` strings.** The card stays open for the later stages, so this PR carries no closing keyword. Text only: no status, error `code`, field, route, export or control flow moves. One module-private interface loses the two members that held the citations as data (see Text-only proof). ## What this does The three packages' refusals, thrown errors, boot and log lines, auth route-ledger notes and the `sys_http_delivery` field help (with its `es-ES`, `ja-JP` and `zh-CN` variants) sent the reader to a tracker number for the reason behind them. In form D, as the earlier stages of the other lanes applied it, the number goes. Where the sentence already said what was decided, only the citation goes. Where it leaned on the number, it now says the decision in words. All 84 ledgered occurrences in the three packages (claim `5936032644`), re-derived from the ledger on `origin/main` at `5e5ce48c`: `plugin-auth` 28 (21 pairs, 13 files), `plugin-webhooks` 33 (19 pairs, 8 files), `service-messaging` 23 (15 pairs, 8 files). They sit in 70 string sites. ### Rewritten in words Author- and administrator-visible text first, log lines last. | Where (head line) | Cited | The text now says | Decision read from | |---|---|---|---| | `service-messaging` `http-delivery.object.ts:237`, `attempts` help, and its three locale leaves | 8069 | "never sent, and not redeliverable, because it carries no signature" | the dev report on the card (the refusal keys on `attempts === 0` because a parked row has no signature) under the maintainer's minimal-cut ruling | | `service-messaging` `http-outbox.ts:514`, `DELIVERY_NEVER_SENT` message | 7799, 8069 | "so the delivery would go out UNSIGNED — which is never allowed" | 8069 ruling (redelivery never sends unsigned) | | `service-messaging` `http-outbox.ts:547`, `DELIVERY_NOT_ELIGIBLE` message | 8069 | "refusing rather than sending, because a check that could not run never reads as allowed" | 8069 (a guard that throws refuses: fail-closed) | | `service-messaging` `outbox.ts:187`, `ack()` refusal | 11453 | "there is deliberately no ack() spelling: cancelling a pending row is not part of this contract until a live consumer needs it" | triage scope ruling 5400898843 (no cancel member; deferred until a live consumer pulls on it) | | `plugin-webhooks` `auto-enqueuer.ts:536`, the parked row's `error` (shown in Setup) | 7799 or 7986, 8069 | "recording it here rather than delivered unsigned [or: without the headers it was authored with], and rather than discarding it without a trace" | 8069 ruling (a discarded event leaves a dead row, never an unsigned send) | | `plugin-webhooks` `redeliver-guard.ts:90` and `:123`, redeliver refusals | 8069; 7799, 8069 | "so it is refused rather than risk going out unsigned"; "refusing rather than sending it unsigned" | same | | `plugin-auth` `audience-posture.ts:245`, boot refusal | 5205 | "Refused, never coerced: an unrecognised value must not fall through to a more permissive posture than the one intended." | maintainer ruling on the card (shape 2) and ACCEPT 5181815596: an out-of-vocabulary value is refused loudly, never read as the permissive branch | | `plugin-auth` `internal-field-readback.ts:336`, thrown composition error | 8118 | "Wire the ObjectQL engine, which provides the accessor (an `internal` column is withheld from every ordinary read and recovered only through it)" | triage routing decision 5269775253 (route 3: redact on read, recover through a privileged accessor) | | `plugin-auth` `walled-owner-verification-path.ts:367`, boot warning | 12751 | "a declared owner is stamped verified only when an operator-provisioned path CREATES the account" | card title and body | | `plugin-auth` `objectql-adapter.ts:400`, warning | 5814 | "`$ieq` is deliberately deferred until there is demonstrated pull for it" | maintainer ruling 2026-08-09, option 3 | | `plugin-auth` `auth-route-ledger.ts:146`, change-email note | 7735, 8019 | "change-email was ruled table stakes, so it is wired rather than de-booked ... the OLD address also gets an auth.email_change_notice, so a hijacked session cannot move the account identity unannounced" | ruling 5261746059, and the 2026-08-12 ruling on 8019 (notify the old address, do not gate) | | `plugin-auth` `auth-route-ledger.ts:157`, delete-user note | 7735 | "self-service deletion needs a deliberate B2B design first, because it reaches record ownership and tenant data (maintainer ruling 2026-08-12)" | ruling 5261746059 | | `plugin-auth` `auth-route-ledger.ts:234`, import-users note | 2766 | "A dedicated identity-import door rather than the generic sys_user import, because a direct ObjectQL write would bypass better-auth's password hashing and mint no sys_account credential" | the decided items in the card body | | `plugin-auth` `auth-route-ledger.ts:236`, `:237`, SSO register notes | 9653 | "platform-admin gated ahead of the delegation (ADR-0068 D4), so registering an identity provider never rests on the vendor's own check alone" | triage ruling 5331642704 | | `plugin-auth` `auth-route-ledger.ts:254`, add-member note | 9941 | "on multi-org it is the only UI path that attaches an existing user to an organization" | card body (the admin-guarded wrapper over `auth.api.addMember`) | | `plugin-auth` `last-admin-guard.ts:795`, standing-key exclusion reason | 10982 | "The resolver now applies isGrantActive ..." (the sentence already carries the ruling: a lapsed membership is NO membership) | maintainer ruling A recorded on the card | | `plugin-auth` `last-admin-guard.ts:840`, standing-key exclusion reason | 11663 (L2) | "now that deployment config names the platform-admin holder, from the configured-and-verified email pair" | ruled direction in the card body | | `plugin-webhooks` `auto-enqueuer.ts:517`, error | 8069 | "DISCARDED WITH NO TRACE in sys_http_delivery — the very loss a parked subscription's dead row exists to prevent" | 8069 ruling | | `plugin-webhooks` `auto-enqueuer.ts:833`, warning | 3196 | "there is no manual fire path (the 'api' trigger was removed because nothing could fire it)" | card body (trim, since no fire path exists) | | `plugin-webhooks` `webhook-outbox-plugin.ts:343`, error | 7799, 8069 | "may send a delivery that can no longer be authenticated (an unsigned send, which webhook delivery otherwise never makes)" | 8069 ruling | | `plugin-webhooks` `bootstrap-declared-webhooks.ts:266`, warning | 7799 | "its signing secret cannot be stored encrypted, and it is never stored in cleartext instead" | card ACCEPT (refuse rather than fall back to cleartext) | | `plugin-webhooks` `migrate-webhook-secrets.ts:134`, `:135`, warnings | 7799, 7986 | "no CryptoProvider to move them into their encrypted columns"; "row left unchanged, still cleartext in definition_json" | both cards (the credentials move out of `definition_json` into encrypted columns) | | `plugin-auth` `two-factor-reenrollment-verified-reset.ts:130`, `:149`, `:170`, error lines | 10700 | "so a re-enrolled TOTP secret may be live at sign-in without ever having been confirmed" | triage route 5367978973 (reset `verified` so the new secret is inert until confirmed) | | `plugin-auth` `member-role-canonical.ts:259` (debug) and `:473` (warning) | 8317 | "so ObjectStack and better-auth read one role"; "the owner-versus-member inversion" | maintainer ruling, option A, and the card title | ### Citation only (the sentence already stated the decision) - `plugin-auth`: `adopt-membership.ts:246` (7725, "adopted the existing sys_member row instead of inserting a second one ... unique by declaration"); `auth-manager.ts:2837` (3585, "will now 500 every /get-session instead of degrading to a missing set-auth-jwt header"); `auth-plugin.ts:507`, `:511` (2780, 4790, the budget's name and its per-node impact); `last-admin-guard.ts:1215` (6084, "the state a DELETED 'admin_full_access' permission-set row leaves behind: it un-makes every platform admin at once"); `member-role-canonical.ts:463`, `:484` (8317); `objectql-adapter.ts:492` (5813, "refusing the query rather than dropping the predicate"); `otp-send-guard.ts:174` (4808, "bounded rather than silently truncated"); `sso-client-secret.ts:339` (8009, now naming the move out of cleartext `oidc_config`). - `plugin-webhooks`: `auto-enqueuer.ts:543`, `:764` (8022, "re-arms on registration and at the next periodic refresh"), `:742` (the say-once repeat line), `:756`, `:759` (7799 or 7986, 8069); `webhook-headers-gate.ts:155` (7986, 8558, 8566, "checked at the write door because one step later there is nothing left to check"); `webhook-headers.ts:253`, `:324`, `:335` (7986, 8558); `webhook-secret.ts:291`, `:301` (7799, 8542, "this is NOT an unsigned webhook, and delivering it unsigned would strip the receiver of its only proof of origin"). - `service-messaging`: `http-delivery.object.ts:176` (8118, "never returned on the generic data path ... recovers it through the engine's privileged accessor") and `:260` (8069); `http-outbox.ts:573` (8069); `sql-http-outbox.ts:327` (8118); `outbox.ts:210` (11453, 11859). Every cited card was read (REST, open or closed) before its string was rewritten. Two answer 404 on the issue itself, 11453 and 11859, and were read through their comment threads, which carry the scope ruling and the decision analysis. ### Translations - `attempts` help: the `es-ES`, `ja-JP` and `zh-CN` leaves were real translations; each now carries the same decision ("porque no lleva firma", "署名を持たないため", "因不带签名") and no number. - `headers_json` and `error` help: in all three locales these leaves were byte copies of the English source. They are now copies of the revised English, and `node scripts/check-i18n-bundles.mjs --write --filter=service-messaging` recorded their new digests in the three `*.source-hashes.generated.ts` companions. No digest was hand-edited; the `en` bundle was already byte-identical to what the extractor writes. - Tracker numbers left in the package's locale bundles: 0. ## Ledger (`scripts/doc-authoring-prose-id.baseline.json`) Recomputed with `node scripts/check-doc-authoring.mjs --census-ledger` (exit 0, no growth refusal) into a scratch file, then copied into place. The diff deletes 113 lines and adds none: exactly the 29 file blocks of the three packages. Every other row is byte-identical (a scripted comparison of every other key: 0 moved). | | before (`5e5ce48c`) | after | |---|---|---| | `plugin-auth` | 28 occurrences, 21 pairs, 13 files | 0 | | `plugin-webhooks` | 33 occurrences, 19 pairs, 8 files | 0 | | `service-messaging` | 23 occurrences, 15 pairs, 8 files | 0 | | whole ledger | 550 occurrences, 375 pairs, 147 files | 466 occurrences, 320 pairs, 118 files | `pnpm check:doc-authoring` at the head: "393 pinned site(s) across 118 file(s), 85833 string(s) read in 1245 parsed source(s), no growth, no burn-down unrecorded" (before: 463 sites across 147 files). No gate is added or loosened; `scripts/check-doc-authoring.mjs` is untouched. ## Changeset `.changeset/20751-services-strings-state-the-decision.md`: `patch` for `@objectstack/plugin-auth`, `@objectstack/plugin-webhooks` and `@objectstack/service-messaging`. Measured after building the three packages at the head: each new author-visible sentence named above is in its package's `dist/index.js` and `dist/index.cjs` (2 files each). A TypeScript scan of every string literal and template in the three packages' built `.js`/`.cjs` finds 0 tracker ids (the ids left in `dist` are in comments). Control: the same scan reads 86 in `plugin-security`'s built output and 66 in `service-automation`'s. The auth route ledger does not ship: `AUTH_ROUTE_LEDGER` is in 0 files under `packages/plugins/plugin-auth/dist`. ## Text-only proof A TypeScript-AST skeleton of each changed non-test `.ts` file, where every string literal and template text is a placeholder, a run of adjacent string operands of a `+` chain is one string (only its embedded expressions are kept), and comments and JSDoc are never read. `5e5ce48c` against the head `0bb7a64f`: 31 of 32 SAME, token counts identical per file. The one DIFF is `auto-enqueuer.ts`, and it is exactly the citations-as-data: the module-private `DropReason` interface loses its `issue` and `issues` members, the two credential constants lose those two properties, and three interpolations (`credential.issue` twice, `credential.issues` once) leave three templates. Nothing else in the file's skeleton moves. Control: the first draft of the tool compared only each file's first statement and reported `auto-enqueuer.ts` SAME; that miss is what exposed it, and the corrected tool reports the DIFF above. ## Pins Three assertions named the old strings and now name the new substance: - `auth-plugin.test.ts:1706`: `per-number OTP send budget bound to the kernel cache service`. - `last-admin-guard.test.ts:1905`, `:1908`: `toThrow(/state a DELETED 'admin_full_access' permission-set row leaves behind/)` instead of the bare citation. The sibling test still asserts `code: 'PERMISSION_DENIED'`, `status: 403` on the same refusal. - `scim-case-insensitive-identifier.test.ts:357`: `` `$ieq` is deliberately deferred until there is demonstrated pull `` instead of the citation. Every other old fragment was searched repo-wide. The remaining hits are comments, test titles and assertion labels, and two test files (`rate-limit-storage.test.ts`, `otp-send-guard.test.ts`) that pass their OWN subject string into the counter store and assert it back; none reads production text. No `code` or `status` assertion was touched. ## Tests All at head `0bb7a64f`, through `scripts/pm/os-verify-lock.sh`, every verdict `VERDICT command-exit 0`: - Build: `turbo run build` over the closure of the three packages and `@objectstack/cli` (59/59 tasks), then the three packages rebuilt at the head, then `turbo run build --filter=./packages/* --filter=./packages/*/*` (71/71). - `@objectstack/plugin-auth`: 116 files, 2484 tests passed. - `@objectstack/plugin-webhooks`: 13 files, 160 tests passed. `@objectstack/service-messaging`: 46 files, 507 tests passed. - `typecheck` for all three, including `check:test-typecheck: OK` for `plugin-auth` and `plugin-webhooks`. ## Gates - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (no paths) at `0bb7a64f`: 75 commands, run one at a time from the worktree, each exit code recorded before any pipe. `--ran`: "75 derived famil(ies) accounted for — 75 run, 0 NOT-MEASURED (a DERIVED zero — all 75 recorded an exit code and none of them is 3)". - `check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (exit 3: only the closure was built). After the full package build it and the other dist-reading gates were re-run, all exit 0: 105 require entry points across 66 packages load; `check:dts-closure` 71 built packages, 167/167 declaration files; `check:sourcemap-no-sources-content` 68 packages, 522 maps; `check:lean-entry-closure` and `check:published-files` green. - `check:i18n`: "all bundles in sync"; `check:i18n-stale-fill`: "0 stale-fill leaf/leaves"; `check:issue-citations`: "no issue citations added against 5e5ce48 (32 file(s) read)"; `check:nul-bytes`: OK, 9850 files; `check:type-check-debt`: "none above its recorded number". - Outside the derived set, all exit 0: the ten declared wide-population families (`check:init-service-contract`, `check:live-db-isolation`, `check:meta-type-normalized`, `check:optional-error-sink`, `check:resume-authority-declared`, `check:runner-env-posture`, `check:settings-bind-window`, `check:startup-registry-verdict`, `check:verify-stand-in`, `check:wildcard-fallthrough`) and the artifact-roster families whose roster sits under one of this PR's directories (`check-changeset-fixed`, `check-published-list-mirrors` and its self-test, `check:authz-resolver`, `check:console-injection`, `check:error-code-casing`, `check:filter-alias-parity`, `check:published-readme-exports`, `check-dts-references --self-test`). The path-scheduled CI jobs and the type-check lanes are CI's. - `pnpm lint` (`eslint . --no-inline-config`, repo-wide, not narrowed) at `0bb7a64f`: exit 0, 101 s under the lock. ## Acceptance notes - `origin/main` moved to `862f12c0` (four commits) after the branch point. None touches any of this PR's 37 paths, and none adds or removes an id-bearing line in a non-test source under `packages/`, so the recomputed ledger stands on the merged tree. The branch was not merged with `main`; the queue rebuilds it there. - Comments in the three packages still cite numbers: a comment is the sanctioned home for an internal anchor, and dead citations in this lane's comments are a sibling card's work. - `docs/qa/platform-checklist/areas/integration-system.json:572` cites 7799 and 8069 in its own clause prose; it quotes none of the text changed here. - The remaining packages of this lane's share are later stages. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c6b6889 commit e47355b

37 files changed

Lines changed: 113 additions & 206 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/plugin-webhooks': patch
4+
'@objectstack/service-messaging': patch
5+
---
6+
7+
Auth, webhook and outbound-delivery refusals, warnings and field help no longer cite tracker numbers; each one states the decision behind it in words
8+
9+
Clause-②: no
10+
11+
Some strings these three packages show to operators, administrators and callers pointed at an issue-tracker number for the reason behind them. The number goes; where the sentence did not already say what was decided, it now does.
12+
13+
- `@objectstack/plugin-auth`: an unrecognised audience posture is refused because it must not fall through to a more permissive posture than the one intended; the `ObjectQL` adapter's case-insensitive warning says the `$ieq` operator is deliberately deferred until there is demonstrated pull for it; the `internal`-column refusal says the column is withheld from every ordinary read and recovered only through the engine's accessor; the 2FA re-enrollment errors say a re-enrolled TOTP secret may be live at sign-in without having been confirmed; the walled-owner boot warning says a declared owner is stamped verified only when an operator-provisioned path creates the account; the OTP send-budget lines name the budget without a number.
14+
- `@objectstack/plugin-webhooks`: the parked-event record says the event is recorded rather than delivered unsigned (or without its authored headers) and rather than discarded without a trace; the redeliver refusals say a delivery that cannot be signed is refused rather than sent unsigned; the zero-trigger warning says the `api` trigger was removed because nothing could fire it; the seed and legacy-migration warnings say a credential is never stored in cleartext instead and that a failed migration leaves it cleartext in `definition_json`.
15+
- `@objectstack/service-messaging`: the `sys_http_delivery` field help for `attempts` (in every shipped locale) says a parked row is not redeliverable because it carries no signature; the `headers_json` and `error` help and the outbox refusals drop their citations; the notification `ack()` refusal says cancelling a pending row is not part of the outbox contract until a live consumer needs it.
16+
17+
Text only: no status, error code, field, route or control flow moves. A client or log filter that matches the old text (for example a tracker-number suffix) needs the new spelling.

‎packages/plugins/plugin-auth/src/adopt-membership.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ export async function adoptExistingMembership(
243243
// reporting. Keep the `console.info` default AND the receiver.
244244
const line =
245245
`[membership] adopted the existing sys_member row instead of inserting a second one ` +
246-
`(${decision.verdict}) — the (organization_id, user_id) pair is unique by declaration [#7725]`;
246+
`(${decision.verdict}) — the (organization_id, user_id) pair is unique by declaration`;
247247
const meta = {
248248
memberId: existing.id,
249249
organizationId,

‎packages/plugins/plugin-auth/src/audience-posture.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ export function assertAudienceConfig(
242242
if (rawPosture !== undefined && !isAudiencePosture(rawPosture)) {
243243
fail(
244244
`posture ${describeAudiencePosture(rawPosture)} is not a recognized audience posture — ` +
245-
`expected one of: ${AUDIENCE_POSTURES.join(', ')}. Refused, never coerced (#5205 fail-open precedent).`,
245+
`expected one of: ${AUDIENCE_POSTURES.join(', ')}. Refused, never coerced: an unrecognised value must not fall through to a more permissive posture than the one intended.`,
246246
);
247247
}
248248
const posture: AudiencePosture = (rawPosture as AudiencePosture | undefined) ?? 'invite_only';

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2834,7 +2834,7 @@ export class AuthManager {
28342834
console.error(
28352835
'[AuthManager] Could not attach the JWT signing guard to better-auth\'s /get-session hook ' +
28362836
'(its `hooks.after` shape changed). A JWT signing failure will now 500 every /get-session ' +
2837-
'instead of degrading to a missing set-auth-jwt header. See objectstack#3585 and re-check ' +
2837+
'instead of degrading to a missing set-auth-jwt header. Re-check ' +
28382838
'the better-auth version in better-auth-schema-parity.test.ts.',
28392839
);
28402840
}

‎packages/plugins/plugin-auth/src/auth-plugin.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1703,7 +1703,7 @@ describe('AuthPlugin', () => {
17031703
// Bound → an info line and NO warning: the operator must be able to
17041704
// tell "shared" from "degraded" without reading the code.
17051705
const info = (ctx.logger.info as any).mock.calls.map((c: any[]) => String(c[0]));
1706-
expect(info.some((m: string) => m.includes('per-number OTP send budget (#2780) bound to the kernel cache service'))).toBe(true);
1706+
expect(info.some((m: string) => m.includes('per-number OTP send budget bound to the kernel cache service'))).toBe(true);
17071707
expect((ctx.logger.warn as any).mock.calls
17081708
.map((c: any[]) => String(c[0]))
17091709
.filter((m: string) => m.includes('per-number OTP send budget'))).toEqual([]);

‎packages/plugins/plugin-auth/src/auth-plugin.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -504,11 +504,11 @@ export class AuthPlugin implements Plugin {
504504
authConfig.sharedCounterStore = createLazyCounterStore({
505505
resolveCache,
506506
logger: ctx.logger,
507-
subject: 'per-number OTP send budget (#2780)',
507+
subject: 'per-number OTP send budget',
508508
degradedImpact:
509509
'The budget is still enforced, but PER NODE: every node grants the same phone number its own ' +
510510
'cooldown and hourly cap, so an N-node deployment can send up to N× the configured number of ' +
511-
'PAID SMS to one number (#4790)',
511+
'PAID SMS to one number',
512512
});
513513
}
514514

‎packages/plugins/plugin-auth/src/auth-route-ledger.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ export interface AuthRouteLedgerEntry {
143143
}
144144

145145
export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [
146-
{ route: 'POST /api/v1/auth/change-email', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.changeEmail', note: 'live since #7735: auth-manager.ts sets user.changeEmail.enabled, and the confirmation link rides emailVerification.sendVerificationEmail to the NEW address; since #8019 the OLD address also gets an auth.email_change_notice (notification only — sendChangeEmailConfirmation stays off, it is a gate not a notifier)' },
146+
{ route: 'POST /api/v1/auth/change-email', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.changeEmail', note: 'live: auth-manager.ts sets user.changeEmail.enabled (change-email was ruled table stakes, so it is wired rather than de-booked), and the confirmation link rides emailVerification.sendVerificationEmail to the NEW address; the OLD address also gets an auth.email_change_notice, so a hijacked session cannot move the account identity unannounced (notification only — sendChangeEmailConfirmation stays off, it is a gate not a notifier)' },
147147
{ route: 'POST /api/v1/auth/change-password', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.changePassword' },
148148
// #7735 — self-service account deletion is NOT wired, and this row says so
149149
// rather than booking it as a live SDK surface. Maintainer ruling
@@ -154,7 +154,7 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [
154154
// was itself broken — has since expired (#7724 landed), and the conclusion
155155
// does not move with it: the design question is the standing one, and a
156156
// future design starts from #7724's deletion semantics.
157-
{ route: 'POST /api/v1/auth/delete-user', family: 'core-auth', source: 'better-auth', disposition: 'disabled', client: 'auth.deleteUser', note: 'better-auth publishes the endpoint but user.deleteUser is deliberately unconfigured, so it answers 404 (as does its GET /delete-user/callback half); self-service deletion needs a deliberate B2B design first — maintainer ruling 2026-08-12 on #7735' },
157+
{ route: 'POST /api/v1/auth/delete-user', family: 'core-auth', source: 'better-auth', disposition: 'disabled', client: 'auth.deleteUser', note: 'better-auth publishes the endpoint but user.deleteUser is deliberately unconfigured, so it answers 404 (as does its GET /delete-user/callback half); self-service deletion needs a deliberate B2B design first, because it reaches record ownership and tenant data (maintainer ruling 2026-08-12)' },
158158
{ route: 'GET /api/v1/auth/get-session', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.me', note: 'auth.me, auth.refreshToken and organizations.getActiveMember all target it — the last one reads only the caller\'s own user id from it, as step 1 of its two-request self-membership lookup' },
159159
{ route: 'POST /api/v1/auth/link-social', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.accounts.linkSocial' },
160160
{ route: 'GET /api/v1/auth/list-accounts', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.accounts.list' },
@@ -231,10 +231,10 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [
231231
// `requires` follows the add-member precedent: it names the better-auth
232232
// plugin the route's WORK needs, not whether the mount is conditional —
233233
// every one of these is mounted unconditionally on the raw app.
234-
{ route: 'POST /api/v1/auth/admin/import-users', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — objectui app-shell\'s identity-import wizard (views/identityImport.ts) posts it directly from the Users list; platform-admin gated (ADR-0068), #2766 V2' },
234+
{ route: 'POST /api/v1/auth/admin/import-users', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — objectui app-shell\'s identity-import wizard (views/identityImport.ts) posts it directly from the Users list; platform-admin gated (ADR-0068). A dedicated identity-import door rather than the generic sys_user import, because a direct ObjectQL write would bypass better-auth\'s password hashing and mint no sys_account credential' },
235235
{ route: 'POST /api/v1/auth/admin/oauth2/toggle-disabled', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the sys_oauth_application disable/enable actions post it directly; ObjectStack mount closing a vendor gap (better-auth\'s /admin/oauth2/update-client strips `disabled` from its body schema), platform-admin gated (ADR-0068)' },
236-
{ route: 'POST /api/v1/auth/admin/sso/register', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', requires: 'sso', note: 'no SDK method builds this URL — the sys_sso_provider register action posts flat form fields; ObjectStack bridge re-dispatching into @better-auth/sso /sso/register, platform-admin gated ahead of the delegation (ADR-0068 D4, #9653). Distinct path from the vendor\'s own /sso/register, which the catch-all serves' },
237-
{ route: 'POST /api/v1/auth/admin/sso/register-saml', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', requires: 'sso', note: 'no SDK method builds this URL — the sys_sso_provider register_saml_provider action posts flat fields the bridge reshapes into better-auth\'s nested samlConfig; platform-admin gated (ADR-0068 D4, #9653), ADR-0069 P3' },
236+
{ route: 'POST /api/v1/auth/admin/sso/register', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', requires: 'sso', note: 'no SDK method builds this URL — the sys_sso_provider register action posts flat form fields; ObjectStack bridge re-dispatching into @better-auth/sso /sso/register, platform-admin gated ahead of the delegation (ADR-0068 D4), so registering an identity provider never rests on the vendor\'s own check alone. Distinct path from the vendor\'s own /sso/register, which the catch-all serves' },
237+
{ route: 'POST /api/v1/auth/admin/sso/register-saml', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', requires: 'sso', note: 'no SDK method builds this URL — the sys_sso_provider register_saml_provider action posts flat fields the bridge reshapes into better-auth\'s nested samlConfig; platform-admin gated ahead of the delegation (ADR-0068 D4), ADR-0069 P3' },
238238
{ route: 'POST /api/v1/auth/admin/sso/request-domain-verification', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', requires: 'sso', note: 'no SDK method builds this URL — the sys_sso_provider action posts it and renders the returned DNS TXT record; ObjectStack bridge over @better-auth/sso, additionally gated on the opt-in ssoDomainVerification switch (OS_SSO_DOMAIN_VERIFICATION) — off means the inner endpoint 404s, the mount itself is unconditional; platform-admin gated (ADR-0068 D4), ADR-0135 D6' },
239239
{ route: 'POST /api/v1/auth/admin/sso/verify-domain', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', requires: 'sso', note: 'no SDK method builds this URL — the sys_sso_provider action posts it after the DNS TXT record is published; same opt-in ssoDomainVerification switch and platform-admin gate as request-domain-verification (ADR-0068 D4), ADR-0135 D6' },
240240
// #16678 — the tracker id stays HERE and never in the `note` string:
@@ -251,7 +251,7 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [
251251
// ahead of the catch-all, behind the ADR-0068 platform-admin gate — it
252252
// restores the URL the `sys_member` `add_member` toolbar action has always
253253
// targeted (on multi-org the only UI path to attach an existing user).
254-
{ route: 'POST /api/v1/auth/organization/add-member', family: 'organization', source: 'objectstack', disposition: 'server-only', requires: 'organization', note: 'no SDK method builds this URL — the sys_member add_member action posts it directly; ObjectStack mount wrapping the vendor server-only auth.api.addMember, platform-admin gated (ADR-0068), #9941' },
254+
{ route: 'POST /api/v1/auth/organization/add-member', family: 'organization', source: 'objectstack', disposition: 'server-only', requires: 'organization', note: 'no SDK method builds this URL — the sys_member add_member action posts it directly; ObjectStack mount wrapping the vendor server-only auth.api.addMember, platform-admin gated (ADR-0068); on multi-org it is the only UI path that attaches an existing user to an organization' },
255255
{ route: 'POST /api/v1/auth/organization/add-team-member', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.teams.addMember', requires: 'organization' },
256256
{ route: 'POST /api/v1/auth/organization/cancel-invitation', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.invitations.cancel', requires: 'organization' },
257257
{ route: 'POST /api/v1/auth/organization/create', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.create', requires: 'organization' },

‎packages/plugins/plugin-auth/src/internal-field-readback.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,8 @@ async function recoverColumns(
333333
`${objectName} rows were read back without '${field}' (the engine's \`internal: true\` `
334334
+ 'strip ran) but this engine offers no `resolveInternalField` accessor to recover it. '
335335
+ `${FAIL_CLOSED_CONSEQUENCE[objectName] ?? 'better-auth would observe an incomplete row'}. `
336-
+ 'Wire the ObjectQL engine (which provides the accessor, #8118), or remove the '
336+
+ 'Wire the ObjectQL engine, which provides the accessor (an `internal` column is withheld from '
337+
+ 'every ordinary read and recovered only through it), or remove the '
337338
+ `\`internal\` flag from ${objectName}.${field}.`,
338339
);
339340
}

‎packages/plugins/plugin-auth/src/last-admin-guard.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1902,10 +1902,10 @@ describe('[#6084] a zero-administrator reading is no longer automatically the bo
19021902
it('…and the user delete and the grant revoke with it — all three halves stay on', async () => {
19031903
const engine = await wipedEnvironment();
19041904

1905-
await expect(removeUser(engine, 'usr_platform')).rejects.toThrow(/#6084/);
1905+
await expect(removeUser(engine, 'usr_platform')).rejects.toThrow(/state a DELETED 'admin_full_access' permission-set row leaves behind/);
19061906
await expect(
19071907
engine.delete('sys_user_permission_set', { where: { id: 'ups_usr_platform' }, ...SYSTEM }),
1908-
).rejects.toThrow(/#6084/);
1908+
).rejects.toThrow(/state a DELETED 'admin_full_access' permission-set row leaves behind/);
19091909
expect(await userExists(engine, 'usr_platform')).toBe(true);
19101910
expect(await rowExists(engine, 'sys_user_permission_set', 'ups_usr_platform')).toBe(true);
19111911
});

‎packages/plugins/plugin-auth/src/last-admin-guard.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -792,7 +792,7 @@ export const STANDING_KEY_EXCLUSIONS: Readonly<Record<string, Readonly<Record<st
792792
'ADR-0091 windows are still not columns on `sys_member` (`sys-member.object.ts` declares '
793793
+ 'neither bound, AUTH_MEMBER_SCHEMA maps neither), and the engine refuses an undeclared '
794794
+ 'write key on both verbs (the declared-field door) — so no stored membership row carries '
795-
+ 'a bound and no write can smuggle one in. Since #10982 the resolver applies isGrantActive '
795+
+ 'a bound and no write can smuggle one in. The resolver now applies isGrantActive '
796796
+ 'to the membership rows of the principal in BOTH derivations off one `sys_member` read — '
797797
+ '`accessible_org_ids` AND the org-administration role projection it feeds `positions` '
798798
+ 'from (maintainer ruling 2026-08-22: a lapsed membership is NO membership) — so the '
@@ -837,7 +837,8 @@ export const STANDING_KEY_EXCLUSIONS: Readonly<Record<string, Readonly<Record<st
837837
+ 'key, so a key rewrite is not expressible through this write path at all.',
838838
ai_access:
839839
'cloud ADR-0024 `ai_seat` synthesis (§7). It grants an AI seat, never administrator standing: the '
840-
+ 'posture rung is derived from the unscoped `admin_full_access` grant and, since #11663 L2, '
840+
+ 'posture rung is derived from the unscoped `admin_full_access` grant and, now that deployment '
841+
+ 'config names the platform-admin holder, '
841842
+ 'from the configured-and-verified email pair — never from this flag. Emptying it costs the '
842843
+ 'holder their AI seat, which is an ADR-0086 capability question with an in-product remedy, '
843844
+ 'not a break-glass one.',
@@ -1211,7 +1212,7 @@ export function registerLastAdminGuard(
12111212
`the bootstrap window — ${dangling.length} unscoped, in-window '${USER_PERMISSION_SET}' ` +
12121213
`grant(s) still point at a '${SystemObjectName.PERMISSION_SET}' row that no longer exists ` +
12131214
`(${dangling.join(', ')}). That is the state a DELETED '${ADMIN_FULL_ACCESS}' ` +
1214-
'permission-set row leaves behind (#6084): it un-makes every platform admin at once, and ' +
1215+
'permission-set row leaves behind: it un-makes every platform admin at once, and ' +
12151216
'reading the resulting emptiness as "no administrator to protect" would switch this guard ' +
12161217
`off for every other write too (${BREAK_GLASS_CITATION}). Restore the ` +
12171218
`'${ADMIN_FULL_ACCESS}' permission set — the grants naming it are still there — before ` +

0 commit comments

Comments
 (0)