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
50 changes: 50 additions & 0 deletions .changeset/durability-swallow-family-census.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
"@objectstack/plugin-auth": patch
"@objectstack/plugin-sharing": patch
---

fix(plugin-auth,plugin-sharing): a refused bootstrap write stops reading as a clean one (#12981)

Two boot-time seams answered a REFUSED write exactly the way they answer a
write there was no need to make. Nothing else failed on either path, so the
deployment kept looking healthy — the durability class AGENTS.md separates
from the functional one, and the class `check-durability-degradation-log-level`
exists for and, at these two sites, structurally cannot see (it matches callee
NAMES from an 18-entry vocabulary, and a seeder reaching storage through
`ql.insert` is not in it; a green there means NOT MEASURED for the site, never
"level approved").

**`plugin-auth` — `ensureDefaultOrganization` reported at `warn`.** A refused
`sys_organization` or `sys_member` insert leaves the platform admin with no
organization: under multi-org the default `tenant_isolation` RLS policy filters
their console to zero rows, and under single-org better-auth has no active org
to resolve, so there is no way to add a user at all (ADR-0081 D1). Both lines
now report at `error` and each names the consequence AND the remedy, per
"Degradation log levels". `BootstrapLogger` gains an OPTIONAL `error`
(`message, error?, meta?` — the spec `Logger` arity, so the kernel logger
satisfies it as-is) beside its already-required `warn`; the fallback to `warn`
is mandatory and lives in one helper so no site can forget it. Additive: a host
passing `{ info, warn }` compiles and behaves exactly as before, and gets the
same line on the `warn` channel.

**`plugin-sharing` — `backfillPrimaryBu` printed NOTHING when every row was
refused.** Its per-row `catch { }` counted nothing and its report was gated on
`updated > 0`, so a pass in which every `sys_user` write was refused emitted
byte-identical output to a pass with nothing to do, while every affected user
kept a stale or absent `primary_business_unit_id` and every sharing rule keyed
on the primary business unit evaluated against the wrong value. Refusals are
now counted, reported once with the consequence and the remedy, and the summary
branch is `updated > 0 || refused > 0` — the same suppressor, repaired the same
way, as `permission-set-drift.ts` in #12970. `backfillPrimaryBu` now answers
`{ updated, refused }`; the added field is additive and its only in-tree caller
ignores the result.

`patch` rather than `minor` for both: no entry-barrel surface is added, no
command or flag, and neither change can turn a previously accepted call into a
rejected one. The `plugin-sharing` report deliberately stays on `warn` even
though the consequence is durability-shaped — `OptionalSharingLogger`'s own
header forbids growing an `error`, and giving that function a stricter sink
means requiring `warn` on a publicly exported shape, which
`scripts/optional-error-sink-contract.baseline.json` records in as many words
as #10556's contract call. What is fixed here is the SILENCE, which needed no
contract at all; the LEVEL belongs to that card.
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,83 @@ describe('ensureDefaultOrganization (plugin-auth home)', () => {
expect(res.memberCreated).toBe(true);
expect(ql.tables.sys_member).toHaveLength(1);
});

// [#12981] A refused bootstrap write is a DURABILITY degradation, not a
// functional one: `tryInsert` answers `null`, the boot goes on, nothing else
// fails, and the admin simply has no organization. AGENTS.md "Degradation log
// levels" puts that at `error`, and the two lines below used to be `warn`.
describe('refused bootstrap writes report at `error` (#12981)', () => {
function refusingQl(object: string) {
const ql = makeQl();
const realInsert = ql.insert;
ql.insert = vi.fn(async (obj: string, data: Row) => {
if (obj === object) throw new Error(`write refused: ${obj}`);
return realInsert(obj, data);
}) as any;
return ql;
}

it('a refused sys_organization insert names the consequence AND the remedy at `error`', async () => {
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const res = await ensureDefaultOrganization(refusingQl('sys_organization'), { logger });

expect(res.reason).toBe('org_insert_failed');
expect(logger.error).toHaveBeenCalledTimes(1);
// ⛔ Not `expect(...).toHaveBeenCalled()` on its own: the level IS the
// defect this repairs, so `warn` must be silent for the assertion to mean
// anything.
expect(logger.warn).not.toHaveBeenCalled();
const [message, cause, meta] = logger.error.mock.calls[0];
expect(message).toContain('NOT created');
expect(message).toContain('LOOKING HEALTHY');
expect(message).toContain('Remedy');
// The spec `Logger.error` arity: the CAUSE is the second argument and meta
// the third. Passing meta into the cause slot puts it where a Logger
// neither reads nor serializes it.
expect(cause).toBeUndefined();
expect(meta).toMatchObject({ object: 'sys_organization' });
});

it('a refused sys_member insert reports at `error` too', async () => {
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const res = await ensureDefaultOrganization(refusingQl('sys_member'), { logger });

expect(res).toMatchObject({ defaultOrgCreated: true, memberCreated: false, reason: 'member_insert_failed' });
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error.mock.calls[0][0]).toContain('NOT bound');
expect(logger.warn).not.toHaveBeenCalled();
});

// #9754: `error` is optional because hosts do inject reduced sinks. A
// fallback that only exists in the type is not a fallback — this is the
// case that proves the `warn` leg is wired, and it is the one
// `logger?.error?.(…)` would fail while looking correct.
it('falls back to `warn` against a sink with no `error`', async () => {
const logger = { info: vi.fn(), warn: vi.fn() };
await ensureDefaultOrganization(refusingQl('sys_organization'), { logger });

expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn.mock.calls[0][0]).toContain('NOT created');
// The fallback takes (message, meta) — no cause slot on `warn`.
expect(logger.warn.mock.calls[0][1]).toMatchObject({ object: 'sys_organization' });
});

it('a class-based sink keeps its receiver (⛔ never a detached `error ?? warn`)', async () => {
// `@objectstack/core`'s ObjectLogger is a class whose `error` reaches for
// `this`. A detached `(logger.error ?? logger.warn)(…)` throws against it
// and survives every plain-closure double in this file, which is why the
// case is written with a real receiver.
class Sink {
seen: string[] = [];
info(): void {}
warn(): void {}
error(message: string): void {
this.seen.push(message);
}
}
const sink = new Sink();
await ensureDefaultOrganization(refusingQl('sys_organization'), { logger: sink });
expect(sink.seen).toHaveLength(1);
});
});
});
88 changes: 86 additions & 2 deletions packages/plugins/plugin-auth/src/ensure-default-organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,65 @@

