feat(ql3): add request-scoped console task creation

This commit is contained in:
whyour
2026-08-29 03:51:35 +08:00
parent 53b7072370
commit 884912d1c8
32 changed files with 2485 additions and 56 deletions
@@ -26,6 +26,7 @@ import type { LocalApiRunAttemptLogReadRoute } from '../run/runAttemptLogReadRou
import type { LocalApiTaskListRoute } from '../task/taskListRoute';
import type { LocalApiTaskReadRoute } from '../task/taskReadRoute';
import type { LocalApiTaskStartRoute } from '../task/taskStartRoute';
import type { LocalApiTaskPutRoute } from '../task/taskPutRoute';
import type { LocalApiResponse } from '../transport/contract';
export type LocalApiAdmissionOperation =
@@ -78,12 +79,18 @@ export type LocalApiAdmissionOperation =
operationId: 'task.start';
projectId: string;
taskId: string;
}>
| Readonly<{
operationId: 'task.put';
projectId: string;
taskId: string;
}>;
export interface LocalApiAdmissionRequest {
readonly requestId: string;
readonly operation: LocalApiAdmissionOperation;
readonly authorization: string | null;
readonly localPresence: string | null;
readonly signal: AbortSignal;
}
@@ -112,6 +119,7 @@ export interface LocalApiAdmissionOptions {
readonly taskListRoute: LocalApiTaskListRoute;
readonly taskReadRoute: LocalApiTaskReadRoute;
readonly taskStartRoute: LocalApiTaskStartRoute;
readonly taskPutRoute: LocalApiTaskPutRoute;
readonly now?: () => number;
readonly randomUuid?: () => string;
}
@@ -190,6 +198,7 @@ export function createLocalApiAdmission(
typeof options.taskListRoute?.handle !== 'function' ||
typeof options.taskReadRoute?.handle !== 'function' ||
typeof options.taskStartRoute?.handle !== 'function' ||
typeof options.taskPutRoute?.handle !== 'function' ||
(options.now !== undefined && typeof options.now !== 'function') ||
(options.randomUuid !== undefined &&
typeof options.randomUuid !== 'function')
@@ -239,6 +248,25 @@ export function createLocalApiAdmission(
}
if (request.signal.aborted) return response(503, 'request_unavailable');
if (request.operation.operationId === 'task.put') {
const taskPutOperation = request.operation;
return Object.freeze({
bodyMode: 'json' as const,
maximumBodyBytes: 72 * 1_024,
async handle(body: unknown | null) {
return options.taskPutRoute.handle({
requestId: request.requestId,
projectId: taskPutOperation.projectId,
taskId: taskPutOperation.taskId,
body,
presence: request.localPresence,
authenticated,
signal: request.signal,
});
},
});
}
let decision: Readonly<SecurityPolicyDecision>;
try {
decision = normalizeSecurityPolicyDecision(
@@ -386,6 +414,8 @@ export function createLocalApiAdmission(
principal: authenticated.principal,
policyFence: decision.fence,
});
case 'task.put':
return response(503, 'request_unavailable');
}
},
});
@@ -9,6 +9,7 @@ import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
import { createLocalApiAdmission } from '../admission/localApiAdmission';
import { createLocalApiCredentialAuthenticator } from '../authentication/credentialAuthenticator';
import { createLocalPresenceProofManager } from '../authentication/localPresenceProof';
import type { LocalApiProcessConfig } from '../production-process/config';
import { createLocalApiRunListRoute } from '../run/runListRoute';
import { createLocalApiRunReadRoute } from '../run/runReadRoute';
@@ -19,6 +20,7 @@ import { createLocalApiRunAttemptLogReadRoute } from '../run/runAttemptLogReadRo
import { createLocalApiTaskListRoute } from '../task/taskListRoute';
import { createLocalApiTaskReadRoute } from '../task/taskReadRoute';
import { createLocalApiTaskStartRoute } from '../task/taskStartRoute';
import { createLocalApiTaskPutRoute } from '../task/taskPutRoute';
import { startLocalApiHttpSurface } from '../transport/httpSurface';
export interface LocalApiProductSurfaceEvent {
@@ -98,6 +100,14 @@ export function createLocalApiProductSurface(
provider,
options.now === undefined ? {} : { now: options.now },
);
const presenceProof = createLocalPresenceProofManager({
deploymentRoot: config.deploymentRoot,
profile: authority.profile,
...(options.now === undefined ? {} : { now: options.now }),
...(options.randomUuid === undefined
? {}
: { randomUuid: options.randomUuid }),
});
const policy = new ProjectPolicyEngine(authority.projectPolicy);
const runReadRoute = createLocalApiRunReadRoute(authority.runs);
const runListRoute = createLocalApiRunListRoute(authority.runs);
@@ -123,6 +133,25 @@ export function createLocalApiProductSurface(
authority.taskStart,
options.randomUuid ?? randomUUID,
);
const taskPutRoute = createLocalApiTaskPutRoute({
projectPolicy: authority.projectPolicy,
taskDefinitions: authority.taskDefinitions,
taskDefinitionAdministrationForCredential: (fence) => {
if (fence.subjectType !== 'user') {
throw new TypeError('Task mutation requires a User credential');
}
return authority.taskDefinitionAdministrationForCredential({
...fence,
subjectType: 'user',
});
},
securityAudit: authority.securityAudit,
presenceProof,
...(options.now === undefined ? {} : { now: options.now }),
...(options.randomUuid === undefined
? {}
: { randomUuid: options.randomUuid }),
});
const admission = createLocalApiAdmission({
authenticator,
policy,
@@ -136,20 +165,27 @@ export function createLocalApiProductSurface(
taskListRoute,
taskReadRoute,
taskStartRoute,
taskPutRoute,
...(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 }),
});
let active;
try {
active = await startLocalApiHttpSurface({
profile: authority.profile,
host: config.listener.host,
port: config.listener.port,
admission,
...(options.randomUuid === undefined
? {}
: { randomUuid: options.randomUuid }),
});
} catch (error) {
presenceProof.close();
throw error;
}
await bestEffortEmit(
options.emit,
surfaceEvent(config, 'listening', { level: 'info' }),
@@ -163,7 +199,12 @@ export function createLocalApiProductSurface(
options.emit,
surfaceEvent(config, 'draining', { level: 'info' }),
);
const stopResult = await active.stopAndDrain();
let stopResult = await active.stopAndDrain();
try {
presenceProof.close();
} catch {
stopResult = 'timed_out';
}
await bestEffortEmit(
options.emit,
surfaceEvent(config, 'stopped', {
@@ -19,6 +19,7 @@ const AUTHORIZATION_PATTERN =
export interface AuthenticatedLocalApiRequest {
readonly principal: Readonly<SecurityPrincipal>;
readonly credentialFence: Readonly<LocalApiCredentialFence>;
confirm(): Promise<void>;
}
@@ -32,7 +33,7 @@ export interface LocalApiCredentialAuthenticatorOptions {
readonly now?: () => number;
}
interface CredentialFence {
export interface LocalApiCredentialFence {
readonly credentialId: string;
readonly credentialVersion: number;
readonly pepperKeyId: string;
@@ -91,7 +92,7 @@ async function loadFence(
provider: LocalOwnerPepperKeyringFileProvider,
credentialId: string,
credentialVersion: number,
): Promise<Readonly<CredentialFence>> {
): Promise<Readonly<LocalApiCredentialFence>> {
try {
const candidate = await authority.apiCredentials.resolve(credentialId);
if (!candidate) throw new Error('credential is unavailable');
@@ -103,11 +104,7 @@ async function loadFence(
credential.state !== 'active' ||
credential.subjectStatus !== 'active' ||
!validKey(key) ||
!validMaterial(
material,
credential.pepperKeyId,
key.materialDigest,
)
!validMaterial(material, credential.pepperKeyId, key.materialDigest)
) {
throw new Error('credential fence is unavailable');
}
@@ -129,7 +126,10 @@ async function loadFence(
}
}
function sameFence(left: CredentialFence, right: CredentialFence): boolean {
function sameFence(
left: LocalApiCredentialFence,
right: LocalApiCredentialFence,
): boolean {
return (
left.credentialId === right.credentialId &&
left.credentialVersion === right.credentialVersion &&
@@ -201,6 +201,7 @@ export function createLocalApiCredentialAuthenticator(
}
return Object.freeze({
principal: authentication.principal,
credentialFence: fence,
async confirm() {
try {
const currentAuthentication =
@@ -227,7 +228,8 @@ export function createLocalApiCredentialAuthenticator(
}
} catch (error) {
if (
error instanceof LocalApiCredentialAuthenticationUnavailableError
error instanceof
LocalApiCredentialAuthenticationUnavailableError
) {
throw error;
}
@@ -238,9 +240,7 @@ export function createLocalApiCredentialAuthenticator(
},
});
} catch (error) {
if (
error instanceof LocalApiCredentialAuthenticationUnavailableError
) {
if (error instanceof LocalApiCredentialAuthenticationUnavailableError) {
throw error;
}
if (error instanceof LocalIdentityAuthenticationUnavailableError) {
@@ -0,0 +1,400 @@
import {
createHash,
randomBytes,
randomUUID,
timingSafeEqual,
} from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import type { LocalApplicationProfile } from '@qinglong/local-application';
const PRESENCE_DIRECTORY = 'console-presence';
const AUTHORIZATION_TTL_MS = 120_000;
const AUTHORIZATION_PATTERN =
/^ql3p_([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})_([A-Za-z0-9_-]{43})$/;
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
interface PendingLocalPresenceAuthorization {
readonly authorizationId: string;
readonly fileName: string;
readonly requestDigest: string;
readonly credentialDigest: string;
readonly proofDigest: Buffer;
readonly expiresAtMs: number;
}
export interface LocalPresenceBinding {
readonly requestDigest: string;
readonly credentialId: string;
readonly credentialVersion: number;
readonly subjectType: 'user';
readonly subjectId: string;
}
export interface LocalPresenceChallenge {
readonly authorizationId: string;
readonly requestDigest: string;
readonly expiresAtMs: number;
readonly proofFileName: string;
}
export interface ConsumedLocalPresenceProof {
readonly authorizationId: string;
readonly authenticatedAtMs: number;
readonly expiresAtMs: number;
}
export interface LocalPresenceProofManager {
issue(binding: Readonly<LocalPresenceBinding>): LocalPresenceChallenge;
consume(
presentation: string | null,
binding: Readonly<LocalPresenceBinding>,
): ConsumedLocalPresenceProof | null;
close(): void;
}
export interface LocalPresenceProofManagerOptions {
readonly deploymentRoot: string;
readonly profile: LocalApplicationProfile;
readonly now?: () => number;
readonly randomUuid?: () => string;
readonly randomSecret?: () => Buffer;
}
export class LocalPresenceProofConfigurationError extends TypeError {
readonly code = 'QL3_LOCAL_PRESENCE_CONFIG_INVALID';
constructor(message: string, options?: ErrorOptions) {
super(`Local presence proof configuration is invalid: ${message}`, options);
this.name = 'LocalPresenceProofConfigurationError';
}
}
export class LocalPresenceProofUnavailableError extends Error {
readonly code = 'QL3_LOCAL_PRESENCE_UNAVAILABLE';
constructor(message: string, options?: ErrorOptions) {
super(`Local presence proof is unavailable: ${message}`, options);
this.name = 'LocalPresenceProofUnavailableError';
}
}
function currentUid(): number {
if (typeof process.getuid !== 'function') {
throw new LocalPresenceProofConfigurationError(
'POSIX user identity is unavailable',
);
}
const uid = process.getuid();
if (!Number.isSafeInteger(uid) || uid < 0) {
throw new LocalPresenceProofConfigurationError('POSIX user is invalid');
}
return uid;
}
function privateDirectory(directoryPath: string, uid: number): void {
let stat: fs.Stats;
try {
stat = fs.lstatSync(directoryPath);
} catch (error) {
throw new LocalPresenceProofConfigurationError(
'private directory is unavailable',
{ cause: error },
);
}
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
stat.uid !== uid ||
(stat.mode & 0o777) !== 0o700
) {
throw new LocalPresenceProofConfigurationError(
'private directory ownership or mode is invalid',
);
}
}
function clock(now: () => number): number {
const value = now();
if (!Number.isSafeInteger(value) || value < 0) {
throw new LocalPresenceProofUnavailableError('clock is invalid');
}
return value;
}
function credentialDigest(binding: Readonly<LocalPresenceBinding>): string {
if (
!binding ||
typeof binding !== 'object' ||
Array.isArray(binding) ||
Object.keys(binding).sort().join('\0') !==
[
'credentialId',
'credentialVersion',
'requestDigest',
'subjectId',
'subjectType',
]
.sort()
.join('\0') ||
!SHA256_PATTERN.test(binding.requestDigest) ||
typeof binding.credentialId !== 'string' ||
binding.credentialId.length < 1 ||
binding.credentialId.length > 64 ||
!Number.isSafeInteger(binding.credentialVersion) ||
binding.credentialVersion < 1 ||
binding.subjectType !== 'user' ||
typeof binding.subjectId !== 'string' ||
binding.subjectId.length < 1 ||
binding.subjectId.length > 128
) {
throw new LocalPresenceProofUnavailableError('binding is invalid');
}
return createHash('sha256')
.update('qinglong3.local-presence-credential.v1\0', 'utf8')
.update(binding.credentialId, 'utf8')
.update('\0', 'utf8')
.update(String(binding.credentialVersion), 'utf8')
.update('\0', 'utf8')
.update(binding.subjectType, 'utf8')
.update('\0', 'utf8')
.update(binding.subjectId, 'utf8')
.digest('hex');
}
function removeFile(directory: string, fileName: string): void {
try {
fs.unlinkSync(path.join(directory, fileName));
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
throw new LocalPresenceProofUnavailableError(
'proof file cannot be removed',
{ cause: error },
);
}
}
}
function writeProofFile(
directory: string,
authorization: Omit<PendingLocalPresenceAuthorization, 'proofDigest'>,
presentation: string,
): void {
const filePath = path.join(directory, authorization.fileName);
const payload = Buffer.from(
`${JSON.stringify({
schemaVersion: 1,
kind: 'qinglong3-local-presence-proof',
authorizationId: authorization.authorizationId,
requestDigest: authorization.requestDigest,
expiresAtMs: authorization.expiresAtMs,
proof: presentation,
})}\n`,
'utf8',
);
let descriptor: number | undefined;
try {
descriptor = fs.openSync(
filePath,
fs.constants.O_CREAT |
fs.constants.O_EXCL |
fs.constants.O_WRONLY |
(fs.constants.O_NOFOLLOW ?? 0),
0o600,
);
fs.writeFileSync(descriptor, payload);
fs.fsyncSync(descriptor);
const stat = fs.fstatSync(descriptor);
if (!stat.isFile() || (stat.mode & 0o777) !== 0o600 || stat.nlink !== 1) {
throw new Error('proof file identity is invalid');
}
} catch (error) {
try {
fs.unlinkSync(filePath);
} catch {
// Preserve the original publication failure.
}
throw new LocalPresenceProofUnavailableError(
'proof file cannot be published',
{ cause: error },
);
} finally {
payload.fill(0);
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
export function createLocalPresenceProofManager(
options: Readonly<LocalPresenceProofManagerOptions>,
): Readonly<LocalPresenceProofManager> {
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
Object.keys(options).some(
(key) =>
key !== 'deploymentRoot' &&
key !== 'profile' &&
key !== 'now' &&
key !== 'randomUuid' &&
key !== 'randomSecret',
) ||
typeof options.deploymentRoot !== 'string' ||
!path.isAbsolute(options.deploymentRoot) ||
path.normalize(options.deploymentRoot) !== options.deploymentRoot ||
path.parse(options.deploymentRoot).root === options.deploymentRoot ||
(options.profile !== 'edge' && options.profile !== 'standalone') ||
(options.now !== undefined && typeof options.now !== 'function') ||
(options.randomUuid !== undefined &&
typeof options.randomUuid !== 'function') ||
(options.randomSecret !== undefined &&
typeof options.randomSecret !== 'function')
) {
throw new LocalPresenceProofConfigurationError('options are invalid');
}
const uid = currentUid();
privateDirectory(options.deploymentRoot, uid);
const directory = path.join(options.deploymentRoot, PRESENCE_DIRECTORY);
try {
fs.mkdirSync(directory, { mode: 0o700 });
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code !== 'EEXIST') {
throw new LocalPresenceProofConfigurationError(
'private directory cannot be created',
{ cause: error },
);
}
}
privateDirectory(directory, uid);
const now = options.now ?? Date.now;
const uuid = options.randomUuid ?? randomUUID;
const secret = options.randomSecret ?? (() => randomBytes(32));
const maximumPending = options.profile === 'edge' ? 8 : 32;
const pending = new Map<string, PendingLocalPresenceAuthorization>();
let closed = false;
const sweep = (nowMs: number) => {
for (const [authorizationId, authorization] of pending) {
if (authorization.expiresAtMs > nowMs) continue;
removeFile(directory, authorization.fileName);
authorization.proofDigest.fill(0);
pending.delete(authorizationId);
}
};
return Object.freeze({
issue(binding: Readonly<LocalPresenceBinding>) {
if (closed) {
throw new LocalPresenceProofUnavailableError('manager is closed');
}
const nowMs = clock(now);
sweep(nowMs);
if (pending.size >= maximumPending) {
throw new LocalPresenceProofUnavailableError(
'pending authorization capacity is exhausted',
);
}
const boundCredentialDigest = credentialDigest(binding);
const authorizationId = uuid();
if (
!AUTHORIZATION_PATTERN.test(`ql3p_${authorizationId}_${'A'.repeat(43)}`)
) {
throw new LocalPresenceProofUnavailableError(
'authorization identity is invalid',
);
}
const material = secret();
if (!Buffer.isBuffer(material) || material.byteLength !== 32) {
throw new LocalPresenceProofUnavailableError(
'proof entropy is unavailable',
);
}
let presentation: string | undefined;
try {
presentation = `ql3p_${authorizationId}_${material.toString(
'base64url',
)}`;
const authorization = Object.freeze({
authorizationId,
fileName: `${authorizationId}.json`,
requestDigest: binding.requestDigest,
credentialDigest: boundCredentialDigest,
expiresAtMs: nowMs + AUTHORIZATION_TTL_MS,
});
writeProofFile(directory, authorization, presentation);
pending.set(
authorizationId,
Object.freeze({
...authorization,
proofDigest: createHash('sha256')
.update('qinglong3.local-presence-proof.v1\0', 'utf8')
.update(presentation, 'utf8')
.digest(),
}),
);
return Object.freeze({
authorizationId,
requestDigest: binding.requestDigest,
expiresAtMs: authorization.expiresAtMs,
proofFileName: authorization.fileName,
});
} finally {
material.fill(0);
presentation = undefined;
}
},
consume(
presentation: string | null,
binding: Readonly<LocalPresenceBinding>,
) {
if (closed || typeof presentation !== 'string') return null;
const nowMs = clock(now);
sweep(nowMs);
const match = AUTHORIZATION_PATTERN.exec(presentation);
if (!match) return null;
const authorization = pending.get(match[1]!);
if (!authorization) return null;
const actualProofDigest = createHash('sha256')
.update('qinglong3.local-presence-proof.v1\0', 'utf8')
.update(presentation, 'utf8')
.digest();
let valid = false;
try {
valid =
authorization.expiresAtMs > nowMs &&
authorization.requestDigest === binding.requestDigest &&
authorization.credentialDigest === credentialDigest(binding) &&
timingSafeEqual(actualProofDigest, authorization.proofDigest);
} finally {
actualProofDigest.fill(0);
}
if (!valid) return null;
pending.delete(authorization.authorizationId);
removeFile(directory, authorization.fileName);
authorization.proofDigest.fill(0);
return Object.freeze({
authorizationId: authorization.authorizationId,
authenticatedAtMs: nowMs,
expiresAtMs: authorization.expiresAtMs,
});
},
close() {
if (closed) return;
closed = true;
let firstError: unknown;
for (const authorization of pending.values()) {
try {
removeFile(directory, authorization.fileName);
} catch (error) {
firstError ??= error;
}
authorization.proofDigest.fill(0);
}
pending.clear();
if (firstError) throw firstError;
},
});
}
@@ -0,0 +1,462 @@
import { createHash, randomUUID } from 'node:crypto';
import {
LocalTaskDefinitionAdministrationAuthenticationError,
LocalTaskDefinitionAdministrationAuthorizationError,
LocalTaskDefinitionAdministrationConfigurationError,
LocalTaskDefinitionAdministrationUnavailableError,
createLocalTaskDefinitionAdministrationService,
} from '@qinglong/local-admin/task-definition-administration';
import {
ProjectPolicyEngine,
ProjectPolicyUnavailableError,
type ProjectPolicyRepository,
} from '@qinglong/runtime-core/project-policy';
import {
normalizeSecurityPolicyDecision,
normalizeSecurityPrincipal,
type SecurityPolicyDecision,
} from '@qinglong/runtime-core/security';
import {
normalizeSecurityAuditRecord,
type SecurityAuditOutcome,
type SecurityAuditSink,
} from '@qinglong/runtime-core/security-audit';
import {
InvalidTaskDefinitionError,
TaskDefinitionConflictError,
TaskDefinitionUnavailableError,
normalizeAppendTaskDefinitionRevisionCommand,
type AppendTaskDefinitionRevisionCommand,
type TaskDefinitionRecord,
type TaskDefinitionSource,
} from '@qinglong/runtime-core/task-definition';
import {
TaskDefinitionAdministrationAuthorizationFenceConflictError,
TaskDefinitionAdministrationMutationConflictError,
type TaskDefinitionAdministrationRepository,
} from '@qinglong/runtime-core/task-definition-administration';
import type { AuthenticatedLocalApiRequest } from '../authentication/credentialAuthenticator';
import {
LocalPresenceProofUnavailableError,
type LocalPresenceBinding,
type LocalPresenceProofManager,
} from '../authentication/localPresenceProof';
import type { LocalApiResponse } from '../transport/contract';
const BODY_KEYS = Object.freeze([
'enabled',
'expectedRevision',
'kind',
'labels',
'mutationId',
'name',
'occurredAtMs',
'spec',
]);
const OPTIONAL_BODY_KEYS = Object.freeze(['description']);
export interface LocalApiTaskPutRequest {
readonly requestId: string;
readonly projectId: string;
readonly taskId: string;
readonly body: unknown | null;
readonly presence: string | null;
readonly authenticated: Readonly<AuthenticatedLocalApiRequest>;
readonly signal: AbortSignal;
}
export interface LocalApiTaskPutRoute {
handle(request: Readonly<LocalApiTaskPutRequest>): Promise<LocalApiResponse>;
}
export interface LocalApiTaskPutRouteOptions {
readonly projectPolicy: ProjectPolicyRepository;
readonly taskDefinitions: TaskDefinitionSource;
readonly taskDefinitionAdministrationForCredential: (
fence: Readonly<AuthenticatedLocalApiRequest['credentialFence']>,
) => TaskDefinitionAdministrationRepository;
readonly securityAudit: SecurityAuditSink;
readonly presenceProof: LocalPresenceProofManager;
readonly now?: () => number;
readonly randomUuid?: () => string;
}
function response(
statusCode: number,
body: Readonly<Record<string, unknown>>,
): LocalApiResponse {
return Object.freeze({ statusCode, body: Object.freeze(body) });
}
function canonicalJson(value: unknown): string {
if (
value === null ||
typeof value === 'boolean' ||
typeof value === 'number' ||
typeof value === 'string'
) {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((entry) => canonicalJson(entry)).join(',')}]`;
}
const record = value as Readonly<Record<string, unknown>>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
.join(',')}}`;
}
function normalizeBody(
body: unknown | null,
projectId: string,
taskId: string,
): Readonly<AppendTaskDefinitionRevisionCommand> {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
throw new InvalidTaskDefinitionError('HTTP body must be an object');
}
const keys = Object.keys(body).sort();
const allowed = new Set([...BODY_KEYS, ...OPTIONAL_BODY_KEYS]);
if (
BODY_KEYS.some((key) => !keys.includes(key)) ||
keys.some((key) => !allowed.has(key))
) {
throw new InvalidTaskDefinitionError('HTTP body has an invalid shape');
}
return normalizeAppendTaskDefinitionRevisionCommand({
projectId,
taskId,
...(body as Omit<
AppendTaskDefinitionRevisionCommand,
'projectId' | 'taskId'
>),
});
}
function requestDigest(
command: Readonly<AppendTaskDefinitionRevisionCommand>,
): string {
return createHash('sha256')
.update('qinglong3.local-api-task-put.v1\0', 'utf8')
.update(canonicalJson(command), 'utf8')
.digest('hex');
}
function presenceBinding(
command: Readonly<AppendTaskDefinitionRevisionCommand>,
authenticated: Readonly<AuthenticatedLocalApiRequest>,
): Readonly<LocalPresenceBinding> {
if (
authenticated.principal.subject.type !== 'user' ||
authenticated.credentialFence.subjectType !== 'user'
) {
throw new LocalPresenceProofUnavailableError(
'strong User credential is required',
);
}
return Object.freeze({
requestDigest: requestDigest(command),
credentialId: authenticated.credentialFence.credentialId,
credentialVersion: authenticated.credentialFence.credentialVersion,
subjectType: 'user',
subjectId: authenticated.credentialFence.subjectId,
});
}
function operationId(
command: Readonly<AppendTaskDefinitionRevisionCommand>,
): 'task.create' | 'task.update' {
return command.expectedRevision === null ? 'task.create' : 'task.update';
}
function summary(value: Readonly<TaskDefinitionRecord>) {
return Object.freeze({
taskId: value.taskId,
revision: value.revision,
name: value.name,
kind: value.kind,
specSchema: value.spec.schema,
enabled: value.enabled,
contentDigest: value.contentDigest,
createdAtMs: value.createdAtMs,
updatedAtMs: value.updatedAtMs,
});
}
function timestamp(now: () => number): number {
const value = now();
if (!Number.isSafeInteger(value) || value < 0) {
throw new LocalPresenceProofUnavailableError('clock is invalid');
}
return value;
}
async function recordAudit(
audit: SecurityAuditSink,
values: {
readonly eventId: string;
readonly requestId: string;
readonly operationId: 'task.create' | 'task.update';
readonly projectId: string;
readonly authenticated: Readonly<AuthenticatedLocalApiRequest> | null;
readonly outcome: SecurityAuditOutcome;
readonly reasons: readonly string[];
readonly fence: SecurityPolicyDecision['fence'];
readonly occurredAtMs: number;
},
): Promise<boolean> {
try {
await audit.record(
normalizeSecurityAuditRecord({
eventId: values.eventId,
requestId: values.requestId,
operationId: values.operationId,
projectId: values.projectId,
subject: values.authenticated?.principal.subject ?? null,
authenticationId:
values.authenticated?.principal.authenticationId ?? null,
outcome: values.outcome,
reasons: values.reasons,
fence: values.fence,
occurredAtMs: values.occurredAtMs,
}),
);
return true;
} catch {
return false;
}
}
export function createLocalApiTaskPutRoute(
options: Readonly<LocalApiTaskPutRouteOptions>,
): Readonly<LocalApiTaskPutRoute> {
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
typeof options.projectPolicy?.resolve !== 'function' ||
typeof options.taskDefinitions?.findCurrentTaskDefinition !== 'function' ||
typeof options.taskDefinitions?.findTaskDefinitionRevision !== 'function' ||
typeof options.taskDefinitions?.listTaskDefinitions !== 'function' ||
typeof options.taskDefinitionAdministrationForCredential !== 'function' ||
typeof options.securityAudit?.record !== 'function' ||
typeof options.presenceProof?.issue !== 'function' ||
typeof options.presenceProof?.consume !== 'function' ||
(options.now !== undefined && typeof options.now !== 'function') ||
(options.randomUuid !== undefined &&
typeof options.randomUuid !== 'function')
) {
throw new TypeError('Local API Task put route options are invalid');
}
const now = options.now ?? Date.now;
const uuid = options.randomUuid ?? randomUUID;
const policy = new ProjectPolicyEngine(options.projectPolicy);
return Object.freeze({
async handle(request: Readonly<LocalApiTaskPutRequest>) {
if (request.signal.aborted) {
return response(503, { code: 'request_unavailable' });
}
let command: Readonly<AppendTaskDefinitionRevisionCommand>;
try {
command = normalizeBody(
request.body,
request.projectId,
request.taskId,
);
} catch (error) {
return error instanceof InvalidTaskDefinitionError
? response(400, { code: 'invalid_task_definition' })
: response(503, { code: 'task_definition_unavailable' });
}
const operation = operationId(command);
const occurredAtMs = timestamp(now);
let decision: Readonly<SecurityPolicyDecision>;
try {
decision = normalizeSecurityPolicyDecision(
await policy.authorize(
request.authenticated.principal,
request.projectId,
operation,
),
);
} catch (error) {
const audited = await recordAudit(options.securityAudit, {
eventId: uuid(),
requestId: request.requestId,
operationId: operation,
projectId: request.projectId,
authenticated: request.authenticated,
outcome: 'authorization_unavailable',
reasons: ['policy_unavailable'],
fence: null,
occurredAtMs,
});
return response(503, {
code:
audited && error instanceof ProjectPolicyUnavailableError
? 'authorization_unavailable'
: 'security_audit_unavailable',
});
}
if (decision.effect !== 'allow') {
const audited = await recordAudit(options.securityAudit, {
eventId: uuid(),
requestId: request.requestId,
operationId: operation,
projectId: request.projectId,
authenticated: request.authenticated,
outcome:
decision.effect === 'require_approval'
? 'approval_required'
: 'denied',
reasons: decision.reasons,
fence: decision.fence,
occurredAtMs,
});
if (!audited) {
return response(503, { code: 'security_audit_unavailable' });
}
return response(403, {
code:
decision.effect === 'require_approval'
? 'approval_required'
: 'forbidden',
});
}
let binding: Readonly<LocalPresenceBinding>;
try {
binding = presenceBinding(command, request.authenticated);
} catch {
return response(401, { code: 'strong_authentication_required' });
}
if (!request.presence) {
let challenge;
try {
challenge = options.presenceProof.issue(binding);
} catch {
return response(503, { code: 'local_presence_unavailable' });
}
const audited = await recordAudit(options.securityAudit, {
eventId: uuid(),
requestId: request.requestId,
operationId: operation,
projectId: request.projectId,
authenticated: request.authenticated,
outcome: 'approval_required',
reasons: ['local_presence_required'],
fence: decision.fence,
occurredAtMs,
});
if (!audited) {
return response(503, { code: 'security_audit_unavailable' });
}
return response(428, {
code: 'local_presence_required',
authorizationId: challenge.authorizationId,
requestDigest: challenge.requestDigest,
expiresAtMs: challenge.expiresAtMs,
proofFileName: challenge.proofFileName,
});
}
let proof;
try {
await request.authenticated.confirm();
proof = options.presenceProof.consume(request.presence, binding);
} catch {
return response(503, { code: 'authentication_unavailable' });
}
if (!proof) {
const audited = await recordAudit(options.securityAudit, {
eventId: uuid(),
requestId: request.requestId,
operationId: operation,
projectId: request.projectId,
authenticated: null,
outcome: 'authentication_rejected',
reasons: ['local_presence_rejected'],
fence: null,
occurredAtMs,
});
return audited
? response(401, { code: 'local_presence_rejected' })
: response(503, { code: 'security_audit_unavailable' });
}
if (request.signal.aborted) {
return response(503, { code: 'request_unavailable' });
}
let strongPrincipal;
try {
strongPrincipal = normalizeSecurityPrincipal(
{
subject: request.authenticated.principal.subject,
authenticationId: `local_presence:${proof.authorizationId}`,
authenticatedAtMs: proof.authenticatedAtMs,
expiresAtMs: Math.min(
proof.expiresAtMs,
request.authenticated.principal.expiresAtMs,
),
assurance: 'local_console',
},
proof.authenticatedAtMs,
);
} catch {
return response(503, { code: 'authentication_unavailable' });
}
try {
const service = createLocalTaskDefinitionAdministrationService(
options.projectPolicy,
options.taskDefinitionAdministrationForCredential(
request.authenticated.credentialFence,
),
options.taskDefinitions,
options.securityAudit,
{ now },
);
const result = await service.put({
...command,
requestId: request.requestId,
principal: strongPrincipal,
});
return response(result.status === 'created' ? 201 : 200, {
status: result.status,
task: summary(result.definition),
});
} catch (error) {
if (
error instanceof TaskDefinitionConflictError ||
error instanceof TaskDefinitionAdministrationMutationConflictError ||
error instanceof
TaskDefinitionAdministrationAuthorizationFenceConflictError
) {
return response(409, { code: 'task_definition_fence_rejected' });
}
if (
error instanceof LocalTaskDefinitionAdministrationAuthenticationError
) {
return response(401, { code: 'strong_authentication_required' });
}
if (
error instanceof LocalTaskDefinitionAdministrationAuthorizationError
) {
return response(403, { code: 'forbidden' });
}
if (
error instanceof InvalidTaskDefinitionError ||
error instanceof LocalTaskDefinitionAdministrationConfigurationError
) {
return response(400, { code: 'invalid_task_definition' });
}
if (
error instanceof TaskDefinitionUnavailableError ||
error instanceof LocalTaskDefinitionAdministrationUnavailableError
) {
return response(503, { code: 'task_definition_unavailable' });
}
return response(503, { code: 'task_definition_unavailable' });
}
},
});
}
@@ -88,6 +88,15 @@ function authorization(request: IncomingMessage): string | null {
return values.length === 1 ? values[0]! : null;
}
function localPresence(request: IncomingMessage): string | null {
const values = rawHeaderValues(request, 'x-qinglong-local-presence');
if (values.length === 0) return null;
if (values.length !== 1 || values[0]!.length > 160) {
throw new TypeError('invalid_local_presence');
}
return values[0]!;
}
function hasRequestBody(request: IncomingMessage): boolean {
const transferEncoding = rawHeaderValues(request, 'transfer-encoding');
const contentLength = rawHeaderValues(request, 'content-length');
@@ -453,6 +462,16 @@ function route(
})
: null;
}
if (request.method === 'PUT') {
const taskPutMatch = TASK_READ_ROUTE_PATTERN.exec(path);
return taskPutMatch && rawQuery === undefined
? Object.freeze({
operationId: 'task.put',
projectId: taskPutMatch[1]!,
taskId: taskPutMatch[2]!,
})
: null;
}
if (request.method !== 'GET') return null;
const runAttemptLogReadMatch = RUN_ATTEMPT_LOG_READ_ROUTE_PATTERN.exec(path);
if (runAttemptLogReadMatch) {
@@ -693,10 +712,19 @@ export async function startLocalApiHttpSurface(
response.once('close', () => {
if (!response.writableFinished) abort.abort();
});
let presentedLocalPresence: string | null;
try {
presentedLocalPresence = localPresence(request);
} catch {
send(response, requestId, errorResponse(400, 'invalid_local_presence'));
request.resume();
return;
}
const admissionRequest: LocalApiAdmissionRequest = Object.freeze({
requestId,
operation: resolvedRoute,
authorization: authorization(request),
localPresence: presentedLocalPresence,
signal: abort.signal,
});
let operation: Promise<void>;