feat(ql3): establish 3.0 incubation baseline

This commit is contained in:
whyour
2026-08-12 00:25:26 +08:00
parent 4bf92dcfeb
commit c699c32461
2817 changed files with 779642 additions and 653 deletions
@@ -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,
});
}
},
});
}
+83
View File
@@ -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;
},
});
}