create-quorum-router 0.1.5 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,462 @@
1
+ import { z } from "zod";
2
+ import {
3
+ GeneratedRouterConfigSchema,
4
+ type SupabaseAuditMode,
5
+ } from "./schema.ts";
6
+
7
+ const CONFIG_PATH = "router.config.json";
8
+ const AUDIT_RPC_PATH = "/rest/v1/rpc/insert_workflow_access_audit_batch";
9
+ const DEFAULT_TIMEOUT_MS = 5_000;
10
+ const MAX_TIMEOUT_MS = 30_000;
11
+
12
+ const FORBIDDEN_SUPABASE_ENV_KEYS = new Set([
13
+ "SUPABASE_SERVICE_ROLE_KEY",
14
+ "QUORUM_ROUTER_SUPABASE_SERVICE_ROLE_KEY",
15
+ "SUPABASE_SERVICE_KEY",
16
+ "QUORUM_ROUTER_SUPABASE_SERVICE_KEY",
17
+ "SUPABASE_ADMIN_KEY",
18
+ "QUORUM_ROUTER_SUPABASE_ADMIN_KEY",
19
+ ]);
20
+
21
+ export type SupabaseStatusKind =
22
+ | "disabled"
23
+ | "configured"
24
+ | "partial"
25
+ | "forbidden-credential";
26
+
27
+ export type SupabaseStatus = {
28
+ kind: SupabaseStatusKind;
29
+ mode: SupabaseAuditMode;
30
+ ok: boolean;
31
+ configuredFields: number;
32
+ requiredFields: number;
33
+ forbiddenEnvKeys: string[];
34
+ detail: string;
35
+ };
36
+
37
+ export type SupabaseAuditRecord = {
38
+ workflow_id: string;
39
+ route?: string;
40
+ decision: "allow" | "error";
41
+ metadata: {
42
+ command: string;
43
+ mode: string;
44
+ auth_mode: string;
45
+ provider_selection_honored: boolean;
46
+ fallback_used: boolean;
47
+ schema_valid: boolean;
48
+ };
49
+ };
50
+
51
+ export type RouteAuditSource = {
52
+ run_id: string;
53
+ command: string;
54
+ mode: string;
55
+ auth_mode: string;
56
+ selected_provider?: string;
57
+ selected_model?: string;
58
+ provider_selection_honored: boolean;
59
+ fallback_used: boolean;
60
+ schema_valid: boolean;
61
+ };
62
+
63
+ export type AuditRequestOptions = {
64
+ fetchFn?: typeof fetch;
65
+ signal?: AbortSignal;
66
+ timeoutMs?: number;
67
+ configPath?: string;
68
+ allowInsecureLocalhostForTest?: boolean;
69
+ };
70
+
71
+ type RuntimeCredentials = {
72
+ url?: string;
73
+ anonKey?: string;
74
+ sessionJwt?: string;
75
+ };
76
+
77
+ function envValue(...names: string[]): string | undefined {
78
+ for (const name of names) {
79
+ const value = Deno.env.get(name)?.trim();
80
+ if (value) return value;
81
+ }
82
+ return undefined;
83
+ }
84
+
85
+ function runtimeCredentials(): RuntimeCredentials {
86
+ return {
87
+ url: envValue("QUORUM_ROUTER_SUPABASE_URL", "SUPABASE_URL"),
88
+ anonKey: envValue(
89
+ "QUORUM_ROUTER_SUPABASE_ANON_KEY",
90
+ "SUPABASE_ANON_KEY",
91
+ ),
92
+ sessionJwt: envValue(
93
+ "QUORUM_ROUTER_SUPABASE_SESSION_JWT",
94
+ "SUPABASE_SESSION_JWT",
95
+ ),
96
+ };
97
+ }
98
+
99
+ export function forbiddenSupabaseEnvKeys(): string[] {
100
+ return Object.keys(Deno.env.toObject()).filter((key) =>
101
+ FORBIDDEN_SUPABASE_ENV_KEYS.has(key.toUpperCase()) ||
102
+ /SUPABASE.*(SERVICE.*ROLE|SERVICE.*KEY|ADMIN.*KEY|JWT.*SECRET)/i.test(key)
103
+ ).sort();
104
+ }
105
+
106
+ function decodeJwtPayload(value: string): Record<string, unknown> | undefined {
107
+ const payload = value.split(".")[1];
108
+ if (!payload) return undefined;
109
+ try {
110
+ const normalized = payload.replace(/-/g, "+").replace(/_/g, "/")
111
+ .padEnd(Math.ceil(payload.length / 4) * 4, "=");
112
+ const parsed = JSON.parse(atob(normalized));
113
+ return parsed && typeof parsed === "object"
114
+ ? parsed as Record<string, unknown>
115
+ : undefined;
116
+ } catch {
117
+ return undefined;
118
+ }
119
+ }
120
+
121
+ function isPrivilegedCredential(value: string): boolean {
122
+ if (/^sb_secret_/i.test(value) || /service[_-]?role/i.test(value)) {
123
+ return true;
124
+ }
125
+ const role = decodeJwtPayload(value)?.role;
126
+ return typeof role === "string" &&
127
+ /^(service_role|supabase_admin)$/i.test(role);
128
+ }
129
+
130
+ function assertRuntimeCredentialBoundary(credentials: RuntimeCredentials) {
131
+ const forbiddenKeys = forbiddenSupabaseEnvKeys();
132
+ if (forbiddenKeys.length > 0) {
133
+ throw new Error(
134
+ `Supabase audit forbidden-credential: runtime env keys present: ${
135
+ forbiddenKeys.join(", ")
136
+ }; values hidden`,
137
+ );
138
+ }
139
+ if (
140
+ (credentials.anonKey && isPrivilegedCredential(credentials.anonKey)) ||
141
+ (credentials.sessionJwt && isPrivilegedCredential(credentials.sessionJwt))
142
+ ) {
143
+ throw new Error(
144
+ "Supabase audit forbidden-credential: service-role/admin credential detected; value hidden",
145
+ );
146
+ }
147
+ }
148
+
149
+ export async function loadSupabaseAuditMode(
150
+ path = CONFIG_PATH,
151
+ ): Promise<SupabaseAuditMode> {
152
+ let text: string;
153
+ try {
154
+ text = await Deno.readTextFile(path);
155
+ } catch (error) {
156
+ if (error instanceof Deno.errors.NotFound) return "disabled";
157
+ throw new Error(`Supabase feature config unreadable: ${path}`);
158
+ }
159
+
160
+ let raw: unknown;
161
+ try {
162
+ raw = JSON.parse(text);
163
+ } catch {
164
+ throw new Error(`Supabase feature config invalid JSON: ${path}`);
165
+ }
166
+ const parsed = GeneratedRouterConfigSchema.safeParse(raw);
167
+ if (!parsed.success) {
168
+ throw new Error(
169
+ `Supabase feature config invalid: ${
170
+ parsed.error.issues.map((issue) => issue.path.join(".") || "config")
171
+ .join(", ")
172
+ }`,
173
+ );
174
+ }
175
+ return parsed.data.features?.supabase?.audit.mode ?? "disabled";
176
+ }
177
+
178
+ export async function getSupabaseStatus(
179
+ configPath = CONFIG_PATH,
180
+ ): Promise<SupabaseStatus> {
181
+ const mode = await loadSupabaseAuditMode(configPath);
182
+ const credentials = runtimeCredentials();
183
+ const forbiddenEnvKeys = forbiddenSupabaseEnvKeys();
184
+ if (
185
+ forbiddenEnvKeys.length > 0 ||
186
+ (credentials.anonKey && isPrivilegedCredential(credentials.anonKey)) ||
187
+ (credentials.sessionJwt && isPrivilegedCredential(credentials.sessionJwt))
188
+ ) {
189
+ return {
190
+ kind: "forbidden-credential",
191
+ mode,
192
+ ok: false,
193
+ configuredFields: Object.values(credentials).filter(Boolean).length,
194
+ requiredFields: 3,
195
+ forbiddenEnvKeys,
196
+ detail: "service-role/admin runtime credential rejected; values hidden",
197
+ };
198
+ }
199
+
200
+ const configuredFields = Object.values(credentials).filter(Boolean).length;
201
+ if (configuredFields > 0 && configuredFields < 3) {
202
+ return {
203
+ kind: "partial",
204
+ mode,
205
+ ok: false,
206
+ configuredFields,
207
+ requiredFields: 3,
208
+ forbiddenEnvKeys: [],
209
+ detail:
210
+ "URL, publishable/anon key, and user session JWT must be set together; values hidden",
211
+ };
212
+ }
213
+ if (mode === "disabled") {
214
+ return {
215
+ kind: "disabled",
216
+ mode,
217
+ ok: true,
218
+ configuredFields,
219
+ requiredFields: 3,
220
+ forbiddenEnvKeys: [],
221
+ detail: configuredFields === 3
222
+ ? "credentials present but audit is disabled; values hidden"
223
+ : "Supabase audit is disabled and no credentials are required",
224
+ };
225
+ }
226
+ if (configuredFields === 3) {
227
+ return {
228
+ kind: "configured",
229
+ mode,
230
+ ok: true,
231
+ configuredFields,
232
+ requiredFields: 3,
233
+ forbiddenEnvKeys: [],
234
+ detail:
235
+ "runtime URL, publishable/anon key, and user session JWT are configured; values hidden",
236
+ };
237
+ }
238
+ return {
239
+ kind: "partial",
240
+ mode,
241
+ ok: false,
242
+ configuredFields,
243
+ requiredFields: 3,
244
+ forbiddenEnvKeys: [],
245
+ detail:
246
+ "audit mode requires URL, publishable/anon key, and user session JWT; values hidden",
247
+ };
248
+ }
249
+
250
+ export async function runSupabaseStatus(): Promise<void> {
251
+ const status = await getSupabaseStatus();
252
+ console.log("QuorumRouter Supabase audit status");
253
+ console.log(`state: ${status.kind}`);
254
+ console.log(`audit_mode: ${status.mode}`);
255
+ console.log(
256
+ `runtime_credentials: ${status.configuredFields}/${status.requiredFields} configured; values hidden`,
257
+ );
258
+ if (status.forbiddenEnvKeys.length > 0) {
259
+ console.log(`forbidden_env_keys: ${status.forbiddenEnvKeys.join(", ")}`);
260
+ }
261
+ console.log(`detail: ${status.detail}`);
262
+ console.log("network_request_sent: false");
263
+ if (!status.ok) Deno.exitCode = 1;
264
+ }
265
+
266
+ export function toSupabaseAuditRecord(
267
+ source: RouteAuditSource,
268
+ ): SupabaseAuditRecord {
269
+ const route = source.selected_provider && source.selected_model
270
+ ? `${source.selected_provider}/${source.selected_model}`
271
+ : source.selected_provider;
272
+ return {
273
+ workflow_id: source.run_id,
274
+ route,
275
+ decision: source.schema_valid ? "allow" : "error",
276
+ metadata: {
277
+ command: source.command,
278
+ mode: source.mode,
279
+ auth_mode: source.auth_mode,
280
+ provider_selection_honored: source.provider_selection_honored,
281
+ fallback_used: source.fallback_used,
282
+ schema_valid: source.schema_valid,
283
+ },
284
+ };
285
+ }
286
+
287
+ function auditEndpoint(
288
+ rawUrl: string,
289
+ allowInsecureLocalhostForTest = false,
290
+ ): string {
291
+ let url: URL;
292
+ try {
293
+ url = new URL(rawUrl);
294
+ } catch {
295
+ throw new Error("Supabase audit invalid project URL; value hidden");
296
+ }
297
+ const loopback = url.hostname === "127.0.0.1" || url.hostname === "[::1]" ||
298
+ url.hostname === "localhost";
299
+ const allowedProtocol = url.protocol === "https:" ||
300
+ (allowInsecureLocalhostForTest && loopback && url.protocol === "http:");
301
+ if (!allowedProtocol || url.username || url.password) {
302
+ throw new Error(
303
+ "Supabase audit invalid project URL; HTTPS required; value hidden",
304
+ );
305
+ }
306
+ return new URL(AUDIT_RPC_PATH, url).toString();
307
+ }
308
+
309
+ function boundedTimeout(value: number | undefined): number {
310
+ if (!Number.isFinite(value) || (value ?? 0) <= 0) return DEFAULT_TIMEOUT_MS;
311
+ return Math.min(Math.floor(value!), MAX_TIMEOUT_MS);
312
+ }
313
+
314
+ function httpFailure(status: number): Error {
315
+ if (status === 401 || status === 403) {
316
+ return new Error(`Supabase audit auth failure (HTTP ${status})`);
317
+ }
318
+ if (status === 429) {
319
+ return new Error("Supabase audit rate limited (HTTP 429)");
320
+ }
321
+ if (status >= 500) {
322
+ return new Error(`Supabase audit server failure (HTTP ${status})`);
323
+ }
324
+ return new Error(`Supabase audit RPC rejected request (HTTP ${status})`);
325
+ }
326
+
327
+ export async function sendSupabaseAuditRecord(
328
+ record: SupabaseAuditRecord,
329
+ credentials: Required<RuntimeCredentials>,
330
+ options: AuditRequestOptions = {},
331
+ ): Promise<void> {
332
+ assertRuntimeCredentialBoundary(credentials);
333
+ const endpoint = auditEndpoint(
334
+ credentials.url,
335
+ options.allowInsecureLocalhostForTest,
336
+ );
337
+ if (options.signal?.aborted) {
338
+ throw new Error("Supabase audit request aborted");
339
+ }
340
+ const controller = new AbortController();
341
+ let timedOut = false;
342
+ const abort = () => controller.abort(options.signal?.reason);
343
+ options.signal?.addEventListener("abort", abort, { once: true });
344
+ const timeout = setTimeout(() => {
345
+ timedOut = true;
346
+ controller.abort();
347
+ }, boundedTimeout(options.timeoutMs));
348
+
349
+ try {
350
+ const response = await (options.fetchFn ?? fetch)(endpoint, {
351
+ method: "POST",
352
+ headers: {
353
+ "content-type": "application/json",
354
+ "prefer": "return=minimal",
355
+ "authorization": `Bearer ${credentials.sessionJwt}`,
356
+ "apikey": credentials.anonKey,
357
+ },
358
+ body: JSON.stringify({ records: [record] }),
359
+ signal: controller.signal,
360
+ redirect: "error",
361
+ });
362
+ if (!response.ok) throw httpFailure(response.status);
363
+ } catch (error) {
364
+ if (timedOut) throw new Error("Supabase audit request timed out");
365
+ if (controller.signal.aborted) {
366
+ throw new Error("Supabase audit request aborted");
367
+ }
368
+ if (error instanceof Error && error.message.startsWith("Supabase audit")) {
369
+ throw error;
370
+ }
371
+ throw new Error("Supabase audit network failure");
372
+ } finally {
373
+ clearTimeout(timeout);
374
+ options.signal?.removeEventListener("abort", abort);
375
+ }
376
+ }
377
+
378
+ export async function preflightRequiredSupabaseAudit(
379
+ options: AuditRequestOptions = {},
380
+ ): Promise<void> {
381
+ const mode = await loadSupabaseAuditMode(options.configPath);
382
+ if (mode !== "required") return;
383
+ const credentials = runtimeCredentials();
384
+ assertRuntimeCredentialBoundary(credentials);
385
+ const missing = Object.entries(credentials).filter(([, value]) => !value).map(
386
+ ([key]) => key,
387
+ );
388
+ if (missing.length > 0) {
389
+ throw new Error(
390
+ `Required Supabase audit incomplete runtime configuration: missing ${
391
+ missing.join(", ")
392
+ }; provider invocation blocked`,
393
+ );
394
+ }
395
+ auditEndpoint(
396
+ credentials.url!,
397
+ options.allowInsecureLocalhostForTest,
398
+ );
399
+ const claims = decodeJwtPayload(credentials.sessionJwt!);
400
+ if (
401
+ claims?.role !== "authenticated" ||
402
+ typeof claims.sub !== "string" || claims.sub.length === 0
403
+ ) {
404
+ throw new Error(
405
+ "Required Supabase audit needs an authenticated user session JWT; provider invocation blocked",
406
+ );
407
+ }
408
+ }
409
+
410
+ export async function auditRouteOutcome(
411
+ source: RouteAuditSource,
412
+ options: AuditRequestOptions = {},
413
+ ): Promise<void> {
414
+ const credentials = runtimeCredentials();
415
+ assertRuntimeCredentialBoundary(credentials);
416
+ const mode = await loadSupabaseAuditMode(options.configPath);
417
+ if (mode === "disabled") return;
418
+
419
+ const missing = Object.entries(credentials).filter(([, value]) => !value)
420
+ .map(([key]) => key);
421
+ try {
422
+ if (missing.length > 0) {
423
+ throw new Error(
424
+ `Supabase audit incomplete runtime configuration: missing ${
425
+ missing.join(", ")
426
+ }; values hidden`,
427
+ );
428
+ }
429
+ await sendSupabaseAuditRecord(
430
+ toSupabaseAuditRecord(source),
431
+ credentials as Required<RuntimeCredentials>,
432
+ options,
433
+ );
434
+ } catch (error) {
435
+ const message = error instanceof Error
436
+ ? error.message
437
+ : "Supabase audit failed";
438
+ if (mode === "optional") {
439
+ console.warn(`WARNING: optional ${message}; route result preserved`);
440
+ return;
441
+ }
442
+ throw new Error(`Required ${message}; route result withheld`);
443
+ }
444
+ }
445
+
446
+ export const SupabaseAuditPayloadSchema = z.object({
447
+ records: z.array(
448
+ z.object({
449
+ workflow_id: z.string().min(1),
450
+ route: z.string().optional(),
451
+ decision: z.enum(["allow", "error"]),
452
+ metadata: z.object({
453
+ command: z.string(),
454
+ mode: z.string(),
455
+ auth_mode: z.string(),
456
+ provider_selection_honored: z.boolean(),
457
+ fallback_used: z.boolean(),
458
+ schema_valid: z.boolean(),
459
+ }).strict(),
460
+ }).strict(),
461
+ ).length(1),
462
+ }).strict();
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  AgentChatTurn,
3
3
  AuthMode,
