import type * as plugins from '../plugins.js'; import * as secretData from '../data/secret.js'; import type { IResolvedSecretManifestEntry, IActiveSecretRecipientMetadata, ISecretMetadata, ISecretSetAttachment, ISecretSetConsumerRolloutStatus, ISecretSetMetadata, ISecretVersionMetadata, ISecretVersionPurgeIntentV1, ISecretVersionRetentionReference, TSecretVersionPurgeOperationV1, TSecretMutationTarget, TSecretValueInput, TSecretDelivery, TSecretEnvironment, } from '../data/secret.js'; import type { IIdentityCredential } from '../data/user.js'; export interface IReq_GetSecretIngressRecipient extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, IReq_GetSecretIngressRecipient > { method: 'getSecretIngressRecipient'; request: { identity: IIdentityCredential; }; response: { recipient: IActiveSecretRecipientMetadata; }; } export interface IReq_ListSecrets extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, IReq_ListSecrets > { method: 'listSecrets'; request: { identity: IIdentityCredential; mutationId: string; target: TSecretMutationTarget; environment?: TSecretEnvironment; }; response: { secrets: ISecretMetadata[]; /** Observable CAS fence for the selected service or SecretSet. */ targetSecretsRevision: number; }; } export interface IReq_GetSecretMetadata extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, IReq_GetSecretMetadata > { method: 'getSecretMetadata'; request: { identity: IIdentityCredential; mutationId: string; secretId: string; }; response: { secret: ISecretMetadata; versions: ISecretVersionMetadata[]; }; } export interface IReq_CreateSecret extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, IReq_CreateSecret > { method: 'createSecret'; request: { identity: IIdentityCredential; mutationId: string; target: TSecretMutationTarget; key: string; environment: TSecretEnvironment; name: string; description?: string; tags?: Array<{ key: string; value: string }>; delivery: TSecretDelivery; valueInput: TSecretValueInput; expectedTargetSecretsRevision: number; }; response: { secret: ISecretMetadata; version: ISecretVersionMetadata; /** Post-create CAS revision for the selected target. */ targetSecretsRevision: number; }; } export interface IReq_RotateSecret extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, IReq_RotateSecret > { method: 'rotateSecret'; request: { identity: IIdentityCredential; mutationId: string; secretId: string; valueInput: TSecretValueInput; expectedSecretRevision: number; expectedActiveVersionId: string; /** Aggregate owner fence incremented by every secret mutation. */ expectedTargetSecretsRevision: number; }; response: { secret: ISecretMetadata; version: ISecretVersionMetadata; targetSecretsRevision: number; }; } export interface IReq_ChangeSecretLifecycle extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, IReq_ChangeSecretLifecycle > { method: 'changeSecretLifecycle'; request: { identity: IIdentityCredential; secretId: string; action: 'retire' | 'revoke' | 'delete'; expectedSecretRevision: number; expectedTargetSecretsRevision: number; }; response: { secret: ISecretMetadata; targetSecretsRevision: number; }; } export interface IReq_GetSecretVersionPurgePreflight extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, IReq_GetSecretVersionPurgePreflight > { method: 'getSecretVersionPurgePreflight'; request: { identity: IIdentityCredential; secretId: string; secretVersionId: string; cursor?: string; limit?: number; }; response: { schemaVersion: 1; secret: ISecretMetadata; version: ISecretVersionMetadata; targetSecretsRevision: number; eligible: boolean; blockers: TSecretVersionPurgeBlocker[]; blockingReferenceCount: number; references: ISecretVersionRetentionReference[]; nextCursor?: string; referencesTruncated: boolean; operation?: TSecretVersionPurgeOperationV1; }; } export type TSecretVersionPurgeBlocker = | 'version-active' | 'retention-reference' | 'purge-pending' | 'already-purged'; export interface IReq_PurgeSecretVersion extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, IReq_PurgeSecretVersion > { method: 'purgeSecretVersion'; request: ISecretVersionPurgeIntentV1 & { identity: IIdentityCredential; }; response: { secret: ISecretMetadata; version: ISecretVersionMetadata; operation: TSecretVersionPurgeOperationV1; targetSecretsRevision: number; }; } export interface IReq_ListSecretSets extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, IReq_ListSecretSets > { method: 'listSecretSets'; request: { identity: IIdentityCredential; organizationId: string; }; response: { secretSets: ISecretSetMetadata[]; }; } export interface IReq_CreateSecretSet extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, IReq_CreateSecretSet > { method: 'createSecretSet'; request: { identity: IIdentityCredential; organizationId: string; name: string; description?: string; }; response: { secretSet: ISecretSetMetadata; }; } export interface IReq_UpdateSecretSet extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, IReq_UpdateSecretSet > { method: 'updateSecretSet'; request: { identity: IIdentityCredential; secretSetId: string; name: string; description?: string; expectedRevision: number; }; response: { secretSet: ISecretSetMetadata; }; } export interface IReq_ChangeSecretSetLifecycle extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, IReq_ChangeSecretSetLifecycle > { method: 'changeSecretSetLifecycle'; request: { identity: IIdentityCredential; secretSetId: string; action: 'retire' | 'delete'; expectedRevision: number; /** Prevents lifecycle changes racing any secret mutation in this set. */ expectedSecretsRevision: number; }; response: { secretSet: ISecretSetMetadata; }; } export interface IReq_GetSecretSetConsumerRollout extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, IReq_GetSecretSetConsumerRollout > { method: 'getSecretSetConsumerRollout'; request: { identity: IIdentityCredential; secretSetId: string; rolloutId: string; cursor?: string; limit?: number; }; response: { consumers: ISecretSetConsumerRolloutStatus[]; nextCursor?: string; }; } export interface IReq_SetServiceSecretSetAttachments extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, IReq_SetServiceSecretSetAttachments > { method: 'setServiceSecretSetAttachments'; request: { identity: IIdentityCredential; serviceId: string; attachments: ISecretSetAttachment[]; expectedSecretConfigurationRevision: number; }; response: { attachments: ISecretSetAttachment[]; /** Post-update attachment CAS revision. */ secretConfigurationRevision: number; }; } export interface ISecretResolutionPreviewCollision { environment: TSecretEnvironment; key: string; sourceSecretIds: string[]; reason: 'duplicate-secret-set-key' | 'duplicate-service-key' | 'invalid-alias'; } export interface IReq_PreviewServiceSecretResolution extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, IReq_PreviewServiceSecretResolution > { method: 'previewServiceSecretResolution'; request: { identity: IIdentityCredential; serviceId: string; attachments?: ISecretSetAttachment[]; }; response: { entries: IResolvedSecretManifestEntry[]; collisions: ISecretResolutionPreviewCollision[]; }; } const isRecord = (valueArg: unknown): valueArg is Record => ( Boolean(valueArg) && typeof valueArg === 'object' && !Array.isArray(valueArg) ); export const validateCreateSecretRequest = async ( requestArg: unknown, activeIngressRecipientArg: IActiveSecretRecipientMetadata, ): Promise => { if (!requestArg || typeof requestArg !== 'object' || Array.isArray(requestArg)) { return ['create secret request must be an object']; } const request = requestArg as Record; const errors = secretData.validateSecretMutationId(request.mutationId); const allowedKeys = new Set([ 'identity', 'mutationId', 'target', 'key', 'environment', 'name', 'description', 'tags', 'delivery', 'valueInput', 'expectedTargetSecretsRevision', ]); if (Object.keys(request).some((keyArg) => !allowedKeys.has(keyArg))) { errors.push('create secret request contains fields outside its schema'); } if (!request.identity || typeof request.identity !== 'object' || typeof (request.identity as Record).jwt !== 'string') { errors.push('create secret identity must be a JWT credential'); } if (!request.target || typeof request.target !== 'object' || Array.isArray(request.target)) { errors.push('create secret target must be an owner object'); } else { errors.push(...secretData.validateSecretOwner(request.target)); } if (typeof request.key !== 'string' || !/^[A-Z_][A-Z0-9_]{0,252}$/.test(request.key)) { errors.push('create secret key must be canonical'); } if (typeof request.environment !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(request.environment)) { errors.push('create secret environment must be canonical'); } if (typeof request.name !== 'string' || request.name.trim() !== request.name || request.name.length < 1 || request.name.length > 200) { errors.push('create secret name must be a bounded non-empty string'); } if (request.description !== undefined && (typeof request.description !== 'string' || request.description.length > 2000)) { errors.push('create secret description must be a bounded string'); } if (request.tags !== undefined) { if (!Array.isArray(request.tags) || request.tags.length > 64) { errors.push('create secret tags must be a bounded array'); } else { const tagKeys: string[] = []; request.tags.forEach((tagArg, indexArg) => { if (!isRecord(tagArg) || JSON.stringify(Object.keys(tagArg).sort()) !== JSON.stringify(['key', 'value']) || typeof tagArg.key !== 'string' || tagArg.key.length < 1 || tagArg.key.length > 100 || typeof tagArg.value !== 'string' || tagArg.value.length > 500) { errors.push(`create secret tags[${indexArg}] must use the bounded key/value schema`); } else { tagKeys.push(tagArg.key); } }); if (new Set(tagKeys).size !== tagKeys.length) { errors.push('create secret tag keys must be unique'); } } } const deliveryErrors = secretData.validateSecretDelivery(request.delivery); errors.push(...deliveryErrors); if (!Number.isSafeInteger(request.expectedTargetSecretsRevision) || (request.expectedTargetSecretsRevision as number) < 0) { errors.push('create secret target revision must be a non-negative integer'); } const canBuildContext = secretData.validateSecretMutationId(request.mutationId).length === 0 && secretData.validateSecretOwner(request.target).length === 0 && typeof request.key === 'string' && /^[A-Z_][A-Z0-9_]{0,252}$/.test(request.key) && typeof request.environment === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(request.environment) && deliveryErrors.length === 0 && Number.isSafeInteger(request.expectedTargetSecretsRevision) && (request.expectedTargetSecretsRevision as number) >= 0; const expectedContext = canBuildContext ? secretData.createSecretCreateEnvelopeContext({ mutationId: request.mutationId as string, target: request.target as TSecretMutationTarget, key: request.key as string, environment: request.environment as TSecretEnvironment, delivery: request.delivery as TSecretDelivery, expectedTargetSecretsRevision: request.expectedTargetSecretsRevision as number, }) : new Uint8Array(); errors.push(...await secretData.validateSecretValueInputForRecipient( request.valueInput, activeIngressRecipientArg, expectedContext, )); return errors; }; export const validateRotateSecretRequest = async ( requestArg: unknown, activeIngressRecipientArg: IActiveSecretRecipientMetadata, ): Promise => { if (!requestArg || typeof requestArg !== 'object' || Array.isArray(requestArg)) { return ['rotate secret request must be an object']; } const request = requestArg as Record; const errors = secretData.validateSecretMutationId(request.mutationId); if (JSON.stringify(Object.keys(request).sort()) !== JSON.stringify([ 'expectedActiveVersionId', 'expectedSecretRevision', 'expectedTargetSecretsRevision', 'identity', 'mutationId', 'secretId', 'valueInput', ])) { errors.push('rotate secret request must use its exact schema'); } if (!request.identity || typeof request.identity !== 'object' || typeof (request.identity as Record).jwt !== 'string') { errors.push('rotate secret identity must be a JWT credential'); } for (const field of ['secretId', 'expectedActiveVersionId'] as const) { if (typeof request[field] !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(request[field] as string)) { errors.push(`rotate secret ${field} must be canonical`); } } if (!Number.isSafeInteger(request.expectedSecretRevision) || (request.expectedSecretRevision as number) < 1) { errors.push('rotate secret revision must be a positive integer'); } if (!Number.isSafeInteger(request.expectedTargetSecretsRevision) || (request.expectedTargetSecretsRevision as number) < 0) { errors.push('rotate secret target revision must be a non-negative integer'); } const canBuildContext = secretData.validateSecretMutationId(request.mutationId).length === 0 && typeof request.secretId === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(request.secretId) && typeof request.expectedActiveVersionId === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(request.expectedActiveVersionId) && Number.isSafeInteger(request.expectedSecretRevision) && (request.expectedSecretRevision as number) >= 1 && Number.isSafeInteger(request.expectedTargetSecretsRevision) && (request.expectedTargetSecretsRevision as number) >= 0; const expectedContext = canBuildContext ? secretData.createSecretRotateEnvelopeContext({ mutationId: request.mutationId as string, secretId: request.secretId as string, expectedSecretRevision: request.expectedSecretRevision as number, expectedActiveVersionId: request.expectedActiveVersionId as string, expectedTargetSecretsRevision: request.expectedTargetSecretsRevision as number, }) : new Uint8Array(); errors.push(...await secretData.validateSecretValueInputForRecipient( request.valueInput, activeIngressRecipientArg, expectedContext, )); return errors; }; export const validatePurgeSecretVersionRequest = ( requestArg: unknown, ): string[] => { try { if (!isRecord(requestArg)) return ['purge secret version request must be an object']; const request = requestArg as Record; const intent = Object.fromEntries( Object.entries(request).filter(([keyArg]) => keyArg !== 'identity'), ); const errors = secretData.validateSecretVersionPurgeIntent(intent); if (!request.identity || typeof request.identity !== 'object' || Array.isArray(request.identity) || JSON.stringify(Object.keys(request.identity).sort()) !== JSON.stringify(['jwt']) || typeof (request.identity as Record).jwt !== 'string' || !(request.identity as Record).jwt) { errors.push('purge secret version request requires only a JWT identity'); } return errors; } catch { return ['purge secret version request must be safely inspectable']; } }; export const validateGetSecretVersionPurgePreflightRequest = ( requestArg: unknown, ): string[] => { try { if (!isRecord(requestArg)) { return ['secret version purge preflight request must be an object']; } const expectedKeys = [ 'identity', 'secretId', 'secretVersionId', ...(requestArg.cursor === undefined ? [] : ['cursor']), ...(requestArg.limit === undefined ? [] : ['limit']), ]; const errors: string[] = []; if (JSON.stringify(Object.keys(requestArg).sort()) !== JSON.stringify(expectedKeys.sort())) { errors.push('secret version purge preflight request must use its exact schema'); } if (!isRecord(requestArg.identity) || JSON.stringify(Object.keys(requestArg.identity).sort()) !== JSON.stringify(['jwt']) || typeof requestArg.identity.jwt !== 'string' || !requestArg.identity.jwt) { errors.push('secret version purge preflight request requires only a JWT identity'); } for (const field of ['secretId', 'secretVersionId'] as const) { if (typeof requestArg[field] !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(requestArg[field])) { errors.push(`secret version purge preflight ${field} must be canonical`); } } if (requestArg.cursor !== undefined && (typeof requestArg.cursor !== 'string' || requestArg.cursor.length === 0)) { errors.push('secret version purge preflight cursor must be opaque and non-empty'); } if (requestArg.limit !== undefined && (!Number.isSafeInteger(requestArg.limit) || (requestArg.limit as number) < 1 || (requestArg.limit as number) > secretData.secretVersionRetentionReferenceLimits.pageSizeMaximum)) { errors.push('secret version purge preflight limit must be bounded'); } return errors; } catch { return ['secret version purge preflight request must be safely inspectable']; } }; export const validateSecretVersionPurgePreflightResponse = async ( responseArg: unknown, ): Promise => { try { if (!isRecord(responseArg)) { return ['secret version purge preflight response must be an object']; } const response = responseArg as Record; const expectedKeys = [ 'schemaVersion', 'secret', 'version', 'targetSecretsRevision', 'eligible', 'blockers', 'blockingReferenceCount', 'references', 'referencesTruncated', ...(response.nextCursor === undefined ? [] : ['nextCursor']), ...(response.operation === undefined ? [] : ['operation']), ]; const errors: string[] = []; if (JSON.stringify(Object.keys(response).sort()) !== JSON.stringify([...expectedKeys].sort())) { errors.push('secret version purge preflight response must use its exact schema'); } if (response.schemaVersion !== 1) { errors.push('secret version purge preflight response schemaVersion must be 1'); } if (secretData.validateSecretVersionMetadata(response.version).length > 0) { errors.push('secret version purge preflight version must be canonical'); } if (secretData.validateSecretMetadata(response.secret).length > 0) { errors.push('secret version purge preflight secret must be canonical'); } if (isRecord(response.secret) && isRecord(response.version) && response.version.secretId !== response.secret.id) { errors.push('secret version purge preflight version must belong to its secret'); } if (!Number.isSafeInteger(response.targetSecretsRevision) || (response.targetSecretsRevision as number) < 0) { errors.push('secret version purge preflight target revision must be non-negative'); } const allowedBlockers = new Set([ 'version-active', 'retention-reference', 'purge-pending', 'already-purged', ]); if (!Array.isArray(response.blockers) || response.blockers.some((blockerArg) => !allowedBlockers.has( blockerArg as TSecretVersionPurgeBlocker, )) || new Set(response.blockers).size !== response.blockers.length || JSON.stringify(response.blockers) !== JSON.stringify([...response.blockers].sort())) { errors.push('secret version purge preflight blockers must be uniquely sorted'); } if (typeof response.eligible !== 'boolean' || response.eligible !== (Array.isArray(response.blockers) && response.blockers.length === 0)) { errors.push('secret version purge preflight eligibility must be derived from blockers'); } if (!Number.isSafeInteger(response.blockingReferenceCount) || (response.blockingReferenceCount as number) < 0) { errors.push('secret version purge preflight reference count must be non-negative'); } errors.push(...secretData.validateSecretVersionRetentionReferences(response.references)); if (typeof response.referencesTruncated !== 'boolean') { errors.push('secret version purge preflight referencesTruncated must be boolean'); } if (response.nextCursor !== undefined && (typeof response.nextCursor !== 'string' || response.nextCursor.length === 0)) { errors.push('secret version purge preflight nextCursor must be opaque and non-empty'); } if (response.referencesTruncated !== (response.nextCursor !== undefined)) { errors.push('secret version purge preflight truncation must match nextCursor presence'); } if (Array.isArray(response.references) && Number.isSafeInteger(response.blockingReferenceCount) && (response.blockingReferenceCount as number) < response.references.length) { errors.push('secret version purge preflight reference count cannot be smaller than its page'); } if (Array.isArray(response.blockers) && isRecord(response.version) && Number.isSafeInteger(response.blockingReferenceCount)) { const expectedBlockers: TSecretVersionPurgeBlocker[] = []; if (response.version.lifecycleState === 'active') expectedBlockers.push('version-active'); if ((response.blockingReferenceCount as number) > 0) { expectedBlockers.push('retention-reference'); } if (response.version.lifecycleState === 'purge-pending') { expectedBlockers.push('purge-pending'); } if (response.version.lifecycleState === 'purged') expectedBlockers.push('already-purged'); expectedBlockers.sort(); if (JSON.stringify(response.blockers) !== JSON.stringify(expectedBlockers)) { errors.push('secret version purge preflight blockers do not match current state'); } } if (response.operation !== undefined) { errors.push(...await secretData.validateSecretVersionPurgeOperation(response.operation)); if (isRecord(response.operation) && isRecord(response.operation.intent) && isRecord(response.secret) && isRecord(response.version) && ( response.operation.id !== response.version.purgeOperationId || response.operation.intent.secretId !== response.secret.id || response.operation.intent.secretVersionId !== response.version.id )) { errors.push('secret version purge preflight operation does not match its version'); } if (isRecord(response.operation) && isRecord(response.version)) { if (response.version.lifecycleState === 'purge-pending' && response.operation.state === 'succeeded') { errors.push('purge-pending version cannot have a succeeded purge operation'); } if (response.version.lifecycleState === 'purged' && (response.operation.state !== 'succeeded' || response.operation.purgedAt !== response.version.purgedAt)) { errors.push('purged version requires the matching succeeded purge operation'); } } } if (isRecord(response.version) && (response.version.lifecycleState === 'purge-pending' || response.version.lifecycleState === 'purged') && response.operation === undefined) { errors.push('secret version purge preflight purging state requires its operation'); } return errors; } catch { return ['secret version purge preflight response must be safely inspectable']; } };