From 8540a8277af5de7ec288a9a3d749622fe41e8de0 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:17:23 +0200 Subject: [PATCH 01/12] feat: add provider-neutral secret-store contracts --- src/secrets.ts | 185 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 src/secrets.ts diff --git a/src/secrets.ts b/src/secrets.ts new file mode 100644 index 0000000..1353b05 --- /dev/null +++ b/src/secrets.ts @@ -0,0 +1,185 @@ +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); +} From 18e42d5a431757015b1e4e04caa38f1af4d039a7 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:17:54 +0200 Subject: [PATCH 02/12] feat: reference OAuth credentials through secret store --- src/auth.ts | 3 +++ 1 file changed, 3 insertions(+) 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 { From cae96ef25ffad3391efeca1baf96c61327833694 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:18:24 +0200 Subject: [PATCH 03/12] feat: export secret-store contracts --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index fd77c09..f9b6a3e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ export * from './db'; export * from './nutrition'; export * from './requirements'; export * from './runtimeCallbacks'; +export * from './secrets'; export * from './state'; export * from './storage'; export * from './types'; From a183fa51bfca79cb7b5af933cd33a27d90a85dcc Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:18:49 +0200 Subject: [PATCH 04/12] feat: expose secret-store contract entrypoint --- package.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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" ], From e234c8391fa0bfa03c6096e065c636f8578b2994 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:19:18 +0200 Subject: [PATCH 05/12] feat: add canonical infra secret-store selection --- src/secretManifest.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/secretManifest.ts 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; + } +} From f5e44a3bdee0e74215665ded2619cb3372b6d06f Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:19:52 +0200 Subject: [PATCH 06/12] feat: export secret-store manifest contract --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index f9b6a3e..c211b4b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ export * from './db'; export * from './nutrition'; export * from './requirements'; export * from './runtimeCallbacks'; +export * from './secretManifest'; export * from './secrets'; export * from './state'; export * from './storage'; From 64d526b708f0b0bc48fdde75d46a3e91aa6705a5 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:20:16 +0200 Subject: [PATCH 07/12] test: cover secret-store contracts and validation --- src/secrets.test.ts | 87 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/secrets.test.ts diff --git a/src/secrets.test.ts b/src/secrets.test.ts new file mode 100644 index 0000000..f648c7e --- /dev/null +++ b/src/secrets.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from 'bun:test'; + +import type { AuthOAuthProviderConfig } from './auth'; +import type { InfraManifest } from './types'; +import { + findForbiddenInlineSecretFields, + normalizeSecretRef, + normalizeSecretScope, + validateSecretPayload, +} from './secrets'; +import './secretManifest'; + +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).toBeFalse(); + 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'); + }); +}); From 86ab9d62b47d2bc6af17bd712d1a072303279450 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:20:45 +0200 Subject: [PATCH 08/12] chore: add secret-store contracts changeset --- .changeset/secure-vault-contracts.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/secure-vault-contracts.md diff --git a/.changeset/secure-vault-contracts.md b/.changeset/secure-vault-contracts.md new file mode 100644 index 0000000..6145253 --- /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. From 3c2ba8c7cac5d13994d79e10460fd76e438c238b Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:21:25 +0200 Subject: [PATCH 09/12] test: use portable boolean matcher --- src/secrets.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/secrets.test.ts b/src/secrets.test.ts index f648c7e..7c1261b 100644 --- a/src/secrets.test.ts +++ b/src/secrets.test.ts @@ -43,7 +43,7 @@ describe('secret-store contracts', () => { }); const result = validateSecretPayload({ clientSecret: '' }); - expect(result.ok).toBeFalse(); + expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.code).toBe('invalid_payload'); expect(result.error.message).not.toContain('secret-value'); From 67c8289906fc0f9bef2f6a502dd2f2f71febfa67 Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:31:41 +0200 Subject: [PATCH 10/12] ci: apply canonical formatting to secret contracts --- .github/workflows/agent-fix.yml | 35 +++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/agent-fix.yml diff --git a/.github/workflows/agent-fix.yml b/.github/workflows/agent-fix.yml new file mode 100644 index 0000000..51c0229 --- /dev/null +++ b/.github/workflows/agent-fix.yml @@ -0,0 +1,35 @@ +name: Agent Fix + +on: + push: + branches: + - agent/phase2-secret-store-contracts + +permissions: + contents: write + +jobs: + fix: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: agent/phase2-secret-store-contracts + fetch-depth: 0 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.13' + - run: bun install --frozen-lockfile + - run: bun run lint:fix + - run: bun run format + - name: Commit canonical fixes + run: | + if git diff --quiet; then + exit 0 + fi + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add -A + git commit -m "chore: apply canonical formatting" + git push origin HEAD:agent/phase2-secret-store-contracts From 83beefe2db2f7a843b9efd5f94d90557294322b4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:31:56 +0000 Subject: [PATCH 11/12] chore: apply canonical formatting --- .changeset/secure-vault-contracts.md | 2 +- src/secrets.test.ts | 5 +++-- src/secrets.ts | 5 ++++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.changeset/secure-vault-contracts.md b/.changeset/secure-vault-contracts.md index 6145253..9ba1849 100644 --- a/.changeset/secure-vault-contracts.md +++ b/.changeset/secure-vault-contracts.md @@ -1,5 +1,5 @@ --- -"@ankhorage/contracts": minor +'@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/src/secrets.test.ts b/src/secrets.test.ts index 7c1261b..c2341f6 100644 --- a/src/secrets.test.ts +++ b/src/secrets.test.ts @@ -1,14 +1,15 @@ +import './secretManifest'; + import { describe, expect, test } from 'bun:test'; import type { AuthOAuthProviderConfig } from './auth'; -import type { InfraManifest } from './types'; import { findForbiddenInlineSecretFields, normalizeSecretRef, normalizeSecretScope, validateSecretPayload, } from './secrets'; -import './secretManifest'; +import type { InfraManifest } from './types'; describe('secret-store contracts', () => { test('normalizes logical secret references', () => { diff --git a/src/secrets.ts b/src/secrets.ts index 1353b05..d2941e8 100644 --- a/src/secrets.ts +++ b/src/secrets.ts @@ -103,7 +103,10 @@ export interface SecretStoreAdapter { 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, '/'); + const normalized = value + .trim() + .replace(/^\/+|\/+$/g, '') + .replace(/\/{2,}/g, '/'); if ( normalized.length === 0 || From 4749d1cd4c4665095a5448407cf740b4a5a0322c Mon Sep 17 00:00:00 2001 From: Fabio Gartenmann <137318798+artiphishle@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:33:46 +0200 Subject: [PATCH 12/12] ci: remove temporary formatter workflow --- .github/workflows/agent-fix.yml | 35 --------------------------------- 1 file changed, 35 deletions(-) delete mode 100644 .github/workflows/agent-fix.yml diff --git a/.github/workflows/agent-fix.yml b/.github/workflows/agent-fix.yml deleted file mode 100644 index 51c0229..0000000 --- a/.github/workflows/agent-fix.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Agent Fix - -on: - push: - branches: - - agent/phase2-secret-store-contracts - -permissions: - contents: write - -jobs: - fix: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: agent/phase2-secret-store-contracts - fetch-depth: 0 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: '1.3.13' - - run: bun install --frozen-lockfile - - run: bun run lint:fix - - run: bun run format - - name: Commit canonical fixes - run: | - if git diff --quiet; then - exit 0 - fi - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add -A - git commit -m "chore: apply canonical formatting" - git push origin HEAD:agent/phase2-secret-store-contracts