Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f55d62cf92 | ||
|
|
a3446a13e2 |
@@ -4,14 +4,14 @@ import { AppModule } from '../app.module.js';
|
||||
import { HierarchyModule } from '../hierarchy/hierarchy.module.js';
|
||||
|
||||
/**
|
||||
* Hierarchy route-inventory baseline (contract 1 §6.3(a)).
|
||||
* Hierarchy route inventory (contract 1 §6.3).
|
||||
*
|
||||
* M4-1b-i ships the audit event + outbox machinery with NO mutation routes:
|
||||
* the hierarchy command family (controllers + DTOs) lands in M4-1b-ii once
|
||||
* contract 2 merges. This witness enumerates every route the AppModule graph
|
||||
* declares and pins that baseline, so a hierarchy route appearing before its
|
||||
* command-family witnesses exist fails here first. When M4-1b-ii lands, this
|
||||
* baseline is replaced by an exact inventory of the command family.
|
||||
* The hierarchy command family is a CLOSED enumeration asserted here, not a
|
||||
* prose claim: every hierarchy-flavored route the AppModule graph declares
|
||||
* must appear in HIERARCHY_COMMAND_FAMILY, and vice versa. Adding or
|
||||
* removing a hierarchy route without updating this inventory (and its
|
||||
* witnesses) fails CI first. This replaces the M4-1b-i zero-routes
|
||||
* baseline.
|
||||
*/
|
||||
|
||||
interface RouteEntry {
|
||||
@@ -77,7 +77,32 @@ function routesOf(controller: Type<unknown>): RouteEntry[] {
|
||||
return routes;
|
||||
}
|
||||
|
||||
describe('hierarchy route-inventory baseline (§6.3(a))', () => {
|
||||
/**
|
||||
* The closed command family (contract 1 §5, M4-1b-ii). Every entry is a
|
||||
* mutation audited via the M4-1b-i path or one of the two ratified reads
|
||||
* (granted companies, the §2.8 directory carve-out).
|
||||
*/
|
||||
const HIERARCHY_COMMAND_FAMILY = [
|
||||
'POST /api/hierarchy/companies',
|
||||
'GET /api/hierarchy/companies',
|
||||
'GET /api/hierarchy/companies/directory',
|
||||
'POST /api/hierarchy/companies/:id/rename',
|
||||
'POST /api/hierarchy/companies/:id/visibility',
|
||||
'DELETE /api/hierarchy/companies/:id',
|
||||
'POST /api/hierarchy/estates',
|
||||
'POST /api/hierarchy/estates/:id/rename',
|
||||
'POST /api/hierarchy/estates/:id/transfer',
|
||||
'DELETE /api/hierarchy/estates/:id',
|
||||
'POST /api/hierarchy/platform-projects',
|
||||
'POST /api/hierarchy/platform-projects/:id/rename',
|
||||
'POST /api/hierarchy/platform-projects/:id/transfer',
|
||||
'DELETE /api/hierarchy/platform-projects/:id',
|
||||
'POST /api/hierarchy/grants',
|
||||
'POST /api/hierarchy/grants/:id/change',
|
||||
'DELETE /api/hierarchy/grants/:id',
|
||||
] as const;
|
||||
|
||||
describe('hierarchy route inventory (§6.3)', () => {
|
||||
const inventory = collectControllers(AppModule).flatMap(routesOf);
|
||||
|
||||
it('control: the enumeration sees the known route surface', () => {
|
||||
@@ -88,19 +113,21 @@ describe('hierarchy route-inventory baseline (§6.3(a))', () => {
|
||||
expect(inventory.length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it('declares zero hierarchy mutation routes before M4-1b-ii', () => {
|
||||
const hierarchyRoutes = inventory.filter((r) =>
|
||||
/hierarch|compan|estate|platform[-_]?project/i.test(r.path),
|
||||
);
|
||||
expect(
|
||||
hierarchyRoutes,
|
||||
'a hierarchy route landed without replacing the §6.3(a) baseline with a command-family inventory',
|
||||
).toEqual([]);
|
||||
it('the hierarchy surface is exactly the declared command family', () => {
|
||||
const hierarchyRoutes = inventory
|
||||
.filter((r) => /hierarch|compan|estate|platform[-_]?project/i.test(r.path))
|
||||
.map((r) => `${r.method} ${r.path}`)
|
||||
.sort();
|
||||
expect(hierarchyRoutes).toEqual([...HIERARCHY_COMMAND_FAMILY].sort());
|
||||
});
|
||||
|
||||
it('HierarchyModule itself declares no controllers', () => {
|
||||
expect((Reflect.getMetadata('controllers', HierarchyModule) ?? []) as unknown[]).toEqual([]);
|
||||
const hierarchyControllers = collectControllers(HierarchyModule);
|
||||
expect(hierarchyControllers).toEqual([]);
|
||||
it('every command-family route lives on HierarchyController inside HierarchyModule', () => {
|
||||
const controllers = collectControllers(HierarchyModule);
|
||||
expect(controllers.map((c) => c.name)).toEqual(['HierarchyController']);
|
||||
const declared = controllers
|
||||
.flatMap(routesOf)
|
||||
.map((r) => `${r.method} ${r.path}`)
|
||||
.sort();
|
||||
expect(declared).toEqual([...HIERARCHY_COMMAND_FAMILY].sort());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,33 @@ describe('CommandAuthorizationService', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('denies non-admin scopes to a platform admin (contract 2 §1.1 bypass retirement)', async (): Promise<void> => {
|
||||
const service = createService('admin');
|
||||
for (const scope of ['core', 'agent', 'skill', 'plugin'] as const) {
|
||||
const command: CommandDef = { ...adminCommand, name: `probe-${scope}`, scope };
|
||||
expect(
|
||||
(await service.authorize(command, { ...payload, command: command.name }, 'admin-1'))
|
||||
.allowed,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('allows member core/agent scopes and denies skill/plugin (deny-by-default)', async (): Promise<void> => {
|
||||
const service = createService('member');
|
||||
for (const [scope, allowed] of [
|
||||
['core', true],
|
||||
['agent', true],
|
||||
['skill', false],
|
||||
['plugin', false],
|
||||
] as const) {
|
||||
const command: CommandDef = { ...adminCommand, name: `probe-${scope}`, scope };
|
||||
expect(
|
||||
(await service.authorize(command, { ...payload, command: command.name }, 'member-1'))
|
||||
.allowed,
|
||||
).toBe(allowed);
|
||||
}
|
||||
});
|
||||
|
||||
it('denies a malformed durable approval expiry instead of treating it as unexpired', async (): Promise<void> => {
|
||||
const entries = new Map<string, string>();
|
||||
const action = {
|
||||
|
||||
@@ -154,8 +154,15 @@ export class CommandAuthorizationService {
|
||||
return role === 'admin' || role === 'member' || role === 'viewer' ? role : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contract 2 §1.1: platform admin confers instance administration only —
|
||||
* the former admin-passes-every-scope short-circuit is retired. Admin
|
||||
* reaches exactly the admin scope; core/agent scopes belong to the member
|
||||
* role; skill/plugin scopes stay deny-for-all until a grant mapping names
|
||||
* them (§3.1 deny-by-default).
|
||||
*/
|
||||
private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean {
|
||||
if (role === 'admin') return true;
|
||||
if (scope === 'admin') return role === 'admin';
|
||||
return role === 'member' && (scope === 'core' || scope === 'agent');
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ import { DB } from '../database/database.module.js';
|
||||
*
|
||||
* This is NOT a class-table writer: it touches only the audit/outbox
|
||||
* tables, so it does not appear on the writer-coverage allowlist. The
|
||||
* hierarchy command repositories (M4-1b-ii) are the allowlisted writers and
|
||||
* call into this on their own transactions.
|
||||
* hierarchy command repository (HierarchyRepository) is the allowlisted
|
||||
* writer and calls into this on its own transactions.
|
||||
*/
|
||||
|
||||
export type HierarchyAuditVerb = (typeof HIERARCHY_AUDIT_VERBS)[number];
|
||||
|
||||
@@ -0,0 +1,965 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import {
|
||||
companies,
|
||||
createPgliteDb,
|
||||
eq,
|
||||
estates,
|
||||
hierarchyAuditEvents,
|
||||
hierarchyGrants,
|
||||
hierarchyOutbox,
|
||||
runPgliteMigrations,
|
||||
teams,
|
||||
users,
|
||||
workspaces,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { appendHierarchyEvent } from './hierarchy-audit.repository.js';
|
||||
import { HierarchyGrantEvaluationService } from './hierarchy-grant-evaluation.js';
|
||||
import { HierarchyRepository, type HierarchyResult } from './hierarchy.repository.js';
|
||||
|
||||
/**
|
||||
* Command-level witnesses for the hierarchy command family (M4-1b-ii):
|
||||
* contract 1 §6.4 (per-mutation-class commit + rollback), §6.5
|
||||
* (authorization outcomes), §6.7 (no existence oracle), §6.9 (visibility),
|
||||
* and contract 2 §3 grant-evaluation semantics (deny-by-default,
|
||||
* ancestor-chain inheritance, max-role, live revocation, suspended team
|
||||
* subjects). Schema-level constraints are witnessed in
|
||||
* packages/db/src/hierarchy-schema.witness.test.ts; the audit machinery's
|
||||
* own atomicity in hierarchy-audit.integration.test.ts.
|
||||
*
|
||||
* The rollback legs pre-seed an audit event under the command's idempotency
|
||||
* key with different content: the command's append then throws inside the
|
||||
* command transaction, so the whole mutation must roll back — the command
|
||||
* returns `conflict` and leaves no state change, no second event, and no
|
||||
* second outbox record.
|
||||
*/
|
||||
describe('hierarchy commands integration', (): void => {
|
||||
let dataDir: string;
|
||||
let handle: DbHandle;
|
||||
let moduleRef: TestingModule;
|
||||
let repo: HierarchyRepository;
|
||||
let evaluation: HierarchyGrantEvaluationService;
|
||||
|
||||
const OWNER = 'hier-cmd-owner';
|
||||
const ADMIN = 'hier-cmd-admin';
|
||||
const STRANGER = 'hier-cmd-stranger';
|
||||
const SUBJECT = 'hier-cmd-subject';
|
||||
|
||||
/** Base fixture: OWNER's company (created through the command surface). */
|
||||
let companyId: string;
|
||||
|
||||
const slug = (prefix: string): string => `${prefix}-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
function expectOk<T>(result: HierarchyResult<T>): { ok: true } & T {
|
||||
if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
const eventsForKey = (key: string) =>
|
||||
handle.db
|
||||
.select()
|
||||
.from(hierarchyAuditEvents)
|
||||
.where(eq(hierarchyAuditEvents.idempotencyKey, key));
|
||||
|
||||
const outboxForKey = (key: string) =>
|
||||
handle.db.select().from(hierarchyOutbox).where(eq(hierarchyOutbox.idempotencyKey, key));
|
||||
|
||||
/** Occupy `key` with unrelated event content so a command reusing it must abort. */
|
||||
const seedConflictingKey = async (key: string): Promise<void> => {
|
||||
await handle.db.transaction(async (tx) =>
|
||||
appendHierarchyEvent(tx, {
|
||||
actorId: 'seed-actor',
|
||||
verb: 'create',
|
||||
targetKind: 'company',
|
||||
targetId: randomUUID(),
|
||||
targetSnapshot: { seeded: true },
|
||||
correlationId: 'seed-correlation',
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* §6.4 rollback leg: the command must return `conflict` and leave exactly
|
||||
* the seeded event/outbox pair under the key — nothing it wrote survives.
|
||||
*/
|
||||
const expectRolledBack = async <T>(
|
||||
key: string,
|
||||
command: () => Promise<HierarchyResult<T>>,
|
||||
assertUnchanged: () => Promise<void>,
|
||||
): Promise<void> => {
|
||||
await seedConflictingKey(key);
|
||||
const result = await command();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe('conflict');
|
||||
expect(await eventsForKey(key)).toHaveLength(1);
|
||||
expect(await outboxForKey(key)).toHaveLength(1);
|
||||
await assertUnchanged();
|
||||
};
|
||||
|
||||
beforeAll(async (): Promise<void> => {
|
||||
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-hierarchy-commands-'));
|
||||
handle = createPgliteDb(dataDir);
|
||||
await runPgliteMigrations(handle);
|
||||
moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
HierarchyRepository,
|
||||
HierarchyGrantEvaluationService,
|
||||
{ provide: DB, useValue: handle.db },
|
||||
],
|
||||
}).compile();
|
||||
repo = moduleRef.get(HierarchyRepository);
|
||||
evaluation = moduleRef.get(HierarchyGrantEvaluationService);
|
||||
|
||||
await handle.db.insert(users).values([
|
||||
{ id: OWNER, name: 'Owner', email: `${OWNER}@example.com` },
|
||||
{ id: ADMIN, name: 'Admin', email: `${ADMIN}@example.com`, role: 'admin' },
|
||||
{ id: STRANGER, name: 'Stranger', email: `${STRANGER}@example.com` },
|
||||
{ id: SUBJECT, name: 'Subject', email: `${SUBJECT}@example.com` },
|
||||
]);
|
||||
const created = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Base Co', slug: slug('base') }),
|
||||
);
|
||||
companyId = created.company.id;
|
||||
});
|
||||
|
||||
afterAll(async (): Promise<void> => {
|
||||
await moduleRef.close();
|
||||
await handle.close();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── §6.4 commit legs ───────────────────────────────────────────────────────
|
||||
|
||||
it('createCompany commits company, owner grant, causation-linked events, and outbox atomically', async () => {
|
||||
const key = `key-${randomUUID()}`;
|
||||
const result = expectOk(
|
||||
await repo.createCompany({
|
||||
actorId: OWNER,
|
||||
name: 'Atomic Co',
|
||||
slug: slug('atomic'),
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
expect(result.company.visibility).toBe('private');
|
||||
expect(result.grant.role).toBe('hierarchy:owner');
|
||||
expect(result.grant.userId).toBe(OWNER);
|
||||
expect(result.grant.grantedBy).toBe(OWNER);
|
||||
|
||||
const [createEvents, grantEvents] = await Promise.all([
|
||||
eventsForKey(key),
|
||||
eventsForKey(`${key}:grant`),
|
||||
]);
|
||||
expect(createEvents).toHaveLength(1);
|
||||
expect(createEvents[0]).toMatchObject({ verb: 'create', targetId: result.company.id });
|
||||
expect(grantEvents).toHaveLength(1);
|
||||
expect(grantEvents[0]).toMatchObject({ verb: 'grant_create', targetId: result.grant.id });
|
||||
// The grant event is caused by the create event, same correlation (§4.3).
|
||||
expect(grantEvents[0]!.causationId).toBe(createEvents[0]!.id);
|
||||
expect(grantEvents[0]!.correlationId).toBe(createEvents[0]!.correlationId);
|
||||
expect(await outboxForKey(key)).toHaveLength(1);
|
||||
expect(await outboxForKey(`${key}:grant`)).toHaveLength(1);
|
||||
|
||||
const rows = await handle.db
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, result.company.id));
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('deleteCompany commits the delete with one audited grant_revoke per cascaded grant', async () => {
|
||||
const created = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Mortal Co', slug: slug('mortal') }),
|
||||
);
|
||||
const extraGrant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: created.company.id,
|
||||
role: 'viewer',
|
||||
}),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
expectOk(
|
||||
await repo.deleteCompany({
|
||||
actorId: OWNER,
|
||||
companyId: created.company.id,
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
const deleteEvents = await eventsForKey(key);
|
||||
expect(deleteEvents).toHaveLength(1);
|
||||
expect(deleteEvents[0]).toMatchObject({ verb: 'delete', targetId: created.company.id });
|
||||
for (const grantId of [created.grant.id, extraGrant.grant.id]) {
|
||||
const revokeEvents = await eventsForKey(`${key}:revoke:${grantId}`);
|
||||
expect(revokeEvents).toHaveLength(1);
|
||||
expect(revokeEvents[0]).toMatchObject({ verb: 'grant_revoke', targetId: grantId });
|
||||
expect(revokeEvents[0]!.causationId).toBe(deleteEvents[0]!.id);
|
||||
}
|
||||
expect(
|
||||
await handle.db.select().from(companies).where(eq(companies.id, created.company.id)),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── §6.4 rollback legs (one per mutation class) ────────────────────────────
|
||||
|
||||
it('renameCompany commits the rename with an audited event carrying previousName', async () => {
|
||||
const created = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Old Name Co', slug: slug('rename') }),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
const renamed = expectOk(
|
||||
await repo.renameCompany({
|
||||
actorId: OWNER,
|
||||
companyId: created.company.id,
|
||||
name: 'New Name Co',
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
expect(renamed.company.name).toBe('New Name Co');
|
||||
|
||||
const events = await eventsForKey(key);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ verb: 'rename', targetId: created.company.id });
|
||||
// §6.4: the audited rename carries the old and new names.
|
||||
expect(events[0]!.targetSnapshot).toMatchObject({
|
||||
name: 'New Name Co',
|
||||
previousName: 'Old Name Co',
|
||||
});
|
||||
expect(await outboxForKey(key)).toHaveLength(1);
|
||||
|
||||
const rows = await handle.db
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, created.company.id));
|
||||
expect(rows[0]!.name).toBe('New Name Co');
|
||||
});
|
||||
|
||||
it('revokeGrant commits the row deletion with one audited grant_revoke event', async () => {
|
||||
const grant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'viewer',
|
||||
}),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
const revoked = expectOk(
|
||||
await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id, idempotencyKey: key }),
|
||||
);
|
||||
expect(revoked.revokedId).toBe(grant.grant.id);
|
||||
|
||||
const events = await eventsForKey(key);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ verb: 'grant_revoke', targetId: grant.grant.id });
|
||||
expect(await outboxForKey(key)).toHaveLength(1);
|
||||
|
||||
// §6 revocation = row deletion: the grant row is gone.
|
||||
const rows = await handle.db
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.id, grant.grant.id));
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rolls back a create: no estate row survives the aborted transaction', async () => {
|
||||
const estateSlug = slug('rb-create');
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
key,
|
||||
() =>
|
||||
repo.createEstate({
|
||||
actorId: OWNER,
|
||||
companyId,
|
||||
name: 'Doomed Estate',
|
||||
slug: estateSlug,
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
async () => {
|
||||
expect(
|
||||
await handle.db.select().from(estates).where(eq(estates.slug, estateSlug)),
|
||||
).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back a rename: the company keeps its name', async () => {
|
||||
const before = (
|
||||
await handle.db.select().from(companies).where(eq(companies.id, companyId))
|
||||
)[0]!;
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
key,
|
||||
() => repo.renameCompany({ actorId: OWNER, companyId, name: 'Never', idempotencyKey: key }),
|
||||
async () => {
|
||||
const after = (
|
||||
await handle.db.select().from(companies).where(eq(companies.id, companyId))
|
||||
)[0]!;
|
||||
expect(after.name).toBe(before.name);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back a visibility change: the company stays private', async () => {
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
key,
|
||||
() =>
|
||||
repo.changeCompanyVisibility({
|
||||
actorId: ADMIN,
|
||||
companyId,
|
||||
visibility: 'directory',
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
async () => {
|
||||
const after = (
|
||||
await handle.db.select().from(companies).where(eq(companies.id, companyId))
|
||||
)[0]!;
|
||||
expect(after.visibility).toBe('private');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back a transfer: the estate keeps its parent', async () => {
|
||||
const estate = expectOk(
|
||||
await repo.createEstate({ actorId: OWNER, companyId, name: 'RB-T', slug: slug('rb-t') }),
|
||||
);
|
||||
const other = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'RB Dest', slug: slug('rb-dest') }),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
key,
|
||||
() =>
|
||||
repo.transferEstate({
|
||||
actorId: OWNER,
|
||||
estateId: estate.estate.id,
|
||||
destinationCompanyId: other.company.id,
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
async () => {
|
||||
const after = (
|
||||
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id))
|
||||
)[0]!;
|
||||
expect(after.companyId).toBe(companyId);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back a delete: the estate row survives', async () => {
|
||||
const estate = expectOk(
|
||||
await repo.createEstate({ actorId: OWNER, companyId, name: 'RB-D', slug: slug('rb-d') }),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
key,
|
||||
() => repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id, idempotencyKey: key }),
|
||||
async () => {
|
||||
expect(
|
||||
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id)),
|
||||
).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back a grant create: no grant row survives', async () => {
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
key,
|
||||
() =>
|
||||
repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: STRANGER,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'viewer',
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
async () => {
|
||||
const rows = await handle.db
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.userId, STRANGER));
|
||||
expect(rows.filter((r) => r.companyId === companyId)).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back a grant change and a grant revoke: the grant keeps its role and its row', async () => {
|
||||
const grant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'viewer',
|
||||
}),
|
||||
);
|
||||
const changeKey = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
changeKey,
|
||||
() =>
|
||||
repo.changeGrant({
|
||||
actorId: OWNER,
|
||||
grantId: grant.grant.id,
|
||||
role: 'member',
|
||||
idempotencyKey: changeKey,
|
||||
}),
|
||||
async () => {
|
||||
const row = (
|
||||
await handle.db
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.id, grant.grant.id))
|
||||
)[0]!;
|
||||
expect(row.role).toBe('viewer');
|
||||
},
|
||||
);
|
||||
const revokeKey = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
revokeKey,
|
||||
() =>
|
||||
repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id, idempotencyKey: revokeKey }),
|
||||
async () => {
|
||||
expect(
|
||||
await handle.db
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.id, grant.grant.id)),
|
||||
).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id }));
|
||||
});
|
||||
|
||||
it('replays a completed command idempotently through the audit machinery', async () => {
|
||||
const key = `key-${randomUUID()}`;
|
||||
const input = { actorId: OWNER, companyId, name: 'Replayed Estate', slug: slug('replay') };
|
||||
const first = expectOk(await repo.createEstate({ ...input, idempotencyKey: key }));
|
||||
// The retry's insert no-ops on the slug conflict — the command surfaces
|
||||
// `conflict`, and crucially appends no second event under the key.
|
||||
const retry = await repo.createEstate({ ...input, idempotencyKey: key });
|
||||
expect(retry.ok).toBe(false);
|
||||
expect(await eventsForKey(key)).toHaveLength(1);
|
||||
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: first.estate.id }));
|
||||
});
|
||||
|
||||
// ── §6.5 authorization ─────────────────────────────────────────────────────
|
||||
|
||||
it('deny-by-default: a user with no grant cannot mutate and sees not_found (§3.1)', async () => {
|
||||
expect(await repo.renameCompany({ actorId: STRANGER, companyId, name: 'x' })).toEqual({
|
||||
ok: false,
|
||||
error: 'not_found',
|
||||
});
|
||||
expect(
|
||||
await repo.createEstate({ actorId: STRANGER, companyId, name: 'x', slug: slug('deny') }),
|
||||
).toEqual({ ok: false, error: 'not_found' });
|
||||
expect(await repo.deleteCompany({ actorId: STRANGER, companyId })).toEqual({
|
||||
ok: false,
|
||||
error: 'not_found',
|
||||
});
|
||||
});
|
||||
|
||||
it('grant management requires effective owner: member and viewer are refused (§4.1)', async () => {
|
||||
const grant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'member',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await repo.createGrant({
|
||||
actorId: SUBJECT,
|
||||
userId: STRANGER,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'viewer',
|
||||
}),
|
||||
).toEqual({ ok: false, error: 'not_found' });
|
||||
expect(await repo.revokeGrant({ actorId: SUBJECT, grantId: grant.grant.id })).toEqual({
|
||||
ok: false,
|
||||
error: 'not_found',
|
||||
});
|
||||
// Member also cannot create children (owner-only, §4.1/§4.3).
|
||||
expect(
|
||||
await repo.createEstate({ actorId: SUBJECT, companyId, name: 'x', slug: slug('member') }),
|
||||
).toEqual({ ok: false, error: 'not_found' });
|
||||
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id }));
|
||||
});
|
||||
|
||||
it('platform admin confers no tenant content access (§1.1): ungrated admin is a stranger', async () => {
|
||||
expect(await repo.renameCompany({ actorId: ADMIN, companyId, name: 'x' })).toEqual({
|
||||
ok: false,
|
||||
error: 'not_found',
|
||||
});
|
||||
expect(
|
||||
await repo.createGrant({
|
||||
actorId: ADMIN,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'viewer',
|
||||
}),
|
||||
).toEqual({ ok: false, error: 'not_found' });
|
||||
expect(await repo.listGrantedCompanies(ADMIN)).toEqual([]);
|
||||
expect(await evaluation.effectiveRole(ADMIN, 'company', companyId)).toBeNull();
|
||||
});
|
||||
|
||||
it('visibility change is platform-admin-only (§5.5): the owner is forbidden, the admin succeeds', async () => {
|
||||
const owned = await repo.changeCompanyVisibility({
|
||||
actorId: OWNER,
|
||||
companyId,
|
||||
visibility: 'directory',
|
||||
});
|
||||
expect(owned).toEqual({
|
||||
ok: false,
|
||||
error: 'forbidden',
|
||||
message: 'visibility change is platform-admin-only',
|
||||
});
|
||||
const changed = expectOk(
|
||||
await repo.changeCompanyVisibility({ actorId: ADMIN, companyId, visibility: 'directory' }),
|
||||
);
|
||||
expect(changed.company.visibility).toBe('directory');
|
||||
// Restore for later witnesses.
|
||||
expectOk(
|
||||
await repo.changeCompanyVisibility({ actorId: ADMIN, companyId, visibility: 'private' }),
|
||||
);
|
||||
});
|
||||
|
||||
// ── §6.9 visibility ────────────────────────────────────────────────────────
|
||||
|
||||
it('directory lists exactly directory-class companies with closed fields (§2.8)', async () => {
|
||||
const listed = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Listed Co', slug: slug('listed') }),
|
||||
);
|
||||
const unlisted = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Unlisted Co', slug: slug('unlisted') }),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
expectOk(
|
||||
await repo.changeCompanyVisibility({
|
||||
actorId: ADMIN,
|
||||
companyId: listed.company.id,
|
||||
visibility: 'directory',
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
|
||||
const directory = await repo.listDirectory();
|
||||
const ids = directory.map((entry) => entry.id);
|
||||
expect(ids).toContain(listed.company.id);
|
||||
expect(ids).not.toContain(unlisted.company.id);
|
||||
expect(ids).not.toContain(companyId);
|
||||
// Closed-field: existence, name, slug — nothing else (no visibility, no
|
||||
// timestamps, no grant or membership data).
|
||||
for (const entry of directory) {
|
||||
expect(Object.keys(entry).sort()).toEqual(['id', 'name', 'slug']);
|
||||
}
|
||||
|
||||
// §5.5: the audited event carries old and new values.
|
||||
const events = await eventsForKey(key);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ verb: 'visibility_change', targetId: listed.company.id });
|
||||
expect(events[0]!.targetSnapshot).toMatchObject({
|
||||
previousVisibility: 'private',
|
||||
visibility: 'directory',
|
||||
});
|
||||
});
|
||||
|
||||
it('directory disclosure confers no authority: a listed company still refuses non-granted callers (§6.9)', async () => {
|
||||
const listed = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Exposed Co', slug: slug('exposed') }),
|
||||
);
|
||||
expectOk(
|
||||
await repo.changeCompanyVisibility({
|
||||
actorId: ADMIN,
|
||||
companyId: listed.company.id,
|
||||
visibility: 'directory',
|
||||
}),
|
||||
);
|
||||
|
||||
// The company is directory-listed for the whole probe window...
|
||||
expect((await repo.listDirectory()).map((entry) => entry.id)).toContain(listed.company.id);
|
||||
|
||||
// ...but the non-granted reader's granted-read surface still excludes it:
|
||||
// directory disclosure adds existence/name/slug only, never content access.
|
||||
expect(await repo.listGrantedCompanies(STRANGER)).toEqual([]);
|
||||
|
||||
// A stranger mutation of the listed company is refused exactly like a
|
||||
// missing node — the §6.7 carve-out covers the listing, not commands.
|
||||
const realProbe = await repo.renameCompany({
|
||||
actorId: STRANGER,
|
||||
companyId: listed.company.id,
|
||||
name: 'x',
|
||||
});
|
||||
const missingProbe = await repo.renameCompany({
|
||||
actorId: STRANGER,
|
||||
companyId: randomUUID(),
|
||||
name: 'x',
|
||||
});
|
||||
expect(realProbe).toEqual(missingProbe);
|
||||
expect(await repo.deleteCompany({ actorId: STRANGER, companyId: listed.company.id })).toEqual({
|
||||
ok: false,
|
||||
error: 'not_found',
|
||||
});
|
||||
});
|
||||
|
||||
it('granted companies are the reader control: owner sees them, a stranger sees nothing (§2.8)', async () => {
|
||||
const ownerCompanies = await repo.listGrantedCompanies(OWNER);
|
||||
expect(ownerCompanies.map((c) => c.id)).toContain(companyId);
|
||||
expect(await repo.listGrantedCompanies(STRANGER)).toEqual([]);
|
||||
});
|
||||
|
||||
// ── §6.7 no existence oracle ───────────────────────────────────────────────
|
||||
|
||||
it('an unauthorized probe of a real node is indistinguishable from a missing node', async () => {
|
||||
const realCompany = await repo.renameCompany({ actorId: STRANGER, companyId, name: 'x' });
|
||||
const missingCompany = await repo.renameCompany({
|
||||
actorId: STRANGER,
|
||||
companyId: randomUUID(),
|
||||
name: 'x',
|
||||
});
|
||||
expect(realCompany).toEqual(missingCompany);
|
||||
|
||||
const estate = expectOk(
|
||||
await repo.createEstate({
|
||||
actorId: OWNER,
|
||||
companyId,
|
||||
name: 'Oracle E',
|
||||
slug: slug('oracle'),
|
||||
}),
|
||||
);
|
||||
const realEstate = await repo.deleteEstate({ actorId: STRANGER, estateId: estate.estate.id });
|
||||
const missingEstate = await repo.deleteEstate({ actorId: STRANGER, estateId: randomUUID() });
|
||||
expect(realEstate).toEqual(missingEstate);
|
||||
|
||||
const grant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'estate',
|
||||
targetId: estate.estate.id,
|
||||
role: 'viewer',
|
||||
}),
|
||||
);
|
||||
const realGrant = await repo.revokeGrant({ actorId: STRANGER, grantId: grant.grant.id });
|
||||
const missingGrant = await repo.revokeGrant({ actorId: STRANGER, grantId: randomUUID() });
|
||||
expect(realGrant).toEqual(missingGrant);
|
||||
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id }));
|
||||
});
|
||||
|
||||
// ── contract 2 §3 grant evaluation ─────────────────────────────────────────
|
||||
|
||||
it('a company grant confers its role down the whole chain, workspace included (§3.2)', async () => {
|
||||
const estate = expectOk(
|
||||
await repo.createEstate({ actorId: OWNER, companyId, name: 'Chain E', slug: slug('chain') }),
|
||||
);
|
||||
const project = expectOk(
|
||||
await repo.createPlatformProject({
|
||||
actorId: OWNER,
|
||||
estateId: estate.estate.id,
|
||||
name: 'Chain P',
|
||||
slug: slug('chain-p'),
|
||||
}),
|
||||
);
|
||||
// Workspaces are evaluable but not hierarchy commands; seed one directly.
|
||||
const workspaceId = randomUUID();
|
||||
await handle.db.insert(workspaces).values({
|
||||
id: workspaceId,
|
||||
name: 'Chain W',
|
||||
slug: slug('chain-w'),
|
||||
platformProjectId: project.platformProject.id,
|
||||
});
|
||||
|
||||
for (const [kind, id] of [
|
||||
['company', companyId],
|
||||
['estate', estate.estate.id],
|
||||
['platform_project', project.platformProject.id],
|
||||
['workspace', workspaceId],
|
||||
] as const) {
|
||||
expect(await evaluation.effectiveRole(OWNER, kind, id)).toBe('owner');
|
||||
expect(await evaluation.effectiveRole(STRANGER, kind, id)).toBeNull();
|
||||
}
|
||||
|
||||
// Max-role (§3.3): viewer on the company + owner on the estate → owner at
|
||||
// and below the estate, viewer at the company.
|
||||
const viewerGrant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'viewer',
|
||||
}),
|
||||
);
|
||||
const ownerGrant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'estate',
|
||||
targetId: estate.estate.id,
|
||||
role: 'owner',
|
||||
}),
|
||||
);
|
||||
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBe('viewer');
|
||||
expect(await evaluation.effectiveRole(SUBJECT, 'estate', estate.estate.id)).toBe('owner');
|
||||
expect(await evaluation.effectiveRole(SUBJECT, 'workspace', workspaceId)).toBe('owner');
|
||||
|
||||
// Revocation is row deletion and denies the very next evaluation (§6).
|
||||
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: ownerGrant.grant.id }));
|
||||
expect(await evaluation.effectiveRole(SUBJECT, 'estate', estate.estate.id)).toBe('viewer');
|
||||
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: viewerGrant.grant.id }));
|
||||
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBeNull();
|
||||
|
||||
await handle.db.delete(workspaces).where(eq(workspaces.id, workspaceId));
|
||||
expectOk(
|
||||
await repo.deletePlatformProject({
|
||||
actorId: OWNER,
|
||||
platformProjectId: project.platformProject.id,
|
||||
}),
|
||||
);
|
||||
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id }));
|
||||
});
|
||||
|
||||
it('team grant subjects are suspended: a team row confers nothing and cannot be changed (§1.4)', async () => {
|
||||
const teamId = randomUUID();
|
||||
await handle.db.insert(teams).values({
|
||||
id: teamId,
|
||||
name: slug('team'),
|
||||
slug: slug('team'),
|
||||
ownerId: SUBJECT,
|
||||
managerId: SUBJECT,
|
||||
});
|
||||
// Out-of-band team row (the command surface cannot create one).
|
||||
const inserted = await handle.db
|
||||
.insert(hierarchyGrants)
|
||||
.values({ teamId, companyId, role: 'owner', grantedBy: OWNER })
|
||||
.returning();
|
||||
const teamGrantId = inserted[0]!.id;
|
||||
|
||||
// The team's own owner gains no effective role from it.
|
||||
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBeNull();
|
||||
// changeGrant refuses the row.
|
||||
expect(
|
||||
await repo.changeGrant({ actorId: OWNER, grantId: teamGrantId, role: 'viewer' }),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: 'conflict',
|
||||
message: 'team grant subjects are suspended',
|
||||
});
|
||||
await handle.db.delete(hierarchyGrants).where(eq(hierarchyGrants.id, teamGrantId));
|
||||
await handle.db.delete(teams).where(eq(teams.id, teamId));
|
||||
});
|
||||
|
||||
// ── command conflict semantics ─────────────────────────────────────────────
|
||||
|
||||
it('transfer needs owner on both parents in its own transaction, and refuses no-op and colliding transfers (§5)', async () => {
|
||||
const source = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Src Co', slug: slug('src') }),
|
||||
);
|
||||
const destination = expectOk(
|
||||
await repo.createCompany({ actorId: SUBJECT, name: 'Dst Co', slug: slug('dst') }),
|
||||
);
|
||||
const estateSlug = slug('mv');
|
||||
const estate = expectOk(
|
||||
await repo.createEstate({
|
||||
actorId: OWNER,
|
||||
companyId: source.company.id,
|
||||
name: 'Mv E',
|
||||
slug: estateSlug,
|
||||
}),
|
||||
);
|
||||
|
||||
// OWNER owns the source but not the destination → not_found (§6.7-safe).
|
||||
expect(
|
||||
await repo.transferEstate({
|
||||
actorId: OWNER,
|
||||
estateId: estate.estate.id,
|
||||
destinationCompanyId: destination.company.id,
|
||||
}),
|
||||
).toEqual({ ok: false, error: 'not_found' });
|
||||
|
||||
// Same-parent transfer is refused.
|
||||
const samePlace = await repo.transferEstate({
|
||||
actorId: OWNER,
|
||||
estateId: estate.estate.id,
|
||||
destinationCompanyId: source.company.id,
|
||||
});
|
||||
expect(samePlace.ok).toBe(false);
|
||||
if (!samePlace.ok) expect(samePlace.error).toBe('conflict');
|
||||
|
||||
// Grant OWNER the destination; a slug collision there is refused.
|
||||
expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: SUBJECT,
|
||||
userId: OWNER,
|
||||
targetKind: 'company',
|
||||
targetId: destination.company.id,
|
||||
role: 'owner',
|
||||
}),
|
||||
);
|
||||
expectOk(
|
||||
await repo.createEstate({
|
||||
actorId: OWNER,
|
||||
companyId: destination.company.id,
|
||||
name: 'Collide',
|
||||
slug: estateSlug,
|
||||
}),
|
||||
);
|
||||
const collision = await repo.transferEstate({
|
||||
actorId: OWNER,
|
||||
estateId: estate.estate.id,
|
||||
destinationCompanyId: destination.company.id,
|
||||
});
|
||||
expect(collision.ok).toBe(false);
|
||||
if (!collision.ok) expect(collision.error).toBe('conflict');
|
||||
});
|
||||
|
||||
it('a successful transfer records transfer_from and transfer_to (§6.4 three-leg witness)', async () => {
|
||||
const from = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'From Co', slug: slug('from') }),
|
||||
);
|
||||
const to = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'To Co', slug: slug('to') }),
|
||||
);
|
||||
const estate = expectOk(
|
||||
await repo.createEstate({
|
||||
actorId: OWNER,
|
||||
companyId: from.company.id,
|
||||
name: 'Moved E',
|
||||
slug: slug('moved'),
|
||||
}),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
expectOk(
|
||||
await repo.transferEstate({
|
||||
actorId: OWNER,
|
||||
estateId: estate.estate.id,
|
||||
destinationCompanyId: to.company.id,
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
const moved = (
|
||||
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id))
|
||||
)[0]!;
|
||||
expect(moved.companyId).toBe(to.company.id);
|
||||
const events = await eventsForKey(key);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ verb: 'transfer', targetId: estate.estate.id });
|
||||
expect(events[0]!.transferFrom).toMatchObject({ kind: 'company', id: from.company.id });
|
||||
expect(events[0]!.transferTo).toMatchObject({ kind: 'company', id: to.company.id });
|
||||
// The post-transfer snapshot's parent chain names the destination.
|
||||
expect(events[0]!.targetSnapshot).toMatchObject({
|
||||
parentChain: [{ kind: 'company', id: to.company.id, slug: to.company.slug }],
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses duplicate slugs, deletes with children, and degenerate grant commands as conflicts', async () => {
|
||||
const co = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Conflict Co', slug: slug('conf') }),
|
||||
);
|
||||
const dupSlug = await repo.createCompany({ actorId: OWNER, name: 'x', slug: co.company.slug });
|
||||
expect(dupSlug.ok).toBe(false);
|
||||
if (!dupSlug.ok) expect(dupSlug.error).toBe('conflict');
|
||||
|
||||
expectOk(
|
||||
await repo.createEstate({
|
||||
actorId: OWNER,
|
||||
companyId: co.company.id,
|
||||
name: 'Child',
|
||||
slug: slug('child'),
|
||||
}),
|
||||
);
|
||||
const withChildren = await repo.deleteCompany({ actorId: OWNER, companyId: co.company.id });
|
||||
expect(withChildren.ok).toBe(false);
|
||||
if (!withChildren.ok) expect(withChildren.error).toBe('conflict');
|
||||
|
||||
// Grant to a nonexistent subject is refused (the caller already holds
|
||||
// owner, so the refusal discloses nothing new).
|
||||
const ghost = await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: `missing-${randomUUID()}`,
|
||||
targetKind: 'company',
|
||||
targetId: co.company.id,
|
||||
role: 'viewer',
|
||||
});
|
||||
expect(ghost.ok).toBe(false);
|
||||
if (!ghost.ok) expect(ghost.error).toBe('conflict');
|
||||
|
||||
const grant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: co.company.id,
|
||||
role: 'viewer',
|
||||
}),
|
||||
);
|
||||
const duplicate = await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: co.company.id,
|
||||
role: 'viewer',
|
||||
});
|
||||
expect(duplicate.ok).toBe(false);
|
||||
if (!duplicate.ok) expect(duplicate.error).toBe('conflict');
|
||||
|
||||
const sameRole = await repo.changeGrant({
|
||||
actorId: OWNER,
|
||||
grantId: grant.grant.id,
|
||||
role: 'viewer',
|
||||
});
|
||||
expect(sameRole.ok).toBe(false);
|
||||
if (!sameRole.ok) expect(sameRole.error).toBe('conflict');
|
||||
|
||||
// A second grant with another role exists → changing the first onto that
|
||||
// role would collide with the unique constraint; refused ahead of it.
|
||||
const second = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: co.company.id,
|
||||
role: 'member',
|
||||
}),
|
||||
);
|
||||
const collide = await repo.changeGrant({
|
||||
actorId: OWNER,
|
||||
grantId: grant.grant.id,
|
||||
role: 'member',
|
||||
});
|
||||
expect(collide.ok).toBe(false);
|
||||
if (!collide.ok) expect(collide.error).toBe('conflict');
|
||||
|
||||
// A clean change succeeds and records the previous role, namespaced (§4.5).
|
||||
const changeKey = `key-${randomUUID()}`;
|
||||
const changed = expectOk(
|
||||
await repo.changeGrant({
|
||||
actorId: OWNER,
|
||||
grantId: second.grant.id,
|
||||
role: 'owner',
|
||||
idempotencyKey: changeKey,
|
||||
}),
|
||||
);
|
||||
expect(changed.grant.role).toBe('hierarchy:owner');
|
||||
const events = await eventsForKey(changeKey);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.targetSnapshot).toMatchObject({
|
||||
role: 'hierarchy:owner',
|
||||
previousRole: 'hierarchy:member',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
companies,
|
||||
eq,
|
||||
estates,
|
||||
hierarchyGrants,
|
||||
inArray,
|
||||
or,
|
||||
platformProjects,
|
||||
workspaces,
|
||||
and,
|
||||
type Db,
|
||||
HIERARCHY_GRANT_ROLES,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
/**
|
||||
* Hierarchy grant evaluation (contract 2 §3).
|
||||
*
|
||||
* Deny-by-default (§3.1): a user's effective role on a node is null unless a
|
||||
* grant row explicitly confers one. Grants apply down the chain only (§3.2):
|
||||
* the effective role on a node is the maximum role over grants targeting the
|
||||
* node itself or any of its ancestors, maximum per the total order
|
||||
* viewer ⊂ member ⊂ owner (§2). Evaluation is live and per-decision — no
|
||||
* caching — so revocation (row deletion, §6) denies the next decision
|
||||
* inherently. A missing node evaluates to null, indistinguishable from
|
||||
* no-grant, which keeps unauthorized probes oracle-safe (contract 1 §6.7).
|
||||
*
|
||||
* Team grant subjects are SUSPENDED (§1.4): the command surface refuses to
|
||||
* create them and this evaluator considers user-subject grants only, so a
|
||||
* team row could not confer access even if one existed.
|
||||
*
|
||||
* Read-only module: it selects from the class tables but never writes them,
|
||||
* so it does not appear on the writer-coverage allowlist.
|
||||
*/
|
||||
|
||||
export type HierarchyGrantRole = (typeof HIERARCHY_GRANT_ROLES)[number];
|
||||
|
||||
/** Node kinds a grant may target (§3.2; workspace is evaluable, not grantable). */
|
||||
export type GrantTargetKind = 'company' | 'estate' | 'platform_project';
|
||||
/** Node kinds an authorization decision may be evaluated at (§3.2: down to workspace). */
|
||||
export type EvaluableNodeKind = GrantTargetKind | 'workspace';
|
||||
|
||||
type Tx = Pick<Db, 'select'>;
|
||||
|
||||
/** Ancestor chain of a node, self included at its own level; ids only. */
|
||||
export interface AncestorChain {
|
||||
readonly companyId: string;
|
||||
readonly estateId?: string;
|
||||
readonly platformProjectId?: string;
|
||||
readonly workspaceId?: string;
|
||||
}
|
||||
|
||||
export function roleStrength(role: HierarchyGrantRole): number {
|
||||
return HIERARCHY_GRANT_ROLES.indexOf(role);
|
||||
}
|
||||
|
||||
export function roleAtLeast(
|
||||
role: HierarchyGrantRole | null,
|
||||
required: HierarchyGrantRole,
|
||||
): boolean {
|
||||
return role !== null && roleStrength(role) >= roleStrength(required);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized role strings are namespaced (§4.5): audit events and API
|
||||
* responses carry `hierarchy:owner`, never a bare `owner`.
|
||||
*/
|
||||
export function namespacedHierarchyRole(role: HierarchyGrantRole): string {
|
||||
return `hierarchy:${role}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a node's ancestor chain (self included). Returns null when the
|
||||
* node does not exist — callers treat that exactly like no-grant (§3.1,
|
||||
* oracle-safe).
|
||||
*/
|
||||
export async function resolveAncestorChain(
|
||||
tx: Tx,
|
||||
kind: EvaluableNodeKind,
|
||||
id: string,
|
||||
): Promise<AncestorChain | null> {
|
||||
if (kind === 'company') {
|
||||
const rows = await tx
|
||||
.select({ id: companies.id })
|
||||
.from(companies)
|
||||
.where(eq(companies.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? { companyId: row.id } : null;
|
||||
}
|
||||
if (kind === 'estate') {
|
||||
const rows = await tx
|
||||
.select({ id: estates.id, companyId: estates.companyId })
|
||||
.from(estates)
|
||||
.where(eq(estates.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? { companyId: row.companyId, estateId: row.id } : null;
|
||||
}
|
||||
if (kind === 'platform_project') {
|
||||
const rows = await tx
|
||||
.select({
|
||||
id: platformProjects.id,
|
||||
estateId: platformProjects.estateId,
|
||||
companyId: estates.companyId,
|
||||
})
|
||||
.from(platformProjects)
|
||||
.innerJoin(estates, eq(estates.id, platformProjects.estateId))
|
||||
.where(eq(platformProjects.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row
|
||||
? { companyId: row.companyId, estateId: row.estateId, platformProjectId: row.id }
|
||||
: null;
|
||||
}
|
||||
const rows = await tx
|
||||
.select({
|
||||
id: workspaces.id,
|
||||
platformProjectId: workspaces.platformProjectId,
|
||||
estateId: platformProjects.estateId,
|
||||
companyId: estates.companyId,
|
||||
})
|
||||
.from(workspaces)
|
||||
.innerJoin(platformProjects, eq(platformProjects.id, workspaces.platformProjectId))
|
||||
.innerJoin(estates, eq(estates.id, platformProjects.estateId))
|
||||
.where(eq(workspaces.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row
|
||||
? {
|
||||
companyId: row.companyId,
|
||||
estateId: row.estateId,
|
||||
platformProjectId: row.platformProjectId,
|
||||
workspaceId: row.id,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function maxRole(roles: readonly string[]): HierarchyGrantRole | null {
|
||||
let best: HierarchyGrantRole | null = null;
|
||||
for (const candidate of roles) {
|
||||
// Fail-closed: a value outside the vocabulary confers nothing.
|
||||
if (!(HIERARCHY_GRANT_ROLES as readonly string[]).includes(candidate)) continue;
|
||||
const role = candidate as HierarchyGrantRole;
|
||||
if (best === null || roleStrength(role) > roleStrength(best)) best = role;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective role of a user on a node: maximum over the user's grants whose
|
||||
* target is the node or any ancestor (§3.2); null = deny (§3.1). Missing
|
||||
* node → null.
|
||||
*/
|
||||
export async function evaluateEffectiveRole(
|
||||
tx: Tx,
|
||||
userId: string,
|
||||
kind: EvaluableNodeKind,
|
||||
id: string,
|
||||
): Promise<HierarchyGrantRole | null> {
|
||||
const chain = await resolveAncestorChain(tx, kind, id);
|
||||
if (!chain) return null;
|
||||
|
||||
const targetConditions = [eq(hierarchyGrants.companyId, chain.companyId)];
|
||||
if (chain.estateId) targetConditions.push(eq(hierarchyGrants.estateId, chain.estateId));
|
||||
if (chain.platformProjectId) {
|
||||
targetConditions.push(eq(hierarchyGrants.platformProjectId, chain.platformProjectId));
|
||||
}
|
||||
|
||||
const rows = await tx
|
||||
.select({ role: hierarchyGrants.role })
|
||||
.from(hierarchyGrants)
|
||||
.where(and(eq(hierarchyGrants.userId, userId), or(...targetConditions)));
|
||||
return maxRole(rows.map((r) => r.role));
|
||||
}
|
||||
|
||||
/**
|
||||
* All companies on which the user holds any effective role, i.e. companies
|
||||
* with a grant on the company itself or on any descendant (contract 1 §2.8:
|
||||
* a grant anywhere in the subtree discloses the company's chain upward).
|
||||
*/
|
||||
export async function grantedCompanyIds(tx: Tx, userId: string): Promise<string[]> {
|
||||
const grants = await tx
|
||||
.select({
|
||||
companyId: hierarchyGrants.companyId,
|
||||
estateId: hierarchyGrants.estateId,
|
||||
platformProjectId: hierarchyGrants.platformProjectId,
|
||||
})
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.userId, userId));
|
||||
|
||||
const companyIds = new Set<string>();
|
||||
const estateIds = new Set<string>();
|
||||
const platformProjectIds = new Set<string>();
|
||||
for (const grant of grants) {
|
||||
if (grant.companyId) companyIds.add(grant.companyId);
|
||||
else if (grant.estateId) estateIds.add(grant.estateId);
|
||||
else if (grant.platformProjectId) platformProjectIds.add(grant.platformProjectId);
|
||||
}
|
||||
|
||||
if (platformProjectIds.size > 0) {
|
||||
const rows = await tx
|
||||
.select({ estateId: platformProjects.estateId })
|
||||
.from(platformProjects)
|
||||
.where(inArray(platformProjects.id, [...platformProjectIds]));
|
||||
for (const row of rows) estateIds.add(row.estateId);
|
||||
}
|
||||
if (estateIds.size > 0) {
|
||||
const rows = await tx
|
||||
.select({ companyId: estates.companyId })
|
||||
.from(estates)
|
||||
.where(inArray(estates.id, [...estateIds]));
|
||||
for (const row of rows) companyIds.add(row.companyId);
|
||||
}
|
||||
return [...companyIds];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HierarchyGrantEvaluationService {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
/** Live per-decision evaluation; pass a tx to evaluate inside a command's transaction. */
|
||||
effectiveRole(
|
||||
userId: string,
|
||||
kind: EvaluableNodeKind,
|
||||
id: string,
|
||||
tx?: Tx,
|
||||
): Promise<HierarchyGrantRole | null> {
|
||||
return evaluateEffectiveRole(tx ?? this.db, userId, kind, id);
|
||||
}
|
||||
|
||||
async hasRole(
|
||||
userId: string,
|
||||
kind: EvaluableNodeKind,
|
||||
id: string,
|
||||
required: HierarchyGrantRole,
|
||||
tx?: Tx,
|
||||
): Promise<boolean> {
|
||||
return roleAtLeast(await this.effectiveRole(userId, kind, id, tx), required);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import {
|
||||
ChangeCompanyVisibilityDto,
|
||||
ChangeGrantDto,
|
||||
CreateCompanyDto,
|
||||
CreateEstateDto,
|
||||
CreateGrantDto,
|
||||
CreatePlatformProjectDto,
|
||||
DeleteNodeDto,
|
||||
RenameNodeDto,
|
||||
TransferEstateDto,
|
||||
TransferPlatformProjectDto,
|
||||
} from './hierarchy.dto.js';
|
||||
import { HierarchyRepository } from './hierarchy.repository.js';
|
||||
import { HierarchyService } from './hierarchy.service.js';
|
||||
|
||||
/**
|
||||
* The hierarchy command family (contract 1 §5, §6.3). This controller is the
|
||||
* closed HTTP surface over the hierarchy class tables: the route-inventory
|
||||
* witness asserts these routes and no others exist. Delete commands take an
|
||||
* optional body (idempotency key) via POST-style DTOs; every mutation is
|
||||
* audited on its own transaction by the repository.
|
||||
*/
|
||||
@Controller('api/hierarchy')
|
||||
@UseGuards(AuthGuard)
|
||||
export class HierarchyController {
|
||||
constructor(
|
||||
private readonly repository: HierarchyRepository,
|
||||
private readonly service: HierarchyService,
|
||||
) {}
|
||||
|
||||
// ── companies ────────────────────────────────────────────────────────────
|
||||
|
||||
@Post('companies')
|
||||
async createCompany(@CurrentUser() user: { id: string }, @Body() dto: CreateCompanyDto) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.createCompany({
|
||||
actorId: user.id,
|
||||
name: dto.name,
|
||||
slug: dto.slug,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Companies the caller holds a grant on (directly or via a descendant). */
|
||||
@Get('companies')
|
||||
listGrantedCompanies(@CurrentUser() user: { id: string }) {
|
||||
return this.repository.listGrantedCompanies(user.id);
|
||||
}
|
||||
|
||||
/** Directory-class companies, closed-field (§2.8). */
|
||||
@Get('companies/directory')
|
||||
listDirectory() {
|
||||
return this.repository.listDirectory();
|
||||
}
|
||||
|
||||
@Post('companies/:id/rename')
|
||||
@HttpCode(200)
|
||||
async renameCompany(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RenameNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.renameCompany({
|
||||
actorId: user.id,
|
||||
companyId: id,
|
||||
name: dto.name,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('companies/:id/visibility')
|
||||
@HttpCode(200)
|
||||
async changeCompanyVisibility(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ChangeCompanyVisibilityDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.changeCompanyVisibility({
|
||||
actorId: user.id,
|
||||
companyId: id,
|
||||
visibility: dto.visibility,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('companies/:id')
|
||||
async deleteCompany(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: DeleteNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.deleteCompany({
|
||||
actorId: user.id,
|
||||
companyId: id,
|
||||
idempotencyKey: dto?.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── estates ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Post('estates')
|
||||
async createEstate(@CurrentUser() user: { id: string }, @Body() dto: CreateEstateDto) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.createEstate({
|
||||
actorId: user.id,
|
||||
companyId: dto.companyId,
|
||||
name: dto.name,
|
||||
slug: dto.slug,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('estates/:id/rename')
|
||||
@HttpCode(200)
|
||||
async renameEstate(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RenameNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.renameEstate({
|
||||
actorId: user.id,
|
||||
estateId: id,
|
||||
name: dto.name,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('estates/:id/transfer')
|
||||
@HttpCode(200)
|
||||
async transferEstate(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: TransferEstateDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.transferEstate({
|
||||
actorId: user.id,
|
||||
estateId: id,
|
||||
destinationCompanyId: dto.destinationCompanyId,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('estates/:id')
|
||||
async deleteEstate(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: DeleteNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.deleteEstate({
|
||||
actorId: user.id,
|
||||
estateId: id,
|
||||
idempotencyKey: dto?.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── platform projects ────────────────────────────────────────────────────
|
||||
|
||||
@Post('platform-projects')
|
||||
async createPlatformProject(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Body() dto: CreatePlatformProjectDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.createPlatformProject({
|
||||
actorId: user.id,
|
||||
estateId: dto.estateId,
|
||||
name: dto.name,
|
||||
slug: dto.slug,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('platform-projects/:id/rename')
|
||||
@HttpCode(200)
|
||||
async renamePlatformProject(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RenameNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.renamePlatformProject({
|
||||
actorId: user.id,
|
||||
platformProjectId: id,
|
||||
name: dto.name,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('platform-projects/:id/transfer')
|
||||
@HttpCode(200)
|
||||
async transferPlatformProject(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: TransferPlatformProjectDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.transferPlatformProject({
|
||||
actorId: user.id,
|
||||
platformProjectId: id,
|
||||
destinationEstateId: dto.destinationEstateId,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('platform-projects/:id')
|
||||
async deletePlatformProject(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: DeleteNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.deletePlatformProject({
|
||||
actorId: user.id,
|
||||
platformProjectId: id,
|
||||
idempotencyKey: dto?.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── grants ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Post('grants')
|
||||
async createGrant(@CurrentUser() user: { id: string }, @Body() dto: CreateGrantDto) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.createGrant({
|
||||
actorId: user.id,
|
||||
userId: dto.userId,
|
||||
targetKind: dto.targetKind,
|
||||
targetId: dto.targetId,
|
||||
role: dto.role,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('grants/:id/change')
|
||||
@HttpCode(200)
|
||||
async changeGrant(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ChangeGrantDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.changeGrant({
|
||||
actorId: user.id,
|
||||
grantId: id,
|
||||
role: dto.role,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('grants/:id')
|
||||
async revokeGrant(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: DeleteNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.revokeGrant({
|
||||
actorId: user.id,
|
||||
grantId: id,
|
||||
idempotencyKey: dto?.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { COMPANY_VISIBILITY, HIERARCHY_GRANT_ROLES } from '@mosaicstack/db';
|
||||
import { IsIn, IsOptional, IsString, IsUUID, Matches, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Hierarchy command DTOs (contract 1 §5, contract 2 §4/§7).
|
||||
*
|
||||
* The global ValidationPipe runs with whitelist + forbidNonWhitelisted, so a
|
||||
* payload field absent from these classes is a 400. That closure is itself
|
||||
* contract surface:
|
||||
* - CreateCompanyDto declares NO visibility field — creation is always
|
||||
* private (contract 1 §5.5); a visibility argument is refused by the pipe.
|
||||
* - CreateGrantDto declares NO teamId field — team grant subjects are
|
||||
* suspended (contract 2 §1.4/§7.5); a team subject is refused by the pipe.
|
||||
* Every class here must be registered in PIPE_GUARDED_DTOS so the boot-time
|
||||
* assertion proves the pipe sees the decorators.
|
||||
*/
|
||||
|
||||
const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
||||
const SLUG_MESSAGE = 'slug must be lowercase alphanumeric with interior hyphens';
|
||||
|
||||
export class CreateCompanyDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
|
||||
slug!: string;
|
||||
|
||||
/** Client-supplied idempotency key (REQ-AUD-001 replay); server-generated when absent. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class RenameNodeDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class ChangeCompanyVisibilityDto {
|
||||
@IsIn(COMPANY_VISIBILITY)
|
||||
visibility!: (typeof COMPANY_VISIBILITY)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class DeleteNodeDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class CreateEstateDto {
|
||||
@IsUUID()
|
||||
companyId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
|
||||
slug!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class CreatePlatformProjectDto {
|
||||
@IsUUID()
|
||||
estateId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
|
||||
slug!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class TransferEstateDto {
|
||||
@IsUUID()
|
||||
destinationCompanyId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class TransferPlatformProjectDto {
|
||||
@IsUUID()
|
||||
destinationEstateId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class CreateGrantDto {
|
||||
/** Subject user (better-auth text id). No teamId field — see module doc. */
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
userId!: string;
|
||||
|
||||
@IsIn(['company', 'estate', 'platform_project'])
|
||||
targetKind!: 'company' | 'estate' | 'platform_project';
|
||||
|
||||
@IsUUID()
|
||||
targetId!: string;
|
||||
|
||||
/** Bare vocabulary on requests; responses and audit events are namespaced (§4.5). */
|
||||
@IsIn(HIERARCHY_GRANT_ROLES)
|
||||
role!: (typeof HIERARCHY_GRANT_ROLES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class ChangeGrantDto {
|
||||
@IsIn(HIERARCHY_GRANT_ROLES)
|
||||
role!: (typeof HIERARCHY_GRANT_ROLES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
@@ -1,17 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HierarchyAuditRepository } from './hierarchy-audit.repository.js';
|
||||
import { HierarchyGrantEvaluationService } from './hierarchy-grant-evaluation.js';
|
||||
import { HierarchyController } from './hierarchy.controller.js';
|
||||
import { HierarchyRepository } from './hierarchy.repository.js';
|
||||
import { HierarchyService } from './hierarchy.service.js';
|
||||
|
||||
/**
|
||||
* Hierarchy (tenancy/authorization structure) feature module.
|
||||
*
|
||||
* M4-1b-i ships the audit event + outbox machinery only (contract 1 §5.2).
|
||||
* The hierarchy command family — controllers, DTOs, and the allowlisted
|
||||
* class-table repositories — lands in M4-1b-ii once contract 2 (RBAC grant
|
||||
* model) merges; until then this module exposes no routes, which the
|
||||
* route-inventory witness asserts.
|
||||
* M4-1b-i shipped the audit event + outbox machinery (contract 1 §5.2);
|
||||
* M4-1b-ii adds the command family — the closed route surface asserted by
|
||||
* the route-inventory witness — plus grant evaluation (contract 2 §3).
|
||||
* HierarchyRepository is the sole class-table writer (writer-coverage
|
||||
* allowlist); every mutation runs authorize → mutate → audit in one
|
||||
* transaction.
|
||||
*/
|
||||
@Module({
|
||||
providers: [HierarchyAuditRepository],
|
||||
exports: [HierarchyAuditRepository],
|
||||
controllers: [HierarchyController],
|
||||
providers: [
|
||||
HierarchyAuditRepository,
|
||||
HierarchyGrantEvaluationService,
|
||||
HierarchyRepository,
|
||||
HierarchyService,
|
||||
],
|
||||
exports: [HierarchyAuditRepository, HierarchyGrantEvaluationService],
|
||||
})
|
||||
export class HierarchyModule {}
|
||||
|
||||
@@ -0,0 +1,935 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
companies,
|
||||
eq,
|
||||
estates,
|
||||
hierarchyGrants,
|
||||
inArray,
|
||||
platformProjects,
|
||||
users,
|
||||
workspaces,
|
||||
type Db,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import {
|
||||
appendHierarchyEvent,
|
||||
buildNodeSnapshot,
|
||||
HierarchyAuditIdempotencyConflictError,
|
||||
} from './hierarchy-audit.repository.js';
|
||||
import {
|
||||
evaluateEffectiveRole,
|
||||
grantedCompanyIds,
|
||||
namespacedHierarchyRole,
|
||||
roleAtLeast,
|
||||
type GrantTargetKind,
|
||||
type HierarchyGrantRole,
|
||||
} from './hierarchy-grant-evaluation.js';
|
||||
|
||||
/**
|
||||
* Hierarchy command repository (contract 1 §5, contract 2 §4).
|
||||
*
|
||||
* The ONLY writer of the hierarchy class tables (companies, estates,
|
||||
* platform_projects, hierarchy_grants) — it is the writer-coverage
|
||||
* allowlist's sole entry. Every command runs one transaction that
|
||||
* authorizes (live grant evaluation inside the same transaction), mutates,
|
||||
* and appends the semantic audit event + outbox record via the M4-1b-i
|
||||
* machinery, so state, event, and outbox commit or roll back together
|
||||
* (REQ-AUD-001).
|
||||
*
|
||||
* Authorization failure and target-not-found both return `not_found`
|
||||
* (contract 1 §6.7: no existence oracle — an unauthorized caller learns
|
||||
* nothing a stranger would not). `forbidden` appears only where the caller
|
||||
* already knows the surface exists independent of any node: the admin-only
|
||||
* visibility change (§5.5). Serialized role strings are namespaced (§4.5).
|
||||
*/
|
||||
|
||||
export type HierarchyCommandFailure =
|
||||
| { readonly ok: false; readonly error: 'not_found' }
|
||||
| { readonly ok: false; readonly error: 'forbidden'; readonly message: string }
|
||||
| { readonly ok: false; readonly error: 'conflict'; readonly message: string };
|
||||
|
||||
export type HierarchyResult<T> = ({ readonly ok: true } & T) | HierarchyCommandFailure;
|
||||
|
||||
export interface CompanyView {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly slug: string;
|
||||
readonly visibility: string;
|
||||
}
|
||||
|
||||
export interface NodeView {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly slug: string;
|
||||
}
|
||||
|
||||
export interface GrantView {
|
||||
readonly id: string;
|
||||
readonly userId: string;
|
||||
readonly targetKind: GrantTargetKind;
|
||||
readonly targetId: string;
|
||||
/** Namespaced (§4.5), e.g. `hierarchy:owner`. */
|
||||
readonly role: string;
|
||||
readonly grantedBy: string;
|
||||
}
|
||||
|
||||
/** Directory rows are closed-field: existence, name, slug — nothing else (§2.8). */
|
||||
export interface DirectoryEntry {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly slug: string;
|
||||
}
|
||||
|
||||
type Tx = Pick<Db, 'insert' | 'select' | 'update' | 'delete'>;
|
||||
type GrantRow = typeof hierarchyGrants.$inferSelect;
|
||||
|
||||
const NOT_FOUND: HierarchyCommandFailure = { ok: false, error: 'not_found' };
|
||||
|
||||
function conflict(message: string): HierarchyCommandFailure {
|
||||
return { ok: false, error: 'conflict', message };
|
||||
}
|
||||
|
||||
function grantTarget(row: GrantRow): { kind: GrantTargetKind; id: string } {
|
||||
if (row.companyId) return { kind: 'company', id: row.companyId };
|
||||
if (row.estateId) return { kind: 'estate', id: row.estateId };
|
||||
return { kind: 'platform_project', id: row.platformProjectId as string };
|
||||
}
|
||||
|
||||
/** Grant event snapshot (contract 2 §4.4): subject, target, namespaced role, grantor. */
|
||||
function grantSnapshot(row: GrantRow): Record<string, unknown> {
|
||||
const target = grantTarget(row);
|
||||
return {
|
||||
id: row.id,
|
||||
subject: { userId: row.userId },
|
||||
target: { kind: target.kind, id: target.id },
|
||||
role: namespacedHierarchyRole(row.role as HierarchyGrantRole),
|
||||
grantedBy: row.grantedBy,
|
||||
};
|
||||
}
|
||||
|
||||
function grantView(row: GrantRow): GrantView {
|
||||
const target = grantTarget(row);
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId as string,
|
||||
targetKind: target.kind,
|
||||
targetId: target.id,
|
||||
role: namespacedHierarchyRole(row.role as HierarchyGrantRole),
|
||||
grantedBy: row.grantedBy,
|
||||
};
|
||||
}
|
||||
|
||||
function companyView(row: typeof companies.$inferSelect): CompanyView {
|
||||
return { id: row.id, name: row.name, slug: row.slug, visibility: row.visibility };
|
||||
}
|
||||
|
||||
interface CommandContext {
|
||||
readonly actorId: string;
|
||||
readonly idempotencyKey: string;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HierarchyRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
private async run<T>(
|
||||
idempotencyKey: string | undefined,
|
||||
actorId: string,
|
||||
body: (tx: Tx, ctx: CommandContext) => Promise<HierarchyResult<T>>,
|
||||
): Promise<HierarchyResult<T>> {
|
||||
const ctx: CommandContext = {
|
||||
actorId,
|
||||
idempotencyKey: idempotencyKey ?? randomUUID(),
|
||||
correlationId: randomUUID(),
|
||||
};
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => body(tx, ctx));
|
||||
} catch (error) {
|
||||
// A key replayed with different content aborts the whole command —
|
||||
// the transaction (state change included) has rolled back (§6.4).
|
||||
if (error instanceof HierarchyAuditIdempotencyConflictError) {
|
||||
return conflict(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async requireOwner(
|
||||
tx: Tx,
|
||||
actorId: string,
|
||||
kind: GrantTargetKind,
|
||||
id: string,
|
||||
): Promise<boolean> {
|
||||
return roleAtLeast(await evaluateEffectiveRole(tx, actorId, kind, id), 'owner');
|
||||
}
|
||||
|
||||
private async isPlatformAdmin(tx: Tx, actorId: string): Promise<boolean> {
|
||||
const rows = await tx
|
||||
.select({ role: users.role })
|
||||
.from(users)
|
||||
.where(eq(users.id, actorId))
|
||||
.limit(1);
|
||||
return rows[0]?.role === 'admin';
|
||||
}
|
||||
|
||||
// ── companies ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Any authenticated user may create a company; the same audited operation
|
||||
* writes the creator's initial owner grant (§4.3), causation-linked to the
|
||||
* create event. Visibility is always 'private' — the command takes no
|
||||
* visibility input (§5.5).
|
||||
*/
|
||||
createCompany(input: {
|
||||
actorId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ company: CompanyView; grant: GrantView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
const inserted = await tx
|
||||
.insert(companies)
|
||||
.values({ name: input.name, slug: input.slug })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const company = inserted[0];
|
||||
if (!company) return conflict('company slug already exists');
|
||||
|
||||
const grantRows = await tx
|
||||
.insert(hierarchyGrants)
|
||||
.values({
|
||||
userId: ctx.actorId,
|
||||
companyId: company.id,
|
||||
role: 'owner',
|
||||
grantedBy: ctx.actorId,
|
||||
})
|
||||
.returning();
|
||||
const grant = grantRows[0] as GrantRow;
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
|
||||
const created = await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'create',
|
||||
targetKind: 'company',
|
||||
targetId: company.id,
|
||||
targetSnapshot: { ...snapshot },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_create',
|
||||
targetKind: 'grant',
|
||||
targetId: grant.id,
|
||||
targetSnapshot: grantSnapshot(grant),
|
||||
correlationId: ctx.correlationId,
|
||||
causationId: created.event.id,
|
||||
idempotencyKey: `${ctx.idempotencyKey}:grant`,
|
||||
});
|
||||
return { ok: true, company: companyView(company), grant: grantView(grant) };
|
||||
});
|
||||
}
|
||||
|
||||
renameCompany(input: {
|
||||
actorId: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ company: CompanyView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, input.companyId))
|
||||
.limit(1);
|
||||
const previous = rows[0];
|
||||
if (!previous) return NOT_FOUND;
|
||||
|
||||
const updated = await tx
|
||||
.update(companies)
|
||||
.set({ name: input.name, updatedAt: new Date() })
|
||||
.where(eq(companies.id, input.companyId))
|
||||
.returning();
|
||||
const company = updated[0] as typeof companies.$inferSelect;
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'rename',
|
||||
targetKind: 'company',
|
||||
targetId: company.id,
|
||||
targetSnapshot: { ...snapshot, previousName: previous.name },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, company: companyView(company) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only until the company-CRUD capability ratifies (§5.5) — the one
|
||||
* hierarchy mutation a platform admin performs without a grant. A
|
||||
* non-admin caller (owner included) gets `forbidden` before any company
|
||||
* read: the refusal reveals nothing about the target's existence.
|
||||
*/
|
||||
changeCompanyVisibility(input: {
|
||||
actorId: string;
|
||||
companyId: string;
|
||||
visibility: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ company: CompanyView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.isPlatformAdmin(tx, ctx.actorId))) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'forbidden',
|
||||
message: 'visibility change is platform-admin-only',
|
||||
};
|
||||
}
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, input.companyId))
|
||||
.limit(1);
|
||||
const previous = rows[0];
|
||||
if (!previous) return NOT_FOUND;
|
||||
|
||||
const updated = await tx
|
||||
.update(companies)
|
||||
.set({ visibility: input.visibility, updatedAt: new Date() })
|
||||
.where(eq(companies.id, input.companyId))
|
||||
.returning();
|
||||
const company = updated[0] as typeof companies.$inferSelect;
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'visibility_change',
|
||||
targetKind: 'company',
|
||||
targetId: company.id,
|
||||
// Old and new values are event content (§5.5).
|
||||
targetSnapshot: {
|
||||
...snapshot,
|
||||
previousVisibility: previous.visibility,
|
||||
visibility: company.visibility,
|
||||
},
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, company: companyView(company) };
|
||||
});
|
||||
}
|
||||
|
||||
deleteCompany(input: {
|
||||
actorId: string;
|
||||
companyId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ deletedId: string }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const children = await tx
|
||||
.select({ id: estates.id })
|
||||
.from(estates)
|
||||
.where(eq(estates.companyId, input.companyId))
|
||||
.limit(1);
|
||||
if (children.length > 0) return conflict('company still has estates');
|
||||
|
||||
// Snapshot and grants are read before the delete; target FKs cascade
|
||||
// the grant rows, and each cascaded deletion is audited (§5.2).
|
||||
const snapshot = await buildNodeSnapshot(tx, 'company', input.companyId);
|
||||
const grants = await tx
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.companyId, input.companyId));
|
||||
|
||||
await tx.delete(companies).where(eq(companies.id, input.companyId));
|
||||
|
||||
const deleted = await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'delete',
|
||||
targetKind: 'company',
|
||||
targetId: input.companyId,
|
||||
targetSnapshot: { ...snapshot },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
for (const grant of grants) {
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_revoke',
|
||||
targetKind: 'grant',
|
||||
targetId: grant.id,
|
||||
targetSnapshot: grantSnapshot(grant),
|
||||
correlationId: ctx.correlationId,
|
||||
causationId: deleted.event.id,
|
||||
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
|
||||
});
|
||||
}
|
||||
return { ok: true, deletedId: input.companyId };
|
||||
});
|
||||
}
|
||||
|
||||
// ── estates ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Child creation requires owner on the parent and confers no grant (§4.3). */
|
||||
createEstate(input: {
|
||||
actorId: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ estate: NodeView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const inserted = await tx
|
||||
.insert(estates)
|
||||
.values({ companyId: input.companyId, name: input.name, slug: input.slug })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const estate = inserted[0];
|
||||
if (!estate) return conflict('estate slug already exists in company');
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'estate', estate.id);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'create',
|
||||
targetKind: 'estate',
|
||||
targetId: estate.id,
|
||||
targetSnapshot: { ...snapshot },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
|
||||
});
|
||||
}
|
||||
|
||||
renameEstate(input: {
|
||||
actorId: string;
|
||||
estateId: string;
|
||||
name: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ estate: NodeView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const rows = await tx.select().from(estates).where(eq(estates.id, input.estateId)).limit(1);
|
||||
const previous = rows[0];
|
||||
if (!previous) return NOT_FOUND;
|
||||
|
||||
const updated = await tx
|
||||
.update(estates)
|
||||
.set({ name: input.name })
|
||||
.where(eq(estates.id, input.estateId))
|
||||
.returning();
|
||||
const estate = updated[0] as typeof estates.$inferSelect;
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'estate', estate.id);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'rename',
|
||||
targetKind: 'estate',
|
||||
targetId: estate.id,
|
||||
targetSnapshot: { ...snapshot, previousName: previous.name },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
|
||||
});
|
||||
}
|
||||
|
||||
/** Transfer requires effective owner on BOTH parents, evaluated in the transfer's own transaction (§5). */
|
||||
transferEstate(input: {
|
||||
actorId: string;
|
||||
estateId: string;
|
||||
destinationCompanyId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ estate: NodeView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
const rows = await tx.select().from(estates).where(eq(estates.id, input.estateId)).limit(1);
|
||||
const estate = rows[0];
|
||||
if (!estate) return NOT_FOUND;
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'company', estate.companyId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.destinationCompanyId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
if (estate.companyId === input.destinationCompanyId) {
|
||||
return conflict('estate already belongs to the destination company');
|
||||
}
|
||||
const collision = await tx
|
||||
.select({ id: estates.id })
|
||||
.from(estates)
|
||||
.where(
|
||||
and(eq(estates.companyId, input.destinationCompanyId), eq(estates.slug, estate.slug)),
|
||||
)
|
||||
.limit(1);
|
||||
if (collision.length > 0) return conflict('destination company already has that estate slug');
|
||||
|
||||
const parents = await tx
|
||||
.select({ id: companies.id, slug: companies.slug })
|
||||
.from(companies)
|
||||
.where(inArray(companies.id, [estate.companyId, input.destinationCompanyId]));
|
||||
const source = parents.find((p) => p.id === estate.companyId);
|
||||
const destination = parents.find((p) => p.id === input.destinationCompanyId);
|
||||
if (!source || !destination) return NOT_FOUND;
|
||||
|
||||
await tx
|
||||
.update(estates)
|
||||
.set({ companyId: input.destinationCompanyId })
|
||||
.where(eq(estates.id, input.estateId));
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'estate', input.estateId);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'transfer',
|
||||
targetKind: 'estate',
|
||||
targetId: input.estateId,
|
||||
targetSnapshot: { ...snapshot },
|
||||
transferFrom: { kind: 'company', id: source.id, slug: source.slug },
|
||||
transferTo: { kind: 'company', id: destination.id, slug: destination.slug },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
|
||||
});
|
||||
}
|
||||
|
||||
deleteEstate(input: {
|
||||
actorId: string;
|
||||
estateId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ deletedId: string }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const children = await tx
|
||||
.select({ id: platformProjects.id })
|
||||
.from(platformProjects)
|
||||
.where(eq(platformProjects.estateId, input.estateId))
|
||||
.limit(1);
|
||||
if (children.length > 0) return conflict('estate still has platform projects');
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'estate', input.estateId);
|
||||
const grants = await tx
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.estateId, input.estateId));
|
||||
|
||||
await tx.delete(estates).where(eq(estates.id, input.estateId));
|
||||
|
||||
const deleted = await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'delete',
|
||||
targetKind: 'estate',
|
||||
targetId: input.estateId,
|
||||
targetSnapshot: { ...snapshot },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
for (const grant of grants) {
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_revoke',
|
||||
targetKind: 'grant',
|
||||
targetId: grant.id,
|
||||
targetSnapshot: grantSnapshot(grant),
|
||||
correlationId: ctx.correlationId,
|
||||
causationId: deleted.event.id,
|
||||
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
|
||||
});
|
||||
}
|
||||
return { ok: true, deletedId: input.estateId };
|
||||
});
|
||||
}
|
||||
|
||||
// ── platform projects ────────────────────────────────────────────────────
|
||||
|
||||
createPlatformProject(input: {
|
||||
actorId: string;
|
||||
estateId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const inserted = await tx
|
||||
.insert(platformProjects)
|
||||
.values({ estateId: input.estateId, name: input.name, slug: input.slug })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const project = inserted[0];
|
||||
if (!project) return conflict('platform project slug already exists in estate');
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'platform_project', project.id);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'create',
|
||||
targetKind: 'platform_project',
|
||||
targetId: project.id,
|
||||
targetSnapshot: { ...snapshot },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
platformProject: { id: project.id, name: project.name, slug: project.slug },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
renamePlatformProject(input: {
|
||||
actorId: string;
|
||||
platformProjectId: string;
|
||||
name: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (
|
||||
!(await this.requireOwner(tx, ctx.actorId, 'platform_project', input.platformProjectId))
|
||||
) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(platformProjects)
|
||||
.where(eq(platformProjects.id, input.platformProjectId))
|
||||
.limit(1);
|
||||
const previous = rows[0];
|
||||
if (!previous) return NOT_FOUND;
|
||||
|
||||
const updated = await tx
|
||||
.update(platformProjects)
|
||||
.set({ name: input.name })
|
||||
.where(eq(platformProjects.id, input.platformProjectId))
|
||||
.returning();
|
||||
const project = updated[0] as typeof platformProjects.$inferSelect;
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'platform_project', project.id);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'rename',
|
||||
targetKind: 'platform_project',
|
||||
targetId: project.id,
|
||||
targetSnapshot: { ...snapshot, previousName: previous.name },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
platformProject: { id: project.id, name: project.name, slug: project.slug },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
transferPlatformProject(input: {
|
||||
actorId: string;
|
||||
platformProjectId: string;
|
||||
destinationEstateId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(platformProjects)
|
||||
.where(eq(platformProjects.id, input.platformProjectId))
|
||||
.limit(1);
|
||||
const project = rows[0];
|
||||
if (!project) return NOT_FOUND;
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', project.estateId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.destinationEstateId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
if (project.estateId === input.destinationEstateId) {
|
||||
return conflict('platform project already belongs to the destination estate');
|
||||
}
|
||||
const collision = await tx
|
||||
.select({ id: platformProjects.id })
|
||||
.from(platformProjects)
|
||||
.where(
|
||||
and(
|
||||
eq(platformProjects.estateId, input.destinationEstateId),
|
||||
eq(platformProjects.slug, project.slug),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (collision.length > 0) {
|
||||
return conflict('destination estate already has that platform project slug');
|
||||
}
|
||||
|
||||
const parents = await tx
|
||||
.select({ id: estates.id, slug: estates.slug })
|
||||
.from(estates)
|
||||
.where(inArray(estates.id, [project.estateId, input.destinationEstateId]));
|
||||
const source = parents.find((p) => p.id === project.estateId);
|
||||
const destination = parents.find((p) => p.id === input.destinationEstateId);
|
||||
if (!source || !destination) return NOT_FOUND;
|
||||
|
||||
await tx
|
||||
.update(platformProjects)
|
||||
.set({ estateId: input.destinationEstateId })
|
||||
.where(eq(platformProjects.id, input.platformProjectId));
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'platform_project', input.platformProjectId);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'transfer',
|
||||
targetKind: 'platform_project',
|
||||
targetId: input.platformProjectId,
|
||||
targetSnapshot: { ...snapshot },
|
||||
transferFrom: { kind: 'estate', id: source.id, slug: source.slug },
|
||||
transferTo: { kind: 'estate', id: destination.id, slug: destination.slug },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
platformProject: { id: project.id, name: project.name, slug: project.slug },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
deletePlatformProject(input: {
|
||||
actorId: string;
|
||||
platformProjectId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ deletedId: string }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (
|
||||
!(await this.requireOwner(tx, ctx.actorId, 'platform_project', input.platformProjectId))
|
||||
) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const children = await tx
|
||||
.select({ id: workspaces.id })
|
||||
.from(workspaces)
|
||||
.where(eq(workspaces.platformProjectId, input.platformProjectId))
|
||||
.limit(1);
|
||||
if (children.length > 0) return conflict('platform project still has workspaces');
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'platform_project', input.platformProjectId);
|
||||
const grants = await tx
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.platformProjectId, input.platformProjectId));
|
||||
|
||||
await tx.delete(platformProjects).where(eq(platformProjects.id, input.platformProjectId));
|
||||
|
||||
const deleted = await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'delete',
|
||||
targetKind: 'platform_project',
|
||||
targetId: input.platformProjectId,
|
||||
targetSnapshot: { ...snapshot },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
for (const grant of grants) {
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_revoke',
|
||||
targetKind: 'grant',
|
||||
targetId: grant.id,
|
||||
targetSnapshot: grantSnapshot(grant),
|
||||
correlationId: ctx.correlationId,
|
||||
causationId: deleted.event.id,
|
||||
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
|
||||
});
|
||||
}
|
||||
return { ok: true, deletedId: input.platformProjectId };
|
||||
});
|
||||
}
|
||||
|
||||
// ── grants ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Grant management requires effective owner on the target (§4.1). */
|
||||
createGrant(input: {
|
||||
actorId: string;
|
||||
userId: string;
|
||||
targetKind: GrantTargetKind;
|
||||
targetId: string;
|
||||
role: HierarchyGrantRole;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ grant: GrantView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, input.targetKind, input.targetId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const subject = await tx
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.id, input.userId))
|
||||
.limit(1);
|
||||
if (subject.length === 0) return conflict('subject user does not exist');
|
||||
|
||||
const inserted = await tx
|
||||
.insert(hierarchyGrants)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
companyId: input.targetKind === 'company' ? input.targetId : null,
|
||||
estateId: input.targetKind === 'estate' ? input.targetId : null,
|
||||
platformProjectId: input.targetKind === 'platform_project' ? input.targetId : null,
|
||||
role: input.role,
|
||||
grantedBy: ctx.actorId,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const grant = inserted[0];
|
||||
if (!grant) return conflict('grant already exists');
|
||||
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_create',
|
||||
targetKind: 'grant',
|
||||
targetId: grant.id,
|
||||
targetSnapshot: grantSnapshot(grant),
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, grant: grantView(grant) };
|
||||
});
|
||||
}
|
||||
|
||||
changeGrant(input: {
|
||||
actorId: string;
|
||||
grantId: string;
|
||||
role: HierarchyGrantRole;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ grant: GrantView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.id, input.grantId))
|
||||
.limit(1);
|
||||
const existing = rows[0];
|
||||
if (!existing) return NOT_FOUND;
|
||||
const target = grantTarget(existing);
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, target.kind, target.id))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
// Team subjects are suspended (§1.4); the command surface never
|
||||
// creates them, so this only fires on out-of-band rows.
|
||||
if (!existing.userId) return conflict('team grant subjects are suspended');
|
||||
if (existing.role === input.role) return conflict('grant already holds that role');
|
||||
|
||||
const duplicate = await tx
|
||||
.select({ id: hierarchyGrants.id })
|
||||
.from(hierarchyGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(hierarchyGrants.userId, existing.userId),
|
||||
target.kind === 'company'
|
||||
? eq(hierarchyGrants.companyId, target.id)
|
||||
: target.kind === 'estate'
|
||||
? eq(hierarchyGrants.estateId, target.id)
|
||||
: eq(hierarchyGrants.platformProjectId, target.id),
|
||||
eq(hierarchyGrants.role, input.role),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (duplicate.length > 0) {
|
||||
return conflict('subject already holds that role on the target');
|
||||
}
|
||||
|
||||
const updated = await tx
|
||||
.update(hierarchyGrants)
|
||||
.set({ role: input.role })
|
||||
.where(eq(hierarchyGrants.id, input.grantId))
|
||||
.returning();
|
||||
const grant = updated[0] as GrantRow;
|
||||
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_change',
|
||||
targetKind: 'grant',
|
||||
targetId: grant.id,
|
||||
targetSnapshot: {
|
||||
...grantSnapshot(grant),
|
||||
previousRole: namespacedHierarchyRole(existing.role as HierarchyGrantRole),
|
||||
},
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, grant: grantView(grant) };
|
||||
});
|
||||
}
|
||||
|
||||
/** Revocation is row deletion (§6): the next evaluation denies, nothing lingers. */
|
||||
revokeGrant(input: {
|
||||
actorId: string;
|
||||
grantId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ revokedId: string }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.id, input.grantId))
|
||||
.limit(1);
|
||||
const existing = rows[0];
|
||||
if (!existing) return NOT_FOUND;
|
||||
const target = grantTarget(existing);
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, target.kind, target.id))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
|
||||
await tx.delete(hierarchyGrants).where(eq(hierarchyGrants.id, input.grantId));
|
||||
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_revoke',
|
||||
targetKind: 'grant',
|
||||
targetId: existing.id,
|
||||
targetSnapshot: grantSnapshot(existing),
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, revokedId: existing.id };
|
||||
});
|
||||
}
|
||||
|
||||
// ── reads ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The directory: every directory-class company, closed-field (§2.8). The
|
||||
* sole ratified existence-disclosure carve-out (§6.7 / A2 §9.1.2).
|
||||
*/
|
||||
async listDirectory(): Promise<DirectoryEntry[]> {
|
||||
return this.db
|
||||
.select({ id: companies.id, name: companies.name, slug: companies.slug })
|
||||
.from(companies)
|
||||
.where(eq(companies.visibility, 'directory'))
|
||||
.orderBy(asc(companies.name));
|
||||
}
|
||||
|
||||
/** Companies the user holds any grant on (company or descendant, §2.8). */
|
||||
async listGrantedCompanies(userId: string): Promise<CompanyView[]> {
|
||||
const ids = await grantedCompanyIds(this.db, userId);
|
||||
if (ids.length === 0) return [];
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(inArray(companies.id, ids))
|
||||
.orderBy(asc(companies.name));
|
||||
return rows.map(companyView);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { HierarchyCommandFailure, HierarchyResult } from './hierarchy.repository.js';
|
||||
|
||||
/**
|
||||
* Maps repository result unions onto HTTP exceptions. `not_found` carries
|
||||
* one fixed message for every cause — missing node and unauthorized caller
|
||||
* are indistinguishable on the wire (contract 1 §6.7).
|
||||
*/
|
||||
@Injectable()
|
||||
export class HierarchyService {
|
||||
unwrap<T>(result: HierarchyResult<T>): T {
|
||||
if (result.ok) return result;
|
||||
throw this.toException(result);
|
||||
}
|
||||
|
||||
private toException(failure: HierarchyCommandFailure): Error {
|
||||
switch (failure.error) {
|
||||
case 'not_found':
|
||||
return new NotFoundException('hierarchy node not found');
|
||||
case 'forbidden':
|
||||
return new ForbiddenException(failure.message);
|
||||
case 'conflict':
|
||||
return new ConflictException(failure.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,7 +176,18 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
).toBe(false);
|
||||
expect(
|
||||
deriveMcpToolScopesForUser({ role: 'platform-admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('derives no scope elevation from any platform role (contract 2 §1.1 bypass retirement)', () => {
|
||||
const memberScopes = deriveMcpToolScopesForUser({ role: 'member' });
|
||||
for (const role of ['admin', 'platform-admin', 'super-admin', null, undefined]) {
|
||||
const scopes = deriveMcpToolScopesForUser({ role });
|
||||
expect([...scopes].sort()).toEqual([...memberScopes].sort());
|
||||
expect(scopes.has(MCP_TOOL_SCOPES.brain_create_task)).toBe(false);
|
||||
expect(scopes.has(MCP_TOOL_SCOPES.brain_update_task)).toBe(false);
|
||||
expect(scopes.has(MCP_TOOL_SCOPES.coord_list_tasks)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed when scopes are not supplied by the authenticated context policy', () => {
|
||||
@@ -311,14 +322,20 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('enforces tenant boundaries for tenant-admin brain project, mission, and task reads', async () => {
|
||||
const { service } = makeService({
|
||||
it('gives admin-role and platform-admin-role actors only owned content on brain reads (§1.1 retirement)', async () => {
|
||||
// Contract 2 §1.1: users.role confers no content visibility. An actor whose
|
||||
// role is 'admin', 'platform-admin', or 'super-admin' but who holds no
|
||||
// ownership sees exactly what an unprivileged member with the same
|
||||
// ownership would see — here, only the one project they own, and nothing
|
||||
// tenant-wide or platform-wide.
|
||||
const fixtures = {
|
||||
projects: [
|
||||
{ id: 'project-owned', ownerId: 'role-bearing-user', teamId: 'tenant-a', name: 'owned' },
|
||||
{
|
||||
id: 'project-tenant-a',
|
||||
ownerId: 'other-user-a',
|
||||
teamId: 'tenant-a',
|
||||
name: 'same tenant',
|
||||
name: 'same tenant, unowned',
|
||||
},
|
||||
{
|
||||
id: 'project-tenant-b',
|
||||
@@ -328,39 +345,50 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
},
|
||||
],
|
||||
missions: [
|
||||
{ id: 'mission-owned', projectId: 'project-owned' },
|
||||
{ id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' },
|
||||
{ id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'task-owned', projectId: 'project-owned', status: 'not-started' },
|
||||
{ id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' },
|
||||
{ id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' },
|
||||
],
|
||||
});
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
|
||||
};
|
||||
|
||||
service.registerTools(server, actor);
|
||||
const actors = [
|
||||
makeAdminActor('role-bearing-user', 'tenant-a'),
|
||||
makePlatformAdminActor('role-bearing-user'),
|
||||
];
|
||||
|
||||
const projects = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-tenant-a']);
|
||||
for (const actor of actors) {
|
||||
const { service } = makeService(fixtures);
|
||||
const { server, tools } = makeCapturingServer();
|
||||
service.registerTools(server, actor);
|
||||
|
||||
const missions = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-tenant-a']);
|
||||
const projects = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-owned']);
|
||||
|
||||
const tasks = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-tenant-a']);
|
||||
const missions = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-owned']);
|
||||
|
||||
const tasks = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-owned']);
|
||||
}
|
||||
});
|
||||
|
||||
it('denies tenant-admin task writes outside the authenticated tenant', async () => {
|
||||
const { service, brain } = makeService({
|
||||
projects: [
|
||||
{ id: 'project-tenant-a', ownerId: 'other-user-a', teamId: 'tenant-a' },
|
||||
// §1.1 retirement: content visibility comes from ownership, not the
|
||||
// tenant-admin role — the acting user owns the tenant-a project.
|
||||
{ id: 'project-tenant-a', ownerId: 'tenant-admin-user', teamId: 'tenant-a' },
|
||||
{ id: 'project-tenant-b', ownerId: 'other-user-b', teamId: 'tenant-b' },
|
||||
],
|
||||
missions: [
|
||||
@@ -373,7 +401,29 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
],
|
||||
});
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
|
||||
// Platform role no longer derives task-write scopes (§1.1 retirement):
|
||||
// a role-derived admin actor is scope-denied before any tenant logic.
|
||||
const roleDerivedAdmin = makeAdminActor('tenant-admin-user', 'tenant-a');
|
||||
service.registerTools(server, roleDerivedAdmin);
|
||||
await expect(
|
||||
getTool(tools, 'brain_create_task').handler({ title: 'role-derived write' }),
|
||||
).rejects.toThrow('MCP tool scope denied');
|
||||
expect(brain.tasks.create).not.toHaveBeenCalled();
|
||||
|
||||
// The tenant-scoping checks below sit behind the scope gate; exercise
|
||||
// them with explicitly granted task-write scopes (how grant-mapped
|
||||
// scopes will arrive), not with a platform role.
|
||||
tools.clear();
|
||||
const actor = createMcpActorContext({
|
||||
userId: 'tenant-admin-user',
|
||||
tenantId: 'tenant-a',
|
||||
role: 'member',
|
||||
scopes: [
|
||||
...deriveMcpToolScopesForUser({ role: 'member' }),
|
||||
MCP_TOOL_SCOPES.brain_create_task,
|
||||
MCP_TOOL_SCOPES.brain_update_task,
|
||||
],
|
||||
});
|
||||
|
||||
service.registerTools(server, actor);
|
||||
|
||||
@@ -416,7 +466,7 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps admin-only coordination tools on server-derived paths', async () => {
|
||||
it('denies coordination tools to every role-derived actor and keeps the granted path server-derived', async () => {
|
||||
const { service, coord } = makeService();
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const member = makeMemberActor('authenticated-user');
|
||||
@@ -433,10 +483,25 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
const tenantAdminTool = getTool(tools, 'coord_list_tasks');
|
||||
await expect(tenantAdminTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read');
|
||||
|
||||
// §1.1 retirement: platform-admin no longer derives coord scopes either.
|
||||
tools.clear();
|
||||
service.registerTools(server, platformAdmin);
|
||||
const platformAdminTool = getTool(tools, 'coord_list_tasks');
|
||||
await platformAdminTool.handler({ projectPath: '/tmp/victim' });
|
||||
await expect(platformAdminTool.handler({})).rejects.toThrow(
|
||||
'MCP tool scope denied: coord:read',
|
||||
);
|
||||
|
||||
// An explicitly granted coord:read scope reaches the server-derived
|
||||
// path (caller-supplied projectPath is stripped by the schema).
|
||||
tools.clear();
|
||||
const grantedActor = createMcpActorContext({
|
||||
userId: 'granted-user',
|
||||
role: 'member',
|
||||
scopes: [MCP_TOOL_SCOPES.coord_list_tasks],
|
||||
});
|
||||
service.registerTools(server, grantedActor);
|
||||
const grantedTool = getTool(tools, 'coord_list_tasks');
|
||||
await grantedTool.handler({ projectPath: '/tmp/victim' });
|
||||
expect(coord.listTasks).toHaveBeenCalledWith(process.cwd());
|
||||
});
|
||||
|
||||
|
||||
@@ -63,20 +63,6 @@ interface SessionEntry {
|
||||
actor: McpActorContext;
|
||||
}
|
||||
|
||||
const GLOBAL_ADMIN_MCP_SCOPES = new Set<McpToolScope>(Object.values(MCP_TOOL_SCOPES));
|
||||
const TENANT_ADMIN_MCP_SCOPES = new Set<McpToolScope>([
|
||||
MCP_TOOL_SCOPES.brain_list_projects,
|
||||
MCP_TOOL_SCOPES.brain_get_project,
|
||||
MCP_TOOL_SCOPES.brain_list_tasks,
|
||||
MCP_TOOL_SCOPES.brain_create_task,
|
||||
MCP_TOOL_SCOPES.brain_update_task,
|
||||
MCP_TOOL_SCOPES.brain_list_missions,
|
||||
MCP_TOOL_SCOPES.brain_list_conversations,
|
||||
MCP_TOOL_SCOPES.memory_search,
|
||||
MCP_TOOL_SCOPES.memory_get_preferences,
|
||||
MCP_TOOL_SCOPES.memory_save_preference,
|
||||
MCP_TOOL_SCOPES.memory_save_insight,
|
||||
]);
|
||||
const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
|
||||
MCP_TOOL_SCOPES.brain_list_projects,
|
||||
MCP_TOOL_SCOPES.brain_get_project,
|
||||
@@ -89,15 +75,17 @@ const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
|
||||
MCP_TOOL_SCOPES.memory_save_insight,
|
||||
]);
|
||||
|
||||
export function deriveMcpToolScopesForUser(input: {
|
||||
/**
|
||||
* Contract 2 §1.1: platform role confers NO MCP scope elevation — the
|
||||
* former tenant-admin/global-admin scope sets keyed on users.role are
|
||||
* retired. Every authenticated user receives the base member set; task
|
||||
* writes and coordination scopes attach to explicit hierarchy grants when
|
||||
* the MCP grant mapping lands, never to a platform role. The role
|
||||
* parameter is kept for caller compatibility and deliberately ignored.
|
||||
*/
|
||||
export function deriveMcpToolScopesForUser(_input: {
|
||||
role?: string | null;
|
||||
}): ReadonlySet<McpToolScope> {
|
||||
if (input.role === 'platform-admin' || input.role === 'super-admin') {
|
||||
return new Set(GLOBAL_ADMIN_MCP_SCOPES);
|
||||
}
|
||||
if (input.role === 'admin') {
|
||||
return new Set(TENANT_ADMIN_MCP_SCOPES);
|
||||
}
|
||||
return new Set(MEMBER_MCP_SCOPES);
|
||||
}
|
||||
|
||||
@@ -168,41 +156,22 @@ type TaskLike = TenantScopedLike & {
|
||||
userId?: string | null;
|
||||
};
|
||||
|
||||
function isGlobalAdminActor(actor: McpActorContext): boolean {
|
||||
return actor.role === 'platform-admin' || actor.role === 'super-admin';
|
||||
}
|
||||
|
||||
function isTenantAdminActor(actor: McpActorContext): boolean {
|
||||
return actor.role === 'admin';
|
||||
}
|
||||
|
||||
function matchesTenant(actor: McpActorContext, record: TenantScopedLike): boolean {
|
||||
return (
|
||||
record.tenantId === actor.tenantId ||
|
||||
record.organizationId === actor.tenantId ||
|
||||
record.teamId === actor.tenantId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Contract 2 §1.1: `users.role` confers NO content visibility — the former
|
||||
* global-admin/tenant-admin filter short-circuits keyed on the platform role
|
||||
* are retired along with the role-derived scope sets. Content reaches an MCP
|
||||
* actor through ownership only; widened access arrives as explicit hierarchy
|
||||
* grants when the MCP grant mapping lands.
|
||||
*/
|
||||
function filterProjectsForActor<T extends ProjectLike>(actor: McpActorContext, projects: T[]): T[] {
|
||||
if (isGlobalAdminActor(actor)) return projects;
|
||||
return projects.filter(
|
||||
(project) =>
|
||||
project.ownerId === actor.userId ||
|
||||
(isTenantAdminActor(actor) && matchesTenant(actor, project)),
|
||||
);
|
||||
return projects.filter((project) => project.ownerId === actor.userId);
|
||||
}
|
||||
|
||||
function filterMissionsByDirectActorScope<T extends MissionLike>(
|
||||
actor: McpActorContext,
|
||||
missions: T[],
|
||||
): T[] {
|
||||
if (isGlobalAdminActor(actor)) return missions;
|
||||
return missions.filter(
|
||||
(mission) =>
|
||||
mission.userId === actor.userId ||
|
||||
(isTenantAdminActor(actor) && matchesTenant(actor, mission)),
|
||||
);
|
||||
return missions.filter((mission) => mission.userId === actor.userId);
|
||||
}
|
||||
|
||||
function scopesEqual(left: ReadonlySet<McpToolScope>, right: ReadonlySet<McpToolScope>): boolean {
|
||||
@@ -293,7 +262,6 @@ export class McpService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
private async isProjectAuthorized(actor: McpActorContext, projectId: string): Promise<boolean> {
|
||||
if (isGlobalAdminActor(actor)) return true;
|
||||
const project = (await this.brain.projects.findById(projectId)) as ProjectLike | undefined;
|
||||
return project ? filterProjectsForActor(actor, [project]).length === 1 : false;
|
||||
}
|
||||
@@ -302,8 +270,6 @@ export class McpService implements OnModuleDestroy {
|
||||
actor: McpActorContext,
|
||||
missions: T[],
|
||||
): Promise<T[]> {
|
||||
if (isGlobalAdminActor(actor)) return missions;
|
||||
|
||||
const projects = (await this.brain.projects.findAll()) as ProjectLike[];
|
||||
const projectIds = new Set(
|
||||
filterProjectsForActor(actor, projects).map((project) => project.id),
|
||||
@@ -317,7 +283,6 @@ export class McpService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
private async isMissionAuthorized(actor: McpActorContext, missionId: string): Promise<boolean> {
|
||||
if (isGlobalAdminActor(actor)) return true;
|
||||
const mission = (await this.brain.missions.findById(missionId)) as MissionLike | undefined;
|
||||
if (!mission) return false;
|
||||
return (await this.filterMissionsForActor(actor, [mission])).length === 1;
|
||||
@@ -339,7 +304,7 @@ export class McpService implements OnModuleDestroy {
|
||||
actor: McpActorContext,
|
||||
refs: { projectId?: string | null; missionId?: string | null },
|
||||
): Promise<void> {
|
||||
if (!isGlobalAdminActor(actor) && !refs.projectId && !refs.missionId) {
|
||||
if (!refs.projectId && !refs.missionId) {
|
||||
throw new Error('MCP task scope denied');
|
||||
}
|
||||
await this.assertTaskReferencesAuthorized(actor, refs);
|
||||
@@ -349,8 +314,6 @@ export class McpService implements OnModuleDestroy {
|
||||
actor: McpActorContext,
|
||||
tasks: T[],
|
||||
): Promise<T[]> {
|
||||
if (isGlobalAdminActor(actor)) return tasks;
|
||||
|
||||
const [projects, missions] = await Promise.all([
|
||||
this.brain.projects.findAll(),
|
||||
this.brain.missions.findAll(),
|
||||
@@ -367,7 +330,6 @@ export class McpService implements OnModuleDestroy {
|
||||
return tasks.filter(
|
||||
(task) =>
|
||||
task.userId === actor.userId ||
|
||||
(isTenantAdminActor(actor) && matchesTenant(actor, task)) ||
|
||||
(typeof task.projectId === 'string' && projectIds.has(task.projectId)) ||
|
||||
(typeof task.missionId === 'string' && missionIds.has(task.missionId)),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import 'reflect-metadata';
|
||||
import { getMetadataStorage } from 'class-validator';
|
||||
import { BootstrapSetupDto } from './admin/bootstrap.dto.js';
|
||||
import {
|
||||
ChangeCompanyVisibilityDto,
|
||||
ChangeGrantDto,
|
||||
CreateCompanyDto,
|
||||
CreateEstateDto,
|
||||
CreateGrantDto,
|
||||
CreatePlatformProjectDto,
|
||||
DeleteNodeDto,
|
||||
RenameNodeDto,
|
||||
TransferEstateDto,
|
||||
TransferPlatformProjectDto,
|
||||
} from './hierarchy/hierarchy.dto.js';
|
||||
|
||||
/**
|
||||
* Boot-time self-check: the global ValidationPipe must be able to SEE the
|
||||
@@ -43,6 +55,56 @@ export const PIPE_GUARDED_DTOS: Array<{
|
||||
target: BootstrapSetupDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
},
|
||||
{
|
||||
name: 'CreateCompanyDto',
|
||||
target: CreateCompanyDto,
|
||||
properties: ['name', 'slug', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'RenameNodeDto',
|
||||
target: RenameNodeDto,
|
||||
properties: ['name', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'ChangeCompanyVisibilityDto',
|
||||
target: ChangeCompanyVisibilityDto,
|
||||
properties: ['visibility', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'DeleteNodeDto',
|
||||
target: DeleteNodeDto,
|
||||
properties: ['idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'CreateEstateDto',
|
||||
target: CreateEstateDto,
|
||||
properties: ['companyId', 'name', 'slug', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'CreatePlatformProjectDto',
|
||||
target: CreatePlatformProjectDto,
|
||||
properties: ['estateId', 'name', 'slug', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'TransferEstateDto',
|
||||
target: TransferEstateDto,
|
||||
properties: ['destinationCompanyId', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'TransferPlatformProjectDto',
|
||||
target: TransferPlatformProjectDto,
|
||||
properties: ['destinationEstateId', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'CreateGrantDto',
|
||||
target: CreateGrantDto,
|
||||
properties: ['userId', 'targetKind', 'targetId', 'role', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'ChangeGrantDto',
|
||||
target: ChangeGrantDto,
|
||||
properties: ['role', 'idempotencyKey'],
|
||||
},
|
||||
];
|
||||
|
||||
export class PipeMetatypeCheckError extends Error {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "hierarchy_audit_events" DROP CONSTRAINT "hierarchy_audit_events_verb_check";--> statement-breakpoint
|
||||
ALTER TABLE "companies" ADD COLUMN "visibility" text DEFAULT 'private' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "companies" ADD CONSTRAINT "companies_visibility_check" CHECK (visibility IN ('private', 'directory'));--> statement-breakpoint
|
||||
ALTER TABLE "hierarchy_audit_events" ADD CONSTRAINT "hierarchy_audit_events_verb_check" CHECK (verb IN ('create', 'rename', 'transfer', 'visibility_change', 'delete', 'grant_create', 'grant_change', 'grant_revoke'));--> statement-breakpoint
|
||||
ALTER TABLE "hierarchy_grants" ADD CONSTRAINT "hierarchy_grants_role_check" CHECK (role IN ('viewer', 'member', 'owner'));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -141,6 +141,13 @@
|
||||
"when": 1787880918208,
|
||||
"tag": "0019_volatile_killraven",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1787963521142,
|
||||
"tag": "0020_special_betty_brant",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -44,7 +44,7 @@ type AnyDb = {
|
||||
|
||||
/** Column allowlist — the exact declared sets of §2/§3. Nothing else. */
|
||||
const COLUMN_ALLOWLIST: Record<string, string[]> = {
|
||||
companies: ['id', 'name', 'slug', 'created_at', 'updated_at'],
|
||||
companies: ['id', 'name', 'slug', 'visibility', 'created_at', 'updated_at'],
|
||||
estates: ['id', 'name', 'slug', 'company_id'],
|
||||
platform_projects: ['id', 'name', 'slug', 'estate_id'],
|
||||
workspaces: ['id', 'name', 'slug', 'platform_project_id'],
|
||||
@@ -377,7 +377,61 @@ function witnessSuite(getHandle: () => AnyDb): void {
|
||||
// Control: same subject and target with a different role is a new grant.
|
||||
await db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userA, companyId, role: `${T}-other-role`, grantedBy: userA });
|
||||
.values({ userId: userA, companyId, role: 'member', grantedBy: userA });
|
||||
});
|
||||
|
||||
// ── §2.6 role vocabulary CHECK ─────────────────────────────────────────────
|
||||
|
||||
it('refuses a grant role outside the ratified vocabulary, accepts each ratified role', async () => {
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userB, companyId, role: 'superuser', grantedBy: userA }),
|
||||
/check constraint/i,
|
||||
);
|
||||
// Serialized namespaced forms are storage-invalid too: rows hold bare roles.
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userB, companyId, role: 'hierarchy:owner', grantedBy: userA }),
|
||||
/check constraint/i,
|
||||
);
|
||||
for (const role of ['viewer', 'member', 'owner'] as const) {
|
||||
await db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userB, estateId, role, grantedBy: userA });
|
||||
}
|
||||
await db().execute(
|
||||
sql`DELETE FROM hierarchy_grants WHERE user_id = ${userB} AND estate_id = ${estateId}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── §2.8 visibility column ─────────────────────────────────────────────────
|
||||
|
||||
it('defaults companies.visibility to private and refuses values outside the class', async () => {
|
||||
const visId = randomUUID();
|
||||
await db().execute(
|
||||
sql`INSERT INTO companies (id, name, slug) VALUES (${visId}, 'Vis', ${T + '-vis'})`,
|
||||
);
|
||||
const res = rows(await db().execute(sql`SELECT visibility FROM companies WHERE id = ${visId}`));
|
||||
expect(res[0]!['visibility']).toBe('private');
|
||||
await db().execute(
|
||||
sql`INSERT INTO companies (id, name, slug, visibility) VALUES (${randomUUID()}, 'Vis D', ${T + '-vis-d'}, 'directory')`,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO companies (id, name, slug, visibility) VALUES (${randomUUID()}, 'Vis X', ${T + '-vis-x'}, 'public')`,
|
||||
),
|
||||
/check constraint/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(sql`UPDATE companies SET visibility = 'hidden' WHERE id = ${visId}`),
|
||||
/check constraint/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(sql`UPDATE companies SET visibility = NULL WHERE id = ${visId}`),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
});
|
||||
|
||||
// ── §6.1 NOT NULLs ─────────────────────────────────────────────────────────
|
||||
@@ -391,7 +445,7 @@ function witnessSuite(getHandle: () => AnyDb): void {
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO hierarchy_grants (user_id, company_id, role, granted_by) VALUES (${userA}, ${companyId}, 'x', NULL)`,
|
||||
sql`INSERT INTO hierarchy_grants (user_id, company_id, role, granted_by) VALUES (${userA}, ${companyId}, 'viewer', NULL)`,
|
||||
),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
|
||||
@@ -135,9 +135,9 @@
|
||||
* production code is anomalous and review-visible; that blind spot is
|
||||
* accepted as a residual, not closed.
|
||||
*
|
||||
* The writer allowlist names hierarchy command/repository modules ONLY. It is
|
||||
* empty today: the hierarchy command family (M4-1b-ii) has not landed, so no
|
||||
* production module may write the class tables. The infrastructure register
|
||||
* The writer allowlist names hierarchy command/repository modules ONLY. Its
|
||||
* single entry is the M4-1b-ii hierarchy command repository — the sole
|
||||
* production module permitted to write the class tables. The infrastructure register
|
||||
* holds legitimate non-hierarchy raw execution; registered modules are exempt
|
||||
* from prong (iii) only — prongs (i) and (ii) apply to them with no
|
||||
* exemption, and no registered module may appear on the writer allowlist.
|
||||
@@ -181,13 +181,15 @@ const CLASS_TABLES = [
|
||||
|
||||
/**
|
||||
* Writer allowlist (§6.3b): hierarchy command/repository modules only.
|
||||
* EMPTY until the hierarchy command family lands (M4-1b-ii; M4-1b-i ships
|
||||
* only the audit/outbox machinery, which writes no class table). Adding a module
|
||||
* here is a contract-conformance decision reviewed under §5.1 — the module
|
||||
* must be part of the Gateway hierarchy command path, and it must not export
|
||||
* a function that executes caller-supplied SQL.
|
||||
* Adding a module here is a contract-conformance decision reviewed under
|
||||
* §5.1 — the module must be part of the Gateway hierarchy command path, and
|
||||
* it must not export a function that executes caller-supplied SQL.
|
||||
*/
|
||||
const WRITER_ALLOWLIST: string[] = [];
|
||||
const WRITER_ALLOWLIST: string[] = [
|
||||
// The hierarchy command repository (M4-1b-ii): the sole class-table
|
||||
// writer; every mutation is audited on its own transaction (§5.2).
|
||||
'apps/gateway/src/hierarchy/hierarchy.repository.ts',
|
||||
];
|
||||
|
||||
/**
|
||||
* Infrastructure register: closed enumeration of legitimate non-hierarchy raw
|
||||
|
||||
@@ -1063,13 +1063,24 @@ export const federationEnrollmentTokens = pgTable('federation_enrollment_tokens'
|
||||
// command family only (§5.1), enforced by the writer-coverage assertion
|
||||
// (§6.3b) — do not add writers outside that allowlist.
|
||||
|
||||
export const companies = pgTable('companies', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
/** Company visibility classes (contract 1 §2.8, Ruling 4b): 'private' is the
|
||||
* only creatable class (§5.5 — creation carries no visibility argument);
|
||||
* 'directory' discloses existence/name/slug to all users and is entered only
|
||||
* through the admin-gated visibility-change command. */
|
||||
export const COMPANY_VISIBILITY = ['private', 'directory'] as const;
|
||||
|
||||
export const companies = pgTable(
|
||||
'companies',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
visibility: text('visibility').notNull().default('private'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
() => [check('companies_visibility_check', sql`visibility IN ('private', 'directory')`)],
|
||||
);
|
||||
|
||||
export const estates = pgTable(
|
||||
'estates',
|
||||
@@ -1110,6 +1121,10 @@ export const workspaces = pgTable(
|
||||
(t) => [unique('workspaces_platform_project_slug_uniq').on(t.platformProjectId, t.slug)],
|
||||
);
|
||||
|
||||
/** Grant role vocabulary (contract 2 §2): totally ordered, viewer ⊂ member ⊂
|
||||
* owner. Order in this tuple IS the ordering — index = strength. */
|
||||
export const HIERARCHY_GRANT_ROLES = ['viewer', 'member', 'owner'] as const;
|
||||
|
||||
export const hierarchyGrants = pgTable(
|
||||
'hierarchy_grants',
|
||||
{
|
||||
@@ -1126,7 +1141,8 @@ export const hierarchyGrants = pgTable(
|
||||
platformProjectId: uuid('platform_project_id').references(() => platformProjects.id, {
|
||||
onDelete: 'cascade',
|
||||
}),
|
||||
// Role vocabulary and its CHECK constraint are contract 2 §2 (M4-2).
|
||||
// Role vocabulary per contract 2 §2: exactly viewer ⊂ member ⊂ owner,
|
||||
// totally ordered; CHECK below closes the column to that vocabulary.
|
||||
role: text('role').notNull(),
|
||||
grantedBy: text('granted_by')
|
||||
.notNull()
|
||||
@@ -1134,6 +1150,7 @@ export const hierarchyGrants = pgTable(
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
check('hierarchy_grants_role_check', sql`role IN ('viewer', 'member', 'owner')`),
|
||||
check('hierarchy_grants_subject_check', sql`num_nonnulls(user_id, team_id) = 1`),
|
||||
check(
|
||||
'hierarchy_grants_target_check',
|
||||
@@ -1170,6 +1187,7 @@ export const HIERARCHY_AUDIT_VERBS = [
|
||||
'create',
|
||||
'rename',
|
||||
'transfer',
|
||||
'visibility_change',
|
||||
'delete',
|
||||
'grant_create',
|
||||
'grant_change',
|
||||
@@ -1220,7 +1238,7 @@ export const hierarchyAuditEvents = pgTable(
|
||||
index('hierarchy_audit_events_correlation_idx').on(t.correlationId),
|
||||
check(
|
||||
'hierarchy_audit_events_verb_check',
|
||||
sql`verb IN ('create', 'rename', 'transfer', 'delete', 'grant_create', 'grant_change', 'grant_revoke')`,
|
||||
sql`verb IN ('create', 'rename', 'transfer', 'visibility_change', 'delete', 'grant_create', 'grant_change', 'grant_revoke')`,
|
||||
),
|
||||
check(
|
||||
'hierarchy_audit_events_target_kind_check',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/bin/bash
|
||||
# issue-comment.sh - Add a comment to an issue on GitHub or Gitea
|
||||
# Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>]
|
||||
# (-c/--comment is a backward-compatible alias for -b/--body; R1, 2026-08-28)
|
||||
# Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]
|
||||
#
|
||||
# tea v0.11.1 defines no `comment` subcommand under `tea issue` (or `tea pr`);
|
||||
# the non-existent `tea issue comment ...` form does not error — tea silently
|
||||
@@ -33,61 +32,45 @@ ISSUE_NUMBER=""
|
||||
COMMENT=""
|
||||
LOGIN_OVERRIDE=""
|
||||
|
||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1), so a
|
||||
# caller or stop gate can tell an invocation defect from a delivery blocker
|
||||
# (CONSTITUTION gate 8 as amended; E2E-DELIVERY).
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
echo "Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>] (see --help)" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-i|--issue)
|
||||
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
|
||||
ISSUE_NUMBER="$2"
|
||||
shift 2
|
||||
;;
|
||||
-b|--body|-c|--comment)
|
||||
# R1 (2026-08-28): --body is the canonical flag, matching
|
||||
# issue-create/issue-edit/pr-create/pr-edit; -c/--comment stays a
|
||||
# backward-compatible alias.
|
||||
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
|
||||
-c|--comment)
|
||||
COMMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l|--login)
|
||||
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
|
||||
LOGIN_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>]"
|
||||
echo "Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -i, --issue Issue number (required)"
|
||||
echo " -b, --body Comment text (required; canonical)"
|
||||
echo " -c, --comment Alias for --body"
|
||||
echo " -c, --comment Comment text (required)"
|
||||
echo " -l, --login Override the detected Gitea tea login for this call"
|
||||
echo " -h, --help Show this help"
|
||||
echo ""
|
||||
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown option: $1"
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||
usage_error "issue number is required (-i/--issue)"
|
||||
echo "Error: Issue number is required (-i)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$COMMENT" ]]; then
|
||||
usage_error "comment is required (-b/--body, or the -c/--comment alias)"
|
||||
echo "Error: Comment is required (-c)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
@@ -357,15 +340,7 @@ PY
|
||||
}
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
# R4 exit-code contract: normalize provider failures to exit 1. gh's own
|
||||
# usage errors exit 2, which would collide with this wrapper's reserved
|
||||
# usage-error status if propagated raw (codex review of 08a00149).
|
||||
gh_rc=0
|
||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" || gh_rc=$?
|
||||
if [[ "$gh_rc" -ne 0 ]]; then
|
||||
echo "Error: GitHub comment write failed (gh exit $gh_rc; provider/credential failure — usage errors are exit 2)" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
||||
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
# A --login override selects a NAMED tea credential and is the only way to
|
||||
|
||||
@@ -42,8 +42,6 @@
|
||||
# 10. leaves NO temp files behind (POST/GET bodies + metadata) on either the
|
||||
# success or the failure path — nested function-scoped RETURN traps do not
|
||||
# clobber each other and every scratch file is removed on all exit paths.
|
||||
# 11. accepts the canonical -b/--body flag exactly like the -c/--comment alias
|
||||
# (R1, 2026-08-28): a full verified write via -b alone.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -411,28 +409,11 @@ run_comment() {
|
||||
seed_state "$mode"
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
# Provisioned seats export MOSAIC_GIT_IDENTITY and MOSAIC_BRAIN_HOME
|
||||
# seat-wide (launcher), and both escape this harness's sandboxed HOME:
|
||||
# detect-platform.sh consults MOSAIC_GIT_IDENTITY BEFORE the repo-local
|
||||
# mosaic.gitIdentity pin, and resolves the brain home (whose
|
||||
# fleet/agents presence arms the no-identity fail-loud branch) from
|
||||
# MOSAIC_BRAIN_HOME before $HOME. Without these explicit empties the
|
||||
# wrapper either resolves the REAL seat-slot token (stub curl rejects
|
||||
# it: the documented HTTP 401) or fails loud before any request.
|
||||
# Set-but-empty reads as unset to detect-platform's "${VAR:-}" forms.
|
||||
# NOTE: keep this comment block ABOVE the assignment chain — a comment
|
||||
# inside a backslash-continued prefix chain terminates the command and
|
||||
# silently demotes every earlier assignment to an unexported subshell
|
||||
# assignment (measured 2026-08-28: the wrapper then ran without
|
||||
# MOSAIC_CREDENTIALS_FILE and the suite died at credential resolution
|
||||
# with zero diagnostic output).
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
TMPDIR="$TMP_SCRATCH" \
|
||||
HOME="$HOME_DIR" \
|
||||
XDG_CONFIG_HOME="$XDG_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_GIT_IDENTITY="" \
|
||||
MOSAIC_BRAIN_HOME="" \
|
||||
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
|
||||
ISSUE_COMMENT_CURL_LOG="$CURL_LOG" \
|
||||
ISSUE_COMMENT_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
|
||||
@@ -449,7 +430,7 @@ run_comment() {
|
||||
ISSUE_COMMENT_REPO_SLUG="$REPO_SLUG" \
|
||||
ISSUE_COMMENT_API_BASE="$API_BASE" \
|
||||
ISSUE_COMMENT_API_ROOT="$API_ROOT" \
|
||||
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" "${BODY_FLAG:--c}" "$BODY" "$@"
|
||||
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" -c "$BODY" "$@"
|
||||
) > "$OUTPUT_FILE" 2>&1
|
||||
}
|
||||
|
||||
@@ -633,21 +614,4 @@ done
|
||||
# issue_url (already exercised by Case 1's fresh-success), so the tightened check
|
||||
# is not rejecting genuine writes.
|
||||
|
||||
# Case 11 (R1, 2026-08-28): -b/--body is the canonical comment flag and must
|
||||
# drive a full verified write exactly like the -c/--comment alias. BODY_FLAG
|
||||
# swaps only the flag spelling; every assertion below is case 1's contract.
|
||||
BODY_FLAG="-b"
|
||||
run_comment fresh-success
|
||||
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 51)' "$OUTPUT_FILE"
|
||||
grep -q "^POST $API_BASE/issues/7/comments$" "$CURL_LOG"
|
||||
if grep -Eq '^comment |^issue comment ' "$TEA_LOG"; then
|
||||
echo "FAIL: --body write went through tea instead of REST" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q "^GET $API_BASE/issues/comments/51$" "$CURL_LOG"
|
||||
grep -q "^POST $API_BASE/issues/7/comments $ACTING_LOGIN$" "$AUTH_LOG"
|
||||
assert_no_temp_leak "fresh-success-body-flag"
|
||||
assert_token_not_in_argv "fresh-success-body-flag"
|
||||
unset BODY_FLAG
|
||||
|
||||
echo "issue-comment.sh REST create + exact-id read-back regression passed"
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for issue-comment.sh (R1/R4 remediation, 2026-08-28).
|
||||
#
|
||||
# R4: usage errors print to STDERR and exit 2, distinct from provider,
|
||||
# credential, and verification failures (exit 1), so a caller (or a stop gate)
|
||||
# can tell an invocation defect from a delivery blocker. Before this contract
|
||||
# the wrapper exited 1 for usage errors with messages on STDOUT, and a
|
||||
# value-less flag (-c with no value) died SILENTLY at rc=1 because set -e
|
||||
# killed the failed `shift 2`. That silent shape is what full-stopped a fleet
|
||||
# seat: a caller could not distinguish "I invoked it wrong" from "delivery is
|
||||
# blocked".
|
||||
#
|
||||
# R1: -b/--body is the canonical comment flag (matching issue-create,
|
||||
# issue-edit, pr-create, pr-edit); -c/--comment remains a backward-compatible
|
||||
# alias.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. Missing required comment exits 2 (stderr).
|
||||
# 5. A value-less flag (-i -b -c -l and long forms) exits 2 with a
|
||||
# "requires a value" message on stderr (the former silent-death class).
|
||||
# 6. -b and -c both pass parsing (the run then fails at platform detection
|
||||
# in this non-repo fixture, nonzero and NOT 2), proving alias acceptance
|
||||
# without any provider fixture.
|
||||
# 7. No arm performs any provider request: PATH shims for gh/tea/curl
|
||||
# record every invocation and the probe log must stay empty.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-comment-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Provider shims: any invocation is recorded and fails the run at the end.
|
||||
# Usage-error arms must exit during argument parsing, before detect_platform,
|
||||
# so these prove "no provider request on parser failure".
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
# gh doubles as platform probe AND write path in arm 6b: probes exit 0; the
|
||||
# comment write exits 2 (gh's own usage-error status) to prove the wrapper
|
||||
# normalizes provider failures to exit 1 instead of propagating 2.
|
||||
if [[ "\$1 \$2" == "issue comment" ]]; then exit 2; fi
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-comment.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant for parse-acceptance arms: neutralizes every identity/
|
||||
# credential source the wrapper consults (seat env vars, HOME, XDG tea config)
|
||||
# so the arm fails at credential resolution in ANY cwd repo, never reading a
|
||||
# real token or contacting a provider. Measured 2026-08-28: without this, the
|
||||
# arm's outcome depended on incidental URL-resolution state (brain cwd died at
|
||||
# URL-not-found; a stack worktree cwd resolved a configured URL, read the real
|
||||
# seat token, and invoked the curl stub — the suite then failed its own
|
||||
# no-provider-contact check, correctly).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/issue-comment.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$desc: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage on stdout.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: issue-comment.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, message on stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "unknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required issue number: rc 2, stderr.
|
||||
expect_rc 2 "missing -i exits 2"
|
||||
expect_stderr "issue number is required" "missing -i message on stderr"
|
||||
|
||||
# 4. Missing required comment: rc 2, stderr.
|
||||
expect_rc 2 "missing comment exits 2" -i 5
|
||||
expect_stderr "comment is required" "missing comment message on stderr"
|
||||
|
||||
# 5. Value-less flags: rc 2 with "requires a value" on stderr. The old parser
|
||||
# died here silently (set -e on the failed shift 2).
|
||||
for flag in -i -b -c -l --issue --body --comment --login; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 6. Alias acceptance at parse level: both -b and -c carry a value past
|
||||
# parsing; the wrapper then fails at platform detection (not a git repo)
|
||||
# nonzero but NOT as a usage error (rc must not be 2).
|
||||
for flag in -b -c; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -i 5 "$flag" "some text" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6b. GitHub-path exit normalization (codex blocker on 08a00149): gh's own
|
||||
# usage errors exit 2; the wrapper must NOT propagate that status (reserved
|
||||
# for the wrapper's usage-error contract). With a github remote and a gh stub
|
||||
# whose comment write exits 2, the wrapper must exit 1 with the normalized
|
||||
# error on stderr.
|
||||
GH_REPO="$WORK_DIR/repo-gh"
|
||||
mkdir -p "$GH_REPO"
|
||||
git -C "$GH_REPO" init -q
|
||||
git -C "$GH_REPO" remote add origin https://github.com/acme/widgets.git
|
||||
git -C "$GH_REPO" config mosaic.gitIdentity ""
|
||||
rc=0
|
||||
(
|
||||
cd "$GH_REPO"
|
||||
PATH="$BIN_DIR:$PATH" MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/issue-comment.sh" -i 5 -b "text" >"$OUT_FILE" 2>"$ERR_FILE"
|
||||
) || rc=$?
|
||||
[[ "$rc" -eq 1 ]] || fail "GitHub path: gh exit 2 must normalize to wrapper exit 1 (got $rc)"
|
||||
grep -q "GitHub comment write failed" "$ERR_FILE" || fail "GitHub path: normalized error missing from stderr"
|
||||
grep -q "^gh issue comment" "$PROBE_LOG" || fail "GitHub path: gh write was not invoked"
|
||||
|
||||
# 7. No provider contact from any usage-error arm (arm 6b's deliberate gh
|
||||
# invocation is the only permitted entry in the probe log).
|
||||
if grep -v '^gh issue comment' "$PROBE_LOG" | grep -q .; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
grep -v '^gh issue comment' "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "issue-comment.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -14,6 +14,7 @@
|
||||
packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix (git -C scoping)
|
||||
packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix
|
||||
packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh | resolves real credentials (#1007 census, fourth entry via family-grep); joins CI after the wrapper-half hermeticity fix
|
||||
packages/mosaic/framework/tools/git/test-issue-comment-readback.sh | resolves real credentials (#1007 census, fifth entry); joins CI after the wrapper-half hermeticity fix
|
||||
|
||||
# --- tools/git: push guards — measured green locally, CI-image fitness unverified ---
|
||||
packages/mosaic/framework/tools/git/test-push-guard.sh | measured green at 826a8b3b (46 passed / 0 failed, one run, 2026-07-31); CI-image fitness unverified; #1017 burndown
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-repo-decl-consumption.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-issue-comment-usage-contract.sh && bash framework/tools/git/test-issue-comment-readback.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh && bash framework/tools/fleet/test-agent-session-legacy-socket-guard.sh && bash framework/tools/git/test-grant-reviewer.sh"
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-repo-decl-consumption.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh && bash framework/tools/fleet/test-agent-session-legacy-socket-guard.sh && bash framework/tools/git/test-grant-reviewer.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
/**
|
||||
* Read-only adapters for the installation config pipeline (§16).
|
||||
*
|
||||
* Every adapter is a bounded, side-effect-free read. The registry resolver
|
||||
* is DEPENDENCY-BLOCKED (§2.2): its stub returns CONFIG_REGISTRY_INVALID
|
||||
* with the blocked-interface marker. When the reviewed resolver ships, the
|
||||
* stub binds to it without schema changes.
|
||||
*/
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import {
|
||||
type ConfigDiagnostic,
|
||||
type RegistryProvenance,
|
||||
type InstallationBindings,
|
||||
type FrameworkBindingDefaults,
|
||||
FRAMEWORK_BINDING_DEFAULTS,
|
||||
REGISTRY_RESOLVER_BLOCKED,
|
||||
} from './types.js';
|
||||
|
||||
// ─── Digest helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/** SHA-256 over canonical JSON with recursively sorted keys (§9). */
|
||||
export function digestCanonical(value: unknown): string {
|
||||
const canonical = JSON.stringify(sortKeysDeep(value));
|
||||
return crypto.createHash('sha256').update(canonical).digest('hex');
|
||||
}
|
||||
|
||||
function sortKeysDeep(value: unknown): unknown {
|
||||
if (value === null || typeof value !== 'object') return value;
|
||||
if (Array.isArray(value)) return value.map(sortKeysDeep);
|
||||
const sorted: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
||||
sorted[key] = sortKeysDeep((value as Record<string, unknown>)[key]);
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export function digestBytes(content: string | Buffer): string {
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
// ─── 1. Registry resolver adapter (§16, dependency-blocked) ──────────────────
|
||||
|
||||
export interface RegistryAdapter {
|
||||
resolve(): { provenance: RegistryProvenance; diagnostics: ConfigDiagnostic[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* DEPENDENCY GATE (§2.2): the approved MosaicRegistryResolver does not exist
|
||||
* at the pinned baseline. This stub returns the blocked marker. When the
|
||||
* reviewed resolver ships (CFG-REQ-001..006), replace this stub's resolve()
|
||||
* to delegate to it. The interface is stable.
|
||||
*/
|
||||
export class BlockedRegistryAdapter implements RegistryAdapter {
|
||||
resolve(): { provenance: RegistryProvenance; diagnostics: ConfigDiagnostic[] } {
|
||||
return {
|
||||
provenance: {
|
||||
resolved: false,
|
||||
brainHome: null,
|
||||
sourceKeys: [],
|
||||
},
|
||||
diagnostics: [
|
||||
{
|
||||
code: 'CONFIG_REGISTRY_INVALID',
|
||||
message: REGISTRY_RESOLVER_BLOCKED,
|
||||
retryable: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 2. Bounded file reader (§14.1, §14.2) ───────────────────────────────────
|
||||
|
||||
export interface BoundedReadResult {
|
||||
ok: boolean;
|
||||
content?: string;
|
||||
diagnostics: ConfigDiagnostic[];
|
||||
}
|
||||
|
||||
export function boundedRead(filePath: string, context: string): BoundedReadResult {
|
||||
const diagnostics: ConfigDiagnostic[] = [];
|
||||
|
||||
try {
|
||||
const stat = fs.lstatSync(filePath);
|
||||
if (stat.isSymbolicLink()) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_ADAPTER_UNAVAILABLE',
|
||||
message: `${context}: symlink input rejected`,
|
||||
retryable: false,
|
||||
});
|
||||
return { ok: false, diagnostics };
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_ADAPTER_UNAVAILABLE',
|
||||
message: `${context}: not a regular file`,
|
||||
retryable: false,
|
||||
});
|
||||
return { ok: false, diagnostics };
|
||||
}
|
||||
if (stat.size > 1024 * 1024) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_ADAPTER_UNAVAILABLE',
|
||||
message: `${context}: file exceeds 1 MiB limit`,
|
||||
retryable: false,
|
||||
});
|
||||
return { ok: false, diagnostics };
|
||||
}
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return { ok: true, content, diagnostics: [] };
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BLUEPRINT_MISSING',
|
||||
message: `${context}: file not found at ${filePath}`,
|
||||
retryable: false,
|
||||
});
|
||||
return { ok: false, diagnostics };
|
||||
}
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_ADAPTER_UNAVAILABLE',
|
||||
message: `${context}: read error: ${e instanceof Error ? e.message : String(e)}`,
|
||||
retryable: false,
|
||||
});
|
||||
return { ok: false, diagnostics };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 3. Bindings resolution (§5.2, §7, A5) ───────────────────────────────────
|
||||
|
||||
export interface ResolvedBindings {
|
||||
runtime: string;
|
||||
runtimeByClass: Record<string, string>;
|
||||
workingDirectory: string;
|
||||
source: 'file' | 'framework-default';
|
||||
digest: string;
|
||||
}
|
||||
|
||||
export function resolveBindings(
|
||||
bindings: InstallationBindings | null,
|
||||
frameworkDefaults?: FrameworkBindingDefaults,
|
||||
): ResolvedBindings {
|
||||
const defaults = frameworkDefaults ?? FRAMEWORK_BINDING_DEFAULTS;
|
||||
if (!bindings) {
|
||||
return {
|
||||
runtime: defaults.runtime,
|
||||
runtimeByClass: {},
|
||||
workingDirectory: defaults.workingDirectory,
|
||||
source: 'framework-default',
|
||||
digest: digestCanonical(defaults),
|
||||
};
|
||||
}
|
||||
|
||||
const fleet = bindings.spec.fleet;
|
||||
return {
|
||||
runtime: fleet.runtime?.default ?? defaults.runtime,
|
||||
runtimeByClass: fleet.runtime?.byClass ?? {},
|
||||
workingDirectory: fleet.workingDirectory ?? defaults.workingDirectory,
|
||||
source: 'file',
|
||||
digest: digestCanonical(bindings),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 4. Git tracking/ignore probe (§7.2) ────────────────────────────────────
|
||||
|
||||
export function isBindingsIgnored(bindingsPath: string, repoRoot: string): boolean {
|
||||
try {
|
||||
const relative = path.relative(repoRoot, bindingsPath);
|
||||
if (relative.startsWith('..')) return true; // outside repo = not tracked
|
||||
|
||||
// Check if the file is tracked
|
||||
try {
|
||||
execSync(`git ls-files --error-unmatch "${relative}"`, {
|
||||
cwd: repoRoot,
|
||||
stdio: 'pipe',
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
||||
});
|
||||
return false; // tracked = NOT ignored
|
||||
} catch {
|
||||
// Not tracked; check if ignored
|
||||
try {
|
||||
execSync(`git check-ignore "${relative}"`, {
|
||||
cwd: repoRoot,
|
||||
stdio: 'pipe',
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
||||
});
|
||||
return true; // check-ignore succeeded = ignored
|
||||
} catch {
|
||||
return false; // not tracked but not ignored either = fail
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return true; // no git evidence = treat as ignored (valid absence)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 5. Blueprint path resolver ───────────────────────────────────────────────
|
||||
|
||||
export function resolveBlueprintPath(brainHome: string): string {
|
||||
return path.join(brainHome, 'fleet', 'configuration', 'installation.yaml');
|
||||
}
|
||||
|
||||
export function resolveBindingsPath(brainHome: string): string {
|
||||
return path.join(brainHome, 'config', 'installation.local.yaml');
|
||||
}
|
||||
|
||||
export function resolveRosterPath(brainHome: string): string {
|
||||
return path.join(brainHome, 'fleet', 'roster.yaml');
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
/**
|
||||
* InstallationConfigCore — shared pure pipeline for validate and plan (§9).
|
||||
*
|
||||
* Both commands call this core in the same order. No mutation, no network,
|
||||
* no subprocess. The core is deterministic: identical inputs → identical
|
||||
* outputs (except the caller-supplied or generated correlation ID).
|
||||
*/
|
||||
import * as crypto from 'node:crypto';
|
||||
|
||||
import {
|
||||
type InstallationBlueprint,
|
||||
type InstallationBindings,
|
||||
type ConfigDiagnostic,
|
||||
type ConfigValidationDataV1,
|
||||
type ConfigPlanDataV1,
|
||||
type ConfigPlanActionV1,
|
||||
type ConfigPlanFieldDiff,
|
||||
EXIT_OK,
|
||||
EXIT_INVALID,
|
||||
EXIT_NONCONFORMANT,
|
||||
EXIT_UNAVAILABLE,
|
||||
BOOTSTRAP_MINIMAL_V1,
|
||||
} from './types.js';
|
||||
import { loadStrictYaml, validateBlueprint, validateBindings } from './schema.js';
|
||||
import {
|
||||
digestCanonical,
|
||||
digestBytes,
|
||||
boundedRead,
|
||||
resolveBindings,
|
||||
resolveBlueprintPath,
|
||||
resolveBindingsPath,
|
||||
resolveRosterPath,
|
||||
isBindingsIgnored,
|
||||
type RegistryAdapter,
|
||||
} from './adapters.js';
|
||||
|
||||
// ─── Pipeline input/output ───────────────────────────────────────────────────
|
||||
|
||||
export interface CoreInput {
|
||||
registryAdapter: RegistryAdapter;
|
||||
/** Explicit blueprint file path, or null to use --preset. */
|
||||
filePath: string | null;
|
||||
/** Preset ID, or null to use --file. */
|
||||
presetId: string | null;
|
||||
/** Resolved brainHome (from registry). */
|
||||
brainHome: string;
|
||||
}
|
||||
|
||||
export interface CoreResult {
|
||||
exitCode: number;
|
||||
diagnostics: ConfigDiagnostic[];
|
||||
validationData?: ConfigValidationDataV1;
|
||||
planData?: ConfigPlanDataV1;
|
||||
}
|
||||
|
||||
// ─── The pipeline (§9, steps 1-11) ───────────────────────────────────────────
|
||||
|
||||
export function runPipeline(input: CoreInput, mode: 'validate' | 'plan'): CoreResult {
|
||||
const diagnostics: ConfigDiagnostic[] = [];
|
||||
|
||||
// Step 1: resolve central registry
|
||||
const registryResult = input.registryAdapter.resolve();
|
||||
diagnostics.push(...registryResult.diagnostics);
|
||||
if (diagnostics.some((d) => d.code === 'CONFIG_REGISTRY_INVALID')) {
|
||||
return { exitCode: EXIT_UNAVAILABLE, diagnostics };
|
||||
}
|
||||
|
||||
const brainHome = input.brainHome;
|
||||
|
||||
// Step 2: select and bounded-read blueprint or preset
|
||||
let blueprintContent: string;
|
||||
let blueprintSource: 'file' | 'preset';
|
||||
let blueprintId: string;
|
||||
|
||||
if (input.presetId) {
|
||||
if (input.presetId !== BOOTSTRAP_MINIMAL_V1.id) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_PRESET_UNKNOWN' as const,
|
||||
message: `Unknown preset '${input.presetId}'. Available: ${BOOTSTRAP_MINIMAL_V1.id}`,
|
||||
retryable: false,
|
||||
});
|
||||
return { exitCode: EXIT_INVALID, diagnostics };
|
||||
}
|
||||
blueprintContent = JSON.stringify(BOOTSTRAP_MINIMAL_V1.blueprint, null, 2);
|
||||
blueprintSource = 'preset';
|
||||
blueprintId = BOOTSTRAP_MINIMAL_V1.id;
|
||||
} else {
|
||||
const bpPath = input.filePath ?? resolveBlueprintPath(brainHome);
|
||||
const readResult = boundedRead(bpPath, 'blueprint');
|
||||
if (!readResult.ok) {
|
||||
diagnostics.push(...readResult.diagnostics);
|
||||
return { exitCode: EXIT_INVALID, diagnostics };
|
||||
}
|
||||
blueprintContent = readResult.content!;
|
||||
blueprintSource = 'file';
|
||||
blueprintId = bpPath;
|
||||
}
|
||||
|
||||
const blueprintDigest = digestBytes(blueprintContent);
|
||||
|
||||
// Step 3: bounded-read optional bindings
|
||||
const bindingsPath = resolveBindingsPath(brainHome);
|
||||
const bindingsRead = boundedRead(bindingsPath, 'bindings');
|
||||
let bindings: InstallationBindings | null = null;
|
||||
let bindingsDigest: string;
|
||||
|
||||
if (bindingsRead.ok && bindingsRead.content) {
|
||||
// Step 4 (bindings): parse strict YAML + validate schema
|
||||
const bYaml = loadStrictYaml(bindingsRead.content, 'bindings');
|
||||
if (!bYaml.ok) {
|
||||
diagnostics.push(...bYaml.diagnostics);
|
||||
return { exitCode: EXIT_INVALID, diagnostics };
|
||||
}
|
||||
const bValid = validateBindings(bYaml.value, 'bindings');
|
||||
if (!bValid.ok) {
|
||||
diagnostics.push(...bValid.diagnostics);
|
||||
return { exitCode: EXIT_INVALID, diagnostics };
|
||||
}
|
||||
bindings = bValid.bindings!;
|
||||
|
||||
// §7.2: bindings must be ignored/untracked
|
||||
if (!isBindingsIgnored(bindingsPath, brainHome)) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BINDINGS_NOT_IGNORED',
|
||||
message: `Host bindings at ${bindingsPath} are tracked or not ignored`,
|
||||
path: bindingsPath,
|
||||
retryable: false,
|
||||
});
|
||||
return { exitCode: EXIT_INVALID, diagnostics };
|
||||
}
|
||||
bindingsDigest = digestCanonical(bindings);
|
||||
} else if (bindingsRead.diagnostics.some((d) => d.code === 'CONFIG_BLUEPRINT_MISSING')) {
|
||||
// Absent bindings = valid (§7.1)
|
||||
bindingsDigest = digestCanonical(null);
|
||||
} else {
|
||||
diagnostics.push(...bindingsRead.diagnostics);
|
||||
return { exitCode: EXIT_INVALID, diagnostics };
|
||||
}
|
||||
|
||||
// Step 4 (blueprint): parse strict YAML + validate schema
|
||||
const yamlResult = loadStrictYaml(blueprintContent, 'blueprint');
|
||||
if (!yamlResult.ok) {
|
||||
diagnostics.push(...yamlResult.diagnostics);
|
||||
return { exitCode: EXIT_INVALID, diagnostics };
|
||||
}
|
||||
const bpValid = validateBlueprint(yamlResult.value, 'blueprint');
|
||||
if (!bpValid.ok) {
|
||||
diagnostics.push(...bpValid.diagnostics);
|
||||
return { exitCode: EXIT_INVALID, diagnostics };
|
||||
}
|
||||
const blueprint = bpValid.blueprint!;
|
||||
|
||||
// Step 5: load and validate the selected profile
|
||||
// (delegates to the existing profile loader — this is the resolution step
|
||||
// that would call the profile adapter; for the dependency-gated v1 we
|
||||
// accept the profile reference as structurally valid and mark semantic
|
||||
// resolution as notChecked)
|
||||
const profileId = blueprint.spec.fleet.profile;
|
||||
const profileDigest = digestCanonical({ profile: profileId });
|
||||
|
||||
// Step 6: resolve permitted host bindings
|
||||
const resolved = resolveBindings(bindings);
|
||||
|
||||
// Step 7: generate the desired roster in memory
|
||||
// (pure profile-to-roster generation — delegates to the adapter; for the
|
||||
// dependency-gated v1 we mark this as notChecked since the generator
|
||||
// depends on the full profile catalog)
|
||||
const desiredRoster = {
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
tmux: { socket_name: 'mosaic-fleet' },
|
||||
defaults: { working_directory: resolved.workingDirectory },
|
||||
agents: [] as Array<Record<string, unknown>>,
|
||||
};
|
||||
const desiredRosterDigest = digestCanonical(desiredRoster);
|
||||
|
||||
// Step 8: bounded-read and validate observed roster
|
||||
const rosterPath = resolveRosterPath(brainHome);
|
||||
const rosterRead = boundedRead(rosterPath, 'roster');
|
||||
let observedRoster: Record<string, unknown> | null = null;
|
||||
let observedRosterDigest: string | null = null;
|
||||
|
||||
if (rosterRead.ok && rosterRead.content) {
|
||||
const rYaml = loadStrictYaml(rosterRead.content, 'roster');
|
||||
if (!rYaml.ok) {
|
||||
diagnostics.push(...rYaml.diagnostics);
|
||||
return { exitCode: EXIT_INVALID, diagnostics };
|
||||
}
|
||||
observedRoster = rYaml.value as Record<string, unknown>;
|
||||
observedRosterDigest = digestCanonical(observedRoster);
|
||||
} else if (rosterRead.diagnostics.some((d) => d.code === 'CONFIG_BLUEPRINT_MISSING')) {
|
||||
// Missing roster = valid observed absence (§10.1)
|
||||
observedRoster = null;
|
||||
observedRosterDigest = null;
|
||||
} else {
|
||||
diagnostics.push(...rosterRead.diagnostics);
|
||||
return { exitCode: EXIT_UNAVAILABLE, diagnostics };
|
||||
}
|
||||
|
||||
// Step 9-10: normalize and compare semantically
|
||||
const conformant = observedRoster !== null && observedRosterDigest === desiredRosterDigest;
|
||||
|
||||
// Step 11: render
|
||||
const checks = [
|
||||
{ id: 'registry-resolution', status: 'passed' as const },
|
||||
{ id: 'blueprint-schema', status: 'passed' as const },
|
||||
{ id: 'bindings-schema', status: 'passed' as const },
|
||||
{ id: 'profile-resolution', status: 'notChecked' as const }, // dependency-gated
|
||||
{ id: 'role-resolution', status: 'notChecked' as const }, // dependency-gated
|
||||
{ id: 'desired-roster-generation', status: 'notChecked' as const }, // dependency-gated
|
||||
{
|
||||
id: 'observed-roster-valid',
|
||||
status: observedRoster ? ('passed' as const) : ('notChecked' as const),
|
||||
},
|
||||
{ id: 'conformance', status: conformant ? ('passed' as const) : ('failed' as const) },
|
||||
{ id: 'operational-availability', status: 'notChecked' as const },
|
||||
];
|
||||
|
||||
const validationData: ConfigValidationDataV1 = {
|
||||
resultSchemaVersion: 1,
|
||||
valid: true,
|
||||
conformant,
|
||||
blueprint: { source: blueprintSource, id: blueprintId, digest: blueprintDigest },
|
||||
bindings: { source: resolved.source, digest: bindingsDigest },
|
||||
profile: { id: profileId, digest: profileDigest, selection: blueprint.spec.fleet.selection },
|
||||
observed: { roster: observedRoster ? 'present' : 'absent', digest: observedRosterDigest },
|
||||
checks,
|
||||
};
|
||||
|
||||
if (!conformant) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_NONCONFORMANT',
|
||||
message: observedRoster
|
||||
? 'Observed roster differs from desired state within the v1 ownership mask'
|
||||
: 'Observed roster is absent; desired state requires one',
|
||||
retryable: false,
|
||||
});
|
||||
if (observedRoster === null) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_ROSTER_MISSING' as const,
|
||||
message: 'Observed roster absent at expected path',
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'validate') {
|
||||
return {
|
||||
exitCode: conformant ? EXIT_OK : EXIT_NONCONFORMANT,
|
||||
diagnostics,
|
||||
validationData,
|
||||
};
|
||||
}
|
||||
|
||||
// Plan mode: compute actions
|
||||
const actions = computeActions(
|
||||
blueprint,
|
||||
desiredRoster,
|
||||
observedRoster,
|
||||
desiredRosterDigest,
|
||||
observedRosterDigest,
|
||||
);
|
||||
|
||||
const planId = computePlanId(
|
||||
blueprintDigest,
|
||||
bindingsDigest,
|
||||
profileDigest,
|
||||
observedRosterDigest,
|
||||
actions,
|
||||
);
|
||||
|
||||
const planData: ConfigPlanDataV1 = {
|
||||
resultSchemaVersion: 1,
|
||||
planSchemaVersion: 1,
|
||||
planId,
|
||||
applySupported: false,
|
||||
valid: true,
|
||||
conformant,
|
||||
changeCount: actions.filter((a) => a.operation !== 'blocked').length,
|
||||
blockedCount: actions.filter((a) => a.operation === 'blocked').length,
|
||||
inputs: {
|
||||
blueprintDigest,
|
||||
bindingsDigest,
|
||||
profileDigest,
|
||||
observedRosterDigest,
|
||||
},
|
||||
actions,
|
||||
};
|
||||
|
||||
return {
|
||||
exitCode: EXIT_OK, // plan returns 0 whether zero or more actions (§11.1)
|
||||
diagnostics,
|
||||
validationData,
|
||||
planData,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Action computation (§11.2) ──────────────────────────────────────────────
|
||||
|
||||
function computeActions(
|
||||
_blueprint: InstallationBlueprint,
|
||||
desiredRoster: Record<string, unknown>,
|
||||
observedRoster: Record<string, unknown> | null,
|
||||
desiredDigest: string,
|
||||
observedDigest: string | null,
|
||||
): ConfigPlanActionV1[] {
|
||||
const actions: ConfigPlanActionV1[] = [];
|
||||
|
||||
if (observedRoster === null) {
|
||||
// §11.2 rule 4: missing roster = one roster create + seat creates
|
||||
actions.push(
|
||||
makeAction(
|
||||
'fleet-roster',
|
||||
'roster',
|
||||
'create',
|
||||
'none',
|
||||
'CONFIG_DRIFT_CREATE',
|
||||
null,
|
||||
desiredDigest,
|
||||
[],
|
||||
),
|
||||
);
|
||||
// seat creates are dependency-gated (profile resolution notChecked)
|
||||
return actions;
|
||||
}
|
||||
|
||||
if (desiredDigest === observedDigest) {
|
||||
return []; // §11.2 rule 5: exact conformance = zero actions
|
||||
}
|
||||
|
||||
// v1 ownership mask: compare owned fields
|
||||
const fieldDiffs: ConfigPlanFieldDiff[] = [];
|
||||
const ownedPaths = ['version', 'transport', 'tmux.socket_name', 'defaults.working_directory'];
|
||||
|
||||
for (const p of ownedPaths) {
|
||||
const before = getPath(observedRoster, p);
|
||||
const after = getPath(desiredRoster, p);
|
||||
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||||
fieldDiffs.push({ path: p, before: renderValue(before), after: renderValue(after) });
|
||||
}
|
||||
}
|
||||
|
||||
// Agent membership: extra observed agents are blocked (§9.1)
|
||||
const observedAgents = Array.isArray(observedRoster.agents)
|
||||
? (observedRoster.agents as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const desiredAgents = Array.isArray(desiredRoster.agents)
|
||||
? (desiredRoster.agents as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
|
||||
const desiredNames = new Set(desiredAgents.map((a) => String(a.name ?? '')));
|
||||
|
||||
for (const agent of observedAgents) {
|
||||
const name = String(agent.name ?? '');
|
||||
if (!desiredNames.has(name)) {
|
||||
actions.push(
|
||||
makeAction(
|
||||
'fleet-seat',
|
||||
name,
|
||||
'blocked',
|
||||
'full-engine-required',
|
||||
'CONFIG_DRIFT_FULL_ENGINE_REQUIRED',
|
||||
digestCanonical(agent),
|
||||
null,
|
||||
[],
|
||||
['full-engine: seat removal'],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldDiffs.length > 0) {
|
||||
actions.push(
|
||||
makeAction(
|
||||
'fleet-roster',
|
||||
'roster',
|
||||
'update',
|
||||
'none',
|
||||
'CONFIG_DRIFT_UPDATE',
|
||||
observedDigest,
|
||||
desiredDigest,
|
||||
fieldDiffs,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Sort (§11.2 rule 6)
|
||||
actions.sort((a, b) => {
|
||||
if (a.resourceKind !== b.resourceKind) return a.resourceKind < b.resourceKind ? -1 : 1;
|
||||
if (a.resourceId !== b.resourceId) return a.resourceId < b.resourceId ? -1 : 1;
|
||||
if (a.operation !== b.operation) return a.operation < b.operation ? -1 : 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
function makeAction(
|
||||
resourceKind: 'fleet-roster' | 'fleet-seat',
|
||||
resourceId: string,
|
||||
operation: 'create' | 'update' | 'blocked',
|
||||
risk: 'none' | 'review-required' | 'full-engine-required',
|
||||
reasonCode: string,
|
||||
beforeDigest: string | null,
|
||||
afterDigest: string | null,
|
||||
fieldDiffs: ConfigPlanFieldDiff[],
|
||||
blockedBy: string[] = [],
|
||||
): ConfigPlanActionV1 {
|
||||
const payload = {
|
||||
resourceKind,
|
||||
resourceId,
|
||||
operation,
|
||||
risk,
|
||||
reasonCode,
|
||||
beforeDigest,
|
||||
afterDigest,
|
||||
fieldDiffs,
|
||||
};
|
||||
const id = crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify(sortKeys(payload)))
|
||||
.digest('hex')
|
||||
.substring(0, 16);
|
||||
return {
|
||||
id,
|
||||
resourceKind,
|
||||
resourceId,
|
||||
operation,
|
||||
risk,
|
||||
reasonCode,
|
||||
beforeDigest,
|
||||
afterDigest,
|
||||
fieldDiffs,
|
||||
blockedBy,
|
||||
};
|
||||
}
|
||||
|
||||
function getPath(obj: Record<string, unknown>, dotPath: string): unknown {
|
||||
const parts = dotPath.split('.');
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (current === null || typeof current !== 'object') return null;
|
||||
current = (current as Record<string, unknown>)[part] ?? null;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function renderValue(v: unknown): string | number | boolean | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v;
|
||||
return JSON.stringify(v);
|
||||
}
|
||||
|
||||
function sortKeys(value: unknown): unknown {
|
||||
if (value === null || typeof value !== 'object') return value;
|
||||
if (Array.isArray(value)) return value.map(sortKeys);
|
||||
const sorted: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
||||
sorted[key] = sortKeys((value as Record<string, unknown>)[key]);
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function computePlanId(
|
||||
blueprintDigest: string,
|
||||
bindingsDigest: string,
|
||||
profileDigest: string,
|
||||
observedRosterDigest: string | null,
|
||||
actions: ConfigPlanActionV1[],
|
||||
): string {
|
||||
const parts = {
|
||||
planSchemaVersion: 1,
|
||||
blueprintDigest,
|
||||
bindingsDigest,
|
||||
profileDigest,
|
||||
observedRosterDigest: observedRosterDigest ?? 'absent',
|
||||
actions: actions.map((a) => a.id),
|
||||
};
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify(sortKeys(parts)))
|
||||
.digest('hex');
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
/**
|
||||
* Installation config minimal-subset tests.
|
||||
*
|
||||
* Covers: schema validation (positive + hostile), preset identity,
|
||||
* pipeline exit codes, and the no-mutation contract's type shape.
|
||||
* The dependency-gated adapters are tested through their stubs.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
|
||||
import { BOOTSTRAP_MINIMAL_V1, BLUEPRINT_API_VERSION, EXIT_UNAVAILABLE } from './types.js';
|
||||
import { loadStrictYaml, validateBlueprint, validateBindings } from './schema.js';
|
||||
import { runPipeline } from './core.js';
|
||||
import { BlockedRegistryAdapter, digestCanonical } from './adapters.js';
|
||||
import { renderJsonValidate, renderTableValidate } from './render.js';
|
||||
|
||||
const SB = fs.mkdtempSync(path.join(os.tmpdir(), 'configimpl-test-'));
|
||||
|
||||
describe('schema: strict YAML loader', () => {
|
||||
it('accepts a valid single-document mapping', () => {
|
||||
const result = loadStrictYaml('a: 1\nb: two', 'test');
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects multi-document YAML', () => {
|
||||
const result = loadStrictYaml('a: 1\n---\nb: 2', 'test');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.diagnostics[0]?.code).toBe('CONFIG_BLUEPRINT_SCHEMA');
|
||||
});
|
||||
|
||||
it('rejects null/empty input', () => {
|
||||
const result = loadStrictYaml('', 'test');
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-mapping top level', () => {
|
||||
const result = loadStrictYaml('- just\n- a\n- list', 'test');
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('schema: blueprint validation', () => {
|
||||
const validBlueprint = {
|
||||
apiVersion: BLUEPRINT_API_VERSION,
|
||||
kind: 'InstallationBlueprint',
|
||||
metadata: { name: 'test', generation: 1 },
|
||||
spec: { fleet: { profile: 'software-delivery', selection: 'floor' } },
|
||||
};
|
||||
|
||||
it('accepts the valid canonical shape', () => {
|
||||
const r = validateBlueprint(validBlueprint, 'test');
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.blueprint?.metadata.name).toBe('test');
|
||||
});
|
||||
|
||||
it('rejects unknown top-level key', () => {
|
||||
const r = validateBlueprint({ ...validBlueprint, extra: true }, 'test');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.diagnostics.some((d) => d.message.includes("unknown top-level key 'extra'"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects wrong apiVersion', () => {
|
||||
const r = validateBlueprint({ ...validBlueprint, apiVersion: 'wrong' }, 'test');
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects wrong kind', () => {
|
||||
const r = validateBlueprint({ ...validBlueprint, kind: 'Wrong' }, 'test');
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid name (uppercase)', () => {
|
||||
const r = validateBlueprint(
|
||||
{ ...validBlueprint, metadata: { ...validBlueprint.metadata, name: 'Bad' } },
|
||||
'test',
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects generation < 1', () => {
|
||||
const r = validateBlueprint(
|
||||
{ ...validBlueprint, metadata: { ...validBlueprint.metadata, generation: 0 } },
|
||||
'test',
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid selection', () => {
|
||||
const r = validateBlueprint(
|
||||
{
|
||||
...validBlueprint,
|
||||
spec: { fleet: { profile: 'test', selection: 'partial' } },
|
||||
},
|
||||
'test',
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unknown spec key', () => {
|
||||
const r = validateBlueprint(
|
||||
{
|
||||
...validBlueprint,
|
||||
spec: { fleet: validBlueprint.spec.fleet, extra: 1 },
|
||||
},
|
||||
'test',
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('schema: bindings validation', () => {
|
||||
const validBindings = {
|
||||
apiVersion: BLUEPRINT_API_VERSION,
|
||||
kind: 'InstallationBindings',
|
||||
spec: {
|
||||
fleet: {
|
||||
runtime: { default: 'pi' },
|
||||
workingDirectory: '~/src',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('accepts the valid canonical shape', () => {
|
||||
const r = validateBindings(validBindings, 'test');
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty spec', () => {
|
||||
const r = validateBindings(
|
||||
{ apiVersion: BLUEPRINT_API_VERSION, kind: 'InstallationBindings', spec: {} },
|
||||
'test',
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unknown fleet key', () => {
|
||||
const r = validateBindings(
|
||||
{
|
||||
...validBindings,
|
||||
spec: { fleet: { ...validBindings.spec.fleet, socket: 'override' } },
|
||||
},
|
||||
'test',
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects relative workingDirectory', () => {
|
||||
const r = validateBindings(
|
||||
{
|
||||
...validBindings,
|
||||
spec: { fleet: { workingDirectory: 'relative/path' } },
|
||||
},
|
||||
'test',
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects control bytes in workingDirectory', () => {
|
||||
const r = validateBindings(
|
||||
{
|
||||
...validBindings,
|
||||
spec: { fleet: { workingDirectory: '/tmp/\x00bad' } },
|
||||
},
|
||||
'test',
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preset: bootstrap-minimal@1', () => {
|
||||
it('has the correct ID', () => {
|
||||
expect(BOOTSTRAP_MINIMAL_V1.id).toBe('bootstrap-minimal@1');
|
||||
});
|
||||
|
||||
it('validates against the blueprint schema', () => {
|
||||
const r = validateBlueprint(BOOTSTRAP_MINIMAL_V1.blueprint, 'preset');
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('selects software-delivery floor per A4', () => {
|
||||
expect(BOOTSTRAP_MINIMAL_V1.blueprint.spec.fleet.profile).toBe('software-delivery');
|
||||
expect(BOOTSTRAP_MINIMAL_V1.blueprint.spec.fleet.selection).toBe('floor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('core: pipeline', () => {
|
||||
it('returns EXIT_UNAVAILABLE when registry is dependency-blocked', () => {
|
||||
const result = runPipeline(
|
||||
{
|
||||
registryAdapter: new BlockedRegistryAdapter(),
|
||||
filePath: null,
|
||||
presetId: null,
|
||||
brainHome: SB,
|
||||
},
|
||||
'validate',
|
||||
);
|
||||
expect(result.exitCode).toBe(EXIT_UNAVAILABLE);
|
||||
expect(result.diagnostics.some((d) => d.code === 'CONFIG_REGISTRY_INVALID')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns EXIT_INVALID for unknown preset', () => {
|
||||
// NOTE: registry is blocked, so this test would hit the registry gate first.
|
||||
// The preset check happens after registry resolution in the current pipeline.
|
||||
// This is documented as the dependency-gate behavior.
|
||||
const result = runPipeline(
|
||||
{
|
||||
registryAdapter: new BlockedRegistryAdapter(),
|
||||
filePath: null,
|
||||
presetId: 'unknown@9',
|
||||
brainHome: SB,
|
||||
},
|
||||
'validate',
|
||||
);
|
||||
expect(result.exitCode).toBe(EXIT_UNAVAILABLE); // registry gate fires first
|
||||
});
|
||||
});
|
||||
|
||||
describe('render: output', () => {
|
||||
it('JSON validate envelope has the correct capability ID', () => {
|
||||
const data = {
|
||||
resultSchemaVersion: 1 as const,
|
||||
valid: true,
|
||||
conformant: true,
|
||||
blueprint: { source: 'preset' as const, id: 'test', digest: 'abc' },
|
||||
bindings: { source: 'framework-default' as const, digest: 'def' },
|
||||
profile: { id: 'test', digest: 'ghi', selection: 'floor' as const },
|
||||
observed: { roster: 'present' as const, digest: 'jkl' },
|
||||
checks: [],
|
||||
};
|
||||
const json = renderJsonValidate(data, 'test-corr');
|
||||
const parsed = JSON.parse(json);
|
||||
expect(parsed.capabilityId).toBe('config.installation.validate');
|
||||
expect(parsed.status).toBe('succeeded');
|
||||
expect(parsed.correlationId).toBe('test-corr');
|
||||
});
|
||||
|
||||
it('table and JSON agree on conformant', () => {
|
||||
const data = {
|
||||
resultSchemaVersion: 1 as const,
|
||||
valid: true,
|
||||
conformant: false,
|
||||
blueprint: { source: 'preset' as const, id: 'test', digest: 'abc' },
|
||||
bindings: { source: 'framework-default' as const, digest: 'def' },
|
||||
profile: { id: 'test', digest: 'ghi', selection: 'floor' as const },
|
||||
observed: { roster: 'present' as const, digest: 'jkl' },
|
||||
checks: [],
|
||||
};
|
||||
const json = renderJsonValidate(data, 'test');
|
||||
const table = renderTableValidate(data, []);
|
||||
expect(json).toContain('"conformant": false');
|
||||
expect(table).toContain('Conformant: false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('digest: determinism', () => {
|
||||
it('produces identical digests for identical inputs with different key order', () => {
|
||||
const a = { z: 1, a: { y: 2, b: 3 } };
|
||||
const b = { a: { b: 3, y: 2 }, z: 1 };
|
||||
expect(digestCanonical(a)).toBe(digestCanonical(b));
|
||||
});
|
||||
|
||||
it('produces different digests for different values', () => {
|
||||
expect(digestCanonical({ a: 1 })).not.toBe(digestCanonical({ a: 2 }));
|
||||
});
|
||||
});
|
||||
|
||||
// cleanup
|
||||
afterEach(() => {
|
||||
// no per-test cleanup needed (sandbox is shared)
|
||||
});
|
||||
|
||||
// Note: the suite creates the sandbox directory at module load and relies on
|
||||
// the OS to clean /tmp. For CI, a trap would be added. This is documented.
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* Table and JSON renderers for validate and plan results (§12).
|
||||
*
|
||||
* Table is a human rendering of the same envelope. Text and JSON must
|
||||
* never disagree on valid, conformant, change, blocked, or exit status.
|
||||
*/
|
||||
import type {
|
||||
CapabilityResultV1,
|
||||
ConfigValidationDataV1,
|
||||
ConfigPlanDataV1,
|
||||
ConfigDiagnostic,
|
||||
} from './types.js';
|
||||
|
||||
// ─── JSON renderer ────────────────────────────────────────────────────────────
|
||||
|
||||
export function renderJsonValidate(data: ConfigValidationDataV1, correlationId: string): string {
|
||||
const envelope: CapabilityResultV1<ConfigValidationDataV1> = {
|
||||
capabilityId: 'config.installation.validate',
|
||||
status: data.conformant ? 'succeeded' : 'failed',
|
||||
data,
|
||||
correlationId,
|
||||
executionMode: 'local-adapter',
|
||||
identityTrust: 'local-asserted',
|
||||
audit: { authority: 'none', recorded: false },
|
||||
};
|
||||
return JSON.stringify(envelope, null, 2);
|
||||
}
|
||||
|
||||
export function renderJsonPlan(data: ConfigPlanDataV1, correlationId: string): string {
|
||||
const envelope: CapabilityResultV1<ConfigPlanDataV1> = {
|
||||
capabilityId: 'config.installation.plan',
|
||||
status: 'succeeded', // plan always succeeds (§11.1)
|
||||
data,
|
||||
correlationId,
|
||||
executionMode: 'local-adapter',
|
||||
identityTrust: 'local-asserted',
|
||||
audit: { authority: 'none', recorded: false },
|
||||
};
|
||||
return JSON.stringify(envelope, null, 2);
|
||||
}
|
||||
|
||||
// ─── Table renderer (§12) ─────────────────────────────────────────────────────
|
||||
|
||||
export function renderTableValidate(
|
||||
data: ConfigValidationDataV1,
|
||||
diagnostics: ConfigDiagnostic[],
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
lines.push('Installation Validation');
|
||||
lines.push('======================');
|
||||
lines.push('');
|
||||
lines.push(`Valid: ${data.valid}`);
|
||||
lines.push(`Conformant: ${data.conformant}`);
|
||||
lines.push(
|
||||
`Blueprint: ${data.blueprint.source === 'preset' ? data.blueprint.id : data.blueprint.id} (${data.blueprint.digest.substring(0, 12)}…)`,
|
||||
);
|
||||
lines.push(`Bindings: ${data.bindings.source} (${data.bindings.digest.substring(0, 12)}…)`);
|
||||
lines.push(`Profile: ${data.profile.id} / ${data.profile.selection}`);
|
||||
lines.push(
|
||||
`Roster: ${data.observed.roster}${data.observed.digest ? ` (${data.observed.digest.substring(0, 12)}…)` : ''}`,
|
||||
);
|
||||
lines.push('');
|
||||
lines.push('Checks:');
|
||||
for (const check of data.checks) {
|
||||
const icon = check.status === 'passed' ? '✓' : check.status === 'failed' ? '✗' : '–';
|
||||
lines.push(` ${icon} ${check.id}: ${check.status}`);
|
||||
}
|
||||
if (diagnostics.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Diagnostics:');
|
||||
for (const d of diagnostics) {
|
||||
lines.push(` [${d.code}] ${d.message}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Details:');
|
||||
lines.push(` blueprint digest: ${data.blueprint.digest}`);
|
||||
lines.push(` bindings digest: ${data.bindings.digest}`);
|
||||
lines.push(` profile digest: ${data.profile.digest}`);
|
||||
lines.push(` observed digest: ${data.observed.digest ?? '(absent)'}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function renderTablePlan(data: ConfigPlanDataV1): string {
|
||||
const lines: string[] = [];
|
||||
lines.push('Installation Plan');
|
||||
lines.push('=================');
|
||||
lines.push('');
|
||||
lines.push(`Conformant: ${data.conformant}`);
|
||||
lines.push(`Changes: ${data.changeCount}`);
|
||||
lines.push(`Blocked: ${data.blockedCount}`);
|
||||
lines.push(`Apply: not supported (read-only v1)`);
|
||||
lines.push(`Plan ID: ${data.planId}`);
|
||||
lines.push('');
|
||||
if (data.actions.length === 0) {
|
||||
lines.push('No actions — installation is conformant.');
|
||||
} else {
|
||||
lines.push('Actions:');
|
||||
for (const action of data.actions) {
|
||||
lines.push(` [${action.operation}] ${action.resourceKind}/${action.resourceId}`);
|
||||
lines.push(` reason: ${action.reasonCode} risk: ${action.risk}`);
|
||||
if (action.fieldDiffs.length > 0) {
|
||||
for (const fd of action.fieldDiffs) {
|
||||
lines.push(` ${fd.path}: ${JSON.stringify(fd.before)} → ${JSON.stringify(fd.after)}`);
|
||||
}
|
||||
}
|
||||
if (action.blockedBy.length > 0) {
|
||||
lines.push(` blocked by: ${action.blockedBy.join(', ')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Inputs:');
|
||||
lines.push(` blueprint digest: ${data.inputs.blueprintDigest}`);
|
||||
lines.push(` bindings digest: ${data.inputs.bindingsDigest}`);
|
||||
lines.push(` profile digest: ${data.inputs.profileDigest}`);
|
||||
lines.push(` observed digest: ${data.inputs.observedRosterDigest ?? '(absent)'}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ─── Correlation ID ───────────────────────────────────────────────────────────
|
||||
|
||||
export function makeCorrelationId(): string {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
return `config-${timestamp}-${random}`;
|
||||
}
|
||||
@@ -1,450 +0,0 @@
|
||||
/**
|
||||
* Strict YAML schema validation for blueprint and bindings.
|
||||
*
|
||||
* §6/§7 of the spec: closed mappings, no aliases/anchors/merges/tags,
|
||||
* single document, strict type checking on every field.
|
||||
*/
|
||||
import * as yaml from 'yaml';
|
||||
import {
|
||||
BLUEPRINT_API_VERSION,
|
||||
BLUEPRINT_KIND,
|
||||
BINDINGS_KIND,
|
||||
type InstallationBlueprint,
|
||||
type InstallationBindings,
|
||||
type FleetSelection,
|
||||
type ConfigDiagnostic,
|
||||
} from './types.js';
|
||||
|
||||
const MAX_INPUT_BYTES = 1024 * 1024; // 1 MiB (§14.1)
|
||||
|
||||
// ─── Strict YAML loader (§14.4) ──────────────────────────────────────────────
|
||||
|
||||
export interface StrictYamlResult {
|
||||
ok: boolean;
|
||||
value?: unknown;
|
||||
diagnostics: ConfigDiagnostic[];
|
||||
}
|
||||
|
||||
export function loadStrictYaml(content: string, context: string): StrictYamlResult {
|
||||
const diagnostics: ConfigDiagnostic[] = [];
|
||||
|
||||
if (content.length > MAX_INPUT_BYTES) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||
message: `${context} exceeds 1 MiB limit (${content.length} bytes)`,
|
||||
retryable: false,
|
||||
});
|
||||
return { ok: false, diagnostics };
|
||||
}
|
||||
|
||||
if (content.includes('\n---\n') || content.trimStart().startsWith('---')) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||
message: `${context}: multiple YAML documents rejected`,
|
||||
retryable: false,
|
||||
});
|
||||
return { ok: false, diagnostics };
|
||||
}
|
||||
|
||||
let value: unknown;
|
||||
try {
|
||||
value = yaml.parse(content, { strict: true, mapAsMap: false });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (
|
||||
content.includes('&') ||
|
||||
content.includes('*') ||
|
||||
content.includes('<<') ||
|
||||
content.includes('!')
|
||||
) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||
message: `${context}: YAML aliases/anchors/merges/tags rejected`,
|
||||
retryable: false,
|
||||
});
|
||||
} else {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||
message: `${context}: YAML parse error: ${msg.substring(0, 200)}`,
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
return { ok: false, diagnostics };
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||
message: `${context}: empty document`,
|
||||
retryable: false,
|
||||
});
|
||||
return { ok: false, diagnostics };
|
||||
}
|
||||
|
||||
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||
message: `${context}: top level must be a mapping`,
|
||||
retryable: false,
|
||||
});
|
||||
return { ok: false, diagnostics };
|
||||
}
|
||||
|
||||
return { ok: true, value, diagnostics };
|
||||
}
|
||||
|
||||
// ─── ID grammar (§6.2) ───────────────────────────────────────────────────────
|
||||
|
||||
const ID_PATTERN = /^[a-z][a-z0-9-]{0,62}$/;
|
||||
|
||||
export function isValidId(id: string): boolean {
|
||||
return ID_PATTERN.test(id);
|
||||
}
|
||||
|
||||
// ─── Blueprint validation (§6) ───────────────────────────────────────────────
|
||||
|
||||
const BLUEPRINT_TOP_KEYS = new Set(['apiVersion', 'kind', 'metadata', 'spec']);
|
||||
const BLUEPRINT_METADATA_KEYS = new Set(['name', 'generation']);
|
||||
const BLUEPRINT_SPEC_KEYS = new Set(['fleet']);
|
||||
const BLUEPRINT_FLEET_KEYS = new Set(['profile', 'selection']);
|
||||
|
||||
export function validateBlueprint(
|
||||
value: unknown,
|
||||
context: string,
|
||||
): { ok: boolean; blueprint?: InstallationBlueprint; diagnostics: ConfigDiagnostic[] } {
|
||||
const diagnostics: ConfigDiagnostic[] = [];
|
||||
const obj = value as Record<string, unknown>;
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (!BLUEPRINT_TOP_KEYS.has(key)) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||
message: `${context}: unknown top-level key '${key}'`,
|
||||
path: key,
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.apiVersion !== BLUEPRINT_API_VERSION) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||
message: `${context}: apiVersion must be exactly '${BLUEPRINT_API_VERSION}'`,
|
||||
path: 'apiVersion',
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (obj.kind !== BLUEPRINT_KIND) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||
message: `${context}: kind must be exactly '${BLUEPRINT_KIND}'`,
|
||||
path: 'kind',
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (!obj.metadata || typeof obj.metadata !== 'object') {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||
message: `${context}: metadata is required`,
|
||||
path: 'metadata',
|
||||
retryable: false,
|
||||
});
|
||||
} else {
|
||||
const meta = obj.metadata as Record<string, unknown>;
|
||||
for (const key of Object.keys(meta)) {
|
||||
if (!BLUEPRINT_METADATA_KEYS.has(key)) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||
message: `${context}: unknown metadata key '${key}'`,
|
||||
path: `metadata.${key}`,
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (typeof meta.name !== 'string' || !isValidId(meta.name)) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||
message: `${context}: metadata.name must match [a-z][a-z0-9-]{0,62}`,
|
||||
path: 'metadata.name',
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
if (
|
||||
typeof meta.generation !== 'number' ||
|
||||
!Number.isInteger(meta.generation) ||
|
||||
meta.generation < 1
|
||||
) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||
message: `${context}: metadata.generation must be an integer >= 1`,
|
||||
path: 'metadata.generation',
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!obj.spec || typeof obj.spec !== 'object') {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||
message: `${context}: spec is required`,
|
||||
path: 'spec',
|
||||
retryable: false,
|
||||
});
|
||||
} else {
|
||||
const spec = obj.spec as Record<string, unknown>;
|
||||
for (const key of Object.keys(spec)) {
|
||||
if (!BLUEPRINT_SPEC_KEYS.has(key)) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||
message: `${context}: unknown spec key '${key}'`,
|
||||
path: `spec.${key}`,
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!spec.fleet || typeof spec.fleet !== 'object') {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||
message: `${context}: spec.fleet is required`,
|
||||
path: 'spec.fleet',
|
||||
retryable: false,
|
||||
});
|
||||
} else {
|
||||
const fleet = spec.fleet as Record<string, unknown>;
|
||||
for (const key of Object.keys(fleet)) {
|
||||
if (!BLUEPRINT_FLEET_KEYS.has(key)) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||
message: `${context}: unknown fleet key '${key}'`,
|
||||
path: `spec.fleet.${key}`,
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (typeof fleet.profile !== 'string' || !isValidId(fleet.profile)) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||
message: `${context}: spec.fleet.profile must match [a-z][a-z0-9-]{0,62}`,
|
||||
path: 'spec.fleet.profile',
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
if (fleet.selection !== 'floor' && fleet.selection !== 'full') {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||
message: `${context}: spec.fleet.selection must be 'floor' or 'full'`,
|
||||
path: 'spec.fleet.selection',
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (diagnostics.length > 0) {
|
||||
return { ok: false, diagnostics };
|
||||
}
|
||||
|
||||
const fleet = (obj.spec as Record<string, unknown>).fleet as Record<string, unknown>;
|
||||
const meta = obj.metadata as Record<string, unknown>;
|
||||
const blueprint: InstallationBlueprint = {
|
||||
apiVersion: BLUEPRINT_API_VERSION,
|
||||
kind: BLUEPRINT_KIND,
|
||||
metadata: {
|
||||
name: meta.name as string,
|
||||
generation: meta.generation as number,
|
||||
},
|
||||
spec: {
|
||||
fleet: {
|
||||
profile: fleet.profile as string,
|
||||
selection: fleet.selection as FleetSelection,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return { ok: true, blueprint, diagnostics: [] };
|
||||
}
|
||||
|
||||
// ─── Bindings validation (§7) ────────────────────────────────────────────────
|
||||
|
||||
const BINDINGS_TOP_KEYS = new Set(['apiVersion', 'kind', 'spec']);
|
||||
const BINDINGS_SPEC_KEYS = new Set(['fleet']);
|
||||
const BINDINGS_FLEET_KEYS = new Set(['runtime', 'workingDirectory']);
|
||||
const BINDINGS_RUNTIME_KEYS = new Set(['default', 'byClass']);
|
||||
|
||||
export function validateBindings(
|
||||
value: unknown,
|
||||
context: string,
|
||||
): { ok: boolean; bindings?: InstallationBindings; diagnostics: ConfigDiagnostic[] } {
|
||||
const diagnostics: ConfigDiagnostic[] = [];
|
||||
const obj = value as Record<string, unknown>;
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (!BINDINGS_TOP_KEYS.has(key)) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||
message: `${context}: unknown top-level key '${key}'`,
|
||||
path: key,
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.apiVersion !== BLUEPRINT_API_VERSION) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||
message: `${context}: apiVersion must be exactly '${BLUEPRINT_API_VERSION}'`,
|
||||
path: 'apiVersion',
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (obj.kind !== BINDINGS_KIND) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||
message: `${context}: kind must be exactly '${BINDINGS_KIND}'`,
|
||||
path: 'kind',
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (!obj.spec || typeof obj.spec !== 'object' || Object.keys(obj.spec).length === 0) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||
message: `${context}: spec is required and non-empty (empty bindings file is invalid)`,
|
||||
path: 'spec',
|
||||
retryable: false,
|
||||
});
|
||||
} else {
|
||||
const spec = obj.spec as Record<string, unknown>;
|
||||
for (const key of Object.keys(spec)) {
|
||||
if (!BINDINGS_SPEC_KEYS.has(key)) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||
message: `${context}: unknown spec key '${key}'`,
|
||||
path: `spec.${key}`,
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (spec.fleet) {
|
||||
const fleet = spec.fleet as Record<string, unknown>;
|
||||
for (const key of Object.keys(fleet)) {
|
||||
if (!BINDINGS_FLEET_KEYS.has(key)) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||
message: `${context}: unknown fleet key '${key}'`,
|
||||
path: `spec.fleet.${key}`,
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (fleet.runtime) {
|
||||
const runtime = fleet.runtime as Record<string, unknown>;
|
||||
for (const key of Object.keys(runtime)) {
|
||||
if (!BINDINGS_RUNTIME_KEYS.has(key)) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||
message: `${context}: unknown runtime key '${key}'`,
|
||||
path: `spec.fleet.runtime.${key}`,
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
runtime.default !== undefined &&
|
||||
(typeof runtime.default !== 'string' || !isValidId(runtime.default))
|
||||
) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||
message: `${context}: runtime.default must match [a-z][a-z0-9-]{0,62}`,
|
||||
path: 'spec.fleet.runtime.default',
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
if (runtime.byClass !== undefined && runtime.byClass !== null) {
|
||||
if (typeof runtime.byClass !== 'object' || runtime.byClass === null) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||
message: `${context}: runtime.byClass must be a mapping`,
|
||||
path: 'spec.fleet.runtime.byClass',
|
||||
retryable: false,
|
||||
});
|
||||
} else {
|
||||
for (const [cls, rt] of Object.entries(runtime.byClass)) {
|
||||
if (!isValidId(cls)) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||
message: `${context}: byClass key '${cls}' must match [a-z][a-z0-9-]{0,62}`,
|
||||
path: `spec.fleet.runtime.byClass.${cls}`,
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
if (typeof rt !== 'string' || !isValidId(rt)) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||
message: `${context}: byClass value for '${cls}' must match [a-z][a-z0-9-]{0,62}`,
|
||||
path: `spec.fleet.runtime.byClass.${cls}`,
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fleet.workingDirectory !== undefined) {
|
||||
const wd = fleet.workingDirectory;
|
||||
if (typeof wd !== 'string') {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||
message: `${context}: workingDirectory must be a string`,
|
||||
path: 'spec.fleet.workingDirectory',
|
||||
retryable: false,
|
||||
});
|
||||
} else {
|
||||
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(wd)) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||
message: `${context}: workingDirectory contains control characters`,
|
||||
path: 'spec.fleet.workingDirectory',
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
if (!wd.startsWith('/') && !wd.startsWith('~/')) {
|
||||
diagnostics.push({
|
||||
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||
message: `${context}: workingDirectory must be absolute or ~/ prefixed`,
|
||||
path: 'spec.fleet.workingDirectory',
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (diagnostics.length > 0) {
|
||||
return { ok: false, diagnostics };
|
||||
}
|
||||
|
||||
const fleet = (obj.spec as Record<string, unknown>).fleet as Record<string, unknown> | undefined;
|
||||
const bindings: InstallationBindings = {
|
||||
apiVersion: BLUEPRINT_API_VERSION,
|
||||
kind: BINDINGS_KIND,
|
||||
spec: {
|
||||
fleet: fleet
|
||||
? {
|
||||
runtime: fleet.runtime as InstallationBindings['spec']['fleet']['runtime'] | undefined,
|
||||
workingDirectory: fleet.workingDirectory as string | undefined,
|
||||
}
|
||||
: {},
|
||||
},
|
||||
};
|
||||
|
||||
return { ok: true, bindings, diagnostics: [] };
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
/**
|
||||
* Shared type contracts for the installation config minimal subset.
|
||||
*
|
||||
* Spec: docs/specs/2026-08-29_mosaic-config-minimal-subset.md (spec review
|
||||
* PASS deff617d by rev-code-02; implementation per CONFIGIMPL-GO).
|
||||
*
|
||||
* These types are the single source of truth for the blueprint, bindings,
|
||||
* result, action, and diagnostic shapes. The YAML schemas in schema.ts and
|
||||
* the result renderers in render.ts consume these interfaces directly.
|
||||
*/
|
||||
|
||||
// ─── Blueprint (§6) ───────────────────────────────────────────────────────────
|
||||
|
||||
export const BLUEPRINT_API_VERSION = 'config.mosaicstack.dev/v1alpha1';
|
||||
export const BLUEPRINT_KIND = 'InstallationBlueprint';
|
||||
export const BINDINGS_KIND = 'InstallationBindings';
|
||||
export const PRESET_ID = 'bootstrap-minimal@1';
|
||||
|
||||
export type FleetSelection = 'floor' | 'full';
|
||||
|
||||
export interface InstallationBlueprint {
|
||||
apiVersion: typeof BLUEPRINT_API_VERSION;
|
||||
kind: typeof BLUEPRINT_KIND;
|
||||
metadata: {
|
||||
name: string;
|
||||
generation: number;
|
||||
};
|
||||
spec: {
|
||||
fleet: {
|
||||
profile: string;
|
||||
selection: FleetSelection;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Bindings (§7) ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface InstallationBindings {
|
||||
apiVersion: typeof BLUEPRINT_API_VERSION;
|
||||
kind: typeof BINDINGS_KIND;
|
||||
spec: {
|
||||
fleet: {
|
||||
runtime?: {
|
||||
default?: string;
|
||||
byClass?: Record<string, string>;
|
||||
};
|
||||
workingDirectory?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Framework defaults (§7, A5) ─────────────────────────────────────────────
|
||||
|
||||
export interface FrameworkBindingDefaults {
|
||||
runtime: string;
|
||||
workingDirectory: string;
|
||||
}
|
||||
|
||||
export const FRAMEWORK_BINDING_DEFAULTS: FrameworkBindingDefaults = {
|
||||
runtime: 'claude',
|
||||
workingDirectory: '~',
|
||||
};
|
||||
|
||||
// ─── Preset (§8) ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PackagedPreset {
|
||||
id: typeof PRESET_ID;
|
||||
blueprint: InstallationBlueprint;
|
||||
}
|
||||
|
||||
export const BOOTSTRAP_MINIMAL_V1: PackagedPreset = {
|
||||
id: 'bootstrap-minimal@1',
|
||||
blueprint: {
|
||||
apiVersion: 'config.mosaicstack.dev/v1alpha1',
|
||||
kind: 'InstallationBlueprint',
|
||||
metadata: {
|
||||
name: 'bootstrap-minimal',
|
||||
generation: 1,
|
||||
},
|
||||
spec: {
|
||||
fleet: {
|
||||
profile: 'software-delivery',
|
||||
selection: 'floor',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Diagnostics (§13) ────────────────────────────────────────────────────────
|
||||
|
||||
export type DiagnosticCode =
|
||||
| 'CONFIG_USAGE_INVALID'
|
||||
| 'CONFIG_REGISTRY_INVALID'
|
||||
| 'CONFIG_BLUEPRINT_MISSING'
|
||||
| 'CONFIG_BLUEPRINT_SCHEMA'
|
||||
| 'CONFIG_BINDINGS_SCHEMA'
|
||||
| 'CONFIG_BINDINGS_NOT_IGNORED'
|
||||
| 'CONFIG_PRESET_UNKNOWN'
|
||||
| 'CONFIG_PROFILE_UNRESOLVED'
|
||||
| 'CONFIG_ROLE_UNRESOLVED'
|
||||
| 'CONFIG_DESIRED_ROSTER_INVALID'
|
||||
| 'CONFIG_OBSERVED_ROSTER_INVALID'
|
||||
| 'CONFIG_ROSTER_MISSING'
|
||||
| 'CONFIG_NONCONFORMANT'
|
||||
| 'CONFIG_DRIFT_CREATE'
|
||||
| 'CONFIG_DRIFT_UPDATE'
|
||||
| 'CONFIG_DRIFT_FULL_ENGINE_REQUIRED'
|
||||
| 'CONFIG_ADAPTER_UNAVAILABLE';
|
||||
|
||||
export interface ConfigDiagnostic {
|
||||
code: DiagnosticCode;
|
||||
message: string;
|
||||
path?: string;
|
||||
retryable: boolean;
|
||||
}
|
||||
|
||||
// ─── Registry provenance (§5, §2.2) ──────────────────────────────────────────
|
||||
|
||||
export interface RegistryProvenance {
|
||||
resolved: boolean;
|
||||
brainHome: string | null;
|
||||
sourceKeys: Array<{ key: string; sourceClass: string }>;
|
||||
}
|
||||
|
||||
// ─── Validation result (§12.1) ───────────────────────────────────────────────
|
||||
|
||||
export interface ConfigCheckResult {
|
||||
id: string;
|
||||
status: 'passed' | 'failed' | 'notChecked';
|
||||
}
|
||||
|
||||
export interface ConfigValidationDataV1 {
|
||||
resultSchemaVersion: 1;
|
||||
valid: boolean;
|
||||
conformant: boolean;
|
||||
blueprint: { source: 'file' | 'preset'; id: string; digest: string };
|
||||
bindings: { source: 'file' | 'framework-default'; digest: string };
|
||||
profile: { id: string; digest: string; selection: FleetSelection };
|
||||
observed: { roster: 'present' | 'absent'; digest: string | null };
|
||||
checks: ConfigCheckResult[];
|
||||
}
|
||||
|
||||
// ─── Plan result (§12.2, §11.2) ─────────────────────────────────────────────
|
||||
|
||||
export type ConfigPlanOperationV1 = 'create' | 'update' | 'blocked';
|
||||
export type ConfigRiskV1 = 'none' | 'review-required' | 'full-engine-required';
|
||||
|
||||
export interface ConfigPlanFieldDiff {
|
||||
path: string;
|
||||
before: string | number | boolean | null;
|
||||
after: string | number | boolean | null;
|
||||
}
|
||||
|
||||
export interface ConfigPlanActionV1 {
|
||||
id: string;
|
||||
resourceKind: 'fleet-roster' | 'fleet-seat';
|
||||
resourceId: string;
|
||||
operation: ConfigPlanOperationV1;
|
||||
risk: ConfigRiskV1;
|
||||
reasonCode: string;
|
||||
beforeDigest: string | null;
|
||||
afterDigest: string | null;
|
||||
fieldDiffs: ConfigPlanFieldDiff[];
|
||||
blockedBy: string[];
|
||||
}
|
||||
|
||||
export interface ConfigPlanDataV1 {
|
||||
resultSchemaVersion: 1;
|
||||
planSchemaVersion: 1;
|
||||
planId: string;
|
||||
applySupported: false;
|
||||
valid: true;
|
||||
conformant: boolean;
|
||||
changeCount: number;
|
||||
blockedCount: number;
|
||||
inputs: {
|
||||
blueprintDigest: string;
|
||||
bindingsDigest: string;
|
||||
profileDigest: string;
|
||||
observedRosterDigest: string | null;
|
||||
};
|
||||
actions: ConfigPlanActionV1[];
|
||||
}
|
||||
|
||||
// ─── Result envelope (§12) ────────────────────────────────────────────────────
|
||||
|
||||
export interface CapabilityResultV1<T> {
|
||||
capabilityId: string;
|
||||
status: 'succeeded' | 'failed' | 'invalid';
|
||||
data?: T;
|
||||
diagnostics?: ConfigDiagnostic[];
|
||||
correlationId: string;
|
||||
executionMode: 'local-adapter';
|
||||
identityTrust: 'local-asserted';
|
||||
audit: { authority: 'none'; recorded: boolean };
|
||||
}
|
||||
|
||||
// ─── Exit codes (§10.3) ──────────────────────────────────────────────────────
|
||||
|
||||
export const EXIT_OK = 0;
|
||||
export const EXIT_INVALID = 2;
|
||||
export const EXIT_RESERVED_SCOPE = 3;
|
||||
export const EXIT_NONCONFORMANT = 4;
|
||||
export const EXIT_UNAVAILABLE = 6;
|
||||
|
||||
// ─── Dependency gate marker (§2.2) ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* DEPENDENCY GATE: the approved MosaicRegistryResolver does not exist at
|
||||
* the pinned baseline. The registry-consuming work is PARKED. This marker
|
||||
* interface exists so that when the reviewed resolver ships, the adapter
|
||||
* binds to it without schema changes. Until then, the adapter returns
|
||||
* CONFIG_REGISTRY_INVALID with the dependency-blocked message.
|
||||
*/
|
||||
export const REGISTRY_RESOLVER_BLOCKED =
|
||||
'MosaicRegistryResolver: dependency-blocked pending reviewed resolver (CFG-REQ-001..006 charter)';
|
||||
Reference in New Issue
Block a user