import type { PlaySecretAuth, PlaySecretAwareRequestInit, PlaySecretHandle, PlaySecretPromise, } from '../plays/authoring-contract'; import { sha256Hex } from '../plays/row-identity'; const SECRET_HANDLE_BRAND = Symbol.for('deepline.secret.handle'); const SECRET_AUTH_BRAND = Symbol.for('deepline.secret.auth'); const SECRET_VALUE_BRAND = Symbol.for('deepline.secret.value'); const SECRET_PLAINTEXT_PROMISE_BRAND = Symbol.for( 'deepline.secret.plaintext-promise', ); const SECRET_HANDLE_MARKER_RE = /\[secret:[A-Z0-9_ -]+\]/i; export type SecretHandle = PlaySecretHandle & { readonly [SECRET_HANDLE_BRAND]: true; readonly name: string; toString(): string; toJSON(): never; }; /** * Runtime-only opaque values retained to execute authoring-contract editions * 1–3. Edition 4's public Play type deliberately no longer exposes these * helpers; published legacy artifacts still invoke them at runtime. */ type SecretExpression = | { readonly [SECRET_VALUE_BRAND]: true; readonly kind: 'concat'; readonly parts: readonly (string | SecretValue)[]; toString(): string; toJSON(): never; } | { readonly [SECRET_VALUE_BRAND]: true; readonly kind: 'base64'; readonly value: SecretValue; toString(): string; toJSON(): never; }; export type SecretValue = SecretHandle | SecretExpression; export type PlaintextSecretPromise = PlaySecretPromise & { readonly [SECRET_PLAINTEXT_PROMISE_BRAND]: true; readonly name: string; }; export type SecretAuthValue = | string | PlaySecretPromise | PlaintextSecretPromise | PlaySecretHandle | SecretValue; export type SecretAuth = Omit & ( | { readonly [SECRET_AUTH_BRAND]: true; readonly kind: 'bearer'; readonly secret: SecretAuthValue; } | { readonly [SECRET_AUTH_BRAND]: true; readonly kind: 'header'; readonly header: string; readonly secret: SecretAuthValue; } ); export type SecretAuthInput = SecretAuth | readonly SecretAuth[]; export type SecretAwareRequestInit = | PlaySecretAwareRequestInit | (Omit & { auth?: SecretAuthInput; }); function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } export function isSecretHandle(value: unknown): value is SecretHandle { return isRecord(value) && value[SECRET_HANDLE_BRAND] === true; } export function isSecretValue(value: unknown): value is SecretValue { return ( isSecretHandle(value) || (isRecord(value) && value[SECRET_VALUE_BRAND] === true) ); } export function isPlaintextSecretPromise( value: unknown, ): value is PlaintextSecretPromise { return ( Boolean(value) && typeof value === 'object' && (value as Record)[ SECRET_PLAINTEXT_PROMISE_BRAND ] === true ); } export function isSecretAuth(value: unknown): value is SecretAuth { return isRecord(value) && value[SECRET_AUTH_BRAND] === true; } export function isSecretAuthInput(value: unknown): value is SecretAuthInput { return ( isSecretAuth(value) || (Array.isArray(value) && value.length > 0 && value.every((entry) => isSecretAuth(entry))) ); } export function secretAuthEntries( auth: SecretAuthInput | undefined, ): readonly SecretAuth[] { if (!auth) return []; return isSecretAuth(auth) ? [auth] : auth; } export function valueContainsSecret(value: unknown): boolean { const pending: unknown[] = [value]; const seen = new WeakSet(); while (pending.length > 0) { const candidate = pending.pop(); if (isSecretValue(candidate) || isSecretAuth(candidate)) return true; if (typeof candidate === 'string') { if (SECRET_HANDLE_MARKER_RE.test(candidate)) return true; continue; } if (!candidate || typeof candidate !== 'object') continue; if (seen.has(candidate)) continue; seen.add(candidate); if (Array.isArray(candidate)) { for (const entry of candidate) pending.push(entry); } else if (isRecord(candidate)) { for (const entry of Object.values(candidate)) pending.push(entry); } } return false; } export function createSecretHandle(name: string): SecretHandle { return { [SECRET_HANDLE_BRAND]: true, name, toString: () => `[secret:${name}]`, toJSON: () => { throw new Error( `Secret ${name} cannot be serialized. Use an approved ctx.secrets helper.`, ); }, } as unknown as SecretHandle; } export function createPlaintextSecretPromise( name: string, resolve: () => Promise, ): PlaintextSecretPromise { const value = resolve() as PlaintextSecretPromise; Object.defineProperties(value, { [SECRET_PLAINTEXT_PROMISE_BRAND]: { value: true }, name: { value: name }, }); return value; } export function createBearerSecretAuth(secret: SecretAuthValue): SecretAuth { // A generic promise has no secret provenance or stable receipt marker. Only // the promise created by ctx.secrets.get(...) carries the runtime brand. if ( typeof secret !== 'string' && !isSecretValue(secret) && !isPlaintextSecretPromise(secret) ) { throw new Error( 'ctx.secrets.bearer(...) requires a resolved string or legacy secret handle. Await ctx.secrets.get(...) first.', ); } return { [SECRET_AUTH_BRAND]: true, kind: 'bearer', secret }; } export function createHeaderSecretAuth( header: string, secret: SecretAuthValue, ): SecretAuth { // Keep this check in sync with bearer: ordinary promises must be awaited // before auth construction, while the branded get() promise is compatible. if ( typeof secret !== 'string' && !isSecretValue(secret) && !isPlaintextSecretPromise(secret) ) { throw new Error( 'ctx.secrets.header(...) requires a resolved string or legacy secret handle. Await ctx.secrets.get(...) first.', ); } if (typeof header !== 'string' || !header.trim()) { throw new Error('ctx.secrets.header(...) requires a header name.'); } return { [SECRET_AUTH_BRAND]: true, kind: 'header', header: header.trim(), secret, }; } export function createSecretConcat( parts: readonly (string | SecretValue)[], ): SecretValue { if (parts.length === 0 || !parts.some(isSecretValue)) { throw new Error( 'ctx.secrets.concat(...) requires at least one secret value.', ); } if (!parts.every((part) => typeof part === 'string' || isSecretValue(part))) { throw new Error( 'ctx.secrets.concat(...) accepts strings and secret values only.', ); } return { [SECRET_VALUE_BRAND]: true, kind: 'concat', parts, toString: () => '[secret-expression]', toJSON: () => { throw new Error( 'Secret expressions cannot be serialized. Use them only with ctx.secrets auth helpers.', ); }, } as SecretValue; } export function createBase64SecretValue(value: SecretValue): SecretValue { if (!isSecretValue(value)) { throw new Error('ctx.secrets.base64(...) requires a secret value.'); } return { [SECRET_VALUE_BRAND]: true, kind: 'base64', value, toString: () => '[secret-expression]', toJSON: () => { throw new Error( 'Secret expressions cannot be serialized. Use them only with ctx.secrets auth helpers.', ); }, } as SecretValue; } export function secretValueMarker(value: SecretValue): string { if (isSecretHandle(value)) return `[secret:${value.name}]`; if (value.kind === 'base64') return `[secret-base64:${secretValueMarker(value.value)}]`; return `[secret-concat:${value.parts .map((part) => typeof part === 'string' ? `[literal:${sha256Hex(part)}]` : secretValueMarker(part), ) .join('+')}]`; } export function secretAuthHeaderMarkers( auth: SecretAuthInput | undefined, ): Record { const markers: Record = {}; for (const entry of secretAuthEntries(auth)) { const header = entry.kind === 'bearer' ? 'authorization' : entry.header.toLowerCase(); if (markers[header] !== undefined) { throw new Error( `ctx.fetch cannot attach more than one secret to the ${header} header.`, ); } markers[header] = typeof entry.secret === 'string' ? `[secret-plaintext:${sha256Hex(entry.secret)}]` : isPlaintextSecretPromise(entry.secret) ? `[secret:${entry.secret.name}]` : secretValueMarker(entry.secret as SecretValue); } return markers; } export function assertSecretAuthUsesTls( auth: SecretAuthInput | undefined, input: string | URL, sink: string, ): void { if (!auth) return; const url = input instanceof URL ? input : new URL(input); if (url.protocol === 'https:') return; throw new Error( `${sink} with ctx.secrets auth requires an https:// URL. Customer secrets may only leave Deepline over TLS.`, ); } export function assertNoSecretTaint(value: unknown, sink: string): void { if (valueContainsSecret(value)) { throw new Error( `${sink} cannot receive secret handles or secret-tainted values. Use an approved ctx.secrets helper.`, ); } }