This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Authentication-provider boundary.
|
||||
*
|
||||
* Providers prove an external identity. They do not decide whether that
|
||||
* identity belongs to PIG; workspace membership remains a database decision
|
||||
* in the authenticator and signup route.
|
||||
*/
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
import type { Config } from './config';
|
||||
|
||||
export interface VerifiedIdentity {
|
||||
subject: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface AuthProvider {
|
||||
verifyAccessToken(token: string): Promise<VerifiedIdentity>;
|
||||
}
|
||||
|
||||
export function createSupabaseAuthProvider(supabaseUrl: string): AuthProvider {
|
||||
const issuer = `${supabaseUrl}/auth/v1`;
|
||||
// `jose` fetches lazily and caches this set, including safe key rotation.
|
||||
// Sharing one provider instance avoids a remote lookup path per handler.
|
||||
const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));
|
||||
|
||||
return {
|
||||
async verifyAccessToken(token: string): Promise<VerifiedIdentity> {
|
||||
const { payload } = await jwtVerify(token, jwks, { issuer });
|
||||
if (!payload.sub) throw new Error('token has no subject');
|
||||
|
||||
return {
|
||||
subject: payload.sub,
|
||||
email: typeof payload.email === 'string' ? payload.email : undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createConfiguredAuthProvider(
|
||||
config: Pick<Config, 'SUPABASE_URL'>,
|
||||
): AuthProvider | null {
|
||||
return config.SUPABASE_URL ? createSupabaseAuthProvider(config.SUPABASE_URL) : null;
|
||||
}
|
||||
+70
-32
@@ -4,9 +4,8 @@
|
||||
* The distinction is the whole point of this file, and it is the thing most
|
||||
* likely to be got wrong by someone extending PIG later:
|
||||
*
|
||||
* **Authentication** answers "who is this?" and is delegated to Supabase.
|
||||
* PIG verifies the JWT against the project's JWKS. It stores no passwords
|
||||
* and issues no sessions of its own.
|
||||
* **Authentication** answers "who is this?" and is delegated to an identity
|
||||
* provider. PIG stores no passwords and issues no sessions of its own.
|
||||
*
|
||||
* **Authorization** answers "may they use PIG?" and is answered ONLY by a row
|
||||
* in PIG's `users` table.
|
||||
@@ -20,13 +19,21 @@
|
||||
* A token with no matching PIG user gets 403 with `needs_profile`, which the
|
||||
* front end turns into the invite-redemption screen.
|
||||
*/
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { Database } from '@pig/db';
|
||||
import { apiKeys, teamMemberships, users } from '@pig/db';
|
||||
import type { Team, TeamRole } from '@pig/core';
|
||||
import {
|
||||
permissionGranted,
|
||||
resolvePermissionGrants,
|
||||
type Capability,
|
||||
type PermissionGrant,
|
||||
type Team,
|
||||
type TeamCapability,
|
||||
type TeamRole,
|
||||
} from '@pig/core';
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
import type { Config } from './config';
|
||||
import type { AuthProvider } from './auth-provider';
|
||||
|
||||
export interface Principal {
|
||||
userId: string;
|
||||
@@ -51,13 +58,11 @@ export class AuthError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export function createAuthenticator(config: Config, db: Database) {
|
||||
// The JWKS is fetched lazily and cached by `jose`, which also handles key
|
||||
// rotation. Building it once avoids a fetch per request.
|
||||
const jwks = config.SUPABASE_URL
|
||||
? createRemoteJWKSet(new URL(`${config.SUPABASE_URL}/auth/v1/.well-known/jwks.json`))
|
||||
: null;
|
||||
|
||||
export function createAuthenticator(
|
||||
config: Config,
|
||||
db: Database,
|
||||
authProvider: AuthProvider | null,
|
||||
) {
|
||||
async function loadPrincipal(
|
||||
userId: string,
|
||||
via: Principal['via'],
|
||||
@@ -95,13 +100,21 @@ export function createAuthenticator(config: Config, db: Database) {
|
||||
* Accepts either a Supabase JWT or a PIG API key, both in the
|
||||
* Authorization header. API keys exist so that an agent acting for a person
|
||||
* is a distinct principal from that person — separately revocable, with its
|
||||
* own audit trail and its own scopes.
|
||||
* own audit trail and its own scopes.
|
||||
*/
|
||||
async authenticate(header: string | undefined): Promise<Principal> {
|
||||
const token = header?.startsWith('Bearer ') ? header.slice(7).trim() : null;
|
||||
|
||||
// An explicit PIG key must be honoured even in development. Otherwise a
|
||||
// revoked or malformed key silently becomes the development user, which
|
||||
// makes local integration tests pass without testing the credential at
|
||||
// all and hides the exact failures developers need to see.
|
||||
if (token?.startsWith('pig_')) return authenticateApiKey(token);
|
||||
|
||||
// Development escape hatch. Guarded three ways, and `loadConfig` refuses
|
||||
// to start in production without Supabase, so this cannot leak into a
|
||||
// real deployment.
|
||||
if (!config.SUPABASE_URL && !config.isProduction) {
|
||||
// to start in production without identity configuration, so this cannot
|
||||
// leak into a real deployment.
|
||||
if (!authProvider && !config.isProduction) {
|
||||
const [devUser] = await db.select().from(users).limit(1);
|
||||
if (!devUser) {
|
||||
throw new AuthError(
|
||||
@@ -116,22 +129,14 @@ export function createAuthenticator(config: Config, db: Database) {
|
||||
if (!header?.startsWith('Bearer ')) {
|
||||
throw new AuthError('Missing bearer token.', 401, 'no_token');
|
||||
}
|
||||
const token = header.slice(7).trim();
|
||||
|
||||
// PIG-issued API keys carry a recognisable prefix, so we can route
|
||||
// without attempting an expensive and pointless JWT verification.
|
||||
if (token.startsWith('pig_')) return authenticateApiKey(token);
|
||||
|
||||
if (!jwks) throw new AuthError('Authentication is not configured.', 401, 'no_jwks');
|
||||
if (!authProvider) {
|
||||
throw new AuthError('Authentication is not configured.', 401, 'no_jwks');
|
||||
}
|
||||
|
||||
let subject: string;
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, jwks, {
|
||||
// Supabase signs with the project URL as issuer.
|
||||
issuer: `${config.SUPABASE_URL}/auth/v1`,
|
||||
});
|
||||
if (!payload.sub) throw new Error('token has no subject');
|
||||
subject = payload.sub;
|
||||
subject = (await authProvider.verifyAccessToken(token!)).subject;
|
||||
} catch {
|
||||
// Deliberately opaque: distinguishing "expired" from "malformed" from
|
||||
// "wrong issuer" tells an attacker which knob to turn.
|
||||
@@ -167,10 +172,7 @@ export function createAuthenticator(config: Config, db: Database) {
|
||||
.limit(1);
|
||||
|
||||
if (!record) throw new AuthError('Unknown API key.', 401, 'invalid_key');
|
||||
if (record.revokedAt) throw new AuthError('This API key was revoked.', 401, 'revoked_key');
|
||||
if (record.expiresAt && record.expiresAt < new Date()) {
|
||||
throw new AuthError('This API key has expired.', 401, 'expired_key');
|
||||
}
|
||||
assertApiKeyActive(record);
|
||||
|
||||
// Best-effort last-used stamp. Never block the request on it: a failed
|
||||
// bookkeeping write must not deny access.
|
||||
@@ -187,6 +189,16 @@ export function createAuthenticator(config: Config, db: Database) {
|
||||
}
|
||||
}
|
||||
|
||||
export function assertApiKeyActive(
|
||||
record: { revokedAt: Date | null; expiresAt: Date | null },
|
||||
now = new Date(),
|
||||
): void {
|
||||
if (record.revokedAt) throw new AuthError('This API key was revoked.', 401, 'revoked_key');
|
||||
if (record.expiresAt && record.expiresAt < now) {
|
||||
throw new AuthError('This API key has expired.', 401, 'expired_key');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash an API key for storage and lookup.
|
||||
*
|
||||
@@ -227,3 +239,29 @@ export function requireScope(principal: Principal, scope: string): void {
|
||||
if (principal.scopes.includes(scope)) return;
|
||||
throw new AuthError(`This credential lacks the '${scope}' scope.`, 403, 'insufficient_scope');
|
||||
}
|
||||
|
||||
/** Effective grants include credential scope, not merely the owner's roles. */
|
||||
export function effectivePermissions(principal: Principal): PermissionGrant[] {
|
||||
if (!principal.scopes.includes('write')) return [];
|
||||
return resolvePermissionGrants(principal);
|
||||
}
|
||||
|
||||
export function requireCapability(principal: Principal, capability: Capability): void;
|
||||
export function requireCapability(
|
||||
principal: Principal,
|
||||
capability: TeamCapability,
|
||||
team: Team,
|
||||
): void;
|
||||
export function requireCapability(
|
||||
principal: Principal,
|
||||
capability: Capability,
|
||||
team?: Team,
|
||||
): void {
|
||||
requireScope(principal, 'write');
|
||||
if (permissionGranted(resolvePermissionGrants(principal), capability, team)) return;
|
||||
throw new AuthError(
|
||||
`This principal lacks the '${capability}' capability${team ? ` for ${team}` : ''}.`,
|
||||
403,
|
||||
'insufficient_permission',
|
||||
);
|
||||
}
|
||||
|
||||
+119
-4
@@ -26,6 +26,9 @@ const envBoolean = (defaultValue: boolean) =>
|
||||
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
|
||||
});
|
||||
|
||||
const optionalEnvString = (value: unknown) =>
|
||||
typeof value === 'string' && value.trim() === '' ? undefined : value;
|
||||
|
||||
const schema = z.object({
|
||||
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required.'),
|
||||
|
||||
@@ -45,16 +48,126 @@ const schema = z.object({
|
||||
PRIME_SYNC_ENABLED: envBoolean(false),
|
||||
PRIME_SYNC_INTERVAL_MINUTES: z.coerce.number().int().positive().default(30),
|
||||
|
||||
/** Base64-encoded 32-byte key. Secrets written in the admin UI require it. */
|
||||
PIG_SETTINGS_ENCRYPTION_KEY: z.string().optional(),
|
||||
|
||||
PIGGY_ENABLED: envBoolean(false),
|
||||
ANTHROPIC_API_KEY: z.string().optional(),
|
||||
PIGGY_MODEL: z.string().default('claude-sonnet-5'),
|
||||
PIGGY_MODEL: z.string().default('nvidia/nemotron-3-nano-30b-a3b'),
|
||||
PIGGY_INFERENCE_BASE: z.string().url().default('https://api.pinference.ai/api/v1'),
|
||||
PIGGY_LEASE_SECONDS: z.coerce.number().int().positive().default(300),
|
||||
PIGGY_INTERNAL_URL: z.preprocess(
|
||||
(value) => (value === '' ? undefined : value),
|
||||
z.string().url().optional(),
|
||||
),
|
||||
PIGGY_INTERNAL_TOKEN: z.preprocess(
|
||||
(value) => (value === '' ? undefined : value),
|
||||
z.string().min(32).optional(),
|
||||
),
|
||||
|
||||
SLACK_BOT_TOKEN: z.string().optional(),
|
||||
SLACK_SIGNING_SECRET: z.string().optional(),
|
||||
BUZZ_RELAY_URL: z.string().optional(),
|
||||
BUZZ_RELAY_URL: z.preprocess(optionalEnvString, z.string().url().optional()),
|
||||
BUZZ_PRIVATE_KEY: z.preprocess(optionalEnvString, z.string().min(1).optional()),
|
||||
BUZZ_AUTH_TAG: z.preprocess(optionalEnvString, z.string().min(1).optional()),
|
||||
|
||||
NOTION_CLIENT_ID: z.preprocess(optionalEnvString, z.string().min(1).optional()),
|
||||
NOTION_CLIENT_SECRET: z.preprocess(optionalEnvString, z.string().min(1).optional()),
|
||||
NOTION_REDIRECT_URI: z.preprocess(optionalEnvString, z.string().url().optional()),
|
||||
GOOGLE_CLIENT_ID: z.preprocess(optionalEnvString, z.string().min(1).optional()),
|
||||
GOOGLE_CLIENT_SECRET: z.preprocess(optionalEnvString, z.string().min(1).optional()),
|
||||
GOOGLE_REDIRECT_URI: z.preprocess(optionalEnvString, z.string().url().optional()),
|
||||
}).superRefine((value, context) => {
|
||||
const buzzConfigured = Boolean(
|
||||
value.BUZZ_RELAY_URL || value.BUZZ_PRIVATE_KEY || value.BUZZ_AUTH_TAG,
|
||||
);
|
||||
if (buzzConfigured && !value.BUZZ_RELAY_URL) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['BUZZ_RELAY_URL'],
|
||||
message: 'BUZZ_RELAY_URL is required when Buzz delivery is configured.',
|
||||
});
|
||||
}
|
||||
if (buzzConfigured && !value.BUZZ_PRIVATE_KEY) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['BUZZ_PRIVATE_KEY'],
|
||||
message: 'BUZZ_PRIVATE_KEY is required when Buzz delivery is configured.',
|
||||
});
|
||||
}
|
||||
|
||||
const notionConfigured = Boolean(
|
||||
value.NOTION_CLIENT_ID || value.NOTION_CLIENT_SECRET || value.NOTION_REDIRECT_URI,
|
||||
);
|
||||
for (const key of ['NOTION_CLIENT_ID', 'NOTION_CLIENT_SECRET', 'NOTION_REDIRECT_URI'] as const) {
|
||||
if (notionConfigured && !value[key]) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [key],
|
||||
message: `${key} is required when Notion import is configured.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (notionConfigured && !hasValidEncryptionKey(value.PIG_SETTINGS_ENCRYPTION_KEY)) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['PIG_SETTINGS_ENCRYPTION_KEY'],
|
||||
message: 'A base64-encoded 32-byte PIG_SETTINGS_ENCRYPTION_KEY is required for Notion OAuth.',
|
||||
});
|
||||
}
|
||||
|
||||
const googleConfigured = Boolean(
|
||||
value.GOOGLE_CLIENT_ID || value.GOOGLE_CLIENT_SECRET || value.GOOGLE_REDIRECT_URI,
|
||||
);
|
||||
for (const key of ['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET', 'GOOGLE_REDIRECT_URI'] as const) {
|
||||
if (googleConfigured && !value[key]) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [key],
|
||||
message: `${key} is required when Google Sheets import is configured.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (googleConfigured && !hasValidEncryptionKey(value.PIG_SETTINGS_ENCRYPTION_KEY)) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['PIG_SETTINGS_ENCRYPTION_KEY'],
|
||||
message: 'A base64-encoded 32-byte PIG_SETTINGS_ENCRYPTION_KEY is required for Google OAuth.',
|
||||
});
|
||||
}
|
||||
if (value.GOOGLE_REDIRECT_URI) {
|
||||
try {
|
||||
const redirect = new URL(value.GOOGLE_REDIRECT_URI);
|
||||
const publicUrl = new URL(value.PIG_PUBLIC_URL);
|
||||
if (
|
||||
redirect.origin !== publicUrl.origin
|
||||
|| redirect.pathname !== '/oauth/google/callback'
|
||||
|| redirect.search
|
||||
|| redirect.hash
|
||||
) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['GOOGLE_REDIRECT_URI'],
|
||||
message: 'GOOGLE_REDIRECT_URI must use the PIG_PUBLIC_URL origin and exact /oauth/google/callback path.',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['GOOGLE_REDIRECT_URI'],
|
||||
message: 'GOOGLE_REDIRECT_URI must use the PIG_PUBLIC_URL origin and exact /oauth/google/callback path.',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function hasValidEncryptionKey(value: string | undefined): boolean {
|
||||
if (!value) return false;
|
||||
const decoded = Buffer.from(value, 'base64');
|
||||
return decoded.length === 32
|
||||
&& decoded.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export type Config = z.infer<typeof schema> & {
|
||||
adminEmails: string[];
|
||||
isProduction: boolean;
|
||||
@@ -108,8 +221,10 @@ function warnOnFootguns(config: Config): void {
|
||||
warn('PRIME_SYNC_ENABLED is on but PRIME_API_KEY is unset — sync will not run.');
|
||||
}
|
||||
|
||||
if (config.PIGGY_ENABLED && !config.ANTHROPIC_API_KEY) {
|
||||
warn('PIGGY_ENABLED is on but ANTHROPIC_API_KEY is unset — the agent will idle.');
|
||||
if (config.PIGGY_ENABLED && (!config.PIGGY_INTERNAL_URL || !config.PIGGY_INTERNAL_TOKEN)) {
|
||||
warn(
|
||||
'PIGGY_ENABLED is on but the internal URL or token is unset — interactive chat will be unavailable.',
|
||||
);
|
||||
}
|
||||
|
||||
if (config.SUPABASE_SERVICE_KEY) {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { ActivityType, GlobalCapability, Team, TeamCapability } from '@pig/core';
|
||||
import type { Database } from '@pig/db';
|
||||
import { activities } from '@pig/db';
|
||||
import type { Context, Handler } from 'hono';
|
||||
import type { ZodIssue, ZodTypeAny, infer as Infer } from 'zod';
|
||||
import { requireCapability, type Principal } from './auth';
|
||||
|
||||
export type ApiEnv = { Variables: { principal: Principal } };
|
||||
|
||||
export interface ApiErrorEnvelope {
|
||||
error: string;
|
||||
code: string;
|
||||
issues?: ZodIssue[];
|
||||
}
|
||||
|
||||
export function apiError(
|
||||
code: string,
|
||||
error: string,
|
||||
issues?: ZodIssue[],
|
||||
): ApiErrorEnvelope {
|
||||
return issues ? { error, code, issues } : { error, code };
|
||||
}
|
||||
|
||||
export class MutationError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly status: 400 | 404 | 409 | 502 | 503,
|
||||
readonly issues?: ZodIssue[],
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'MutationError';
|
||||
}
|
||||
|
||||
static notFound(resource: string): MutationError {
|
||||
return new MutationError('not_found', `${resource} not found.`, 404);
|
||||
}
|
||||
}
|
||||
|
||||
type PermissionRequirement =
|
||||
| { capability: GlobalCapability; team?: never }
|
||||
| { capability: TeamCapability; team: Team }
|
||||
| { authorize(principal: Principal): void };
|
||||
|
||||
type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0];
|
||||
|
||||
export interface MutationActivity {
|
||||
type: ActivityType;
|
||||
subject: string;
|
||||
body?: string;
|
||||
accountId?: string;
|
||||
contactId?: string;
|
||||
demandDealId?: string;
|
||||
supplyDealId?: string;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MutationResult<Result> {
|
||||
data: Result;
|
||||
activity: MutationActivity;
|
||||
}
|
||||
|
||||
interface MutationContext<Input> {
|
||||
input: Input;
|
||||
principal: Principal;
|
||||
params: Readonly<Record<string, string>>;
|
||||
tx: Transaction;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
export interface MutationDefinition<Schema extends ZodTypeAny, Result> {
|
||||
schema: Schema;
|
||||
permission: PermissionRequirement;
|
||||
invalidMessage: string;
|
||||
mutate(context: MutationContext<Infer<Schema>>): Promise<MutationResult<Result>>;
|
||||
}
|
||||
|
||||
function enforcePermission(principal: Principal, permission: PermissionRequirement): void {
|
||||
if ('authorize' in permission) {
|
||||
permission.authorize(principal);
|
||||
return;
|
||||
}
|
||||
if (permission.capability === 'settings:admin') {
|
||||
requireCapability(principal, permission.capability);
|
||||
return;
|
||||
}
|
||||
requireCapability(principal, permission.capability, permission.team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an authorised, validated write and its audit event atomically.
|
||||
*
|
||||
* Permission precedes body parsing so a caller cannot probe validation rules
|
||||
* for a write they are not allowed to perform. Validation precedes the
|
||||
* transaction so bad input never consumes a connection or leaves audit noise.
|
||||
*/
|
||||
export async function executeMutation<Schema extends ZodTypeAny, Result>(
|
||||
db: Database,
|
||||
principal: Principal,
|
||||
readInput: () => Promise<unknown>,
|
||||
definition: MutationDefinition<Schema, Result>,
|
||||
params: Readonly<Record<string, string>> = {},
|
||||
): Promise<Result> {
|
||||
enforcePermission(principal, definition.permission);
|
||||
|
||||
let rawInput: unknown;
|
||||
try {
|
||||
rawInput = await readInput();
|
||||
} catch {
|
||||
throw new MutationError('invalid_json', 'Request body must be valid JSON.', 400);
|
||||
}
|
||||
|
||||
const parsed = definition.schema.safeParse(rawInput);
|
||||
if (!parsed.success) {
|
||||
throw new MutationError(
|
||||
'invalid_request',
|
||||
definition.invalidMessage,
|
||||
400,
|
||||
parsed.error.issues,
|
||||
);
|
||||
}
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
const context: MutationContext<Infer<Schema>> = {
|
||||
input: parsed.data,
|
||||
principal,
|
||||
params,
|
||||
tx,
|
||||
now,
|
||||
};
|
||||
const result = await definition.mutate(context);
|
||||
|
||||
await tx.insert(activities).values({
|
||||
...result.activity,
|
||||
actorUserId: principal.userId,
|
||||
actorAgent: principal.via === 'api_key' ? 'agent' : null,
|
||||
source: principal.via === 'api_key' ? 'agent' : 'manual',
|
||||
occurredAt: now,
|
||||
});
|
||||
return result.data;
|
||||
});
|
||||
}
|
||||
|
||||
export function mutation<Schema extends ZodTypeAny, Result>(
|
||||
db: Database,
|
||||
definition: MutationDefinition<Schema, Result>,
|
||||
): Handler<ApiEnv> {
|
||||
return async (c: Context<ApiEnv>) => {
|
||||
try {
|
||||
const result = await executeMutation(
|
||||
db,
|
||||
c.get('principal'),
|
||||
() => c.req.json(),
|
||||
definition,
|
||||
c.req.param(),
|
||||
);
|
||||
return c.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof MutationError) {
|
||||
return c.json(apiError(error.code, error.message, error.issues), error.status);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE-style writes still use the mutation convention without making clients
|
||||
* send a meaningless JSON body. The empty input is deliberate: authorization
|
||||
* still runs first, and route parameters remain request-local.
|
||||
*/
|
||||
export function bodylessMutation<Schema extends ZodTypeAny, Result>(
|
||||
db: Database,
|
||||
definition: MutationDefinition<Schema, Result>,
|
||||
): Handler<ApiEnv> {
|
||||
return async (c: Context<ApiEnv>) => {
|
||||
try {
|
||||
const result = await executeMutation(
|
||||
db,
|
||||
c.get('principal'),
|
||||
async () => ({}),
|
||||
definition,
|
||||
c.req.param(),
|
||||
);
|
||||
return c.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof MutationError) {
|
||||
return c.json(apiError(error.code, error.message, error.issues), error.status);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
|
||||
const DEFAULT_SECRET_PURPOSE = 'platform-settings:prime-api-key';
|
||||
|
||||
function additionalAuthenticatedData(purpose: string): Buffer {
|
||||
return Buffer.from(`pig:${purpose}:v1`);
|
||||
}
|
||||
|
||||
export class SecretConfigurationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SecretConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
function masterKey(value: string | undefined): Buffer {
|
||||
if (!value) {
|
||||
throw new SecretConfigurationError(
|
||||
'PIG_SETTINGS_ENCRYPTION_KEY is required before credentials can be stored.',
|
||||
);
|
||||
}
|
||||
const key = Buffer.from(value, 'base64');
|
||||
if (key.length !== 32 || key.toString('base64').replace(/=+$/, '') !== value.replace(/=+$/, '')) {
|
||||
throw new SecretConfigurationError(
|
||||
'PIG_SETTINGS_ENCRYPTION_KEY must be a base64-encoded 32-byte key.',
|
||||
);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
export function encryptionReady(value: string | undefined): boolean {
|
||||
try {
|
||||
masterKey(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function encryptSecret(
|
||||
plaintext: string,
|
||||
keyValue: string | undefined,
|
||||
purpose = DEFAULT_SECRET_PURPOSE,
|
||||
): string {
|
||||
const key = masterKey(keyValue);
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||
cipher.setAAD(additionalAuthenticatedData(purpose));
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return ['v1', iv.toString('base64url'), tag.toString('base64url'), ciphertext.toString('base64url')].join('.');
|
||||
}
|
||||
|
||||
export function decryptSecret(
|
||||
envelope: string,
|
||||
keyValue: string | undefined,
|
||||
purpose = DEFAULT_SECRET_PURPOSE,
|
||||
): string {
|
||||
const [version, ivValue, tagValue, ciphertextValue, extra] = envelope.split('.');
|
||||
if (version !== 'v1' || !ivValue || !tagValue || !ciphertextValue || extra) {
|
||||
throw new SecretConfigurationError('Stored credential has an unsupported format.');
|
||||
}
|
||||
try {
|
||||
const decipher = createDecipheriv('aes-256-gcm', masterKey(keyValue), Buffer.from(ivValue, 'base64url'));
|
||||
decipher.setAAD(additionalAuthenticatedData(purpose));
|
||||
decipher.setAuthTag(Buffer.from(tagValue, 'base64url'));
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(ciphertextValue, 'base64url')),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
} catch (error) {
|
||||
if (error instanceof SecretConfigurationError) throw error;
|
||||
throw new SecretConfigurationError('Stored credential could not be decrypted.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user