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/application/ReactorService.ts b/packages/core/src/application/ReactorService.ts index dcd1dd2..3dac15e 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 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/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 5991569..5a4e4df 100644 --- a/packages/core/src/infrastructure/in-memory/InMemoryClarificationRepository.ts +++ b/packages/core/src/infrastructure/in-memory/InMemoryClarificationRepository.ts @@ -19,8 +19,9 @@ export class InMemoryClarificationRepository implements ClarificationRepository } if (filter?.viewerId !== undefined) { const viewerId = filter.viewerId + const includeServiceOwned = filter.includeServiceOwned ?? false tickets = tickets.filter(ticket => - ticket.status !== 'pending' || ticket.owner === 'system' || 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 57a6ac0..79213ea 100644 --- a/packages/core/src/infrastructure/in-memory/InMemoryProposalRepository.ts +++ b/packages/core/src/infrastructure/in-memory/InMemoryProposalRepository.ts @@ -23,8 +23,9 @@ export class InMemoryProposalRepository implements ProposalRepository { } if (filter?.viewerId !== undefined) { const viewerId = filter.viewerId + const includeServiceOwned = filter.includeServiceOwned ?? false proposals = proposals.filter(proposal => - proposal.status !== 'pending' || proposal.owner === 'system' || proposal.owner === viewerId, + proposal.status !== 'pending' || proposal.owner === viewerId || (includeServiceOwned && proposal.ownerKind === 'service'), ) } 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/clarification.ts b/packages/schema/src/clarification.ts index 0b32594..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,5 +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(), + // 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/proposal.ts b/packages/schema/src/proposal.ts index ec25c07..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,5 +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(), + // 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/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..a91cb55 100644 --- a/packages/server/src/composeFsApp.ts +++ b/packages/server/src/composeFsApp.ts @@ -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,6 +145,10 @@ export async function composeFsApp(options: ComposeFsOptions = {}): Promise> => { const workspaces = await workspaceRepository.list() @@ -325,6 +330,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/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 58038ef..5f2a3f1 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 includeServiceOwned = filter.includeServiceOwned ?? false tickets = tickets.filter(ticket => - ticket.status !== 'pending' || ticket.owner === 'system' || 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 c529dd5..371f68b 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 includeServiceOwned = filter.includeServiceOwned ?? false proposals = proposals.filter(proposal => - proposal.status !== 'pending' || proposal.owner === 'system' || 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/middleware/auth.ts b/packages/server/src/middleware/auth.ts index 2628d7b..134627e 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/src/routes/clarifications.ts b/packages/server/src/routes/clarifications.ts index 417faa7..06da848 100644 --- a/packages/server/src/routes/clarifications.ts +++ b/packages/server/src/routes/clarifications.ts @@ -208,13 +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) + 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 } : {}), + ...(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 0544c64..25adb97 100644 --- a/packages/server/src/routes/proposals.ts +++ b/packages/server/src/routes/proposals.ts @@ -183,13 +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) + 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 } : {}), + ...(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/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..9f9eee0 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', ownerKind?: 'human' | 'service'): Proposal { return new Proposal({ id: id as ProposalId, workspaceId, @@ -21,7 +21,8 @@ function makeProposal(id: string, workspaceId: WorkspaceId, status: 'pending' | generatedBy: 'extract' as SkillId, generatedAt: isoTimestamp, rationale: 'r', - owner: 'system', + owner, + ...(ownerKind ? { ownerKind } : {}), }) } @@ -65,6 +66,24 @@ describe('FsProposalRepository', () => { expect(pending.map(p => p.id)).toEqual(['p-1']) }) + 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({ + 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, '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, includeServiceOwned: 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 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' }) + }) +}) 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() + }) +})