This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@devrunbook/worker",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json && esbuild src/index.ts src/operator/password-reset.ts src/operator/artifact-retention.ts --bundle --platform=node --format=esm --target=node24 --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\" --outdir=dist --outbase=src",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"lint": "eslint src --max-warnings=0",
|
||||
"operator:password-reset": "tsx src/operator/password-reset.ts",
|
||||
"operator:retention": "tsx src/operator/artifact-retention.ts",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@devrunbook/application": "workspace:*",
|
||||
"@devrunbook/artifacts": "workspace:*",
|
||||
"@devrunbook/config": "workspace:*",
|
||||
"@devrunbook/content": "workspace:*",
|
||||
"@devrunbook/db": "workspace:*",
|
||||
"@devrunbook/integrations": "workspace:*",
|
||||
"@devrunbook/observability": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.13.3",
|
||||
"esbuild": "0.28.1",
|
||||
"tsx": "4.20.6",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { BuiltInPlaybookImportRecord } from '@devrunbook/application'
|
||||
|
||||
import {
|
||||
resolveBuiltInCatalogPaths,
|
||||
synchronizeBuiltInCatalog,
|
||||
} from './built-in-catalog'
|
||||
|
||||
function records(): BuiltInPlaybookImportRecord[] {
|
||||
return Array.from({ length: 28 }, (_, index) => ({
|
||||
logicalId: `id-${index}`,
|
||||
slug: `slug-${index}`,
|
||||
namespace: 'builtin',
|
||||
sourceType: 'built_in',
|
||||
semanticVersion: '1.0.0',
|
||||
lifecycle: 'reviewed',
|
||||
packageApiVersion: 'devrunbook.io/v1alpha1',
|
||||
title: `Title ${index}`,
|
||||
summary: 'Summary',
|
||||
category: 'testing',
|
||||
riskTier: 'low',
|
||||
packageJson: {},
|
||||
templateText: 'Prompt\n',
|
||||
contentDigest: index.toString(16).padStart(64, '0'),
|
||||
searchProjection: { searchText: `Title ${index}` },
|
||||
}))
|
||||
}
|
||||
|
||||
describe('worker built-in catalog synchronization', () => {
|
||||
it('supports the repository layout used by the development container', () => {
|
||||
const repositoryCatalog = path.resolve(
|
||||
'/app/content',
|
||||
'../catalog/seed-catalog.yaml',
|
||||
)
|
||||
|
||||
expect(
|
||||
resolveBuiltInCatalogPaths(
|
||||
'/app/content',
|
||||
(candidate) => candidate === repositoryCatalog,
|
||||
),
|
||||
).toEqual({
|
||||
packageRoot: path.resolve('/app/content', 'playbooks'),
|
||||
seedCatalogPath: repositoryCatalog,
|
||||
})
|
||||
})
|
||||
|
||||
it('validates, persists and safely logs counts before polling can begin', async () => {
|
||||
const logger = { info: vi.fn() }
|
||||
const store = {
|
||||
importBuiltIns: vi.fn().mockResolvedValue({
|
||||
total: 28,
|
||||
insertedPlaybooks: 0,
|
||||
insertedVersions: 0,
|
||||
unchangedVersions: 28,
|
||||
}),
|
||||
}
|
||||
|
||||
await expect(
|
||||
synchronizeBuiltInCatalog({
|
||||
logger,
|
||||
contentRoot: '/canonical-content',
|
||||
load: async (contentRoot, seedCatalogPath) => {
|
||||
expect(contentRoot).toBe(
|
||||
path.resolve('/canonical-content', 'playbooks'),
|
||||
)
|
||||
expect(seedCatalogPath).toBe(
|
||||
path.resolve('/canonical-content', 'catalog/seed-catalog.yaml'),
|
||||
)
|
||||
return records()
|
||||
},
|
||||
store,
|
||||
}),
|
||||
).resolves.toMatchObject({ total: 28, unchangedVersions: 28 })
|
||||
expect(store.importBuiltIns).toHaveBeenCalledOnce()
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{
|
||||
total: 28,
|
||||
insertedPlaybooks: 0,
|
||||
insertedVersions: 0,
|
||||
unchangedVersions: 28,
|
||||
},
|
||||
'built-in catalog synchronized',
|
||||
)
|
||||
})
|
||||
|
||||
it('does not persist an incomplete canonical load', async () => {
|
||||
const store = { importBuiltIns: vi.fn() }
|
||||
|
||||
await expect(
|
||||
synchronizeBuiltInCatalog({
|
||||
logger: { info: vi.fn() },
|
||||
load: async () => records().slice(0, 27),
|
||||
store,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'catalog_import_incomplete' })
|
||||
expect(store.importBuiltIns).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
importBuiltInPlaybooks,
|
||||
type BuiltInPlaybookImportRecord,
|
||||
type BuiltInPlaybookImportResult,
|
||||
type BuiltInPlaybookImportStore,
|
||||
} from '@devrunbook/application'
|
||||
import { loadBuiltInPlaybookRecords } from '@devrunbook/content'
|
||||
import { DrizzleBuiltInPlaybookImporter } from '@devrunbook/db'
|
||||
|
||||
interface SafeCatalogLogger {
|
||||
info(bindings: Record<string, number>, message: string): void
|
||||
}
|
||||
|
||||
export interface SynchronizeBuiltInCatalogOptions {
|
||||
readonly logger: SafeCatalogLogger
|
||||
readonly contentRoot?: string
|
||||
readonly load?: (
|
||||
contentRoot?: string,
|
||||
seedCatalogPath?: string,
|
||||
) => Promise<readonly BuiltInPlaybookImportRecord[]>
|
||||
readonly store?: BuiltInPlaybookImportStore
|
||||
}
|
||||
|
||||
export function resolveBuiltInCatalogPaths(
|
||||
contentRoot: string,
|
||||
pathExists: (candidate: string) => boolean = existsSync,
|
||||
): { packageRoot: string; seedCatalogPath: string } {
|
||||
const packageRoot = path.resolve(contentRoot, 'playbooks')
|
||||
const seedCatalogCandidates = [
|
||||
path.resolve(contentRoot, 'catalog/seed-catalog.yaml'),
|
||||
path.resolve(contentRoot, '../catalog/seed-catalog.yaml'),
|
||||
]
|
||||
return {
|
||||
packageRoot,
|
||||
seedCatalogPath:
|
||||
seedCatalogCandidates.find((candidate) => pathExists(candidate)) ??
|
||||
seedCatalogCandidates[0]!,
|
||||
}
|
||||
}
|
||||
|
||||
export async function synchronizeBuiltInCatalog(
|
||||
options: SynchronizeBuiltInCatalogOptions,
|
||||
): Promise<BuiltInPlaybookImportResult> {
|
||||
const paths = options.contentRoot
|
||||
? resolveBuiltInCatalogPaths(options.contentRoot)
|
||||
: undefined
|
||||
const records = await (options.load ?? loadBuiltInPlaybookRecords)(
|
||||
paths?.packageRoot,
|
||||
paths?.seedCatalogPath,
|
||||
)
|
||||
const result = await importBuiltInPlaybooks(
|
||||
options.store ?? new DrizzleBuiltInPlaybookImporter(),
|
||||
records,
|
||||
)
|
||||
options.logger.info(
|
||||
{
|
||||
total: result.total,
|
||||
insertedPlaybooks: result.insertedPlaybooks,
|
||||
insertedVersions: result.insertedVersions,
|
||||
unchangedVersions: result.unchangedVersions,
|
||||
},
|
||||
'built-in catalog synchronized',
|
||||
)
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { hostname } from 'node:os'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { parseEnvironment } from '@devrunbook/config'
|
||||
import {
|
||||
closeDatabase,
|
||||
PostgresJobStore,
|
||||
RepositoryRefreshScheduler,
|
||||
} from '@devrunbook/db'
|
||||
import { createLogger } from '@devrunbook/observability'
|
||||
|
||||
import { synchronizeBuiltInCatalog } from './built-in-catalog'
|
||||
import { createJobHandlers } from './jobs/handlers'
|
||||
import { createGiteaSnapshotDependencies } from './jobs/gitea-snapshot-dependencies'
|
||||
import { runWorkerLoop } from './jobs/worker-loop'
|
||||
|
||||
export async function main(
|
||||
environment: Record<string, string | undefined> = process.env,
|
||||
): Promise<void> {
|
||||
const config = parseEnvironment(environment)
|
||||
const logger = createLogger(config.LOG_LEVEL)
|
||||
const controller = new AbortController()
|
||||
const workerId = `${hostname()}:${process.pid}:${randomUUID()}`
|
||||
|
||||
const shutdown = () => controller.abort()
|
||||
process.once('SIGINT', shutdown)
|
||||
process.once('SIGTERM', shutdown)
|
||||
try {
|
||||
await synchronizeBuiltInCatalog({
|
||||
logger,
|
||||
contentRoot: config.CONTENT_ROOT,
|
||||
})
|
||||
logger.info(
|
||||
{
|
||||
pollIntervalMs: config.WORKER_POLL_INTERVAL_MS,
|
||||
leaseSeconds: config.JOB_LEASE_SECONDS,
|
||||
repositoryRefreshScheduleMs: config.REPOSITORY_REFRESH_SCHEDULE_MS,
|
||||
repositoryStaleAfterHours: config.REPOSITORY_STALE_AFTER_HOURS,
|
||||
},
|
||||
'worker started',
|
||||
)
|
||||
const repositoryRefreshScheduler = new RepositoryRefreshScheduler()
|
||||
await runWorkerLoop({
|
||||
store: new PostgresJobStore(),
|
||||
handlers: createJobHandlers(
|
||||
() => new Date(),
|
||||
createGiteaSnapshotDependencies(config),
|
||||
),
|
||||
workerId,
|
||||
nextLeaseId: randomUUID,
|
||||
leaseDurationMs: config.JOB_LEASE_SECONDS * 1_000,
|
||||
pollIntervalMs: config.WORKER_POLL_INTERVAL_MS,
|
||||
signal: controller.signal,
|
||||
scheduleIntervalMs: config.REPOSITORY_REFRESH_SCHEDULE_MS,
|
||||
schedule: async () => {
|
||||
const result = await repositoryRefreshScheduler.plan({
|
||||
staleAfterHours: config.REPOSITORY_STALE_AFTER_HOURS,
|
||||
})
|
||||
if (result.queued > 0)
|
||||
logger.info(result, 'repository refreshes planned')
|
||||
},
|
||||
onScheduleError: (error) => {
|
||||
logger.error(
|
||||
{ errorType: error instanceof Error ? error.name : 'unknown' },
|
||||
'repository refresh planning failed',
|
||||
)
|
||||
},
|
||||
onResult: (result) => {
|
||||
if (result.outcome !== 'idle') {
|
||||
logger.info(
|
||||
{
|
||||
outcome: result.outcome,
|
||||
jobId: result.jobId,
|
||||
jobType: result.jobType,
|
||||
errorCode: result.errorCode,
|
||||
},
|
||||
'worker job processed',
|
||||
)
|
||||
}
|
||||
},
|
||||
onPollError: (error) => {
|
||||
logger.error(
|
||||
{ errorType: error instanceof Error ? error.name : 'unknown' },
|
||||
'worker poll failed',
|
||||
)
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
process.removeListener('SIGINT', shutdown)
|
||||
process.removeListener('SIGTERM', shutdown)
|
||||
await closeDatabase()
|
||||
logger.info('worker stopped')
|
||||
}
|
||||
}
|
||||
|
||||
const entryPoint = process.argv[1]
|
||||
if (entryPoint && import.meta.url === pathToFileURL(entryPoint).href) {
|
||||
await main()
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { parseEnvironment } from '@devrunbook/config'
|
||||
import {
|
||||
DrizzleGiteaIntegrationStore,
|
||||
RepositorySnapshotStore,
|
||||
} from '@devrunbook/db'
|
||||
import {
|
||||
decryptIntegrationSecret,
|
||||
GiteaAdapter,
|
||||
GiteaClient,
|
||||
type GiteaNetworkPolicy,
|
||||
type IntegrationKeyRing,
|
||||
type SecretEnvelopeV1,
|
||||
} from '@devrunbook/integrations'
|
||||
|
||||
import type { RepositorySnapshotDependencies } from './repository-snapshot'
|
||||
|
||||
type Configuration = ReturnType<typeof parseEnvironment>
|
||||
|
||||
function allowedHosts(value: string): readonly string[] {
|
||||
return Object.freeze([
|
||||
...new Set(
|
||||
value
|
||||
.split(',')
|
||||
.map((host) => host.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
function keyRing(config: Configuration): IntegrationKeyRing {
|
||||
return {
|
||||
activeVersion: config.INTEGRATION_ENCRYPTION_KEY_VERSION,
|
||||
keys: Object.freeze({
|
||||
...config.INTEGRATION_ENCRYPTION_OLD_KEYS,
|
||||
[config.INTEGRATION_ENCRYPTION_KEY_VERSION]:
|
||||
config.INTEGRATION_ENCRYPTION_KEY,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function envelope(value: {
|
||||
readonly keyVersion: string
|
||||
readonly nonce: Uint8Array
|
||||
readonly ciphertext: Uint8Array
|
||||
readonly authTag: Uint8Array
|
||||
}): SecretEnvelopeV1 {
|
||||
return {
|
||||
algorithm: 'AES-256-GCM',
|
||||
envelopeVersion: 1,
|
||||
keyVersion: value.keyVersion,
|
||||
nonce: Buffer.from(value.nonce).toString('base64'),
|
||||
ciphertext: Buffer.from(value.ciphertext).toString('base64'),
|
||||
authenticationTag: Buffer.from(value.authTag).toString('base64'),
|
||||
}
|
||||
}
|
||||
|
||||
export function createGiteaSnapshotDependencies(
|
||||
config: Configuration,
|
||||
): RepositorySnapshotDependencies {
|
||||
const integrations = new DrizzleGiteaIntegrationStore()
|
||||
const persistence = new RepositorySnapshotStore()
|
||||
return {
|
||||
persistence,
|
||||
async createReader({ workspaceId, integrationId }) {
|
||||
const stored = await integrations.findWithSecretForWorkspace(
|
||||
workspaceId,
|
||||
integrationId,
|
||||
)
|
||||
if (!stored || stored.integration.status === 'disabled') {
|
||||
throw new Error('Gitea snapshot integration is unavailable')
|
||||
}
|
||||
const token = decryptIntegrationSecret(
|
||||
envelope(stored.secret),
|
||||
{
|
||||
workspaceId,
|
||||
integrationId,
|
||||
secretKind: 'access-token',
|
||||
},
|
||||
keyRing(config),
|
||||
)
|
||||
const policy: GiteaNetworkPolicy = {
|
||||
privateNetworkPolicy: config.GITEA_PRIVATE_NETWORK_POLICY,
|
||||
allowedHosts: allowedHosts(config.GITEA_ALLOWED_HOSTS),
|
||||
allowInsecureHttp: stored.allowPrivateHttp,
|
||||
requestTimeoutMs: stored.requestTimeoutMs,
|
||||
maxResponseBytes: config.MAX_EVIDENCE_BYTES,
|
||||
maxRedirects: config.GITEA_MAX_REDIRECTS,
|
||||
}
|
||||
return new GiteaAdapter(
|
||||
new GiteaClient({
|
||||
baseUrl: stored.integration.baseUrl,
|
||||
token,
|
||||
networkPolicy: policy,
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { JobRecord } from '@devrunbook/application'
|
||||
import { createJobHandlers } from './handlers'
|
||||
|
||||
function job(payload: JobRecord['payload']): JobRecord {
|
||||
const timestamp = new Date('2026-07-27T12:00:00.000Z')
|
||||
return {
|
||||
id: '00000000-0000-4000-8000-000000000911',
|
||||
workspaceId: null,
|
||||
type: 'system.health-probe',
|
||||
state: 'running',
|
||||
idempotencyKey: 'probe-1',
|
||||
payload,
|
||||
progress: {},
|
||||
attemptCount: 1,
|
||||
maxAttempts: 3,
|
||||
leaseOwner: 'worker:lease',
|
||||
leaseExpiresAt: timestamp,
|
||||
availableAt: timestamp,
|
||||
startedAt: timestamp,
|
||||
finishedAt: null,
|
||||
errorCode: null,
|
||||
errorDetailRedacted: null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
describe('worker job handlers', () => {
|
||||
it('handles a durable health probe as data without executing payload text', async () => {
|
||||
const handler = createJobHandlers(
|
||||
() => new Date('2026-07-27T12:01:00.000Z'),
|
||||
)['system.health-probe']
|
||||
if (!handler) throw new Error('Health probe handler is not registered')
|
||||
|
||||
await expect(
|
||||
handler(job({ requestedBy: '$(unsafe command)' }), {
|
||||
signal: new AbortController().signal,
|
||||
heartbeat: async () => true,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: 'ok',
|
||||
checkedAt: '2026-07-27T12:01:00.000Z',
|
||||
workerProtocol: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unsupported payload fields permanently', async () => {
|
||||
const handler = createJobHandlers()['system.health-probe']
|
||||
if (!handler) throw new Error('Health probe handler is not registered')
|
||||
|
||||
await expect(
|
||||
handler(job({ command: 'whoami' }), {
|
||||
signal: new AbortController().signal,
|
||||
heartbeat: async () => true,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'health_probe_payload_invalid' })
|
||||
})
|
||||
|
||||
it('registers repository collection only when explicit dependencies are supplied', () => {
|
||||
expect(createJobHandlers()['gitea.repository-snapshot']).toBeUndefined()
|
||||
expect(
|
||||
createJobHandlers(undefined, {
|
||||
createReader: async () => {
|
||||
throw new Error('not used')
|
||||
},
|
||||
persistence: {
|
||||
resolveCollectionTarget: async () => null,
|
||||
completeCollection: async () => null,
|
||||
failCollection: async () => false,
|
||||
},
|
||||
})['gitea.repository-snapshot'],
|
||||
).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
PermanentJobError,
|
||||
type JobHandlers,
|
||||
type JobJsonValue,
|
||||
} from '@devrunbook/application'
|
||||
import {
|
||||
createRepositorySnapshotJobHandler,
|
||||
type RepositorySnapshotDependencies,
|
||||
} from './repository-snapshot'
|
||||
|
||||
function validateHealthProbePayload(payload: JobJsonValue): void {
|
||||
if (
|
||||
payload === null ||
|
||||
Array.isArray(payload) ||
|
||||
typeof payload !== 'object'
|
||||
) {
|
||||
throw new PermanentJobError(
|
||||
'health_probe_payload_invalid',
|
||||
'Health probe payload must be a JSON object',
|
||||
)
|
||||
}
|
||||
const objectPayload = payload as { readonly [key: string]: JobJsonValue }
|
||||
const keys = Object.keys(objectPayload)
|
||||
if (keys.some((key) => key !== 'requestedBy')) {
|
||||
throw new PermanentJobError(
|
||||
'health_probe_payload_invalid',
|
||||
'Health probe payload contains unsupported fields',
|
||||
)
|
||||
}
|
||||
const requestedBy = objectPayload.requestedBy
|
||||
if (
|
||||
requestedBy !== undefined &&
|
||||
(typeof requestedBy !== 'string' || requestedBy.length > 100)
|
||||
) {
|
||||
throw new PermanentJobError(
|
||||
'health_probe_payload_invalid',
|
||||
'Health probe requestedBy must be a string of at most 100 characters',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The baseline probe proves durable worker dispatch without evaluating payload
|
||||
* text or invoking a shell. Product job handlers are added explicitly here.
|
||||
*/
|
||||
export function createJobHandlers(
|
||||
now: () => Date = () => new Date(),
|
||||
repositorySnapshot?: RepositorySnapshotDependencies,
|
||||
): JobHandlers {
|
||||
const handlers: JobHandlers = {
|
||||
'system.health-probe': async (job) => {
|
||||
validateHealthProbePayload(job.payload)
|
||||
return {
|
||||
status: 'ok',
|
||||
checkedAt: now().toISOString(),
|
||||
workerProtocol: 1,
|
||||
}
|
||||
},
|
||||
...(repositorySnapshot
|
||||
? {
|
||||
'gitea.repository-snapshot':
|
||||
createRepositorySnapshotJobHandler(repositorySnapshot),
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
return Object.freeze(handlers)
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { JobRecord } from '@devrunbook/application'
|
||||
import {
|
||||
createRepositorySnapshotJobHandler,
|
||||
RepositorySnapshotSourceError,
|
||||
type RepositorySnapshotDependencies,
|
||||
type RepositorySnapshotPersistence,
|
||||
type RepositorySnapshotReader,
|
||||
type SnapshotCapability,
|
||||
} from './repository-snapshot'
|
||||
|
||||
const ids = {
|
||||
job: '00000000-0000-4000-8000-000000000901',
|
||||
workspace: '00000000-0000-4000-8000-000000000902',
|
||||
repository: '00000000-0000-4000-8000-000000000903',
|
||||
integration: '00000000-0000-4000-8000-000000000904',
|
||||
snapshot: '00000000-0000-4000-8000-000000000905',
|
||||
user: '00000000-0000-4000-8000-000000000906',
|
||||
revision: '00000000-0000-4000-8000-000000000907',
|
||||
}
|
||||
|
||||
function job(overrides: Partial<JobRecord> = {}): JobRecord {
|
||||
const timestamp = new Date('2026-07-27T12:00:00.000Z')
|
||||
return {
|
||||
id: ids.job,
|
||||
workspaceId: ids.workspace,
|
||||
type: 'gitea.repository-snapshot',
|
||||
state: 'running',
|
||||
idempotencyKey: 'repository-snapshot-1',
|
||||
payload: {
|
||||
schemaVersion: 1,
|
||||
workspaceId: ids.workspace,
|
||||
repositoryId: ids.repository,
|
||||
integrationId: ids.integration,
|
||||
requestedBy: ids.user,
|
||||
collectionMode: 'bounded-read-only',
|
||||
profileRevisionPolicy: 'create-initial-only',
|
||||
},
|
||||
progress: {},
|
||||
attemptCount: 1,
|
||||
maxAttempts: 3,
|
||||
leaseOwner: 'worker:lease',
|
||||
leaseExpiresAt: timestamp,
|
||||
availableAt: timestamp,
|
||||
startedAt: timestamp,
|
||||
finishedAt: null,
|
||||
errorCode: null,
|
||||
errorDetailRedacted: null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const supported: SnapshotCapability = {
|
||||
status: 'supported',
|
||||
checkedAt: '2026-07-27T12:00:00.000Z',
|
||||
}
|
||||
|
||||
function reader(
|
||||
overrides: Partial<RepositorySnapshotReader> = {},
|
||||
): RepositorySnapshotReader {
|
||||
const files: Record<string, string> = {
|
||||
'package.json': JSON.stringify({
|
||||
scripts: { test: '$(unsafe-command)', build: 'node build.js' },
|
||||
dependencies: { next: '15.0.0', react: '19.0.0' },
|
||||
}),
|
||||
'pnpm-lock.yaml': 'lockfileVersion: 9',
|
||||
'README.md': 'setup text that is untrusted and never executed',
|
||||
}
|
||||
return {
|
||||
getRepository: vi.fn(async () => ({
|
||||
id: '42',
|
||||
owner: 'devrunbook',
|
||||
name: 'platform',
|
||||
fullName: 'devrunbook/platform',
|
||||
defaultBranch: 'main',
|
||||
archived: false,
|
||||
private: true,
|
||||
})),
|
||||
getCapabilities: vi.fn(async () => ({
|
||||
branches: supported,
|
||||
tags: supported,
|
||||
releases: supported,
|
||||
contents: supported,
|
||||
'branch-protection': supported,
|
||||
workflows: supported,
|
||||
})),
|
||||
listTree: vi.fn<RepositorySnapshotReader['listTree']>(
|
||||
async (_repository, _ref, page = 1) =>
|
||||
page === 1
|
||||
? [
|
||||
{
|
||||
path: 'src',
|
||||
kind: 'directory' as const,
|
||||
sha: 'd1',
|
||||
size: null,
|
||||
},
|
||||
{
|
||||
path: 'tests',
|
||||
kind: 'directory' as const,
|
||||
sha: 'd2',
|
||||
size: null,
|
||||
},
|
||||
...Object.entries(files).map(([path, value]) => ({
|
||||
path,
|
||||
kind: 'file' as const,
|
||||
sha: `sha-${path}`,
|
||||
size: Buffer.byteLength(value),
|
||||
})),
|
||||
]
|
||||
: [],
|
||||
),
|
||||
getFile: vi.fn(async (_repository, _ref, path) => {
|
||||
const bytes = Buffer.from(files[path]!, 'utf8')
|
||||
return { path, sha: `sha-${path}`, size: bytes.byteLength, bytes }
|
||||
}),
|
||||
getBranches: vi.fn(async () => [
|
||||
{ name: 'main', commitSha: 'abc123', protected: true },
|
||||
]),
|
||||
getTags: vi.fn(async () => ['v2', 'v1']),
|
||||
getReleases: vi.fn(async () => ['v1']),
|
||||
getGovernanceEvidence: vi.fn(async () => ({ protected: true })),
|
||||
getWorkflowEvidence: vi.fn(async () => ({ workflows: 1 })),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function dependencies(
|
||||
source = reader(),
|
||||
overrides: Partial<RepositorySnapshotDependencies> = {},
|
||||
) {
|
||||
const completeCollection = vi.fn<
|
||||
RepositorySnapshotPersistence['completeCollection']
|
||||
>(async () => ({
|
||||
snapshotId: ids.snapshot,
|
||||
profileRevision: {
|
||||
id: ids.revision,
|
||||
revisionNumber: 1,
|
||||
contentDigest: 'a'.repeat(64),
|
||||
},
|
||||
findingCount: 1,
|
||||
}))
|
||||
const failCollection = vi.fn<RepositorySnapshotPersistence['failCollection']>(
|
||||
async () => true,
|
||||
)
|
||||
const resolveCollectionTarget = vi.fn<
|
||||
RepositorySnapshotPersistence['resolveCollectionTarget']
|
||||
>(async () => ({
|
||||
snapshotId: ids.snapshot,
|
||||
owner: 'devrunbook',
|
||||
name: 'platform',
|
||||
}))
|
||||
return {
|
||||
dependencies: {
|
||||
createReader: vi.fn(async () => source),
|
||||
persistence: {
|
||||
resolveCollectionTarget,
|
||||
completeCollection,
|
||||
failCollection,
|
||||
},
|
||||
now: () => new Date('2026-07-27T12:05:00.000Z'),
|
||||
...overrides,
|
||||
} satisfies RepositorySnapshotDependencies,
|
||||
completeCollection,
|
||||
failCollection,
|
||||
}
|
||||
}
|
||||
|
||||
const context = {
|
||||
signal: new AbortController().signal,
|
||||
heartbeat: vi.fn(async () => true),
|
||||
}
|
||||
|
||||
describe('repository snapshot job handler', () => {
|
||||
it('retries when queue dispatch races the snapshot target binding', async () => {
|
||||
const fixture = dependencies()
|
||||
fixture.dependencies.persistence.resolveCollectionTarget = async () => null
|
||||
const handler = createRepositorySnapshotJobHandler(fixture.dependencies)
|
||||
await expect(handler(job(), context)).rejects.toMatchObject({
|
||||
code: 'repository_snapshot_target_pending',
|
||||
})
|
||||
})
|
||||
|
||||
it('persists deterministic bounded evidence and never adopts manifest script text', async () => {
|
||||
const source = reader()
|
||||
const fixture = dependencies(source)
|
||||
const handler = createRepositorySnapshotJobHandler(fixture.dependencies)
|
||||
|
||||
await expect(handler(job(), context)).resolves.toEqual({
|
||||
status: 'complete',
|
||||
snapshotId: ids.snapshot,
|
||||
findingCount: 1,
|
||||
profileRevisionId: ids.revision,
|
||||
inspectedFiles: 3,
|
||||
inspectedBytes: 179,
|
||||
})
|
||||
|
||||
expect(fixture.completeCollection).toHaveBeenCalledOnce()
|
||||
const request = fixture.completeCollection.mock.calls[0]![0]
|
||||
const evidence = request.evidence as {
|
||||
collectionStatus: string
|
||||
files: readonly unknown[]
|
||||
proposedProfile: unknown
|
||||
}
|
||||
expect(request.profileRevisionPolicy).toBe('create-initial-only')
|
||||
expect(evidence.collectionStatus).toBe('complete')
|
||||
expect(request.evidence).not.toHaveProperty('rawContent')
|
||||
expect(evidence.files).toEqual([
|
||||
expect.objectContaining({ path: 'package.json' }),
|
||||
expect.objectContaining({ path: 'pnpm-lock.yaml' }),
|
||||
expect.objectContaining({ path: 'README.md' }),
|
||||
])
|
||||
expect(evidence.proposedProfile).toEqual(request.profile)
|
||||
const profile = request.profile as {
|
||||
spec: {
|
||||
commands: readonly {
|
||||
command: string
|
||||
confirmed: boolean
|
||||
safeForAgentSuggestion: boolean
|
||||
}[]
|
||||
}
|
||||
}
|
||||
expect(profile.spec.commands).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
command: 'pnpm test',
|
||||
confirmed: false,
|
||||
safeForAgentSuggestion: false,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
expect(JSON.stringify(request)).not.toContain('$(unsafe-command)')
|
||||
expect(source.getFile).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('skips file analysis and cancels collection when preflight evidence is unchanged', async () => {
|
||||
const source = reader()
|
||||
const fixture = dependencies(source)
|
||||
const cancelUnchangedCollection = vi.fn(async () => true)
|
||||
const baseline = dependencies(source)
|
||||
await createRepositorySnapshotJobHandler(baseline.dependencies)(
|
||||
job(),
|
||||
context,
|
||||
)
|
||||
const evidence = baseline.completeCollection.mock.calls[0]![0].evidence as {
|
||||
preflightDigest: string
|
||||
}
|
||||
const treeCallsBeforePreflight = vi.mocked(source.listTree).mock.calls
|
||||
.length
|
||||
fixture.dependencies.persistence.getLastPreflightDigest = async () =>
|
||||
evidence.preflightDigest
|
||||
fixture.dependencies.persistence.cancelUnchangedCollection =
|
||||
cancelUnchangedCollection
|
||||
const result = await createRepositorySnapshotJobHandler(
|
||||
fixture.dependencies,
|
||||
)(job(), context)
|
||||
|
||||
expect(result).toMatchObject({ status: 'unchanged', inspectedFiles: 0 })
|
||||
expect(cancelUnchangedCollection).toHaveBeenCalledOnce()
|
||||
expect(source.listTree).toHaveBeenCalledTimes(treeCallsBeforePreflight)
|
||||
expect(fixture.completeCollection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('completes explicitly partial and accepts no automatic profile revision', async () => {
|
||||
const source = reader({
|
||||
getCapabilities: vi.fn<RepositorySnapshotReader['getCapabilities']>(
|
||||
async () => ({
|
||||
contents: supported,
|
||||
branches: {
|
||||
status: 'forbidden',
|
||||
checkedAt: '2026-07-27T12:00:00.000Z',
|
||||
errorCode: 'PERMISSION_MISSING',
|
||||
},
|
||||
tags: supported,
|
||||
releases: {
|
||||
status: 'unsupported',
|
||||
checkedAt: '2026-07-27T12:00:00.000Z',
|
||||
},
|
||||
'branch-protection': {
|
||||
status: 'forbidden',
|
||||
checkedAt: '2026-07-27T12:00:00.000Z',
|
||||
},
|
||||
workflows: supported,
|
||||
}),
|
||||
),
|
||||
})
|
||||
const fixture = dependencies(source)
|
||||
fixture.completeCollection.mockResolvedValueOnce({
|
||||
snapshotId: ids.snapshot,
|
||||
profileRevision: null,
|
||||
findingCount: 2,
|
||||
})
|
||||
|
||||
await expect(
|
||||
createRepositorySnapshotJobHandler(fixture.dependencies)(job(), context),
|
||||
).resolves.toMatchObject({ status: 'partial', profileRevisionId: null })
|
||||
|
||||
const request = fixture.completeCollection.mock.calls[0]![0]
|
||||
const evidence = request.evidence as { limitations: readonly string[] }
|
||||
expect(evidence.limitations).toEqual([
|
||||
'branch-protection',
|
||||
'branches',
|
||||
'releases',
|
||||
])
|
||||
expect(request.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ ruleId: 'REPO_SNAPSHOT_PARTIAL' }),
|
||||
]),
|
||||
)
|
||||
expect(request.findings).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ ruleId: 'REPO_NO_RELEASE_HISTORY' }),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('skips sensitive paths and enforces both file-count and byte limits', async () => {
|
||||
const content = '12345678'
|
||||
const source = reader({
|
||||
listTree: vi.fn<RepositorySnapshotReader['listTree']>(
|
||||
async (_repository, _ref, page = 1) =>
|
||||
page === 1
|
||||
? [
|
||||
{ path: '.env', kind: 'file' as const, sha: 'a', size: 1 },
|
||||
{
|
||||
path: 'private.pem',
|
||||
kind: 'file' as const,
|
||||
sha: 'b',
|
||||
size: 1,
|
||||
},
|
||||
{ path: 'README.md', kind: 'file' as const, sha: 'c', size: 8 },
|
||||
{
|
||||
path: 'package.json',
|
||||
kind: 'file' as const,
|
||||
sha: 'd',
|
||||
size: 8,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
getFile: vi.fn(async (_repository, _ref, path) => ({
|
||||
path,
|
||||
sha: path,
|
||||
size: 8,
|
||||
bytes: Buffer.from(content),
|
||||
})),
|
||||
})
|
||||
const fixture = dependencies(source, {
|
||||
limits: {
|
||||
maximumFiles: 2,
|
||||
maximumFileBytes: 8,
|
||||
maximumTotalBytes: 8,
|
||||
maximumTreePages: 2,
|
||||
},
|
||||
})
|
||||
|
||||
await createRepositorySnapshotJobHandler(fixture.dependencies)(
|
||||
job(),
|
||||
context,
|
||||
)
|
||||
|
||||
expect(source.getFile).toHaveBeenCalledOnce()
|
||||
expect(source.getFile).toHaveBeenCalledWith(
|
||||
{ owner: 'devrunbook', name: 'platform' },
|
||||
'main',
|
||||
'package.json',
|
||||
8,
|
||||
)
|
||||
const request = fixture.completeCollection.mock.calls[0]![0]
|
||||
const evidence = request.evidence as { limitations: readonly string[] }
|
||||
expect(evidence.limitations).toEqual([
|
||||
'inspected-byte-limit',
|
||||
'inspected-file-count-limit',
|
||||
])
|
||||
expect(JSON.stringify(request)).not.toContain('.env')
|
||||
expect(JSON.stringify(request)).not.toContain('private.pem')
|
||||
})
|
||||
|
||||
it('retries safe transient source failures without prematurely failing the snapshot', async () => {
|
||||
const fixture = dependencies(reader(), {
|
||||
createReader: vi.fn(async () => {
|
||||
throw new RepositorySnapshotSourceError('RATE_LIMITED', true)
|
||||
}),
|
||||
})
|
||||
|
||||
await expect(
|
||||
createRepositorySnapshotJobHandler(fixture.dependencies)(job(), context),
|
||||
).rejects.toMatchObject({
|
||||
name: 'TransientJobError',
|
||||
code: 'repository_snapshot_rate_limited',
|
||||
})
|
||||
expect(fixture.failCollection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records only a safe code when the final source attempt fails', async () => {
|
||||
const fixture = dependencies(reader(), {
|
||||
createReader: vi.fn(async () => {
|
||||
throw new RepositorySnapshotSourceError('AUTH_INVALID', false)
|
||||
}),
|
||||
})
|
||||
|
||||
await expect(
|
||||
createRepositorySnapshotJobHandler(fixture.dependencies)(
|
||||
job({ attemptCount: 3 }),
|
||||
context,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
name: 'PermanentJobError',
|
||||
code: 'repository_snapshot_auth_invalid',
|
||||
})
|
||||
expect(fixture.failCollection).toHaveBeenCalledWith({
|
||||
workspaceId: ids.workspace,
|
||||
snapshotId: ids.snapshot,
|
||||
safeCode: 'AUTH_INVALID',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects command-like and extra payload data before resolving a reader', async () => {
|
||||
const fixture = dependencies()
|
||||
await expect(
|
||||
createRepositorySnapshotJobHandler(fixture.dependencies)(
|
||||
job({
|
||||
payload: {
|
||||
schemaVersion: 1,
|
||||
workspaceId: ids.workspace,
|
||||
repositoryId: ids.repository,
|
||||
integrationId: ids.integration,
|
||||
requestedBy: ids.user,
|
||||
collectionMode: 'bounded-read-only',
|
||||
profileRevisionPolicy: 'create-initial-only',
|
||||
command: 'whoami',
|
||||
},
|
||||
}),
|
||||
context,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: 'repository_snapshot_payload_invalid',
|
||||
})
|
||||
expect(fixture.dependencies.createReader).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { JobStore } from '@devrunbook/application'
|
||||
import { runWorkerLoop } from './worker-loop'
|
||||
|
||||
describe('worker loop', () => {
|
||||
it('stops cleanly after an idle poll is aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
const store = {
|
||||
claim: vi.fn(async () => {
|
||||
controller.abort()
|
||||
return null
|
||||
}),
|
||||
} as unknown as JobStore
|
||||
await runWorkerLoop({
|
||||
store,
|
||||
handlers: {},
|
||||
workerId: 'worker-test',
|
||||
nextLeaseId: () => 'lease-test',
|
||||
leaseDurationMs: 30_000,
|
||||
pollIntervalMs: 2_000,
|
||||
signal: controller.signal,
|
||||
})
|
||||
expect(store.claim).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('runs periodic planning before polling and contains planner failures', async () => {
|
||||
const controller = new AbortController()
|
||||
const order: string[] = []
|
||||
const store = {
|
||||
claim: vi.fn(async () => {
|
||||
order.push('poll')
|
||||
controller.abort()
|
||||
return null
|
||||
}),
|
||||
} as unknown as JobStore
|
||||
await runWorkerLoop({
|
||||
store,
|
||||
handlers: {},
|
||||
workerId: 'worker-test',
|
||||
nextLeaseId: () => 'lease-test',
|
||||
leaseDurationMs: 30_000,
|
||||
pollIntervalMs: 2_000,
|
||||
signal: controller.signal,
|
||||
schedule: async () => {
|
||||
order.push('schedule')
|
||||
throw new Error('temporary planner failure')
|
||||
},
|
||||
onScheduleError: vi.fn(),
|
||||
})
|
||||
expect(order).toEqual(['schedule', 'poll'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
processNextJob,
|
||||
type JobHandlers,
|
||||
type JobStore,
|
||||
type ProcessJobResult,
|
||||
} from '@devrunbook/application'
|
||||
|
||||
export interface WorkerLoopOptions {
|
||||
readonly store: JobStore
|
||||
readonly handlers: JobHandlers
|
||||
readonly workerId: string
|
||||
readonly nextLeaseId: () => string
|
||||
readonly leaseDurationMs: number
|
||||
readonly pollIntervalMs: number
|
||||
readonly signal: AbortSignal
|
||||
readonly onResult?: (result: ProcessJobResult) => void
|
||||
readonly onPollError?: (error: unknown) => void
|
||||
readonly schedule?: () => Promise<void>
|
||||
readonly scheduleIntervalMs?: number
|
||||
readonly onScheduleError?: (error: unknown) => void
|
||||
readonly now?: () => number
|
||||
}
|
||||
|
||||
function wait(milliseconds: number, signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) return Promise.resolve()
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(done, milliseconds)
|
||||
timer.unref()
|
||||
signal.addEventListener('abort', done, { once: true })
|
||||
|
||||
function done() {
|
||||
clearTimeout(timer)
|
||||
signal.removeEventListener('abort', done)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Sequential polling prevents one worker process from over-claiming jobs. */
|
||||
export async function runWorkerLoop(options: WorkerLoopOptions): Promise<void> {
|
||||
const now = options.now ?? Date.now
|
||||
let nextScheduleAt = 0
|
||||
while (!options.signal.aborted) {
|
||||
if (options.schedule && now() >= nextScheduleAt) {
|
||||
nextScheduleAt = now() + (options.scheduleIntervalMs ?? 300_000)
|
||||
try {
|
||||
await options.schedule()
|
||||
} catch (error) {
|
||||
options.onScheduleError?.(error)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await processNextJob({
|
||||
store: options.store,
|
||||
handlers: options.handlers,
|
||||
workerId: options.workerId,
|
||||
nextLeaseId: options.nextLeaseId,
|
||||
leaseDurationMs: options.leaseDurationMs,
|
||||
})
|
||||
options.onResult?.(result)
|
||||
if (result.outcome === 'idle') {
|
||||
await wait(options.pollIntervalMs, options.signal)
|
||||
}
|
||||
} catch (error) {
|
||||
options.onPollError?.(error)
|
||||
await wait(options.pollIntervalMs, options.signal)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { enforceArtifactRetention } from '@devrunbook/application'
|
||||
import { LocalArtifactStorage } from '@devrunbook/artifacts'
|
||||
import { parseEnvironment } from '@devrunbook/config'
|
||||
import { closeDatabase, DrizzleArtifactRetentionStore } from '@devrunbook/db'
|
||||
|
||||
export async function runArtifactRetention(
|
||||
environment: Record<string, string | undefined> = process.env,
|
||||
) {
|
||||
const config = parseEnvironment(environment)
|
||||
const store = new DrizzleArtifactRetentionStore()
|
||||
const storage = new LocalArtifactStorage(config.ARTIFACT_ROOT)
|
||||
const totals = { scanned: 0, deleted: 0, missing: 0 }
|
||||
while (true) {
|
||||
const result = await enforceArtifactRetention({
|
||||
store,
|
||||
storage,
|
||||
limit: 500,
|
||||
})
|
||||
totals.scanned += result.scanned
|
||||
totals.deleted += result.deleted
|
||||
totals.missing += result.missing
|
||||
if (result.scanned < 500) return Object.freeze(totals)
|
||||
}
|
||||
}
|
||||
|
||||
const entryPoint = process.argv[1]
|
||||
if (entryPoint && import.meta.url === pathToFileURL(entryPoint).href) {
|
||||
try {
|
||||
const result = await runArtifactRetention()
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ outcome: 'success', ...result })}\n`,
|
||||
)
|
||||
} finally {
|
||||
await closeDatabase()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { runOperatorPasswordReset } from './password-reset'
|
||||
|
||||
const environment = {
|
||||
SESSION_SECRET: 's'.repeat(32),
|
||||
PUBLIC_BASE_URL: 'https://runbook.example.test',
|
||||
}
|
||||
|
||||
describe('operator password reset command', () => {
|
||||
it('accepts only an email and emits exactly one reset URL line', async () => {
|
||||
const output = vi.fn()
|
||||
const issue = vi.fn(async () => ({
|
||||
resetUrl:
|
||||
'https://runbook.example.test/reset-password#token=secret-token',
|
||||
expiresAt: new Date('2026-07-27T12:30:00Z'),
|
||||
}))
|
||||
|
||||
await runOperatorPasswordReset(
|
||||
['owner@example.test'],
|
||||
environment,
|
||||
output,
|
||||
issue,
|
||||
)
|
||||
|
||||
expect(issue).toHaveBeenCalledWith({
|
||||
email: 'owner@example.test',
|
||||
publicBaseUrl: 'https://runbook.example.test',
|
||||
})
|
||||
expect(output).toHaveBeenCalledOnce()
|
||||
expect(output).toHaveBeenCalledWith(
|
||||
'https://runbook.example.test/reset-password#token=secret-token',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a second argument instead of accepting a new password', async () => {
|
||||
const issue = vi.fn()
|
||||
await expect(
|
||||
runOperatorPasswordReset(
|
||||
['owner@example.test', 'new-password'],
|
||||
environment,
|
||||
vi.fn(),
|
||||
issue,
|
||||
),
|
||||
).rejects.toThrow('Usage:')
|
||||
expect(issue).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import {
|
||||
issueOperatorPasswordResetToken,
|
||||
TokenDigester,
|
||||
type IssueOperatorPasswordResetRequest,
|
||||
type IssuedPasswordReset,
|
||||
} from '@devrunbook/application'
|
||||
import { closeDatabase, DrizzlePasswordResetStore } from '@devrunbook/db'
|
||||
|
||||
type IssueReset = (
|
||||
request: IssueOperatorPasswordResetRequest,
|
||||
) => Promise<IssuedPasswordReset>
|
||||
|
||||
export async function runOperatorPasswordReset(
|
||||
arguments_: readonly string[],
|
||||
environment: Record<string, string | undefined>,
|
||||
writeOutput: (value: string) => void,
|
||||
issueReset?: IssueReset,
|
||||
): Promise<void> {
|
||||
if (arguments_.length !== 1 || !arguments_[0]) {
|
||||
throw new Error(
|
||||
'Usage: pnpm --filter @devrunbook/worker operator:password-reset -- <user-email>',
|
||||
)
|
||||
}
|
||||
const sessionSecret = environment.SESSION_SECRET
|
||||
const publicBaseUrl = environment.PUBLIC_BASE_URL
|
||||
if (!sessionSecret || sessionSecret.length < 32) {
|
||||
throw new Error('SESSION_SECRET must contain at least 32 characters')
|
||||
}
|
||||
if (!publicBaseUrl) throw new Error('PUBLIC_BASE_URL is required')
|
||||
|
||||
const issue =
|
||||
issueReset ??
|
||||
((request) =>
|
||||
issueOperatorPasswordResetToken(
|
||||
{
|
||||
store: new DrizzlePasswordResetStore(),
|
||||
digester: new TokenDigester(Buffer.from(sessionSecret, 'utf8')),
|
||||
},
|
||||
request,
|
||||
))
|
||||
const result = await issue({
|
||||
email: arguments_[0],
|
||||
publicBaseUrl,
|
||||
})
|
||||
|
||||
// Successful stdout is deliberately one line containing only the reset URL.
|
||||
writeOutput(result.resetUrl)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
await runOperatorPasswordReset(
|
||||
process.argv.slice(2),
|
||||
process.env,
|
||||
(value) => console.log(value),
|
||||
)
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Password reset failed'
|
||||
console.error(message)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
await closeDatabase()
|
||||
}
|
||||
}
|
||||
|
||||
const entryPoint = process.argv[1]
|
||||
if (entryPoint && import.meta.url === pathToFileURL(entryPoint).href) {
|
||||
await main()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "dist", "rootDir": "src", "types": ["node"] },
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user