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.
- package/README.md +22 -2
- package/bin/create-quorum-router.js +25 -4
- package/package.json +1 -1
- package/templates/basic/README.md +72 -6
- package/templates/basic/deno.json +2 -0
- package/templates/basic/router.config.example.json +11 -0
- package/templates/basic/src/auth_session.ts +1 -0
- package/templates/basic/src/best_route.ts +10 -1
- package/templates/basic/src/calibration.ts +249 -0
- package/templates/basic/src/calibration_demo.ts +25 -0
- package/templates/basic/src/cli.ts +14 -2
- package/templates/basic/src/cost_aware.ts +150 -0
- package/templates/basic/src/provider_registry.ts +14 -7
- package/templates/basic/src/schema.ts +87 -0
- package/templates/basic/src/supabase.ts +462 -0
- package/templates/basic/src/trace.ts +3 -0
- package/templates/basic/src/wrapper_client.ts +46 -13
- package/templates/basic/supabase/migrations/20260701130000_workflow_access_audit.sql +125 -0
- package/templates/basic/supabase/migrations/20260712211500_workflow_access_audit_limits.sql +94 -0
|
@@ -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 === "
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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;
|