@opengeni/api-router 0.11.2 → 0.12.0

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/src/app.ts CHANGED
@@ -6,10 +6,13 @@ import {
6
6
  } from "@opengeni/config";
7
7
  import {
8
8
  ClientConfig,
9
+ ErrorEnvelope,
9
10
  OPENGENI_API_CONTRACT_HEADER,
10
11
  OPENGENI_API_CONTRACT_REVISION,
12
+ OPENGENI_CORRELATION_HEADER,
11
13
  resolveWorkspaceMemoryEnabled,
12
14
  type AccessGrant,
15
+ type ErrorCode,
13
16
  } from "@opengeni/contracts";
14
17
  import {
15
18
  createDocumentServices,
@@ -24,6 +27,7 @@ import { Hono } from "hono";
24
27
  import { bodyLimit } from "hono/body-limit";
25
28
  import { cors } from "hono/cors";
26
29
  import { HTTPException } from "hono/http-exception";
30
+ import type { ContentfulStatusCode } from "hono/utils/http-status";
27
31
  import type { ApiRouteDeps, AppDependencies } from "@opengeni/core";
28
32
  import {
29
33
  hasPermission,
@@ -59,6 +63,7 @@ import { registerScheduledTaskRoutes } from "./routes/scheduled-tasks";
59
63
  import { registerSessionRoutes } from "./routes/sessions";
60
64
  import { registerSocialRoutes } from "./routes/social";
61
65
  import { registerWorkspaceRoutes } from "./routes/workspaces";
66
+ import { registerWorkspaceInstructionPolicyRoutes } from "./routes/workspace-instruction-policies";
62
67
  import { projectClientModel } from "./model-catalog";
63
68
 
64
69
  export type {
@@ -83,6 +88,7 @@ export { workflowIdForSession } from "@opengeni/core";
83
88
  export { replaySessionEvents, sseSessionStream, sseWorkspaceControlStream } from "./http/sse";
84
89
 
85
90
  export const API_MAX_REQUEST_BODY_BYTES = 8 * 1024 * 1024;
91
+ const API_PUBLIC_ERROR_MESSAGE_MAX_BYTES = 512;
86
92
 
87
93
  export function createApp(deps: AppDependencies): Hono {
88
94
  const managedAuth = deps.managedAuth ?? createManagedAuth(deps.settings, deps.db);
@@ -148,6 +154,15 @@ export function createApp(deps: AppDependencies): Hono {
148
154
  resumeBoxById,
149
155
  };
150
156
  const app = new Hono();
157
+ const correlationIds = new WeakMap<Request, string>();
158
+
159
+ app.use("*", async (c, next) => {
160
+ const correlationId =
161
+ boundedCorrelationId(c.req.header(OPENGENI_CORRELATION_HEADER)) ?? crypto.randomUUID();
162
+ correlationIds.set(c.req.raw, correlationId);
163
+ c.header(OPENGENI_CORRELATION_HEADER, correlationId);
164
+ await next();
165
+ });
151
166
 
152
167
  app.use(
153
168
  "*",
@@ -159,9 +174,10 @@ export function createApp(deps: AppDependencies): Hono {
159
174
  "Content-Type",
160
175
  "X-OpenGeni-Access-Key",
161
176
  "X-OpenGeni-Api-Contract",
177
+ "X-OpenGeni-Correlation-Id",
162
178
  "X-OpenGeni-Subject",
163
179
  ],
164
- exposeHeaders: ["X-OpenGeni-Api-Contract"],
180
+ exposeHeaders: ["X-OpenGeni-Api-Contract", "X-OpenGeni-Correlation-Id"],
165
181
  origin: (origin) => {
166
182
  if (!origin) {
167
183
  return null;
@@ -183,6 +199,7 @@ export function createApp(deps: AppDependencies): Hono {
183
199
  app.use("*", async (c, next) => {
184
200
  const url = new URL(c.req.url);
185
201
  const route = routeLabel(url.pathname);
202
+ const correlationId = correlationIds.get(c.req.raw) ?? crypto.randomUUID();
186
203
  const start = performance.now();
187
204
  const span = observability.startSpan(`HTTP ${c.req.method} ${route}`, {
188
205
  "http.request.method": c.req.method,
@@ -212,9 +229,11 @@ export function createApp(deps: AppDependencies): Hono {
212
229
  durationMs: Math.round(durationSeconds * 1000),
213
230
  traceId: span.traceId,
214
231
  spanId: span.spanId,
232
+ correlationId,
215
233
  });
216
234
  } catch (error) {
217
235
  const status = httpStatusForError(error);
236
+ const errorCode = errorCodeForStatus(status);
218
237
  const durationSeconds = (performance.now() - start) / 1000;
219
238
  observability.recordHttpRequest({
220
239
  method: c.req.method,
@@ -222,6 +241,11 @@ export function createApp(deps: AppDependencies): Hono {
222
241
  status,
223
242
  durationSeconds,
224
243
  });
244
+ observability.incrementCounter({
245
+ name: "opengeni_http_errors_total",
246
+ help: "Total OpenGeni HTTP request failures by bounded route, status, and stable code.",
247
+ labels: { route, status: String(status), code: errorCode },
248
+ });
225
249
  span.end({
226
250
  attributes: {
227
251
  "http.response.status_code": status,
@@ -236,7 +260,9 @@ export function createApp(deps: AppDependencies): Hono {
236
260
  durationMs: Math.round(durationSeconds * 1000),
237
261
  traceId: span.traceId,
238
262
  spanId: span.spanId,
239
- error: error instanceof Error ? error.message : String(error),
263
+ correlationId,
264
+ errorCode,
265
+ errorClass: error instanceof Error ? error.name : "NonErrorThrown",
240
266
  });
241
267
  throw error;
242
268
  }
@@ -282,6 +308,12 @@ export function createApp(deps: AppDependencies): Hono {
282
308
  return c.json(result, result.ok ? 200 : 503);
283
309
  });
284
310
 
311
+ app.get("/traffic-readyz", async (c) => {
312
+ const { db } = readinessChecks(deps);
313
+ const result = await runReadinessChecks({ db }, 2_000);
314
+ return c.json(result, result.ok ? 200 : 503);
315
+ });
316
+
285
317
  app.get("/metrics", async (c) =>
286
318
  c.text(await observability.prometheusMetrics(), 200, {
287
319
  "content-type": "text/plain; version=0.0.4; charset=utf-8",
@@ -392,6 +424,7 @@ export function createApp(deps: AppDependencies): Hono {
392
424
  registerGitHubRoutes(app, routeDeps);
393
425
  registerInstallRoutes(app, routeDeps);
394
426
  registerWorkspaceRoutes(app, routeDeps);
427
+ registerWorkspaceInstructionPolicyRoutes(app, routeDeps);
395
428
  registerSocialRoutes(app, routeDeps);
396
429
  registerConnectionRoutes(app, routeDeps);
397
430
  registerCapabilityRoutes(app, routeDeps);
@@ -405,6 +438,43 @@ export function createApp(deps: AppDependencies): Hono {
405
438
  registerScheduledTaskRoutes(app, routeDeps);
406
439
  registerCodexRoutes(app, routeDeps);
407
440
 
441
+ app.notFound((c) => {
442
+ if (!new URL(c.req.url).pathname.startsWith("/v1/")) return c.text("Not Found", 404);
443
+ const requestId = correlationIds.get(c.req.raw) ?? crypto.randomUUID();
444
+ return c.json(
445
+ ErrorEnvelope.parse({
446
+ error: {
447
+ status: 404,
448
+ code: "not_found",
449
+ message: "Resource not found.",
450
+ retryable: false,
451
+ requestId,
452
+ },
453
+ }),
454
+ 404,
455
+ );
456
+ });
457
+
458
+ app.onError((error, c) => {
459
+ const status = httpStatusForError(error);
460
+ const code = errorCodeForStatus(status);
461
+ const requestId = correlationIds.get(c.req.raw) ?? crypto.randomUUID();
462
+ c.header(OPENGENI_CORRELATION_HEADER, requestId);
463
+ if (new URL(c.req.url).pathname.startsWith("/v1/")) {
464
+ c.header(OPENGENI_API_CONTRACT_HEADER, OPENGENI_API_CONTRACT_REVISION);
465
+ }
466
+ const envelope = ErrorEnvelope.parse({
467
+ error: {
468
+ status,
469
+ code,
470
+ message: publicErrorMessage(error, status),
471
+ retryable: retryableHttpStatus(status),
472
+ requestId,
473
+ },
474
+ });
475
+ return c.json(envelope, status as ContentfulStatusCode);
476
+ });
477
+
408
478
  return app;
409
479
  }
410
480
 
@@ -467,8 +537,54 @@ export function httpStatusForError(error: unknown): number {
467
537
  return 500;
468
538
  }
469
539
 
540
+ export function errorCodeForStatus(status: number): ErrorCode {
541
+ if (status === 401) return "unauthenticated";
542
+ if (status === 403) return "forbidden";
543
+ if (status === 404) return "not_found";
544
+ if (status === 409) return "conflict";
545
+ if (status === 413 || status === 422 || status === 400) return "validation_failed";
546
+ if (status === 429) return "limit_exceeded";
547
+ if (status === 502 || status === 503 || status === 504) return "upstream_unavailable";
548
+ return "internal_error";
549
+ }
550
+
551
+ function retryableHttpStatus(status: number): boolean {
552
+ return status === 408 || status === 425 || status === 429 || status >= 500;
553
+ }
554
+
555
+ function publicErrorMessage(error: unknown, status: number): string {
556
+ if (status === 502 || status === 503 || status === 504) {
557
+ return "OpenGeni is temporarily unavailable — retry.";
558
+ }
559
+ if (status >= 500) {
560
+ return "OpenGeni could not complete the request.";
561
+ }
562
+ if (error instanceof HTTPException) {
563
+ return boundedPublicMessage(error.message) ?? "Request failed.";
564
+ }
565
+ if (error instanceof McpPayloadTooLargeError) {
566
+ return "Request payload is too large.";
567
+ }
568
+ return "Request failed.";
569
+ }
570
+
571
+ function boundedPublicMessage(value: string): string | null {
572
+ const normalized = value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
573
+ if (!normalized) return null;
574
+ const bytes = new TextEncoder().encode(normalized);
575
+ if (bytes.byteLength <= API_PUBLIC_ERROR_MESSAGE_MAX_BYTES) return normalized;
576
+ return new TextDecoder().decode(bytes.slice(0, API_PUBLIC_ERROR_MESSAGE_MAX_BYTES)).trim();
577
+ }
578
+
579
+ function boundedCorrelationId(value: string | undefined): string | null {
580
+ if (!value || value.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(value)) return null;
581
+ return value;
582
+ }
583
+
470
584
  type ReadinessCheckName = "db" | "nats" | "temporal";
471
- type ReadinessChecks = Record<ReadinessCheckName, () => Promise<void> | void>;
585
+ type ReadinessCheck = () => Promise<void> | void;
586
+ type ReadinessChecks = Record<ReadinessCheckName, ReadinessCheck>;
587
+ type ReadinessCheckResult = { ok: boolean; error?: string };
472
588
 
473
589
  function readinessChecks(deps: AppDependencies): ReadinessChecks {
474
590
  return {
@@ -493,35 +609,30 @@ function readinessChecks(deps: AppDependencies): ReadinessChecks {
493
609
  };
494
610
  }
495
611
 
496
- async function runReadinessChecks(
497
- checks: ReadinessChecks,
612
+ async function runReadinessChecks<const Checks extends Readonly<Record<string, ReadinessCheck>>>(
613
+ checks: Checks,
498
614
  timeoutMs: number,
499
615
  ): Promise<{
500
616
  ok: boolean;
501
- checks: Record<ReadinessCheckName, { ok: boolean; error?: string }>;
617
+ checks: { [Name in keyof Checks]: ReadinessCheckResult };
502
618
  }> {
503
619
  const entries = await Promise.all(
504
- (Object.entries(checks) as Array<[ReadinessCheckName, () => Promise<void> | void]>).map(
505
- async ([name, check]) => {
506
- try {
507
- await withTimeout(Promise.resolve().then(check), timeoutMs);
508
- return [name, { ok: true }] as const;
509
- } catch (error) {
510
- return [
511
- name,
512
- {
513
- ok: false,
514
- error: error instanceof Error ? error.message : String(error),
515
- },
516
- ] as const;
517
- }
518
- },
519
- ),
620
+ (Object.entries(checks) as Array<[keyof Checks, ReadinessCheck]>).map(async ([name, check]) => {
621
+ try {
622
+ await withTimeout(Promise.resolve().then(check), timeoutMs);
623
+ return [name, { ok: true }] as const;
624
+ } catch (error) {
625
+ return [
626
+ name,
627
+ {
628
+ ok: false,
629
+ error: error instanceof Error ? error.message : String(error),
630
+ },
631
+ ] as const;
632
+ }
633
+ }),
520
634
  );
521
- const result = Object.fromEntries(entries) as Record<
522
- ReadinessCheckName,
523
- { ok: boolean; error?: string }
524
- >;
635
+ const result = Object.fromEntries(entries) as { [Name in keyof Checks]: ReadinessCheckResult };
525
636
  return {
526
637
  ok: Object.values(result).every((check) => check.ok),
527
638
  checks: result,
@@ -550,6 +661,7 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T
550
661
  const routeLabelPatterns: Array<{ pattern: RegExp; label: string }> = [
551
662
  { pattern: /^\/healthz$/, label: "/healthz" },
552
663
  { pattern: /^\/readyz$/, label: "/readyz" },
664
+ { pattern: /^\/traffic-readyz$/, label: "/traffic-readyz" },
553
665
  {
554
666
  pattern: /^\/v1\/workspaces\/[^/]+\/codex\/connect\/start$/,
555
667
  label: "/v1/workspaces/:workspaceId/codex/connect/start",
@@ -844,6 +956,10 @@ const routeLabelPatterns: Array<{ pattern: RegExp; label: string }> = [
844
956
  pattern: /^\/v1\/workspaces\/[^/]+\/connections\/oauth\/start$/,
845
957
  label: "/v1/workspaces/:workspaceId/connections/oauth/start",
846
958
  },
959
+ {
960
+ pattern: /^\/v1\/workspaces\/[^/]+\/connections\/slack-bot$/,
961
+ label: "/v1/workspaces/:workspaceId/connections/slack-bot",
962
+ },
847
963
  {
848
964
  pattern: /^\/v1\/workspaces\/[^/]+\/connections\/[^/]+$/,
849
965
  label: "/v1/workspaces/:workspaceId/connections/:connectionId",
@@ -1,6 +1,13 @@
1
- import type { GitHubInstallationBinding, GitHubRepository } from "@opengeni/contracts";
2
- import { listGitHubInstallationAccessForWorkspace } from "@opengeni/db";
3
- import { listGitHubAppRepositories } from "@opengeni/github";
1
+ import type {
2
+ GitHubBindingStatus,
3
+ GitHubInstallationBinding,
4
+ GitHubRepository,
5
+ } from "@opengeni/contracts";
6
+ import {
7
+ hasAuditableGitHubInstallationAuthority,
8
+ listGitHubInstallationAccessForWorkspace,
9
+ } from "@opengeni/db";
10
+ import { listGitHubAppInstallationSummaries, listGitHubAppRepositories } from "@opengeni/github";
4
11
  import type { ApiRouteDeps } from "@opengeni/core";
5
12
 
6
13
  export async function listWorkspaceGitHubInstallationBindings(
@@ -8,10 +15,53 @@ export async function listWorkspaceGitHubInstallationBindings(
8
15
  workspaceId: string,
9
16
  ): Promise<GitHubInstallationBinding[]> {
10
17
  const installations = await listGitHubInstallationAccessForWorkspace(deps.db, workspaceId);
18
+ if (installations.length === 0) {
19
+ return [];
20
+ }
21
+ let liveById = new Map<number, LiveGitHubInstallation | null>();
22
+ let lifecycleVerified = false;
23
+ try {
24
+ if (deps.githubAppApi?.getInstallation) {
25
+ liveById = new Map(
26
+ await Promise.all(
27
+ installations.map(
28
+ async (installation) =>
29
+ [
30
+ installation.installationId,
31
+ await deps.githubAppApi!.getInstallation!({
32
+ installationId: installation.installationId,
33
+ }),
34
+ ] as const,
35
+ ),
36
+ ),
37
+ );
38
+ lifecycleVerified = true;
39
+ } else if (!deps.githubAppApi) {
40
+ liveById = new Map(
41
+ (await listGitHubAppInstallationSummaries(deps.settings)).map((installation) => [
42
+ installation.installationId,
43
+ installation,
44
+ ]),
45
+ );
46
+ lifecycleVerified = true;
47
+ }
48
+ } catch {
49
+ // A provider outage cannot make a stored row healthy. Preserve the row for
50
+ // audit/unlink but project it as unverified and keep workspace status
51
+ // unbound until GitHub can be checked again.
52
+ liveById = new Map();
53
+ }
11
54
  return installations.map((installation) => ({
12
55
  installationId: installation.installationId,
56
+ githubAccountId: installation.githubAccountId,
13
57
  accountLogin: installation.accountLogin,
14
58
  accountType: installation.accountType,
59
+ lifecycle: githubInstallationBindingLifecycle(
60
+ installation,
61
+ liveById,
62
+ installation.installationId,
63
+ lifecycleVerified,
64
+ ),
15
65
  repositoryScope: installation.repositoryScope,
16
66
  repositoryCount: installation.repositoryIds.length,
17
67
  createdAt: installation.createdAt,
@@ -19,28 +69,86 @@ export async function listWorkspaceGitHubInstallationBindings(
19
69
  }));
20
70
  }
21
71
 
72
+ export function githubBindingStatus(
73
+ configured: boolean,
74
+ installations: GitHubInstallationBinding[],
75
+ ): GitHubBindingStatus {
76
+ if (!configured) {
77
+ return "disabled";
78
+ }
79
+ return installations.some((installation) => installation.lifecycle === "active")
80
+ ? "bound"
81
+ : "unbound";
82
+ }
83
+
84
+ export type LiveGitHubInstallation = {
85
+ installationId: number;
86
+ accountId: number;
87
+ suspended: boolean;
88
+ };
89
+
90
+ export function githubInstallationBindingLifecycle(
91
+ stored: Awaited<ReturnType<typeof listGitHubInstallationAccessForWorkspace>>[number],
92
+ liveById: Map<number, LiveGitHubInstallation | null>,
93
+ installationId: number,
94
+ lifecycleVerified: boolean,
95
+ ): GitHubInstallationBinding["lifecycle"] {
96
+ if (!hasAuditableGitHubInstallationAuthority(stored)) {
97
+ return "unverified";
98
+ }
99
+ if (!lifecycleVerified) {
100
+ return "unverified";
101
+ }
102
+ if (!liveById.has(installationId)) {
103
+ return "deleted";
104
+ }
105
+ const live = liveById.get(installationId);
106
+ if (!live) {
107
+ return "deleted";
108
+ }
109
+ if (live.suspended) {
110
+ return "suspended";
111
+ }
112
+ if (live.installationId !== installationId || stored.githubAccountId !== live.accountId) {
113
+ return "unverified";
114
+ }
115
+ return "active";
116
+ }
117
+
22
118
  export async function listWorkspaceGitHubRepositories(
23
119
  deps: ApiRouteDeps,
24
120
  workspaceId: string,
25
121
  ): Promise<GitHubRepository[]> {
122
+ const bindings = await listWorkspaceGitHubInstallationBindings(deps, workspaceId);
123
+ const activeInstallationIds = new Set(
124
+ bindings
125
+ .filter((installation) => installation.lifecycle === "active")
126
+ .map((installation) => installation.installationId),
127
+ );
128
+ if (activeInstallationIds.size === 0) {
129
+ return [];
130
+ }
26
131
  const access = await listGitHubInstallationAccessForWorkspace(deps.db, workspaceId);
27
- if (access.length === 0) {
132
+ const authorizedAccess = access.filter(
133
+ (installation) =>
134
+ activeInstallationIds.has(installation.installationId) &&
135
+ hasAuditableGitHubInstallationAuthority(installation),
136
+ );
137
+ if (authorizedAccess.length === 0) {
28
138
  return [];
29
139
  }
30
- const installationIds = access.map((installation) => installation.installationId);
140
+ const installationIds = authorizedAccess.map((installation) => installation.installationId);
31
141
  const repositories = deps.githubAppApi?.listRepositories
32
142
  ? await deps.githubAppApi.listRepositories({ installationIds })
33
143
  : await listGitHubAppRepositories(deps.settings, { installationIds });
34
144
  const accessByInstallation = new Map(
35
- access.map((installation) => [installation.installationId, installation]),
145
+ authorizedAccess.map((installation) => [installation.installationId, installation]),
36
146
  );
37
147
  return repositories.filter((repository) => {
38
148
  const installation = accessByInstallation.get(repository.installationId);
39
149
  if (!installation) {
40
150
  return false;
41
151
  }
42
- return (
43
- installation.repositoryScope === "all" || installation.repositoryIds.includes(repository.id)
44
- );
152
+ return installation.repositoryIds.includes(repository.id);
45
153
  });
46
154
  }
@@ -4,10 +4,10 @@ import { hasPermission } from "@opengeni/core";
4
4
  import type { GitHubSignedStatePayload } from "@opengeni/github";
5
5
 
6
6
  /**
7
- * Dormant compatibility helpers for tests and decoding already-issued browser
8
- * handoffs. No production route imports this module. Its signed claims preserve
9
- * a prior OpenGeni grant across a redirect; they do not prove that GitHub
10
- * authorizes the human to install, configure, or bind an App installation.
7
+ * Bounded configured-token browser handoff. These signed claims preserve only
8
+ * the exact OpenGeni github:manage grant across GitHub redirects; the callback
9
+ * independently proves current GitHub personal/organization ownership. This
10
+ * state must never be interpreted as GitHub installation authority.
11
11
  */
12
12
  export const githubBrowserGrantMaxAgeSeconds = 10 * 60;
13
13
 
package/src/http/auth.ts CHANGED
@@ -59,9 +59,8 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
59
59
  if (path.startsWith("/v1/catalog-assets/")) {
60
60
  return true;
61
61
  }
62
- // Compatibility entry for already-issued GitHub install/link URLs. It stays
63
- // public like the callbacks above, verifies signed workspace-bound state,
64
- // and then terminates with 410 while new installation binding is disabled.
62
+ // The GitHub owner-consent entry remains public like the callbacks above; it
63
+ // verifies fresh signed workspace-bound state before redirecting to GitHub.
65
64
  if (githubConnectPathPattern.test(path)) {
66
65
  return true;
67
66
  }
@@ -78,7 +77,10 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
78
77
  if (installExactPaths.has(path) || isInstallRedirectPath(path)) {
79
78
  return true;
80
79
  }
81
- if (settings.authAllowHealth && (path === "/healthz" || path === "/readyz")) {
80
+ if (
81
+ settings.authAllowHealth &&
82
+ (path === "/healthz" || path === "/readyz" || path === "/traffic-readyz")
83
+ ) {
82
84
  return true;
83
85
  }
84
86
  if (settings.authAllowMetrics && path === "/metrics") {
package/src/index.ts CHANGED
@@ -12,7 +12,13 @@ import type {
12
12
  ScheduledTaskOverlapPolicy,
13
13
  ScheduledTaskScheduleSpec,
14
14
  } from "@opengeni/contracts";
15
- import { createDb, markSessionWorkflowWakeDelivered, type Database } from "@opengeni/db";
15
+ import {
16
+ assertRuntimeDatabasePosture,
17
+ createDb,
18
+ markSessionWorkflowWakeDelivered,
19
+ runtimeDatabaseReadyCheck,
20
+ type Database,
21
+ } from "@opengeni/db";
16
22
  import { createNatsEventBus, type ResponderConnection } from "@opengeni/events";
17
23
  import { createObservability, logStartupDependencyRetry } from "@opengeni/observability";
18
24
  import { SESSION_WORKFLOW_WAKE_DISPATCHER_SCHEDULE_ID } from "@opengeni/core";
@@ -253,11 +259,21 @@ export async function startApi() {
253
259
  const retryOptions = startupRetryOptions(settings);
254
260
  const onRetry = (event: Parameters<typeof logStartupDependencyRetry>[1]) =>
255
261
  logStartupDependencyRetry(observability, event);
262
+ const databasePosture = {
263
+ rlsStrategy: settings.rlsStrategy,
264
+ expectedRole: settings.runtimeDatabaseRole,
265
+ targetSchema: settings.dbSchema.trim() || "public",
266
+ } as const;
256
267
  // The PRIVILEGED control-plane NATS login (M-AUTH): when the server runs with
257
268
  // auth_callout, api/worker authenticate as a static account user permitted to
258
269
  // request `agent.*.rpc`. Null in local dev (anonymous connect — the bus default).
259
270
  const controlPlaneAuth = resolveNatsControlPlaneAuth(settings);
260
271
  try {
272
+ await retryStartupDependency(
273
+ "PostgreSQL runtime posture",
274
+ () => assertRuntimeDatabasePosture(dbClient.db, databasePosture),
275
+ { ...retryOptions, onRetry },
276
+ );
261
277
  bus = await retryStartupDependency(
262
278
  "NATS",
263
279
  () =>
@@ -296,6 +312,9 @@ export async function startApi() {
296
312
  workflowClient: workflowClient.client,
297
313
  documentIndexer: workflowClient.documentIndexer,
298
314
  observability,
315
+ readinessChecks: {
316
+ db: runtimeDatabaseReadyCheck(dbClient.db, databasePosture),
317
+ },
299
318
  });
300
319
  const server = Bun.serve({
301
320
  hostname: settings.apiHost,