From e989cacdea8b4e9d255653770b45be95d8de12ee Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Fri, 10 Jul 2026 10:30:14 +0800 Subject: [PATCH 1/2] chore: bump objectui to 7a68d78f2a0c chore: release packages (#2304) objectui@7a68d78f2a0c3c1f99dafd39b75b4f117a24917b --- .objectui-sha | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.objectui-sha b/.objectui-sha index e18aa26bfb..37dadd1210 100644 --- a/.objectui-sha +++ b/.objectui-sha @@ -1 +1 @@ -b0aa25129542504b81589ee95bb81a5d6003f998 +7a68d78f2a0c3c1f99dafd39b75b4f117a24917b From b7aef772a34f342a808cb920d9baa37e307c1334 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Fri, 10 Jul 2026 11:50:44 +0800 Subject: [PATCH 2/2] fix(cli,plugin-security): os meta resync to re-materialize default permission sets (#2705) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default permission sets (admin_full_access / member_default / viewer_readonly …) were seeded INSERT-ONCE at boot: bootstrapPlatformAdmin skipped any existing row and never wrote the shipped declaration back. So editing a default set's source, recompiling, and restarting `os dev` WITHOUT `--fresh` left the runtime serving the OLD value — silently, because the runtime authz resolver hydrates permission sets from the sys_permission_set row (resolve-authz-context.ts), not from the in-memory dist. Every OTHER metadata seed (declared sets, positions, built-in roles, capabilities) already upserts on boot, leaving the platform-default path the lone insert-once holdout — a gap ADR-0090 widened by persisting more facets (system_permissions, delegated-admin admin_scope) onto the same row. Insert-once is deliberate for prod (it protects an admin's Setup edits and keeps the defaults env-authored), so this is NOT a blind upsert: - bootstrapPlatformAdmin gains a `resync` option. Default boot behavior is unchanged. Under resync an existing row is reconciled to the shipped dist only when the platform still owns it (managed_by absent or 'platform'); a row an admin took over ('user') or a package owns ('package') is left untouched. - New `os meta resync` command boots the runtime, reconciles the default permission-set rows to the compiled dist, and reports reconciled / preserved / newly-seeded — without touching business data and without a `--fresh` wipe. Confirmation-gated (--yes to skip; --json for scripting). Verified: plugin-security 240/240, cli 466/466, and end-to-end `os meta resync` against examples/app-todo — insert path (seeded 4 new), overwrite path (resynced 4), and clean process exit. Co-Authored-By: Claude Opus 4.8 --- .../meta-resync-default-permission-sets.md | 38 ++++ packages/cli/src/commands/meta/resync.ts | 181 ++++++++++++++++++ packages/cli/src/utils/schema-migrate.ts | 4 + .../src/bootstrap-platform-admin.test.ts | 109 +++++++++++ .../src/bootstrap-platform-admin.ts | 119 +++++++++--- 5 files changed, 426 insertions(+), 25 deletions(-) create mode 100644 .changeset/meta-resync-default-permission-sets.md create mode 100644 packages/cli/src/commands/meta/resync.ts create mode 100644 packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts diff --git a/.changeset/meta-resync-default-permission-sets.md b/.changeset/meta-resync-default-permission-sets.md new file mode 100644 index 0000000000..23d670611f --- /dev/null +++ b/.changeset/meta-resync-default-permission-sets.md @@ -0,0 +1,38 @@ +--- +"@objectstack/plugin-security": minor +"@objectstack/cli": minor +--- + +fix(cli,plugin-security): `os meta resync` to re-materialize default permission sets from dist (#2705) + +The default permission sets (`admin_full_access` / `member_default` / +`viewer_readonly` …) were seeded **insert-once** at boot: `bootstrapPlatformAdmin` +skipped any row that already existed and never wrote the shipped declaration +back. So editing a default set's source, recompiling, and restarting `os dev` +**without** `--fresh` left the runtime serving the OLD value — silently, because +the runtime authz resolver hydrates permission sets from the `sys_permission_set` +row (`resolve-authz-context.ts`), not from the in-memory dist. A permission-gated +surface (e.g. `setup.access`) would keep its stale behavior with no error, which +repeatedly misled debugging. Every *other* metadata seed (declared permission +sets, positions, built-in roles, capabilities) already upserts on boot, leaving +the platform-default path the lone insert-once holdout — a gap ADR-0090 widened +by persisting more facets (`system_permissions`, delegated-admin `admin_scope`) +onto the same row. + +The insert-once posture is deliberate for prod (it protects an admin's Setup +edits and keeps the defaults env-authored — the exact posture +`bootstrapDeclaredPermissions` relies on), so this is **not** switched to a blind +upsert. Instead: + +- `bootstrapPlatformAdmin` gains a `resync` option. Default boot behavior is + unchanged (insert-once). Under `resync`, an existing row is reconciled to the + shipped dist **only** when the platform still owns it (`managed_by` absent or + `'platform'`); a row an admin took over (`managed_by:'user'`) or a package owns + (`'package'`) is an intentional override and is left untouched. +- New `os meta resync` command boots the runtime, reconciles the default + permission-set rows to the compiled dist, and reports what was reconciled / + preserved / newly seeded — **without touching business data** and without a + `--fresh` wipe. Gated behind a confirmation prompt (`--yes` to skip; `--json` + for scripting). + +Prod boot is unaffected; the fix is entirely opt-in via the new command. diff --git a/packages/cli/src/commands/meta/resync.ts b/packages/cli/src/commands/meta/resync.ts new file mode 100644 index 0000000000..016c25006e --- /dev/null +++ b/packages/cli/src/commands/meta/resync.ts @@ -0,0 +1,181 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { Command, Flags } from '@oclif/core'; +import chalk from 'chalk'; +import { createInterface } from 'node:readline'; +import { + printHeader, + printSuccess, + printWarning, + printError, + printInfo, + printStep, + createTimer, +} from '../../utils/format.js'; +import { bootSchemaStack } from '../../utils/schema-migrate.js'; +import { bootstrapPlatformAdmin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; + +async function confirm(question: string): Promise { + if (!process.stdin.isTTY) return false; // non-interactive → require --yes + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer: string = await new Promise((resolve) => rl.question(question, resolve)); + return /^y(es)?$/i.test(answer.trim()); + } finally { + rl.close(); + } +} + +function safeGetService(kernel: any, name: string): any { + try { + return kernel?.getService?.(name); + } catch { + return undefined; + } +} + +/** + * `os meta resync` — reconcile materialized metadata to the compiled `dist` + * without a `--fresh` wipe (#2705). + * + * The default permission sets (admin_full_access / member_default / + * viewer_readonly …) are seeded INSERT-ONCE at boot: an existing row is never + * clobbered, so an edit to a default set's source is served with its OLD value + * until the DB is wiped — a silent dev-loop trap (a permission-gated action + * "mysteriously" keeps its old behavior). Every OTHER metadata seed (declared + * permission sets, positions, built-in roles, capabilities) already upserts on + * boot; this command closes the one remaining gap by force-reconciling the + * default permission-set rows to the shipped declaration. + * + * Business data is never touched — only `sys_permission_set` definition rows. + * A row an admin has taken over in Setup (`managed_by:'user'`) or a package + * owns (`'package'`) is an intentional override and is left alone. + */ +export default class MetaResync extends Command { + static override description = + 'Reconcile materialized metadata (default permission sets) to the compiled dist without a --fresh wipe (#2705)'; + + static override examples = [ + '$ os meta resync', + '$ os meta resync --yes', + '$ os meta resync --json', + ]; + + static override flags = { + 'database-url': Flags.string({ + description: 'Database URL to reconcile (defaults to $OS_DATABASE_URL / the project DB)', + env: 'OS_DATABASE_URL', + }), + yes: Flags.boolean({ char: 'y', description: 'Skip the confirmation prompt', default: false }), + json: Flags.boolean({ description: 'Output as JSON (implies non-interactive; requires --yes to mutate)' }), + }; + + async run(): Promise { + const { flags } = await this.parse(MetaResync); + const timer = createTimer(); + let exitCode = 0; + + if (!flags.json) { + printHeader('Meta · resync'); + printStep('Booting runtime stack…'); + } + + let stack; + try { + stack = await bootSchemaStack({ databaseUrl: flags['database-url'] }); + } catch (error: any) { + if (flags.json) console.log(JSON.stringify({ error: error.message })); + else printError(error.message || String(error)); + process.exit(1); + } + + try { + const ql = safeGetService(stack.kernel, 'objectql'); + // Prefer the SecurityPlugin's resolved set when a full stack is booted (it + // picks up an app's `defaultPermissionSets` override); fall back to the + // platform defaults, which is what the standalone stack this command boots + // exposes. Either way these are the sets `bootstrapPlatformAdmin` seeds. + const svcSets = safeGetService(stack.kernel, 'security.bootstrapPermissionSets'); + const sets: any[] = Array.isArray(svcSets) && svcSets.length > 0 ? svcSets : securityDefaultPermissionSets; + + if (!ql) { + if (flags.json) { + console.log(JSON.stringify({ error: 'objectql_unavailable' })); + return; + } + printError('ObjectQL service is not available — cannot resync.'); + return; + } + if (!Array.isArray(sets) || sets.length === 0) { + if (flags.json) { + console.log(JSON.stringify({ resynced: 0, resyncSkipped: 0, inserted: 0, message: 'no_default_permission_sets' })); + return; + } + printWarning('No default permission sets are available to resync.'); + return; + } + + // Confirmation gate — resync overwrites the default permission-set + // definitions from the compiled dist, so admin Setup edits to those sets + // are replaced. Business data is never touched. + if (!flags.yes) { + if (flags.json || !process.stdin.isTTY) { + if (flags.json) { + console.log(JSON.stringify({ resynced: 0, resyncSkipped: 0, inserted: 0, message: 'confirmation_required', hint: 'pass --yes' })); + return; + } + printWarning('Confirmation required. Re-run with --yes to resync.'); + return; + } + printInfo(`Database: ${chalk.white(stack.dbLabel)}`); + printWarning('This overwrites the default permission-set definitions from the compiled dist.'); + console.log(chalk.dim(' Admin Setup edits to those sets are replaced. Business data is untouched.')); + console.log(chalk.dim(' Sets an admin or a package has taken over (managed_by user/package) are left alone.')); + const ok = await confirm(chalk.bold(`\nResync ${sets.length} default permission set(s) to ${stack.dbLabel}? [y/N] `)); + if (!ok) { + printInfo('Aborted — no changes made.'); + return; + } + } + + const logger = flags.json + ? undefined + : { info: (m: string) => printInfo(m), warn: (m: string) => printWarning(m) }; + + const report = await bootstrapPlatformAdmin(ql, sets as any[], { resync: true, logger }); + const resynced = report.resynced ?? 0; + const resyncSkipped = report.resyncSkipped ?? 0; + // seeded = existing + newly inserted; existing (under resync) = resynced + + // resyncSkipped, so the remainder is what this run freshly seeded. + const inserted = Math.max(0, (report.seeded ?? 0) - resynced - resyncSkipped); + + if (flags.json) { + console.log(JSON.stringify({ database: stack.dbLabel, resynced, resyncSkipped, inserted, duration: timer.elapsed() }, null, 2)); + return; + } + + console.log(''); + if (resynced > 0 || inserted > 0) { + printSuccess(`Reconciled ${resynced} default permission set(s) to dist${inserted > 0 ? `, seeded ${inserted} new` : ''}.`); + } else { + printInfo('Nothing reconciled.'); + } + if (resyncSkipped > 0) { + printWarning(`Left ${resyncSkipped} set(s) untouched (admin- or package-owned override).`); + } + console.log(chalk.dim(` ${timer.display()}`)); + console.log(''); + } catch (error: any) { + exitCode = 1; + if (flags.json) console.log(JSON.stringify({ error: error.message })); + else printError(error.message || String(error)); + } finally { + await stack.shutdown(); + // A one-shot command must exit even when the booted app stack left + // keep-alive handles open (schedulers/watchers registered on kernel:ready + // that `runtime.stop()` cannot fully drain). Matches the process.exit + // posture the other one-shot CLI commands use. + process.exit(exitCode); + } + } +} diff --git a/packages/cli/src/utils/schema-migrate.ts b/packages/cli/src/utils/schema-migrate.ts index 24787e5c09..4b01e25bac 100644 --- a/packages/cli/src/utils/schema-migrate.ts +++ b/packages/cli/src/utils/schema-migrate.ts @@ -29,6 +29,9 @@ export interface SchemaStack { driver: SqlDriverLike | null; dbLabel: string; managedTableCount: number; + /** The booted kernel — `getService('objectql')` etc. for one-shot commands + * beyond schema migration (e.g. `os meta resync`, #2705). */ + kernel: any; shutdown: () => Promise; } @@ -95,6 +98,7 @@ export async function bootSchemaStack(opts: { databaseUrl?: string } = {}): Prom driver, dbLabel: describeDb(driver), managedTableCount, + kernel, shutdown: async () => { try { await (runtime as any).stop?.(); } catch { /* ignore */ } try { await driver?.disconnect?.(); } catch { /* ignore */ } diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts new file mode 100644 index 0000000000..c65eb7964a --- /dev/null +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts @@ -0,0 +1,109 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * bootstrapPlatformAdmin — permission-set materialization, focused on the + * insert-once vs `resync` (#2705) split. + * + * The default boot path is insert-once: an existing default permission-set row + * is env-authored config and is never clobbered on restart (so admin Setup + * edits survive). That protection is CORRECT for prod but makes a dev source + * edit silently stale until a `--fresh` wipe. `os meta resync` passes + * `{ resync: true }` to reconcile platform-owned rows to the shipped dist + * without touching business data — while still refusing to overwrite a row an + * admin or a package has explicitly taken over. + */ + +import { describe, it, expect } from 'vitest'; +import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; + +/** Minimal in-memory ql. Only `sys_permission_set` is modeled; the admin- + * promotion tables return empty, so promotion short-circuits and we assert the + * seed/resync outcome (carried on every return) directly. */ +function makeQl(seedRows: any[] = []) { + const rows: any[] = seedRows.map((r) => ({ ...r })); + return { + rows, + async find(object: string, q: any) { + if (object !== 'sys_permission_set') return []; + const where = q?.where ?? {}; + return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v)); + }, + async insert(object: string, data: any) { + if (object !== 'sys_permission_set') return null; + rows.push({ ...data }); + return { id: data.id }; + }, + async update(object: string, data: any) { + if (object !== 'sys_permission_set') return; + const r = rows.find((x) => x.id === data.id); + if (r) Object.assign(r, data); + }, + }; +} + +/** Shipped declaration: member_default now grants `setup.access`. */ +const memberDefault = (over: Record = {}) => + ({ + name: 'member_default', + label: 'Member', + objects: { crm_lead: { allowRead: true } }, + systemPermissions: ['setup.access'], + ...over, + }) as any; + +const row = (ql: ReturnType) => ql.rows.find((x) => x.name === 'member_default'); + +describe('bootstrapPlatformAdmin — insert-once vs resync (#2705)', () => { + it('default (no resync): leaves an existing row stale — the deliberate insert-once posture', async () => { + const ql = makeQl([ + { id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}' }, + ]); + const r = await bootstrapPlatformAdmin(ql, [memberDefault()]); + expect(r.resynced).toBe(0); + // Same row, and the shipped `setup.access` did NOT land — this is the + // #2705 boot behavior (protects admin edits; stale in the dev loop). + expect(row(ql)!.id).toBe('ps_old'); + expect(row(ql)!.system_permissions).toBe('[]'); + }); + + it('resync: reconciles a platform-owned row to the shipped declaration in place', async () => { + const ql = makeQl([ + { id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}', managed_by: null }, + ]); + const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); + expect(r.resynced).toBe(1); + expect(r.resyncSkipped).toBe(0); + // Updated in place (no new insert), and the declaration is now live. + expect(ql.rows.filter((x) => x.name === 'member_default')).toHaveLength(1); + expect(row(ql)!.id).toBe('ps_old'); + expect(JSON.parse(row(ql)!.system_permissions)).toEqual(['setup.access']); + expect(JSON.parse(row(ql)!.object_permissions)).toEqual({ crm_lead: { allowRead: true } }); + }); + + it('resync: leaves an admin-owned (managed_by:user) row untouched', async () => { + const ql = makeQl([ + { id: 'ps_custom', name: 'member_default', system_permissions: '["custom.perm"]', managed_by: 'user' }, + ]); + const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); + expect(r.resynced).toBe(0); + expect(r.resyncSkipped).toBe(1); + expect(row(ql)!.system_permissions).toBe('["custom.perm"]'); + }); + + it('resync: leaves a package-owned row untouched', async () => { + const ql = makeQl([ + { id: 'ps_pkg', name: 'member_default', system_permissions: '[]', managed_by: 'package', package_id: 'com.x' }, + ]); + const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); + expect(r.resynced).toBe(0); + expect(r.resyncSkipped).toBe(1); + }); + + it('resync: inserts a set that does not exist yet (nothing to reconcile)', async () => { + const ql = makeQl([]); + const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); + expect(r.resynced).toBe(0); + expect(row(ql)).toBeTruthy(); + expect(JSON.parse(row(ql)!.system_permissions)).toEqual(['setup.access']); + }); +}); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index 9ad8d75f0b..16ed330120 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -29,6 +29,25 @@ interface BootstrapOptions { info: (message: string, meta?: Record) => void; warn: (message: string, meta?: Record) => void; }; + /** + * [#2705] Force re-materialization of the default permission-set rows from + * the compiled declaration. + * + * Default (`false`) keeps the insert-once shape: an existing row is left + * untouched so an admin's Setup customizations survive every restart, and so + * the platform defaults stay env-authored (never clobbered — the exact + * posture `bootstrapDeclaredPermissions` relies on). This is correct for + * prod boot. + * + * `os meta resync` sets it to `true` to reconcile the DB rows to the shipped + * `dist` after a source edit — the dev loop that insert-once otherwise makes + * silently stale (a changed default set is served with its OLD value until a + * `--fresh` wipe). Only platform-owned rows (`managed_by` absent or + * `'platform'`) are overwritten; a row an admin explicitly took over + * (`managed_by:'user'`) or a package owns (`'package'`) is left alone so the + * resync never destroys an intentional override. + */ + resync?: boolean; } const SYSTEM_CTX = { isSystem: true }; @@ -50,12 +69,48 @@ async function tryInsert(ql: any, object: string, data: any): Promise { + try { + await ql.update(object, data, { context: SYSTEM_CTX }); + return true; + } catch { + return false; + } +} + function genId(prefix: string): string { const rand = Math.random().toString(36).slice(2, 10); const ts = Date.now().toString(36); return `${prefix}_${ts}${rand}`; } +/** + * The platform-owned definition facets of a default permission set — the + * fields the runtime resolver hydrates back into ExecutionContext + * (`resolve-authz-context.ts` → systemPermissions / tabPermissions / object & + * field masks). Single source for both the first-boot insert and the `#2705` + * resync update so the two paths can never drift. Identity/provenance columns + * (`id`, `name`, `active`, `managed_by`, `package_id`) are deliberately NOT + * here — resync reconciles the declaration, never the ownership. + * + * `description` / `adminScope` are read defensively: neither is on the typed + * PermissionSet shape (name/label/objects/fields/...), but both persist when a + * runtime declaration provides them without tripping the dts typecheck. + */ +function platformOwnedFields(ps: PermissionSet): Record { + return { + label: ps.label ?? ps.name, + description: (ps as any).description ?? null, + object_permissions: JSON.stringify(ps.objects ?? {}), + field_permissions: JSON.stringify(ps.fields ?? {}), + system_permissions: JSON.stringify(ps.systemPermissions ?? []), + row_level_security: JSON.stringify(ps.rowLevelSecurity ?? []), + tab_permissions: JSON.stringify(ps.tabPermissions ?? {}), + // [ADR-0090 D12] Delegated-admin scope travels with the set row. + admin_scope: (ps as any).adminScope ? JSON.stringify((ps as any).adminScope) : null, + }; +} + /** * Persist seed permission sets and promote the first registered user to * platform admin. Safe to call multiple times. @@ -70,6 +125,10 @@ export async function bootstrapPlatformAdmin( reason?: string; /** Count of seeded rows re-owned to the freshly-promoted admin. */ ownershipClaimed?: number; + /** [#2705] Existing platform-owned rows reconciled to dist under `resync`. */ + resynced?: number; + /** [#2705] Existing rows left untouched by `resync` (admin/package-owned). */ + resyncSkipped?: number; }> { const logger = options.logger; if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') { @@ -78,36 +137,42 @@ export async function bootstrapPlatformAdmin( // 1. Seed permission set rows. const seeded: Record = {}; + let resynced = 0; + let resyncSkipped = 0; for (const ps of bootstrapPermissionSets) { if (!ps.name) continue; const existing = await tryFind(ql, 'sys_permission_set', { name: ps.name }, 1); if (existing.length > 0 && existing[0].id) { - seeded[ps.name] = existing[0].id; + const row = existing[0]; + seeded[ps.name] = row.id; + // Insert-once by default: an existing row is env-authored config and is + // never clobbered on restart (protects admin Setup edits, and keeps the + // platform defaults env-authored — the posture bootstrapDeclaredPermissions + // relies on). Under `resync` (`os meta resync`, #2705) reconcile the row to + // the shipped dist so a dev source edit takes effect without `--fresh` — + // but only for rows the platform still owns. A row an admin explicitly took + // over (`managed_by:'user'`) or a package owns (`'package'`) is an + // intentional override and is left alone. + if (options.resync) { + if (!row.managed_by || row.managed_by === 'platform') { + if (await tryUpdate(ql, 'sys_permission_set', { id: row.id, ...platformOwnedFields(ps) })) { + resynced += 1; + } + } else { + resyncSkipped += 1; + logger?.warn?.( + `[security] resync left ${ps.name} untouched — row is ${row.managed_by}-owned (intentional override)`, + { name: ps.name, managedBy: row.managed_by }, + ); + } + } continue; } const id = genId('ps'); const created = await tryInsert(ql, 'sys_permission_set', { id, name: ps.name, - label: ps.label ?? ps.name, - // `description` is not part of the typed PermissionSet shape (name/label - // only); read it defensively so a runtime-provided description still - // persists without tripping the dts typecheck. - description: (ps as any).description ?? null, - object_permissions: JSON.stringify(ps.objects ?? {}), - field_permissions: JSON.stringify(ps.fields ?? {}), - // Persist the remaining permset facets so the runtime resolver - // (rest-server.ts / resolve-execution-context.ts) can hydrate - // them back into ExecutionContext.systemPermissions etc. Without - // these the platform-admin promotion grants the right LINK row - // but the permission set itself carries no capabilities, so - // `setup.access` / `studio.access` never reach the app filter - // and the Setup app is invisible even to admin_full_access. - system_permissions: JSON.stringify(ps.systemPermissions ?? []), - row_level_security: JSON.stringify(ps.rowLevelSecurity ?? []), - tab_permissions: JSON.stringify(ps.tabPermissions ?? {}), - // [ADR-0090 D12] Delegated-admin scope travels with the set row. - admin_scope: (ps as any).adminScope ? JSON.stringify((ps as any).adminScope) : null, + ...platformOwnedFields(ps), active: true, }); if (created?.id) seeded[ps.name] = created.id; @@ -115,11 +180,15 @@ export async function bootstrapPlatformAdmin( } const seededCount = Object.keys(seeded).length; + // Attached to every return below so `os meta resync` can report the reconcile + // outcome even when admin promotion short-circuits (the common dev case: a DB + // that already has an admin returns `already_have_admin`). + const resyncCounts = { resynced, resyncSkipped }; // 2. First-user platform admin promotion. const adminPsId = seeded['admin_full_access']; if (!adminPsId) { - return { seeded: seededCount, adminPromoted: false, reason: 'admin_permission_set_missing' }; + return { seeded: seededCount, adminPromoted: false, reason: 'admin_permission_set_missing', ...resyncCounts }; } const existingAdminLinks = await tryFind( @@ -135,7 +204,7 @@ export async function bootstrapPlatformAdmin( // every real admin forever. Ignoring it here makes the bootstrap // self-healing on restart. if (existingAdminLinks.some((r) => !r.organization_id && r.user_id !== SystemUserId.SYSTEM)) { - return { seeded: seededCount, adminPromoted: false, reason: 'already_have_admin' }; + return { seeded: seededCount, adminPromoted: false, reason: 'already_have_admin', ...resyncCounts }; } const allUsers = await tryFind(ql, 'sys_user', {}, 50); @@ -149,7 +218,7 @@ export async function bootstrapPlatformAdmin( ); if (humanUsers.length === 0) { logger?.info?.('[security] no human users yet — first sign-up will be promoted to platform admin'); - return { seeded: seededCount, adminPromoted: false, reason: 'no_users' }; + return { seeded: seededCount, adminPromoted: false, reason: 'no_users', ...resyncCounts }; } const sorted = [...humanUsers].sort((a, b) => { const ta = a.created_at ? new Date(a.created_at).getTime() : 0; @@ -167,7 +236,7 @@ export async function bootstrapPlatformAdmin( }); if (!inserted) { logger?.warn?.(`[security] failed to grant admin_full_access to first user ${target.email ?? target.id}`); - return { seeded: seededCount, adminPromoted: false, reason: 'insert_failed' }; + return { seeded: seededCount, adminPromoted: false, reason: 'insert_failed', ...resyncCounts }; } logger?.info?.(`[security] first user promoted to platform admin: ${target.email ?? target.id}`); @@ -182,5 +251,5 @@ export async function bootstrapPlatformAdmin( logger?.warn?.('[security] seed ownership handoff failed', { error: (e as Error).message }); } - return { seeded: seededCount, adminPromoted: true, ownershipClaimed }; + return { seeded: seededCount, adminPromoted: true, ownershipClaimed, ...resyncCounts }; }