Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/20751-services-strings-state-the-decision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/plugin-auth': patch
'@objectstack/plugin-webhooks': patch
'@objectstack/service-messaging': patch
---

Auth, webhook and outbound-delivery refusals, warnings and field help no longer cite tracker numbers; each one states the decision behind it in words

Clause-②: no

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.

- `@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.
- `@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`.
- `@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.

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.
2 changes: 1 addition & 1 deletion packages/plugins/plugin-auth/src/adopt-membership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ export async function adoptExistingMembership(
// reporting. Keep the `console.info` default AND the receiver.
const line =
`[membership] adopted the existing sys_member row instead of inserting a second one ` +
`(${decision.verdict}) — the (organization_id, user_id) pair is unique by declaration [#7725]`;
`(${decision.verdict}) — the (organization_id, user_id) pair is unique by declaration`;
const meta = {
memberId: existing.id,
organizationId,
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/plugin-auth/src/audience-posture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ export function assertAudienceConfig(
if (rawPosture !== undefined && !isAudiencePosture(rawPosture)) {
fail(
`posture ${describeAudiencePosture(rawPosture)} is not a recognized audience posture — ` +
`expected one of: ${AUDIENCE_POSTURES.join(', ')}. Refused, never coerced (#5205 fail-open precedent).`,
`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.`,
);
}
const posture: AudiencePosture = (rawPosture as AudiencePosture | undefined) ?? 'invite_only';
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2834,7 +2834,7 @@ export class AuthManager {
console.error(
'[AuthManager] Could not attach the JWT signing guard to better-auth\'s /get-session hook ' +
'(its `hooks.after` shape changed). A JWT signing failure will now 500 every /get-session ' +
'instead of degrading to a missing set-auth-jwt header. See objectstack#3585 and re-check ' +
'instead of degrading to a missing set-auth-jwt header. Re-check ' +
'the better-auth version in better-auth-schema-parity.test.ts.',
);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/plugin-auth/src/auth-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1703,7 +1703,7 @@ describe('AuthPlugin', () => {
// Bound → an info line and NO warning: the operator must be able to
// tell "shared" from "degraded" without reading the code.
const info = (ctx.logger.info as any).mock.calls.map((c: any[]) => String(c[0]));
expect(info.some((m: string) => m.includes('per-number OTP send budget (#2780) bound to the kernel cache service'))).toBe(true);
expect(info.some((m: string) => m.includes('per-number OTP send budget bound to the kernel cache service'))).toBe(true);
expect((ctx.logger.warn as any).mock.calls
.map((c: any[]) => String(c[0]))
.filter((m: string) => m.includes('per-number OTP send budget'))).toEqual([]);
Expand Down
4 changes: 2 additions & 2 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,11 +504,11 @@ export class AuthPlugin implements Plugin {
authConfig.sharedCounterStore = createLazyCounterStore({
resolveCache,
logger: ctx.logger,
subject: 'per-number OTP send budget (#2780)',
subject: 'per-number OTP send budget',
degradedImpact:
'The budget is still enforced, but PER NODE: every node grants the same phone number its own ' +
'cooldown and hourly cap, so an N-node deployment can send up to N× the configured number of ' +
'PAID SMS to one number (#4790)',
'PAID SMS to one number',
});
}

Expand Down
12 changes: 6 additions & 6 deletions packages/plugins/plugin-auth/src/auth-route-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export interface AuthRouteLedgerEntry {
}

export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [
{ 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)' },
{ 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)' },
{ route: 'POST /api/v1/auth/change-password', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.changePassword' },
// #7735 — self-service account deletion is NOT wired, and this row says so
// rather than booking it as a live SDK surface. Maintainer ruling
Expand All @@ -154,7 +154,7 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [
// was itself broken — has since expired (#7724 landed), and the conclusion
// does not move with it: the design question is the standing one, and a
// future design starts from #7724's deletion semantics.
{ 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' },
{ 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)' },
{ 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' },
{ route: 'POST /api/v1/auth/link-social', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.accounts.linkSocial' },
{ route: 'GET /api/v1/auth/list-accounts', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.accounts.list' },
Expand Down Expand Up @@ -231,10 +231,10 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [
// `requires` follows the add-member precedent: it names the better-auth
// plugin the route's WORK needs, not whether the mount is conditional —
// every one of these is mounted unconditionally on the raw app.
{ 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' },
{ 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' },
{ 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)' },
{ 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' },
{ 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' },
{ 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' },
{ 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' },
{ 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' },
{ 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' },
// #16678 — the tracker id stays HERE and never in the `note` string:
Expand All @@ -251,7 +251,7 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [
// ahead of the catch-all, behind the ADR-0068 platform-admin gate — it
// restores the URL the `sys_member` `add_member` toolbar action has always
// targeted (on multi-org the only UI path to attach an existing user).
{ 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' },
{ 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' },
{ route: 'POST /api/v1/auth/organization/add-team-member', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.teams.addMember', requires: 'organization' },
{ route: 'POST /api/v1/auth/organization/cancel-invitation', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.invitations.cancel', requires: 'organization' },
{ route: 'POST /api/v1/auth/organization/create', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.create', requires: 'organization' },
Expand Down
3 changes: 2 additions & 1 deletion packages/plugins/plugin-auth/src/internal-field-readback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,8 @@ async function recoverColumns(
`${objectName} rows were read back without '${field}' (the engine's \`internal: true\` `
+ 'strip ran) but this engine offers no `resolveInternalField` accessor to recover it. '
+ `${FAIL_CLOSED_CONSEQUENCE[objectName] ?? 'better-auth would observe an incomplete row'}. `
+ 'Wire the ObjectQL engine (which provides the accessor, #8118), or remove the '
+ 'Wire the ObjectQL engine, which provides the accessor (an `internal` column is withheld from '
+ 'every ordinary read and recovered only through it), or remove the '
+ `\`internal\` flag from ${objectName}.${field}.`,
);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/plugins/plugin-auth/src/last-admin-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1902,10 +1902,10 @@ describe('[#6084] a zero-administrator reading is no longer automatically the bo
it('…and the user delete and the grant revoke with it — all three halves stay on', async () => {
const engine = await wipedEnvironment();

await expect(removeUser(engine, 'usr_platform')).rejects.toThrow(/#6084/);
await expect(removeUser(engine, 'usr_platform')).rejects.toThrow(/state a DELETED 'admin_full_access' permission-set row leaves behind/);
await expect(
engine.delete('sys_user_permission_set', { where: { id: 'ups_usr_platform' }, ...SYSTEM }),
).rejects.toThrow(/#6084/);
).rejects.toThrow(/state a DELETED 'admin_full_access' permission-set row leaves behind/);
expect(await userExists(engine, 'usr_platform')).toBe(true);
expect(await rowExists(engine, 'sys_user_permission_set', 'ups_usr_platform')).toBe(true);
});
Expand Down
7 changes: 4 additions & 3 deletions packages/plugins/plugin-auth/src/last-admin-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -792,7 +792,7 @@ export const STANDING_KEY_EXCLUSIONS: Readonly<Record<string, Readonly<Record<st
'ADR-0091 windows are still not columns on `sys_member` (`sys-member.object.ts` declares '
+ 'neither bound, AUTH_MEMBER_SCHEMA maps neither), and the engine refuses an undeclared '
+ 'write key on both verbs (the declared-field door) — so no stored membership row carries '
+ 'a bound and no write can smuggle one in. Since #10982 the resolver applies isGrantActive '
+ 'a bound and no write can smuggle one in. The resolver now applies isGrantActive '
+ 'to the membership rows of the principal in BOTH derivations off one `sys_member` read — '
+ '`accessible_org_ids` AND the org-administration role projection it feeds `positions` '
+ 'from (maintainer ruling 2026-08-22: a lapsed membership is NO membership) — so the '
Expand Down Expand Up @@ -837,7 +837,8 @@ export const STANDING_KEY_EXCLUSIONS: Readonly<Record<string, Readonly<Record<st
+ 'key, so a key rewrite is not expressible through this write path at all.',
ai_access:
'cloud ADR-0024 `ai_seat` synthesis (§7). It grants an AI seat, never administrator standing: the '
+ 'posture rung is derived from the unscoped `admin_full_access` grant and, since #11663 L2, '
+ 'posture rung is derived from the unscoped `admin_full_access` grant and, now that deployment '
+ 'config names the platform-admin holder, '
+ 'from the configured-and-verified email pair — never from this flag. Emptying it costs the '
+ 'holder their AI seat, which is an ADR-0086 capability question with an in-product remedy, '
+ 'not a break-glass one.',
Expand Down Expand Up @@ -1211,7 +1212,7 @@ export function registerLastAdminGuard(
`the bootstrap window — ${dangling.length} unscoped, in-window '${USER_PERMISSION_SET}' ` +
`grant(s) still point at a '${SystemObjectName.PERMISSION_SET}' row that no longer exists ` +
`(${dangling.join(', ')}). That is the state a DELETED '${ADMIN_FULL_ACCESS}' ` +
'permission-set row leaves behind (#6084): it un-makes every platform admin at once, and ' +
'permission-set row leaves behind: it un-makes every platform admin at once, and ' +
'reading the resulting emptiness as "no administrator to protect" would switch this guard ' +
`off for every other write too (${BREAK_GLASS_CITATION}). Restore the ` +
`'${ADMIN_FULL_ACCESS}' permission set — the grants naming it are still there — before ` +
Expand Down
Loading
Loading