interface BootstrapLogger {
info: (message: string, meta?: Record<string, any>) => void;
/**
* The GUARANTEED channel. Required, which is what makes the `error` fallback
* below real rather than aspirational — see {@link logDurabilityFailure}.
*/
warn: (message: string, meta?: Record<string, any>) => void;
/**
* Durability-degradation channel (AGENTS.md "Degradation log levels", #4632).
* A bootstrap write that was supposed to land and did not is an `error`, not a
* `warn`: nothing looks broken afterwards, which is exactly why it has to be
* loud.
*
* OPTIONAL, deliberately (#9754): hosts do inject reduced sinks, and forcing
* this member would foreclose them. The fallback to the REQUIRED `warn` is
* therefore mandatory at every call site and lives in
* {@link logDurabilityFailure} so no site can forget it. That pairing —
* optional `error` beside a required `warn` — is the shape
* `check:optional-error-sink-contract` is satisfied by; an optional `error`
* beside an optional `warn` is the shape it exists to refuse.
*
* Signature matches `Logger.error` in `@objectstack/spec/contracts` (the CAUSE
* is its own second argument, meta is third), so the kernel logger satisfies
* this as-is. Getting the arity wrong would put the meta object in the error
* slot, where a `Logger` neither reads nor serializes it.
*/
error?: (message: string, error?: Error, meta?: Record<string, any>) => void;
}

