mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 18:08:20 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@qinglong/local-api",
|
||||
"version": "3.0.0-alpha.0",
|
||||
"private": true,
|
||||
"description": "QingLong 3.0 optional single-process authenticated Local HTTP API",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=24.18.0 <25"
|
||||
},
|
||||
"main": "dist/application-runtime/localApiProductSurface.js",
|
||||
"types": "dist/application-runtime/localApiProductSurface.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/application-runtime/localApiProductSurface.d.ts",
|
||||
"require": "./dist/application-runtime/localApiProductSurface.js",
|
||||
"default": "./dist/application-runtime/localApiProductSurface.js"
|
||||
},
|
||||
"./config": {
|
||||
"types": "./dist/production-process/config.d.ts",
|
||||
"require": "./dist/production-process/config.js",
|
||||
"default": "./dist/production-process/config.js"
|
||||
},
|
||||
"./process": {
|
||||
"types": "./dist/production-process/processApplication.d.ts",
|
||||
"require": "./dist/production-process/processApplication.js",
|
||||
"default": "./dist/production-process/processApplication.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*.js",
|
||||
"dist/**/*.d.ts"
|
||||
],
|
||||
"bin": {
|
||||
"ql3-local-api": "dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"check": "node ../../scripts/ql3-build-package-closure.cjs && tsc -p tsconfig.json --noEmit",
|
||||
"test": "node ../../scripts/ql3-build-package-closure.cjs && node --test test/*.test.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@qinglong/local-application": "workspace:*",
|
||||
"@qinglong/local-command-file": "workspace:*",
|
||||
"@qinglong/local-owner-console": "workspace:*",
|
||||
"@qinglong/runtime-core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@qinglong/local-sqlite": "workspace:*",
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
normalizeSecurityPolicyDecision,
|
||||
type SecurityPolicyDecision,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditOutcome,
|
||||
type SecurityAuditSink,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
import type { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
|
||||
import type { LocalApiCredentialAuthenticator } from '../authentication/credentialAuthenticator';
|
||||
import type { BoundedRunListInput } from '@qinglong/runtime-core/bounded-run-list-projection';
|
||||
import type { BoundedRunEventListInput } from '@qinglong/runtime-core/bounded-run-event-list-projection';
|
||||
import type { BoundedRunStepListInput } from '@qinglong/runtime-core/bounded-run-step-list-projection';
|
||||
import type { BoundedTaskListInput } from '@qinglong/runtime-core/bounded-task-list-projection';
|
||||
import type { LocalApiRunEventListRoute } from '../run/runEventListRoute';
|
||||
import type { LocalApiRunListRoute } from '../run/runListRoute';
|
||||
import type { LocalApiRunReadRoute } from '../run/runReadRoute';
|
||||
import type { LocalApiRunStepListRoute } from '../run/runStepListRoute';
|
||||
import type { LocalApiRunCancellationRoute } from '../run/runCancellationRoute';
|
||||
import type { LocalApiTaskListRoute } from '../task/taskListRoute';
|
||||
import type { LocalApiTaskReadRoute } from '../task/taskReadRoute';
|
||||
import type { LocalApiTaskStartRoute } from '../task/taskStartRoute';
|
||||
import type { LocalApiResponse } from '../transport/contract';
|
||||
|
||||
export type LocalApiAdmissionOperation =
|
||||
| Readonly<{
|
||||
operationId: 'run.get';
|
||||
projectId: string;
|
||||
runId: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
operationId: 'run.list';
|
||||
projectId: string;
|
||||
input: Readonly<BoundedRunListInput>;
|
||||
}>
|
||||
| Readonly<{
|
||||
operationId: 'run.events.list';
|
||||
projectId: string;
|
||||
runId: string;
|
||||
input: Readonly<BoundedRunEventListInput>;
|
||||
}>
|
||||
| Readonly<{
|
||||
operationId: 'run.steps.list';
|
||||
projectId: string;
|
||||
runId: string;
|
||||
input: Readonly<BoundedRunStepListInput>;
|
||||
}>
|
||||
| Readonly<{
|
||||
operationId: 'run.cancel';
|
||||
projectId: string;
|
||||
runId: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
operationId: 'task.list';
|
||||
projectId: string;
|
||||
input: Readonly<BoundedTaskListInput>;
|
||||
}>
|
||||
| Readonly<{
|
||||
operationId: 'task.get';
|
||||
projectId: string;
|
||||
taskId: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
operationId: 'task.start';
|
||||
projectId: string;
|
||||
taskId: string;
|
||||
}>;
|
||||
|
||||
export interface LocalApiAdmissionRequest {
|
||||
readonly requestId: string;
|
||||
readonly operation: LocalApiAdmissionOperation;
|
||||
readonly authorization: string | null;
|
||||
readonly signal: AbortSignal;
|
||||
}
|
||||
|
||||
export interface LocalApiAdmission {
|
||||
prepare(
|
||||
request: Readonly<LocalApiAdmissionRequest>,
|
||||
): Promise<LocalApiResponse | Readonly<LocalApiPreparedAdmission>>;
|
||||
}
|
||||
|
||||
export interface LocalApiPreparedAdmission {
|
||||
readonly bodyMode: 'none' | 'json';
|
||||
readonly maximumBodyBytes: number;
|
||||
handle(body: unknown | null): Promise<LocalApiResponse>;
|
||||
}
|
||||
|
||||
export interface LocalApiAdmissionOptions {
|
||||
readonly authenticator: LocalApiCredentialAuthenticator;
|
||||
readonly policy: Pick<ProjectPolicyEngine, 'authorize'>;
|
||||
readonly audit: SecurityAuditSink;
|
||||
readonly runReadRoute: LocalApiRunReadRoute;
|
||||
readonly runListRoute: LocalApiRunListRoute;
|
||||
readonly runEventListRoute: LocalApiRunEventListRoute;
|
||||
readonly runStepListRoute: LocalApiRunStepListRoute;
|
||||
readonly runCancellationRoute: LocalApiRunCancellationRoute;
|
||||
readonly taskListRoute: LocalApiTaskListRoute;
|
||||
readonly taskReadRoute: LocalApiTaskReadRoute;
|
||||
readonly taskStartRoute: LocalApiTaskStartRoute;
|
||||
readonly now?: () => number;
|
||||
readonly randomUuid?: () => string;
|
||||
}
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
code: string,
|
||||
): Readonly<LocalApiResponse> {
|
||||
return Object.freeze({
|
||||
statusCode,
|
||||
body: Object.freeze({ code }),
|
||||
});
|
||||
}
|
||||
|
||||
function timestamp(now: () => number): number {
|
||||
const value = now();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new Error('clock is unavailable');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function recordAudit(
|
||||
audit: SecurityAuditSink,
|
||||
request: Readonly<LocalApiAdmissionRequest>,
|
||||
outcome: SecurityAuditOutcome,
|
||||
reasons: readonly string[],
|
||||
principal: Readonly<SecurityPrincipal> | null,
|
||||
fence: SecurityPolicyDecision['fence'],
|
||||
now: () => number,
|
||||
uuid: () => string,
|
||||
): Promise<void> {
|
||||
await audit.record(
|
||||
normalizeSecurityAuditRecord({
|
||||
eventId: uuid(),
|
||||
requestId: request.requestId,
|
||||
operationId: request.operation.operationId,
|
||||
projectId: request.operation.projectId,
|
||||
subject: principal?.subject ?? null,
|
||||
authenticationId: principal?.authenticationId ?? null,
|
||||
outcome,
|
||||
reasons,
|
||||
fence,
|
||||
occurredAtMs: timestamp(now),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function audited(
|
||||
action: () => Promise<void>,
|
||||
): Promise<LocalApiResponse | null> {
|
||||
try {
|
||||
await action();
|
||||
return null;
|
||||
} catch {
|
||||
return response(503, 'security_audit_unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
export function createLocalApiAdmission(
|
||||
options: LocalApiAdmissionOptions,
|
||||
): Readonly<LocalApiAdmission> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
typeof options.authenticator?.authenticate !== 'function' ||
|
||||
typeof options.policy?.authorize !== 'function' ||
|
||||
typeof options.audit?.record !== 'function' ||
|
||||
typeof options.runReadRoute?.handle !== 'function' ||
|
||||
typeof options.runListRoute?.handle !== 'function' ||
|
||||
typeof options.runEventListRoute?.handle !== 'function' ||
|
||||
typeof options.runStepListRoute?.handle !== 'function' ||
|
||||
typeof options.runCancellationRoute?.handle !== 'function' ||
|
||||
typeof options.taskListRoute?.handle !== 'function' ||
|
||||
typeof options.taskReadRoute?.handle !== 'function' ||
|
||||
typeof options.taskStartRoute?.handle !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.randomUuid !== undefined &&
|
||||
typeof options.randomUuid !== 'function')
|
||||
) {
|
||||
throw new TypeError('Local API admission options are invalid');
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const uuid = options.randomUuid ?? randomUUID;
|
||||
|
||||
return Object.freeze({
|
||||
async prepare(request: Readonly<LocalApiAdmissionRequest>) {
|
||||
if (request.signal.aborted) return response(503, 'request_unavailable');
|
||||
let authenticated;
|
||||
try {
|
||||
authenticated = await options.authenticator.authenticate(
|
||||
request.authorization,
|
||||
);
|
||||
} catch {
|
||||
const auditFailure = await audited(() =>
|
||||
recordAudit(
|
||||
options.audit,
|
||||
request,
|
||||
'authentication_unavailable',
|
||||
['authentication_unavailable'],
|
||||
null,
|
||||
null,
|
||||
now,
|
||||
uuid,
|
||||
),
|
||||
);
|
||||
return auditFailure ?? response(503, 'authentication_unavailable');
|
||||
}
|
||||
if (!authenticated) {
|
||||
const auditFailure = await audited(() =>
|
||||
recordAudit(
|
||||
options.audit,
|
||||
request,
|
||||
'authentication_rejected',
|
||||
['authentication_rejected'],
|
||||
null,
|
||||
null,
|
||||
now,
|
||||
uuid,
|
||||
),
|
||||
);
|
||||
return auditFailure ?? response(401, 'authentication_required');
|
||||
}
|
||||
if (request.signal.aborted) return response(503, 'request_unavailable');
|
||||
|
||||
let decision: Readonly<SecurityPolicyDecision>;
|
||||
try {
|
||||
decision = normalizeSecurityPolicyDecision(
|
||||
await options.policy.authorize(
|
||||
authenticated.principal,
|
||||
request.operation.projectId,
|
||||
request.operation.operationId === 'run.cancel'
|
||||
? 'run.stop'
|
||||
: request.operation.operationId === 'task.start'
|
||||
? 'run.start'
|
||||
: request.operation.operationId === 'task.list' ||
|
||||
request.operation.operationId === 'task.get'
|
||||
? 'task.read'
|
||||
: 'run.read',
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
const auditFailure = await audited(() =>
|
||||
recordAudit(
|
||||
options.audit,
|
||||
request,
|
||||
'authorization_unavailable',
|
||||
['authorization_unavailable'],
|
||||
authenticated.principal,
|
||||
null,
|
||||
now,
|
||||
uuid,
|
||||
),
|
||||
);
|
||||
return auditFailure ?? response(503, 'authorization_unavailable');
|
||||
}
|
||||
|
||||
const outcome: SecurityAuditOutcome =
|
||||
decision.effect === 'allow'
|
||||
? 'allowed'
|
||||
: decision.effect === 'require_approval'
|
||||
? 'approval_required'
|
||||
: 'denied';
|
||||
const auditFailure = await audited(() =>
|
||||
recordAudit(
|
||||
options.audit,
|
||||
request,
|
||||
outcome,
|
||||
decision.reasons,
|
||||
authenticated.principal,
|
||||
decision.fence,
|
||||
now,
|
||||
uuid,
|
||||
),
|
||||
);
|
||||
if (auditFailure) return auditFailure;
|
||||
if (decision.effect === 'deny') return response(403, 'forbidden');
|
||||
if (decision.effect === 'require_approval') {
|
||||
return response(403, 'approval_required');
|
||||
}
|
||||
if (request.signal.aborted) return response(503, 'request_unavailable');
|
||||
try {
|
||||
await authenticated.confirm();
|
||||
} catch {
|
||||
return response(503, 'authentication_unavailable');
|
||||
}
|
||||
if (request.signal.aborted) return response(503, 'request_unavailable');
|
||||
const bodyMode =
|
||||
request.operation.operationId === 'run.cancel' ||
|
||||
request.operation.operationId === 'task.start'
|
||||
? 'json'
|
||||
: 'none';
|
||||
return Object.freeze({
|
||||
bodyMode,
|
||||
maximumBodyBytes: bodyMode === 'json' ? 512 : 0,
|
||||
async handle(body: unknown | null) {
|
||||
if (request.signal.aborted) {
|
||||
return response(503, 'request_unavailable');
|
||||
}
|
||||
switch (request.operation.operationId) {
|
||||
case 'run.get':
|
||||
if (body !== null) return response(400, 'invalid_request_body');
|
||||
return options.runReadRoute.handle({
|
||||
projectId: request.operation.projectId,
|
||||
runId: request.operation.runId,
|
||||
});
|
||||
case 'run.list':
|
||||
if (body !== null) return response(400, 'invalid_request_body');
|
||||
return options.runListRoute.handle({
|
||||
projectId: request.operation.projectId,
|
||||
input: request.operation.input,
|
||||
});
|
||||
case 'run.events.list':
|
||||
if (body !== null) return response(400, 'invalid_request_body');
|
||||
return options.runEventListRoute.handle({
|
||||
projectId: request.operation.projectId,
|
||||
runId: request.operation.runId,
|
||||
input: request.operation.input,
|
||||
});
|
||||
case 'run.steps.list':
|
||||
if (body !== null) return response(400, 'invalid_request_body');
|
||||
return options.runStepListRoute.handle({
|
||||
projectId: request.operation.projectId,
|
||||
runId: request.operation.runId,
|
||||
input: request.operation.input,
|
||||
});
|
||||
case 'run.cancel':
|
||||
return options.runCancellationRoute.handle({
|
||||
projectId: request.operation.projectId,
|
||||
runId: request.operation.runId,
|
||||
body,
|
||||
principal: authenticated.principal,
|
||||
policyFence: decision.fence,
|
||||
});
|
||||
case 'task.list':
|
||||
if (body !== null) return response(400, 'invalid_request_body');
|
||||
return options.taskListRoute.handle({
|
||||
projectId: request.operation.projectId,
|
||||
input: request.operation.input,
|
||||
});
|
||||
case 'task.get':
|
||||
if (body !== null) return response(400, 'invalid_request_body');
|
||||
return options.taskReadRoute.handle({
|
||||
projectId: request.operation.projectId,
|
||||
taskId: request.operation.taskId,
|
||||
});
|
||||
case 'task.start':
|
||||
return options.taskStartRoute.handle({
|
||||
projectId: request.operation.projectId,
|
||||
taskId: request.operation.taskId,
|
||||
body,
|
||||
principal: authenticated.principal,
|
||||
policyFence: decision.fence,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type {
|
||||
LocalApplicationProductSurface,
|
||||
LocalApplicationProductSurfaceAuthority,
|
||||
} from '@qinglong/local-application';
|
||||
import { LocalOwnerPepperKeyringFileProvider } from '@qinglong/local-owner-console/pepper-custody';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
|
||||
import { createLocalApiAdmission } from '../admission/localApiAdmission';
|
||||
import { createLocalApiCredentialAuthenticator } from '../authentication/credentialAuthenticator';
|
||||
import type { LocalApiProcessConfig } from '../production-process/config';
|
||||
import { createLocalApiRunListRoute } from '../run/runListRoute';
|
||||
import { createLocalApiRunReadRoute } from '../run/runReadRoute';
|
||||
import { createLocalApiRunEventListRoute } from '../run/runEventListRoute';
|
||||
import { createLocalApiRunStepListRoute } from '../run/runStepListRoute';
|
||||
import { createLocalApiRunCancellationRoute } from '../run/runCancellationRoute';
|
||||
import { createLocalApiTaskListRoute } from '../task/taskListRoute';
|
||||
import { createLocalApiTaskReadRoute } from '../task/taskReadRoute';
|
||||
import { createLocalApiTaskStartRoute } from '../task/taskStartRoute';
|
||||
import { startLocalApiHttpSurface } from '../transport/httpSurface';
|
||||
|
||||
export interface LocalApiProductSurfaceEvent {
|
||||
readonly schemaVersion: 1;
|
||||
readonly component: 'qinglong3-local-api';
|
||||
readonly level: 'info' | 'error';
|
||||
readonly event: 'listening' | 'draining' | 'stopped';
|
||||
readonly host: '127.0.0.1' | '::1';
|
||||
readonly port: number;
|
||||
readonly stopResult?: 'stopped' | 'timed_out';
|
||||
}
|
||||
|
||||
export interface LocalApiProductSurfaceOptions {
|
||||
readonly emit?: (
|
||||
event: Readonly<LocalApiProductSurfaceEvent>,
|
||||
) => void | Promise<void>;
|
||||
readonly now?: () => number;
|
||||
readonly randomUuid?: () => string;
|
||||
}
|
||||
|
||||
async function bestEffortEmit(
|
||||
emit: LocalApiProductSurfaceOptions['emit'],
|
||||
event: Readonly<LocalApiProductSurfaceEvent>,
|
||||
): Promise<void> {
|
||||
if (!emit) return;
|
||||
try {
|
||||
await emit(event);
|
||||
} catch {
|
||||
// Diagnostics cannot replace listener or drain outcomes.
|
||||
}
|
||||
}
|
||||
|
||||
function surfaceEvent(
|
||||
config: Readonly<LocalApiProcessConfig>,
|
||||
event: LocalApiProductSurfaceEvent['event'],
|
||||
values: Readonly<
|
||||
Pick<LocalApiProductSurfaceEvent, 'level'> &
|
||||
Partial<Pick<LocalApiProductSurfaceEvent, 'stopResult'>>
|
||||
>,
|
||||
): Readonly<LocalApiProductSurfaceEvent> {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-local-api',
|
||||
event,
|
||||
host: config.listener.host,
|
||||
port: config.listener.port,
|
||||
...values,
|
||||
});
|
||||
}
|
||||
|
||||
export function createLocalApiProductSurface(
|
||||
config: Readonly<LocalApiProcessConfig>,
|
||||
options: LocalApiProductSurfaceOptions = {},
|
||||
): Readonly<LocalApplicationProductSurface> {
|
||||
if (
|
||||
!config ||
|
||||
typeof config !== 'object' ||
|
||||
Array.isArray(config) ||
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
(options.emit !== undefined && typeof options.emit !== 'function') ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.randomUuid !== undefined &&
|
||||
typeof options.randomUuid !== 'function')
|
||||
) {
|
||||
throw new TypeError('Local API product surface options are invalid');
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
async start(authority: Readonly<LocalApplicationProductSurfaceAuthority>) {
|
||||
const provider = new LocalOwnerPepperKeyringFileProvider(
|
||||
config.ownerPepperKeyringDirectory,
|
||||
);
|
||||
const authenticator = createLocalApiCredentialAuthenticator(
|
||||
authority,
|
||||
provider,
|
||||
options.now === undefined ? {} : { now: options.now },
|
||||
);
|
||||
const policy = new ProjectPolicyEngine(authority.projectPolicy);
|
||||
const runReadRoute = createLocalApiRunReadRoute(authority.runs);
|
||||
const runListRoute = createLocalApiRunListRoute(authority.runs);
|
||||
const runEventListRoute = createLocalApiRunEventListRoute(authority.runs);
|
||||
const runStepListRoute = createLocalApiRunStepListRoute(
|
||||
authority.runs,
|
||||
authority.stepRuns,
|
||||
);
|
||||
const runCancellationRoute = createLocalApiRunCancellationRoute(
|
||||
authority.runCancellation,
|
||||
options.randomUuid ?? randomUUID,
|
||||
);
|
||||
const taskListRoute = createLocalApiTaskListRoute(
|
||||
authority.taskDefinitions,
|
||||
);
|
||||
const taskReadRoute = createLocalApiTaskReadRoute(
|
||||
authority.taskDefinitions,
|
||||
);
|
||||
const taskStartRoute = createLocalApiTaskStartRoute(
|
||||
authority.taskStart,
|
||||
options.randomUuid ?? randomUUID,
|
||||
);
|
||||
const admission = createLocalApiAdmission({
|
||||
authenticator,
|
||||
policy,
|
||||
audit: authority.securityAudit,
|
||||
runReadRoute,
|
||||
runListRoute,
|
||||
runEventListRoute,
|
||||
runStepListRoute,
|
||||
runCancellationRoute,
|
||||
taskListRoute,
|
||||
taskReadRoute,
|
||||
taskStartRoute,
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
...(options.randomUuid === undefined
|
||||
? {}
|
||||
: { randomUuid: options.randomUuid }),
|
||||
});
|
||||
const active = await startLocalApiHttpSurface({
|
||||
profile: authority.profile,
|
||||
host: config.listener.host,
|
||||
port: config.listener.port,
|
||||
admission,
|
||||
...(options.randomUuid === undefined
|
||||
? {}
|
||||
: { randomUuid: options.randomUuid }),
|
||||
});
|
||||
await bestEffortEmit(
|
||||
options.emit,
|
||||
surfaceEvent(config, 'listening', { level: 'info' }),
|
||||
);
|
||||
let stopPromise: Promise<'stopped' | 'timed_out'> | undefined;
|
||||
return Object.freeze({
|
||||
stopAndDrain() {
|
||||
if (stopPromise) return stopPromise;
|
||||
stopPromise = (async () => {
|
||||
await bestEffortEmit(
|
||||
options.emit,
|
||||
surfaceEvent(config, 'draining', { level: 'info' }),
|
||||
);
|
||||
const stopResult = await active.stopAndDrain();
|
||||
await bestEffortEmit(
|
||||
options.emit,
|
||||
surfaceEvent(config, 'stopped', {
|
||||
level: stopResult === 'stopped' ? 'info' : 'error',
|
||||
stopResult,
|
||||
}),
|
||||
);
|
||||
return stopResult;
|
||||
})();
|
||||
return stopPromise;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
createLocalIdentityKeyringAuthenticator,
|
||||
LocalIdentityAuthenticationUnavailableError,
|
||||
} from '@qinglong/local-owner-console/identity-authentication';
|
||||
import {
|
||||
LocalOwnerPepperKeyringFileProvider,
|
||||
type LocalOwnerPepperKeyMaterial,
|
||||
} from '@qinglong/local-owner-console/pepper-custody';
|
||||
import {
|
||||
normalizeApiCredentialRecord,
|
||||
type ApiCredentialRecord,
|
||||
} from '@qinglong/runtime-core/api-credential';
|
||||
import type { LocalOwnerPepperKeyRecord } from '@qinglong/runtime-core/local-owner-pepper';
|
||||
import type { SecurityPrincipal } from '@qinglong/runtime-core/security';
|
||||
import type { LocalApplicationProductSurfaceAuthority } from '@qinglong/local-application';
|
||||
|
||||
const AUTHORIZATION_PATTERN =
|
||||
/^Bearer (ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43})$/;
|
||||
|
||||
export interface AuthenticatedLocalApiRequest {
|
||||
readonly principal: Readonly<SecurityPrincipal>;
|
||||
confirm(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface LocalApiCredentialAuthenticator {
|
||||
authenticate(
|
||||
authorization: string | null,
|
||||
): Promise<Readonly<AuthenticatedLocalApiRequest> | null>;
|
||||
}
|
||||
|
||||
export interface LocalApiCredentialAuthenticatorOptions {
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
interface CredentialFence {
|
||||
readonly credentialId: string;
|
||||
readonly credentialVersion: number;
|
||||
readonly pepperKeyId: string;
|
||||
readonly materialDigest: string;
|
||||
readonly subjectType: ApiCredentialRecord['subject']['type'];
|
||||
readonly subjectId: string;
|
||||
readonly secretDigest: string;
|
||||
readonly notBeforeAtMs: number;
|
||||
readonly expiresAtMs: number;
|
||||
}
|
||||
|
||||
export class LocalApiCredentialAuthenticationConfigurationError extends TypeError {
|
||||
readonly code = 'QL3_LOCAL_API_AUTHENTICATION_CONFIG_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Local API credential authentication is invalid: ${message}`);
|
||||
this.name = 'LocalApiCredentialAuthenticationConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalApiCredentialAuthenticationUnavailableError extends Error {
|
||||
readonly code = 'QL3_LOCAL_API_AUTHENTICATION_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Local API credential authentication is unavailable', options);
|
||||
this.name = 'LocalApiCredentialAuthenticationUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function validKey(
|
||||
value: Readonly<LocalOwnerPepperKeyRecord> | null,
|
||||
): value is Readonly<LocalOwnerPepperKeyRecord> & {
|
||||
readonly materialDigest: string;
|
||||
} {
|
||||
return Boolean(
|
||||
value &&
|
||||
(value.state === 'active' || value.state === 'retired') &&
|
||||
value.materialDigest,
|
||||
);
|
||||
}
|
||||
|
||||
function validMaterial(
|
||||
value: Readonly<LocalOwnerPepperKeyMaterial> | null,
|
||||
pepperKeyId: string,
|
||||
materialDigest: string,
|
||||
): value is Readonly<LocalOwnerPepperKeyMaterial> {
|
||||
return Boolean(
|
||||
value &&
|
||||
value.pepperKeyId === pepperKeyId &&
|
||||
value.summary.digest === materialDigest,
|
||||
);
|
||||
}
|
||||
|
||||
async function loadFence(
|
||||
authority: Readonly<LocalApplicationProductSurfaceAuthority>,
|
||||
provider: LocalOwnerPepperKeyringFileProvider,
|
||||
credentialId: string,
|
||||
credentialVersion: number,
|
||||
): Promise<Readonly<CredentialFence>> {
|
||||
try {
|
||||
const candidate = await authority.apiCredentials.resolve(credentialId);
|
||||
if (!candidate) throw new Error('credential is unavailable');
|
||||
const credential = normalizeApiCredentialRecord(candidate);
|
||||
const key = await authority.ownerPepper.resolveKey(credential.pepperKeyId);
|
||||
const material = provider.resolve(credential.pepperKeyId);
|
||||
if (
|
||||
credential.version !== credentialVersion ||
|
||||
credential.state !== 'active' ||
|
||||
credential.subjectStatus !== 'active' ||
|
||||
!validKey(key) ||
|
||||
!validMaterial(
|
||||
material,
|
||||
credential.pepperKeyId,
|
||||
key.materialDigest,
|
||||
)
|
||||
) {
|
||||
throw new Error('credential fence is unavailable');
|
||||
}
|
||||
return Object.freeze({
|
||||
credentialId: credential.credentialId,
|
||||
credentialVersion: credential.version,
|
||||
pepperKeyId: credential.pepperKeyId,
|
||||
materialDigest: key.materialDigest,
|
||||
subjectType: credential.subject.type,
|
||||
subjectId: credential.subject.id,
|
||||
secretDigest: credential.secretDigest,
|
||||
notBeforeAtMs: credential.notBeforeAtMs,
|
||||
expiresAtMs: credential.expiresAtMs,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new LocalApiCredentialAuthenticationUnavailableError({
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function sameFence(left: CredentialFence, right: CredentialFence): boolean {
|
||||
return (
|
||||
left.credentialId === right.credentialId &&
|
||||
left.credentialVersion === right.credentialVersion &&
|
||||
left.pepperKeyId === right.pepperKeyId &&
|
||||
left.materialDigest === right.materialDigest &&
|
||||
left.subjectType === right.subjectType &&
|
||||
left.subjectId === right.subjectId &&
|
||||
left.secretDigest === right.secretDigest &&
|
||||
left.notBeforeAtMs === right.notBeforeAtMs &&
|
||||
left.expiresAtMs === right.expiresAtMs
|
||||
);
|
||||
}
|
||||
|
||||
export function createLocalApiCredentialAuthenticator(
|
||||
authority: Readonly<LocalApplicationProductSurfaceAuthority>,
|
||||
provider: LocalOwnerPepperKeyringFileProvider,
|
||||
options: LocalApiCredentialAuthenticatorOptions = {},
|
||||
): Readonly<LocalApiCredentialAuthenticator> {
|
||||
if (
|
||||
!authority ||
|
||||
typeof authority !== 'object' ||
|
||||
Array.isArray(authority) ||
|
||||
typeof authority.apiCredentials?.resolve !== 'function' ||
|
||||
typeof authority.ownerPepper?.resolveKey !== 'function' ||
|
||||
!provider ||
|
||||
typeof provider.resolve !== 'function' ||
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some((key) => key !== 'now') ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new LocalApiCredentialAuthenticationConfigurationError(
|
||||
'authority, provider or options are invalid',
|
||||
);
|
||||
}
|
||||
const identity = createLocalIdentityKeyringAuthenticator(
|
||||
authority.apiCredentials,
|
||||
authority.ownerPepper,
|
||||
provider,
|
||||
{
|
||||
principalTtlMs: 60_000,
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
},
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
async authenticate(authorization: string | null) {
|
||||
if (typeof authorization !== 'string' || authorization.length > 320) {
|
||||
return null;
|
||||
}
|
||||
const match = AUTHORIZATION_PATTERN.exec(authorization);
|
||||
if (!match) return null;
|
||||
const token = match[1]!;
|
||||
try {
|
||||
const authentication = await identity.authenticateCredential(token);
|
||||
if (!authentication) return null;
|
||||
const fence = await loadFence(
|
||||
authority,
|
||||
provider,
|
||||
authentication.credentialId,
|
||||
authentication.credentialVersion,
|
||||
);
|
||||
if (
|
||||
authentication.principal.subject.type !== fence.subjectType ||
|
||||
authentication.principal.subject.id !== fence.subjectId
|
||||
) {
|
||||
throw new LocalApiCredentialAuthenticationUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
principal: authentication.principal,
|
||||
async confirm() {
|
||||
try {
|
||||
const currentAuthentication =
|
||||
await identity.authenticateCredential(token);
|
||||
if (
|
||||
!currentAuthentication ||
|
||||
currentAuthentication.credentialId !== fence.credentialId ||
|
||||
currentAuthentication.credentialVersion !==
|
||||
fence.credentialVersion ||
|
||||
currentAuthentication.principal.subject.type !==
|
||||
fence.subjectType ||
|
||||
currentAuthentication.principal.subject.id !== fence.subjectId
|
||||
) {
|
||||
throw new Error('credential authentication changed');
|
||||
}
|
||||
const currentFence = await loadFence(
|
||||
authority,
|
||||
provider,
|
||||
fence.credentialId,
|
||||
fence.credentialVersion,
|
||||
);
|
||||
if (!sameFence(fence, currentFence)) {
|
||||
throw new Error('credential authority changed');
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof LocalApiCredentialAuthenticationUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalApiCredentialAuthenticationUnavailableError({
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof LocalApiCredentialAuthenticationUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof LocalIdentityAuthenticationUnavailableError) {
|
||||
throw new LocalApiCredentialAuthenticationUnavailableError({
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw new LocalApiCredentialAuthenticationUnavailableError({
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import type {
|
||||
LocalApplicationProcessSignal,
|
||||
LocalApplicationProcessSignalSource,
|
||||
} from '@qinglong/local-application/process';
|
||||
|
||||
import { runProductionLocalApiProcess } from './production-process/processApplication';
|
||||
|
||||
const USAGE = 'Usage: ql3-local-api --config /absolute/private-config.json';
|
||||
|
||||
const nodeSignals: LocalApplicationProcessSignalSource = Object.freeze({
|
||||
subscribe(
|
||||
listener: (signal: LocalApplicationProcessSignal) => void,
|
||||
): () => void {
|
||||
const handlers = Object.freeze({
|
||||
SIGINT: () => listener('SIGINT' as const),
|
||||
SIGTERM: () => listener('SIGTERM' as const),
|
||||
});
|
||||
process.on('SIGINT', handlers.SIGINT);
|
||||
process.on('SIGTERM', handlers.SIGTERM);
|
||||
return () => {
|
||||
process.off('SIGINT', handlers.SIGINT);
|
||||
process.off('SIGTERM', handlers.SIGTERM);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function configFileArgument(argv: readonly string[]): string | null {
|
||||
return argv.length === 2 && argv[0] === '--config' && argv[1]
|
||||
? argv[1]
|
||||
: null;
|
||||
}
|
||||
|
||||
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
|
||||
const candidate = error as { readonly name?: unknown; readonly code?: unknown };
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-local-api',
|
||||
level: 'error',
|
||||
event: 'process_failed',
|
||||
name:
|
||||
typeof candidate?.name === 'string' && candidate.name.length <= 128
|
||||
? candidate.name
|
||||
: 'Error',
|
||||
...(typeof candidate?.code === 'string' && candidate.code.length <= 128
|
||||
? { code: candidate.code }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const configFilePath = configFileArgument(argv);
|
||||
if (!configFilePath) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'QL3_LOCAL_API_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stopResult = await runProductionLocalApiProcess({
|
||||
configFilePath,
|
||||
signals: nodeSignals,
|
||||
emit(event) {
|
||||
process.stdout.write(`${JSON.stringify(event)}\n`);
|
||||
},
|
||||
});
|
||||
if (stopResult !== 'stopped') process.exitCode = 1;
|
||||
} catch (error) {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,180 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
|
||||
export const LOCAL_API_PROCESS_CONFIG_SCHEMA =
|
||||
'qinglong/local-api-process@v1' as const;
|
||||
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
const LOOPBACK_HOSTS = Object.freeze(['127.0.0.1', '::1'] as const);
|
||||
|
||||
export interface LocalApiListenerConfig {
|
||||
readonly host: (typeof LOOPBACK_HOSTS)[number];
|
||||
readonly port: number;
|
||||
}
|
||||
|
||||
export interface LocalApiProcessConfig {
|
||||
readonly schema: typeof LOCAL_API_PROCESS_CONFIG_SCHEMA;
|
||||
readonly deploymentRoot: string;
|
||||
readonly applicationConfigFilePath: string;
|
||||
readonly ownerPepperKeyringDirectory: string;
|
||||
readonly listener: Readonly<LocalApiListenerConfig>;
|
||||
}
|
||||
|
||||
export class LocalApiProcessConfigError extends TypeError {
|
||||
readonly code = 'QL3_LOCAL_API_PROCESS_CONFIG_INVALID';
|
||||
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(`Local API process configuration is invalid: ${message}`, options);
|
||||
this.name = 'LocalApiProcessConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
throw new LocalApiProcessConfigError(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: Record<string, unknown>,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
actual.length !== canonical.length ||
|
||||
actual.some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
throw new LocalApiProcessConfigError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function absolutePath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES ||
|
||||
value.includes('\0') ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.normalize(value) !== value ||
|
||||
path.parse(value).root === value
|
||||
) {
|
||||
throw new LocalApiProcessConfigError(
|
||||
`${label} must be a normalized bounded absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function descendant(root: string, value: string, label: string): void {
|
||||
const relative = path.relative(root, value);
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new LocalApiProcessConfigError(
|
||||
`${label} must be a descendant of deploymentRoot`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function listener(value: unknown): Readonly<LocalApiListenerConfig> {
|
||||
const candidate = record(value, 'listener');
|
||||
exactKeys(candidate, ['host', 'port'], 'listener');
|
||||
if (
|
||||
!LOOPBACK_HOSTS.includes(
|
||||
candidate.host as (typeof LOOPBACK_HOSTS)[number],
|
||||
)
|
||||
) {
|
||||
throw new LocalApiProcessConfigError('listener host must be loopback');
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(candidate.port) ||
|
||||
(candidate.port as number) < 1_024 ||
|
||||
(candidate.port as number) > 65_535
|
||||
) {
|
||||
throw new LocalApiProcessConfigError('listener port is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
host: candidate.host as LocalApiListenerConfig['host'],
|
||||
port: candidate.port as number,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalApiProcessConfig(
|
||||
value: unknown,
|
||||
): Readonly<LocalApiProcessConfig> {
|
||||
const candidate = record(value, 'configuration');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'applicationConfigFilePath',
|
||||
'deploymentRoot',
|
||||
'listener',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'schema',
|
||||
],
|
||||
'configuration',
|
||||
);
|
||||
if (candidate.schema !== LOCAL_API_PROCESS_CONFIG_SCHEMA) {
|
||||
throw new LocalApiProcessConfigError('schema is unsupported');
|
||||
}
|
||||
const deploymentRoot = absolutePath(
|
||||
candidate.deploymentRoot,
|
||||
'deploymentRoot',
|
||||
);
|
||||
const applicationConfigFilePath = absolutePath(
|
||||
candidate.applicationConfigFilePath,
|
||||
'applicationConfigFilePath',
|
||||
);
|
||||
const ownerPepperKeyringDirectory = absolutePath(
|
||||
candidate.ownerPepperKeyringDirectory,
|
||||
'ownerPepperKeyringDirectory',
|
||||
);
|
||||
descendant(
|
||||
deploymentRoot,
|
||||
applicationConfigFilePath,
|
||||
'applicationConfigFilePath',
|
||||
);
|
||||
descendant(
|
||||
deploymentRoot,
|
||||
ownerPepperKeyringDirectory,
|
||||
'ownerPepperKeyringDirectory',
|
||||
);
|
||||
if (applicationConfigFilePath === ownerPepperKeyringDirectory) {
|
||||
throw new LocalApiProcessConfigError('authority paths must be distinct');
|
||||
}
|
||||
return Object.freeze({
|
||||
schema: LOCAL_API_PROCESS_CONFIG_SCHEMA,
|
||||
deploymentRoot,
|
||||
applicationConfigFilePath,
|
||||
ownerPepperKeyringDirectory,
|
||||
listener: listener(candidate.listener),
|
||||
});
|
||||
}
|
||||
|
||||
export function readLocalApiProcessConfig(
|
||||
configFilePath: string,
|
||||
): Readonly<LocalApiProcessConfig> {
|
||||
try {
|
||||
return normalizeLocalApiProcessConfig(
|
||||
readPrivateLocalCommandFile(configFilePath),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof LocalApiProcessConfigError) throw error;
|
||||
throw new LocalApiProcessConfigError('private config cannot be read', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
runProductionLocalApplicationProcess,
|
||||
type LocalApplicationProcessEvent,
|
||||
type LocalApplicationProcessSignalSource,
|
||||
} from '@qinglong/local-application/process';
|
||||
import type { LocalApplicationStopResult } from '@qinglong/local-application';
|
||||
|
||||
import {
|
||||
createLocalApiProductSurface,
|
||||
type LocalApiProductSurfaceEvent,
|
||||
} from '../application-runtime/localApiProductSurface';
|
||||
import {
|
||||
readLocalApiProcessConfig,
|
||||
type LocalApiProcessConfig,
|
||||
} from './config';
|
||||
|
||||
export interface ProductionLocalApiProcessOptions {
|
||||
readonly configFilePath: string;
|
||||
readonly signals: LocalApplicationProcessSignalSource;
|
||||
readonly emit: (
|
||||
event: Readonly<LocalApplicationProcessEvent | LocalApiProductSurfaceEvent>,
|
||||
) => void | Promise<void>;
|
||||
readonly now?: () => number;
|
||||
readonly randomUuid?: () => string;
|
||||
}
|
||||
|
||||
export interface ProductionLocalApiProcessAdapters {
|
||||
readonly readConfig: typeof readLocalApiProcessConfig;
|
||||
readonly runApplication: typeof runProductionLocalApplicationProcess;
|
||||
}
|
||||
|
||||
function validateOptions(options: ProductionLocalApiProcessOptions): void {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
typeof options.configFilePath !== 'string' ||
|
||||
typeof options.signals?.subscribe !== 'function' ||
|
||||
typeof options.emit !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.randomUuid !== undefined &&
|
||||
typeof options.randomUuid !== 'function')
|
||||
) {
|
||||
throw new TypeError('Production Local API process options are invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function validateAdapters(adapters: ProductionLocalApiProcessAdapters): void {
|
||||
if (
|
||||
!adapters ||
|
||||
typeof adapters !== 'object' ||
|
||||
Array.isArray(adapters) ||
|
||||
typeof adapters.readConfig !== 'function' ||
|
||||
typeof adapters.runApplication !== 'function'
|
||||
) {
|
||||
throw new TypeError('Production Local API process adapters are invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export async function runProductionLocalApiProcess(
|
||||
options: ProductionLocalApiProcessOptions,
|
||||
adapters: ProductionLocalApiProcessAdapters = {
|
||||
readConfig: readLocalApiProcessConfig,
|
||||
runApplication: runProductionLocalApplicationProcess,
|
||||
},
|
||||
): Promise<LocalApplicationStopResult> {
|
||||
validateOptions(options);
|
||||
validateAdapters(adapters);
|
||||
const config: Readonly<LocalApiProcessConfig> = adapters.readConfig(
|
||||
options.configFilePath,
|
||||
);
|
||||
const surface = createLocalApiProductSurface(config, {
|
||||
emit: options.emit,
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
...(options.randomUuid === undefined
|
||||
? {}
|
||||
: { randomUuid: options.randomUuid }),
|
||||
});
|
||||
return adapters.runApplication({
|
||||
configFilePath: config.applicationConfigFilePath,
|
||||
signals: options.signals,
|
||||
emit: options.emit,
|
||||
productSurface: surface,
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
RUN_CANCELLATION_SCHEMA,
|
||||
InvalidRunCancellationError,
|
||||
RunCancellationFenceRejectedError,
|
||||
RunCancellationNotFoundError,
|
||||
RunCancellationUnavailableError,
|
||||
createRunCancellationResponseBody,
|
||||
parseRunCancellationRequestBody,
|
||||
type RunCancellationRepository,
|
||||
} from '@qinglong/runtime-core/run-cancellation';
|
||||
import type {
|
||||
SecurityPolicyFence,
|
||||
SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
|
||||
import type { LocalApiResponse } from '../transport/contract';
|
||||
|
||||
export interface LocalApiRunCancellationRequest {
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly body: unknown | null;
|
||||
readonly principal: Readonly<SecurityPrincipal>;
|
||||
readonly policyFence: Readonly<SecurityPolicyFence> | null;
|
||||
}
|
||||
|
||||
export interface LocalApiRunCancellationRoute {
|
||||
handle(
|
||||
request: Readonly<LocalApiRunCancellationRequest>,
|
||||
): Promise<LocalApiResponse>;
|
||||
}
|
||||
|
||||
export type LocalApiRunCancellationEventIdFactory = () => string;
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): LocalApiResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
export function createLocalApiRunCancellationRoute(
|
||||
repository: RunCancellationRepository,
|
||||
createEventId: LocalApiRunCancellationEventIdFactory,
|
||||
): Readonly<LocalApiRunCancellationRoute> {
|
||||
if (
|
||||
!repository ||
|
||||
typeof repository.requestUserCancellation !== 'function' ||
|
||||
typeof createEventId !== 'function'
|
||||
) {
|
||||
throw new TypeError('Local API Run cancellation route is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
async handle(request: Readonly<LocalApiRunCancellationRequest>) {
|
||||
let body;
|
||||
try {
|
||||
body = parseRunCancellationRequestBody(request.body);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidRunCancellationError) {
|
||||
return response(400, {
|
||||
code: 'invalid_run_cancellation_request',
|
||||
schema: RUN_CANCELLATION_SCHEMA,
|
||||
});
|
||||
}
|
||||
return response(503, { code: 'run_cancellation_unavailable' });
|
||||
}
|
||||
if (!request.policyFence || request.policyFence.bindingVersion === null) {
|
||||
return response(503, { code: 'run_cancellation_unavailable' });
|
||||
}
|
||||
try {
|
||||
const result = await repository.requestUserCancellation({
|
||||
projectId: request.projectId,
|
||||
runId: request.runId,
|
||||
mutationId: body.mutationId,
|
||||
eventId: createEventId(),
|
||||
subject: request.principal.subject,
|
||||
policyFence: request.policyFence,
|
||||
});
|
||||
return response(
|
||||
result.status === 'accepted' ? 202 : 200,
|
||||
createRunCancellationResponseBody(result),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof RunCancellationNotFoundError) {
|
||||
return response(404, { code: 'run_not_found' });
|
||||
}
|
||||
if (error instanceof RunCancellationFenceRejectedError) {
|
||||
return response(409, {
|
||||
code: 'run_cancellation_fence_rejected',
|
||||
reason: error.reason,
|
||||
});
|
||||
}
|
||||
if (
|
||||
error instanceof InvalidRunCancellationError ||
|
||||
error instanceof RunCancellationUnavailableError
|
||||
) {
|
||||
return response(503, { code: 'run_cancellation_unavailable' });
|
||||
}
|
||||
return response(503, { code: 'run_cancellation_unavailable' });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
BoundedRunEventListProjectionUnavailableError,
|
||||
InvalidBoundedRunEventListProjectionError,
|
||||
executeBoundedRunEventListProjection,
|
||||
type BoundedRunEventListInput,
|
||||
} from '@qinglong/runtime-core/bounded-run-event-list-projection';
|
||||
import type { RunRepositoryReader } from '@qinglong/runtime-core/run-repository';
|
||||
|
||||
import type { LocalApiResponse } from '../transport/contract';
|
||||
|
||||
export interface LocalApiRunEventListRequest {
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly input: Readonly<BoundedRunEventListInput>;
|
||||
}
|
||||
|
||||
export interface LocalApiRunEventListRoute {
|
||||
handle(
|
||||
request: Readonly<LocalApiRunEventListRequest>,
|
||||
): Promise<LocalApiResponse>;
|
||||
}
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): LocalApiResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
export function createLocalApiRunEventListRoute(
|
||||
runs: Pick<RunRepositoryReader, 'findRunById' | 'listEvents'>,
|
||||
): Readonly<LocalApiRunEventListRoute> {
|
||||
if (
|
||||
!runs ||
|
||||
typeof runs.findRunById !== 'function' ||
|
||||
typeof runs.listEvents !== 'function'
|
||||
) {
|
||||
throw new TypeError('Local API Run event list repository is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
async handle(request: Readonly<LocalApiRunEventListRequest>) {
|
||||
try {
|
||||
const result = await executeBoundedRunEventListProjection(
|
||||
runs,
|
||||
request.projectId,
|
||||
request.runId,
|
||||
request.input,
|
||||
);
|
||||
if (!result.found) {
|
||||
return response(404, { code: 'run_not_found' });
|
||||
}
|
||||
const { found: _found, ...timeline } = result;
|
||||
return response(200, { ...timeline });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof InvalidBoundedRunEventListProjectionError ||
|
||||
error instanceof BoundedRunEventListProjectionUnavailableError
|
||||
) {
|
||||
return response(503, { code: 'run_event_list_unavailable' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
BoundedRunListProjectionUnavailableError,
|
||||
InvalidBoundedRunListProjectionError,
|
||||
executeBoundedRunListProjection,
|
||||
type BoundedRunListInput,
|
||||
} from '@qinglong/runtime-core/bounded-run-list-projection';
|
||||
import type { ProjectRunListReader } from '@qinglong/runtime-core/project-run-list';
|
||||
|
||||
import type { LocalApiResponse } from '../transport/contract';
|
||||
|
||||
export interface LocalApiRunListRequest {
|
||||
readonly projectId: string;
|
||||
readonly input: Readonly<BoundedRunListInput>;
|
||||
}
|
||||
|
||||
export interface LocalApiRunListRoute {
|
||||
handle(request: Readonly<LocalApiRunListRequest>): Promise<LocalApiResponse>;
|
||||
}
|
||||
|
||||
function unavailable(): LocalApiResponse {
|
||||
return Object.freeze({
|
||||
statusCode: 503,
|
||||
body: Object.freeze({ code: 'run_list_unavailable' }),
|
||||
});
|
||||
}
|
||||
|
||||
export function createLocalApiRunListRoute(
|
||||
runs: ProjectRunListReader,
|
||||
): Readonly<LocalApiRunListRoute> {
|
||||
if (!runs || typeof runs.listRunsByProject !== 'function') {
|
||||
throw new TypeError('Local API Run list repository is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
async handle(request: Readonly<LocalApiRunListRequest>) {
|
||||
try {
|
||||
const result = await executeBoundedRunListProjection(
|
||||
runs,
|
||||
request.projectId,
|
||||
request.input,
|
||||
);
|
||||
return Object.freeze({
|
||||
statusCode: 200,
|
||||
body: Object.freeze({ ...result }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof InvalidBoundedRunListProjectionError ||
|
||||
error instanceof BoundedRunListProjectionUnavailableError
|
||||
) {
|
||||
return unavailable();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
BoundedRunReadProjectionUnavailableError,
|
||||
executeBoundedRunReadProjection,
|
||||
} from '@qinglong/runtime-core/bounded-run-read-projection';
|
||||
import type { RunRepositoryReader } from '@qinglong/runtime-core/run-repository';
|
||||
|
||||
import type { LocalApiResponse } from '../transport/contract';
|
||||
|
||||
export interface LocalApiRunReadRequest {
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
}
|
||||
|
||||
export interface LocalApiRunReadRoute {
|
||||
handle(request: Readonly<LocalApiRunReadRequest>): Promise<LocalApiResponse>;
|
||||
}
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): LocalApiResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
export function createLocalApiRunReadRoute(
|
||||
runs: Pick<RunRepositoryReader, 'findRunById'>,
|
||||
): Readonly<LocalApiRunReadRoute> {
|
||||
if (!runs || typeof runs.findRunById !== 'function') {
|
||||
throw new TypeError('Local API Run read repository is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
async handle(request: Readonly<LocalApiRunReadRequest>) {
|
||||
try {
|
||||
const projection = await executeBoundedRunReadProjection(
|
||||
runs,
|
||||
request.projectId,
|
||||
request.runId,
|
||||
);
|
||||
if (projection.found !== true) {
|
||||
return response(404, { code: 'run_not_found' });
|
||||
}
|
||||
const { found: _found, ...view } = projection;
|
||||
return response(200, {
|
||||
run: Object.freeze({ projectId: request.projectId, ...view }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof BoundedRunReadProjectionUnavailableError ||
|
||||
error instanceof TypeError
|
||||
) {
|
||||
return response(503, { code: 'run_query_unavailable' });
|
||||
}
|
||||
return response(503, { code: 'run_query_unavailable' });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
BoundedRunStepListProjectionUnavailableError,
|
||||
InvalidBoundedRunStepListProjectionError,
|
||||
executeBoundedRunStepListProjection,
|
||||
type BoundedRunStepListInput,
|
||||
} from '@qinglong/runtime-core/bounded-run-step-list-projection';
|
||||
import type { RunRepositoryReader } from '@qinglong/runtime-core/run-repository';
|
||||
import type { StepRunRepository } from '@qinglong/runtime-core/step-run';
|
||||
|
||||
import type { LocalApiResponse } from '../transport/contract';
|
||||
|
||||
export interface LocalApiRunStepListRequest {
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly input: Readonly<BoundedRunStepListInput>;
|
||||
}
|
||||
|
||||
export interface LocalApiRunStepListRoute {
|
||||
handle(
|
||||
request: Readonly<LocalApiRunStepListRequest>,
|
||||
): Promise<LocalApiResponse>;
|
||||
}
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): LocalApiResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
export function createLocalApiRunStepListRoute(
|
||||
runs: Pick<RunRepositoryReader, 'findRunById'>,
|
||||
stepRuns: Pick<StepRunRepository, 'listByRun'>,
|
||||
): Readonly<LocalApiRunStepListRoute> {
|
||||
if (
|
||||
!runs ||
|
||||
typeof runs.findRunById !== 'function' ||
|
||||
!stepRuns ||
|
||||
typeof stepRuns.listByRun !== 'function'
|
||||
) {
|
||||
throw new TypeError('Local API Run Step list repository is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
async handle(request: Readonly<LocalApiRunStepListRequest>) {
|
||||
try {
|
||||
const result = await executeBoundedRunStepListProjection(
|
||||
runs,
|
||||
stepRuns,
|
||||
request.projectId,
|
||||
request.runId,
|
||||
request.input,
|
||||
);
|
||||
if (!result.found) {
|
||||
return response(404, { code: 'run_not_found' });
|
||||
}
|
||||
const { found: _found, ...page } = result;
|
||||
return response(200, { ...page });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof InvalidBoundedRunStepListProjectionError ||
|
||||
error instanceof BoundedRunStepListProjectionUnavailableError
|
||||
) {
|
||||
return response(503, { code: 'run_step_list_unavailable' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
BoundedTaskListProjectionUnavailableError,
|
||||
InvalidBoundedTaskListProjectionError,
|
||||
executeBoundedTaskListProjection,
|
||||
type BoundedTaskListInput,
|
||||
} from '@qinglong/runtime-core/bounded-task-list-projection';
|
||||
import type { TaskDefinitionSource } from '@qinglong/runtime-core/task-definition';
|
||||
|
||||
import type { LocalApiResponse } from '../transport/contract';
|
||||
|
||||
export interface LocalApiTaskListRequest {
|
||||
readonly projectId: string;
|
||||
readonly input: Readonly<BoundedTaskListInput>;
|
||||
}
|
||||
|
||||
export interface LocalApiTaskListRoute {
|
||||
handle(request: Readonly<LocalApiTaskListRequest>): Promise<LocalApiResponse>;
|
||||
}
|
||||
|
||||
function unavailable(): LocalApiResponse {
|
||||
return Object.freeze({
|
||||
statusCode: 503,
|
||||
body: Object.freeze({ code: 'task_list_unavailable' }),
|
||||
});
|
||||
}
|
||||
|
||||
export function createLocalApiTaskListRoute(
|
||||
tasks: Pick<TaskDefinitionSource, 'listTaskDefinitions'>,
|
||||
): Readonly<LocalApiTaskListRoute> {
|
||||
if (!tasks || typeof tasks.listTaskDefinitions !== 'function') {
|
||||
throw new TypeError('Local API Task list repository is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
async handle(request: Readonly<LocalApiTaskListRequest>) {
|
||||
try {
|
||||
const result = await executeBoundedTaskListProjection(
|
||||
tasks,
|
||||
request.projectId,
|
||||
request.input,
|
||||
);
|
||||
return Object.freeze({
|
||||
statusCode: 200,
|
||||
body: Object.freeze({ ...result }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof InvalidBoundedTaskListProjectionError ||
|
||||
error instanceof BoundedTaskListProjectionUnavailableError
|
||||
) {
|
||||
return unavailable();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
BoundedTaskReadProjectionUnavailableError,
|
||||
InvalidBoundedTaskReadProjectionError,
|
||||
executeBoundedTaskReadProjection,
|
||||
} from '@qinglong/runtime-core/bounded-task-read-projection';
|
||||
import type { TaskDefinitionSource } from '@qinglong/runtime-core/task-definition';
|
||||
|
||||
import type { LocalApiResponse } from '../transport/contract';
|
||||
|
||||
export interface LocalApiTaskReadRequest {
|
||||
readonly projectId: string;
|
||||
readonly taskId: string;
|
||||
}
|
||||
|
||||
export interface LocalApiTaskReadRoute {
|
||||
handle(request: Readonly<LocalApiTaskReadRequest>): Promise<LocalApiResponse>;
|
||||
}
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): LocalApiResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
export function createLocalApiTaskReadRoute(
|
||||
tasks: Pick<TaskDefinitionSource, 'findCurrentTaskDefinition'>,
|
||||
): Readonly<LocalApiTaskReadRoute> {
|
||||
if (!tasks || typeof tasks.findCurrentTaskDefinition !== 'function') {
|
||||
throw new TypeError('Local API Task read repository is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
async handle(request: Readonly<LocalApiTaskReadRequest>) {
|
||||
try {
|
||||
const projection = await executeBoundedTaskReadProjection(
|
||||
tasks,
|
||||
request.projectId,
|
||||
request.taskId,
|
||||
);
|
||||
if (projection.found !== true) {
|
||||
return response(404, { code: 'task_not_found' });
|
||||
}
|
||||
const { found: _found, ...task } = projection;
|
||||
return response(200, { task: Object.freeze(task) });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof InvalidBoundedTaskReadProjectionError ||
|
||||
error instanceof BoundedTaskReadProjectionUnavailableError
|
||||
) {
|
||||
return response(503, { code: 'task_query_unavailable' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
TASK_START_SCHEMA,
|
||||
InvalidTaskStartError,
|
||||
TaskStartFenceRejectedError,
|
||||
TaskStartNotFoundError,
|
||||
TaskStartUnavailableError,
|
||||
createTaskStartResponseBody,
|
||||
parseTaskStartRequestBody,
|
||||
type TaskStartRepository,
|
||||
} from '@qinglong/runtime-core/task-start';
|
||||
import type {
|
||||
SecurityPolicyFence,
|
||||
SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
|
||||
import type { LocalApiResponse } from '../transport/contract';
|
||||
|
||||
export interface LocalApiTaskStartRequest {
|
||||
readonly projectId: string;
|
||||
readonly taskId: string;
|
||||
readonly body: unknown | null;
|
||||
readonly principal: Readonly<SecurityPrincipal>;
|
||||
readonly policyFence: Readonly<SecurityPolicyFence> | null;
|
||||
}
|
||||
|
||||
export interface LocalApiTaskStartRoute {
|
||||
handle(request: Readonly<LocalApiTaskStartRequest>): Promise<LocalApiResponse>;
|
||||
}
|
||||
|
||||
export type LocalApiTaskStartIdFactory = () => string;
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): LocalApiResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
export function createLocalApiTaskStartRoute(
|
||||
repository: TaskStartRepository,
|
||||
createId: LocalApiTaskStartIdFactory,
|
||||
): Readonly<LocalApiTaskStartRoute> {
|
||||
if (
|
||||
!repository ||
|
||||
typeof repository.startTask !== 'function' ||
|
||||
typeof createId !== 'function'
|
||||
) {
|
||||
throw new TypeError('Local API Task start route is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
async handle(request: Readonly<LocalApiTaskStartRequest>) {
|
||||
let body;
|
||||
try {
|
||||
body = parseTaskStartRequestBody(request.body);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidTaskStartError) {
|
||||
return response(400, {
|
||||
code: 'invalid_task_start_request',
|
||||
schema: TASK_START_SCHEMA,
|
||||
});
|
||||
}
|
||||
return response(503, { code: 'task_start_unavailable' });
|
||||
}
|
||||
if (!request.policyFence || request.policyFence.bindingVersion === null) {
|
||||
return response(503, { code: 'task_start_unavailable' });
|
||||
}
|
||||
try {
|
||||
const result = await repository.startTask({
|
||||
projectId: request.projectId,
|
||||
taskId: request.taskId,
|
||||
mutationId: body.mutationId,
|
||||
expectedRevision: body.expectedRevision,
|
||||
expectedContentDigest: body.expectedContentDigest,
|
||||
runId: createId(),
|
||||
attemptId: createId(),
|
||||
createdEventId: createId(),
|
||||
queuedEventId: createId(),
|
||||
subject: request.principal.subject,
|
||||
policyFence: request.policyFence,
|
||||
});
|
||||
return response(
|
||||
result.status === 'accepted' ? 202 : 200,
|
||||
createTaskStartResponseBody(result),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof TaskStartNotFoundError) {
|
||||
return response(404, { code: 'task_not_found' });
|
||||
}
|
||||
if (error instanceof TaskStartFenceRejectedError) {
|
||||
return response(409, {
|
||||
code: 'task_start_fence_rejected',
|
||||
reason: error.reason,
|
||||
});
|
||||
}
|
||||
if (
|
||||
error instanceof InvalidTaskStartError ||
|
||||
error instanceof TaskStartUnavailableError
|
||||
) {
|
||||
return response(503, { code: 'task_start_unavailable' });
|
||||
}
|
||||
return response(503, { code: 'task_start_unavailable' });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface LocalApiResponse {
|
||||
readonly statusCode: number;
|
||||
readonly body: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import http, { type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import type { Socket } from 'node:net';
|
||||
|
||||
import type { LocalApplicationProfile } from '@qinglong/local-application';
|
||||
|
||||
import type {
|
||||
LocalApiAdmission,
|
||||
LocalApiAdmissionOperation,
|
||||
LocalApiAdmissionRequest,
|
||||
} from '../admission/localApiAdmission';
|
||||
import type { BoundedRunListInput } from '@qinglong/runtime-core/bounded-run-list-projection';
|
||||
import type { BoundedRunEventListInput } from '@qinglong/runtime-core/bounded-run-event-list-projection';
|
||||
import type { BoundedRunStepListInput } from '@qinglong/runtime-core/bounded-run-step-list-projection';
|
||||
import type { BoundedTaskListInput } from '@qinglong/runtime-core/bounded-task-list-projection';
|
||||
import type { LocalApiResponse } from './contract';
|
||||
|
||||
const MAX_HEADER_BYTES = 8 * 1_024;
|
||||
const MAX_URL_BYTES = 512;
|
||||
const MAX_RESPONSE_BYTES = 64 * 1_024;
|
||||
const RUN_READ_ROUTE_PATTERN =
|
||||
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/runs\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})$/;
|
||||
const RUN_LIST_ROUTE_PATTERN =
|
||||
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/runs$/;
|
||||
const RUN_EVENT_LIST_ROUTE_PATTERN =
|
||||
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/runs\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/events$/;
|
||||
const RUN_STEP_LIST_ROUTE_PATTERN =
|
||||
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/runs\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/steps$/;
|
||||
const RUN_CANCELLATION_ROUTE_PATTERN =
|
||||
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/runs\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/cancellation$/;
|
||||
const TASK_LIST_ROUTE_PATTERN =
|
||||
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/tasks$/;
|
||||
const TASK_READ_ROUTE_PATTERN =
|
||||
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/tasks\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})$/;
|
||||
const TASK_START_ROUTE_PATTERN =
|
||||
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/tasks\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/runs$/;
|
||||
const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
type LocalApiRouteResolution =
|
||||
| LocalApiAdmissionOperation
|
||||
| Readonly<{
|
||||
errorCode:
|
||||
| 'invalid_run_list_query'
|
||||
| 'invalid_run_event_list_query'
|
||||
| 'invalid_run_step_list_query'
|
||||
| 'invalid_task_list_query';
|
||||
}>;
|
||||
|
||||
export interface LocalApiHttpSurfaceOptions {
|
||||
readonly profile: LocalApplicationProfile;
|
||||
readonly host: '127.0.0.1' | '::1';
|
||||
readonly port: number;
|
||||
readonly admission: LocalApiAdmission;
|
||||
readonly randomUuid?: () => string;
|
||||
}
|
||||
|
||||
export interface ActiveLocalApiHttpSurface {
|
||||
readonly host: '127.0.0.1' | '::1';
|
||||
readonly port: number;
|
||||
stopAndDrain(): Promise<'stopped' | 'timed_out'>;
|
||||
}
|
||||
|
||||
function rawHeaderValues(
|
||||
request: IncomingMessage,
|
||||
name: string,
|
||||
): readonly string[] {
|
||||
const values: string[] = [];
|
||||
for (let index = 0; index < request.rawHeaders.length; index += 2) {
|
||||
if (request.rawHeaders[index]?.toLowerCase() === name) {
|
||||
values.push(request.rawHeaders[index + 1] ?? '');
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function authorization(request: IncomingMessage): string | null {
|
||||
const values = rawHeaderValues(request, 'authorization');
|
||||
return values.length === 1 ? values[0]! : null;
|
||||
}
|
||||
|
||||
function hasRequestBody(request: IncomingMessage): boolean {
|
||||
const transferEncoding = rawHeaderValues(request, 'transfer-encoding');
|
||||
const contentLength = rawHeaderValues(request, 'content-length');
|
||||
return (
|
||||
transferEncoding.length !== 0 ||
|
||||
contentLength.length > 1 ||
|
||||
(contentLength.length === 1 && contentLength[0] !== '0')
|
||||
);
|
||||
}
|
||||
|
||||
function jsonContentLength(
|
||||
request: IncomingMessage,
|
||||
maximumBodyBytes: number,
|
||||
): number {
|
||||
const transferEncoding = rawHeaderValues(request, 'transfer-encoding');
|
||||
const contentLength = rawHeaderValues(request, 'content-length');
|
||||
const contentType = rawHeaderValues(request, 'content-type');
|
||||
if (
|
||||
transferEncoding.length !== 0 ||
|
||||
contentLength.length !== 1 ||
|
||||
contentType.length !== 1 ||
|
||||
contentType[0]!.trim().toLowerCase() !== 'application/json' ||
|
||||
!/^[1-9]\d*$/.test(contentLength[0]!)
|
||||
) {
|
||||
throw new TypeError('invalid_request_body');
|
||||
}
|
||||
const length = Number(contentLength[0]);
|
||||
if (!Number.isSafeInteger(length) || length > maximumBodyBytes) {
|
||||
throw new RangeError('request_body_too_large');
|
||||
}
|
||||
return length;
|
||||
}
|
||||
|
||||
function readJsonBody(
|
||||
request: IncomingMessage,
|
||||
expectedBytes: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let received = 0;
|
||||
const fail = (error: Error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const aborted = () => fail(new Error('request_unavailable'));
|
||||
const cleanup = () => {
|
||||
signal.removeEventListener('abort', aborted);
|
||||
request.removeListener('data', data);
|
||||
request.removeListener('end', end);
|
||||
request.removeListener('error', fail);
|
||||
};
|
||||
const data = (chunk: Buffer) => {
|
||||
received += chunk.byteLength;
|
||||
if (received > expectedBytes) {
|
||||
fail(new TypeError('invalid_request_body'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
};
|
||||
const end = () => {
|
||||
cleanup();
|
||||
if (received !== expectedBytes) {
|
||||
reject(new TypeError('invalid_request_body'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const text = new TextDecoder('utf-8', { fatal: true }).decode(
|
||||
Buffer.concat(chunks, received),
|
||||
);
|
||||
resolve(JSON.parse(text));
|
||||
} catch {
|
||||
reject(new TypeError('invalid_request_body'));
|
||||
}
|
||||
};
|
||||
signal.addEventListener('abort', aborted, { once: true });
|
||||
request.on('data', data);
|
||||
request.once('end', end);
|
||||
request.once('error', fail);
|
||||
if (signal.aborted) aborted();
|
||||
});
|
||||
}
|
||||
|
||||
function parseRunListQuery(rawQuery: string | undefined): BoundedRunListInput {
|
||||
if (rawQuery === undefined) return Object.freeze({});
|
||||
if (rawQuery.length === 0) throw new TypeError();
|
||||
const values = new Map<string, string>();
|
||||
for (const field of rawQuery.split('&')) {
|
||||
const separator = field.indexOf('=');
|
||||
if (
|
||||
separator < 1 ||
|
||||
separator !== field.lastIndexOf('=') ||
|
||||
separator === field.length - 1
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const name = field.slice(0, separator);
|
||||
const value = field.slice(separator + 1);
|
||||
if (
|
||||
values.has(name) ||
|
||||
(name !== 'limit' &&
|
||||
name !== 'after_created_at_ms' &&
|
||||
name !== 'after_run_id')
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
values.set(name, value);
|
||||
}
|
||||
const rawLimit = values.get('limit');
|
||||
const limit = rawLimit === undefined ? undefined : Number(rawLimit);
|
||||
if (
|
||||
rawLimit !== undefined &&
|
||||
(!Number.isSafeInteger(limit) ||
|
||||
Number(limit) < 1 ||
|
||||
Number(limit) > 64 ||
|
||||
String(limit) !== rawLimit)
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const rawCreatedAtMs = values.get('after_created_at_ms');
|
||||
const runId = values.get('after_run_id');
|
||||
if ((rawCreatedAtMs === undefined) !== (runId === undefined)) {
|
||||
throw new TypeError();
|
||||
}
|
||||
if (rawCreatedAtMs === undefined || runId === undefined) {
|
||||
return Object.freeze({ ...(limit === undefined ? {} : { limit }) });
|
||||
}
|
||||
const createdAtMs = Number(rawCreatedAtMs);
|
||||
if (
|
||||
!Number.isSafeInteger(createdAtMs) ||
|
||||
createdAtMs < 0 ||
|
||||
String(createdAtMs) !== rawCreatedAtMs ||
|
||||
!RUN_ID_PATTERN.test(runId)
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
return Object.freeze({
|
||||
...(limit === undefined ? {} : { limit }),
|
||||
after: Object.freeze({ createdAtMs, runId }),
|
||||
});
|
||||
}
|
||||
|
||||
function parseRunEventListQuery(
|
||||
rawQuery: string | undefined,
|
||||
): BoundedRunEventListInput {
|
||||
if (rawQuery === undefined) return Object.freeze({});
|
||||
if (rawQuery.length === 0) throw new TypeError();
|
||||
const values = new Map<string, string>();
|
||||
for (const field of rawQuery.split('&')) {
|
||||
const separator = field.indexOf('=');
|
||||
if (
|
||||
separator < 1 ||
|
||||
separator !== field.lastIndexOf('=') ||
|
||||
separator === field.length - 1
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const name = field.slice(0, separator);
|
||||
const value = field.slice(separator + 1);
|
||||
if (values.has(name) || (name !== 'limit' && name !== 'after_sequence')) {
|
||||
throw new TypeError();
|
||||
}
|
||||
values.set(name, value);
|
||||
}
|
||||
const rawLimit = values.get('limit');
|
||||
const limit = rawLimit === undefined ? undefined : Number(rawLimit);
|
||||
const rawAfterSequence = values.get('after_sequence');
|
||||
const afterSequence =
|
||||
rawAfterSequence === undefined ? undefined : Number(rawAfterSequence);
|
||||
if (
|
||||
(rawLimit !== undefined &&
|
||||
(!Number.isSafeInteger(limit) ||
|
||||
Number(limit) < 1 ||
|
||||
Number(limit) > 64 ||
|
||||
String(limit) !== rawLimit)) ||
|
||||
(rawAfterSequence !== undefined &&
|
||||
(!Number.isSafeInteger(afterSequence) ||
|
||||
Number(afterSequence) < 0 ||
|
||||
Number(afterSequence) > 2_147_483_647 ||
|
||||
String(afterSequence) !== rawAfterSequence))
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
return Object.freeze({
|
||||
...(afterSequence === undefined ? {} : { afterSequence }),
|
||||
...(limit === undefined ? {} : { limit }),
|
||||
});
|
||||
}
|
||||
|
||||
function parseRunStepListQuery(
|
||||
rawQuery: string | undefined,
|
||||
): BoundedRunStepListInput {
|
||||
if (rawQuery === undefined) return Object.freeze({});
|
||||
if (rawQuery.length === 0) throw new TypeError();
|
||||
const values = new Map<string, string>();
|
||||
for (const field of rawQuery.split('&')) {
|
||||
const separator = field.indexOf('=');
|
||||
if (
|
||||
separator < 1 ||
|
||||
separator !== field.lastIndexOf('=') ||
|
||||
separator === field.length - 1
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const name = field.slice(0, separator);
|
||||
const value = field.slice(separator + 1);
|
||||
if (
|
||||
values.has(name) ||
|
||||
(name !== 'limit' &&
|
||||
name !== 'after_step_key' &&
|
||||
name !== 'after_step_run_id')
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
values.set(name, value);
|
||||
}
|
||||
const rawLimit = values.get('limit');
|
||||
const limit = rawLimit === undefined ? undefined : Number(rawLimit);
|
||||
if (
|
||||
rawLimit !== undefined &&
|
||||
(!Number.isSafeInteger(limit) ||
|
||||
Number(limit) < 1 ||
|
||||
Number(limit) > 64 ||
|
||||
String(limit) !== rawLimit)
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const stepKey = values.get('after_step_key');
|
||||
const stepRunId = values.get('after_step_run_id');
|
||||
if ((stepKey === undefined) !== (stepRunId === undefined)) {
|
||||
throw new TypeError();
|
||||
}
|
||||
if (stepKey === undefined || stepRunId === undefined) {
|
||||
return Object.freeze({ ...(limit === undefined ? {} : { limit }) });
|
||||
}
|
||||
if (!RUN_ID_PATTERN.test(stepKey) || !RUN_ID_PATTERN.test(stepRunId)) {
|
||||
throw new TypeError();
|
||||
}
|
||||
return Object.freeze({
|
||||
...(limit === undefined ? {} : { limit }),
|
||||
after: Object.freeze({ stepKey, stepRunId }),
|
||||
});
|
||||
}
|
||||
|
||||
function parseTaskListQuery(
|
||||
rawQuery: string | undefined,
|
||||
): BoundedTaskListInput {
|
||||
if (rawQuery === undefined) return Object.freeze({});
|
||||
if (rawQuery.length === 0) throw new TypeError();
|
||||
const values = new Map<string, string>();
|
||||
for (const field of rawQuery.split('&')) {
|
||||
const separator = field.indexOf('=');
|
||||
if (
|
||||
separator < 1 ||
|
||||
separator !== field.lastIndexOf('=') ||
|
||||
separator === field.length - 1
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const name = field.slice(0, separator);
|
||||
const value = field.slice(separator + 1);
|
||||
if (
|
||||
values.has(name) ||
|
||||
(name !== 'limit' && name !== 'after_task_id')
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
values.set(name, value);
|
||||
}
|
||||
const rawLimit = values.get('limit');
|
||||
const limit = rawLimit === undefined ? undefined : Number(rawLimit);
|
||||
const taskId = values.get('after_task_id');
|
||||
if (
|
||||
(rawLimit !== undefined &&
|
||||
(!Number.isSafeInteger(limit) ||
|
||||
Number(limit) < 1 ||
|
||||
Number(limit) > 64 ||
|
||||
String(limit) !== rawLimit)) ||
|
||||
(taskId !== undefined && !TASK_ID_PATTERN.test(taskId))
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
return Object.freeze({
|
||||
...(limit === undefined ? {} : { limit }),
|
||||
...(taskId === undefined
|
||||
? {}
|
||||
: { after: Object.freeze({ taskId }) }),
|
||||
});
|
||||
}
|
||||
|
||||
function route(request: IncomingMessage): LocalApiRouteResolution | null {
|
||||
const rawUrl = request.url;
|
||||
if (
|
||||
typeof rawUrl !== 'string' ||
|
||||
rawUrl.length < 1 ||
|
||||
Buffer.byteLength(rawUrl, 'utf8') > MAX_URL_BYTES ||
|
||||
rawUrl.includes('%') ||
|
||||
rawUrl.includes('#')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const separator = rawUrl.indexOf('?');
|
||||
if (separator !== rawUrl.lastIndexOf('?')) return null;
|
||||
const path = separator < 0 ? rawUrl : rawUrl.slice(0, separator);
|
||||
const rawQuery = separator < 0 ? undefined : rawUrl.slice(separator + 1);
|
||||
if (request.method === 'POST') {
|
||||
const taskStartMatch = TASK_START_ROUTE_PATTERN.exec(path);
|
||||
if (taskStartMatch && rawQuery === undefined) {
|
||||
return Object.freeze({
|
||||
operationId: 'task.start',
|
||||
projectId: taskStartMatch[1]!,
|
||||
taskId: taskStartMatch[2]!,
|
||||
});
|
||||
}
|
||||
const cancellationMatch = RUN_CANCELLATION_ROUTE_PATTERN.exec(path);
|
||||
return cancellationMatch && rawQuery === undefined
|
||||
? Object.freeze({
|
||||
operationId: 'run.cancel',
|
||||
projectId: cancellationMatch[1]!,
|
||||
runId: cancellationMatch[2]!,
|
||||
})
|
||||
: null;
|
||||
}
|
||||
if (request.method !== 'GET') return null;
|
||||
const taskReadMatch = TASK_READ_ROUTE_PATTERN.exec(path);
|
||||
if (taskReadMatch) {
|
||||
return rawQuery === undefined
|
||||
? Object.freeze({
|
||||
operationId: 'task.get',
|
||||
projectId: taskReadMatch[1]!,
|
||||
taskId: taskReadMatch[2]!,
|
||||
})
|
||||
: null;
|
||||
}
|
||||
const taskListMatch = TASK_LIST_ROUTE_PATTERN.exec(path);
|
||||
if (taskListMatch) {
|
||||
try {
|
||||
return Object.freeze({
|
||||
operationId: 'task.list',
|
||||
projectId: taskListMatch[1]!,
|
||||
input: parseTaskListQuery(rawQuery),
|
||||
});
|
||||
} catch {
|
||||
return Object.freeze({ errorCode: 'invalid_task_list_query' });
|
||||
}
|
||||
}
|
||||
const eventListMatch = RUN_EVENT_LIST_ROUTE_PATTERN.exec(path);
|
||||
if (eventListMatch) {
|
||||
try {
|
||||
return Object.freeze({
|
||||
operationId: 'run.events.list',
|
||||
projectId: eventListMatch[1]!,
|
||||
runId: eventListMatch[2]!,
|
||||
input: parseRunEventListQuery(rawQuery),
|
||||
});
|
||||
} catch {
|
||||
return Object.freeze({ errorCode: 'invalid_run_event_list_query' });
|
||||
}
|
||||
}
|
||||
const stepListMatch = RUN_STEP_LIST_ROUTE_PATTERN.exec(path);
|
||||
if (stepListMatch) {
|
||||
try {
|
||||
return Object.freeze({
|
||||
operationId: 'run.steps.list',
|
||||
projectId: stepListMatch[1]!,
|
||||
runId: stepListMatch[2]!,
|
||||
input: parseRunStepListQuery(rawQuery),
|
||||
});
|
||||
} catch {
|
||||
return Object.freeze({ errorCode: 'invalid_run_step_list_query' });
|
||||
}
|
||||
}
|
||||
const readMatch = RUN_READ_ROUTE_PATTERN.exec(path);
|
||||
if (readMatch) {
|
||||
return rawQuery === undefined
|
||||
? Object.freeze({
|
||||
operationId: 'run.get',
|
||||
projectId: readMatch[1]!,
|
||||
runId: readMatch[2]!,
|
||||
})
|
||||
: null;
|
||||
}
|
||||
const listMatch = RUN_LIST_ROUTE_PATTERN.exec(path);
|
||||
if (!listMatch) return null;
|
||||
try {
|
||||
return Object.freeze({
|
||||
operationId: 'run.list',
|
||||
projectId: listMatch[1]!,
|
||||
input: parseRunListQuery(rawQuery),
|
||||
});
|
||||
} catch {
|
||||
return Object.freeze({ errorCode: 'invalid_run_list_query' });
|
||||
}
|
||||
}
|
||||
|
||||
function send(
|
||||
response: ServerResponse,
|
||||
requestId: string,
|
||||
value: Readonly<LocalApiResponse>,
|
||||
): void {
|
||||
if (response.destroyed || response.headersSent) return;
|
||||
let body: string;
|
||||
try {
|
||||
body = JSON.stringify(value.body);
|
||||
} catch {
|
||||
body = JSON.stringify({ code: 'response_unavailable' });
|
||||
value = Object.freeze({ statusCode: 503, body: Object.freeze({}) });
|
||||
}
|
||||
if (Buffer.byteLength(body, 'utf8') > MAX_RESPONSE_BYTES) {
|
||||
body = JSON.stringify({ code: 'response_unavailable' });
|
||||
value = Object.freeze({ statusCode: 503, body: Object.freeze({}) });
|
||||
}
|
||||
response.statusCode = value.statusCode;
|
||||
response.setHeader('content-type', 'application/json; charset=utf-8');
|
||||
response.setHeader('cache-control', 'no-store');
|
||||
response.setHeader('x-content-type-options', 'nosniff');
|
||||
response.setHeader('x-request-id', requestId);
|
||||
response.setHeader('content-length', Buffer.byteLength(body, 'utf8'));
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function errorResponse(statusCode: number, code: string): LocalApiResponse {
|
||||
return Object.freeze({
|
||||
statusCode,
|
||||
body: Object.freeze({ code }),
|
||||
});
|
||||
}
|
||||
|
||||
function validateOptions(options: LocalApiHttpSurfaceOptions): void {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
(options.profile !== 'edge' && options.profile !== 'standalone') ||
|
||||
(options.host !== '127.0.0.1' && options.host !== '::1') ||
|
||||
!Number.isSafeInteger(options.port) ||
|
||||
options.port < 1_024 ||
|
||||
options.port > 65_535 ||
|
||||
typeof options.admission?.prepare !== 'function' ||
|
||||
(options.randomUuid !== undefined &&
|
||||
typeof options.randomUuid !== 'function')
|
||||
) {
|
||||
throw new TypeError('Local API HTTP surface options are invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export async function startLocalApiHttpSurface(
|
||||
options: LocalApiHttpSurfaceOptions,
|
||||
): Promise<Readonly<ActiveLocalApiHttpSurface>> {
|
||||
validateOptions(options);
|
||||
const uuid = options.randomUuid ?? randomUUID;
|
||||
const maxConcurrentRequests = options.profile === 'edge' ? 4 : 32;
|
||||
const drainTimeoutMs = options.profile === 'edge' ? 5_000 : 10_000;
|
||||
let accepting = true;
|
||||
const inFlight = new Set<Promise<void>>();
|
||||
const sockets = new Set<Socket>();
|
||||
|
||||
const server = http.createServer(
|
||||
{
|
||||
maxHeaderSize: MAX_HEADER_BYTES,
|
||||
requestTimeout: 5_000,
|
||||
keepAlive: true,
|
||||
},
|
||||
(request, response) => {
|
||||
const requestId = `local:${uuid()}`;
|
||||
if (!accepting) {
|
||||
send(response, requestId, errorResponse(503, 'server_draining'));
|
||||
return;
|
||||
}
|
||||
if (inFlight.size >= maxConcurrentRequests) {
|
||||
send(response, requestId, errorResponse(503, 'server_overloaded'));
|
||||
return;
|
||||
}
|
||||
const resolvedRoute = route(request);
|
||||
if (!resolvedRoute) {
|
||||
send(response, requestId, errorResponse(404, 'route_not_found'));
|
||||
return;
|
||||
}
|
||||
if ('errorCode' in resolvedRoute) {
|
||||
send(response, requestId, errorResponse(400, resolvedRoute.errorCode));
|
||||
return;
|
||||
}
|
||||
const abort = new AbortController();
|
||||
request.once('aborted', () => abort.abort());
|
||||
response.once('close', () => {
|
||||
if (!response.writableFinished) abort.abort();
|
||||
});
|
||||
const admissionRequest: LocalApiAdmissionRequest = Object.freeze({
|
||||
requestId,
|
||||
operation: resolvedRoute,
|
||||
authorization: authorization(request),
|
||||
signal: abort.signal,
|
||||
});
|
||||
let operation: Promise<void>;
|
||||
operation = options.admission
|
||||
.prepare(admissionRequest)
|
||||
.then(async (prepared) => {
|
||||
if (!('handle' in prepared)) {
|
||||
if (hasRequestBody(request)) {
|
||||
response.setHeader('connection', 'close');
|
||||
}
|
||||
send(response, requestId, prepared);
|
||||
return;
|
||||
}
|
||||
if (prepared.bodyMode === 'none') {
|
||||
if (hasRequestBody(request)) {
|
||||
send(
|
||||
response,
|
||||
requestId,
|
||||
errorResponse(400, 'invalid_request_body'),
|
||||
);
|
||||
request.resume();
|
||||
return;
|
||||
}
|
||||
send(response, requestId, await prepared.handle(null));
|
||||
return;
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
const expectedBytes = jsonContentLength(
|
||||
request,
|
||||
prepared.maximumBodyBytes,
|
||||
);
|
||||
body = await readJsonBody(request, expectedBytes, abort.signal);
|
||||
} catch (error) {
|
||||
const code =
|
||||
error instanceof RangeError
|
||||
? 'request_body_too_large'
|
||||
: error instanceof Error &&
|
||||
error.message === 'request_unavailable'
|
||||
? 'request_unavailable'
|
||||
: 'invalid_request_body';
|
||||
send(
|
||||
response,
|
||||
requestId,
|
||||
errorResponse(
|
||||
code === 'request_body_too_large'
|
||||
? 413
|
||||
: code === 'request_unavailable'
|
||||
? 503
|
||||
: 400,
|
||||
code,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
send(response, requestId, await prepared.handle(body));
|
||||
})
|
||||
.catch(() =>
|
||||
send(response, requestId, errorResponse(503, 'request_unavailable')),
|
||||
)
|
||||
.finally(() => {
|
||||
inFlight.delete(operation);
|
||||
});
|
||||
inFlight.add(operation);
|
||||
},
|
||||
);
|
||||
server.headersTimeout = 5_000;
|
||||
server.keepAliveTimeout = 5_000;
|
||||
server.maxRequestsPerSocket = 100;
|
||||
server.maxConnections = maxConcurrentRequests * 2;
|
||||
server.on('connection', (socket) => {
|
||||
sockets.add(socket);
|
||||
socket.once('close', () => sockets.delete(socket));
|
||||
});
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (error: Error) => {
|
||||
server.off('listening', onListening);
|
||||
reject(error);
|
||||
};
|
||||
const onListening = () => {
|
||||
server.off('error', onError);
|
||||
resolve();
|
||||
};
|
||||
server.once('error', onError);
|
||||
server.once('listening', onListening);
|
||||
server.listen(options.port, options.host);
|
||||
});
|
||||
} catch (error) {
|
||||
accepting = false;
|
||||
for (const socket of sockets) socket.destroy();
|
||||
throw error;
|
||||
}
|
||||
|
||||
let stopPromise: Promise<'stopped' | 'timed_out'> | undefined;
|
||||
return Object.freeze({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
stopAndDrain() {
|
||||
if (stopPromise) return stopPromise;
|
||||
accepting = false;
|
||||
stopPromise = (async () => {
|
||||
const closed = new Promise<void>((resolve, reject) => {
|
||||
server.close((error?: Error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
server.closeIdleConnections();
|
||||
});
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<'timed_out'>((resolve) => {
|
||||
timer = setTimeout(() => resolve('timed_out'), drainTimeoutMs);
|
||||
});
|
||||
const drained = Promise.allSettled([...inFlight]).then(
|
||||
() => 'stopped' as const,
|
||||
);
|
||||
const result = await Promise.race([drained, timeout]);
|
||||
if (timer) clearTimeout(timer);
|
||||
if (result === 'timed_out') {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
}
|
||||
await closed;
|
||||
return result;
|
||||
})();
|
||||
return stopPromise;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createLocalApiAdmission,
|
||||
} = require('../dist/admission/localApiAdmission.js');
|
||||
|
||||
const PRINCIPAL = Object.freeze({
|
||||
subject: Object.freeze({ type: 'user', id: 'usr_local' }),
|
||||
authenticationId: 'credential:local',
|
||||
authenticatedAtMs: 9_000,
|
||||
expiresAtMs: 11_000,
|
||||
assurance: 'single_factor',
|
||||
});
|
||||
|
||||
function request(overrides = {}) {
|
||||
return Object.freeze({
|
||||
requestId: 'local:019f70c0-0000-7000-8000-000000000001',
|
||||
operation: Object.freeze({
|
||||
operationId: 'run.get',
|
||||
projectId: 'prj_default',
|
||||
runId: 'run_123',
|
||||
}),
|
||||
authorization: 'Bearer opaque',
|
||||
signal: new AbortController().signal,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function fixture(overrides = {}) {
|
||||
const events = [];
|
||||
const options = {
|
||||
authenticator: {
|
||||
async authenticate() {
|
||||
events.push('authenticate');
|
||||
return Object.freeze({
|
||||
principal: PRINCIPAL,
|
||||
async confirm() {
|
||||
events.push('confirm');
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
policy: {
|
||||
async authorize(principal, projectId, permission) {
|
||||
assert.equal(principal, PRINCIPAL);
|
||||
events.push(`authorize:${permission}:${projectId}`);
|
||||
return {
|
||||
effect: 'allow',
|
||||
reasons: ['role_grant'],
|
||||
fence: { projectVersion: 2, bindingVersion: 3 },
|
||||
};
|
||||
},
|
||||
},
|
||||
audit: {
|
||||
async record(record) {
|
||||
events.push(`audit:${record.outcome}:${record.operationId}`);
|
||||
},
|
||||
},
|
||||
runReadRoute: {
|
||||
async handle(value) {
|
||||
events.push(`route:${value.projectId}:${value.runId}`);
|
||||
return { statusCode: 200, body: { run: { id: value.runId } } };
|
||||
},
|
||||
},
|
||||
runListRoute: {
|
||||
async handle(value) {
|
||||
events.push(`list:${value.projectId}:${value.input.limit ?? 32}`);
|
||||
return { statusCode: 200, body: { runs: [], hasMore: false } };
|
||||
},
|
||||
},
|
||||
runEventListRoute: {
|
||||
async handle(value) {
|
||||
events.push(
|
||||
`events:${value.projectId}:${value.runId}:${
|
||||
value.input.afterSequence ?? 0
|
||||
}`,
|
||||
);
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { events: [], hasMore: false, nextAfterSequence: 0 },
|
||||
};
|
||||
},
|
||||
},
|
||||
runStepListRoute: {
|
||||
async handle(value) {
|
||||
events.push(
|
||||
`steps:${value.projectId}:${value.runId}:${
|
||||
value.input.after?.stepKey ?? 'start'
|
||||
}`,
|
||||
);
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { steps: [], hasMore: false, next: null },
|
||||
};
|
||||
},
|
||||
},
|
||||
runCancellationRoute: {
|
||||
async handle(value) {
|
||||
events.push(`cancel:${value.projectId}:${value.runId}`);
|
||||
return { statusCode: 202, body: { status: 'accepted' } };
|
||||
},
|
||||
},
|
||||
taskListRoute: {
|
||||
async handle(value) {
|
||||
events.push(`tasks:${value.projectId}:${value.input.limit ?? 32}`);
|
||||
return { statusCode: 200, body: { tasks: [], hasMore: false } };
|
||||
},
|
||||
},
|
||||
taskReadRoute: {
|
||||
async handle(value) {
|
||||
events.push(`task:${value.projectId}:${value.taskId}`);
|
||||
return { statusCode: 200, body: { task: { taskId: value.taskId } } };
|
||||
},
|
||||
},
|
||||
taskStartRoute: {
|
||||
async handle(value) {
|
||||
events.push(`task-start:${value.projectId}:${value.taskId}`);
|
||||
return { statusCode: 202, body: { status: 'accepted' } };
|
||||
},
|
||||
},
|
||||
now: () => 10_000,
|
||||
randomUuid: () => '019f70c0-0000-4000-8000-000000000002',
|
||||
...overrides,
|
||||
};
|
||||
return { admission: createLocalApiAdmission(options), events };
|
||||
}
|
||||
|
||||
async function execute(admission, value, body = null) {
|
||||
const prepared = await admission.prepare(value);
|
||||
return typeof prepared.handle === 'function'
|
||||
? prepared.handle(body)
|
||||
: prepared;
|
||||
}
|
||||
|
||||
test('authenticates, authorizes, durably audits and re-confirms before reading', async () => {
|
||||
const { admission, events } = fixture();
|
||||
assert.deepEqual(await execute(admission, request()), {
|
||||
statusCode: 200,
|
||||
body: { run: { id: 'run_123' } },
|
||||
});
|
||||
assert.deepEqual(events, [
|
||||
'authenticate',
|
||||
'authorize:run.read:prj_default',
|
||||
'audit:allowed:run.get',
|
||||
'confirm',
|
||||
'route:prj_default:run_123',
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses the same admission chain with a route-owned run.list audit identity', async () => {
|
||||
const { admission, events } = fixture();
|
||||
assert.deepEqual(
|
||||
await execute(
|
||||
admission,
|
||||
request({
|
||||
operation: Object.freeze({
|
||||
operationId: 'run.list',
|
||||
projectId: 'prj_default',
|
||||
input: Object.freeze({ limit: 8 }),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
{ statusCode: 200, body: { runs: [], hasMore: false } },
|
||||
);
|
||||
assert.deepEqual(events, [
|
||||
'authenticate',
|
||||
'authorize:run.read:prj_default',
|
||||
'audit:allowed:run.list',
|
||||
'confirm',
|
||||
'list:prj_default:8',
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses the same admission chain with a route-owned run.events.list audit identity', async () => {
|
||||
const { admission, events } = fixture();
|
||||
assert.deepEqual(
|
||||
await execute(
|
||||
admission,
|
||||
request({
|
||||
operation: Object.freeze({
|
||||
operationId: 'run.events.list',
|
||||
projectId: 'prj_default',
|
||||
runId: 'run_123',
|
||||
input: Object.freeze({ afterSequence: 7, limit: 8 }),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
{
|
||||
statusCode: 200,
|
||||
body: { events: [], hasMore: false, nextAfterSequence: 0 },
|
||||
},
|
||||
);
|
||||
assert.deepEqual(events, [
|
||||
'authenticate',
|
||||
'authorize:run.read:prj_default',
|
||||
'audit:allowed:run.events.list',
|
||||
'confirm',
|
||||
'events:prj_default:run_123:7',
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses the same admission chain with a route-owned run.steps.list audit identity', async () => {
|
||||
const { admission, events } = fixture();
|
||||
assert.deepEqual(
|
||||
await execute(
|
||||
admission,
|
||||
request({
|
||||
operation: Object.freeze({
|
||||
operationId: 'run.steps.list',
|
||||
projectId: 'prj_default',
|
||||
runId: 'run_123',
|
||||
input: Object.freeze({
|
||||
after: Object.freeze({
|
||||
stepKey: 'build',
|
||||
stepRunId: 'step_1',
|
||||
}),
|
||||
limit: 8,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
{ statusCode: 200, body: { steps: [], hasMore: false, next: null } },
|
||||
);
|
||||
assert.deepEqual(events, [
|
||||
'authenticate',
|
||||
'authorize:run.read:prj_default',
|
||||
'audit:allowed:run.steps.list',
|
||||
'confirm',
|
||||
'steps:prj_default:run_123:build',
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses task.read with a route-owned task.list audit identity', async () => {
|
||||
const { admission, events } = fixture();
|
||||
assert.deepEqual(
|
||||
await execute(
|
||||
admission,
|
||||
request({
|
||||
operation: Object.freeze({
|
||||
operationId: 'task.list',
|
||||
projectId: 'prj_default',
|
||||
input: Object.freeze({ limit: 8 }),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
{ statusCode: 200, body: { tasks: [], hasMore: false } },
|
||||
);
|
||||
assert.deepEqual(events, [
|
||||
'authenticate',
|
||||
'authorize:task.read:prj_default',
|
||||
'audit:allowed:task.list',
|
||||
'confirm',
|
||||
'tasks:prj_default:8',
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses task.read with a route-owned task.get audit identity', async () => {
|
||||
const { admission, events } = fixture();
|
||||
assert.deepEqual(
|
||||
await execute(
|
||||
admission,
|
||||
request({
|
||||
operation: Object.freeze({
|
||||
operationId: 'task.get',
|
||||
projectId: 'prj_default',
|
||||
taskId: 'task-a',
|
||||
}),
|
||||
}),
|
||||
),
|
||||
{ statusCode: 200, body: { task: { taskId: 'task-a' } } },
|
||||
);
|
||||
assert.deepEqual(events, [
|
||||
'authenticate',
|
||||
'authorize:task.read:prj_default',
|
||||
'audit:allowed:task.get',
|
||||
'confirm',
|
||||
'task:prj_default:task-a',
|
||||
]);
|
||||
});
|
||||
|
||||
test('authorizes and audits run.stop before exposing the cancellation body handler', async () => {
|
||||
const { admission, events } = fixture();
|
||||
const prepared = await admission.prepare(
|
||||
request({
|
||||
operation: Object.freeze({
|
||||
operationId: 'run.cancel',
|
||||
projectId: 'prj_default',
|
||||
runId: 'run_123',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.equal(typeof prepared.handle, 'function');
|
||||
assert.equal(prepared.bodyMode, 'json');
|
||||
assert.equal(prepared.maximumBodyBytes, 512);
|
||||
assert.deepEqual(events, [
|
||||
'authenticate',
|
||||
'authorize:run.stop:prj_default',
|
||||
'audit:allowed:run.cancel',
|
||||
'confirm',
|
||||
]);
|
||||
assert.deepEqual(await prepared.handle({ schema: 'x' }), {
|
||||
statusCode: 202,
|
||||
body: { status: 'accepted' },
|
||||
});
|
||||
assert.equal(events.at(-1), 'cancel:prj_default:run_123');
|
||||
});
|
||||
|
||||
test('authorizes and audits run.start before exposing the Task body handler', async () => {
|
||||
const { admission, events } = fixture();
|
||||
const prepared = await admission.prepare(
|
||||
request({
|
||||
operation: Object.freeze({
|
||||
operationId: 'task.start',
|
||||
projectId: 'prj_default',
|
||||
taskId: 'task-a',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.equal(prepared.bodyMode, 'json');
|
||||
assert.equal(prepared.maximumBodyBytes, 512);
|
||||
assert.deepEqual(events, [
|
||||
'authenticate',
|
||||
'authorize:run.start:prj_default',
|
||||
'audit:allowed:task.start',
|
||||
'confirm',
|
||||
]);
|
||||
assert.equal((await prepared.handle({ schema: 'x' })).statusCode, 202);
|
||||
assert.equal(events.at(-1), 'task-start:prj_default:task-a');
|
||||
});
|
||||
|
||||
test('audits authentication rejection before returning a challenge', async () => {
|
||||
const events = [];
|
||||
const { admission } = fixture({
|
||||
authenticator: {
|
||||
async authenticate() {
|
||||
events.push('authenticate');
|
||||
return null;
|
||||
},
|
||||
},
|
||||
audit: {
|
||||
async record(record) {
|
||||
events.push(`audit:${record.outcome}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await execute(admission, request()), {
|
||||
statusCode: 401,
|
||||
body: { code: 'authentication_required' },
|
||||
});
|
||||
assert.deepEqual(events, ['authenticate', 'audit:authentication_rejected']);
|
||||
});
|
||||
|
||||
test('does not confirm or route denied, unaudited or changed credentials', async () => {
|
||||
const denied = fixture({
|
||||
policy: {
|
||||
async authorize() {
|
||||
return { effect: 'deny', reasons: ['no_binding'], fence: null };
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await execute(denied.admission, request()), {
|
||||
statusCode: 403,
|
||||
body: { code: 'forbidden' },
|
||||
});
|
||||
assert.equal(denied.events.includes('confirm'), false);
|
||||
assert.equal(
|
||||
denied.events.some((event) => event.startsWith('route:')),
|
||||
false,
|
||||
);
|
||||
|
||||
const unaudited = fixture({
|
||||
audit: {
|
||||
async record() {
|
||||
throw new Error('audit unavailable');
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await execute(unaudited.admission, request()), {
|
||||
statusCode: 503,
|
||||
body: { code: 'security_audit_unavailable' },
|
||||
});
|
||||
assert.equal(unaudited.events.includes('confirm'), false);
|
||||
|
||||
const changed = fixture({
|
||||
authenticator: {
|
||||
async authenticate() {
|
||||
return {
|
||||
principal: PRINCIPAL,
|
||||
async confirm() {
|
||||
throw new Error('credential rotated');
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await execute(changed.admission, request()), {
|
||||
statusCode: 503,
|
||||
body: { code: 'authentication_unavailable' },
|
||||
});
|
||||
assert.equal(
|
||||
changed.events.some((event) => event.startsWith('route:')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
test('publishes bounded help without bootstrapping storage or a listener', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.resolve(__dirname, '../dist/cli.js'), '--help'],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(result.stderr, '');
|
||||
assert.equal(
|
||||
result.stdout,
|
||||
'Usage: ql3-local-api --config /absolute/private-config.json\n',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LOCAL_API_PROCESS_CONFIG_SCHEMA,
|
||||
LocalApiProcessConfigError,
|
||||
normalizeLocalApiProcessConfig,
|
||||
} = require('../dist/production-process/config.js');
|
||||
|
||||
function candidate(overrides = {}) {
|
||||
return {
|
||||
schema: LOCAL_API_PROCESS_CONFIG_SCHEMA,
|
||||
deploymentRoot: '/srv/qinglong',
|
||||
applicationConfigFilePath: '/srv/qinglong/private/application.json',
|
||||
ownerPepperKeyringDirectory: '/srv/qinglong/private/owner-pepper',
|
||||
listener: { host: '127.0.0.1', port: 5701 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('normalizes one exact loopback-only Local API process configuration', () => {
|
||||
assert.deepEqual(normalizeLocalApiProcessConfig(candidate()), candidate());
|
||||
assert.deepEqual(
|
||||
normalizeLocalApiProcessConfig(
|
||||
candidate({ listener: { host: '::1', port: 65535 } }),
|
||||
).listener,
|
||||
{ host: '::1', port: 65535 },
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects remote listeners, privileged ports and path authority escapes', () => {
|
||||
for (const value of [
|
||||
candidate({ listener: { host: '0.0.0.0', port: 5701 } }),
|
||||
candidate({ listener: { host: '127.0.0.1', port: 80 } }),
|
||||
candidate({ applicationConfigFilePath: '/srv/application.json' }),
|
||||
candidate({ ownerPepperKeyringDirectory: '/srv/qinglong' }),
|
||||
{ ...candidate(), unexpected: true },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => normalizeLocalApiProcessConfig(value),
|
||||
LocalApiProcessConfigError,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LocalOwnerPepperKeyringFileProvider,
|
||||
provisionLocalOwnerPepperKey,
|
||||
} = require('@qinglong/local-owner-console/pepper-custody');
|
||||
const {
|
||||
apiCredentialSecretDigest,
|
||||
formatApiCredentialToken,
|
||||
} = require('@qinglong/runtime-core/api-credential-token');
|
||||
const {
|
||||
LocalApiCredentialAuthenticationUnavailableError,
|
||||
createLocalApiCredentialAuthenticator,
|
||||
} = require('../dist/authentication/credentialAuthenticator.js');
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
const CREDENTIAL_ID = 'local-api-owner';
|
||||
const PEPPER_KEY_ID = 'owner-pepper-v1';
|
||||
const SECRET = Buffer.alloc(32, 42).toString('base64url');
|
||||
const PEPPER = Buffer.alloc(32, 43).toString('base64url');
|
||||
const TOKEN = formatApiCredentialToken(CREDENTIAL_ID, SECRET);
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-api-auth-'));
|
||||
fs.chmodSync(directory, 0o700);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const summary = provisionLocalOwnerPepperKey({
|
||||
keyringDirectory: directory,
|
||||
pepperKeyId: PEPPER_KEY_ID,
|
||||
randomBytes: () => Buffer.alloc(32, 43),
|
||||
});
|
||||
const credential = {
|
||||
credentialId: CREDENTIAL_ID,
|
||||
version: 1,
|
||||
pepperKeyId: PEPPER_KEY_ID,
|
||||
state: 'active',
|
||||
subject: { type: 'user', id: 'user-local-api' },
|
||||
subjectStatus: 'active',
|
||||
secretDigest: apiCredentialSecretDigest(
|
||||
PEPPER,
|
||||
CREDENTIAL_ID,
|
||||
SECRET,
|
||||
),
|
||||
createdAtMs: NOW - 1_000,
|
||||
notBeforeAtMs: NOW - 1_000,
|
||||
expiresAtMs: NOW + 60_000,
|
||||
};
|
||||
const pepperKey = {
|
||||
pepperKeyId: PEPPER_KEY_ID,
|
||||
materialDigest: summary.digest,
|
||||
backupDigest: 'b'.repeat(64),
|
||||
state: 'active',
|
||||
version: 2,
|
||||
registeredAtMs: NOW - 2_000,
|
||||
activatedAtMs: NOW - 1_500,
|
||||
};
|
||||
const authority = {
|
||||
profile: 'edge',
|
||||
runs: {},
|
||||
apiCredentials: {
|
||||
async resolve(credentialId) {
|
||||
return credentialId === CREDENTIAL_ID ? { ...credential } : null;
|
||||
},
|
||||
},
|
||||
ownerPepper: {
|
||||
async resolveKey(pepperKeyId) {
|
||||
return pepperKeyId === PEPPER_KEY_ID ? { ...pepperKey } : null;
|
||||
},
|
||||
},
|
||||
projectPolicy: {},
|
||||
securityAudit: {},
|
||||
};
|
||||
return {
|
||||
authority,
|
||||
credential,
|
||||
pepperKey,
|
||||
provider: new LocalOwnerPepperKeyringFileProvider(directory),
|
||||
};
|
||||
}
|
||||
|
||||
test('authenticates one exact Bearer credential and re-confirms its authority fence', async (t) => {
|
||||
const value = fixture(t);
|
||||
const authenticator = createLocalApiCredentialAuthenticator(
|
||||
value.authority,
|
||||
value.provider,
|
||||
{ now: () => NOW },
|
||||
);
|
||||
assert.equal(await authenticator.authenticate(`Basic ${TOKEN}`), null);
|
||||
assert.equal(await authenticator.authenticate('Bearer malformed'), null);
|
||||
|
||||
const authentication = await authenticator.authenticate(`Bearer ${TOKEN}`);
|
||||
assert.deepEqual(authentication.principal.subject, {
|
||||
type: 'user',
|
||||
id: 'user-local-api',
|
||||
});
|
||||
await authentication.confirm();
|
||||
});
|
||||
|
||||
test('fails closed when credential or pepper authority changes after audit', async (t) => {
|
||||
const value = fixture(t);
|
||||
const authenticator = createLocalApiCredentialAuthenticator(
|
||||
value.authority,
|
||||
value.provider,
|
||||
{ now: () => NOW },
|
||||
);
|
||||
const credentialRevoked = await authenticator.authenticate(`Bearer ${TOKEN}`);
|
||||
value.credential.state = 'revoked';
|
||||
await assert.rejects(
|
||||
credentialRevoked.confirm(),
|
||||
LocalApiCredentialAuthenticationUnavailableError,
|
||||
);
|
||||
|
||||
value.credential.state = 'active';
|
||||
const pepperChanged = await authenticator.authenticate(`Bearer ${TOKEN}`);
|
||||
value.pepperKey.materialDigest = 'f'.repeat(64);
|
||||
await assert.rejects(
|
||||
pepperChanged.confirm(),
|
||||
LocalApiCredentialAuthenticationUnavailableError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,653 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const http = require('node:http');
|
||||
const net = require('node:net');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
startLocalApiHttpSurface,
|
||||
} = require('../dist/transport/httpSurface.js');
|
||||
|
||||
function reservePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve(address.port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function request(port, path, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const outgoing = http.request(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path,
|
||||
method: options.method ?? 'GET',
|
||||
headers: options.headers ?? { authorization: 'Bearer opaque' },
|
||||
},
|
||||
(response) => {
|
||||
const chunks = [];
|
||||
response.on('data', (chunk) => chunks.push(chunk));
|
||||
response.on('end', () =>
|
||||
resolve({
|
||||
statusCode: response.statusCode,
|
||||
headers: response.headers,
|
||||
body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
outgoing.once('error', reject);
|
||||
if (options.body) outgoing.write(options.body);
|
||||
outgoing.end();
|
||||
});
|
||||
}
|
||||
|
||||
function preparedAdmission(handler) {
|
||||
return {
|
||||
async prepare(value) {
|
||||
const json = ['run.cancel', 'task.start'].includes(
|
||||
value.operation.operationId,
|
||||
);
|
||||
return {
|
||||
bodyMode: json ? 'json' : 'none',
|
||||
maximumBodyBytes: json ? 512 : 0,
|
||||
handle(body) {
|
||||
return handler(value, body);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('serves only the fixed canonical loopback Run route and drains idempotently', async (t) => {
|
||||
const port = await reservePort();
|
||||
const observed = [];
|
||||
const surface = await startLocalApiHttpSurface({
|
||||
profile: 'edge',
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
admission: preparedAdmission(async (value, body) => {
|
||||
observed.push(value);
|
||||
if (
|
||||
value.operation.operationId === 'run.cancel' ||
|
||||
value.operation.operationId === 'task.start'
|
||||
) {
|
||||
return { statusCode: 202, body: { accepted: body } };
|
||||
}
|
||||
if (value.operation.operationId === 'run.get') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { run: { id: value.operation.runId } },
|
||||
};
|
||||
}
|
||||
if (value.operation.operationId === 'run.events.list') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
events: [],
|
||||
hasMore: false,
|
||||
nextAfterSequence: value.operation.input.afterSequence ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (value.operation.operationId === 'run.steps.list') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
steps: [],
|
||||
hasMore: false,
|
||||
next: value.operation.input.after ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (value.operation.operationId === 'task.list') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
tasks: [],
|
||||
hasMore: false,
|
||||
input: value.operation.input,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (value.operation.operationId === 'task.get') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { task: { taskId: value.operation.taskId } },
|
||||
};
|
||||
}
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { runs: [], hasMore: false, input: value.operation.input },
|
||||
};
|
||||
}),
|
||||
randomUuid: () => '019f70c0-0000-4000-8000-000000000003',
|
||||
});
|
||||
t.after(() => surface.stopAndDrain());
|
||||
|
||||
const accepted = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/runs/run_123',
|
||||
);
|
||||
assert.equal(accepted.statusCode, 200);
|
||||
assert.deepEqual(accepted.body, { run: { id: 'run_123' } });
|
||||
assert.equal(accepted.headers['cache-control'], 'no-store');
|
||||
assert.equal(observed.length, 1);
|
||||
assert.equal(observed[0].authorization, 'Bearer opaque');
|
||||
assert.deepEqual(observed[0].operation, {
|
||||
operationId: 'run.get',
|
||||
projectId: 'prj_default',
|
||||
runId: 'run_123',
|
||||
});
|
||||
|
||||
const listed = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/runs?limit=8&after_created_at_ms=100&after_run_id=run_100',
|
||||
);
|
||||
assert.equal(listed.statusCode, 200);
|
||||
assert.deepEqual(listed.body, {
|
||||
runs: [],
|
||||
hasMore: false,
|
||||
input: {
|
||||
limit: 8,
|
||||
after: { createdAtMs: 100, runId: 'run_100' },
|
||||
},
|
||||
});
|
||||
assert.equal(observed[1].operation.operationId, 'run.list');
|
||||
|
||||
const events = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/runs/run_123/events?after_sequence=7&limit=8',
|
||||
);
|
||||
assert.deepEqual(events.body, {
|
||||
events: [],
|
||||
hasMore: false,
|
||||
nextAfterSequence: 7,
|
||||
});
|
||||
assert.deepEqual(observed[2].operation, {
|
||||
operationId: 'run.events.list',
|
||||
projectId: 'prj_default',
|
||||
runId: 'run_123',
|
||||
input: { afterSequence: 7, limit: 8 },
|
||||
});
|
||||
|
||||
const steps = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/runs/run_123/steps?after_step_key=build&after_step_run_id=step_1&limit=8',
|
||||
);
|
||||
assert.deepEqual(steps.body, {
|
||||
steps: [],
|
||||
hasMore: false,
|
||||
next: { stepKey: 'build', stepRunId: 'step_1' },
|
||||
});
|
||||
assert.deepEqual(observed[3].operation, {
|
||||
operationId: 'run.steps.list',
|
||||
projectId: 'prj_default',
|
||||
runId: 'run_123',
|
||||
input: {
|
||||
after: { stepKey: 'build', stepRunId: 'step_1' },
|
||||
limit: 8,
|
||||
},
|
||||
});
|
||||
|
||||
const tasks = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/tasks?after_task_id=task_100&limit=8',
|
||||
);
|
||||
assert.deepEqual(tasks.body, {
|
||||
tasks: [],
|
||||
hasMore: false,
|
||||
input: { after: { taskId: 'task_100' }, limit: 8 },
|
||||
});
|
||||
assert.deepEqual(observed[4].operation, {
|
||||
operationId: 'task.list',
|
||||
projectId: 'prj_default',
|
||||
input: { after: { taskId: 'task_100' }, limit: 8 },
|
||||
});
|
||||
|
||||
const task = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/tasks/task_1',
|
||||
);
|
||||
assert.deepEqual(task.body, { task: { taskId: 'task_1' } });
|
||||
assert.deepEqual(observed[5].operation, {
|
||||
operationId: 'task.get',
|
||||
projectId: 'prj_default',
|
||||
taskId: 'task_1',
|
||||
});
|
||||
|
||||
const cancellationBody = JSON.stringify({
|
||||
schema: 'qinglong/run-cancellation@v1',
|
||||
mutationId: 'mutation-1',
|
||||
});
|
||||
const cancellation = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/runs/run_123/cancellation',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: 'Bearer opaque',
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(Buffer.byteLength(cancellationBody)),
|
||||
},
|
||||
body: cancellationBody,
|
||||
},
|
||||
);
|
||||
assert.equal(cancellation.statusCode, 202);
|
||||
assert.deepEqual(cancellation.body.accepted, JSON.parse(cancellationBody));
|
||||
assert.deepEqual(observed[6].operation, {
|
||||
operationId: 'run.cancel',
|
||||
projectId: 'prj_default',
|
||||
runId: 'run_123',
|
||||
});
|
||||
|
||||
const taskStartBody = JSON.stringify({
|
||||
schema: 'qinglong/task-start@v1',
|
||||
mutationId: '019f7300-0000-7000-8000-000000000800',
|
||||
expectedRevision: 7,
|
||||
expectedContentDigest: 'a'.repeat(64),
|
||||
});
|
||||
const taskStart = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/tasks/task_1/runs',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: 'Bearer opaque',
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(Buffer.byteLength(taskStartBody)),
|
||||
},
|
||||
body: taskStartBody,
|
||||
},
|
||||
);
|
||||
assert.equal(taskStart.statusCode, 202);
|
||||
assert.deepEqual(taskStart.body.accepted, JSON.parse(taskStartBody));
|
||||
assert.deepEqual(observed[7].operation, {
|
||||
operationId: 'task.start',
|
||||
projectId: 'prj_default',
|
||||
taskId: 'task_1',
|
||||
});
|
||||
|
||||
for (const invalidPath of [
|
||||
'/api/v3/projects/prj_default/runs/run_123?expanded=true',
|
||||
'/api/v3/projects/prj_default/runs/run%5f123',
|
||||
'/api/v3/projects/prj_default/tasks/task_1?expanded=true',
|
||||
'/api/v3/projects/prj_default/tasks/task%5f1',
|
||||
'/api/v3/projects/prj_default/tasks/task_1/runs?expanded=true',
|
||||
'/api/v3/projects/prj_default/tasks?after_task_id=%74ask_1',
|
||||
]) {
|
||||
assert.deepEqual((await request(port, invalidPath)).body, {
|
||||
code: 'route_not_found',
|
||||
});
|
||||
}
|
||||
for (const invalidQuery of [
|
||||
'/api/v3/projects/prj_default/runs?',
|
||||
'/api/v3/projects/prj_default/runs?limit=08',
|
||||
'/api/v3/projects/prj_default/runs?limit=65',
|
||||
'/api/v3/projects/prj_default/runs?limit=8&limit=9',
|
||||
'/api/v3/projects/prj_default/runs?after_run_id=run_100',
|
||||
'/api/v3/projects/prj_default/runs?unknown=value',
|
||||
]) {
|
||||
const invalid = await request(port, invalidQuery);
|
||||
assert.equal(invalid.statusCode, 400);
|
||||
assert.deepEqual(invalid.body, { code: 'invalid_run_list_query' });
|
||||
}
|
||||
for (const invalidQuery of [
|
||||
'/api/v3/projects/prj_default/tasks?',
|
||||
'/api/v3/projects/prj_default/tasks?limit=08',
|
||||
'/api/v3/projects/prj_default/tasks?limit=65',
|
||||
'/api/v3/projects/prj_default/tasks?unknown=value',
|
||||
]) {
|
||||
const invalid = await request(port, invalidQuery);
|
||||
assert.equal(invalid.statusCode, 400);
|
||||
assert.deepEqual(invalid.body, { code: 'invalid_task_list_query' });
|
||||
}
|
||||
for (const invalidQuery of [
|
||||
'/api/v3/projects/prj_default/runs/run_123/events?',
|
||||
'/api/v3/projects/prj_default/runs/run_123/events?after_sequence=07',
|
||||
'/api/v3/projects/prj_default/runs/run_123/events?after_sequence=-1',
|
||||
'/api/v3/projects/prj_default/runs/run_123/events?limit=65',
|
||||
'/api/v3/projects/prj_default/runs/run_123/events?unknown=value',
|
||||
]) {
|
||||
const invalid = await request(port, invalidQuery);
|
||||
assert.equal(invalid.statusCode, 400);
|
||||
assert.deepEqual(invalid.body, { code: 'invalid_run_event_list_query' });
|
||||
}
|
||||
for (const invalidQuery of [
|
||||
'/api/v3/projects/prj_default/runs/run_123/steps?',
|
||||
'/api/v3/projects/prj_default/runs/run_123/steps?after_step_key=build',
|
||||
'/api/v3/projects/prj_default/runs/run_123/steps?after_step_run_id=step_1',
|
||||
'/api/v3/projects/prj_default/runs/run_123/steps?after_step_key=-bad&after_step_run_id=step_1',
|
||||
'/api/v3/projects/prj_default/runs/run_123/steps?limit=65',
|
||||
'/api/v3/projects/prj_default/runs/run_123/steps?unknown=value',
|
||||
]) {
|
||||
const invalid = await request(port, invalidQuery);
|
||||
assert.equal(invalid.statusCode, 400);
|
||||
assert.deepEqual(invalid.body, { code: 'invalid_run_step_list_query' });
|
||||
}
|
||||
assert.equal(observed.length, 8);
|
||||
assert.deepEqual(
|
||||
await Promise.all([surface.stopAndDrain(), surface.stopAndDrain()]),
|
||||
['stopped', 'stopped'],
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects GET bodies without invoking the prepared route handler', async (t) => {
|
||||
const port = await reservePort();
|
||||
let handlers = 0;
|
||||
const surface = await startLocalApiHttpSurface({
|
||||
profile: 'standalone',
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
admission: preparedAdmission(async () => {
|
||||
handlers += 1;
|
||||
return { statusCode: 200, body: {} };
|
||||
}),
|
||||
});
|
||||
t.after(() => surface.stopAndDrain());
|
||||
const response = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/runs/run_123',
|
||||
{
|
||||
headers: {
|
||||
authorization: 'Bearer opaque',
|
||||
'content-length': '1',
|
||||
},
|
||||
body: 'x',
|
||||
},
|
||||
);
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.deepEqual(response.body, { code: 'invalid_request_body' });
|
||||
assert.equal(handlers, 0);
|
||||
});
|
||||
|
||||
test('authenticates before reading a strictly bounded cancellation JSON body', async (t) => {
|
||||
const port = await reservePort();
|
||||
const events = [];
|
||||
const surface = await startLocalApiHttpSurface({
|
||||
profile: 'edge',
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
admission: {
|
||||
async prepare(value) {
|
||||
events.push(`prepare:${value.operation.operationId}`);
|
||||
return {
|
||||
bodyMode: 'json',
|
||||
maximumBodyBytes: 512,
|
||||
handle(body) {
|
||||
events.push(`handle:${body.mutationId}`);
|
||||
return { statusCode: 202, body };
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
t.after(() => surface.stopAndDrain());
|
||||
const body = JSON.stringify({
|
||||
schema: 'qinglong/run-cancellation@v1',
|
||||
mutationId: 'mutation-1',
|
||||
});
|
||||
const accepted = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/runs/run_123/cancellation',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: 'Bearer opaque',
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(Buffer.byteLength(body)),
|
||||
},
|
||||
body,
|
||||
},
|
||||
);
|
||||
assert.equal(accepted.statusCode, 202);
|
||||
assert.deepEqual(events, ['prepare:run.cancel', 'handle:mutation-1']);
|
||||
|
||||
for (const [headers, payload, statusCode, code] of [
|
||||
[
|
||||
{
|
||||
authorization: 'Bearer opaque',
|
||||
'content-type': 'text/plain',
|
||||
'content-length': String(Buffer.byteLength(body)),
|
||||
},
|
||||
body,
|
||||
400,
|
||||
'invalid_request_body',
|
||||
],
|
||||
[
|
||||
{
|
||||
authorization: 'Bearer opaque',
|
||||
'content-type': 'application/json',
|
||||
'content-length': '513',
|
||||
},
|
||||
'x'.repeat(513),
|
||||
413,
|
||||
'request_body_too_large',
|
||||
],
|
||||
[
|
||||
{
|
||||
authorization: 'Bearer opaque',
|
||||
'content-type': 'application/json',
|
||||
'content-length': '1',
|
||||
},
|
||||
'{',
|
||||
400,
|
||||
'invalid_request_body',
|
||||
],
|
||||
]) {
|
||||
const rejected = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/runs/run_123/cancellation',
|
||||
{ method: 'POST', headers, body: payload },
|
||||
);
|
||||
assert.equal(rejected.statusCode, statusCode);
|
||||
assert.deepEqual(rejected.body, { code });
|
||||
}
|
||||
assert.equal(events.filter((event) => event.startsWith('handle:')).length, 1);
|
||||
});
|
||||
|
||||
test('serves the reviewed worst-case 64-item Run list inside the fixed response cap', async (t) => {
|
||||
const port = await reservePort();
|
||||
const text255 = 'x'.repeat(255);
|
||||
const id128 = 'x'.repeat(128);
|
||||
const item = Object.freeze({
|
||||
id: id128,
|
||||
taskId: text255,
|
||||
taskRevision: text255,
|
||||
status: 'succeeded',
|
||||
version: 2_147_483_647,
|
||||
eventSequence: 2_147_483_647,
|
||||
priority: -2_147_483_648,
|
||||
executionOrigin: 'scheduled_system',
|
||||
executionOwner: 'runtime',
|
||||
createdAtMs: Number.MAX_SAFE_INTEGER,
|
||||
queuedAtMs: Number.MAX_SAFE_INTEGER,
|
||||
startedAtMs: Number.MAX_SAFE_INTEGER,
|
||||
finishedAtMs: Number.MAX_SAFE_INTEGER,
|
||||
});
|
||||
const surface = await startLocalApiHttpSurface({
|
||||
profile: 'edge',
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
admission: preparedAdmission(async () => {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
runs: Object.freeze(Array.from({ length: 64 }, () => item)),
|
||||
hasMore: true,
|
||||
next: { createdAtMs: Number.MAX_SAFE_INTEGER, runId: id128 },
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
t.after(() => surface.stopAndDrain());
|
||||
const response = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/runs?limit=64',
|
||||
);
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.body.runs.length, 64);
|
||||
assert.equal(Number(response.headers['content-length']), 61_516);
|
||||
});
|
||||
|
||||
test('serves the reviewed worst-case 64-item Task list inside the fixed response cap', async (t) => {
|
||||
const port = await reservePort();
|
||||
const id128 = 'x'.repeat(128);
|
||||
const item = Object.freeze({
|
||||
taskId: id128,
|
||||
revision: 2_147_483_647,
|
||||
name: 'x'.repeat(255),
|
||||
kind: 'workflow',
|
||||
specSchema: `${'x'.repeat(64)}/${'x'.repeat(64)}@v999999`,
|
||||
enabled: false,
|
||||
updatedAtMs: Number.MAX_SAFE_INTEGER,
|
||||
});
|
||||
const surface = await startLocalApiHttpSurface({
|
||||
profile: 'edge',
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
admission: preparedAdmission(async () => ({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
tasks: Object.freeze(Array.from({ length: 64 }, () => item)),
|
||||
hasMore: true,
|
||||
next: { taskId: id128 },
|
||||
},
|
||||
})),
|
||||
});
|
||||
t.after(() => surface.stopAndDrain());
|
||||
const response = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/tasks?limit=64',
|
||||
);
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.body.tasks.length, 64);
|
||||
assert.ok(Number(response.headers['content-length']) < 64 * 1_024);
|
||||
});
|
||||
|
||||
test('serves the reviewed worst-case 64-item RunEvent list inside the fixed response cap', async (t) => {
|
||||
const port = await reservePort();
|
||||
const event = Object.freeze({
|
||||
sequence: 2_147_483_647,
|
||||
type: 'x'.repeat(128),
|
||||
actorType: 'system',
|
||||
createdAtMs: Number.MAX_SAFE_INTEGER,
|
||||
});
|
||||
const surface = await startLocalApiHttpSurface({
|
||||
profile: 'edge',
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
admission: preparedAdmission(async () => {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
events: Object.freeze(Array.from({ length: 64 }, () => event)),
|
||||
hasMore: true,
|
||||
nextAfterSequence: event.sequence,
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
t.after(() => surface.stopAndDrain());
|
||||
const response = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/runs/run_123/events?limit=64',
|
||||
);
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.body.events.length, 64);
|
||||
assert.equal(Number(response.headers['content-length']), 13_754);
|
||||
});
|
||||
|
||||
test('serves the reviewed worst-case 64-item Run Step list inside the fixed response cap', async (t) => {
|
||||
const port = await reservePort();
|
||||
const id128 = 'x'.repeat(128);
|
||||
const item = Object.freeze({
|
||||
id: id128,
|
||||
parentStepRunId: id128,
|
||||
stepKey: id128,
|
||||
kind: 'tool',
|
||||
required: true,
|
||||
status: 'waiting_approval',
|
||||
version: 2_147_483_647,
|
||||
attemptCount: 64,
|
||||
readyAtMs: Number.MAX_SAFE_INTEGER,
|
||||
startedAtMs: Number.MAX_SAFE_INTEGER,
|
||||
finishedAtMs: Number.MAX_SAFE_INTEGER,
|
||||
resultCode: 'x'.repeat(64),
|
||||
createdAtMs: Number.MAX_SAFE_INTEGER,
|
||||
updatedAtMs: Number.MAX_SAFE_INTEGER,
|
||||
});
|
||||
const surface = await startLocalApiHttpSurface({
|
||||
profile: 'edge',
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
admission: preparedAdmission(async () => {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
steps: Object.freeze(Array.from({ length: 64 }, () => item)),
|
||||
hasMore: true,
|
||||
next: { stepKey: id128, stepRunId: id128 },
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
t.after(() => surface.stopAndDrain());
|
||||
const response = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/runs/run_123/steps?limit=64',
|
||||
);
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.body.steps.length, 64);
|
||||
assert.ok(Number(response.headers['content-length']) < 65_536);
|
||||
});
|
||||
|
||||
test('bounds Edge admission concurrency and drains accepted work', async (t) => {
|
||||
const port = await reservePort();
|
||||
let admissions = 0;
|
||||
let release;
|
||||
const barrier = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const surface = await startLocalApiHttpSurface({
|
||||
profile: 'edge',
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
admission: preparedAdmission(async (value) => {
|
||||
admissions += 1;
|
||||
await barrier;
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { run: { id: value.operation.runId } },
|
||||
};
|
||||
}),
|
||||
});
|
||||
t.after(() => surface.stopAndDrain());
|
||||
const accepted = Array.from({ length: 4 }, () =>
|
||||
request(port, '/api/v3/projects/prj_default/runs/run_123'),
|
||||
);
|
||||
while (admissions < 4) await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const overloaded = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/runs/run_123',
|
||||
);
|
||||
assert.equal(overloaded.statusCode, 503);
|
||||
assert.deepEqual(overloaded.body, { code: 'server_overloaded' });
|
||||
assert.equal(admissions, 4);
|
||||
|
||||
const stopping = surface.stopAndDrain();
|
||||
release();
|
||||
assert.deepEqual(
|
||||
(await Promise.all(accepted)).map(({ statusCode }) => statusCode),
|
||||
[200, 200, 200, 200],
|
||||
);
|
||||
assert.equal(await stopping, 'stopped');
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LOCAL_API_PROCESS_CONFIG_SCHEMA,
|
||||
} = require('../dist/production-process/config.js');
|
||||
const {
|
||||
runProductionLocalApiProcess,
|
||||
} = require('../dist/production-process/processApplication.js');
|
||||
|
||||
test('injects the HTTP surface into exactly one Local Application process', async () => {
|
||||
const events = [];
|
||||
const signals = Object.freeze({ subscribe() { return () => {}; } });
|
||||
const emit = (event) => events.push(event);
|
||||
let applicationCalls = 0;
|
||||
const result = await runProductionLocalApiProcess(
|
||||
{
|
||||
configFilePath: '/srv/qinglong/private/api.json',
|
||||
signals,
|
||||
emit,
|
||||
},
|
||||
{
|
||||
readConfig(configFilePath) {
|
||||
assert.equal(configFilePath, '/srv/qinglong/private/api.json');
|
||||
return Object.freeze({
|
||||
schema: LOCAL_API_PROCESS_CONFIG_SCHEMA,
|
||||
deploymentRoot: '/srv/qinglong',
|
||||
applicationConfigFilePath:
|
||||
'/srv/qinglong/private/application.json',
|
||||
ownerPepperKeyringDirectory:
|
||||
'/srv/qinglong/private/owner-pepper',
|
||||
listener: Object.freeze({ host: '127.0.0.1', port: 5701 }),
|
||||
});
|
||||
},
|
||||
async runApplication(options) {
|
||||
applicationCalls += 1;
|
||||
assert.equal(
|
||||
options.configFilePath,
|
||||
'/srv/qinglong/private/application.json',
|
||||
);
|
||||
assert.equal(options.signals, signals);
|
||||
assert.equal(options.emit, emit);
|
||||
assert.equal(typeof options.productSurface.start, 'function');
|
||||
return 'stopped';
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(result, 'stopped');
|
||||
assert.equal(applicationCalls, 1);
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
RUN_CANCELLATION_SCHEMA,
|
||||
RunCancellationFenceRejectedError,
|
||||
RunCancellationNotFoundError,
|
||||
RunCancellationUnavailableError,
|
||||
} = require('@qinglong/runtime-core/run-cancellation');
|
||||
const {
|
||||
createLocalApiRunCancellationRoute,
|
||||
} = require('../dist/run/runCancellationRoute.js');
|
||||
|
||||
const PRINCIPAL = Object.freeze({
|
||||
subject: Object.freeze({ type: 'user', id: 'user-1' }),
|
||||
authenticationId: 'credential:user-1',
|
||||
authenticatedAtMs: 9_000,
|
||||
expiresAtMs: 11_000,
|
||||
assurance: 'single_factor',
|
||||
});
|
||||
const FENCE = Object.freeze({ projectVersion: 2, bindingVersion: 3 });
|
||||
|
||||
function accepted(overrides = {}) {
|
||||
return {
|
||||
status: 'accepted',
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
runStatus: 'running',
|
||||
runVersion: 5,
|
||||
eventSequence: 7,
|
||||
cancelRequestedAtMs: 10_000,
|
||||
cancelReason: 'user',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function request(overrides = {}) {
|
||||
return {
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
body: { schema: RUN_CANCELLATION_SCHEMA, mutationId: 'mutation-1' },
|
||||
principal: PRINCIPAL,
|
||||
policyFence: FENCE,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('publishes one profile-neutral durable cancellation command', async () => {
|
||||
let observed;
|
||||
const route = createLocalApiRunCancellationRoute(
|
||||
{
|
||||
async requestUserCancellation(command) {
|
||||
observed = command;
|
||||
return accepted();
|
||||
},
|
||||
},
|
||||
() => '018f0000-0000-7000-8000-000000000001',
|
||||
);
|
||||
assert.deepEqual(await route.handle(request()), {
|
||||
statusCode: 202,
|
||||
body: { schema: RUN_CANCELLATION_SCHEMA, ...accepted() },
|
||||
});
|
||||
assert.deepEqual(observed, {
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
mutationId: 'mutation-1',
|
||||
eventId: '018f0000-0000-7000-8000-000000000001',
|
||||
subject: PRINCIPAL.subject,
|
||||
policyFence: FENCE,
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects malformed bodies and incomplete authorization fences', async () => {
|
||||
let calls = 0;
|
||||
const route = createLocalApiRunCancellationRoute(
|
||||
{
|
||||
async requestUserCancellation() {
|
||||
calls += 1;
|
||||
return accepted();
|
||||
},
|
||||
},
|
||||
() => '018f0000-0000-7000-8000-000000000001',
|
||||
);
|
||||
assert.deepEqual(await route.handle(request({ body: { mutationId: 'x' } })), {
|
||||
statusCode: 400,
|
||||
body: {
|
||||
code: 'invalid_run_cancellation_request',
|
||||
schema: RUN_CANCELLATION_SCHEMA,
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await route.handle(request({ policyFence: null })), {
|
||||
statusCode: 503,
|
||||
body: { code: 'run_cancellation_unavailable' },
|
||||
});
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('maps replay, terminal, missing, fence and unavailable outcomes', async () => {
|
||||
for (const outcome of [
|
||||
accepted({ status: 'already_requested' }),
|
||||
{
|
||||
status: 'already_terminal',
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
runStatus: 'succeeded',
|
||||
runVersion: 6,
|
||||
eventSequence: 8,
|
||||
},
|
||||
]) {
|
||||
const route = createLocalApiRunCancellationRoute(
|
||||
{ async requestUserCancellation() { return outcome; } },
|
||||
() => '018f0000-0000-7000-8000-000000000001',
|
||||
);
|
||||
assert.equal((await route.handle(request())).statusCode, 200);
|
||||
}
|
||||
for (const [error, statusCode, code] of [
|
||||
[new RunCancellationNotFoundError(), 404, 'run_not_found'],
|
||||
[
|
||||
new RunCancellationFenceRejectedError('authorization_changed'),
|
||||
409,
|
||||
'run_cancellation_fence_rejected',
|
||||
],
|
||||
[
|
||||
new RunCancellationUnavailableError(),
|
||||
503,
|
||||
'run_cancellation_unavailable',
|
||||
],
|
||||
]) {
|
||||
const route = createLocalApiRunCancellationRoute(
|
||||
{ async requestUserCancellation() { throw error; } },
|
||||
() => '018f0000-0000-7000-8000-000000000001',
|
||||
);
|
||||
const response = await route.handle(request());
|
||||
assert.equal(response.statusCode, statusCode);
|
||||
assert.equal(response.body.code, code);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createLocalApiRunEventListRoute,
|
||||
} = require('../dist/run/runEventListRoute.js');
|
||||
|
||||
function run(projectId = 'prj_default') {
|
||||
return { id: 'run-1', projectId };
|
||||
}
|
||||
|
||||
function event(sequence, overrides = {}) {
|
||||
return {
|
||||
id: `event-${sequence}`,
|
||||
runId: 'run-1',
|
||||
sequence,
|
||||
type: `run.event.${sequence}`,
|
||||
actorType: 'system',
|
||||
actorId: 'private-actor',
|
||||
dedupeKey: 'private-dedupe',
|
||||
payload: { secret: 'must-not-cross-projection' },
|
||||
createdAtMs: 1_000 + sequence,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('returns the shared bounded Run event projection', async () => {
|
||||
const calls = [];
|
||||
const route = createLocalApiRunEventListRoute({
|
||||
async findRunById(runId) {
|
||||
calls.push(['run', runId]);
|
||||
return run();
|
||||
},
|
||||
async listEvents(runId, input) {
|
||||
calls.push(['events', runId, input]);
|
||||
return [event(3), event(4)];
|
||||
},
|
||||
});
|
||||
const response = await route.handle({
|
||||
projectId: 'prj_default',
|
||||
runId: 'run-1',
|
||||
input: { afterSequence: 2, limit: 1 },
|
||||
});
|
||||
assert.deepEqual(calls, [
|
||||
['run', 'run-1'],
|
||||
['events', 'run-1', { afterSequence: 2, limit: 2 }],
|
||||
]);
|
||||
assert.deepEqual(response, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
events: [
|
||||
{
|
||||
sequence: 3,
|
||||
type: 'run.event.3',
|
||||
actorType: 'system',
|
||||
createdAtMs: 1_003,
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
nextAfterSequence: 3,
|
||||
},
|
||||
});
|
||||
assert.equal(JSON.stringify(response).includes('private'), false);
|
||||
assert.equal(JSON.stringify(response).includes('secret'), false);
|
||||
});
|
||||
|
||||
test('masks absent and cross-Project Runs and fails closed on corrupt storage', async () => {
|
||||
for (const value of [null, run('prj_other')]) {
|
||||
const route = createLocalApiRunEventListRoute({
|
||||
async findRunById() {
|
||||
return value;
|
||||
},
|
||||
async listEvents() {
|
||||
throw new Error('must not read');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
await route.handle({
|
||||
projectId: 'prj_default',
|
||||
runId: 'run-1',
|
||||
input: {},
|
||||
}),
|
||||
{ statusCode: 404, body: { code: 'run_not_found' } },
|
||||
);
|
||||
}
|
||||
const route = createLocalApiRunEventListRoute({
|
||||
async findRunById() {
|
||||
return run();
|
||||
},
|
||||
async listEvents() {
|
||||
return [event(2), event(1)];
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
await route.handle({ projectId: 'prj_default', runId: 'run-1', input: {} }),
|
||||
{ statusCode: 503, body: { code: 'run_event_list_unavailable' } },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createLocalApiRunListRoute,
|
||||
} = require('../dist/run/runListRoute.js');
|
||||
|
||||
function run(id, createdAtMs, overrides = {}) {
|
||||
return {
|
||||
id,
|
||||
projectId: 'prj_default',
|
||||
taskId: `task-${id}`,
|
||||
taskRevision: 'revision-1',
|
||||
triggerType: 'manual',
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
status: 'running',
|
||||
version: 0,
|
||||
eventSequence: 1,
|
||||
priority: 0,
|
||||
createdAtMs,
|
||||
privateValue: 'secret-adjacent',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('returns the shared bounded Project Run list projection', async () => {
|
||||
const calls = [];
|
||||
const route = createLocalApiRunListRoute({
|
||||
async listRunsByProject(query) {
|
||||
calls.push(query);
|
||||
return [run('run-b', 20), run('run-a', 10)];
|
||||
},
|
||||
});
|
||||
const response = await route.handle({
|
||||
projectId: 'prj_default',
|
||||
input: { limit: 1 },
|
||||
});
|
||||
assert.deepEqual(calls, [{ projectId: 'prj_default', limit: 2 }]);
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.body.runs[0].id, 'run-b');
|
||||
assert.equal(response.body.hasMore, true);
|
||||
assert.deepEqual(response.body.next, { createdAtMs: 20, runId: 'run-b' });
|
||||
assert.equal(JSON.stringify(response).includes('secret-adjacent'), false);
|
||||
});
|
||||
|
||||
test('fails closed on cross-Project, malformed and unavailable pages', async () => {
|
||||
for (const rows of [
|
||||
[run('run-a', 10, { projectId: 'prj_other' })],
|
||||
[run('run-a', 10, { status: 'invented' })],
|
||||
]) {
|
||||
const route = createLocalApiRunListRoute({
|
||||
async listRunsByProject() { return rows; },
|
||||
});
|
||||
assert.deepEqual(
|
||||
await route.handle({ projectId: 'prj_default', input: {} }),
|
||||
{ statusCode: 503, body: { code: 'run_list_unavailable' } },
|
||||
);
|
||||
}
|
||||
const unavailable = createLocalApiRunListRoute({
|
||||
async listRunsByProject() { throw new Error('offline'); },
|
||||
});
|
||||
assert.deepEqual(
|
||||
await unavailable.handle({ projectId: 'prj_default', input: {} }),
|
||||
{ statusCode: 503, body: { code: 'run_list_unavailable' } },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createLocalApiRunReadRoute,
|
||||
} = require('../dist/run/runReadRoute.js');
|
||||
|
||||
function run(overrides = {}) {
|
||||
return {
|
||||
id: 'run_123',
|
||||
projectId: 'prj_default',
|
||||
taskId: 'task_1',
|
||||
taskRevision: 'revision_7',
|
||||
taskName: 'must not cross the wire',
|
||||
taskSnapshotRef: 'secret-adjacent-ref',
|
||||
triggerType: 'manual',
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
triggeredBy: 'private-user-id',
|
||||
requestId: 'private-request-id',
|
||||
status: 'running',
|
||||
version: 4,
|
||||
eventSequence: 6,
|
||||
priority: 10,
|
||||
inputRef: 'private-input-ref',
|
||||
outputRef: 'private-output-ref',
|
||||
createdAtMs: 1_000,
|
||||
queuedAtMs: 2_000,
|
||||
startedAtMs: 3_000,
|
||||
errorCode: 'private-error-code',
|
||||
errorSummary: 'private error detail',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('returns the shared bounded Run projection without secret-adjacent fields', async () => {
|
||||
const route = createLocalApiRunReadRoute({
|
||||
async findRunById(runId) {
|
||||
assert.equal(runId, 'run_123');
|
||||
return run({ version: 0 });
|
||||
},
|
||||
});
|
||||
|
||||
const response = await route.handle({
|
||||
projectId: 'prj_default',
|
||||
runId: 'run_123',
|
||||
});
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.body.run.projectId, 'prj_default');
|
||||
assert.equal(response.body.run.version, 0);
|
||||
assert.equal(JSON.stringify(response).includes('private'), false);
|
||||
});
|
||||
|
||||
test('collapses absent and cross-project Runs and fails closed on repository errors', async () => {
|
||||
for (const value of [null, run({ projectId: 'another_project' })]) {
|
||||
const route = createLocalApiRunReadRoute({
|
||||
async findRunById() {
|
||||
return value;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
await route.handle({ projectId: 'prj_default', runId: 'run_123' }),
|
||||
{ statusCode: 404, body: { code: 'run_not_found' } },
|
||||
);
|
||||
}
|
||||
const unavailable = createLocalApiRunReadRoute({
|
||||
async findRunById() {
|
||||
throw new Error('database unavailable');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
await unavailable.handle({ projectId: 'prj_default', runId: 'run_123' }),
|
||||
{ statusCode: 503, body: { code: 'run_query_unavailable' } },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createLocalApiRunStepListRoute,
|
||||
} = require('../dist/run/runStepListRoute.js');
|
||||
const {
|
||||
createStepRunRecord,
|
||||
} = require('../../ql3-runtime-core/dist/run/stepRun.js');
|
||||
|
||||
function run(projectId = 'prj_default') {
|
||||
return { id: 'run-1', projectId };
|
||||
}
|
||||
|
||||
function step(id, stepKey) {
|
||||
return createStepRunRecord({
|
||||
id,
|
||||
runId: 'run-1',
|
||||
parentStepRunId: 'step-parent',
|
||||
stepKey,
|
||||
kind: 'tool',
|
||||
definitionRef: 'tool:private.internal@1.0.0',
|
||||
definitionDigest: 'a'.repeat(64),
|
||||
required: true,
|
||||
initialStatus: 'ready',
|
||||
inputRef: 'artifact:private-input',
|
||||
mutationId: `create-${id}`,
|
||||
createdAtMs: 1_000,
|
||||
});
|
||||
}
|
||||
|
||||
test('returns the shared bounded low-sensitive Run Step projection', async () => {
|
||||
const calls = [];
|
||||
const first = step('step-1', 'build');
|
||||
const second = step('step-2', 'deploy');
|
||||
const route = createLocalApiRunStepListRoute(
|
||||
{
|
||||
async findRunById(runId) {
|
||||
calls.push(['run', runId]);
|
||||
return run();
|
||||
},
|
||||
},
|
||||
{
|
||||
async listByRun(query) {
|
||||
calls.push(['steps', query]);
|
||||
return {
|
||||
stepRuns: [first, second],
|
||||
truncated: true,
|
||||
next: { stepKey: second.stepKey, id: second.id },
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
const response = await route.handle({
|
||||
projectId: 'prj_default',
|
||||
runId: 'run-1',
|
||||
input: {
|
||||
after: { stepKey: 'admit', stepRunId: 'step-0' },
|
||||
limit: 2,
|
||||
},
|
||||
});
|
||||
assert.deepEqual(calls, [
|
||||
['run', 'run-1'],
|
||||
[
|
||||
'steps',
|
||||
{
|
||||
runId: 'run-1',
|
||||
limit: 2,
|
||||
after: { stepKey: 'admit', id: 'step-0' },
|
||||
},
|
||||
],
|
||||
]);
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.body.steps.length, 2);
|
||||
assert.deepEqual(response.body.next, {
|
||||
stepKey: 'deploy',
|
||||
stepRunId: 'step-2',
|
||||
});
|
||||
assert.equal(response.body.hasMore, true);
|
||||
const serialized = JSON.stringify(response);
|
||||
assert.equal(serialized.includes('private.internal'), false);
|
||||
assert.equal(serialized.includes('private-input'), false);
|
||||
assert.equal(serialized.includes('stepRunDigest'), false);
|
||||
});
|
||||
|
||||
test('masks absent and cross-Project Runs and fails closed on corrupt storage', async () => {
|
||||
for (const value of [null, run('prj_other')]) {
|
||||
const route = createLocalApiRunStepListRoute(
|
||||
{
|
||||
async findRunById() {
|
||||
return value;
|
||||
},
|
||||
},
|
||||
{
|
||||
async listByRun() {
|
||||
throw new Error('must not read');
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
await route.handle({
|
||||
projectId: 'prj_default',
|
||||
runId: 'run-1',
|
||||
input: {},
|
||||
}),
|
||||
{ statusCode: 404, body: { code: 'run_not_found' } },
|
||||
);
|
||||
}
|
||||
const route = createLocalApiRunStepListRoute(
|
||||
{
|
||||
async findRunById() {
|
||||
return run();
|
||||
},
|
||||
},
|
||||
{
|
||||
async listByRun() {
|
||||
return {
|
||||
stepRuns: [step('step-2', 'deploy'), step('step-1', 'build')],
|
||||
truncated: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
await route.handle({ projectId: 'prj_default', runId: 'run-1', input: {} }),
|
||||
{ statusCode: 503, body: { code: 'run_step_list_unavailable' } },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,688 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const http = require('node:http');
|
||||
const fs = require('node:fs');
|
||||
const net = require('node:net');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LocalOwnerPepperKeyringFileProvider,
|
||||
provisionLocalOwnerPepperKey,
|
||||
} = require('@qinglong/local-owner-console/pepper-custody');
|
||||
const {
|
||||
apiCredentialSecretDigest,
|
||||
formatApiCredentialToken,
|
||||
} = require('@qinglong/runtime-core/api-credential-token');
|
||||
const {
|
||||
createStepRunRecord,
|
||||
} = require('../../ql3-runtime-core/dist/run/stepRun.js');
|
||||
const {
|
||||
createTaskDefinitionRecord,
|
||||
} = require('@qinglong/runtime-core/task-definition');
|
||||
const {
|
||||
compileLocalCommandTaskDefinition,
|
||||
} = require('@qinglong/runtime-core/task-definition-execution-compiler');
|
||||
const {
|
||||
createBuiltInTaskSpecSemanticRegistry,
|
||||
} = require('@qinglong/runtime-core/task-spec-semantic');
|
||||
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
openLocalSqliteRuntimeDatabase,
|
||||
} = require('@qinglong/local-sqlite/runtime');
|
||||
const {
|
||||
createLocalApiProductSurface,
|
||||
} = require('../dist/application-runtime/localApiProductSurface.js');
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
const PEPPER_KEY_ID = 'local-api-pepper-v1';
|
||||
const CREDENTIAL_ID = 'local-api-owner';
|
||||
const RUN_ID = 'run_local_api_1';
|
||||
const SECRET = Buffer.alloc(32, 81).toString('base64url');
|
||||
const PEPPER = Buffer.alloc(32, 82).toString('base64url');
|
||||
const TOKEN = formatApiCredentialToken(CREDENTIAL_ID, SECRET);
|
||||
|
||||
function reservePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve(address.port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function request(
|
||||
port,
|
||||
authorization,
|
||||
requestPath = `/api/v3/projects/default/runs/${RUN_ID}`,
|
||||
options = {},
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const outgoing = http.request(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: requestPath,
|
||||
method: options.method ?? 'GET',
|
||||
headers: {
|
||||
authorization,
|
||||
connection: 'close',
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
const chunks = [];
|
||||
response.on('data', (chunk) => chunks.push(chunk));
|
||||
response.on('end', () =>
|
||||
resolve({
|
||||
statusCode: response.statusCode,
|
||||
body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
outgoing.once('error', reject);
|
||||
if (options.body) outgoing.write(options.body);
|
||||
outgoing.end();
|
||||
});
|
||||
}
|
||||
|
||||
function seed(databasePath, materialDigest) {
|
||||
const stepRun = createStepRunRecord({
|
||||
id: 'step-local-api-1',
|
||||
runId: RUN_ID,
|
||||
stepKey: 'build',
|
||||
kind: 'tool',
|
||||
definitionRef: 'tool:private.internal@1.0.0',
|
||||
definitionDigest: 'a'.repeat(64),
|
||||
required: true,
|
||||
initialStatus: 'ready',
|
||||
inputRef: 'artifact:private-input',
|
||||
mutationId: 'create-step-local-api-1',
|
||||
createdAtMs: NOW - 75,
|
||||
});
|
||||
const client = new DatabaseSync(databasePath);
|
||||
try {
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalOwnerPepperKeys" (
|
||||
"pepper_key_id", "material_digest", "backup_digest", "state",
|
||||
"version", "register_mutation_id", "activate_mutation_id",
|
||||
"registered_at_ms", "activated_at_ms"
|
||||
) VALUES (?, ?, ?, 'active', 2, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
PEPPER_KEY_ID,
|
||||
materialDigest,
|
||||
'b'.repeat(64),
|
||||
'00000000-0000-4000-8000-000000000111',
|
||||
'00000000-0000-4000-8000-000000000112',
|
||||
NOW - 2_000,
|
||||
NOW - 1_500,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalOwnerPepperActivations" (
|
||||
"generation", "mutation_id", "expected_generation",
|
||||
"previous_pepper_key_id", "active_pepper_key_id",
|
||||
"material_digest", "backup_digest", "activated_at_ms"
|
||||
) VALUES (1, ?, 0, NULL, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
'00000000-0000-4000-8000-000000000112',
|
||||
PEPPER_KEY_ID,
|
||||
materialDigest,
|
||||
'b'.repeat(64),
|
||||
NOW - 1_500,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3IdentitySubjects" (
|
||||
"subject_type", "subject_id", "status", "version",
|
||||
"created_at_ms", "updated_at_ms"
|
||||
) VALUES ('user', 'local-api-user', 'active', 1, ?, ?)`,
|
||||
)
|
||||
.run(NOW - 1_000, NOW - 1_000);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApiCredentials" (
|
||||
"credential_id", "version", "state", "subject_type",
|
||||
"subject_id", "secret_digest", "created_at_ms",
|
||||
"not_before_at_ms", "expires_at_ms"
|
||||
) VALUES (?, 1, 'active', 'user', 'local-api-user', ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
CREDENTIAL_ID,
|
||||
apiCredentialSecretDigest(PEPPER, CREDENTIAL_ID, SECRET),
|
||||
NOW - 1_000,
|
||||
NOW - 1_000,
|
||||
NOW + 60_000,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApiCredentialPepperBindings" (
|
||||
"credential_id", "credential_version", "pepper_key_id"
|
||||
) VALUES (?, 1, ?)`,
|
||||
)
|
||||
.run(CREDENTIAL_ID, PEPPER_KEY_ID);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings" (
|
||||
"project_id", "subject_type", "subject_id", "version", "state",
|
||||
"role", "mutation_id", "changed_by_type", "changed_by_id",
|
||||
"created_at_ms"
|
||||
) VALUES (
|
||||
'default', 'user', 'local-api-user', 1, 'active', 'operator',
|
||||
'grant-local-api-operator', 'user', 'local-api-user', ?
|
||||
)`,
|
||||
)
|
||||
.run(NOW - 500);
|
||||
const taskSemantics = createBuiltInTaskSpecSemanticRegistry();
|
||||
const taskCommand = {
|
||||
projectId: 'default',
|
||||
taskId: 'task-1',
|
||||
expectedRevision: null,
|
||||
mutationId: '00000000-0000-4000-8000-000000000113',
|
||||
name: 'Local API Task',
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: {
|
||||
command: {
|
||||
kind: 'argv',
|
||||
file: '/bin/echo',
|
||||
args: ['private-command'],
|
||||
},
|
||||
},
|
||||
},
|
||||
labels: { private: 'label' },
|
||||
enabled: true,
|
||||
occurredAtMs: NOW - 200,
|
||||
};
|
||||
const taskDefinition = createTaskDefinitionRecord({
|
||||
...taskCommand,
|
||||
spec: taskSemantics.normalize({
|
||||
projectId: taskCommand.projectId,
|
||||
taskId: taskCommand.taskId,
|
||||
kind: taskCommand.kind,
|
||||
spec: taskCommand.spec,
|
||||
}),
|
||||
}, NOW - 200);
|
||||
const taskExecution = compileLocalCommandTaskDefinition(
|
||||
taskDefinition,
|
||||
taskSemantics,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3TaskDefinitions" (
|
||||
"project_id", "task_id", "current_revision",
|
||||
"created_at_ms", "updated_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
taskDefinition.projectId,
|
||||
taskDefinition.taskId,
|
||||
taskDefinition.revision,
|
||||
taskDefinition.createdAtMs,
|
||||
taskDefinition.updatedAtMs,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalExecutionContextRecipes" (
|
||||
"context_ref", "environment_json", "content_digest",
|
||||
"created_at_ms"
|
||||
) VALUES (?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
taskExecution.contextRecipe.contextRef,
|
||||
JSON.stringify(taskExecution.contextRecipe.environment),
|
||||
taskExecution.contextRecipe.contentDigest,
|
||||
taskExecution.contextRecipe.createdAtMs,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalTaskExecutionRevisions" (
|
||||
"project_id", "task_id", "task_revision", "executor_type",
|
||||
"command_json", "working_directory", "timeout_ms", "context_ref",
|
||||
"content_digest", "created_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
taskExecution.executionRevision.projectId,
|
||||
taskExecution.executionRevision.taskId,
|
||||
taskExecution.executionRevision.taskRevision,
|
||||
taskExecution.executionRevision.executorType,
|
||||
JSON.stringify(taskExecution.executionRevision.command),
|
||||
taskExecution.executionRevision.workingDirectory ?? null,
|
||||
taskExecution.executionRevision.timeoutMs ?? null,
|
||||
taskExecution.executionRevision.contextRef,
|
||||
taskExecution.executionRevision.contentDigest,
|
||||
taskExecution.executionRevision.createdAtMs,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3TaskDefinitionRevisions" (
|
||||
"project_id", "task_id", "revision", "mutation_id",
|
||||
"name", "description", "kind", "spec_json", "labels_json",
|
||||
"enabled", "content_digest", "created_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
taskDefinition.projectId,
|
||||
taskDefinition.taskId,
|
||||
taskDefinition.revision,
|
||||
taskDefinition.mutationId,
|
||||
taskDefinition.name,
|
||||
null,
|
||||
taskDefinition.kind,
|
||||
JSON.stringify(taskDefinition.spec),
|
||||
JSON.stringify(taskDefinition.labels),
|
||||
1,
|
||||
taskDefinition.contentDigest,
|
||||
taskDefinition.updatedAtMs,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "Runs" (
|
||||
id, project_id, task_id, task_revision, trigger_type,
|
||||
execution_origin, execution_owner, status, version,
|
||||
event_sequence, priority, created_at_ms
|
||||
) VALUES (?, 'default', 'task-1', 'revision-1', 'manual',
|
||||
'manual', 'runtime', 'running', 1, 1, 0, ?)`,
|
||||
)
|
||||
.run(RUN_ID, NOW - 100);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "StepRuns" (
|
||||
"id", "run_id", "parent_step_run_id", "step_key", "kind",
|
||||
"definition_ref", "definition_digest", "required", "status",
|
||||
"version", "attempt_count", "input_ref", "output_ref",
|
||||
"approval_request_id", "ready_at_ms", "started_at_ms",
|
||||
"finished_at_ms", "result_code", "error_summary", "created_at_ms",
|
||||
"updated_at_ms", "last_mutation_id", "step_run_digest",
|
||||
"step_run_json"
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?
|
||||
)`,
|
||||
)
|
||||
.run(
|
||||
stepRun.id,
|
||||
stepRun.runId,
|
||||
stepRun.parentStepRunId,
|
||||
stepRun.stepKey,
|
||||
stepRun.kind,
|
||||
stepRun.definitionRef,
|
||||
stepRun.definitionDigest,
|
||||
stepRun.required ? 1 : 0,
|
||||
stepRun.status,
|
||||
stepRun.version,
|
||||
stepRun.attemptCount,
|
||||
stepRun.inputRef,
|
||||
stepRun.outputRef,
|
||||
stepRun.approvalRequestId,
|
||||
stepRun.readyAtMs,
|
||||
stepRun.startedAtMs,
|
||||
stepRun.finishedAtMs,
|
||||
stepRun.resultCode,
|
||||
stepRun.errorSummary,
|
||||
stepRun.createdAtMs,
|
||||
stepRun.updatedAtMs,
|
||||
stepRun.lastMutationId,
|
||||
stepRun.stepRunDigest,
|
||||
JSON.stringify(stepRun),
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "RunEvents" (
|
||||
id, run_id, sequence, type, actor_type, payload, created_at_ms
|
||||
) VALUES (?, ?, 1, 'run.started', 'system', '{}', ?)`,
|
||||
)
|
||||
.run('run-local-api-event-1', RUN_ID, NOW - 50);
|
||||
return taskDefinition;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
test('serves an authenticated Run through one real SQLite authority and durable audit', async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-api-sqlite-'));
|
||||
fs.chmodSync(root, 0o700);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const databasePath = path.join(root, 'qinglong3.sqlite');
|
||||
const keyringDirectory = path.join(root, 'owner-pepper');
|
||||
fs.mkdirSync(keyringDirectory, { mode: 0o700 });
|
||||
const summary = provisionLocalOwnerPepperKey({
|
||||
keyringDirectory,
|
||||
pepperKeyId: PEPPER_KEY_ID,
|
||||
randomBytes: () => Buffer.alloc(32, 82),
|
||||
});
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
||||
const taskDefinition = seed(databasePath, summary.digest);
|
||||
const runtime = await openLocalSqliteRuntimeDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
t.after(() => runtime.close());
|
||||
const port = await reservePort();
|
||||
let uuidSequence = 0;
|
||||
const surface = createLocalApiProductSurface(
|
||||
{
|
||||
schema: 'qinglong/local-api-process@v1',
|
||||
deploymentRoot: root,
|
||||
applicationConfigFilePath: path.join(root, 'application.json'),
|
||||
ownerPepperKeyringDirectory: keyringDirectory,
|
||||
listener: { host: '127.0.0.1', port },
|
||||
},
|
||||
{
|
||||
now: () => NOW,
|
||||
randomUuid() {
|
||||
uuidSequence += 1;
|
||||
return `00000000-0000-4000-8000-${String(uuidSequence).padStart(
|
||||
12,
|
||||
'0',
|
||||
)}`;
|
||||
},
|
||||
},
|
||||
);
|
||||
const active = await surface.start({
|
||||
profile: 'edge',
|
||||
runs: runtime.runRepository,
|
||||
stepRuns: await runtime.stepRunReader(),
|
||||
runCancellation: await runtime.runCancellationRepository(),
|
||||
taskStart: await runtime.taskStartRepository(),
|
||||
taskDefinitions: runtime.taskDefinitions,
|
||||
apiCredentials: runtime.apiCredentials,
|
||||
ownerPepper: runtime.ownerPepper,
|
||||
projectPolicy: runtime.projectPolicy,
|
||||
securityAudit: runtime.securityAudit,
|
||||
});
|
||||
t.after(() => active.stopAndDrain());
|
||||
|
||||
const accepted = await request(port, `Bearer ${TOKEN}`);
|
||||
assert.equal(accepted.statusCode, 200);
|
||||
assert.equal(accepted.body.run.id, RUN_ID);
|
||||
assert.equal(accepted.body.run.projectId, 'default');
|
||||
assert.equal(JSON.stringify(accepted).includes('secret'), false);
|
||||
|
||||
const listed = await request(
|
||||
port,
|
||||
`Bearer ${TOKEN}`,
|
||||
'/api/v3/projects/default/runs?limit=1',
|
||||
);
|
||||
assert.equal(listed.statusCode, 200);
|
||||
assert.equal(listed.body.runs[0].id, RUN_ID);
|
||||
assert.equal(listed.body.hasMore, false);
|
||||
assert.equal(JSON.stringify(listed).includes('secret'), false);
|
||||
|
||||
const tasks = await request(
|
||||
port,
|
||||
`Bearer ${TOKEN}`,
|
||||
'/api/v3/projects/default/tasks?limit=1',
|
||||
);
|
||||
assert.deepEqual(tasks, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
tasks: [
|
||||
{
|
||||
taskId: 'task-1',
|
||||
revision: 1,
|
||||
name: 'Local API Task',
|
||||
kind: 'command',
|
||||
specSchema: 'qinglong/command@v1',
|
||||
enabled: true,
|
||||
updatedAtMs: NOW - 200,
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
},
|
||||
});
|
||||
assert.equal(JSON.stringify(tasks).includes('private'), false);
|
||||
|
||||
const currentTask = await request(
|
||||
port,
|
||||
`Bearer ${TOKEN}`,
|
||||
'/api/v3/projects/default/tasks/task-1',
|
||||
);
|
||||
assert.equal(currentTask.statusCode, 200);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...currentTask.body.task,
|
||||
contentDigest: '<digest>',
|
||||
},
|
||||
{
|
||||
taskId: 'task-1',
|
||||
revision: 1,
|
||||
name: 'Local API Task',
|
||||
kind: 'command',
|
||||
specSchema: 'qinglong/command@v1',
|
||||
enabled: true,
|
||||
contentDigest: '<digest>',
|
||||
createdAtMs: NOW - 200,
|
||||
updatedAtMs: NOW - 200,
|
||||
},
|
||||
);
|
||||
assert.match(currentTask.body.task.contentDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(JSON.stringify(currentTask).includes('private'), false);
|
||||
assert.deepEqual(
|
||||
await request(
|
||||
port,
|
||||
`Bearer ${TOKEN}`,
|
||||
'/api/v3/projects/default/tasks/task-absent',
|
||||
),
|
||||
{ statusCode: 404, body: { code: 'task_not_found' } },
|
||||
);
|
||||
|
||||
const taskStartBody = JSON.stringify({
|
||||
schema: 'qinglong/task-start@v1',
|
||||
mutationId: '019f7300-0000-7000-8000-000000000800',
|
||||
expectedRevision: taskDefinition.revision,
|
||||
expectedContentDigest: taskDefinition.contentDigest,
|
||||
});
|
||||
const taskStartOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(Buffer.byteLength(taskStartBody)),
|
||||
},
|
||||
body: taskStartBody,
|
||||
};
|
||||
const taskStartPath = '/api/v3/projects/default/tasks/task-1/runs';
|
||||
const started = await request(
|
||||
port,
|
||||
`Bearer ${TOKEN}`,
|
||||
taskStartPath,
|
||||
taskStartOptions,
|
||||
);
|
||||
assert.equal(started.statusCode, 202);
|
||||
assert.equal(started.body.schema, 'qinglong/task-start@v1');
|
||||
assert.equal(started.body.status, 'accepted');
|
||||
assert.equal(started.body.runStatus, 'queued');
|
||||
assert.equal(started.body.executorType, 'local_process');
|
||||
assert.equal(started.body.taskContentDigest, taskDefinition.contentDigest);
|
||||
const taskStartReplay = await request(
|
||||
port,
|
||||
`Bearer ${TOKEN}`,
|
||||
taskStartPath,
|
||||
taskStartOptions,
|
||||
);
|
||||
assert.equal(taskStartReplay.statusCode, 200);
|
||||
assert.equal(taskStartReplay.body.status, 'existing');
|
||||
assert.equal(taskStartReplay.body.runId, started.body.runId);
|
||||
assert.equal(taskStartReplay.body.attemptId, started.body.attemptId);
|
||||
|
||||
const timeline = await request(
|
||||
port,
|
||||
`Bearer ${TOKEN}`,
|
||||
`/api/v3/projects/default/runs/${RUN_ID}/events?limit=1`,
|
||||
);
|
||||
assert.deepEqual(timeline, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
events: [
|
||||
{
|
||||
sequence: 1,
|
||||
type: 'run.started',
|
||||
actorType: 'system',
|
||||
createdAtMs: NOW - 50,
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
nextAfterSequence: 1,
|
||||
},
|
||||
});
|
||||
assert.equal(JSON.stringify(timeline).includes('payload'), false);
|
||||
|
||||
const steps = await request(
|
||||
port,
|
||||
`Bearer ${TOKEN}`,
|
||||
`/api/v3/projects/default/runs/${RUN_ID}/steps?limit=1`,
|
||||
);
|
||||
assert.deepEqual(steps, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
steps: [
|
||||
{
|
||||
id: 'step-local-api-1',
|
||||
parentStepRunId: null,
|
||||
stepKey: 'build',
|
||||
kind: 'tool',
|
||||
required: true,
|
||||
status: 'ready',
|
||||
version: 1,
|
||||
attemptCount: 0,
|
||||
readyAtMs: NOW - 75,
|
||||
startedAtMs: null,
|
||||
finishedAtMs: null,
|
||||
resultCode: null,
|
||||
createdAtMs: NOW - 75,
|
||||
updatedAtMs: NOW - 75,
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
next: null,
|
||||
},
|
||||
});
|
||||
assert.equal(JSON.stringify(steps).includes('private'), false);
|
||||
|
||||
const cancellationBody = JSON.stringify({
|
||||
schema: 'qinglong/run-cancellation@v1',
|
||||
mutationId: 'cancel-local-api-1',
|
||||
});
|
||||
const cancellationPath =
|
||||
`/api/v3/projects/default/runs/${RUN_ID}/cancellation`;
|
||||
const cancellationOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(Buffer.byteLength(cancellationBody)),
|
||||
},
|
||||
body: cancellationBody,
|
||||
};
|
||||
const cancelled = await request(
|
||||
port,
|
||||
`Bearer ${TOKEN}`,
|
||||
cancellationPath,
|
||||
cancellationOptions,
|
||||
);
|
||||
assert.equal(cancelled.statusCode, 202);
|
||||
assert.equal(cancelled.body.schema, 'qinglong/run-cancellation@v1');
|
||||
assert.equal(cancelled.body.status, 'accepted');
|
||||
assert.equal(cancelled.body.cancelReason, 'user');
|
||||
|
||||
const replayed = await request(
|
||||
port,
|
||||
`Bearer ${TOKEN}`,
|
||||
cancellationPath,
|
||||
cancellationOptions,
|
||||
);
|
||||
assert.equal(replayed.statusCode, 200);
|
||||
assert.equal(replayed.body.status, 'already_requested');
|
||||
|
||||
const wrongSecret = Buffer.alloc(32, 83).toString('base64url');
|
||||
assert.deepEqual(
|
||||
await request(
|
||||
port,
|
||||
`Bearer ${formatApiCredentialToken(CREDENTIAL_ID, wrongSecret)}`,
|
||||
),
|
||||
{ statusCode: 401, body: { code: 'authentication_required' } },
|
||||
);
|
||||
const auditReader = new DatabaseSync(databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.deepEqual(
|
||||
auditReader
|
||||
.prepare(
|
||||
`SELECT operation_id, outcome FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE operation_id IN (
|
||||
'run.get', 'run.list', 'run.events.list', 'run.steps.list',
|
||||
'run.cancel', 'task.get', 'task.list'
|
||||
, 'task.start'
|
||||
)
|
||||
ORDER BY operation_id, outcome`,
|
||||
)
|
||||
.all()
|
||||
.map(({ operation_id, outcome }) => `${operation_id}:${outcome}`),
|
||||
[
|
||||
'run.cancel:allowed',
|
||||
'run.cancel:allowed',
|
||||
'run.events.list:allowed',
|
||||
'run.get:allowed',
|
||||
'run.get:authentication_rejected',
|
||||
'run.list:allowed',
|
||||
'run.steps.list:allowed',
|
||||
'task.get:allowed',
|
||||
'task.get:allowed',
|
||||
'task.list:allowed',
|
||||
'task.start:allowed',
|
||||
'task.start:allowed',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...auditReader
|
||||
.prepare(
|
||||
`SELECT "status", "version", "event_sequence" AS "eventSequence",
|
||||
"trigger_type" AS "triggerType"
|
||||
FROM "Runs" WHERE "id" = ?`,
|
||||
)
|
||||
.get(started.body.runId),
|
||||
},
|
||||
{
|
||||
status: 'queued',
|
||||
version: 2,
|
||||
eventSequence: 2,
|
||||
triggerType: 'task_start',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...auditReader
|
||||
.prepare(
|
||||
`SELECT "version", "event_sequence" AS "eventSequence",
|
||||
"cancel_reason" AS "cancelReason"
|
||||
FROM "Runs" WHERE "id" = ?`,
|
||||
)
|
||||
.get(RUN_ID),
|
||||
},
|
||||
{ version: 2, eventSequence: 2, cancelReason: 'user' },
|
||||
);
|
||||
assert.equal(
|
||||
auditReader
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count FROM "RunEvents"
|
||||
WHERE "run_id" = ? AND "type" = 'run.cancel_requested'`,
|
||||
)
|
||||
.get(RUN_ID).count,
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
auditReader.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createLocalApiTaskListRoute,
|
||||
} = require('../dist/task/taskListRoute.js');
|
||||
|
||||
function task(taskId, overrides = {}) {
|
||||
return {
|
||||
projectId: 'prj_default',
|
||||
taskId,
|
||||
revision: 2,
|
||||
name: `Task ${taskId}`,
|
||||
description: 'secret-adjacent',
|
||||
kind: 'command',
|
||||
spec: { schema: 'qinglong/command@v1', config: { command: ['private'] } },
|
||||
labels: { private: 'value' },
|
||||
enabled: true,
|
||||
mutationId: 'mutation-private',
|
||||
contentDigest: 'digest-private',
|
||||
createdAtMs: 10,
|
||||
updatedAtMs: 20,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('returns the shared bounded Project Task list projection', async () => {
|
||||
const calls = [];
|
||||
const route = createLocalApiTaskListRoute({
|
||||
async listTaskDefinitions(query) {
|
||||
calls.push(query);
|
||||
return {
|
||||
definitions: [task('task-a', { enabled: false })],
|
||||
truncated: true,
|
||||
next: { taskId: 'task-a' },
|
||||
};
|
||||
},
|
||||
});
|
||||
const response = await route.handle({
|
||||
projectId: 'prj_default',
|
||||
input: { limit: 1 },
|
||||
});
|
||||
assert.deepEqual(calls, [{ projectId: 'prj_default', limit: 1 }]);
|
||||
assert.deepEqual(response, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
tasks: [
|
||||
{
|
||||
taskId: 'task-a',
|
||||
revision: 2,
|
||||
name: 'Task task-a',
|
||||
kind: 'command',
|
||||
specSchema: 'qinglong/command@v1',
|
||||
enabled: false,
|
||||
updatedAtMs: 20,
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
next: { taskId: 'task-a' },
|
||||
},
|
||||
});
|
||||
assert.equal(JSON.stringify(response).includes('secret-adjacent'), false);
|
||||
assert.equal(JSON.stringify(response).includes('private'), false);
|
||||
});
|
||||
|
||||
test('fails closed on cross-Project, malformed and unavailable pages', async () => {
|
||||
for (const page of [
|
||||
{
|
||||
definitions: [task('task-a', { projectId: 'prj_other' })],
|
||||
truncated: false,
|
||||
},
|
||||
{
|
||||
definitions: [task('task-a', { kind: 'invented' })],
|
||||
truncated: false,
|
||||
},
|
||||
]) {
|
||||
const route = createLocalApiTaskListRoute({
|
||||
async listTaskDefinitions() { return page; },
|
||||
});
|
||||
assert.deepEqual(
|
||||
await route.handle({ projectId: 'prj_default', input: {} }),
|
||||
{ statusCode: 503, body: { code: 'task_list_unavailable' } },
|
||||
);
|
||||
}
|
||||
const unavailable = createLocalApiTaskListRoute({
|
||||
async listTaskDefinitions() { throw new Error('offline'); },
|
||||
});
|
||||
assert.deepEqual(
|
||||
await unavailable.handle({ projectId: 'prj_default', input: {} }),
|
||||
{ statusCode: 503, body: { code: 'task_list_unavailable' } },
|
||||
);
|
||||
assert.throws(() => createLocalApiTaskListRoute({}), TypeError);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createTaskDefinitionRecord,
|
||||
} = require('@qinglong/runtime-core/task-definition');
|
||||
const {
|
||||
createLocalApiTaskReadRoute,
|
||||
} = require('../dist/task/taskReadRoute.js');
|
||||
|
||||
function task(overrides = {}) {
|
||||
return createTaskDefinitionRecord(
|
||||
{
|
||||
projectId: 'prj_default',
|
||||
taskId: 'task-a',
|
||||
expectedRevision: null,
|
||||
mutationId: '123e4567-e89b-42d3-a456-426614174101',
|
||||
name: 'Task A',
|
||||
description: 'secret-adjacent',
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: { command: { kind: 'shell', command: 'private' } },
|
||||
},
|
||||
labels: { private: 'value' },
|
||||
enabled: true,
|
||||
occurredAtMs: 20,
|
||||
...overrides,
|
||||
},
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
test('returns one shared current Task projection without definition internals', async () => {
|
||||
const calls = [];
|
||||
const definition = task({ enabled: false });
|
||||
const route = createLocalApiTaskReadRoute({
|
||||
async findCurrentTaskDefinition(projectId, taskId) {
|
||||
calls.push([projectId, taskId]);
|
||||
return definition;
|
||||
},
|
||||
});
|
||||
const response = await route.handle({
|
||||
projectId: 'prj_default',
|
||||
taskId: 'task-a',
|
||||
});
|
||||
assert.deepEqual(calls, [['prj_default', 'task-a']]);
|
||||
assert.deepEqual(response, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
task: {
|
||||
taskId: 'task-a',
|
||||
revision: 1,
|
||||
name: 'Task A',
|
||||
kind: 'command',
|
||||
specSchema: 'qinglong/command@v1',
|
||||
enabled: false,
|
||||
contentDigest: definition.contentDigest,
|
||||
createdAtMs: 10,
|
||||
updatedAtMs: 20,
|
||||
},
|
||||
},
|
||||
});
|
||||
const serialized = JSON.stringify(response);
|
||||
assert.equal(serialized.includes('secret-adjacent'), false);
|
||||
assert.equal(serialized.includes('private'), false);
|
||||
});
|
||||
|
||||
test('masks absence and Project mismatch and fails closed on corruption', async () => {
|
||||
for (const value of [null, task({ projectId: 'prj_other' })]) {
|
||||
const route = createLocalApiTaskReadRoute({
|
||||
async findCurrentTaskDefinition() { return value; },
|
||||
});
|
||||
assert.deepEqual(
|
||||
await route.handle({ projectId: 'prj_default', taskId: 'task-a' }),
|
||||
{ statusCode: 404, body: { code: 'task_not_found' } },
|
||||
);
|
||||
}
|
||||
const corrupt = task();
|
||||
const route = createLocalApiTaskReadRoute({
|
||||
async findCurrentTaskDefinition() {
|
||||
return { ...corrupt, contentDigest: '0'.repeat(64) };
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
await route.handle({ projectId: 'prj_default', taskId: 'task-a' }),
|
||||
{ statusCode: 503, body: { code: 'task_query_unavailable' } },
|
||||
);
|
||||
assert.throws(() => createLocalApiTaskReadRoute({}), TypeError);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
TASK_START_SCHEMA,
|
||||
TaskStartFenceRejectedError,
|
||||
TaskStartNotFoundError,
|
||||
TaskStartUnavailableError,
|
||||
} = require('@qinglong/runtime-core/task-start');
|
||||
const {
|
||||
createLocalApiTaskStartRoute,
|
||||
} = require('../dist/task/taskStartRoute.js');
|
||||
|
||||
const IDS = [
|
||||
'019f7300-0000-7000-8000-000000000701',
|
||||
'019f7300-0000-7000-8000-000000000702',
|
||||
'019f7300-0000-7000-8000-000000000703',
|
||||
'019f7300-0000-7000-8000-000000000704',
|
||||
];
|
||||
const MUTATION_ID = '019f7300-0000-7000-8000-000000000700';
|
||||
const DIGEST = 'a'.repeat(64);
|
||||
const PRINCIPAL = Object.freeze({
|
||||
subject: Object.freeze({ type: 'user', id: 'user-1' }),
|
||||
authenticationId: 'credential-1',
|
||||
authenticatedAtMs: 1,
|
||||
expiresAtMs: 20,
|
||||
assurance: 'single_factor',
|
||||
});
|
||||
|
||||
function request(overrides = {}) {
|
||||
return {
|
||||
projectId: 'project-1',
|
||||
taskId: 'task-1',
|
||||
body: {
|
||||
schema: TASK_START_SCHEMA,
|
||||
mutationId: MUTATION_ID,
|
||||
expectedRevision: 3,
|
||||
expectedContentDigest: DIGEST,
|
||||
},
|
||||
principal: PRINCIPAL,
|
||||
policyFence: { projectVersion: 2, bindingVersion: 4 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function receipt(overrides = {}) {
|
||||
return {
|
||||
status: 'accepted',
|
||||
projectId: 'project-1',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 3,
|
||||
taskContentDigest: DIGEST,
|
||||
runId: IDS[0],
|
||||
attemptId: IDS[1],
|
||||
runStatus: 'queued',
|
||||
runVersion: 2,
|
||||
eventSequence: 2,
|
||||
executorType: 'local_process',
|
||||
executionRevisionDigest: 'b'.repeat(64),
|
||||
createdAtMs: 10,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function route(repository) {
|
||||
let index = 0;
|
||||
return createLocalApiTaskStartRoute(repository, () => IDS[index++]);
|
||||
}
|
||||
|
||||
test('publishes one server-owned Task start command and exact receipt', async () => {
|
||||
let observed;
|
||||
const result = await route({
|
||||
async startTask(command) {
|
||||
observed = command;
|
||||
return receipt();
|
||||
},
|
||||
}).handle(request());
|
||||
assert.deepEqual(result, {
|
||||
statusCode: 202,
|
||||
body: { schema: TASK_START_SCHEMA, ...receipt() },
|
||||
});
|
||||
assert.deepEqual(observed, {
|
||||
projectId: 'project-1',
|
||||
taskId: 'task-1',
|
||||
mutationId: MUTATION_ID,
|
||||
expectedRevision: 3,
|
||||
expectedContentDigest: DIGEST,
|
||||
runId: IDS[0],
|
||||
attemptId: IDS[1],
|
||||
createdEventId: IDS[2],
|
||||
queuedEventId: IDS[3],
|
||||
subject: PRINCIPAL.subject,
|
||||
policyFence: { projectVersion: 2, bindingVersion: 4 },
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects widened bodies and maps replay plus stable failures', async () => {
|
||||
let calls = 0;
|
||||
assert.equal((await route({
|
||||
async startTask() { calls += 1; return receipt(); },
|
||||
}).handle(request({ body: { ...request().body, command: '/bin/sh' } }))).statusCode, 400);
|
||||
assert.equal(calls, 0);
|
||||
|
||||
assert.equal((await route({
|
||||
async startTask() { return receipt({ status: 'existing' }); },
|
||||
}).handle(request())).statusCode, 200);
|
||||
|
||||
for (const [error, statusCode, code] of [
|
||||
[new TaskStartNotFoundError(), 404, 'task_not_found'],
|
||||
[new TaskStartFenceRejectedError('task_disabled'), 409, 'task_start_fence_rejected'],
|
||||
[new TaskStartUnavailableError(), 503, 'task_start_unavailable'],
|
||||
]) {
|
||||
const result = await route({ async startTask() { throw error; } }).handle(request());
|
||||
assert.equal(result.statusCode, statusCode);
|
||||
assert.equal(result.body.code, code);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user