From af66cdc60b5238263faed8f93defb0059f9f1469 Mon Sep 17 00:00:00 2001 From: mroops0111 Date: Tue, 4 Aug 2026 23:48:03 +0800 Subject: [PATCH 1/4] feat(server): reactor acts as a service account so its proposals are owned and visible An autonomous reactor run has no human caller, so its agent's API calls had no identity: under local trust they fell back to local-user, under authentication they were rejected (401). Neither yielded a reviewable owner. Introduce built-in service accounts (reactor, system) in @braidhq/schema. The reactor runs as the reactor service account by minting a short-lived session and threading it as the run's caller token, so its proposals are owned by 'reactor'. The auth middleware honours a valid Bearer session even under local trust (Studio sends none, so nothing is shadowed), and the accounts are seeded as admins so they clear the workspace-access gate via the existing admin-to-owner rule. Pending visibility now treats any service account owner as always-visible (isServiceAccount), generalising the prior owner === 'system' special-case, so a sole workspace owner can review autonomous proposals. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/src/application/ReactorService.ts | 16 +++++-- .../InMemoryClarificationRepository.ts | 3 +- .../in-memory/InMemoryProposalRepository.ts | 3 +- .../test/application/ReactorService.test.ts | 27 +++++++++-- packages/schema/src/identity.ts | 30 +++++++++++++ packages/schema/src/index.ts | 1 + packages/schema/test/identity.test.ts | 22 +++++++++ packages/server/src/authMode.ts | 21 ++++++++- packages/server/src/composeApp.ts | 4 ++ packages/server/src/composeFsApp.ts | 9 +++- .../hitl/FsClarificationRepository.ts | 4 +- .../hitl/FsProposalRepository.ts | 4 +- packages/server/src/middleware/auth.ts | 10 +++++ packages/server/test/middleware/auth.test.ts | 45 +++++++++++++++++++ 14 files changed, 184 insertions(+), 15 deletions(-) create mode 100644 packages/schema/src/identity.ts create mode 100644 packages/schema/test/identity.test.ts create mode 100644 packages/server/test/middleware/auth.test.ts diff --git a/packages/core/src/application/ReactorService.ts b/packages/core/src/application/ReactorService.ts index dcd1dd2..83f86a7 100644 --- a/packages/core/src/application/ReactorService.ts +++ b/packages/core/src/application/ReactorService.ts @@ -37,6 +37,13 @@ export interface ReactorServiceDeps { readonly skillRunner: SkillRunner readonly sourceUnitObservationService: SourceUnitObservationService readonly unitLister: UnitLister + /** + * Yields the caller token the reactor's autonomous skill runs act under. + * The reactor has no human caller, so it runs as the `reactor` service + * account, and its proposals are owned by it. Absent means run tokenless, + * which falls back to the deployment's default principal. + */ + readonly reactorToken?: () => Promise readonly digest: SourceUnitDigest readonly reactorCycleRepository: ReactorCycleRepository readonly clock: Clock @@ -104,6 +111,8 @@ interface CycleContext { readonly workspace: Workspace readonly sourceId: SourceId readonly batchBinding: OntologyBatchBinding + // Token the per-unit and checkpoint skill runs act under, resolved once per cycle. + readonly callerToken?: string cycle: ReactorCycle } @@ -224,6 +233,7 @@ export class ReactorService { if (!batchBinding?.perUnit?.skillId) return undefined const startedAt = this.deps.clock.now() + const callerToken = await this.deps.reactorToken?.() const cycle: ReactorCycle = { id: newReactorCycleId(), workspaceId: workspace.id, @@ -232,7 +242,7 @@ export class ReactorService { status: 'dispatched', units: [], } - return { workspace, sourceId: event.sourceId, batchBinding, cycle } + return { workspace, sourceId: event.sourceId, batchBinding, cycle, ...(callerToken ? { callerToken } : {}) } } private async changedUnitPaths(context: CycleContext): Promise { @@ -270,7 +280,7 @@ export class ReactorService { let runId: SkillRunId try { const args = argsForPath(batchBinding.perUnit, path) - runId = await this.deps.skillRunner.start(workspace, batchBinding.perUnit.skillId, args) + runId = await this.deps.skillRunner.start(workspace, batchBinding.perUnit.skillId, args, context.callerToken ? { callerToken: context.callerToken } : undefined) context.cycle = updateUnit(cycle, index, { status: 'running', skillRunId: runId, startedAt: this.deps.clock.now() }) await this.persistAndEmit(context, this.unitStartedEvent(context, index, runId, total)) await waitForCompletion(this.deps.skillRunner, runId) @@ -313,7 +323,7 @@ export class ReactorService { const { workspace } = context const startedAt = this.deps.clock.now() try { - const runId = await this.deps.skillRunner.start(workspace, skillId, '') + const runId = await this.deps.skillRunner.start(workspace, skillId, '', context.callerToken ? { callerToken: context.callerToken } : undefined) context.cycle = updateCheckpoint(context.cycle, { skillId, status: 'running', skillRunId: runId, startedAt }) await this.persistAndEmit(context, this.checkpointStartedEvent(context, skillId, runId)) await waitForCompletion(this.deps.skillRunner, runId) diff --git a/packages/core/src/infrastructure/in-memory/InMemoryClarificationRepository.ts b/packages/core/src/infrastructure/in-memory/InMemoryClarificationRepository.ts index 5991569..aa7fd72 100644 --- a/packages/core/src/infrastructure/in-memory/InMemoryClarificationRepository.ts +++ b/packages/core/src/infrastructure/in-memory/InMemoryClarificationRepository.ts @@ -1,6 +1,7 @@ import type { ClarificationFilter, ClarificationId } from '@braidhq/schema' import type { Clarification } from '../../domain/hitl/Clarification.js' import type { ClarificationRepository } from '../../domain/hitl/ClarificationRepository.js' +import { isServiceAccount } from '@braidhq/schema' import { paginate } from '../../domain/paginate.js' import { InMemoryKeyedStore } from './InMemoryKeyedStore.js' @@ -20,7 +21,7 @@ export class InMemoryClarificationRepository implements ClarificationRepository if (filter?.viewerId !== undefined) { const viewerId = filter.viewerId tickets = tickets.filter(ticket => - ticket.status !== 'pending' || ticket.owner === 'system' || ticket.owner === viewerId, + ticket.status !== 'pending' || isServiceAccount(ticket.owner) || ticket.owner === viewerId, ) } return paginate(tickets, filter?.limit, filter?.offset) diff --git a/packages/core/src/infrastructure/in-memory/InMemoryProposalRepository.ts b/packages/core/src/infrastructure/in-memory/InMemoryProposalRepository.ts index 57a6ac0..49569c8 100644 --- a/packages/core/src/infrastructure/in-memory/InMemoryProposalRepository.ts +++ b/packages/core/src/infrastructure/in-memory/InMemoryProposalRepository.ts @@ -1,6 +1,7 @@ import type { ProposalFilter, ProposalId } from '@braidhq/schema' import type { Proposal } from '../../domain/hitl/Proposal.js' import type { ProposalRepository } from '../../domain/hitl/ProposalRepository.js' +import { isServiceAccount } from '@braidhq/schema' import { paginate } from '../../domain/paginate.js' import { InMemoryKeyedStore } from './InMemoryKeyedStore.js' @@ -24,7 +25,7 @@ export class InMemoryProposalRepository implements ProposalRepository { if (filter?.viewerId !== undefined) { const viewerId = filter.viewerId proposals = proposals.filter(proposal => - proposal.status !== 'pending' || proposal.owner === 'system' || proposal.owner === viewerId, + proposal.status !== 'pending' || isServiceAccount(proposal.owner) || proposal.owner === viewerId, ) } return paginate(proposals, filter?.limit, filter?.offset) diff --git a/packages/core/test/application/ReactorService.test.ts b/packages/core/test/application/ReactorService.test.ts index 1258c1a..97734b9 100644 --- a/packages/core/test/application/ReactorService.test.ts +++ b/packages/core/test/application/ReactorService.test.ts @@ -49,7 +49,7 @@ function secondarySource(id: string): SourceDescriptor { } class FakeSkillRunner implements SkillRunner { - readonly startCalls: Array<{ skillId: SkillId, args: string }> = [] + readonly startCalls: Array<{ skillId: SkillId, args: string, callerToken?: string }> = [] private readonly listeners = new Map() // When set, start() defers completion until flushOne fires it. controlled = false @@ -57,9 +57,9 @@ class FakeSkillRunner implements SkillRunner { private readonly pending: Array<() => void> = [] exitCodes: number[] = [] - async start(_workspace: unknown, skillId: SkillId, args: string): Promise { + async start(_workspace: unknown, skillId: SkillId, args: string, options?: { callerToken?: string }): Promise { const runId = `r-${this.startCalls.length}` as SkillRunId - this.startCalls.push({ skillId, args }) + this.startCalls.push({ skillId, args, ...(options?.callerToken ? { callerToken: options.callerToken } : {}) }) const code = this.exitCodes.shift() ?? 0 const fire = () => { const listener = this.listeners.get(runId) @@ -109,6 +109,7 @@ async function setup(opts: { hasPerUnit?: boolean hasCheckpoint?: boolean maxRunsPerHour?: number + reactorToken?: string } = {}) { resetTestIds() const workspaceRepo = new InMemoryWorkspaceRepository() @@ -200,6 +201,7 @@ async function setup(opts: { skillRunner, sourceUnitObservationService, unitLister, + ...(opts.reactorToken ? { reactorToken: async () => opts.reactorToken } : {}), digest, reactorCycleRepository, workspaceLock: new WorkspaceLock(), @@ -240,6 +242,25 @@ async function tick(ms = 20): Promise { } describe('ReactorService', () => { + it('runs its skills under the reactor caller token, so proposals are owned by it', async () => { + const { workspace, eventBus, skillRunner } = await setup({ hasCheckpoint: true, reactorToken: 'reactor-token' }) + emitSync(eventBus, workspace.id, 'issues') + await tick(100) + + // Every dispatched run, per-unit and checkpoint, carries the token. + expect(skillRunner.startCalls.length).toBeGreaterThan(0) + expect(skillRunner.startCalls.every(c => c.callerToken === 'reactor-token')).toBe(true) + }) + + it('runs tokenless when no reactor token is provided', async () => { + const { workspace, eventBus, skillRunner } = await setup() + emitSync(eventBus, workspace.id, 'issues') + await tick(100) + + expect(skillRunner.startCalls.length).toBeGreaterThan(0) + expect(skillRunner.startCalls.every(c => c.callerToken === undefined)).toBe(true) + }) + it('dispatches one per-unit run for each of three new units, then one checkpoint', async () => { const { workspace, eventBus, skillRunner, captured } = await setup({ hasCheckpoint: true }) emitSync(eventBus, workspace.id, 'issues') diff --git a/packages/schema/src/identity.ts b/packages/schema/src/identity.ts new file mode 100644 index 0000000..183ea8f --- /dev/null +++ b/packages/schema/src/identity.ts @@ -0,0 +1,30 @@ +import { UserId } from './common.js' + +/** A built-in non-human principal that an autonomous server component acts as. */ +export interface ServiceAccount { + readonly id: UserId + readonly displayName: string +} + +export const REACTOR_USER_ID = UserId.parse('reactor') +export const SYSTEM_USER_ID = UserId.parse('system') + +/** + * Built-in service accounts. An autonomous component acts as one of these, + * carrying its session token so its API calls authenticate as a known, + * non-human principal rather than an anonymous caller. Seeding, auth, + * workspace access, and pending-item visibility all read this set, so adding + * a new autonomous component here needs no change to any of them. + * `system` is the generic autonomous principal, `reactor` the source-sync one. + */ +export const SERVICE_ACCOUNTS: readonly ServiceAccount[] = [ + { id: REACTOR_USER_ID, displayName: 'Reactor' }, + { id: SYSTEM_USER_ID, displayName: 'System' }, +] + +const SERVICE_ACCOUNT_IDS = new Set(SERVICE_ACCOUNTS.map(account => account.id)) + +/** True when the id belongs to a built-in service account, not a human user. */ +export function isServiceAccount(id: string): boolean { + return SERVICE_ACCOUNT_IDS.has(id) +} diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index ed88304..c2c67e2 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -10,6 +10,7 @@ export * from './error.js' export * from './event.js' export * from './graph-validation.js' export * from './history.js' +export * from './identity.js' export * from './mcp.js' export * from './model.js' export * from './ontology.js' diff --git a/packages/schema/test/identity.test.ts b/packages/schema/test/identity.test.ts new file mode 100644 index 0000000..8eac8ae --- /dev/null +++ b/packages/schema/test/identity.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { isServiceAccount, REACTOR_USER_ID, SERVICE_ACCOUNTS } from '../src/identity.js' + +describe('service accounts', () => { + it('recognises the reactor as a service account', () => { + expect(isServiceAccount(REACTOR_USER_ID)).toBe(true) + expect(isServiceAccount('reactor')).toBe(true) + }) + + it('recognises the generic system principal', () => { + expect(isServiceAccount('system')).toBe(true) + }) + + it('does not treat a human user id as a service account', () => { + expect(isServiceAccount('usr-7176d698')).toBe(false) + expect(isServiceAccount('local-user')).toBe(false) + }) + + it('declares a display name for every service account', () => { + expect(SERVICE_ACCOUNTS.every(account => account.displayName.length > 0)).toBe(true) + }) +}) diff --git a/packages/server/src/authMode.ts b/packages/server/src/authMode.ts index b489441..e898a3f 100644 --- a/packages/server/src/authMode.ts +++ b/packages/server/src/authMode.ts @@ -3,7 +3,7 @@ import type { AccessPolicy } from './infrastructure/auth/AccessPolicy.js' import type { UserRegistryFile } from './infrastructure/users/UserRegistryFile.js' import { userInfo } from 'node:os' import process from 'node:process' -import { UserId as UserIdSchema } from '@braidhq/schema' +import { SERVICE_ACCOUNTS, UserId as UserIdSchema } from '@braidhq/schema' export const LOCAL_USER_ID = UserIdSchema.parse('local-user') @@ -68,6 +68,25 @@ export const authenticated: AuthMode = { }, } +/** + * Seed the built-in service accounts, idempotently, in every mode. + * They act as admins so an autonomous component's calls clear the same + * membership gate a workspace owner does, via the admin-to-owner rule, + * with no service-account special-case in the access middleware. + */ +export async function provisionServiceAccounts({ userRegistry }: AuthContext): Promise { + for (const account of SERVICE_ACCOUNTS) { + if (await userRegistry.get(account.id)) + continue + await userRegistry.create({ + id: account.id, + displayName: account.displayName, + serverRole: 'admin', + createdAt: new Date().toISOString() as Timestamp, + }) + } +} + // Default display name from the OS account. // Some sandboxed environments throw on userInfo(), so fall through. function osUsername(): string { diff --git a/packages/server/src/composeApp.ts b/packages/server/src/composeApp.ts index 5ecc9cc..ba6a0bf 100644 --- a/packages/server/src/composeApp.ts +++ b/packages/server/src/composeApp.ts @@ -156,6 +156,9 @@ export interface ComposeOptions { // to a no-op stub that throws, fine unless a batch or reactor runs. unitLister?: UnitLister sourceUnitDigest?: SourceUnitDigest + // Yields the caller token the reactor's autonomous runs act under. + // Absent means the reactor runs tokenless, falling back to the default principal. + reactorToken?: () => Promise // Skills. skillRegistry?: SkillRegistry @@ -263,6 +266,7 @@ export function composeApp(options: ComposeOptions = {}): AppDependencies { skillRunner: options.skillRunner, sourceUnitObservationService, unitLister: options.unitLister, + ...(options.reactorToken ? { reactorToken: options.reactorToken } : {}), digest: sourceUnitDigest, reactorCycleRepository, workspaceLock, diff --git a/packages/server/src/composeFsApp.ts b/packages/server/src/composeFsApp.ts index 4e4f7d4..c049d0e 100644 --- a/packages/server/src/composeFsApp.ts +++ b/packages/server/src/composeFsApp.ts @@ -16,12 +16,12 @@ import { } from '@braidhq/core' import { InMemoryWorkspaceEventBus } from '@braidhq/core/in-memory' import { dddOntology } from '@braidhq/ontology-ddd' -import { AgentId, AgentKind, StorageKind as StorageKindSchema } from '@braidhq/schema' +import { AgentId, AgentKind, REACTOR_USER_ID, StorageKind as StorageKindSchema } from '@braidhq/schema' import { createGoogleDriveLoader } from '@braidhq/source-loader-gdrive' import { gitLoader } from '@braidhq/source-loader-git' import { createGithubLoader } from '@braidhq/source-loader-github' import { kuzuStoragePlugin } from '@braidhq/storage-kuzu' -import { authenticated, localTrust } from './authMode.js' +import { authenticated, localTrust, provisionServiceAccounts } from './authMode.js' import { composeApp } from './composeApp.js' import { parseBoolEnv } from './infrastructure/_shared/env.js' import { AccessPolicy } from './infrastructure/auth/AccessPolicy.js' @@ -144,6 +144,8 @@ export async function composeFsApp(options: ComposeFsOptions = {}): Promise> => { const workspaces = await workspaceRepository.list() @@ -325,6 +327,9 @@ export async function composeFsApp(options: ComposeFsOptions = {}): Promise listUnitItems(workspace, unitBearingRolesOf(pluginRegistry, workspace)), + // The reactor has no human caller, so it acts as the `reactor` service + // account, minting a short-lived session so its API calls authenticate. + reactorToken: async () => (await sessionStore.issue(REACTOR_USER_ID, { ttlSeconds: 3600 })).token, sourceUnitObservationRepository: new FsSourceUnitObservationRepository({ workspaceRoots }), reactorCycleRepository: new FsReactorCycleRepository({ workspaceRoots }), sourceUnitDigest: new FsSourceUnitDigest(), diff --git a/packages/server/src/infrastructure/hitl/FsClarificationRepository.ts b/packages/server/src/infrastructure/hitl/FsClarificationRepository.ts index 58038ef..43101cb 100644 --- a/packages/server/src/infrastructure/hitl/FsClarificationRepository.ts +++ b/packages/server/src/infrastructure/hitl/FsClarificationRepository.ts @@ -5,7 +5,7 @@ import type { WorkspaceId, } from '@braidhq/schema' import { Clarification, type ClarificationRepository, paginate } from '@braidhq/core' -import { Clarification as ClarificationSchema } from '@braidhq/schema' +import { Clarification as ClarificationSchema, isServiceAccount } from '@braidhq/schema' import { clarificationDir, CLARIFY_STATUSES } from '../_shared/paths.js' import { StatusedJsonStore } from './StatusedJsonStore.js' @@ -42,7 +42,7 @@ export class FsClarificationRepository implements ClarificationRepository { if (filter?.viewerId !== undefined) { const viewerId = filter.viewerId tickets = tickets.filter(ticket => - ticket.status !== 'pending' || ticket.owner === 'system' || ticket.owner === viewerId, + ticket.status !== 'pending' || isServiceAccount(ticket.owner) || ticket.owner === viewerId, ) } return paginate(tickets, filter?.limit, filter?.offset) diff --git a/packages/server/src/infrastructure/hitl/FsProposalRepository.ts b/packages/server/src/infrastructure/hitl/FsProposalRepository.ts index c529dd5..8cc82f3 100644 --- a/packages/server/src/infrastructure/hitl/FsProposalRepository.ts +++ b/packages/server/src/infrastructure/hitl/FsProposalRepository.ts @@ -1,6 +1,6 @@ import type { AbsolutePath, ProposalFilter, ProposalId, WorkspaceId } from '@braidhq/schema' import { paginate, Proposal, type ProposalRepository } from '@braidhq/core' -import { Proposal as ProposalSchema } from '@braidhq/schema' +import { isServiceAccount, Proposal as ProposalSchema } from '@braidhq/schema' import { PROPOSAL_STATUSES, proposalsDir } from '../_shared/paths.js' import { StatusedJsonStore } from './StatusedJsonStore.js' @@ -42,7 +42,7 @@ export class FsProposalRepository implements ProposalRepository { if (filter?.viewerId !== undefined) { const viewerId = filter.viewerId proposals = proposals.filter(proposal => - proposal.status !== 'pending' || proposal.owner === 'system' || proposal.owner === viewerId, + proposal.status !== 'pending' || isServiceAccount(proposal.owner) || proposal.owner === viewerId, ) } return paginate(proposals, filter?.limit, filter?.offset) diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts index 2628d7b..0300588 100644 --- a/packages/server/src/middleware/auth.ts +++ b/packages/server/src/middleware/auth.ts @@ -77,6 +77,16 @@ export function authMiddleware(options: AuthMiddlewareOptions): MiddlewareHandle const path = context.req.path if (!options.requireAuth) { + // A presented, valid Bearer session wins even under local trust, so an + // internal caller like the reactor is identified as its service account. + // Studio under local trust sends no Bearer, so this never shadows it. + const token = extractBearerToken(context) + const session = token ? await options.sessionStore?.resolve(token) : undefined + if (session) { + context.set('userId', session.userId) + await next() + return undefined + } const header = context.req.header(USER_HEADER) const fromHeader = header && header.length > 0 ? UserId.parse(header) : null const resolved = fromHeader ?? options.defaultPrincipal diff --git a/packages/server/test/middleware/auth.test.ts b/packages/server/test/middleware/auth.test.ts new file mode 100644 index 0000000..7b34f86 --- /dev/null +++ b/packages/server/test/middleware/auth.test.ts @@ -0,0 +1,45 @@ +import type { UserId } from '@braidhq/schema' +import type { ResolvedSession, SessionStore } from '../../src/infrastructure/auth/SessionStore.js' +import { Hono } from 'hono' +import { describe, expect, it } from 'vitest' +import { authMiddleware, getUserId } from '../../src/middleware/auth.js' + +function fakeSessionStore(byToken: Record): SessionStore { + return { + issue: async () => { throw new Error('not used') }, + resolve: async (token: string): Promise => + byToken[token] ? { userId: byToken[token]! } : null, + revoke: async () => {}, + revokeAllForUser: async () => {}, + } +} + +function appWith(store: SessionStore, requireAuth: boolean, defaultPrincipal: UserId | null): Hono { + const app = new Hono() + app.use('*', authMiddleware({ sessionStore: store, requireAuth, defaultPrincipal })) + app.get('/who', c => c.json({ userId: getUserId(c) ?? null })) + return app +} + +describe('authMiddleware', () => { + it('under local trust, a valid Bearer session wins over the default principal', async () => { + const store = fakeSessionStore({ 'reactor-token': 'reactor' as UserId }) + const app = appWith(store, false, 'local-user' as UserId) + const res = await app.request('/who', { headers: { Authorization: 'Bearer reactor-token' } }) + expect(await res.json()).toEqual({ userId: 'reactor' }) + }) + + it('under local trust with no Bearer, falls back to the default principal', async () => { + const store = fakeSessionStore({}) + const app = appWith(store, false, 'local-user' as UserId) + const res = await app.request('/who') + expect(await res.json()).toEqual({ userId: 'local-user' }) + }) + + it('under local trust, an unresolvable Bearer falls back to the default principal', async () => { + const store = fakeSessionStore({}) + const app = appWith(store, false, 'local-user' as UserId) + const res = await app.request('/who', { headers: { Authorization: 'Bearer bogus' } }) + expect(await res.json()).toEqual({ userId: 'local-user' }) + }) +}) From df27e3b8ef374a9095906c02b8324adf7cf85bfc Mon Sep 17 00:00:00 2001 From: mroops0111 Date: Wed, 5 Aug 2026 02:17:24 +0800 Subject: [PATCH 2/4] refactor(hitl): service-account pending is visible to workspace owners only Autonomous (service-account) proposals and clarifications can only be applied by an owner, so restrict their pending visibility to owners too: the list routes pass includeServiceAccounts only when the viewer is an owner (or when there is no viewer context, an open in-memory composition). Also prune already-expired sessions on issue, so the reactor's per-cycle short-lived tokens do not accumulate in the session file. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../InMemoryClarificationRepository.ts | 3 ++- .../in-memory/InMemoryProposalRepository.ts | 3 ++- packages/schema/src/clarification.ts | 2 ++ packages/schema/src/proposal.ts | 2 ++ .../src/infrastructure/auth/SessionStore.ts | 7 ++++-- .../hitl/FsClarificationRepository.ts | 3 ++- .../hitl/FsProposalRepository.ts | 3 ++- packages/server/src/routes/clarifications.ts | 5 +++- packages/server/src/routes/proposals.ts | 5 +++- .../infrastructure/auth/SessionStore.test.ts | 8 +++++++ .../hitl/FsProposalRepository.test.ts | 24 ++++++++++++++++--- 11 files changed, 54 insertions(+), 11 deletions(-) diff --git a/packages/core/src/infrastructure/in-memory/InMemoryClarificationRepository.ts b/packages/core/src/infrastructure/in-memory/InMemoryClarificationRepository.ts index aa7fd72..11f0bd5 100644 --- a/packages/core/src/infrastructure/in-memory/InMemoryClarificationRepository.ts +++ b/packages/core/src/infrastructure/in-memory/InMemoryClarificationRepository.ts @@ -20,8 +20,9 @@ export class InMemoryClarificationRepository implements ClarificationRepository } if (filter?.viewerId !== undefined) { const viewerId = filter.viewerId + const includeServiceAccounts = filter.includeServiceAccounts ?? false tickets = tickets.filter(ticket => - ticket.status !== 'pending' || isServiceAccount(ticket.owner) || ticket.owner === viewerId, + ticket.status !== 'pending' || (includeServiceAccounts && isServiceAccount(ticket.owner)) || ticket.owner === viewerId, ) } return paginate(tickets, filter?.limit, filter?.offset) diff --git a/packages/core/src/infrastructure/in-memory/InMemoryProposalRepository.ts b/packages/core/src/infrastructure/in-memory/InMemoryProposalRepository.ts index 49569c8..084760e 100644 --- a/packages/core/src/infrastructure/in-memory/InMemoryProposalRepository.ts +++ b/packages/core/src/infrastructure/in-memory/InMemoryProposalRepository.ts @@ -24,8 +24,9 @@ export class InMemoryProposalRepository implements ProposalRepository { } if (filter?.viewerId !== undefined) { const viewerId = filter.viewerId + const includeServiceAccounts = filter.includeServiceAccounts ?? false proposals = proposals.filter(proposal => - proposal.status !== 'pending' || isServiceAccount(proposal.owner) || proposal.owner === viewerId, + proposal.status !== 'pending' || (includeServiceAccounts && isServiceAccount(proposal.owner)) || proposal.owner === viewerId, ) } return paginate(proposals, filter?.limit, filter?.offset) diff --git a/packages/schema/src/clarification.ts b/packages/schema/src/clarification.ts index 0b32594..4b85dfd 100644 --- a/packages/schema/src/clarification.ts +++ b/packages/schema/src/clarification.ts @@ -87,5 +87,7 @@ export const ClarificationFilter = z.object({ offset: z.number().int().nonnegative().optional(), // When set, hides others' pending tickets. Non-pending stay visible, absent shows all. viewerId: UserId.optional(), + // Whether the viewer also sees service-account-owned pending. Owners only. + includeServiceAccounts: z.boolean().optional(), }) export type ClarificationFilter = z.infer diff --git a/packages/schema/src/proposal.ts b/packages/schema/src/proposal.ts index ec25c07..984a5fc 100644 --- a/packages/schema/src/proposal.ts +++ b/packages/schema/src/proposal.ts @@ -76,5 +76,7 @@ export const ProposalFilter = z.object({ offset: z.number().int().nonnegative().optional(), // When set, hides others' pending proposals. Non-pending stay visible, absent shows all. viewerId: UserId.optional(), + // Whether the viewer also sees service-account-owned pending. Owners only. + includeServiceAccounts: z.boolean().optional(), }) export type ProposalFilter = z.infer diff --git a/packages/server/src/infrastructure/auth/SessionStore.ts b/packages/server/src/infrastructure/auth/SessionStore.ts index 858beb5..81ef077 100644 --- a/packages/server/src/infrastructure/auth/SessionStore.ts +++ b/packages/server/src/infrastructure/auth/SessionStore.ts @@ -64,10 +64,13 @@ export class FsSessionStore implements SessionStore { const content = await this.read() const token = randomBytes(32).toString('base64url') const tokenHash = sha256Hex(token) - const createdAt = new Date().toISOString() + const now = Date.now() + const createdAt = new Date(now).toISOString() const expiresAt = options.ttlSeconds - ? new Date(Date.now() + options.ttlSeconds * 1000).toISOString() + ? new Date(now + options.ttlSeconds * 1000).toISOString() : undefined + // Drop already-expired rows so short-lived service tokens do not accumulate. + content.sessions = content.sessions.filter(s => !s.expiresAt || Date.parse(s.expiresAt) >= now) content.sessions.push({ tokenHash, userId, createdAt, ...(expiresAt ? { expiresAt } : {}) }) await this.write(content) return { token, userId, ...(expiresAt ? { expiresAt } : {}) } diff --git a/packages/server/src/infrastructure/hitl/FsClarificationRepository.ts b/packages/server/src/infrastructure/hitl/FsClarificationRepository.ts index 43101cb..7d6362d 100644 --- a/packages/server/src/infrastructure/hitl/FsClarificationRepository.ts +++ b/packages/server/src/infrastructure/hitl/FsClarificationRepository.ts @@ -41,8 +41,9 @@ export class FsClarificationRepository implements ClarificationRepository { // Answered, applied, and skipped tickets stay workspace-shared. if (filter?.viewerId !== undefined) { const viewerId = filter.viewerId + const includeServiceAccounts = filter.includeServiceAccounts ?? false tickets = tickets.filter(ticket => - ticket.status !== 'pending' || isServiceAccount(ticket.owner) || ticket.owner === viewerId, + ticket.status !== 'pending' || (includeServiceAccounts && isServiceAccount(ticket.owner)) || ticket.owner === viewerId, ) } return paginate(tickets, filter?.limit, filter?.offset) diff --git a/packages/server/src/infrastructure/hitl/FsProposalRepository.ts b/packages/server/src/infrastructure/hitl/FsProposalRepository.ts index 8cc82f3..29406e1 100644 --- a/packages/server/src/infrastructure/hitl/FsProposalRepository.ts +++ b/packages/server/src/infrastructure/hitl/FsProposalRepository.ts @@ -41,8 +41,9 @@ export class FsProposalRepository implements ProposalRepository { // Absent viewerId means no filter, for Owner Show All and legacy callers. if (filter?.viewerId !== undefined) { const viewerId = filter.viewerId + const includeServiceAccounts = filter.includeServiceAccounts ?? false proposals = proposals.filter(proposal => - proposal.status !== 'pending' || isServiceAccount(proposal.owner) || proposal.owner === viewerId, + proposal.status !== 'pending' || (includeServiceAccounts && isServiceAccount(proposal.owner)) || proposal.owner === viewerId, ) } return paginate(proposals, filter?.limit, filter?.offset) diff --git a/packages/server/src/routes/clarifications.ts b/packages/server/src/routes/clarifications.ts index 417faa7..508eb35 100644 --- a/packages/server/src/routes/clarifications.ts +++ b/packages/server/src/routes/clarifications.ts @@ -209,12 +209,15 @@ export function createClarificationRouter(deps: ClarificationRouterDeps): OpenAP const statuses = status === undefined ? undefined : Array.isArray(status) ? status : [status] const viewer = getViewerContext(context) const viewerId = (showAll && viewer?.effectiveRole === 'owner') ? undefined : getUserId(context) + // Only owners see service-account (autonomous) pending, since only they can act on it. + // An absent viewer is an open composition (in-memory, headless), which stays open. + const includeServiceAccounts = !viewer || viewer.effectiveRole === 'owner' const tickets = await deps.clarificationRepository.list({ workspaceId, statuses, limit, offset, - ...(viewerId ? { viewerId } : {}), + ...(viewerId ? { viewerId, includeServiceAccounts } : {}), }) return context.json({ items: tickets.map(ticket => ticket.toData()) }, 200) }) diff --git a/packages/server/src/routes/proposals.ts b/packages/server/src/routes/proposals.ts index 0544c64..afbbece 100644 --- a/packages/server/src/routes/proposals.ts +++ b/packages/server/src/routes/proposals.ts @@ -184,12 +184,15 @@ export function createProposalsRouter(deps: ProposalsRouterDeps): OpenAPIHono { // whatever they send. const viewer = getViewerContext(context) const viewerId = (showAll && viewer?.effectiveRole === 'owner') ? undefined : getUserId(context) + // Only owners see service-account (autonomous) pending, since only they can apply it. + // An absent viewer is an open composition (in-memory, headless), which stays open. + const includeServiceAccounts = !viewer || viewer.effectiveRole === 'owner' const proposals = await deps.proposalRepository.list({ workspaceId, statuses, limit, offset, - ...(viewerId ? { viewerId } : {}), + ...(viewerId ? { viewerId, includeServiceAccounts } : {}), }) return context.json({ items: proposals.map(proposal => proposal.toData()) }, 200) }) diff --git a/packages/server/test/infrastructure/auth/SessionStore.test.ts b/packages/server/test/infrastructure/auth/SessionStore.test.ts index 6369f22..e016970 100644 --- a/packages/server/test/infrastructure/auth/SessionStore.test.ts +++ b/packages/server/test/infrastructure/auth/SessionStore.test.ts @@ -70,6 +70,14 @@ describe('FsSessionStore', () => { expect(await store.resolve(token)).toBeNull() }) + it('issue prunes already-expired sessions so short-lived tokens do not accumulate', async () => { + await store.issue(alice, { ttlSeconds: -1 }) // stamps expiresAt in the past + await store.issue(bob) // triggers the prune, then adds bob's own row + + const content = JSON.parse(await readFile(filePath, 'utf-8')) as { sessions: Array<{ userId: string }> } + expect(content.sessions.map(s => s.userId)).toEqual([bob]) + }) + it('revoke invalidates a single token', async () => { const { token } = await store.issue(alice) await store.revoke(token) diff --git a/packages/server/test/infrastructure/hitl/FsProposalRepository.test.ts b/packages/server/test/infrastructure/hitl/FsProposalRepository.test.ts index 8132cb5..e1fda4f 100644 --- a/packages/server/test/infrastructure/hitl/FsProposalRepository.test.ts +++ b/packages/server/test/infrastructure/hitl/FsProposalRepository.test.ts @@ -1,4 +1,4 @@ -import type { AbsolutePath, ProposalId, SkillId, WorkspaceId } from '@braidhq/schema' +import type { AbsolutePath, ProposalId, SkillId, UserId, WorkspaceId } from '@braidhq/schema' import { mkdtemp, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -12,7 +12,7 @@ async function makeWorkspaceRoot(): Promise { return await mkdtemp(join(tmpdir(), 'braid-fs-prop-')) as AbsolutePath } -function makeProposal(id: string, workspaceId: WorkspaceId, status: 'pending' | 'applied' | 'rejected' = 'pending'): Proposal { +function makeProposal(id: string, workspaceId: WorkspaceId, status: 'pending' | 'applied' | 'rejected' = 'pending', owner: UserId | 'system' = 'system'): Proposal { return new Proposal({ id: id as ProposalId, workspaceId, @@ -21,7 +21,7 @@ function makeProposal(id: string, workspaceId: WorkspaceId, status: 'pending' | generatedBy: 'extract' as SkillId, generatedAt: isoTimestamp, rationale: 'r', - owner: 'system', + owner, }) } @@ -65,6 +65,24 @@ describe('FsProposalRepository', () => { expect(pending.map(p => p.id)).toEqual(['p-1']) }) + it('shows service-account pending only when includeServiceAccounts is set (owner view)', async () => { + const root = await makeWorkspaceRoot() + const workspaceId = 'ws-1' as WorkspaceId + const repository = new FsProposalRepository({ + workspaceRoots: async () => new Map([[workspaceId, root]]), + }) + const alice = 'alice' as UserId + await repository.save(makeProposal('p-mine', workspaceId, 'pending', alice)) + await repository.save(makeProposal('p-reactor', workspaceId, 'pending', 'reactor' as UserId)) + await repository.save(makeProposal('p-bob', workspaceId, 'pending', 'bob' as UserId)) + + const personal = await repository.list({ workspaceId, viewerId: alice }) + expect(personal.map(p => p.id).sort()).toEqual(['p-mine']) + + const asOwner = await repository.list({ workspaceId, viewerId: alice, includeServiceAccounts: true }) + expect(asOwner.map(p => p.id).sort()).toEqual(['p-mine', 'p-reactor']) + }) + it('load throws NotFoundError when proposal missing', async () => { const root = await makeWorkspaceRoot() const workspaceId = 'ws-1' as WorkspaceId From b11a9d32f3271e83388a42024c4da38877348168 Mon Sep 17 00:00:00 2001 From: mroops0111 Date: Wed, 5 Aug 2026 02:34:50 +0800 Subject: [PATCH 3/4] style: wrap comments at clause boundaries per house style Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/application/ReactorService.ts | 6 +++--- packages/schema/src/identity.ts | 4 ++-- packages/server/src/authMode.ts | 5 ++--- packages/server/src/composeFsApp.ts | 4 ++-- packages/server/src/middleware/auth.ts | 4 ++-- packages/server/src/routes/clarifications.ts | 4 ++-- packages/server/src/routes/proposals.ts | 4 ++-- 7 files changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/core/src/application/ReactorService.ts b/packages/core/src/application/ReactorService.ts index 83f86a7..3dac15e 100644 --- a/packages/core/src/application/ReactorService.ts +++ b/packages/core/src/application/ReactorService.ts @@ -39,8 +39,8 @@ export interface ReactorServiceDeps { readonly unitLister: UnitLister /** * Yields the caller token the reactor's autonomous skill runs act under. - * The reactor has no human caller, so it runs as the `reactor` service - * account, and its proposals are owned by it. Absent means run tokenless, + * The reactor has no human caller, so it runs as the `reactor` service account, + * and its proposals are owned by it. Absent means run tokenless, * which falls back to the deployment's default principal. */ readonly reactorToken?: () => Promise @@ -111,7 +111,7 @@ interface CycleContext { readonly workspace: Workspace readonly sourceId: SourceId readonly batchBinding: OntologyBatchBinding - // Token the per-unit and checkpoint skill runs act under, resolved once per cycle. + // Token the skill runs act under, resolved once per cycle. readonly callerToken?: string cycle: ReactorCycle } diff --git a/packages/schema/src/identity.ts b/packages/schema/src/identity.ts index 183ea8f..631eb44 100644 --- a/packages/schema/src/identity.ts +++ b/packages/schema/src/identity.ts @@ -13,8 +13,8 @@ export const SYSTEM_USER_ID = UserId.parse('system') * Built-in service accounts. An autonomous component acts as one of these, * carrying its session token so its API calls authenticate as a known, * non-human principal rather than an anonymous caller. Seeding, auth, - * workspace access, and pending-item visibility all read this set, so adding - * a new autonomous component here needs no change to any of them. + * workspace access, and pending-item visibility all read this set, + * so adding a new autonomous component here needs no change to any of them. * `system` is the generic autonomous principal, `reactor` the source-sync one. */ export const SERVICE_ACCOUNTS: readonly ServiceAccount[] = [ diff --git a/packages/server/src/authMode.ts b/packages/server/src/authMode.ts index e898a3f..80a022d 100644 --- a/packages/server/src/authMode.ts +++ b/packages/server/src/authMode.ts @@ -70,9 +70,8 @@ export const authenticated: AuthMode = { /** * Seed the built-in service accounts, idempotently, in every mode. - * They act as admins so an autonomous component's calls clear the same - * membership gate a workspace owner does, via the admin-to-owner rule, - * with no service-account special-case in the access middleware. + * They are admins, so an autonomous component's calls clear the workspace gate, + * via the admin-to-owner rule, and no service-account special-case is needed. */ export async function provisionServiceAccounts({ userRegistry }: AuthContext): Promise { for (const account of SERVICE_ACCOUNTS) { diff --git a/packages/server/src/composeFsApp.ts b/packages/server/src/composeFsApp.ts index c049d0e..bfe09a0 100644 --- a/packages/server/src/composeFsApp.ts +++ b/packages/server/src/composeFsApp.ts @@ -327,8 +327,8 @@ export async function composeFsApp(options: ComposeFsOptions = {}): Promise listUnitItems(workspace, unitBearingRolesOf(pluginRegistry, workspace)), - // The reactor has no human caller, so it acts as the `reactor` service - // account, minting a short-lived session so its API calls authenticate. + // The reactor has no human caller, so it acts as the `reactor` service account, + // minting a short-lived session so its API calls authenticate. reactorToken: async () => (await sessionStore.issue(REACTOR_USER_ID, { ttlSeconds: 3600 })).token, sourceUnitObservationRepository: new FsSourceUnitObservationRepository({ workspaceRoots }), reactorCycleRepository: new FsReactorCycleRepository({ workspaceRoots }), diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts index 0300588..134627e 100644 --- a/packages/server/src/middleware/auth.ts +++ b/packages/server/src/middleware/auth.ts @@ -77,8 +77,8 @@ export function authMiddleware(options: AuthMiddlewareOptions): MiddlewareHandle const path = context.req.path if (!options.requireAuth) { - // A presented, valid Bearer session wins even under local trust, so an - // internal caller like the reactor is identified as its service account. + // A presented, valid Bearer session wins even under local trust, + // so an internal caller like the reactor is identified as its service account. // Studio under local trust sends no Bearer, so this never shadows it. const token = extractBearerToken(context) const session = token ? await options.sessionStore?.resolve(token) : undefined diff --git a/packages/server/src/routes/clarifications.ts b/packages/server/src/routes/clarifications.ts index 508eb35..54e4de6 100644 --- a/packages/server/src/routes/clarifications.ts +++ b/packages/server/src/routes/clarifications.ts @@ -209,8 +209,8 @@ export function createClarificationRouter(deps: ClarificationRouterDeps): OpenAP const statuses = status === undefined ? undefined : Array.isArray(status) ? status : [status] const viewer = getViewerContext(context) const viewerId = (showAll && viewer?.effectiveRole === 'owner') ? undefined : getUserId(context) - // Only owners see service-account (autonomous) pending, since only they can act on it. - // An absent viewer is an open composition (in-memory, headless), which stays open. + // Only owners see service-account (autonomous) pending, only they can act on it. + // An absent viewer means an open composition (in-memory), which stays open. const includeServiceAccounts = !viewer || viewer.effectiveRole === 'owner' const tickets = await deps.clarificationRepository.list({ workspaceId, diff --git a/packages/server/src/routes/proposals.ts b/packages/server/src/routes/proposals.ts index afbbece..577b848 100644 --- a/packages/server/src/routes/proposals.ts +++ b/packages/server/src/routes/proposals.ts @@ -184,8 +184,8 @@ export function createProposalsRouter(deps: ProposalsRouterDeps): OpenAPIHono { // whatever they send. const viewer = getViewerContext(context) const viewerId = (showAll && viewer?.effectiveRole === 'owner') ? undefined : getUserId(context) - // Only owners see service-account (autonomous) pending, since only they can apply it. - // An absent viewer is an open composition (in-memory, headless), which stays open. + // Only owners see service-account (autonomous) pending, only they can apply it. + // An absent viewer means an open composition (in-memory), which stays open. const includeServiceAccounts = !viewer || viewer.effectiveRole === 'owner' const proposals = await deps.proposalRepository.list({ workspaceId, From f481899c31cce9b13954784a338fb18d78ed05ad Mon Sep 17 00:00:00 2001 From: mroops0111 Date: Wed, 5 Aug 2026 10:21:24 +0800 Subject: [PATCH 4/4] refactor(hitl): classify autonomous pending by owner kind, not a taxonomy Service-ness is now registry data (User.kind='service') snapshotted onto each proposal and ticket as ownerKind at submit time, via the user directory. The HITL filter checks ownerKind==='service' instead of the hardcoded SERVICE_ACCOUNTS set, so a new autonomous component needs no schema edit, it just seeds its own account with ensureServiceAccount. Drops schema/identity.ts and the read-time service-account id plumbing. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/application/HITLService.ts | 2 + .../core/src/domain/hitl/Clarification.ts | 1 + packages/core/src/domain/hitl/Proposal.ts | 1 + .../core/src/domain/users/UserDirectory.ts | 5 +- .../InMemoryClarificationRepository.ts | 5 +- .../in-memory/InMemoryProposalRepository.ts | 5 +- packages/schema/src/clarification.ts | 8 +++- packages/schema/src/identity.ts | 30 ------------ packages/schema/src/index.ts | 1 - packages/schema/src/proposal.ts | 8 +++- packages/schema/src/user.ts | 7 +++ packages/schema/test/identity.test.ts | 22 --------- packages/server/src/authMode.ts | 20 +------- packages/server/src/composeFsApp.ts | 11 +++-- .../hitl/FsClarificationRepository.ts | 6 +-- .../hitl/FsProposalRepository.ts | 6 +-- .../users/UserDirectoryFromRegistry.ts | 1 + packages/server/src/routes/clarifications.ts | 10 ++-- packages/server/src/routes/proposals.ts | 10 ++-- packages/server/src/serviceAccounts.ts | 29 ++++++++++++ .../hitl/FsProposalRepository.test.ts | 9 ++-- packages/server/test/serviceAccounts.test.ts | 47 +++++++++++++++++++ 22 files changed, 137 insertions(+), 107 deletions(-) delete mode 100644 packages/schema/src/identity.ts delete mode 100644 packages/schema/test/identity.test.ts create mode 100644 packages/server/src/serviceAccounts.ts create mode 100644 packages/server/test/serviceAccounts.test.ts diff --git a/packages/core/src/application/HITLService.ts b/packages/core/src/application/HITLService.ts index aa2a5c1..a0951de 100644 --- a/packages/core/src/application/HITLService.ts +++ b/packages/core/src/application/HITLService.ts @@ -78,6 +78,7 @@ export class HITLService { ...(draft.externalReferences ? { externalReferences: draft.externalReferences } : {}), owner: draft.submitterId ?? 'system', ...(submitter?.displayName ? { ownerDisplayName: submitter.displayName } : {}), + ...(submitter?.kind ? { ownerKind: submitter.kind } : {}), }) return this.withLockedWorkspace(draft.workspaceId, async (workspace) => { await this.deps.proposalRepository.save(proposal) @@ -115,6 +116,7 @@ export class HITLService { ...(draft.ambiguityType ? { ambiguityType: draft.ambiguityType } : {}), owner: draft.submitterId ?? 'system', ...(submitter?.displayName ? { ownerDisplayName: submitter.displayName } : {}), + ...(submitter?.kind ? { ownerKind: submitter.kind } : {}), }) return this.withLockedWorkspace(draft.workspaceId, async (workspace) => { await this.deps.clarificationRepository.save(ticket) diff --git a/packages/core/src/domain/hitl/Clarification.ts b/packages/core/src/domain/hitl/Clarification.ts index d45757e..22fc192 100644 --- a/packages/core/src/domain/hitl/Clarification.ts +++ b/packages/core/src/domain/hitl/Clarification.ts @@ -38,6 +38,7 @@ export class Clarification { get proposalId(): ProposalId | undefined { return this.data.proposalId } get owner(): Actor { return this.data.owner } get ownerDisplayName(): string | undefined { return this.data.ownerDisplayName } + get ownerKind(): ClarificationData['ownerKind'] { return this.data.ownerKind } get externalReferences(): readonly ExternalReference[] | undefined { return this.data.externalReferences } resolveCandidate(candidateId: ClarificationCandidateId): readonly GraphOperation[] { diff --git a/packages/core/src/domain/hitl/Proposal.ts b/packages/core/src/domain/hitl/Proposal.ts index cc3a1d3..0230c62 100644 --- a/packages/core/src/domain/hitl/Proposal.ts +++ b/packages/core/src/domain/hitl/Proposal.ts @@ -25,6 +25,7 @@ export class Proposal { get reviewedBy(): UserId | undefined { return this.data.reviewedBy } get owner(): Actor { return this.data.owner } get ownerDisplayName(): string | undefined { return this.data.ownerDisplayName } + get ownerKind(): ProposalData['ownerKind'] { return this.data.ownerKind } get externalReferences(): readonly ExternalReference[] | undefined { return this.data.externalReferences } // Returns a new Proposal in 'applied' state. Caller must persist the new instance and run the operations, diff --git a/packages/core/src/domain/users/UserDirectory.ts b/packages/core/src/domain/users/UserDirectory.ts index 2c5fa2f..fa05573 100644 --- a/packages/core/src/domain/users/UserDirectory.ts +++ b/packages/core/src/domain/users/UserDirectory.ts @@ -1,4 +1,4 @@ -import type { UserId } from '@braidhq/schema' +import type { UserId, UserKind } from '@braidhq/schema' /** * Read-only lookup of a userId's human-facing fields. @@ -17,6 +17,9 @@ export interface UserDirectory { export interface UserAuthor { readonly displayName: string readonly email?: string + // Snapshotted onto proposals and tickets at submit time, + // so the HITL views classify an autonomous owner without a read-time lookup. + readonly kind?: UserKind } /** Drop-in directory that always returns null. Used by tests and the in-memory composition. */ diff --git a/packages/core/src/infrastructure/in-memory/InMemoryClarificationRepository.ts b/packages/core/src/infrastructure/in-memory/InMemoryClarificationRepository.ts index 11f0bd5..5a4e4df 100644 --- a/packages/core/src/infrastructure/in-memory/InMemoryClarificationRepository.ts +++ b/packages/core/src/infrastructure/in-memory/InMemoryClarificationRepository.ts @@ -1,7 +1,6 @@ import type { ClarificationFilter, ClarificationId } from '@braidhq/schema' import type { Clarification } from '../../domain/hitl/Clarification.js' import type { ClarificationRepository } from '../../domain/hitl/ClarificationRepository.js' -import { isServiceAccount } from '@braidhq/schema' import { paginate } from '../../domain/paginate.js' import { InMemoryKeyedStore } from './InMemoryKeyedStore.js' @@ -20,9 +19,9 @@ export class InMemoryClarificationRepository implements ClarificationRepository } if (filter?.viewerId !== undefined) { const viewerId = filter.viewerId - const includeServiceAccounts = filter.includeServiceAccounts ?? false + const includeServiceOwned = filter.includeServiceOwned ?? false tickets = tickets.filter(ticket => - ticket.status !== 'pending' || (includeServiceAccounts && isServiceAccount(ticket.owner)) || ticket.owner === viewerId, + ticket.status !== 'pending' || ticket.owner === viewerId || (includeServiceOwned && ticket.ownerKind === 'service'), ) } return paginate(tickets, filter?.limit, filter?.offset) diff --git a/packages/core/src/infrastructure/in-memory/InMemoryProposalRepository.ts b/packages/core/src/infrastructure/in-memory/InMemoryProposalRepository.ts index 084760e..79213ea 100644 --- a/packages/core/src/infrastructure/in-memory/InMemoryProposalRepository.ts +++ b/packages/core/src/infrastructure/in-memory/InMemoryProposalRepository.ts @@ -1,7 +1,6 @@ import type { ProposalFilter, ProposalId } from '@braidhq/schema' import type { Proposal } from '../../domain/hitl/Proposal.js' import type { ProposalRepository } from '../../domain/hitl/ProposalRepository.js' -import { isServiceAccount } from '@braidhq/schema' import { paginate } from '../../domain/paginate.js' import { InMemoryKeyedStore } from './InMemoryKeyedStore.js' @@ -24,9 +23,9 @@ export class InMemoryProposalRepository implements ProposalRepository { } if (filter?.viewerId !== undefined) { const viewerId = filter.viewerId - const includeServiceAccounts = filter.includeServiceAccounts ?? false + const includeServiceOwned = filter.includeServiceOwned ?? false proposals = proposals.filter(proposal => - proposal.status !== 'pending' || (includeServiceAccounts && isServiceAccount(proposal.owner)) || proposal.owner === viewerId, + proposal.status !== 'pending' || proposal.owner === viewerId || (includeServiceOwned && proposal.ownerKind === 'service'), ) } return paginate(proposals, filter?.limit, filter?.offset) diff --git a/packages/schema/src/clarification.ts b/packages/schema/src/clarification.ts index 4b85dfd..e81d7e0 100644 --- a/packages/schema/src/clarification.ts +++ b/packages/schema/src/clarification.ts @@ -11,6 +11,7 @@ import { WorkspaceId, } from './common.js' import { GraphOperation } from './proposal.js' +import { UserKind } from './user.js' // Only the hard contract here. Authoring rules (length, tone, language) live in the skill layer. const clarificationQuestion = z.string().min(1).max(400).describe('The single question shown to the reviewer.') @@ -47,6 +48,9 @@ export const Clarification = z.object({ owner: Actor, // Display-name snapshot at submit time. Absent for the 'system' owner. ownerDisplayName: z.string().min(1).optional(), + // Owner's kind snapshotted at submit time. + // Absent means a human's private ticket, 'service' is autonomous and owner-visible. + ownerKind: UserKind.optional(), // Set when the resolution becomes a Proposal, so the UI can link the two. proposalId: ProposalId.optional(), externalReferences: z.array(ExternalReference).optional(), @@ -87,7 +91,7 @@ export const ClarificationFilter = z.object({ offset: z.number().int().nonnegative().optional(), // When set, hides others' pending tickets. Non-pending stay visible, absent shows all. viewerId: UserId.optional(), - // Whether the viewer also sees service-account-owned pending. Owners only. - includeServiceAccounts: z.boolean().optional(), + // Owner-only, also shows service-owned (autonomous) pending to this viewer. + includeServiceOwned: z.boolean().optional(), }) export type ClarificationFilter = z.infer diff --git a/packages/schema/src/identity.ts b/packages/schema/src/identity.ts deleted file mode 100644 index 631eb44..0000000 --- a/packages/schema/src/identity.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { UserId } from './common.js' - -/** A built-in non-human principal that an autonomous server component acts as. */ -export interface ServiceAccount { - readonly id: UserId - readonly displayName: string -} - -export const REACTOR_USER_ID = UserId.parse('reactor') -export const SYSTEM_USER_ID = UserId.parse('system') - -/** - * Built-in service accounts. An autonomous component acts as one of these, - * carrying its session token so its API calls authenticate as a known, - * non-human principal rather than an anonymous caller. Seeding, auth, - * workspace access, and pending-item visibility all read this set, - * so adding a new autonomous component here needs no change to any of them. - * `system` is the generic autonomous principal, `reactor` the source-sync one. - */ -export const SERVICE_ACCOUNTS: readonly ServiceAccount[] = [ - { id: REACTOR_USER_ID, displayName: 'Reactor' }, - { id: SYSTEM_USER_ID, displayName: 'System' }, -] - -const SERVICE_ACCOUNT_IDS = new Set(SERVICE_ACCOUNTS.map(account => account.id)) - -/** True when the id belongs to a built-in service account, not a human user. */ -export function isServiceAccount(id: string): boolean { - return SERVICE_ACCOUNT_IDS.has(id) -} diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index c2c67e2..ed88304 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -10,7 +10,6 @@ export * from './error.js' export * from './event.js' export * from './graph-validation.js' export * from './history.js' -export * from './identity.js' export * from './mcp.js' export * from './model.js' export * from './ontology.js' diff --git a/packages/schema/src/proposal.ts b/packages/schema/src/proposal.ts index 984a5fc..04f2c45 100644 --- a/packages/schema/src/proposal.ts +++ b/packages/schema/src/proposal.ts @@ -18,6 +18,7 @@ import { GraphNodeCreate, GraphNodeUpdate, } from './model.js' +import { UserKind } from './user.js' // Only the hard contract here. Authoring rules (length, tone, language) live in the skill layer. const proposalRationale = z.string().min(1).max(1500).describe('One-paragraph plain-text summary of what changed and why.') @@ -56,6 +57,9 @@ export const Proposal = z.object({ owner: Actor, // Name at submit time, survives renames. Absent for the 'system' owner. ownerDisplayName: z.string().min(1).optional(), + // Owner's kind snapshotted at submit time. + // Absent means a human's private draft, 'service' is autonomous and owner-visible. + ownerKind: UserKind.optional(), }) export type Proposal = z.infer @@ -76,7 +80,7 @@ export const ProposalFilter = z.object({ offset: z.number().int().nonnegative().optional(), // When set, hides others' pending proposals. Non-pending stay visible, absent shows all. viewerId: UserId.optional(), - // Whether the viewer also sees service-account-owned pending. Owners only. - includeServiceAccounts: z.boolean().optional(), + // Owner-only, also shows service-owned (autonomous) pending to this viewer. + includeServiceOwned: z.boolean().optional(), }) export type ProposalFilter = z.infer diff --git a/packages/schema/src/user.ts b/packages/schema/src/user.ts index ff36433..de04679 100644 --- a/packages/schema/src/user.ts +++ b/packages/schema/src/user.ts @@ -4,12 +4,19 @@ import { Timestamp, UserId } from './common.js' export const ServerRole = z.enum(['admin', 'user']) export type ServerRole = z.infer +// Absent or 'human' is a person who logs in. +// 'service' is an autonomous component acting via its own token, +// seeded by whatever wires it. +export const UserKind = z.enum(['human', 'service']) +export type UserKind = z.infer + export const User = z.object({ id: UserId, googleSub: z.string().min(1).optional(), email: z.string().email().optional(), displayName: z.string().min(1), serverRole: ServerRole.default('user'), + kind: UserKind.optional(), createdAt: Timestamp, }) export type User = z.infer diff --git a/packages/schema/test/identity.test.ts b/packages/schema/test/identity.test.ts deleted file mode 100644 index 8eac8ae..0000000 --- a/packages/schema/test/identity.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { isServiceAccount, REACTOR_USER_ID, SERVICE_ACCOUNTS } from '../src/identity.js' - -describe('service accounts', () => { - it('recognises the reactor as a service account', () => { - expect(isServiceAccount(REACTOR_USER_ID)).toBe(true) - expect(isServiceAccount('reactor')).toBe(true) - }) - - it('recognises the generic system principal', () => { - expect(isServiceAccount('system')).toBe(true) - }) - - it('does not treat a human user id as a service account', () => { - expect(isServiceAccount('usr-7176d698')).toBe(false) - expect(isServiceAccount('local-user')).toBe(false) - }) - - it('declares a display name for every service account', () => { - expect(SERVICE_ACCOUNTS.every(account => account.displayName.length > 0)).toBe(true) - }) -}) diff --git a/packages/server/src/authMode.ts b/packages/server/src/authMode.ts index 80a022d..b489441 100644 --- a/packages/server/src/authMode.ts +++ b/packages/server/src/authMode.ts @@ -3,7 +3,7 @@ import type { AccessPolicy } from './infrastructure/auth/AccessPolicy.js' import type { UserRegistryFile } from './infrastructure/users/UserRegistryFile.js' import { userInfo } from 'node:os' import process from 'node:process' -import { SERVICE_ACCOUNTS, UserId as UserIdSchema } from '@braidhq/schema' +import { UserId as UserIdSchema } from '@braidhq/schema' export const LOCAL_USER_ID = UserIdSchema.parse('local-user') @@ -68,24 +68,6 @@ export const authenticated: AuthMode = { }, } -/** - * Seed the built-in service accounts, idempotently, in every mode. - * They are admins, so an autonomous component's calls clear the workspace gate, - * via the admin-to-owner rule, and no service-account special-case is needed. - */ -export async function provisionServiceAccounts({ userRegistry }: AuthContext): Promise { - for (const account of SERVICE_ACCOUNTS) { - if (await userRegistry.get(account.id)) - continue - await userRegistry.create({ - id: account.id, - displayName: account.displayName, - serverRole: 'admin', - createdAt: new Date().toISOString() as Timestamp, - }) - } -} - // Default display name from the OS account. // Some sandboxed environments throw on userInfo(), so fall through. function osUsername(): string { diff --git a/packages/server/src/composeFsApp.ts b/packages/server/src/composeFsApp.ts index bfe09a0..a91cb55 100644 --- a/packages/server/src/composeFsApp.ts +++ b/packages/server/src/composeFsApp.ts @@ -16,12 +16,12 @@ import { } from '@braidhq/core' import { InMemoryWorkspaceEventBus } from '@braidhq/core/in-memory' import { dddOntology } from '@braidhq/ontology-ddd' -import { AgentId, AgentKind, REACTOR_USER_ID, StorageKind as StorageKindSchema } from '@braidhq/schema' +import { AgentId, AgentKind, StorageKind as StorageKindSchema } from '@braidhq/schema' import { createGoogleDriveLoader } from '@braidhq/source-loader-gdrive' import { gitLoader } from '@braidhq/source-loader-git' import { createGithubLoader } from '@braidhq/source-loader-github' import { kuzuStoragePlugin } from '@braidhq/storage-kuzu' -import { authenticated, localTrust, provisionServiceAccounts } from './authMode.js' +import { authenticated, localTrust } from './authMode.js' import { composeApp } from './composeApp.js' import { parseBoolEnv } from './infrastructure/_shared/env.js' import { AccessPolicy } from './infrastructure/auth/AccessPolicy.js' @@ -46,6 +46,7 @@ import { UserRegistryFile } from './infrastructure/users/UserRegistryFile.js' import { FsWorkspaceRepository } from './infrastructure/workspace/FsWorkspaceRepository.js' import { discoverCanonicalWorkspaces } from './infrastructure/workspace/WorkspaceDiscovery.js' import { WorkspaceRegistryFile } from './infrastructure/workspace/WorkspaceRegistryFile.js' +import { ensureServiceAccount, REACTOR_USER_ID } from './serviceAccounts.js' import { startupBeforeServe } from './startup.js' // The coding preset's default plugin identities, its worldview in one place. @@ -144,8 +145,10 @@ export async function composeFsApp(options: ComposeFsOptions = {}): Promise> => { const workspaces = await workspaceRepository.list() diff --git a/packages/server/src/infrastructure/hitl/FsClarificationRepository.ts b/packages/server/src/infrastructure/hitl/FsClarificationRepository.ts index 7d6362d..5f2a3f1 100644 --- a/packages/server/src/infrastructure/hitl/FsClarificationRepository.ts +++ b/packages/server/src/infrastructure/hitl/FsClarificationRepository.ts @@ -5,7 +5,7 @@ import type { WorkspaceId, } from '@braidhq/schema' import { Clarification, type ClarificationRepository, paginate } from '@braidhq/core' -import { Clarification as ClarificationSchema, isServiceAccount } from '@braidhq/schema' +import { Clarification as ClarificationSchema } from '@braidhq/schema' import { clarificationDir, CLARIFY_STATUSES } from '../_shared/paths.js' import { StatusedJsonStore } from './StatusedJsonStore.js' @@ -41,9 +41,9 @@ export class FsClarificationRepository implements ClarificationRepository { // Answered, applied, and skipped tickets stay workspace-shared. if (filter?.viewerId !== undefined) { const viewerId = filter.viewerId - const includeServiceAccounts = filter.includeServiceAccounts ?? false + const includeServiceOwned = filter.includeServiceOwned ?? false tickets = tickets.filter(ticket => - ticket.status !== 'pending' || (includeServiceAccounts && isServiceAccount(ticket.owner)) || ticket.owner === viewerId, + ticket.status !== 'pending' || ticket.owner === viewerId || (includeServiceOwned && ticket.ownerKind === 'service'), ) } return paginate(tickets, filter?.limit, filter?.offset) diff --git a/packages/server/src/infrastructure/hitl/FsProposalRepository.ts b/packages/server/src/infrastructure/hitl/FsProposalRepository.ts index 29406e1..371f68b 100644 --- a/packages/server/src/infrastructure/hitl/FsProposalRepository.ts +++ b/packages/server/src/infrastructure/hitl/FsProposalRepository.ts @@ -1,6 +1,6 @@ import type { AbsolutePath, ProposalFilter, ProposalId, WorkspaceId } from '@braidhq/schema' import { paginate, Proposal, type ProposalRepository } from '@braidhq/core' -import { isServiceAccount, Proposal as ProposalSchema } from '@braidhq/schema' +import { Proposal as ProposalSchema } from '@braidhq/schema' import { PROPOSAL_STATUSES, proposalsDir } from '../_shared/paths.js' import { StatusedJsonStore } from './StatusedJsonStore.js' @@ -41,9 +41,9 @@ export class FsProposalRepository implements ProposalRepository { // Absent viewerId means no filter, for Owner Show All and legacy callers. if (filter?.viewerId !== undefined) { const viewerId = filter.viewerId - const includeServiceAccounts = filter.includeServiceAccounts ?? false + const includeServiceOwned = filter.includeServiceOwned ?? false proposals = proposals.filter(proposal => - proposal.status !== 'pending' || (includeServiceAccounts && isServiceAccount(proposal.owner)) || proposal.owner === viewerId, + proposal.status !== 'pending' || proposal.owner === viewerId || (includeServiceOwned && proposal.ownerKind === 'service'), ) } return paginate(proposals, filter?.limit, filter?.offset) diff --git a/packages/server/src/infrastructure/users/UserDirectoryFromRegistry.ts b/packages/server/src/infrastructure/users/UserDirectoryFromRegistry.ts index 50a1eaf..cf2e92e 100644 --- a/packages/server/src/infrastructure/users/UserDirectoryFromRegistry.ts +++ b/packages/server/src/infrastructure/users/UserDirectoryFromRegistry.ts @@ -17,6 +17,7 @@ export class UserDirectoryFromRegistry implements UserDirectory { return { displayName: user.displayName, ...(user.email ? { email: user.email } : {}), + ...(user.kind ? { kind: user.kind } : {}), } } } diff --git a/packages/server/src/routes/clarifications.ts b/packages/server/src/routes/clarifications.ts index 54e4de6..06da848 100644 --- a/packages/server/src/routes/clarifications.ts +++ b/packages/server/src/routes/clarifications.ts @@ -208,16 +208,16 @@ export function createClarificationRouter(deps: ClarificationRouterDeps): OpenAP const { status, limit, offset, showAll } = context.req.valid('query') const statuses = status === undefined ? undefined : Array.isArray(status) ? status : [status] const viewer = getViewerContext(context) - const viewerId = (showAll && viewer?.effectiveRole === 'owner') ? undefined : getUserId(context) - // Only owners see service-account (autonomous) pending, only they can act on it. - // An absent viewer means an open composition (in-memory), which stays open. - const includeServiceAccounts = !viewer || viewer.effectiveRole === 'owner' + const isOwner = viewer?.effectiveRole === 'owner' + // No viewer is an open composition (in-memory), which applies no personal filter. + const viewerId = (!viewer || (showAll && isOwner)) ? undefined : getUserId(context) + // Owners also see service-owned (autonomous) pending, since only they can act on it. const tickets = await deps.clarificationRepository.list({ workspaceId, statuses, limit, offset, - ...(viewerId ? { viewerId, includeServiceAccounts } : {}), + ...(viewerId ? { viewerId, includeServiceOwned: isOwner } : {}), }) return context.json({ items: tickets.map(ticket => ticket.toData()) }, 200) }) diff --git a/packages/server/src/routes/proposals.ts b/packages/server/src/routes/proposals.ts index 577b848..25adb97 100644 --- a/packages/server/src/routes/proposals.ts +++ b/packages/server/src/routes/proposals.ts @@ -183,16 +183,16 @@ export function createProposalsRouter(deps: ProposalsRouterDeps): OpenAPIHono { // Everyone else is forced through the personal-pending filter, // whatever they send. const viewer = getViewerContext(context) - const viewerId = (showAll && viewer?.effectiveRole === 'owner') ? undefined : getUserId(context) - // Only owners see service-account (autonomous) pending, only they can apply it. - // An absent viewer means an open composition (in-memory), which stays open. - const includeServiceAccounts = !viewer || viewer.effectiveRole === 'owner' + const isOwner = viewer?.effectiveRole === 'owner' + // No viewer is an open composition (in-memory), which applies no personal filter. + const viewerId = (!viewer || (showAll && isOwner)) ? undefined : getUserId(context) + // Owners also see service-owned (autonomous) pending, since only they can apply it. const proposals = await deps.proposalRepository.list({ workspaceId, statuses, limit, offset, - ...(viewerId ? { viewerId, includeServiceAccounts } : {}), + ...(viewerId ? { viewerId, includeServiceOwned: isOwner } : {}), }) return context.json({ items: proposals.map(proposal => proposal.toData()) }, 200) }) diff --git a/packages/server/src/serviceAccounts.ts b/packages/server/src/serviceAccounts.ts new file mode 100644 index 0000000..6fb1d88 --- /dev/null +++ b/packages/server/src/serviceAccounts.ts @@ -0,0 +1,29 @@ +import type { Timestamp, UserId as UserIdType } from '@braidhq/schema' +import type { UserRegistryFile } from './infrastructure/users/UserRegistryFile.js' +import { UserId } from '@braidhq/schema' + +/** The autonomous source-sync reactor's service-account identity. */ +export const REACTOR_USER_ID = UserId.parse('reactor') + +/** + * Upsert a service account, idempotently. It is an admin, + * so its calls clear the workspace gate via the admin-to-owner rule. + * kind=service marks it non-human, and that kind rides onto its proposals, + * so the HITL views classify its pending without a read-time registry lookup. + * Each autonomous component seeds its own by calling this. + */ +export async function ensureServiceAccount(userRegistry: UserRegistryFile, id: UserIdType, displayName: string): Promise { + const existing = await userRegistry.get(id) + if (existing) { + if (existing.kind !== 'service' || existing.serverRole !== 'admin') + await userRegistry.update(id, { kind: 'service', serverRole: 'admin' }) + return + } + await userRegistry.create({ + id, + displayName, + serverRole: 'admin', + kind: 'service', + createdAt: new Date().toISOString() as Timestamp, + }) +} diff --git a/packages/server/test/infrastructure/hitl/FsProposalRepository.test.ts b/packages/server/test/infrastructure/hitl/FsProposalRepository.test.ts index e1fda4f..9f9eee0 100644 --- a/packages/server/test/infrastructure/hitl/FsProposalRepository.test.ts +++ b/packages/server/test/infrastructure/hitl/FsProposalRepository.test.ts @@ -12,7 +12,7 @@ async function makeWorkspaceRoot(): Promise { return await mkdtemp(join(tmpdir(), 'braid-fs-prop-')) as AbsolutePath } -function makeProposal(id: string, workspaceId: WorkspaceId, status: 'pending' | 'applied' | 'rejected' = 'pending', owner: UserId | 'system' = 'system'): Proposal { +function makeProposal(id: string, workspaceId: WorkspaceId, status: 'pending' | 'applied' | 'rejected' = 'pending', owner: UserId | 'system' = 'system', ownerKind?: 'human' | 'service'): Proposal { return new Proposal({ id: id as ProposalId, workspaceId, @@ -22,6 +22,7 @@ function makeProposal(id: string, workspaceId: WorkspaceId, status: 'pending' | generatedAt: isoTimestamp, rationale: 'r', owner, + ...(ownerKind ? { ownerKind } : {}), }) } @@ -65,7 +66,7 @@ describe('FsProposalRepository', () => { expect(pending.map(p => p.id)).toEqual(['p-1']) }) - it('shows service-account pending only when includeServiceAccounts is set (owner view)', async () => { + it('shows service-owned pending only when includeServiceOwned is set (owner view)', async () => { const root = await makeWorkspaceRoot() const workspaceId = 'ws-1' as WorkspaceId const repository = new FsProposalRepository({ @@ -73,13 +74,13 @@ describe('FsProposalRepository', () => { }) const alice = 'alice' as UserId await repository.save(makeProposal('p-mine', workspaceId, 'pending', alice)) - await repository.save(makeProposal('p-reactor', workspaceId, 'pending', 'reactor' as UserId)) + await repository.save(makeProposal('p-reactor', workspaceId, 'pending', 'reactor' as UserId, 'service')) await repository.save(makeProposal('p-bob', workspaceId, 'pending', 'bob' as UserId)) const personal = await repository.list({ workspaceId, viewerId: alice }) expect(personal.map(p => p.id).sort()).toEqual(['p-mine']) - const asOwner = await repository.list({ workspaceId, viewerId: alice, includeServiceAccounts: true }) + const asOwner = await repository.list({ workspaceId, viewerId: alice, includeServiceOwned: true }) expect(asOwner.map(p => p.id).sort()).toEqual(['p-mine', 'p-reactor']) }) diff --git a/packages/server/test/serviceAccounts.test.ts b/packages/server/test/serviceAccounts.test.ts new file mode 100644 index 0000000..f51bec3 --- /dev/null +++ b/packages/server/test/serviceAccounts.test.ts @@ -0,0 +1,47 @@ +import type { Timestamp, UserId } from '@braidhq/schema' +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it } from 'vitest' +import { UserRegistryFile } from '../src/infrastructure/users/UserRegistryFile.js' +import { ensureServiceAccount, REACTOR_USER_ID } from '../src/serviceAccounts.js' + +describe('service accounts', () => { + let registry: UserRegistryFile + + beforeEach(async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-svc-acct-')) + registry = new UserRegistryFile(join(root, 'users.json')) + }) + + it('creates a service account as an admin and non-human', async () => { + await ensureServiceAccount(registry, REACTOR_USER_ID, 'Reactor') + const user = await registry.get(REACTOR_USER_ID) + expect(user?.kind).toBe('service') + expect(user?.serverRole).toBe('admin') + }) + + it('upgrades a pre-existing human record to a service account (idempotent)', async () => { + await registry.create({ + id: REACTOR_USER_ID, + displayName: 'Reactor', + serverRole: 'user', + createdAt: '2020-01-01T00:00:00.000Z' as Timestamp, + }) + await ensureServiceAccount(registry, REACTOR_USER_ID, 'Reactor') + const user = await registry.get(REACTOR_USER_ID) + expect(user?.kind).toBe('service') + expect(user?.serverRole).toBe('admin') + }) + + it('leaves a human record human', async () => { + await registry.create({ + id: 'alice' as UserId, + displayName: 'Alice', + serverRole: 'user', + createdAt: '2020-01-01T00:00:00.000Z' as Timestamp, + }) + const user = await registry.get('alice' as UserId) + expect(user?.kind).toBeUndefined() + }) +})