4
+ CostAwareTrace,
4
5
  DogfoodTrace,
5
6
  PromptContextTrace,
6
7
  ProviderResult,
@@ -74,6 +75,7 @@ export async function buildTrace(args: {
74
75
  requestedModel?: string;
75
76
  providerSelectionHonored?: boolean;
76
77
  fallbackUsed?: boolean;
78
+ costAware?: CostAwareTrace;
77
79
  agentChatTurns?: AgentChatTurn[];
78
80
  }): Promise<DogfoodTrace> {
79
81
  const responseSummary = args.results?.map((r) =>
@@ -93,6 +95,7 @@ export async function buildTrace(args: {
93
95
  selected_model: args.selected?.model ?? args.results?.[0]?.model,
94
96
  provider_selection_honored: args.providerSelectionHonored ?? true,
95
97
  fallback_used: args.fallbackUsed ?? false,
98
+ cost_aware: args.costAware,
96
99
  prompt_hash: args.prompt ? await promptHash(args.prompt) : undefined,
97
100
  prompt_summary: args.prompt ? summarize(args.prompt, 160) : undefined,
98
101
  prompt_context: args.promptContext,
@@ -3,18 +3,21 @@ import { redact, summarize } from "./redact.ts";
3
3
 
4
4
  export function buildWrapperArgs(
5
5
  entry: ModelInventoryEntry,
6
- prompt: string,
7
6
  outPath: string,
7
+ promptPath?: string,
8
8
  ): string[] {
9
9
  const args = (entry.args_template ?? []).map((arg) =>
10
- arg === "__PROMPT__"
11
- ? prompt
12
- : arg === "__CWD__"
10
+ arg === "__CWD__"
13
11
  ? Deno.cwd()
14
12
  : arg === "__OUT__"
15
13
  ? outPath
14
+ : arg === "__PROMPT_FILE__"
15
+ ? promptPath ?? ""
16
16
  : arg
17
17
  );
18
+ if (args.includes("__PROMPT__") || args.includes("__PROMPT_FILE__")) {
19
+ throw new Error("QuorumRouter blocked: unresolved prompt placeholder");
20
+ }
18
21
  if (entry.command === "grok" && entry.invocation_model) {
19
22
  if (entry.invocation_model === "grok-build") {
20
23
  const withoutPlanMode: string[] = [];
@@ -166,28 +169,57 @@ export async function callWrapper(
166
169
  `QuorumRouter blocked: ${entry.provider} has no command`,
167
170
  );
168
171
  }
169
- await Deno.mkdir("out", {
170
- recursive: true,
172
+ await Deno.mkdir("out", { recursive: true, mode: 0o700 });
173
+ const outPath = await Deno.makeTempFile({
174
+ dir: "out",
175
+ prefix: "quorum-router-output-",
176
+ suffix: ".txt",
171
177
  });
172
- const outPath = `out/tmp-${crypto.randomUUID()}.txt`;
178
+ await Deno.chmod(outPath, 0o600);
179
+ const absoluteOutPath = await Deno.realPath(outPath);
180
+ const promptPath = entry.prompt_transport === "file"
181
+ ? await Deno.makeTempFile({
182
+ dir: "out",
183
+ prefix: "quorum-router-prompt-",
184
+ suffix: ".txt",
185
+ })
186
+ : undefined;
187
+ if (promptPath) {
188
+ await Deno.chmod(promptPath, 0o600);
189
+ await Deno.writeTextFile(promptPath, prompt);
190
+ }
191
+ const absolutePromptPath = promptPath
192
+ ? await Deno.realPath(promptPath)
193
+ : undefined;
173
194
  try {
174
- const runWrapper = () =>
175
- new Deno.Command(entry.command!, {
176
- args: buildWrapperArgs(entry, prompt, outPath),
195
+ const runWrapper = async () => {
196
+ const child = new Deno.Command(entry.command!, {
197
+ args: buildWrapperArgs(
198
+ entry,
199
+ absoluteOutPath,
200
+ absolutePromptPath,
201
+ ),
177
202
  cwd: Deno.env.get("TMPDIR") || "/tmp",
178
203
  clearEnv: true,
179
204
  env: safeWrapperEnv(),
180
- stdin: "null",
205
+ stdin: entry.prompt_transport === "stdin" ? "piped" : "null",
181
206
  stdout: "piped",
182
207
  stderr: "piped",
183
208
  }).spawn();
184
- let output = await outputWithTimeout(runWrapper(), 120_000);
209
+ if (entry.prompt_transport === "stdin") {
210
+ const writer = child.stdin.getWriter();
211
+ await writer.write(new TextEncoder().encode(prompt));
212
+ await writer.close();
213
+ }
214
+ return child;
215
+ };
216
+ let output = await outputWithTimeout(await runWrapper(), 120_000);
185
217
  if (
186
218
  output.code === 141 && output.stdout.length === 0 &&
187
219
  output.stderr.length === 0
188
220
  ) {
189
221
  await new Promise((resolve) => setTimeout(resolve, 500));
190
- output = await outputWithTimeout(runWrapper(), 120_000);
222
+ output = await outputWithTimeout(await runWrapper(), 120_000);
191
223
  }
192
224
  const stdout = new TextDecoder().decode(output.stdout);
193
225
  const stderr = new TextDecoder().decode(output.stderr);
@@ -220,5 +252,6 @@ export async function callWrapper(
220
252
  };
221
253
  } finally {
222
254
  await Deno.remove(outPath).catch(() => undefined);
255
+ if (promptPath) await Deno.remove(promptPath).catch(() => undefined);
223
256
  }
224
257
  }
@@ -0,0 +1,125 @@
1
+ create extension if not exists pgcrypto with schema extensions;
2
+
3
+ create table if not exists public.workflow_access_audit (
4
+ id uuid primary key default extensions.gen_random_uuid(),
5
+ org_id text not null,
6
+ actor_id uuid not null,
7
+ actor_type text not null default 'ai_assistant'
8
+ check (actor_type in ('ai_assistant', 'user', 'system')),
9
+ event_type text not null check (length(event_type) > 0),
10
+ workflow_id text,
11
+ route text,
12
+ decision text not null check (decision in ('allow', 'deny', 'error')),
13
+ reason text,
14
+ metadata jsonb not null default '{}'::jsonb,
15
+ created_at timestamptz not null default now()
16
+ );
17
+
18
+ alter table public.workflow_access_audit enable row level security;
19
+
20
+ revoke all privileges on table public.workflow_access_audit from public;
21
+ revoke all privileges on table public.workflow_access_audit from anon;
22
+ revoke all privileges on table public.workflow_access_audit from authenticated;
23
+
24
+ create or replace function public.prevent_workflow_access_audit_mutation()
25
+ returns trigger
26
+ language plpgsql
27
+ set search_path = public, pg_temp
28
+ as $$
29
+ begin
30
+ raise exception 'workflow_access_audit is append-only';
31
+ end;
32
+ $$;
33
+
34
+ revoke all privileges on function public.prevent_workflow_access_audit_mutation()
35
+ from public;
36
+
37
+ drop trigger if exists workflow_access_audit_no_update_delete
38
+ on public.workflow_access_audit;
39
+ create trigger workflow_access_audit_no_update_delete
40
+ before update or delete on public.workflow_access_audit
41
+ for each row
42
+ execute function public.prevent_workflow_access_audit_mutation();
43
+
44
+ create or replace function public.insert_workflow_access_audit_batch(records jsonb)
45
+ returns void
46
+ language plpgsql
47
+ security definer
48
+ set search_path = public, pg_temp
49
+ as $$
50
+ declare
51
+ rec jsonb;
52
+ claim_org_id text;
53
+ claim_actor_id uuid;
54
+ event_type text;
55
+ decision text;
56
+ begin
57
+ claim_actor_id := auth.uid();
58
+ if claim_actor_id is null then
59
+ raise exception 'missing authenticated user'
60
+ using errcode = '28000';
61
+ end if;
62
+
63
+ claim_org_id := nullif(auth.jwt() ->> 'org_id', '');
64
+ if claim_org_id is null then
65
+ raise exception 'missing org_id claim'
66
+ using errcode = '28000';
67
+ end if;
68
+
69
+ if records is null or jsonb_typeof(records) <> 'array' then
70
+ raise exception 'records must be a JSON array'
71
+ using errcode = '22023';
72
+ end if;
73
+
74
+ for rec in select value from jsonb_array_elements(records) as entry(value) loop
75
+ event_type := nullif(rec ->> 'event_type', '');
76
+ decision := nullif(rec ->> 'decision', '');
77
+
78
+ if event_type is null then
79
+ raise exception 'audit event_type is required'
80
+ using errcode = '22023';
81
+ end if;
82
+
83
+ if decision is null then
84
+ raise exception 'audit decision is required'
85
+ using errcode = '22023';
86
+ end if;
87
+
88
+ insert into public.workflow_access_audit (
89
+ org_id,
90
+ actor_id,
91
+ actor_type,
92
+ event_type,
93
+ workflow_id,
94
+ route,
95
+ decision,
96
+ reason,
97
+ metadata,
98
+ created_at
99
+ ) values (
100
+ claim_org_id,
101
+ claim_actor_id,
102
+ coalesce(nullif(rec ->> 'actor_type', ''), 'ai_assistant'),
103
+ event_type,
104
+ nullif(rec ->> 'workflow_id', ''),
105
+ nullif(rec ->> 'route', ''),
106
+ decision,
107
+ nullif(rec ->> 'reason', ''),
108
+ case
109
+ when jsonb_typeof(rec -> 'metadata') = 'object' then rec -> 'metadata'
110
+ else '{}'::jsonb
111
+ end,
112
+ now()
113
+ );
114
+ end loop;
115
+ end;
116
+ $$;
117
+
118
+ revoke all privileges on function public.insert_workflow_access_audit_batch(jsonb)
119
+ from public;
120
+ revoke all privileges on function public.insert_workflow_access_audit_batch(jsonb)
121
+ from anon;
122
+ revoke all privileges on function public.insert_workflow_access_audit_batch(jsonb)
123
+ from authenticated;
124
+ grant execute on function public.insert_workflow_access_audit_batch(jsonb)
125
+ to authenticated;