mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 11:27:31 +08:00
feat(ql3): add request-scoped console task creation
This commit is contained in:
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user