/**
* Emit one durability-degradation line, falling back to `warn` when the host
* injected a sink with no `error`.
*
* The spelling is `logSeedDurabilityFailure` in `plugin-security`'s
* `per-organization-catalog.ts`, re-derived here rather than imported: that
* helper is deliberately absent from `plugin-security`'s `index.ts` (an
* intra-package helper, not public API), and `plugin-auth` does not depend on
* `plugin-security` at runtime. Its two prohibitions are the measured part and
* they carry across unchanged:
*
* ⛔ NOT `logger?.error?.(...)` — that prints NOTHING against a reduced sink,
* silently dropping the loudest line in this module in order to look tidy,
* which is the exact failure the rule exists to prevent.
*
* ⛔ NOT `(logger.error ?? logger.warn)(...)` — that evaluates to a bare
* FUNCTION and calls it with `this === undefined`; `@objectstack/core`'s
* `ObjectLogger` is a class whose `error` reaches for `this.writeErrorLike`, so
* a detached call throws. Plain-closure sinks — every double in this package's
* tests — survive it perfectly, which is why no suite would catch it. The
* property-access call form below keeps the receiver.
*/
function logDurabilityFailure(
logger: BootstrapLogger | undefined,
message: string,
meta?: Record<string, any>,
): void {
// No single cause is in scope here: `tryInsert` answers `null`, not the
// thrown error, so the cause slot is `undefined` and the detail travels in
// meta — the same shape the sibling reporter in `plugin-security` uses.
if (logger?.error) logger.error(message, undefined, meta);
else logger?.warn?.(message, meta);
}

