diff --git a/.changeset/secure-vault-contracts.md b/.changeset/secure-vault-contracts.md new file mode 100644 index 0000000..9ba1849 --- /dev/null +++ b/.changeset/secure-vault-contracts.md @@ -0,0 +1,5 @@ +--- +'@ankhorage/contracts': minor +--- + +Add provider-neutral secret-store contracts, canonical `infra.secretStore` provider selection, logical OAuth `credentialsRef` support, and validation helpers that reject inline secret fields. diff --git a/package.json b/package.json index 3fd3758..5e8a0e5 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,10 @@ "types": "./dist/runtimeCallbacks.d.ts", "default": "./dist/runtimeCallbacks.js" }, + "./secrets": { + "types": "./dist/secrets.d.ts", + "default": "./dist/secrets.js" + }, "./state": { "types": "./dist/state.d.ts", "default": "./dist/state.js" @@ -66,13 +70,15 @@ "default": "./dist/ui.js" } }, - "description": "Serializable app, action, and theme config contracts for Ankhorage.", + "description": "Serializable app, action, theme, auth, and secret-store contracts for Ankhorage.", "keywords": [ "typescript", "contracts", "schema", "manifest", "auth", + "secrets", + "secret-store", "storage", "adapter" ], diff --git a/src/auth.ts b/src/auth.ts index 1168d9f..b7b0c3d 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,3 +1,4 @@ +import type { SecretRef } from './secrets'; import type { IconSpec } from './types'; export const AUTH_IDENTIFIER_KINDS = ['email', 'phone', 'username'] as const; @@ -82,6 +83,8 @@ export interface AuthOAuthProviderConfig { redirectTo?: string; queryParams?: Record; icon?: IconSpec; + /** Logical server-side secret reference; raw credentials must never be stored here. */ + credentialsRef?: SecretRef; } export interface AuthOAuthConfig { diff --git a/src/index.ts b/src/index.ts index fd77c09..c211b4b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,8 @@ export * from './db'; export * from './nutrition'; export * from './requirements'; export * from './runtimeCallbacks'; +export * from './secretManifest'; +export * from './secrets'; export * from './state'; export * from './storage'; export * from './types'; diff --git a/src/secretManifest.ts b/src/secretManifest.ts new file mode 100644 index 0000000..5dcc8bd --- /dev/null +++ b/src/secretManifest.ts @@ -0,0 +1,12 @@ +import type { SecretStoreProvider } from './secrets'; + +export interface InfraSecretStoreSpec { + provider: SecretStoreProvider; +} + +declare module './types' { + interface InfraManifest { + /** Non-secret provider selection. Bootstrap credentials stay in trusted environment config. */ + secretStore?: InfraSecretStoreSpec; + } +} diff --git a/src/secrets.test.ts b/src/secrets.test.ts new file mode 100644 index 0000000..c2341f6 --- /dev/null +++ b/src/secrets.test.ts @@ -0,0 +1,88 @@ +import './secretManifest'; + +import { describe, expect, test } from 'bun:test'; + +import type { AuthOAuthProviderConfig } from './auth'; +import { + findForbiddenInlineSecretFields, + normalizeSecretRef, + normalizeSecretScope, + validateSecretPayload, +} from './secrets'; +import type { InfraManifest } from './types'; + +describe('secret-store contracts', () => { + test('normalizes logical secret references', () => { + expect(normalizeSecretRef('/auth//oauth/google/')).toEqual({ + ok: true, + data: 'auth/oauth/google', + }); + }); + + test('rejects invalid secret references', () => { + expect(normalizeSecretRef('Auth OAuth/Google')).toEqual({ + ok: false, + error: { + code: 'invalid_reference', + message: + 'Secret reference must contain lowercase path segments using letters, numbers, dots, underscores, or hyphens.', + }, + }); + }); + + test('normalizes project and environment scope', () => { + expect(normalizeSecretScope({ projectId: ' scanner ', environment: ' local ' })).toEqual({ + ok: true, + data: { projectId: 'scanner', environment: 'local' }, + }); + }); + + test('validates non-empty string payloads without exposing values', () => { + expect(validateSecretPayload({ clientId: 'id', clientSecret: 'secret' })).toEqual({ + ok: true, + data: { clientId: 'id', clientSecret: 'secret' }, + }); + + const result = validateSecretPayload({ clientSecret: '' }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('invalid_payload'); + expect(result.error.message).not.toContain('secret-value'); + } + }); + + test('detects forbidden inline secret fields in manifest-shaped provider config', () => { + expect( + findForbiddenInlineSecretFields({ + id: 'google', + credentialsRef: 'auth/oauth/google', + clientSecret: 'sentinel-secret-value', + }), + ).toEqual(['clientSecret']); + }); + + test('supports OAuth credential references and canonical infra provider selection', () => { + const provider: AuthOAuthProviderConfig = { + id: 'google', + enabled: true, + credentialsRef: 'auth/oauth/google', + }; + + const infra: InfraManifest = { + plugins: [], + secretStore: { provider: 'supabase-vault' }, + auth: { + scope: 'global', + provider: 'supabase', + oauth: { + enabled: true, + callbackRoute: '/auth/callback', + providers: [provider], + }, + }, + }; + + expect(infra.secretStore?.provider).toBe('supabase-vault'); + expect(infra.auth?.oauth?.providers[0]?.credentialsRef).toBe('auth/oauth/google'); + }); +}); diff --git a/src/secrets.ts b/src/secrets.ts new file mode 100644 index 0000000..d2941e8 --- /dev/null +++ b/src/secrets.ts @@ -0,0 +1,188 @@ +export const SECRET_STORE_PROVIDERS = ['supabase-vault'] as const; +export type KnownSecretStoreProvider = (typeof SECRET_STORE_PROVIDERS)[number]; +export type SecretStoreProvider = KnownSecretStoreProvider | (string & {}); + +export type SecretRef = string; + +export interface SecretScope { + projectId: string; + environment: string; +} + +export type SecretPayload = Readonly>; + +export interface SecretMetadata { + ref: SecretRef; + scope: SecretScope; + kind: string; + provider?: string; + configuredFields: readonly string[]; + createdAt: string; + updatedAt: string; +} + +export const SECRET_STORE_ERROR_CODES = [ + 'invalid_config', + 'invalid_reference', + 'invalid_payload', + 'not_found', + 'conflict', + 'permission_denied', + 'unavailable', + 'provider_error', +] as const; +export type SecretStoreErrorCode = (typeof SECRET_STORE_ERROR_CODES)[number]; + +export interface SecretStoreError { + code: SecretStoreErrorCode; + message: string; + cause?: unknown; +} + +export type SecretStoreOkResult = [TData] extends [void] + ? { ok: true; data?: undefined } + : { ok: true; data: TData }; + +export type SecretStoreResult = + | SecretStoreOkResult + | { + ok: false; + error: SecretStoreError; + }; + +export interface SecretListInput { + scope: SecretScope; + kind?: string; + provider?: string; +} + +export interface SecretGetMetadataInput { + scope: SecretScope; + ref: SecretRef; +} + +export interface SecretCreateInput { + scope: SecretScope; + ref: SecretRef; + kind: string; + provider?: string; + payload: SecretPayload; +} + +export interface SecretReplaceInput { + scope: SecretScope; + ref: SecretRef; + payload: SecretPayload; +} + +export interface SecretRemoveInput { + scope: SecretScope; + ref: SecretRef; +} + +export interface SecretResolveInput { + scope: SecretScope; + ref: SecretRef; +} + +/** + * Provider-neutral server-side secret-store boundary. + * + * `resolve` is for trusted server/deployment code only. Browser bridges must expose + * metadata operations without forwarding raw secret payloads. + */ +export interface SecretStoreAdapter { + list(input: SecretListInput): Promise>; + getMetadata(input: SecretGetMetadataInput): Promise>; + create(input: SecretCreateInput): Promise>; + replace(input: SecretReplaceInput): Promise>; + remove(input: SecretRemoveInput): Promise; + resolve(input: SecretResolveInput): Promise>; +} + +const SECRET_REF_SEGMENT_PATTERN = /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/; + +export function normalizeSecretRef(value: string): SecretStoreResult { + const normalized = value + .trim() + .replace(/^\/+|\/+$/g, '') + .replace(/\/{2,}/g, '/'); + + if ( + normalized.length === 0 || + normalized.length > 255 || + normalized.split('/').some((segment) => !SECRET_REF_SEGMENT_PATTERN.test(segment)) + ) { + return { + ok: false, + error: { + code: 'invalid_reference', + message: + 'Secret reference must contain lowercase path segments using letters, numbers, dots, underscores, or hyphens.', + }, + }; + } + + return { ok: true, data: normalized }; +} + +export function normalizeSecretScope(scope: SecretScope): SecretStoreResult { + const projectId = scope.projectId.trim(); + const environment = scope.environment.trim(); + + if (projectId.length === 0 || environment.length === 0) { + return { + ok: false, + error: { + code: 'invalid_config', + message: 'Secret scope requires non-empty projectId and environment values.', + }, + }; + } + + return { ok: true, data: { projectId, environment } }; +} + +export function validateSecretPayload(payload: SecretPayload): SecretStoreResult { + const entries = Object.entries(payload); + + if (entries.length === 0) { + return { + ok: false, + error: { + code: 'invalid_payload', + message: 'Secret payload must contain at least one field.', + }, + }; + } + + for (const [field, value] of entries) { + if (field.trim().length === 0 || typeof value !== 'string' || value.length === 0) { + return { + ok: false, + error: { + code: 'invalid_payload', + message: `Secret payload field ${field.trim().length === 0 ? '' : field} must contain a non-empty string value.`, + }, + }; + } + } + + return { ok: true, data: Object.freeze(Object.fromEntries(entries)) }; +} + +export const FORBIDDEN_INLINE_SECRET_FIELDS = [ + 'apiKey', + 'clientSecret', + 'databasePassword', + 'privateKey', + 'serviceRoleKey', + 'token', +] as const; + +export function findForbiddenInlineSecretFields(value: unknown): readonly string[] { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return []; + + const record = value as Record; + return FORBIDDEN_INLINE_SECRET_FIELDS.filter((field) => field in record); +}