mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:32:44 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
} from '../security/audit/securityAudit';
|
||||
import type { SecuritySubject } from '../security/security';
|
||||
|
||||
export const WORKER_CREDENTIAL_STATES = ['active', 'revoked'] as const;
|
||||
export type WorkerCredentialState = (typeof WORKER_CREDENTIAL_STATES)[number];
|
||||
|
||||
export interface WorkerCredentialRecord {
|
||||
readonly credentialId: string;
|
||||
readonly version: number;
|
||||
readonly state: WorkerCredentialState;
|
||||
readonly workerId: string;
|
||||
readonly secretDigest: string;
|
||||
readonly createdAtMs: number;
|
||||
readonly notBeforeAtMs: number;
|
||||
readonly expiresAtMs: number;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialRepository {
|
||||
resolve(credentialId: string): Promise<WorkerCredentialRecord | null>;
|
||||
}
|
||||
|
||||
export type WorkerCredentialAdministrationOperation =
|
||||
| 'issue'
|
||||
| 'rotate'
|
||||
| 'revoke';
|
||||
|
||||
export interface WorkerCredentialMutationRecord {
|
||||
readonly mutationId: string;
|
||||
readonly operation: WorkerCredentialAdministrationOperation;
|
||||
readonly credentialId: string;
|
||||
readonly credentialVersion: number;
|
||||
readonly expectedPreviousVersion: number;
|
||||
readonly changedBy: SecuritySubject;
|
||||
readonly createdAtMs: number;
|
||||
}
|
||||
|
||||
export interface AppendWorkerCredentialCommand {
|
||||
readonly expectedCurrentVersion: number;
|
||||
readonly credential: WorkerCredentialRecord;
|
||||
readonly mutation: WorkerCredentialMutationRecord;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface AppendWorkerCredentialResult {
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly credential: Readonly<WorkerCredentialRecord>;
|
||||
readonly mutation: Readonly<WorkerCredentialMutationRecord>;
|
||||
}
|
||||
|
||||
export interface ResolvedWorkerCredentialMutation {
|
||||
readonly credential: Readonly<WorkerCredentialRecord>;
|
||||
readonly mutation: Readonly<WorkerCredentialMutationRecord>;
|
||||
readonly audit: Readonly<SecurityAuditRecord>;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialAdministrationRepository {
|
||||
resolveMutation(
|
||||
mutationId: string,
|
||||
): Promise<ResolvedWorkerCredentialMutation | null>;
|
||||
append(
|
||||
command: AppendWorkerCredentialCommand,
|
||||
): Promise<AppendWorkerCredentialResult>;
|
||||
}
|
||||
|
||||
export class WorkerCredentialUnavailableError extends Error {
|
||||
readonly code = 'WORKER_CREDENTIAL_UNAVAILABLE';
|
||||
constructor() {
|
||||
super('Worker credential storage is unavailable');
|
||||
this.name = 'WorkerCredentialUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerCredentialMutationConflictError extends Error {
|
||||
readonly code = 'WORKER_CREDENTIAL_MUTATION_CONFLICT';
|
||||
constructor() {
|
||||
super('Worker credential mutation conflicts with an existing fact');
|
||||
this.name = 'WorkerCredentialMutationConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerCredentialVersionConflictError extends Error {
|
||||
readonly code = 'WORKER_CREDENTIAL_VERSION_CONFLICT';
|
||||
constructor() {
|
||||
super('Worker credential version fence was rejected');
|
||||
this.name = 'WorkerCredentialVersionConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const CREDENTIAL_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
|
||||
const UUID_V4 =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
function exact(value: object, keys: readonly string[], name: string): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new TypeError(`${name} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function safeTime(name: string, value: number): number {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new RangeError(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeWorkerCredentialId(value: string): string {
|
||||
if (typeof value !== 'string' || !CREDENTIAL_ID.test(value)) {
|
||||
throw new TypeError('Worker credential ID is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeWorkerCredentialRecord(
|
||||
value: WorkerCredentialRecord,
|
||||
): Readonly<WorkerCredentialRecord> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError('Worker credential record is invalid');
|
||||
}
|
||||
exact(
|
||||
value,
|
||||
[
|
||||
'credentialId',
|
||||
'version',
|
||||
'state',
|
||||
'workerId',
|
||||
'secretDigest',
|
||||
'createdAtMs',
|
||||
'notBeforeAtMs',
|
||||
'expiresAtMs',
|
||||
],
|
||||
'Worker credential record',
|
||||
);
|
||||
normalizeWorkerCredentialId(value.credentialId);
|
||||
if (!Number.isSafeInteger(value.version) || value.version < 1) {
|
||||
throw new RangeError('Worker credential version is invalid');
|
||||
}
|
||||
if (!WORKER_CREDENTIAL_STATES.includes(value.state)) {
|
||||
throw new TypeError('Worker credential state is invalid');
|
||||
}
|
||||
if (!SAFE_ID.test(value.workerId)) {
|
||||
throw new TypeError('Worker credential worker ID is invalid');
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/.test(value.secretDigest)) {
|
||||
throw new TypeError('Worker credential digest is invalid');
|
||||
}
|
||||
const createdAtMs = safeTime('Worker credential createdAtMs', value.createdAtMs);
|
||||
const notBeforeAtMs = safeTime(
|
||||
'Worker credential notBeforeAtMs',
|
||||
value.notBeforeAtMs,
|
||||
);
|
||||
const expiresAtMs = safeTime(
|
||||
'Worker credential expiresAtMs',
|
||||
value.expiresAtMs,
|
||||
);
|
||||
if (expiresAtMs <= Math.max(createdAtMs, notBeforeAtMs)) {
|
||||
throw new RangeError('Worker credential lifetime is invalid');
|
||||
}
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
export function normalizeWorkerCredentialMutationId(value: string): string {
|
||||
if (typeof value !== 'string' || !UUID_V4.test(value)) {
|
||||
throw new TypeError('Worker credential mutation ID is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeAppendWorkerCredentialCommand(
|
||||
value: AppendWorkerCredentialCommand,
|
||||
): Readonly<AppendWorkerCredentialCommand> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError('Worker credential append command is invalid');
|
||||
}
|
||||
exact(
|
||||
value,
|
||||
['expectedCurrentVersion', 'credential', 'mutation', 'audit'],
|
||||
'Worker credential append command',
|
||||
);
|
||||
const credential = normalizeWorkerCredentialRecord(value.credential);
|
||||
const mutation = value.mutation;
|
||||
if (!mutation || typeof mutation !== 'object' || Array.isArray(mutation)) {
|
||||
throw new TypeError('Worker credential mutation is invalid');
|
||||
}
|
||||
exact(
|
||||
mutation,
|
||||
[
|
||||
'mutationId',
|
||||
'operation',
|
||||
'credentialId',
|
||||
'credentialVersion',
|
||||
'expectedPreviousVersion',
|
||||
'changedBy',
|
||||
'createdAtMs',
|
||||
],
|
||||
'Worker credential mutation',
|
||||
);
|
||||
normalizeWorkerCredentialMutationId(mutation.mutationId);
|
||||
if (!['issue', 'rotate', 'revoke'].includes(mutation.operation)) {
|
||||
throw new TypeError('Worker credential mutation operation is invalid');
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(value.expectedCurrentVersion) ||
|
||||
value.expectedCurrentVersion < 0 ||
|
||||
mutation.expectedPreviousVersion !== value.expectedCurrentVersion ||
|
||||
mutation.credentialVersion !== value.expectedCurrentVersion + 1 ||
|
||||
mutation.credentialId !== credential.credentialId ||
|
||||
mutation.createdAtMs !== credential.createdAtMs ||
|
||||
credential.state !== (mutation.operation === 'revoke' ? 'revoked' : 'active')
|
||||
) {
|
||||
throw new RangeError('Worker credential mutation fence is invalid');
|
||||
}
|
||||
if (
|
||||
!mutation.changedBy ||
|
||||
typeof mutation.changedBy !== 'object' ||
|
||||
Array.isArray(mutation.changedBy)
|
||||
) {
|
||||
throw new TypeError('Worker credential mutation actor is invalid');
|
||||
}
|
||||
exact(mutation.changedBy, ['type', 'id'], 'Worker credential mutation actor');
|
||||
if (!['user', 'system'].includes(mutation.changedBy.type)) {
|
||||
throw new TypeError('Worker credential mutation actor is invalid');
|
||||
}
|
||||
const audit = normalizeSecurityAuditRecord(value.audit);
|
||||
if (
|
||||
audit.eventId !== mutation.mutationId ||
|
||||
audit.occurredAtMs !== mutation.createdAtMs ||
|
||||
audit.operationId !== `worker_credential.${mutation.operation}` ||
|
||||
audit.projectId !== null ||
|
||||
audit.subject?.type !== mutation.changedBy.type ||
|
||||
audit.subject.id !== mutation.changedBy.id ||
|
||||
audit.outcome !== 'allowed' ||
|
||||
audit.reasons.length !== 1 ||
|
||||
audit.reasons[0] !== 'worker_credential_admin' ||
|
||||
audit.fence !== null
|
||||
) {
|
||||
throw new TypeError('Worker credential mutation audit is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
expectedCurrentVersion: value.expectedCurrentVersion,
|
||||
credential,
|
||||
mutation: Object.freeze({
|
||||
...mutation,
|
||||
changedBy: Object.freeze({ ...mutation.changedBy }),
|
||||
}),
|
||||
audit,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
normalizeAppendWorkerCredentialCommand,
|
||||
normalizeWorkerCredentialId,
|
||||
normalizeWorkerCredentialMutationId,
|
||||
type AppendWorkerCredentialCommand,
|
||||
type AppendWorkerCredentialResult,
|
||||
type ResolvedWorkerCredentialMutation,
|
||||
type WorkerCredentialAdministrationRepository,
|
||||
} from './workerCredential';
|
||||
import type {
|
||||
HeartbeatWorkerSessionCommand,
|
||||
TransitionWorkerSessionCommand,
|
||||
WorkerSessionRecord,
|
||||
WorkerSessionRepository,
|
||||
} from './workerSession';
|
||||
|
||||
export const WORKER_CREDENTIAL_DELIVERY_STATES = [
|
||||
'credential_committed',
|
||||
'published',
|
||||
'observed',
|
||||
'previous_revoked',
|
||||
] as const;
|
||||
|
||||
export type WorkerCredentialDeliveryState =
|
||||
(typeof WORKER_CREDENTIAL_DELIVERY_STATES)[number];
|
||||
|
||||
export interface WorkerCredentialDeliveryIntent {
|
||||
readonly deliveryId: string;
|
||||
readonly workerId: string;
|
||||
readonly credentialId: string;
|
||||
readonly credentialVersion: number;
|
||||
readonly previousCredentialId: string | null;
|
||||
readonly secretDigest: string;
|
||||
readonly tokenDigest: string;
|
||||
readonly deploymentTargetDigest: string;
|
||||
readonly deploymentGeneration: string;
|
||||
readonly stagedAtMs: number;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialDeliveryRecord {
|
||||
readonly deliveryId: string;
|
||||
readonly version: number;
|
||||
readonly state: WorkerCredentialDeliveryState;
|
||||
readonly workerId: string;
|
||||
readonly credentialId: string;
|
||||
readonly credentialVersion: number;
|
||||
readonly previousCredentialId: string | null;
|
||||
readonly secretDigest: string;
|
||||
readonly tokenDigest: string;
|
||||
readonly deploymentTargetDigest: string;
|
||||
readonly deploymentGeneration: string;
|
||||
readonly stagedAtMs: number;
|
||||
readonly credentialCommittedAtMs: number;
|
||||
readonly publishedAtMs: number | null;
|
||||
readonly publicationDigest: string | null;
|
||||
readonly observedAtMs: number | null;
|
||||
readonly observedSessionId: string | null;
|
||||
readonly observedSessionVersion: number | null;
|
||||
readonly previousRevokedAtMs: number | null;
|
||||
}
|
||||
|
||||
export interface CommitWorkerCredentialDeliveryCommand {
|
||||
readonly credential: AppendWorkerCredentialCommand;
|
||||
readonly delivery: WorkerCredentialDeliveryRecord;
|
||||
}
|
||||
|
||||
export interface PublishWorkerCredentialDeliveryCommand {
|
||||
readonly deliveryId: string;
|
||||
readonly expectedVersion: number;
|
||||
readonly publicationDigest: string;
|
||||
readonly publishedAtMs: number;
|
||||
}
|
||||
|
||||
export interface RevokePreviousWorkerCredentialDeliveryCommand {
|
||||
readonly credential: AppendWorkerCredentialCommand;
|
||||
readonly delivery: WorkerCredentialDeliveryRecord;
|
||||
}
|
||||
|
||||
export const MAX_WORKER_CREDENTIAL_DELIVERY_RECOVERY_PAGE_SIZE = 64;
|
||||
export const MAX_WORKER_CREDENTIAL_STAGE_DISCARD_PAGE_SIZE = 64;
|
||||
|
||||
export const WORKER_CREDENTIAL_STAGE_DISCARD_STATES = [
|
||||
'discard_authorized',
|
||||
'discarded',
|
||||
] as const;
|
||||
|
||||
export type WorkerCredentialStageDiscardState =
|
||||
(typeof WORKER_CREDENTIAL_STAGE_DISCARD_STATES)[number];
|
||||
|
||||
export interface WorkerCredentialStageDiscardRecord
|
||||
extends WorkerCredentialDeliveryIntent {
|
||||
readonly version: number;
|
||||
readonly state: WorkerCredentialStageDiscardState;
|
||||
readonly authorizedAtMs: number;
|
||||
readonly discardedAtMs: number | null;
|
||||
}
|
||||
|
||||
export interface MarkWorkerCredentialStageDiscardedCommand {
|
||||
readonly deliveryId: string;
|
||||
readonly expectedVersion: number;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialStageDiscardRecoveryPage {
|
||||
readonly observedAtMs: number;
|
||||
readonly discards: readonly Readonly<WorkerCredentialStageDiscardRecord>[];
|
||||
readonly truncated: boolean;
|
||||
readonly nextCursor?: string;
|
||||
}
|
||||
|
||||
const WORKER_CREDENTIAL_DELIVERY_TOKEN_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/worker-credential-delivery-token@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
export function workerCredentialDeliveryTokenDigest(
|
||||
token: Uint8Array,
|
||||
): string {
|
||||
if (
|
||||
!(token instanceof Uint8Array) ||
|
||||
token.byteLength < 1 ||
|
||||
token.byteLength > 256
|
||||
) {
|
||||
throw new TypeError('Worker credential delivery token bytes are invalid');
|
||||
}
|
||||
return createHash('sha256')
|
||||
.update(WORKER_CREDENTIAL_DELIVERY_TOKEN_DIGEST_DOMAIN)
|
||||
.update(token)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export interface WorkerCredentialDeliveryRecoveryPage {
|
||||
readonly observedAtMs: number;
|
||||
readonly deliveries: readonly Readonly<WorkerCredentialDeliveryRecord>[];
|
||||
readonly truncated: boolean;
|
||||
readonly nextCursor?: string;
|
||||
}
|
||||
|
||||
export interface AuthenticatedWorkerCredentialIdentity {
|
||||
readonly workerId: string;
|
||||
readonly credentialId: string;
|
||||
readonly credentialVersion: number;
|
||||
}
|
||||
|
||||
export interface AuthenticatedWorkerSessionRepository
|
||||
extends WorkerSessionRepository {
|
||||
heartbeatAuthenticated(
|
||||
command: HeartbeatWorkerSessionCommand,
|
||||
credential: AuthenticatedWorkerCredentialIdentity,
|
||||
): Promise<WorkerSessionRecord>;
|
||||
transitionAuthenticated(
|
||||
command: TransitionWorkerSessionCommand,
|
||||
credential: AuthenticatedWorkerCredentialIdentity,
|
||||
): Promise<WorkerSessionRecord>;
|
||||
}
|
||||
|
||||
export interface ResolvedWorkerCredentialDelivery
|
||||
extends ResolvedWorkerCredentialMutation {
|
||||
readonly delivery: Readonly<WorkerCredentialDeliveryRecord>;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialDeliveryAdministrationRepository
|
||||
extends WorkerCredentialAdministrationRepository {
|
||||
resolveDelivery(
|
||||
deliveryId: string,
|
||||
): Promise<Readonly<WorkerCredentialDeliveryRecord> | null>;
|
||||
resolveDelivered(
|
||||
deliveryId: string,
|
||||
): Promise<ResolvedWorkerCredentialDelivery | null>;
|
||||
commitDelivered(
|
||||
command: CommitWorkerCredentialDeliveryCommand,
|
||||
): Promise<AppendWorkerCredentialResult>;
|
||||
markPublished(
|
||||
command: PublishWorkerCredentialDeliveryCommand,
|
||||
): Promise<Readonly<WorkerCredentialDeliveryRecord>>;
|
||||
listRecoveryPage(options?: Readonly<{
|
||||
afterDeliveryId?: string;
|
||||
limit?: number;
|
||||
}>): Promise<Readonly<WorkerCredentialDeliveryRecoveryPage>>;
|
||||
revokePreviousDelivered(
|
||||
command: RevokePreviousWorkerCredentialDeliveryCommand,
|
||||
): Promise<AppendWorkerCredentialResult>;
|
||||
resolveStageDiscard(
|
||||
deliveryId: string,
|
||||
): Promise<Readonly<WorkerCredentialStageDiscardRecord> | null>;
|
||||
authorizeStageDiscard(
|
||||
intent: WorkerCredentialDeliveryIntent,
|
||||
): Promise<Readonly<WorkerCredentialStageDiscardRecord>>;
|
||||
markStageDiscarded(
|
||||
command: MarkWorkerCredentialStageDiscardedCommand,
|
||||
): Promise<Readonly<WorkerCredentialStageDiscardRecord>>;
|
||||
listStageDiscardRecoveryPage(options?: Readonly<{
|
||||
afterDeliveryId?: string;
|
||||
limit?: number;
|
||||
}>): Promise<Readonly<WorkerCredentialStageDiscardRecoveryPage>>;
|
||||
}
|
||||
|
||||
export class WorkerCredentialDeliveryConflictError extends Error {
|
||||
readonly code = 'WORKER_CREDENTIAL_DELIVERY_CONFLICT';
|
||||
constructor() {
|
||||
super('Worker credential delivery conflicts with an existing fact');
|
||||
this.name = 'WorkerCredentialDeliveryConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerCredentialDeliveryUnavailableError extends Error {
|
||||
readonly code = 'WORKER_CREDENTIAL_DELIVERY_UNAVAILABLE';
|
||||
constructor() {
|
||||
super('Worker credential delivery is unavailable');
|
||||
this.name = 'WorkerCredentialDeliveryUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const SHA256 = /^[0-9a-f]{64}$/;
|
||||
const UUID_V7 =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
function exact(value: object, keys: readonly string[], name: string): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new TypeError(`${name} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function time(name: string, value: number): number {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new RangeError(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableTime(name: string, value: number | null): number | null {
|
||||
return value === null ? null : time(name, value);
|
||||
}
|
||||
|
||||
function digest(name: string, value: string): string {
|
||||
if (typeof value !== 'string' || !SHA256.test(value)) {
|
||||
throw new TypeError(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeAuthenticatedWorkerCredentialIdentity(
|
||||
value: AuthenticatedWorkerCredentialIdentity,
|
||||
): Readonly<AuthenticatedWorkerCredentialIdentity> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError('Authenticated Worker credential identity is invalid');
|
||||
}
|
||||
exact(
|
||||
value,
|
||||
['workerId', 'credentialId', 'credentialVersion'],
|
||||
'Authenticated Worker credential identity',
|
||||
);
|
||||
if (typeof value.workerId !== 'string' || !SAFE_ID.test(value.workerId)) {
|
||||
throw new TypeError('Authenticated Worker identity is invalid');
|
||||
}
|
||||
normalizeWorkerCredentialId(value.credentialId);
|
||||
if (
|
||||
!Number.isSafeInteger(value.credentialVersion) ||
|
||||
value.credentialVersion < 1 ||
|
||||
value.credentialVersion > 2_147_483_647
|
||||
) {
|
||||
throw new RangeError('Authenticated Worker credential version is invalid');
|
||||
}
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
export function normalizeWorkerCredentialDeliveryIntent(
|
||||
value: WorkerCredentialDeliveryIntent,
|
||||
): Readonly<WorkerCredentialDeliveryIntent> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError('Worker credential delivery intent is invalid');
|
||||
}
|
||||
exact(
|
||||
value,
|
||||
[
|
||||
'deliveryId',
|
||||
'workerId',
|
||||
'credentialId',
|
||||
'credentialVersion',
|
||||
'previousCredentialId',
|
||||
'secretDigest',
|
||||
'tokenDigest',
|
||||
'deploymentTargetDigest',
|
||||
'deploymentGeneration',
|
||||
'stagedAtMs',
|
||||
],
|
||||
'Worker credential delivery intent',
|
||||
);
|
||||
normalizeWorkerCredentialMutationId(value.deliveryId);
|
||||
normalizeWorkerCredentialId(value.credentialId);
|
||||
if (value.previousCredentialId !== null) {
|
||||
normalizeWorkerCredentialId(value.previousCredentialId);
|
||||
if (value.previousCredentialId === value.credentialId) {
|
||||
throw new TypeError('Worker credential delivery requires a new credential ID');
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof value.workerId !== 'string' ||
|
||||
!SAFE_ID.test(value.workerId) ||
|
||||
typeof value.deploymentGeneration !== 'string' ||
|
||||
!SAFE_ID.test(value.deploymentGeneration) ||
|
||||
value.credentialVersion !== 1
|
||||
) {
|
||||
throw new TypeError('Worker credential delivery intent identity is invalid');
|
||||
}
|
||||
digest('Worker credential delivery secret digest', value.secretDigest);
|
||||
digest('Worker credential delivery token digest', value.tokenDigest);
|
||||
digest(
|
||||
'Worker credential delivery deployment target digest',
|
||||
value.deploymentTargetDigest,
|
||||
);
|
||||
time('Worker credential delivery stagedAtMs', value.stagedAtMs);
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
export function normalizeWorkerCredentialStageDiscardRecord(
|
||||
value: WorkerCredentialStageDiscardRecord,
|
||||
): Readonly<WorkerCredentialStageDiscardRecord> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError('Worker credential stage discard record is invalid');
|
||||
}
|
||||
exact(
|
||||
value,
|
||||
[
|
||||
'deliveryId',
|
||||
'version',
|
||||
'state',
|
||||
'workerId',
|
||||
'credentialId',
|
||||
'credentialVersion',
|
||||
'previousCredentialId',
|
||||
'secretDigest',
|
||||
'tokenDigest',
|
||||
'deploymentTargetDigest',
|
||||
'deploymentGeneration',
|
||||
'stagedAtMs',
|
||||
'authorizedAtMs',
|
||||
'discardedAtMs',
|
||||
],
|
||||
'Worker credential stage discard record',
|
||||
);
|
||||
const intent = normalizeWorkerCredentialDeliveryIntent({
|
||||
deliveryId: value.deliveryId,
|
||||
workerId: value.workerId,
|
||||
credentialId: value.credentialId,
|
||||
credentialVersion: value.credentialVersion,
|
||||
previousCredentialId: value.previousCredentialId,
|
||||
secretDigest: value.secretDigest,
|
||||
tokenDigest: value.tokenDigest,
|
||||
deploymentTargetDigest: value.deploymentTargetDigest,
|
||||
deploymentGeneration: value.deploymentGeneration,
|
||||
stagedAtMs: value.stagedAtMs,
|
||||
});
|
||||
const authorizedAtMs = time(
|
||||
'Worker credential stage discard authorization',
|
||||
value.authorizedAtMs,
|
||||
);
|
||||
const discardedAtMs = nullableTime(
|
||||
'Worker credential stage discard completion',
|
||||
value.discardedAtMs,
|
||||
);
|
||||
if (
|
||||
!WORKER_CREDENTIAL_STAGE_DISCARD_STATES.includes(value.state) ||
|
||||
value.version !==
|
||||
WORKER_CREDENTIAL_STAGE_DISCARD_STATES.indexOf(value.state) + 1 ||
|
||||
(value.state === 'discard_authorized' && discardedAtMs !== null) ||
|
||||
(value.state === 'discarded' &&
|
||||
(discardedAtMs === null || discardedAtMs < authorizedAtMs))
|
||||
) {
|
||||
throw new TypeError('Worker credential stage discard state is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
...intent,
|
||||
version: value.version,
|
||||
state: value.state,
|
||||
authorizedAtMs,
|
||||
discardedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeMarkWorkerCredentialStageDiscardedCommand(
|
||||
value: MarkWorkerCredentialStageDiscardedCommand,
|
||||
): Readonly<MarkWorkerCredentialStageDiscardedCommand> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError('Worker credential stage discard completion is invalid');
|
||||
}
|
||||
exact(
|
||||
value,
|
||||
['deliveryId', 'expectedVersion'],
|
||||
'Worker credential stage discard completion',
|
||||
);
|
||||
normalizeWorkerCredentialMutationId(value.deliveryId);
|
||||
if (value.expectedVersion !== 1) {
|
||||
throw new RangeError('Worker credential stage discard fence is invalid');
|
||||
}
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
export function normalizeWorkerCredentialStageDiscardRecoveryPage(
|
||||
value: WorkerCredentialStageDiscardRecoveryPage,
|
||||
): Readonly<WorkerCredentialStageDiscardRecoveryPage> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError('Worker credential stage discard recovery page is invalid');
|
||||
}
|
||||
exact(
|
||||
value,
|
||||
[
|
||||
'observedAtMs',
|
||||
'discards',
|
||||
'truncated',
|
||||
...(value.nextCursor === undefined ? [] : ['nextCursor']),
|
||||
],
|
||||
'Worker credential stage discard recovery page',
|
||||
);
|
||||
time('Worker credential stage discard recovery observation', value.observedAtMs);
|
||||
if (
|
||||
!Array.isArray(value.discards) ||
|
||||
value.discards.length > MAX_WORKER_CREDENTIAL_STAGE_DISCARD_PAGE_SIZE ||
|
||||
typeof value.truncated !== 'boolean'
|
||||
) {
|
||||
throw new TypeError('Worker credential stage discard recovery bound is invalid');
|
||||
}
|
||||
const discards = value.discards.map((record) =>
|
||||
normalizeWorkerCredentialStageDiscardRecord(record));
|
||||
for (let index = 0; index < discards.length; index += 1) {
|
||||
if (
|
||||
discards[index]!.state !== 'discard_authorized' ||
|
||||
value.observedAtMs < discards[index]!.authorizedAtMs ||
|
||||
(index > 0 && discards[index - 1]!.deliveryId >= discards[index]!.deliveryId)
|
||||
) {
|
||||
throw new TypeError('Worker credential stage discard recovery order is invalid');
|
||||
}
|
||||
}
|
||||
const last = discards.at(-1);
|
||||
if (
|
||||
value.truncated !== (value.nextCursor !== undefined) ||
|
||||
(value.nextCursor !== undefined &&
|
||||
(!last || value.nextCursor !== last.deliveryId))
|
||||
) {
|
||||
throw new TypeError('Worker credential stage discard recovery cursor is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
observedAtMs: value.observedAtMs,
|
||||
discards: Object.freeze(discards),
|
||||
truncated: value.truncated,
|
||||
...(value.nextCursor === undefined
|
||||
? {}
|
||||
: { nextCursor: value.nextCursor }),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeWorkerCredentialDeliveryRecord(
|
||||
value: WorkerCredentialDeliveryRecord,
|
||||
): Readonly<WorkerCredentialDeliveryRecord> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError('Worker credential delivery record is invalid');
|
||||
}
|
||||
exact(
|
||||
value,
|
||||
[
|
||||
'deliveryId',
|
||||
'version',
|
||||
'state',
|
||||
'workerId',
|
||||
'credentialId',
|
||||
'credentialVersion',
|
||||
'previousCredentialId',
|
||||
'secretDigest',
|
||||
'tokenDigest',
|
||||
'deploymentTargetDigest',
|
||||
'deploymentGeneration',
|
||||
'stagedAtMs',
|
||||
'credentialCommittedAtMs',
|
||||
'publishedAtMs',
|
||||
'publicationDigest',
|
||||
'observedAtMs',
|
||||
'observedSessionId',
|
||||
'observedSessionVersion',
|
||||
'previousRevokedAtMs',
|
||||
],
|
||||
'Worker credential delivery record',
|
||||
);
|
||||
normalizeWorkerCredentialMutationId(value.deliveryId);
|
||||
normalizeWorkerCredentialDeliveryIntent({
|
||||
deliveryId: value.deliveryId,
|
||||
workerId: value.workerId,
|
||||
credentialId: value.credentialId,
|
||||
credentialVersion: value.credentialVersion,
|
||||
previousCredentialId: value.previousCredentialId,
|
||||
secretDigest: value.secretDigest,
|
||||
tokenDigest: value.tokenDigest,
|
||||
deploymentTargetDigest: value.deploymentTargetDigest,
|
||||
deploymentGeneration: value.deploymentGeneration,
|
||||
stagedAtMs: value.stagedAtMs,
|
||||
});
|
||||
if (!WORKER_CREDENTIAL_DELIVERY_STATES.includes(value.state)) {
|
||||
throw new TypeError('Worker credential delivery state is invalid');
|
||||
}
|
||||
const stagedAtMs = time(
|
||||
'Worker credential delivery stagedAtMs',
|
||||
value.stagedAtMs,
|
||||
);
|
||||
const credentialCommittedAtMs = time(
|
||||
'Worker credential delivery credentialCommittedAtMs',
|
||||
value.credentialCommittedAtMs,
|
||||
);
|
||||
const publishedAtMs = nullableTime(
|
||||
'Worker credential delivery publishedAtMs',
|
||||
value.publishedAtMs,
|
||||
);
|
||||
const observedAtMs = nullableTime(
|
||||
'Worker credential delivery observedAtMs',
|
||||
value.observedAtMs,
|
||||
);
|
||||
const previousRevokedAtMs = nullableTime(
|
||||
'Worker credential delivery previousRevokedAtMs',
|
||||
value.previousRevokedAtMs,
|
||||
);
|
||||
if (credentialCommittedAtMs < stagedAtMs) {
|
||||
throw new RangeError('Worker credential delivery commit precedes staging');
|
||||
}
|
||||
const expectedVersion =
|
||||
WORKER_CREDENTIAL_DELIVERY_STATES.indexOf(value.state) + 1;
|
||||
if (value.version !== expectedVersion) {
|
||||
throw new RangeError('Worker credential delivery version is invalid');
|
||||
}
|
||||
const published = expectedVersion >= 2;
|
||||
const observed = expectedVersion >= 3;
|
||||
const revoked = expectedVersion >= 4;
|
||||
if (
|
||||
published !== (publishedAtMs !== null) ||
|
||||
published !== (value.publicationDigest !== null) ||
|
||||
observed !== (observedAtMs !== null) ||
|
||||
observed !== (value.observedSessionId !== null) ||
|
||||
observed !== (value.observedSessionVersion !== null) ||
|
||||
revoked !== (previousRevokedAtMs !== null)
|
||||
) {
|
||||
throw new TypeError('Worker credential delivery state evidence is incomplete');
|
||||
}
|
||||
if (value.publicationDigest !== null) {
|
||||
digest(
|
||||
'Worker credential delivery publication digest',
|
||||
value.publicationDigest,
|
||||
);
|
||||
}
|
||||
if (
|
||||
value.observedSessionId !== null &&
|
||||
!UUID_V7.test(value.observedSessionId)
|
||||
) {
|
||||
throw new TypeError('Worker credential delivery observed Session is invalid');
|
||||
}
|
||||
if (
|
||||
value.observedSessionVersion !== null &&
|
||||
(!Number.isSafeInteger(value.observedSessionVersion) ||
|
||||
value.observedSessionVersion < 1)
|
||||
) {
|
||||
throw new RangeError('Worker credential delivery observed version is invalid');
|
||||
}
|
||||
if (
|
||||
(publishedAtMs !== null && publishedAtMs < credentialCommittedAtMs) ||
|
||||
(observedAtMs !== null &&
|
||||
(publishedAtMs === null || observedAtMs < publishedAtMs)) ||
|
||||
(previousRevokedAtMs !== null &&
|
||||
(observedAtMs === null || previousRevokedAtMs < observedAtMs))
|
||||
) {
|
||||
throw new RangeError('Worker credential delivery evidence time is invalid');
|
||||
}
|
||||
if (revoked && value.previousCredentialId === null) {
|
||||
throw new TypeError('Worker credential delivery has no previous credential');
|
||||
}
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
export function normalizeCommitWorkerCredentialDeliveryCommand(
|
||||
value: CommitWorkerCredentialDeliveryCommand,
|
||||
): Readonly<CommitWorkerCredentialDeliveryCommand> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError('Worker credential delivery commit is invalid');
|
||||
}
|
||||
exact(value, ['credential', 'delivery'], 'Worker credential delivery commit');
|
||||
const credential = normalizeAppendWorkerCredentialCommand(value.credential);
|
||||
const delivery = normalizeWorkerCredentialDeliveryRecord(value.delivery);
|
||||
if (
|
||||
credential.mutation.operation !== 'issue' ||
|
||||
credential.expectedCurrentVersion !== 0 ||
|
||||
delivery.state !== 'credential_committed' ||
|
||||
delivery.deliveryId !== credential.mutation.mutationId ||
|
||||
delivery.credentialId !== credential.credential.credentialId ||
|
||||
delivery.credentialVersion !== credential.credential.version ||
|
||||
delivery.workerId !== credential.credential.workerId ||
|
||||
delivery.secretDigest !== credential.credential.secretDigest ||
|
||||
delivery.credentialCommittedAtMs !== credential.credential.createdAtMs
|
||||
) {
|
||||
throw new TypeError('Worker credential delivery commit authority is invalid');
|
||||
}
|
||||
return Object.freeze({ credential, delivery });
|
||||
}
|
||||
|
||||
export function normalizePublishWorkerCredentialDeliveryCommand(
|
||||
value: PublishWorkerCredentialDeliveryCommand,
|
||||
): Readonly<PublishWorkerCredentialDeliveryCommand> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError('Worker credential delivery publication is invalid');
|
||||
}
|
||||
exact(
|
||||
value,
|
||||
['deliveryId', 'expectedVersion', 'publicationDigest', 'publishedAtMs'],
|
||||
'Worker credential delivery publication',
|
||||
);
|
||||
normalizeWorkerCredentialMutationId(value.deliveryId);
|
||||
if (value.expectedVersion !== 1) {
|
||||
throw new RangeError('Worker credential delivery publication fence is invalid');
|
||||
}
|
||||
digest(
|
||||
'Worker credential delivery publication digest',
|
||||
value.publicationDigest,
|
||||
);
|
||||
time('Worker credential delivery publishedAtMs', value.publishedAtMs);
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
export function normalizeRevokePreviousWorkerCredentialDeliveryCommand(
|
||||
value: RevokePreviousWorkerCredentialDeliveryCommand,
|
||||
): Readonly<RevokePreviousWorkerCredentialDeliveryCommand> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError('Worker credential delivery revoke is invalid');
|
||||
}
|
||||
exact(value, ['credential', 'delivery'], 'Worker credential delivery revoke');
|
||||
const credential = normalizeAppendWorkerCredentialCommand(value.credential);
|
||||
const delivery = normalizeWorkerCredentialDeliveryRecord(value.delivery);
|
||||
if (
|
||||
credential.mutation.operation !== 'revoke' ||
|
||||
credential.expectedCurrentVersion !== 1 ||
|
||||
credential.credential.version !== 2 ||
|
||||
credential.credential.state !== 'revoked' ||
|
||||
delivery.version !== 4 ||
|
||||
delivery.state !== 'previous_revoked' ||
|
||||
delivery.previousCredentialId === null ||
|
||||
delivery.previousCredentialId !== credential.credential.credentialId ||
|
||||
delivery.workerId !== credential.credential.workerId ||
|
||||
delivery.previousRevokedAtMs !== credential.credential.createdAtMs
|
||||
) {
|
||||
throw new TypeError('Worker credential delivery revoke authority is invalid');
|
||||
}
|
||||
return Object.freeze({ credential, delivery });
|
||||
}
|
||||
|
||||
export function normalizeWorkerCredentialDeliveryRecoveryPage(
|
||||
value: WorkerCredentialDeliveryRecoveryPage,
|
||||
): Readonly<WorkerCredentialDeliveryRecoveryPage> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError('Worker credential delivery recovery page is invalid');
|
||||
}
|
||||
const expected = [
|
||||
'observedAtMs',
|
||||
'deliveries',
|
||||
'truncated',
|
||||
...(value.nextCursor === undefined ? [] : ['nextCursor']),
|
||||
];
|
||||
exact(value, expected, 'Worker credential delivery recovery page');
|
||||
time('Worker credential delivery recovery observation', value.observedAtMs);
|
||||
if (
|
||||
!Array.isArray(value.deliveries) ||
|
||||
value.deliveries.length > MAX_WORKER_CREDENTIAL_DELIVERY_RECOVERY_PAGE_SIZE ||
|
||||
typeof value.truncated !== 'boolean'
|
||||
) {
|
||||
throw new TypeError('Worker credential delivery recovery page bound is invalid');
|
||||
}
|
||||
const deliveries = value.deliveries.map(
|
||||
(delivery) => normalizeWorkerCredentialDeliveryRecord(delivery),
|
||||
);
|
||||
for (let index = 0; index < deliveries.length; index += 1) {
|
||||
const delivery = deliveries[index]!;
|
||||
if (
|
||||
delivery.state === 'previous_revoked' ||
|
||||
(delivery.state === 'observed' && delivery.previousCredentialId === null) ||
|
||||
value.observedAtMs <
|
||||
(delivery.observedAtMs ??
|
||||
delivery.publishedAtMs ??
|
||||
delivery.credentialCommittedAtMs) ||
|
||||
(index > 0 && deliveries[index - 1]!.deliveryId >= delivery.deliveryId)
|
||||
) {
|
||||
throw new TypeError('Worker credential delivery recovery page order is invalid');
|
||||
}
|
||||
}
|
||||
const last = deliveries.at(-1);
|
||||
if (
|
||||
value.truncated !== (value.nextCursor !== undefined) ||
|
||||
(value.nextCursor !== undefined &&
|
||||
(!last || value.nextCursor !== last.deliveryId))
|
||||
) {
|
||||
throw new TypeError('Worker credential delivery recovery cursor is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
observedAtMs: value.observedAtMs,
|
||||
deliveries: Object.freeze(deliveries),
|
||||
truncated: value.truncated,
|
||||
...(value.nextCursor === undefined
|
||||
? {}
|
||||
: { nextCursor: value.nextCursor }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
normalizeWorkerCredentialId,
|
||||
normalizeWorkerCredentialMutationId,
|
||||
} from './workerCredential';
|
||||
import type { SecuritySubject } from '../security/security';
|
||||
|
||||
export const WORKER_CREDENTIAL_MANAGEMENT_PLAN_SCHEMA =
|
||||
'qinglong/worker-credential-management-plan@v1' as const;
|
||||
export const WORKER_CREDENTIAL_MANAGEMENT_ACTIONS = [
|
||||
'issue',
|
||||
'rotate',
|
||||
] as const;
|
||||
export const MAX_WORKER_CREDENTIAL_MANAGEMENT_PLAN_LIFETIME_MS =
|
||||
15 * 60 * 1000;
|
||||
export const MAX_WORKER_CREDENTIAL_MANAGEMENT_PLAN_BYTES = 16 * 1024;
|
||||
|
||||
export type WorkerCredentialManagementAction =
|
||||
(typeof WORKER_CREDENTIAL_MANAGEMENT_ACTIONS)[number];
|
||||
|
||||
export interface WorkerCredentialManagementTarget {
|
||||
readonly deliveryId: string;
|
||||
readonly workerId: string;
|
||||
readonly credentialId: string;
|
||||
readonly previousCredentialId: string | null;
|
||||
readonly credentialNotBeforeAtMs: number;
|
||||
readonly credentialExpiresAtMs: number;
|
||||
readonly deploymentTargetDigest: string;
|
||||
readonly deploymentGeneration: string;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialManagementPlan {
|
||||
readonly schema: typeof WORKER_CREDENTIAL_MANAGEMENT_PLAN_SCHEMA;
|
||||
readonly actionRef: string;
|
||||
readonly authorityProjectId: string;
|
||||
readonly action: WorkerCredentialManagementAction;
|
||||
readonly target: Readonly<WorkerCredentialManagementTarget>;
|
||||
readonly requestedBy: Readonly<SecuritySubject>;
|
||||
readonly plannedAtMs: number;
|
||||
readonly expiresAtMs: number;
|
||||
readonly planDigest: string;
|
||||
readonly previewDigest: string;
|
||||
}
|
||||
|
||||
export interface CreateWorkerCredentialManagementPlanInput {
|
||||
readonly actionRef: string;
|
||||
readonly authorityProjectId: string;
|
||||
readonly action: WorkerCredentialManagementAction;
|
||||
readonly target: WorkerCredentialManagementTarget;
|
||||
readonly requestedBy: SecuritySubject;
|
||||
readonly plannedAtMs: number;
|
||||
readonly expiresAtMs: number;
|
||||
}
|
||||
|
||||
export interface CreateWorkerCredentialManagementPlanResult {
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly plan: Readonly<WorkerCredentialManagementPlan>;
|
||||
}
|
||||
|
||||
export interface WorkerCredentialManagementPlanRepository {
|
||||
create(
|
||||
plan: Readonly<WorkerCredentialManagementPlan>,
|
||||
): Promise<Readonly<CreateWorkerCredentialManagementPlanResult>>;
|
||||
findByActionRef(
|
||||
actionRef: string,
|
||||
): Promise<Readonly<WorkerCredentialManagementPlan> | null>;
|
||||
}
|
||||
|
||||
export class InvalidWorkerCredentialManagementPlanError extends TypeError {
|
||||
readonly code = 'WORKER_CREDENTIAL_MANAGEMENT_PLAN_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Worker credential management plan is invalid: ${message}`);
|
||||
this.name = 'InvalidWorkerCredentialManagementPlanError';
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerCredentialManagementPlanConflictError extends Error {
|
||||
readonly code = 'WORKER_CREDENTIAL_MANAGEMENT_PLAN_CONFLICT';
|
||||
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Worker credential management plan conflicts with durable state: ${message}`,
|
||||
);
|
||||
this.name = 'WorkerCredentialManagementPlanConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerCredentialManagementPlanUnavailableError extends Error {
|
||||
readonly code = 'WORKER_CREDENTIAL_MANAGEMENT_PLAN_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Worker credential management plan is unavailable', options);
|
||||
this.name = 'WorkerCredentialManagementPlanUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
const ACTION_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/;
|
||||
const PROJECT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const WORKER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const GENERATION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const SUBJECT_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const PLAN_DIGEST_DOMAIN =
|
||||
'qinglong/worker-credential-management-plan-digest@v1\0';
|
||||
const PREVIEW_DIGEST_DOMAIN =
|
||||
'qinglong/worker-credential-management-preview-digest@v1\0';
|
||||
const MAX_CREDENTIAL_LIFETIME_MS = 2 * 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidWorkerCredentialManagementPlanError(message);
|
||||
}
|
||||
|
||||
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)
|
||||
) {
|
||||
return invalid(`${label} must be an object`);
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
if (
|
||||
Object.values(descriptors).some(
|
||||
(descriptor) =>
|
||||
descriptor.get !== undefined ||
|
||||
descriptor.set !== undefined ||
|
||||
descriptor.enumerable !== true,
|
||||
)
|
||||
) {
|
||||
return invalid(`${label} must contain enumerable data properties`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: object,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const keys = Reflect.ownKeys(value);
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
keys.some((key) => typeof key !== 'string') ||
|
||||
keys.length !== canonical.length ||
|
||||
keys
|
||||
.map(String)
|
||||
.sort()
|
||||
.some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
invalid(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function timestamp(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function identifier(
|
||||
value: unknown,
|
||||
pattern: RegExp,
|
||||
label: string,
|
||||
): string {
|
||||
if (typeof value !== 'string' || !pattern.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function credentialId(value: unknown, label: string): string {
|
||||
try {
|
||||
return normalizeWorkerCredentialId(value as string);
|
||||
} catch {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function deliveryId(value: unknown): string {
|
||||
try {
|
||||
return normalizeWorkerCredentialMutationId(value as string);
|
||||
} catch {
|
||||
return invalid('deliveryId is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function userSubject(value: SecuritySubject): Readonly<SecuritySubject> {
|
||||
const candidate = record(value, 'requestedBy');
|
||||
exactKeys(candidate, ['id', 'type'], 'requestedBy');
|
||||
if (
|
||||
value.type !== 'user' ||
|
||||
typeof value.id !== 'string' ||
|
||||
value.id.length < 1 ||
|
||||
Buffer.byteLength(value.id, 'utf8') > 255 ||
|
||||
SUBJECT_CONTROL_PATTERN.test(value.id)
|
||||
) {
|
||||
return invalid('requestedBy must be a User subject');
|
||||
}
|
||||
return Object.freeze({ type: 'user', id: value.id });
|
||||
}
|
||||
|
||||
function target(
|
||||
value: WorkerCredentialManagementTarget,
|
||||
action: WorkerCredentialManagementAction,
|
||||
plannedAtMs: number,
|
||||
): Readonly<WorkerCredentialManagementTarget> {
|
||||
const candidate = record(value, 'target');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'credentialExpiresAtMs',
|
||||
'credentialId',
|
||||
'credentialNotBeforeAtMs',
|
||||
'deliveryId',
|
||||
'deploymentGeneration',
|
||||
'deploymentTargetDigest',
|
||||
'previousCredentialId',
|
||||
'workerId',
|
||||
],
|
||||
'target',
|
||||
);
|
||||
const normalizedCredentialId = credentialId(
|
||||
value.credentialId,
|
||||
'credentialId',
|
||||
);
|
||||
const previousCredentialId =
|
||||
value.previousCredentialId === null
|
||||
? null
|
||||
: credentialId(value.previousCredentialId, 'previousCredentialId');
|
||||
const credentialNotBeforeAtMs = timestamp(
|
||||
value.credentialNotBeforeAtMs,
|
||||
'credentialNotBeforeAtMs',
|
||||
);
|
||||
const credentialExpiresAtMs = timestamp(
|
||||
value.credentialExpiresAtMs,
|
||||
'credentialExpiresAtMs',
|
||||
);
|
||||
if (
|
||||
(action === 'issue' && previousCredentialId !== null) ||
|
||||
(action === 'rotate' && previousCredentialId === null) ||
|
||||
previousCredentialId === normalizedCredentialId
|
||||
) {
|
||||
return invalid('action and previousCredentialId do not agree');
|
||||
}
|
||||
if (
|
||||
credentialNotBeforeAtMs < plannedAtMs ||
|
||||
credentialExpiresAtMs <= credentialNotBeforeAtMs ||
|
||||
credentialExpiresAtMs - credentialNotBeforeAtMs >
|
||||
MAX_CREDENTIAL_LIFETIME_MS
|
||||
) {
|
||||
return invalid('credential lifetime is invalid');
|
||||
}
|
||||
if (
|
||||
typeof value.deploymentTargetDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(value.deploymentTargetDigest)
|
||||
) {
|
||||
return invalid('deploymentTargetDigest is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
deliveryId: deliveryId(value.deliveryId),
|
||||
workerId: identifier(value.workerId, WORKER_ID_PATTERN, 'workerId'),
|
||||
credentialId: normalizedCredentialId,
|
||||
previousCredentialId,
|
||||
credentialNotBeforeAtMs,
|
||||
credentialExpiresAtMs,
|
||||
deploymentTargetDigest: value.deploymentTargetDigest,
|
||||
deploymentGeneration: identifier(
|
||||
value.deploymentGeneration,
|
||||
GENERATION_PATTERN,
|
||||
'deploymentGeneration',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
type UnsignedPlan = Omit<
|
||||
WorkerCredentialManagementPlan,
|
||||
'planDigest' | 'previewDigest'
|
||||
>;
|
||||
|
||||
function planFields(value: UnsignedPlan): object {
|
||||
return {
|
||||
schema: value.schema,
|
||||
actionRef: value.actionRef,
|
||||
authorityProjectId: value.authorityProjectId,
|
||||
action: value.action,
|
||||
target: value.target,
|
||||
requestedBy: value.requestedBy,
|
||||
plannedAtMs: value.plannedAtMs,
|
||||
expiresAtMs: value.expiresAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
function previewFields(value: UnsignedPlan): object {
|
||||
return {
|
||||
schema: value.schema,
|
||||
authorityProjectId: value.authorityProjectId,
|
||||
action: value.action,
|
||||
target: value.target,
|
||||
};
|
||||
}
|
||||
|
||||
function digest(domain: string, value: object): string {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function workerCredentialManagementPlanDigest(
|
||||
value: UnsignedPlan,
|
||||
): string {
|
||||
return digest(PLAN_DIGEST_DOMAIN, planFields(value));
|
||||
}
|
||||
|
||||
export function workerCredentialManagementPreviewDigest(
|
||||
value: UnsignedPlan,
|
||||
): string {
|
||||
return digest(PREVIEW_DIGEST_DOMAIN, previewFields(value));
|
||||
}
|
||||
|
||||
function normalizeUnsigned(value: UnsignedPlan): UnsignedPlan {
|
||||
if (value.schema !== WORKER_CREDENTIAL_MANAGEMENT_PLAN_SCHEMA) {
|
||||
return invalid('schema is invalid');
|
||||
}
|
||||
if (!WORKER_CREDENTIAL_MANAGEMENT_ACTIONS.includes(value.action)) {
|
||||
return invalid('action is invalid');
|
||||
}
|
||||
const plannedAtMs = timestamp(value.plannedAtMs, 'plannedAtMs');
|
||||
const expiresAtMs = timestamp(value.expiresAtMs, 'expiresAtMs');
|
||||
if (
|
||||
expiresAtMs <= plannedAtMs ||
|
||||
expiresAtMs - plannedAtMs >
|
||||
MAX_WORKER_CREDENTIAL_MANAGEMENT_PLAN_LIFETIME_MS
|
||||
) {
|
||||
return invalid('plan lifetime is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schema: WORKER_CREDENTIAL_MANAGEMENT_PLAN_SCHEMA,
|
||||
actionRef: identifier(value.actionRef, ACTION_REF_PATTERN, 'actionRef'),
|
||||
authorityProjectId: identifier(
|
||||
value.authorityProjectId,
|
||||
PROJECT_ID_PATTERN,
|
||||
'authorityProjectId',
|
||||
),
|
||||
action: value.action,
|
||||
target: target(value.target, value.action, plannedAtMs),
|
||||
requestedBy: userSubject(value.requestedBy),
|
||||
plannedAtMs,
|
||||
expiresAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function boundedPlan(
|
||||
value: Readonly<WorkerCredentialManagementPlan>,
|
||||
): Readonly<WorkerCredentialManagementPlan> {
|
||||
if (
|
||||
Buffer.byteLength(JSON.stringify(value), 'utf8') >
|
||||
MAX_WORKER_CREDENTIAL_MANAGEMENT_PLAN_BYTES
|
||||
) {
|
||||
return invalid('encoded plan exceeds the size limit');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createWorkerCredentialManagementPlan(
|
||||
input: CreateWorkerCredentialManagementPlanInput,
|
||||
): Readonly<WorkerCredentialManagementPlan> {
|
||||
const value = record(input, 'plan input');
|
||||
exactKeys(
|
||||
value,
|
||||
[
|
||||
'action',
|
||||
'actionRef',
|
||||
'authorityProjectId',
|
||||
'expiresAtMs',
|
||||
'plannedAtMs',
|
||||
'requestedBy',
|
||||
'target',
|
||||
],
|
||||
'plan input',
|
||||
);
|
||||
const unsigned = normalizeUnsigned({
|
||||
schema: WORKER_CREDENTIAL_MANAGEMENT_PLAN_SCHEMA,
|
||||
actionRef: input.actionRef,
|
||||
authorityProjectId: input.authorityProjectId,
|
||||
action: input.action,
|
||||
target: input.target,
|
||||
requestedBy: input.requestedBy,
|
||||
plannedAtMs: input.plannedAtMs,
|
||||
expiresAtMs: input.expiresAtMs,
|
||||
});
|
||||
return boundedPlan(Object.freeze({
|
||||
...unsigned,
|
||||
planDigest: workerCredentialManagementPlanDigest(unsigned),
|
||||
previewDigest: workerCredentialManagementPreviewDigest(unsigned),
|
||||
}));
|
||||
}
|
||||
|
||||
export function normalizeWorkerCredentialManagementPlan(
|
||||
value: WorkerCredentialManagementPlan,
|
||||
): Readonly<WorkerCredentialManagementPlan> {
|
||||
const candidate = record(value, 'plan');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'action',
|
||||
'actionRef',
|
||||
'authorityProjectId',
|
||||
'expiresAtMs',
|
||||
'planDigest',
|
||||
'plannedAtMs',
|
||||
'previewDigest',
|
||||
'requestedBy',
|
||||
'schema',
|
||||
'target',
|
||||
],
|
||||
'plan',
|
||||
);
|
||||
const unsigned = normalizeUnsigned({
|
||||
schema: value.schema,
|
||||
actionRef: value.actionRef,
|
||||
authorityProjectId: value.authorityProjectId,
|
||||
action: value.action,
|
||||
target: value.target,
|
||||
requestedBy: value.requestedBy,
|
||||
plannedAtMs: value.plannedAtMs,
|
||||
expiresAtMs: value.expiresAtMs,
|
||||
});
|
||||
if (
|
||||
typeof value.planDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(value.planDigest) ||
|
||||
value.planDigest !== workerCredentialManagementPlanDigest(unsigned) ||
|
||||
typeof value.previewDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(value.previewDigest) ||
|
||||
value.previewDigest !== workerCredentialManagementPreviewDigest(unsigned)
|
||||
) {
|
||||
return invalid('digest is invalid');
|
||||
}
|
||||
return boundedPlan(Object.freeze({
|
||||
...unsigned,
|
||||
planDigest: value.planDigest,
|
||||
previewDigest: value.previewDigest,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { normalizeWorkerCredentialId } from './workerCredential';
|
||||
|
||||
export const WORKER_CREDENTIAL_TOKEN_LIMITS = Object.freeze({
|
||||
secretBytes: 32,
|
||||
});
|
||||
|
||||
const CANONICAL_SECRET = /^[A-Za-z0-9_-]{43}$/;
|
||||
const DOMAIN = Buffer.from('qinglong-worker-credential-v1\0', 'utf8');
|
||||
|
||||
function decode(name: string, value: string): Buffer {
|
||||
if (typeof value !== 'string' || !CANONICAL_SECRET.test(value)) {
|
||||
throw new TypeError(`${name} must be canonical base64url for 32 bytes`);
|
||||
}
|
||||
const result = Buffer.from(value, 'base64url');
|
||||
if (result.byteLength !== 32 || result.toString('base64url') !== value) {
|
||||
result.fill(0);
|
||||
throw new TypeError(`${name} must be canonical base64url for 32 bytes`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function assertWorkerCredentialPepper(value: string): void {
|
||||
const pepper = decode('Worker credential pepper', value);
|
||||
pepper.fill(0);
|
||||
}
|
||||
|
||||
export function workerCredentialSecretDigest(
|
||||
pepperBase64Url: string,
|
||||
credentialId: string,
|
||||
secretBase64Url: string,
|
||||
): string {
|
||||
normalizeWorkerCredentialId(credentialId);
|
||||
const pepper = decode('Worker credential pepper', pepperBase64Url);
|
||||
const secret = decode('Worker credential secret', secretBase64Url);
|
||||
let digest: Buffer | undefined;
|
||||
try {
|
||||
digest = createHmac('sha256', pepper)
|
||||
.update(DOMAIN)
|
||||
.update(credentialId, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(secret)
|
||||
.digest();
|
||||
return digest.toString('hex');
|
||||
} finally {
|
||||
digest?.fill(0);
|
||||
pepper.fill(0);
|
||||
secret.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatWorkerCredentialToken(
|
||||
credentialId: string,
|
||||
secretBase64Url: string,
|
||||
): string {
|
||||
normalizeWorkerCredentialId(credentialId);
|
||||
const secret = decode('Worker credential secret', secretBase64Url);
|
||||
secret.fill(0);
|
||||
return `ql3w_${credentialId}_${secretBase64Url}`;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
export const WORKER_EXECUTION_ATTESTATION_STATES = [
|
||||
'running',
|
||||
'stopped',
|
||||
] as const;
|
||||
export type WorkerExecutionAttestationState =
|
||||
(typeof WORKER_EXECUTION_ATTESTATION_STATES)[number];
|
||||
|
||||
export interface WorkerExecutionAttestationRecord {
|
||||
readonly attestationId: string;
|
||||
readonly runId: string;
|
||||
readonly attemptId: string;
|
||||
readonly sequence: number;
|
||||
readonly state: WorkerExecutionAttestationState;
|
||||
readonly workerId: string;
|
||||
readonly workerSessionId: string;
|
||||
readonly workerGeneration: number;
|
||||
readonly leaseTokenDigest: string;
|
||||
readonly leaseGeneration: number;
|
||||
readonly leaseVersion: number;
|
||||
readonly offerId: string;
|
||||
readonly callbackSequence: number;
|
||||
readonly executorHandle: string;
|
||||
readonly journalRevision: number;
|
||||
readonly receivedAtMs: number;
|
||||
}
|
||||
|
||||
export interface SubmitWorkerExecutionAttestationCommand
|
||||
extends Omit<WorkerExecutionAttestationRecord, 'receivedAtMs'> {}
|
||||
|
||||
export interface SubmitWorkerExecutionAttestationResult {
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly attestation: Readonly<WorkerExecutionAttestationRecord>;
|
||||
}
|
||||
|
||||
export interface WorkerExecutionAttestationRepository {
|
||||
submit(
|
||||
command: SubmitWorkerExecutionAttestationCommand,
|
||||
): Promise<SubmitWorkerExecutionAttestationResult>;
|
||||
findLatestExact(
|
||||
target: Readonly<{
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
workerId: string;
|
||||
workerSessionId: string;
|
||||
workerGeneration: number;
|
||||
leaseTokenDigest: string;
|
||||
leaseGeneration: number;
|
||||
leaseVersion: number;
|
||||
offerId: string;
|
||||
callbackSequence: number;
|
||||
executorHandle: string;
|
||||
}>,
|
||||
): Promise<Readonly<WorkerExecutionAttestationRecord> | null>;
|
||||
}
|
||||
|
||||
export class WorkerExecutionAttestationFenceRejectedError extends Error {
|
||||
readonly code = 'WORKER_EXECUTION_ATTESTATION_FENCE_REJECTED';
|
||||
constructor(readonly reason: string) {
|
||||
super(`Worker execution attestation fence was rejected: ${reason}`);
|
||||
this.name = 'WorkerExecutionAttestationFenceRejectedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerExecutionAttestationUnavailableError extends Error {
|
||||
readonly code = 'WORKER_EXECUTION_ATTESTATION_UNAVAILABLE';
|
||||
constructor() {
|
||||
super('Worker execution attestation storage is unavailable');
|
||||
this.name = 'WorkerExecutionAttestationUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
const UUID_V7 =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
function bounded(name: string, value: string, maximum: number): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > maximum ||
|
||||
value.includes('\0')
|
||||
) {
|
||||
throw new TypeError(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(name: string, value: number, minimum: number): number {
|
||||
if (!Number.isSafeInteger(value) || value < minimum) {
|
||||
throw new RangeError(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeWorkerExecutionAttestation(
|
||||
value: WorkerExecutionAttestationRecord,
|
||||
): Readonly<WorkerExecutionAttestationRecord> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError('Worker execution attestation is invalid');
|
||||
}
|
||||
const expected = [
|
||||
'attestationId', 'runId', 'attemptId', 'sequence', 'state', 'workerId',
|
||||
'workerSessionId', 'workerGeneration', 'leaseTokenDigest',
|
||||
'leaseGeneration', 'leaseVersion', 'offerId', 'callbackSequence',
|
||||
'executorHandle', 'journalRevision', 'receivedAtMs',
|
||||
].sort();
|
||||
const actual = Object.keys(value).sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new TypeError('Worker execution attestation shape is invalid');
|
||||
}
|
||||
if (!UUID_V7.test(value.attestationId)) {
|
||||
throw new TypeError('Worker execution attestation ID is invalid');
|
||||
}
|
||||
bounded('Worker execution attestation runId', value.runId, 64);
|
||||
bounded('Worker execution attestation attemptId', value.attemptId, 64);
|
||||
integer('Worker execution attestation sequence', value.sequence, 1);
|
||||
if (!WORKER_EXECUTION_ATTESTATION_STATES.includes(value.state)) {
|
||||
throw new TypeError('Worker execution attestation state is invalid');
|
||||
}
|
||||
if (!SAFE_ID.test(value.workerId) || !UUID_V7.test(value.workerSessionId)) {
|
||||
throw new TypeError('Worker execution attestation Worker identity is invalid');
|
||||
}
|
||||
integer('Worker execution attestation Worker generation', value.workerGeneration, 1);
|
||||
if (!/^[0-9a-f]{64}$/.test(value.leaseTokenDigest)) {
|
||||
throw new TypeError('Worker execution attestation lease digest is invalid');
|
||||
}
|
||||
integer('Worker execution attestation lease generation', value.leaseGeneration, 1);
|
||||
integer('Worker execution attestation lease version', value.leaseVersion, 0);
|
||||
bounded('Worker execution attestation offerId', value.offerId, 128);
|
||||
integer('Worker execution attestation callback sequence', value.callbackSequence, 0);
|
||||
bounded('Worker execution attestation executor handle', value.executorHandle, 512);
|
||||
integer('Worker execution attestation journal revision', value.journalRevision, 1);
|
||||
integer('Worker execution attestation receivedAtMs', value.receivedAtMs, 0);
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
export function normalizeSubmitWorkerExecutionAttestationCommand(
|
||||
command: SubmitWorkerExecutionAttestationCommand,
|
||||
): Readonly<SubmitWorkerExecutionAttestationCommand> {
|
||||
const { receivedAtMs: _receivedAtMs, ...normalized } =
|
||||
normalizeWorkerExecutionAttestation({ ...command, receivedAtMs: 0 });
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export const WORKER_SESSION_STATUSES = ['online', 'draining', 'offline'] as const;
|
||||
export type WorkerSessionStatus = (typeof WORKER_SESSION_STATUSES)[number];
|
||||
|
||||
export const MAX_WORKER_ID_LENGTH = 128;
|
||||
export const MAX_WORKER_CAPABILITIES_BYTES = 16 * 1024;
|
||||
export const MAX_WORKER_CONCURRENT_RUNS = 1024;
|
||||
export const MAX_WORKER_SESSION_LEASE_MS = 10 * 60_000;
|
||||
export const MIN_WORKER_SESSION_LEASE_MS = 5_000;
|
||||
export const MAX_AVAILABLE_WORKER_PAGE_SIZE = 64;
|
||||
|
||||
const WORKER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
|
||||
const UUID_V7_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export interface WorkerSessionRecord {
|
||||
readonly workerId: string;
|
||||
readonly sessionId: string;
|
||||
readonly generation: number;
|
||||
readonly status: WorkerSessionStatus;
|
||||
readonly version: number;
|
||||
/** Canonical, bounded JSON. Parsing/placement belongs to the Worker domain. */
|
||||
readonly capabilitiesJson: string;
|
||||
readonly capabilitiesHash: string;
|
||||
readonly maxConcurrentRuns: number;
|
||||
readonly availableSlots: number;
|
||||
readonly registeredAtMs: number;
|
||||
readonly lastHeartbeatAtMs: number;
|
||||
readonly leaseExpiresAtMs: number;
|
||||
readonly updatedAtMs: number;
|
||||
}
|
||||
|
||||
export class InvalidWorkerSessionValueError extends TypeError {
|
||||
constructor(message: string) {
|
||||
super(`Worker session value is invalid: ${message}`);
|
||||
this.name = 'InvalidWorkerSessionValueError';
|
||||
}
|
||||
}
|
||||
|
||||
export type WorkerSessionFenceReason =
|
||||
| 'missing'
|
||||
| 'session_mismatch'
|
||||
| 'generation_mismatch'
|
||||
| 'version_mismatch'
|
||||
| 'lease_expired'
|
||||
| 'offline';
|
||||
|
||||
export class WorkerSessionFenceRejectedError extends Error {
|
||||
constructor(
|
||||
readonly workerId: string,
|
||||
readonly reason: WorkerSessionFenceReason,
|
||||
) {
|
||||
super(`Worker session ${workerId} was fenced: ${reason}`);
|
||||
this.name = 'WorkerSessionFenceRejectedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerSessionConflictError extends Error {
|
||||
constructor(readonly workerId: string) {
|
||||
super(`Worker session ${workerId} replay conflicts with persisted data`);
|
||||
this.name = 'WorkerSessionConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidWorkerSessionValueError(message);
|
||||
}
|
||||
|
||||
function safeInteger(name: string, value: number, minimum = 0): void {
|
||||
if (!Number.isSafeInteger(value) || value < minimum) {
|
||||
invalid(`${name} must be a safe integer greater than or equal to ${minimum}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertWorkerId(value: string): void {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > MAX_WORKER_ID_LENGTH ||
|
||||
!WORKER_ID_PATTERN.test(value)
|
||||
) {
|
||||
invalid('workerId is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertWorkerSessionId(value: string): void {
|
||||
if (typeof value !== 'string' || !UUID_V7_PATTERN.test(value)) {
|
||||
invalid('sessionId must be a lowercase UUIDv7');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertWorkerSessionLeaseDuration(value: number): void {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < MIN_WORKER_SESSION_LEASE_MS ||
|
||||
value > MAX_WORKER_SESSION_LEASE_MS
|
||||
) {
|
||||
invalid(
|
||||
`leaseDurationMs must be between ${MIN_WORKER_SESSION_LEASE_MS} and ${MAX_WORKER_SESSION_LEASE_MS}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertWorkerConcurrency(
|
||||
maxConcurrentRuns: number,
|
||||
availableSlots: number,
|
||||
): void {
|
||||
if (
|
||||
!Number.isSafeInteger(maxConcurrentRuns) ||
|
||||
maxConcurrentRuns < 1 ||
|
||||
maxConcurrentRuns > MAX_WORKER_CONCURRENT_RUNS ||
|
||||
!Number.isSafeInteger(availableSlots) ||
|
||||
availableSlots < 0 ||
|
||||
availableSlots > maxConcurrentRuns
|
||||
) {
|
||||
invalid('Worker concurrency is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertWorkerCapabilitiesSnapshot(
|
||||
capabilitiesJson: string,
|
||||
capabilitiesHash: string,
|
||||
): void {
|
||||
if (
|
||||
typeof capabilitiesJson !== 'string' ||
|
||||
Buffer.byteLength(capabilitiesJson, 'utf8') < 2 ||
|
||||
Buffer.byteLength(capabilitiesJson, 'utf8') >
|
||||
MAX_WORKER_CAPABILITIES_BYTES ||
|
||||
!SHA256_PATTERN.test(capabilitiesHash)
|
||||
) {
|
||||
invalid('Worker capabilities snapshot is invalid');
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(capabilitiesJson);
|
||||
} catch {
|
||||
return invalid('Worker capabilities snapshot is not JSON');
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
invalid('Worker capabilities snapshot must be an object');
|
||||
}
|
||||
if (
|
||||
createHash('sha256').update(capabilitiesJson, 'utf8').digest('hex') !==
|
||||
capabilitiesHash
|
||||
) {
|
||||
invalid('Worker capabilities hash does not match its canonical JSON');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertWorkerSessionRecord(value: WorkerSessionRecord): void {
|
||||
assertWorkerId(value.workerId);
|
||||
assertWorkerSessionId(value.sessionId);
|
||||
safeInteger('generation', value.generation, 1);
|
||||
safeInteger('version', value.version);
|
||||
if (!WORKER_SESSION_STATUSES.includes(value.status)) invalid('status is invalid');
|
||||
assertWorkerCapabilitiesSnapshot(
|
||||
value.capabilitiesJson,
|
||||
value.capabilitiesHash,
|
||||
);
|
||||
assertWorkerConcurrency(value.maxConcurrentRuns, value.availableSlots);
|
||||
for (const [name, timestamp] of [
|
||||
['registeredAtMs', value.registeredAtMs],
|
||||
['lastHeartbeatAtMs', value.lastHeartbeatAtMs],
|
||||
['leaseExpiresAtMs', value.leaseExpiresAtMs],
|
||||
['updatedAtMs', value.updatedAtMs],
|
||||
] as const) {
|
||||
safeInteger(name, timestamp);
|
||||
}
|
||||
if (
|
||||
value.lastHeartbeatAtMs < value.registeredAtMs ||
|
||||
(value.status !== 'offline' &&
|
||||
value.leaseExpiresAtMs <= value.lastHeartbeatAtMs) ||
|
||||
value.updatedAtMs < value.lastHeartbeatAtMs ||
|
||||
(value.status !== 'online' && value.availableSlots !== 0)
|
||||
) {
|
||||
invalid('timestamps or status capacity are inconsistent');
|
||||
}
|
||||
}
|
||||
|
||||
export interface RegisterWorkerSessionCommand {
|
||||
readonly workerId: string;
|
||||
readonly sessionId: string;
|
||||
readonly capabilitiesJson: string;
|
||||
readonly capabilitiesHash: string;
|
||||
readonly maxConcurrentRuns: number;
|
||||
readonly availableSlots: number;
|
||||
readonly leaseDurationMs: number;
|
||||
}
|
||||
|
||||
export interface HeartbeatWorkerSessionCommand {
|
||||
readonly workerId: string;
|
||||
readonly sessionId: string;
|
||||
readonly generation: number;
|
||||
readonly expectedVersion: number;
|
||||
readonly availableSlots: number;
|
||||
readonly leaseDurationMs: number;
|
||||
}
|
||||
|
||||
export interface TransitionWorkerSessionCommand {
|
||||
readonly workerId: string;
|
||||
readonly sessionId: string;
|
||||
readonly generation: number;
|
||||
readonly expectedVersion: number;
|
||||
readonly status: 'draining' | 'offline';
|
||||
}
|
||||
|
||||
export interface RegisterWorkerSessionResult {
|
||||
readonly worker: WorkerSessionRecord;
|
||||
readonly replacedSession: boolean;
|
||||
}
|
||||
|
||||
export interface AvailableWorkerSessionPage {
|
||||
readonly observedAtMs: number;
|
||||
readonly workers: readonly WorkerSessionRecord[];
|
||||
readonly truncated: boolean;
|
||||
readonly nextCursor?: string;
|
||||
}
|
||||
|
||||
export interface WorkerSessionRepository {
|
||||
findById(workerId: string): Promise<WorkerSessionRecord | null>;
|
||||
register(
|
||||
command: RegisterWorkerSessionCommand,
|
||||
): Promise<RegisterWorkerSessionResult>;
|
||||
heartbeat(command: HeartbeatWorkerSessionCommand): Promise<WorkerSessionRecord>;
|
||||
transition(command: TransitionWorkerSessionCommand): Promise<WorkerSessionRecord>;
|
||||
listAvailable(options?: Readonly<{
|
||||
afterWorkerId?: string;
|
||||
limit?: number;
|
||||
}>): Promise<AvailableWorkerSessionPage>;
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import {
|
||||
assertWorkerCapabilitiesSnapshot,
|
||||
assertWorkerConcurrency,
|
||||
assertWorkerId,
|
||||
assertWorkerSessionId,
|
||||
assertWorkerSessionLeaseDuration,
|
||||
assertWorkerSessionRecord,
|
||||
MAX_WORKER_CONCURRENT_RUNS,
|
||||
type HeartbeatWorkerSessionCommand,
|
||||
type RegisterWorkerSessionCommand,
|
||||
type RegisterWorkerSessionResult,
|
||||
type TransitionWorkerSessionCommand,
|
||||
type WorkerSessionRecord,
|
||||
type WorkerSessionStatus,
|
||||
} from './workerSession';
|
||||
|
||||
export const WORKER_SESSION_REGISTER_SCHEMA =
|
||||
'qinglong/worker-session-register@v1';
|
||||
export const WORKER_SESSION_HEARTBEAT_SCHEMA =
|
||||
'qinglong/worker-session-heartbeat@v1';
|
||||
export const WORKER_SESSION_TRANSITION_SCHEMA =
|
||||
'qinglong/worker-session-transition@v1';
|
||||
export const MAX_WORKER_SESSION_REGISTER_REQUEST_BYTES = 20 * 1024;
|
||||
export const MAX_WORKER_SESSION_REQUEST_BYTES = 4 * 1024;
|
||||
export const MAX_WORKER_SESSION_RESPONSE_BYTES = 4 * 1024;
|
||||
|
||||
export interface WorkerSessionPathAuthority {
|
||||
readonly workerId: string;
|
||||
readonly sessionId: string;
|
||||
}
|
||||
|
||||
export type WorkerSessionRegisterRequestBody = Readonly<{
|
||||
readonly schema: typeof WORKER_SESSION_REGISTER_SCHEMA;
|
||||
readonly capabilitiesJson: string;
|
||||
readonly capabilitiesHash: string;
|
||||
readonly maxConcurrentRuns: number;
|
||||
readonly availableSlots: number;
|
||||
readonly leaseDurationMs: number;
|
||||
}>;
|
||||
|
||||
export type WorkerSessionHeartbeatRequestBody = Readonly<{
|
||||
readonly schema: typeof WORKER_SESSION_HEARTBEAT_SCHEMA;
|
||||
readonly generation: number;
|
||||
readonly expectedVersion: number;
|
||||
readonly availableSlots: number;
|
||||
readonly leaseDurationMs: number;
|
||||
}>;
|
||||
|
||||
export type WorkerSessionTransitionRequestBody = Readonly<{
|
||||
readonly schema: typeof WORKER_SESSION_TRANSITION_SCHEMA;
|
||||
readonly generation: number;
|
||||
readonly expectedVersion: number;
|
||||
readonly status: 'draining' | 'offline';
|
||||
}>;
|
||||
|
||||
export interface WorkerSessionWireProjection {
|
||||
readonly workerId: string;
|
||||
readonly sessionId: string;
|
||||
readonly generation: number;
|
||||
readonly version: number;
|
||||
readonly status: WorkerSessionStatus;
|
||||
readonly leaseExpiresAtMs: number;
|
||||
}
|
||||
|
||||
export type WorkerSessionRegisterResponseBody = Readonly<
|
||||
WorkerSessionWireProjection & {
|
||||
readonly schema: typeof WORKER_SESSION_REGISTER_SCHEMA;
|
||||
readonly replacedSession: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
export type WorkerSessionHeartbeatResponseBody = Readonly<
|
||||
WorkerSessionWireProjection & {
|
||||
readonly schema: typeof WORKER_SESSION_HEARTBEAT_SCHEMA;
|
||||
}
|
||||
>;
|
||||
|
||||
export type WorkerSessionTransitionResponseBody = Readonly<
|
||||
WorkerSessionWireProjection & {
|
||||
readonly schema: typeof WORKER_SESSION_TRANSITION_SCHEMA;
|
||||
}
|
||||
>;
|
||||
|
||||
export class InvalidWorkerSessionTransportError extends TypeError {
|
||||
readonly code = 'WORKER_SESSION_TRANSPORT_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Worker Session transport is invalid: ${message}`);
|
||||
this.name = 'InvalidWorkerSessionTransportError';
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidWorkerSessionTransportError(message);
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return invalid(`${label} is not 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 sorted = [...expected].sort();
|
||||
if (
|
||||
actual.length !== sorted.length ||
|
||||
actual.some((key, index) => key !== sorted[index])
|
||||
) invalid(`${label} shape is invalid`);
|
||||
}
|
||||
|
||||
function integer(
|
||||
value: unknown,
|
||||
label: string,
|
||||
minimum = 0,
|
||||
maximum = Number.MAX_SAFE_INTEGER,
|
||||
): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < minimum ||
|
||||
(value as number) > maximum
|
||||
) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function authority(value: WorkerSessionPathAuthority): WorkerSessionPathAuthority {
|
||||
try {
|
||||
assertWorkerId(value?.workerId);
|
||||
assertWorkerSessionId(value?.sessionId);
|
||||
} catch {
|
||||
return invalid('path authority is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
workerId: value.workerId,
|
||||
sessionId: value.sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
function validateRegister(
|
||||
command: RegisterWorkerSessionCommand,
|
||||
): Readonly<RegisterWorkerSessionCommand> {
|
||||
try {
|
||||
assertWorkerId(command?.workerId);
|
||||
assertWorkerSessionId(command?.sessionId);
|
||||
assertWorkerCapabilitiesSnapshot(
|
||||
command?.capabilitiesJson,
|
||||
command?.capabilitiesHash,
|
||||
);
|
||||
assertWorkerConcurrency(
|
||||
command?.maxConcurrentRuns,
|
||||
command?.availableSlots,
|
||||
);
|
||||
assertWorkerSessionLeaseDuration(command?.leaseDurationMs);
|
||||
} catch {
|
||||
return invalid('register command is invalid');
|
||||
}
|
||||
return Object.freeze({ ...command });
|
||||
}
|
||||
|
||||
function validateHeartbeat(
|
||||
command: HeartbeatWorkerSessionCommand,
|
||||
): Readonly<HeartbeatWorkerSessionCommand> {
|
||||
try {
|
||||
assertWorkerId(command?.workerId);
|
||||
assertWorkerSessionId(command?.sessionId);
|
||||
assertWorkerSessionLeaseDuration(command?.leaseDurationMs);
|
||||
} catch {
|
||||
return invalid('heartbeat command is invalid');
|
||||
}
|
||||
integer(command.generation, 'generation', 1);
|
||||
integer(command.expectedVersion, 'expectedVersion');
|
||||
integer(
|
||||
command.availableSlots,
|
||||
'availableSlots',
|
||||
0,
|
||||
MAX_WORKER_CONCURRENT_RUNS,
|
||||
);
|
||||
return Object.freeze({ ...command });
|
||||
}
|
||||
|
||||
function validateTransition(
|
||||
command: TransitionWorkerSessionCommand,
|
||||
): Readonly<TransitionWorkerSessionCommand> {
|
||||
try {
|
||||
assertWorkerId(command?.workerId);
|
||||
assertWorkerSessionId(command?.sessionId);
|
||||
} catch {
|
||||
return invalid('transition command is invalid');
|
||||
}
|
||||
integer(command.generation, 'generation', 1);
|
||||
integer(command.expectedVersion, 'expectedVersion');
|
||||
if (command.status !== 'draining' && command.status !== 'offline') {
|
||||
return invalid('transition status is invalid');
|
||||
}
|
||||
return Object.freeze({ ...command });
|
||||
}
|
||||
|
||||
export function createWorkerSessionRegisterRequestBody(
|
||||
value: RegisterWorkerSessionCommand,
|
||||
): WorkerSessionRegisterRequestBody {
|
||||
const command = validateRegister(value);
|
||||
return Object.freeze({
|
||||
schema: WORKER_SESSION_REGISTER_SCHEMA,
|
||||
capabilitiesJson: command.capabilitiesJson,
|
||||
capabilitiesHash: command.capabilitiesHash,
|
||||
maxConcurrentRuns: command.maxConcurrentRuns,
|
||||
availableSlots: command.availableSlots,
|
||||
leaseDurationMs: command.leaseDurationMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseWorkerSessionRegisterRequestBody(
|
||||
value: unknown,
|
||||
path: WorkerSessionPathAuthority,
|
||||
): Readonly<RegisterWorkerSessionCommand> {
|
||||
const body = object(value, 'register request');
|
||||
exactKeys(body, [
|
||||
'schema', 'capabilitiesJson', 'capabilitiesHash', 'maxConcurrentRuns',
|
||||
'availableSlots', 'leaseDurationMs',
|
||||
], 'register request');
|
||||
if (body.schema !== WORKER_SESSION_REGISTER_SCHEMA) {
|
||||
return invalid('register schema is invalid');
|
||||
}
|
||||
const resolved = authority(path);
|
||||
return validateRegister({
|
||||
...resolved,
|
||||
capabilitiesJson: body.capabilitiesJson as string,
|
||||
capabilitiesHash: body.capabilitiesHash as string,
|
||||
maxConcurrentRuns: body.maxConcurrentRuns as number,
|
||||
availableSlots: body.availableSlots as number,
|
||||
leaseDurationMs: body.leaseDurationMs as number,
|
||||
});
|
||||
}
|
||||
|
||||
export function createWorkerSessionHeartbeatRequestBody(
|
||||
value: HeartbeatWorkerSessionCommand,
|
||||
): WorkerSessionHeartbeatRequestBody {
|
||||
const command = validateHeartbeat(value);
|
||||
return Object.freeze({
|
||||
schema: WORKER_SESSION_HEARTBEAT_SCHEMA,
|
||||
generation: command.generation,
|
||||
expectedVersion: command.expectedVersion,
|
||||
availableSlots: command.availableSlots,
|
||||
leaseDurationMs: command.leaseDurationMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseWorkerSessionHeartbeatRequestBody(
|
||||
value: unknown,
|
||||
path: WorkerSessionPathAuthority,
|
||||
): Readonly<HeartbeatWorkerSessionCommand> {
|
||||
const body = object(value, 'heartbeat request');
|
||||
exactKeys(body, [
|
||||
'schema', 'generation', 'expectedVersion', 'availableSlots',
|
||||
'leaseDurationMs',
|
||||
], 'heartbeat request');
|
||||
if (body.schema !== WORKER_SESSION_HEARTBEAT_SCHEMA) {
|
||||
return invalid('heartbeat schema is invalid');
|
||||
}
|
||||
return validateHeartbeat({
|
||||
...authority(path),
|
||||
generation: body.generation as number,
|
||||
expectedVersion: body.expectedVersion as number,
|
||||
availableSlots: body.availableSlots as number,
|
||||
leaseDurationMs: body.leaseDurationMs as number,
|
||||
});
|
||||
}
|
||||
|
||||
export function createWorkerSessionTransitionRequestBody(
|
||||
value: TransitionWorkerSessionCommand,
|
||||
): WorkerSessionTransitionRequestBody {
|
||||
const command = validateTransition(value);
|
||||
return Object.freeze({
|
||||
schema: WORKER_SESSION_TRANSITION_SCHEMA,
|
||||
generation: command.generation,
|
||||
expectedVersion: command.expectedVersion,
|
||||
status: command.status,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseWorkerSessionTransitionRequestBody(
|
||||
value: unknown,
|
||||
path: WorkerSessionPathAuthority,
|
||||
): Readonly<TransitionWorkerSessionCommand> {
|
||||
const body = object(value, 'transition request');
|
||||
exactKeys(body, [
|
||||
'schema', 'generation', 'expectedVersion', 'status',
|
||||
], 'transition request');
|
||||
if (body.schema !== WORKER_SESSION_TRANSITION_SCHEMA) {
|
||||
return invalid('transition schema is invalid');
|
||||
}
|
||||
return validateTransition({
|
||||
...authority(path),
|
||||
generation: body.generation as number,
|
||||
expectedVersion: body.expectedVersion as number,
|
||||
status: body.status as 'draining' | 'offline',
|
||||
});
|
||||
}
|
||||
|
||||
function projection(record: WorkerSessionRecord): WorkerSessionWireProjection {
|
||||
try {
|
||||
assertWorkerSessionRecord(record);
|
||||
} catch {
|
||||
return invalid('Session record is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
workerId: record.workerId,
|
||||
sessionId: record.sessionId,
|
||||
generation: record.generation,
|
||||
version: record.version,
|
||||
status: record.status,
|
||||
leaseExpiresAtMs: record.leaseExpiresAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function createWorkerSessionRegisterResponseBody(
|
||||
value: RegisterWorkerSessionResult,
|
||||
): WorkerSessionRegisterResponseBody {
|
||||
if (typeof value?.replacedSession !== 'boolean') {
|
||||
return invalid('register result is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schema: WORKER_SESSION_REGISTER_SCHEMA,
|
||||
...projection(value.worker),
|
||||
replacedSession: value.replacedSession,
|
||||
});
|
||||
}
|
||||
|
||||
export function createWorkerSessionHeartbeatResponseBody(
|
||||
value: WorkerSessionRecord,
|
||||
): WorkerSessionHeartbeatResponseBody {
|
||||
return Object.freeze({
|
||||
schema: WORKER_SESSION_HEARTBEAT_SCHEMA,
|
||||
...projection(value),
|
||||
});
|
||||
}
|
||||
|
||||
export function createWorkerSessionTransitionResponseBody(
|
||||
value: WorkerSessionRecord,
|
||||
): WorkerSessionTransitionResponseBody {
|
||||
return Object.freeze({
|
||||
schema: WORKER_SESSION_TRANSITION_SCHEMA,
|
||||
...projection(value),
|
||||
});
|
||||
}
|
||||
|
||||
function parseProjection(
|
||||
value: unknown,
|
||||
schema: string,
|
||||
includeReplacement: boolean,
|
||||
): WorkerSessionWireProjection & { readonly replacedSession?: boolean } {
|
||||
const body = object(value, 'Session response');
|
||||
exactKeys(body, [
|
||||
'schema', 'workerId', 'sessionId', 'generation', 'version', 'status',
|
||||
'leaseExpiresAtMs', ...(includeReplacement ? ['replacedSession'] : []),
|
||||
], 'Session response');
|
||||
if (body.schema !== schema) return invalid('response schema is invalid');
|
||||
try {
|
||||
assertWorkerId(body.workerId as string);
|
||||
assertWorkerSessionId(body.sessionId as string);
|
||||
} catch {
|
||||
return invalid('response authority is invalid');
|
||||
}
|
||||
if (!['online', 'draining', 'offline'].includes(body.status as string)) {
|
||||
return invalid('response status is invalid');
|
||||
}
|
||||
if (includeReplacement && typeof body.replacedSession !== 'boolean') {
|
||||
return invalid('replacement fact is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
workerId: body.workerId as string,
|
||||
sessionId: body.sessionId as string,
|
||||
generation: integer(body.generation, 'generation', 1),
|
||||
version: integer(body.version, 'version'),
|
||||
status: body.status as WorkerSessionStatus,
|
||||
leaseExpiresAtMs: integer(body.leaseExpiresAtMs, 'leaseExpiresAtMs'),
|
||||
...(includeReplacement
|
||||
? { replacedSession: body.replacedSession as boolean }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function parseWorkerSessionRegisterResponseBody(
|
||||
value: unknown,
|
||||
): WorkerSessionRegisterResponseBody {
|
||||
const result = parseProjection(value, WORKER_SESSION_REGISTER_SCHEMA, true);
|
||||
return Object.freeze({
|
||||
schema: WORKER_SESSION_REGISTER_SCHEMA,
|
||||
...result,
|
||||
replacedSession: result.replacedSession!,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseWorkerSessionHeartbeatResponseBody(
|
||||
value: unknown,
|
||||
): WorkerSessionHeartbeatResponseBody {
|
||||
return Object.freeze({
|
||||
schema: WORKER_SESSION_HEARTBEAT_SCHEMA,
|
||||
...parseProjection(value, WORKER_SESSION_HEARTBEAT_SCHEMA, false),
|
||||
});
|
||||
}
|
||||
|
||||
export function parseWorkerSessionTransitionResponseBody(
|
||||
value: unknown,
|
||||
): WorkerSessionTransitionResponseBody {
|
||||
return Object.freeze({
|
||||
schema: WORKER_SESSION_TRANSITION_SCHEMA,
|
||||
...parseProjection(value, WORKER_SESSION_TRANSITION_SCHEMA, false),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user