Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/secure-vault-contracts.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
],
Expand Down
3 changes: 3 additions & 0 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { SecretRef } from './secrets';
import type { IconSpec } from './types';

export const AUTH_IDENTIFIER_KINDS = ['email', 'phone', 'username'] as const;
Expand Down Expand Up @@ -82,6 +83,8 @@ export interface AuthOAuthProviderConfig {
redirectTo?: string;
queryParams?: Record<string, string>;
icon?: IconSpec;
/** Logical server-side secret reference; raw credentials must never be stored here. */
credentialsRef?: SecretRef;
}

export interface AuthOAuthConfig {
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
12 changes: 12 additions & 0 deletions src/secretManifest.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
88 changes: 88 additions & 0 deletions src/secrets.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
188 changes: 188 additions & 0 deletions src/secrets.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>>;

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> = [TData] extends [void]
? { ok: true; data?: undefined }
: { ok: true; data: TData };

export type SecretStoreResult<TData = void> =
| SecretStoreOkResult<TData>
| {
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<SecretStoreResult<readonly SecretMetadata[]>>;
getMetadata(input: SecretGetMetadataInput): Promise<SecretStoreResult<SecretMetadata>>;
create(input: SecretCreateInput): Promise<SecretStoreResult<SecretMetadata>>;
replace(input: SecretReplaceInput): Promise<SecretStoreResult<SecretMetadata>>;
remove(input: SecretRemoveInput): Promise<SecretStoreResult>;
resolve(input: SecretResolveInput): Promise<SecretStoreResult<SecretPayload>>;
}

const SECRET_REF_SEGMENT_PATTERN = /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/;

export function normalizeSecretRef(value: string): SecretStoreResult<SecretRef> {
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<SecretScope> {
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<SecretPayload> {
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 ? '<empty>' : 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<string, unknown>;
return FORBIDDEN_INLINE_SECRET_FIELDS.filter((field) => field in record);
}
Loading