export interface EnsureDefaultOrganizationOptions {
Expand Down Expand Up @@ -160,7 +218,23 @@ export async function ensureDefaultOrganization(
metadata: null,
});
if (!orgRow) {
logger?.warn?.('[default-org] failed to create default organization for platform admin');
// ⛔ `warn` was wrong here, and #12981 is why. `tryInsert` answers `null`
// for a refused write, the boot continues, and nothing downstream fails —
// which is the definition of the durability class in AGENTS.md, not the
// functional one.
logDurabilityFailure(
logger,
'[default-org] the Default Organization row was NOT created — the platform admin has no '
+ 'organization, so under multi-org the default tenant_isolation RLS policy filters their '
+ 'console to zero rows, and under single-org better-auth has no active org to resolve, so '
+ 'there is no way to add a user at all (ADR-0081 D1). NOTHING ELSE FAILS AND THE BOOT GOES '
+ 'ON LOOKING HEALTHY: this line is the only notice. Remedy: make the sys_organization '
+ 'insert land — check the write permission and driver connectivity, and whether a legacy '
+ 'unique index on `slug` is refusing `default`; the bootstrap re-runs on every '
+ 'kernel:ready and after every sys_user_permission_set insert, so no manual repair is '
+ 'needed once the write can land.',
{ object: 'sys_organization', slug: 'default' },
);
return { defaultOrgCreated: false, memberCreated: false, reason: 'org_insert_failed' };
}
defaultOrgId = orgRow?.id ?? newOrgId;
Expand All @@ -175,7 +249,17 @@ export async function ensureDefaultOrganization(
role: 'owner',
});
if (!memRow) {
logger?.warn?.('[default-org] failed to bind platform admin to default organization');
logDurabilityFailure(
logger,
'[default-org] the platform admin was NOT bound to the Default Organization — the sys_member '
+ 'row did not land, so their sessions carry no activeOrganizationId and the console stays '
+ 'empty exactly as if no organization existed. The organization row itself IS present, so '
+ 'the deployment looks healthier than it is and this line is the only notice. Remedy: make '
+ 'the sys_member insert land — check the write permission, driver connectivity, and any '
+ 'unique index over (organization_id, user_id); the bootstrap re-runs on every kernel:ready '
+ 'and after every sys_user_permission_set insert, so the next pass binds it.',
{ object: 'sys_member', organization: defaultOrgId, user: adminUserId },
);
return {
defaultOrgCreated,
defaultOrgId,
Expand Down
118 changes: 118 additions & 0 deletions packages/plugins/plugin-sharing/src/primary-bu-projection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

// [#12981] The boot backfill's REFUSED-WRITE accounting.
//
// The shape under test is the one #12981 was filed about and #12970 repaired in
// `permission-set-drift.ts`: a per-row `catch { }` plus a report gated on
// `updated > 0`. Together they make a pass in which EVERY write was refused
// print exactly the same bytes as a pass with nothing to do — so the assertions
// below are about what the LOGGER heard, not only about the returned counts. A
// test that only checked `refused` would pass against a version that counts the
// refusals and still says nothing.

import { describe, it, expect, vi } from 'vitest';
import { backfillPrimaryBu } from './primary-bu-projection.js';

type Row = Record<string, any>;

function makeEngine(members: Row[], onUpdate: (data: Row) => void) {
return {
registerHook: vi.fn(),
unregisterHooksByPackage: vi.fn(() => 0),
find: vi.fn(async () => members),
update: vi.fn(async (_object: string, data: Row) => {
onUpdate(data);
return data;
}),
};
}

function makeLogger() {
return { info: vi.fn(), warn: vi.fn() };
}

const MEMBERS: Row[] = [
{ user_id: 'u1', business_unit_id: 'bu1' },
{ user_id: 'u2', business_unit_id: 'bu2' },
];

describe('backfillPrimaryBu — refused writes are counted and reported', () => {
it('reports when EVERY row is refused, instead of printing nothing', async () => {
const logger = makeLogger();
const engine = makeEngine(MEMBERS, () => {
throw new Error('sys_user write refused');
});

const result = await backfillPrimaryBu(engine, logger);

// The count is honest...
expect(result).toEqual({ updated: 0, refused: 2 });
// ...and, the half that actually matters, the pass is no longer SILENT.
// Before #12981 this branch was `if (updated > 0)`, so a fully-refused
// backfill logged nothing at all and read as "no work to do".
expect(logger.warn).toHaveBeenCalledTimes(1);
const [message, meta] = logger.warn.mock.calls[0];
expect(message).toContain('REFUSED');
// The consequence is named in the line itself — AGENTS.md "Degradation log
// levels" owes the consequence and the remedy, not a bare count.
expect(message).toContain('primary_business_unit_id');
expect(meta).toMatchObject({ refused: 2, updated: 0, scanned: 2 });
expect(logger.info).toHaveBeenCalledTimes(1);
});

it('still reports the summary on a partially refused pass', async () => {
const logger = makeLogger();
const engine = makeEngine(MEMBERS, (data) => {
if (data.id === 'u2') throw new Error('sys_user write refused');
});

const result = await backfillPrimaryBu(engine, logger);

expect(result).toEqual({ updated: 1, refused: 1 });
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(
'[primary-bu] backfilled projection',
{ updated: 1, refused: 1 },
);
});

it('says nothing when there was nothing to do — the two must stay distinguishable', async () => {
const logger = makeLogger();
const engine = makeEngine([], () => {});

const result = await backfillPrimaryBu(engine, logger);

expect(result).toEqual({ updated: 0, refused: 0 });
expect(logger.warn).not.toHaveBeenCalled();
expect(logger.info).not.toHaveBeenCalled();
});

it('a clean pass reports only the summary', async () => {
const logger = makeLogger();
const engine = makeEngine(MEMBERS, () => {});

const result = await backfillPrimaryBu(engine, logger);

expect(result).toEqual({ updated: 2, refused: 0 });
expect(logger.warn).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
'[primary-bu] backfilled projection',
{ updated: 2, refused: 0 },
);
});

it('a refused SCAN answers both counters, not a half-built result', async () => {
const logger = makeLogger();
const engine = {
registerHook: vi.fn(),
unregisterHooksByPackage: vi.fn(() => 0),
find: vi.fn(async () => {
throw new Error('scan refused');
}),
update: vi.fn(),
};

expect(await backfillPrimaryBu(engine, logger)).toEqual({ updated: 0, refused: 0 });
expect(engine.update).not.toHaveBeenCalled();
});
});
Loading
Loading