From 215faeda0a9036a9b087fc8b32c8755abb4133a5 Mon Sep 17 00:00:00 2001 From: fred Date: Sat, 29 Aug 2026 16:54:39 +0000 Subject: [PATCH] feat(hierarchy): M4-1b-ii hierarchy command family, grant evaluation, visibility (#1465) --- .../hierarchy-route-inventory.test.ts | 67 +- .../command-authorization.service.spec.ts | 27 + .../commands/command-authorization.service.ts | 9 +- .../hierarchy/hierarchy-audit.repository.ts | 4 +- .../hierarchy-commands.integration.test.ts | 965 +++ .../hierarchy/hierarchy-grant-evaluation.ts | 242 + .../src/hierarchy/hierarchy.controller.ts | 297 + apps/gateway/src/hierarchy/hierarchy.dto.ts | 169 + .../gateway/src/hierarchy/hierarchy.module.ts | 25 +- .../src/hierarchy/hierarchy.repository.ts | 935 +++ .../src/hierarchy/hierarchy.service.ts | 31 + apps/gateway/src/mcp/mcp.service.spec.ts | 113 +- apps/gateway/src/mcp/mcp.service.ts | 76 +- apps/gateway/src/validation-pipe-check.ts | 62 + .../db/drizzle/0020_special_betty_brant.sql | 5 + packages/db/drizzle/meta/0020_snapshot.json | 5389 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + .../db/src/hierarchy-schema.witness.test.ts | 60 +- .../db/src/hierarchy-writer-coverage.test.ts | 20 +- packages/db/src/schema.ts | 36 +- 20 files changed, 8407 insertions(+), 132 deletions(-) create mode 100644 apps/gateway/src/hierarchy/hierarchy-commands.integration.test.ts create mode 100644 apps/gateway/src/hierarchy/hierarchy-grant-evaluation.ts create mode 100644 apps/gateway/src/hierarchy/hierarchy.controller.ts create mode 100644 apps/gateway/src/hierarchy/hierarchy.dto.ts create mode 100644 apps/gateway/src/hierarchy/hierarchy.repository.ts create mode 100644 apps/gateway/src/hierarchy/hierarchy.service.ts create mode 100644 packages/db/drizzle/0020_special_betty_brant.sql create mode 100644 packages/db/drizzle/meta/0020_snapshot.json diff --git a/apps/gateway/src/__tests__/hierarchy-route-inventory.test.ts b/apps/gateway/src/__tests__/hierarchy-route-inventory.test.ts index 86c9ecb6..eaf4831e 100644 --- a/apps/gateway/src/__tests__/hierarchy-route-inventory.test.ts +++ b/apps/gateway/src/__tests__/hierarchy-route-inventory.test.ts @@ -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): 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()); }); }); diff --git a/apps/gateway/src/commands/command-authorization.service.spec.ts b/apps/gateway/src/commands/command-authorization.service.spec.ts index 315d902b..17b7dbed 100644 --- a/apps/gateway/src/commands/command-authorization.service.spec.ts +++ b/apps/gateway/src/commands/command-authorization.service.spec.ts @@ -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 => { + 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 => { + 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 => { const entries = new Map(); const action = { diff --git a/apps/gateway/src/commands/command-authorization.service.ts b/apps/gateway/src/commands/command-authorization.service.ts index a9f829e7..fe789bb2 100644 --- a/apps/gateway/src/commands/command-authorization.service.ts +++ b/apps/gateway/src/commands/command-authorization.service.ts @@ -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'); } diff --git a/apps/gateway/src/hierarchy/hierarchy-audit.repository.ts b/apps/gateway/src/hierarchy/hierarchy-audit.repository.ts index 8929b76b..d445ef21 100644 --- a/apps/gateway/src/hierarchy/hierarchy-audit.repository.ts +++ b/apps/gateway/src/hierarchy/hierarchy-audit.repository.ts @@ -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]; diff --git a/apps/gateway/src/hierarchy/hierarchy-commands.integration.test.ts b/apps/gateway/src/hierarchy/hierarchy-commands.integration.test.ts new file mode 100644 index 00000000..89b73fe0 --- /dev/null +++ b/apps/gateway/src/hierarchy/hierarchy-commands.integration.test.ts @@ -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(result: HierarchyResult): { 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 => { + 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 ( + key: string, + command: () => Promise>, + assertUnchanged: () => Promise, + ): Promise => { + 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 => { + 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 => { + 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', + }); + }); +}); diff --git a/apps/gateway/src/hierarchy/hierarchy-grant-evaluation.ts b/apps/gateway/src/hierarchy/hierarchy-grant-evaluation.ts new file mode 100644 index 00000000..6d1d4a83 --- /dev/null +++ b/apps/gateway/src/hierarchy/hierarchy-grant-evaluation.ts @@ -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; + +/** 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 { + 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 { + 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 { + 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(); + const estateIds = new Set(); + const platformProjectIds = new Set(); + 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 { + return evaluateEffectiveRole(tx ?? this.db, userId, kind, id); + } + + async hasRole( + userId: string, + kind: EvaluableNodeKind, + id: string, + required: HierarchyGrantRole, + tx?: Tx, + ): Promise { + return roleAtLeast(await this.effectiveRole(userId, kind, id, tx), required); + } +} diff --git a/apps/gateway/src/hierarchy/hierarchy.controller.ts b/apps/gateway/src/hierarchy/hierarchy.controller.ts new file mode 100644 index 00000000..dbc7534e --- /dev/null +++ b/apps/gateway/src/hierarchy/hierarchy.controller.ts @@ -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, + }), + ); + } +} diff --git a/apps/gateway/src/hierarchy/hierarchy.dto.ts b/apps/gateway/src/hierarchy/hierarchy.dto.ts new file mode 100644 index 00000000..2359a929 --- /dev/null +++ b/apps/gateway/src/hierarchy/hierarchy.dto.ts @@ -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; +} diff --git a/apps/gateway/src/hierarchy/hierarchy.module.ts b/apps/gateway/src/hierarchy/hierarchy.module.ts index 71ac8e49..57854af3 100644 --- a/apps/gateway/src/hierarchy/hierarchy.module.ts +++ b/apps/gateway/src/hierarchy/hierarchy.module.ts @@ -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 {} diff --git a/apps/gateway/src/hierarchy/hierarchy.repository.ts b/apps/gateway/src/hierarchy/hierarchy.repository.ts new file mode 100644 index 00000000..23af7c32 --- /dev/null +++ b/apps/gateway/src/hierarchy/hierarchy.repository.ts @@ -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 = ({ 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; +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 { + 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( + idempotencyKey: string | undefined, + actorId: string, + body: (tx: Tx, ctx: CommandContext) => Promise>, + ): Promise> { + 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 { + return roleAtLeast(await evaluateEffectiveRole(tx, actorId, kind, id), 'owner'); + } + + private async isPlatformAdmin(tx: Tx, actorId: string): Promise { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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 { + 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 { + 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); + } +} diff --git a/apps/gateway/src/hierarchy/hierarchy.service.ts b/apps/gateway/src/hierarchy/hierarchy.service.ts new file mode 100644 index 00000000..8b878279 --- /dev/null +++ b/apps/gateway/src/hierarchy/hierarchy.service.ts @@ -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(result: HierarchyResult): 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); + } + } +} diff --git a/apps/gateway/src/mcp/mcp.service.spec.ts b/apps/gateway/src/mcp/mcp.service.spec.ts index 9842feea..30c0de65 100644 --- a/apps/gateway/src/mcp/mcp.service.spec.ts +++ b/apps/gateway/src/mcp/mcp.service.spec.ts @@ -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()); }); diff --git a/apps/gateway/src/mcp/mcp.service.ts b/apps/gateway/src/mcp/mcp.service.ts index e5fb6b92..5fdc3b3e 100644 --- a/apps/gateway/src/mcp/mcp.service.ts +++ b/apps/gateway/src/mcp/mcp.service.ts @@ -63,20 +63,6 @@ interface SessionEntry { actor: McpActorContext; } -const GLOBAL_ADMIN_MCP_SCOPES = new Set(Object.values(MCP_TOOL_SCOPES)); -const TENANT_ADMIN_MCP_SCOPES = new Set([ - 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([ MCP_TOOL_SCOPES.brain_list_projects, MCP_TOOL_SCOPES.brain_get_project, @@ -89,15 +75,17 @@ const MEMBER_MCP_SCOPES = new Set([ 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 { - 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(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( 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, right: ReadonlySet): boolean { @@ -293,7 +262,6 @@ export class McpService implements OnModuleDestroy { } private async isProjectAuthorized(actor: McpActorContext, projectId: string): Promise { - 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 { - 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 { - 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 { - 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 { - 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)), ); diff --git a/apps/gateway/src/validation-pipe-check.ts b/apps/gateway/src/validation-pipe-check.ts index 61af929a..2492daaf 100644 --- a/apps/gateway/src/validation-pipe-check.ts +++ b/apps/gateway/src/validation-pipe-check.ts @@ -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 { diff --git a/packages/db/drizzle/0020_special_betty_brant.sql b/packages/db/drizzle/0020_special_betty_brant.sql new file mode 100644 index 00000000..ce635c73 --- /dev/null +++ b/packages/db/drizzle/0020_special_betty_brant.sql @@ -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')); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0020_snapshot.json b/packages/db/drizzle/meta/0020_snapshot.json new file mode 100644 index 00000000..86aea5c3 --- /dev/null +++ b/packages/db/drizzle/meta/0020_snapshot.json @@ -0,0 +1,5389 @@ +{ + "id": "12db954b-83ad-4c40-81b9-4e4d817bd836", + "prevId": "81949b09-c995-41b6-b35e-40d73ae85975", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_tokens": { + "name": "admin_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_tokens_user_id_idx": { + "name": "admin_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_tokens_hash_idx": { + "name": "admin_tokens_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_tokens_user_id_users_id_fk": { + "name": "admin_tokens_user_id_users_id_fk", + "tableFrom": "admin_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_logs": { + "name": "agent_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'hot'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "summarized_at": { + "name": "summarized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_logs_session_tier_idx": { + "name": "agent_logs_session_tier_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_user_id_idx": { + "name": "agent_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_tier_created_at_idx": { + "name": "agent_logs_tier_created_at_idx", + "columns": [ + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_logs_user_id_users_id_fk": { + "name": "agent_logs_user_id_users_id_fk", + "tableFrom": "agent_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_project_id_idx": { + "name": "agents_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_owner_id_idx": { + "name": "agents_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_is_system_idx": { + "name": "agents_is_system_idx", + "columns": [ + { + "expression": "is_system", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_project_id_projects_id_fk": { + "name": "agents_project_id_projects_id_fk", + "tableFrom": "agents", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_owner_id_users_id_fk": { + "name": "agents_owner_id_users_id_fk", + "tableFrom": "agents", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appreciations": { + "name": "appreciations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "from_user": { + "name": "from_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_user": { + "name": "to_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backlog": { + "name": "backlog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "backlog_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "depends_on": { + "name": "depends_on", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "claim_owner": { + "name": "claim_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_ttl_seconds": { + "name": "claim_ttl_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acceptance": { + "name": "acceptance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "backlog_status_priority_idx": { + "name": "backlog_status_priority_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_status_claimed_at_idx": { + "name": "backlog_status_claimed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_idempotency_key_idx": { + "name": "backlog_idempotency_key_idx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "companies_slug_unique": { + "name": "companies_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": { + "companies_visibility_check": { + "name": "companies_visibility_check", + "value": "visibility IN ('private', 'directory')" + } + }, + "isRLSEnabled": false + }, + "public.connector_lease_audit_log": { + "name": "connector_lease_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logical_agent_id": { + "name": "logical_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lease_id": { + "name": "lease_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_epoch": { + "name": "lease_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connector_lease_audit_binding_occurred_idx": { + "name": "connector_lease_audit_binding_occurred_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "logical_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connector_lease_audit_correlation_idx": { + "name": "connector_lease_audit_correlation_idx", + "columns": [ + { + "expression": "correlation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_user_archived_idx": { + "name": "conversations_user_archived_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_id_idx": { + "name": "conversations_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_agent_id_idx": { + "name": "conversations_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_user_id_users_id_fk": { + "name": "conversations_user_id_users_id_fk", + "tableFrom": "conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_agent_id_agents_id_fk": { + "name": "conversations_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.estates": { + "name": "estates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "estates_company_id_companies_id_fk": { + "name": "estates_company_id_companies_id_fk", + "tableFrom": "estates", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "estates_company_slug_uniq": { + "name": "estates_company_slug_uniq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_type_idx": { + "name": "events_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_date_idx": { + "name": "events_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_audit_log": { + "name": "federation_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "verb": { + "name": "verb", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "result_count": { + "name": "result_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "denied_reason": { + "name": "denied_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "query_hash": { + "name": "query_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bytes_out": { + "name": "bytes_out", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_audit_log_peer_created_at_idx": { + "name": "federation_audit_log_peer_created_at_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_subject_created_at_idx": { + "name": "federation_audit_log_subject_created_at_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_created_at_idx": { + "name": "federation_audit_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_audit_log_peer_id_federation_peers_id_fk": { + "name": "federation_audit_log_peer_id_federation_peers_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_subject_user_id_users_id_fk": { + "name": "federation_audit_log_subject_user_id_users_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_grant_id_federation_grants_id_fk": { + "name": "federation_audit_log_grant_id_federation_grants_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_enrollment_tokens": { + "name": "federation_enrollment_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "federation_enrollment_tokens_grant_id_federation_grants_id_fk": { + "name": "federation_enrollment_tokens_grant_id_federation_grants_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_enrollment_tokens_peer_id_federation_peers_id_fk": { + "name": "federation_enrollment_tokens_peer_id_federation_peers_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_grants": { + "name": "federation_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_grants_subject_status_idx": { + "name": "federation_grants_subject_status_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_grants_peer_status_idx": { + "name": "federation_grants_peer_status_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_grants_subject_user_id_users_id_fk": { + "name": "federation_grants_subject_user_id_users_id_fk", + "tableFrom": "federation_grants", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_grants_peer_id_federation_peers_id_fk": { + "name": "federation_grants_peer_id_federation_peers_id_fk", + "tableFrom": "federation_grants", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_peers": { + "name": "federation_peers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "common_name": { + "name": "common_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_pem": { + "name": "cert_pem", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_serial": { + "name": "cert_serial", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_not_after": { + "name": "cert_not_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "client_key_pem": { + "name": "client_key_pem", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "peer_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "endpoint_url": { + "name": "endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_peers_cert_serial_idx": { + "name": "federation_peers_cert_serial_idx", + "columns": [ + { + "expression": "cert_serial", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_peers_state_idx": { + "name": "federation_peers_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "federation_peers_common_name_unique": { + "name": "federation_peers_common_name_unique", + "nullsNotDistinct": false, + "columns": [ + "common_name" + ] + }, + "federation_peers_cert_serial_unique": { + "name": "federation_peers_cert_serial_unique", + "nullsNotDistinct": false, + "columns": [ + "cert_serial" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hierarchy_audit_events": { + "name": "hierarchy_audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "seq": { + "name": "seq", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "identity": { + "type": "always", + "name": "hierarchy_audit_events_seq_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verb": { + "name": "verb", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_snapshot": { + "name": "target_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "transfer_from": { + "name": "transfer_from", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "transfer_to": { + "name": "transfer_to", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "causation_id": { + "name": "causation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hierarchy_audit_events_idempotency_idx": { + "name": "hierarchy_audit_events_idempotency_idx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hierarchy_audit_events_seq_idx": { + "name": "hierarchy_audit_events_seq_idx", + "columns": [ + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hierarchy_audit_events_target_seq_idx": { + "name": "hierarchy_audit_events_target_seq_idx", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hierarchy_audit_events_correlation_idx": { + "name": "hierarchy_audit_events_correlation_idx", + "columns": [ + { + "expression": "correlation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hierarchy_audit_events_causation_id_hierarchy_audit_events_id_fk": { + "name": "hierarchy_audit_events_causation_id_hierarchy_audit_events_id_fk", + "tableFrom": "hierarchy_audit_events", + "tableTo": "hierarchy_audit_events", + "columnsFrom": [ + "causation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "hierarchy_audit_events_verb_check": { + "name": "hierarchy_audit_events_verb_check", + "value": "verb IN ('create', 'rename', 'transfer', 'visibility_change', 'delete', 'grant_create', 'grant_change', 'grant_revoke')" + }, + "hierarchy_audit_events_target_kind_check": { + "name": "hierarchy_audit_events_target_kind_check", + "value": "target_kind IN ('company', 'estate', 'platform_project', 'grant')" + }, + "hierarchy_audit_events_transfer_check": { + "name": "hierarchy_audit_events_transfer_check", + "value": "(verb = 'transfer') = (transfer_from IS NOT NULL AND transfer_to IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.hierarchy_grants": { + "name": "hierarchy_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "estate_id": { + "name": "estate_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform_project_id": { + "name": "platform_project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hierarchy_grants_company_id_idx": { + "name": "hierarchy_grants_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hierarchy_grants_estate_id_idx": { + "name": "hierarchy_grants_estate_id_idx", + "columns": [ + { + "expression": "estate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hierarchy_grants_platform_project_id_idx": { + "name": "hierarchy_grants_platform_project_id_idx", + "columns": [ + { + "expression": "platform_project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hierarchy_grants_user_id_idx": { + "name": "hierarchy_grants_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hierarchy_grants_team_id_idx": { + "name": "hierarchy_grants_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hierarchy_grants_granted_by_idx": { + "name": "hierarchy_grants_granted_by_idx", + "columns": [ + { + "expression": "granted_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hierarchy_grants_user_id_users_id_fk": { + "name": "hierarchy_grants_user_id_users_id_fk", + "tableFrom": "hierarchy_grants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "hierarchy_grants_team_id_teams_id_fk": { + "name": "hierarchy_grants_team_id_teams_id_fk", + "tableFrom": "hierarchy_grants", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "hierarchy_grants_company_id_companies_id_fk": { + "name": "hierarchy_grants_company_id_companies_id_fk", + "tableFrom": "hierarchy_grants", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hierarchy_grants_estate_id_estates_id_fk": { + "name": "hierarchy_grants_estate_id_estates_id_fk", + "tableFrom": "hierarchy_grants", + "tableTo": "estates", + "columnsFrom": [ + "estate_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hierarchy_grants_platform_project_id_platform_projects_id_fk": { + "name": "hierarchy_grants_platform_project_id_platform_projects_id_fk", + "tableFrom": "hierarchy_grants", + "tableTo": "platform_projects", + "columnsFrom": [ + "platform_project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hierarchy_grants_granted_by_users_id_fk": { + "name": "hierarchy_grants_granted_by_users_id_fk", + "tableFrom": "hierarchy_grants", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hierarchy_grants_subject_target_role_uniq": { + "name": "hierarchy_grants_subject_target_role_uniq", + "nullsNotDistinct": true, + "columns": [ + "user_id", + "team_id", + "company_id", + "estate_id", + "platform_project_id", + "role" + ] + } + }, + "policies": {}, + "checkConstraints": { + "hierarchy_grants_role_check": { + "name": "hierarchy_grants_role_check", + "value": "role IN ('viewer', 'member', 'owner')" + }, + "hierarchy_grants_subject_check": { + "name": "hierarchy_grants_subject_check", + "value": "num_nonnulls(user_id, team_id) = 1" + }, + "hierarchy_grants_target_check": { + "name": "hierarchy_grants_target_check", + "value": "num_nonnulls(company_id, estate_id, platform_project_id) = 1" + } + }, + "isRLSEnabled": false + }, + "public.hierarchy_outbox": { + "name": "hierarchy_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "hierarchy_outbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "hierarchy_outbox_event_idx": { + "name": "hierarchy_outbox_event_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hierarchy_outbox_idempotency_idx": { + "name": "hierarchy_outbox_idempotency_idx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hierarchy_outbox_status_created_idx": { + "name": "hierarchy_outbox_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hierarchy_outbox_event_id_hierarchy_audit_events_id_fk": { + "name": "hierarchy_outbox_event_id_hierarchy_audit_events_id_fk", + "tableFrom": "hierarchy_outbox", + "tableTo": "hierarchy_audit_events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.insights": { + "name": "insights", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "relevance_score": { + "name": "relevance_score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decayed_at": { + "name": "decayed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "insights_user_id_idx": { + "name": "insights_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_category_idx": { + "name": "insights_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_relevance_idx": { + "name": "insights_relevance_idx", + "columns": [ + { + "expression": "relevance_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "insights_user_id_users_id_fk": { + "name": "insights_user_id_users_id_fk", + "tableFrom": "insights", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_checkpoints": { + "name": "interaction_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_digest": { + "name": "content_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "compaction_epoch": { + "name": "compaction_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_checkpoints_session_idempotency_idx": { + "name": "interaction_checkpoints_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_checkpoints_session_epoch_idx": { + "name": "interaction_checkpoints_session_epoch_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "compaction_epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_checkpoints_session_id_interaction_sessions_id_fk": { + "name": "interaction_checkpoints_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_checkpoints", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_handoffs": { + "name": "interaction_handoffs", + "schema": "", + "columns": { + "handoff_id": { + "name": "handoff_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_handoff_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_handoffs_session_status_idx": { + "name": "interaction_handoffs_session_status_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_handoffs_session_id_interaction_sessions_id_fk": { + "name": "interaction_handoffs_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_handoffs", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_inbox": { + "name": "interaction_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_digest": { + "name": "content_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_inbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_inbox_session_idempotency_idx": { + "name": "interaction_inbox_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_inbox_session_status_created_idx": { + "name": "interaction_inbox_session_status_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_inbox_session_id_interaction_sessions_id_fk": { + "name": "interaction_inbox_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_inbox", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_outbox": { + "name": "interaction_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_digest": { + "name": "content_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_outbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_outbox_session_idempotency_idx": { + "name": "interaction_outbox_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_outbox_session_status_created_idx": { + "name": "interaction_outbox_session_status_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_outbox_session_id_interaction_sessions_id_fk": { + "name": "interaction_outbox_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_outbox", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_sessions": { + "name": "interaction_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_session_id": { + "name": "runtime_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "interaction_sessions_owner_id_users_id_fk": { + "name": "interaction_sessions_owner_id_users_id_fk", + "tableFrom": "interaction_sessions", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.logical_agent_connector_leases": { + "name": "logical_agent_connector_leases", + "schema": "", + "columns": { + "lease_id": { + "name": "lease_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logical_agent_id": { + "name": "logical_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "lease_epoch": { + "name": "lease_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "acquired_at": { + "name": "acquired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "logical_agent_connector_lease_binding_idx": { + "name": "logical_agent_connector_lease_binding_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "logical_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "logical_agent_connector_lease_expiry_idx": { + "name": "logical_agent_connector_lease_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "logical_agent_connector_lease_connector_idx": { + "name": "logical_agent_connector_lease_connector_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_conversation_id_idx": { + "name": "messages_conversation_id_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mission_tasks": { + "name": "mission_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr": { + "name": "pr", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mission_tasks_mission_id_idx": { + "name": "mission_tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_task_id_idx": { + "name": "mission_tasks_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_user_id_idx": { + "name": "mission_tasks_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_status_idx": { + "name": "mission_tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mission_tasks_mission_id_missions_id_fk": { + "name": "mission_tasks_mission_id_missions_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mission_tasks_task_id_tasks_id_fk": { + "name": "mission_tasks_task_id_tasks_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mission_tasks_user_id_users_id_fk": { + "name": "mission_tasks_user_id_users_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.missions": { + "name": "missions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planning'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "milestones": { + "name": "milestones", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "missions_project_id_idx": { + "name": "missions_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "missions_user_id_idx": { + "name": "missions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "missions_project_id_projects_id_fk": { + "name": "missions_project_id_projects_id_fk", + "tableFrom": "missions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "missions_user_id_users_id_fk": { + "name": "missions_user_id_users_id_fk", + "tableFrom": "missions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_projects": { + "name": "platform_projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "estate_id": { + "name": "estate_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "platform_projects_estate_id_estates_id_fk": { + "name": "platform_projects_estate_id_estates_id_fk", + "tableFrom": "platform_projects", + "tableTo": "estates", + "columnsFrom": [ + "estate_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "platform_projects_estate_slug_uniq": { + "name": "platform_projects_estate_slug_uniq", + "nullsNotDistinct": false, + "columns": [ + "estate_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.preferences": { + "name": "preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mutable": { + "name": "mutable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "preferences_user_id_idx": { + "name": "preferences_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "preferences_user_key_idx": { + "name": "preferences_user_key_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "preferences_user_id_users_id_fk": { + "name": "preferences_user_id_users_id_fk", + "tableFrom": "preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_owner_id_users_id_fk": { + "name": "projects_owner_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "projects_team_id_teams_id_fk": { + "name": "projects_team_id_teams_id_fk", + "tableFrom": "projects", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_credentials": { + "name": "provider_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_type": { + "name": "credential_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_credentials_user_provider_idx": { + "name": "provider_credentials_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_credentials_user_id_idx": { + "name": "provider_credentials_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_credentials_user_id_users_id_fk": { + "name": "provider_credentials_user_id_users_id_fk", + "tableFrom": "provider_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routing_rules": { + "name": "routing_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routing_rules_scope_priority_idx": { + "name": "routing_rules_scope_priority_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_user_id_idx": { + "name": "routing_rules_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_enabled_idx": { + "name": "routing_rules_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routing_rules_user_id_users_id_fk": { + "name": "routing_rules_user_id_users_id_fk", + "tableFrom": "routing_rules", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_enabled_idx": { + "name": "skills_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_installed_by_users_id_fk": { + "name": "skills_installed_by_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "installed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "skills_name_unique": { + "name": "skills_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.summarization_jobs": { + "name": "summarization_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "logs_processed": { + "name": "logs_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "insights_created": { + "name": "insights_created", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "summarization_jobs_status_idx": { + "name": "summarization_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee": { + "name": "assignee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_project_id_idx": { + "name": "tasks_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_mission_id_idx": { + "name": "tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_mission_id_missions_id_fk": { + "name": "tasks_mission_id_missions_id_fk", + "tableFrom": "tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_members_team_user_idx": { + "name": "team_members_team_user_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_invited_by_users_id_fk": { + "name": "team_members_invited_by_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_id": { + "name": "manager_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "teams_owner_id_users_id_fk": { + "name": "teams_owner_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "teams_manager_id_users_id_fk": { + "name": "teams_manager_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "manager_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_slug_unique": { + "name": "teams_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tickets": { + "name": "tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tickets_status_idx": { + "name": "tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform_project_id": { + "name": "platform_project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "workspaces_platform_project_id_platform_projects_id_fk": { + "name": "workspaces_platform_project_id_platform_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "platform_projects", + "columnsFrom": [ + "platform_project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspaces_platform_project_slug_uniq": { + "name": "workspaces_platform_project_slug_uniq", + "nullsNotDistinct": false, + "columns": [ + "platform_project_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.backlog_status": { + "name": "backlog_status", + "schema": "public", + "values": [ + "ready", + "claimed", + "blocked", + "done" + ] + }, + "public.grant_status": { + "name": "grant_status", + "schema": "public", + "values": [ + "pending", + "active", + "revoked", + "expired" + ] + }, + "public.hierarchy_outbox_status": { + "name": "hierarchy_outbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "delivered" + ] + }, + "public.interaction_handoff_status": { + "name": "interaction_handoff_status", + "schema": "public", + "values": [ + "pending", + "accepted" + ] + }, + "public.interaction_inbox_status": { + "name": "interaction_inbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "processed" + ] + }, + "public.interaction_outbox_status": { + "name": "interaction_outbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "delivered" + ] + }, + "public.peer_state": { + "name": "peer_state", + "schema": "public", + "values": [ + "pending", + "active", + "suspended", + "revoked" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index c09e4691..59edceb3 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/packages/db/src/hierarchy-schema.witness.test.ts b/packages/db/src/hierarchy-schema.witness.test.ts index ff226e84..65a36c8d 100644 --- a/packages/db/src/hierarchy-schema.witness.test.ts +++ b/packages/db/src/hierarchy-schema.witness.test.ts @@ -44,7 +44,7 @@ type AnyDb = { /** Column allowlist — the exact declared sets of §2/§3. Nothing else. */ const COLUMN_ALLOWLIST: Record = { - 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, ); diff --git a/packages/db/src/hierarchy-writer-coverage.test.ts b/packages/db/src/hierarchy-writer-coverage.test.ts index 97f1e649..534df9e4 100644 --- a/packages/db/src/hierarchy-writer-coverage.test.ts +++ b/packages/db/src/hierarchy-writer-coverage.test.ts @@ -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 diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 78bab662..5e59d742 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -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',