@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/dist/app.d.ts +3 -2
- package/dist/app.js +3 -1
- package/dist/{chunk-BFWSDESE.js → chunk-S2N4252E.js} +3731 -2009
- package/dist/chunk-S2N4252E.js.map +1 -0
- package/dist/index.js +21 -3
- package/dist/index.js.map +1 -1
- package/package.json +10 -10
- package/src/app.ts +142 -26
- package/src/github-access.ts +117 -9
- package/src/github-browser-flow.ts +4 -4
- package/src/http/auth.ts +6 -4
- package/src/index.ts +20 -1
- package/src/integrations/slack-bot.ts +694 -0
- package/src/mcp/documents.ts +19 -5
- package/src/mcp/server.ts +158 -12
- package/src/routes/connections.ts +155 -1
- package/src/routes/documents.ts +191 -9
- package/src/routes/files.ts +1 -1
- package/src/routes/github.ts +331 -23
- package/src/routes/sessions.ts +37 -0
- package/src/routes/workspace-instruction-policies.ts +243 -0
- package/src/sandbox/channel-a.ts +131 -70
- package/dist/chunk-BFWSDESE.js.map +0 -1
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
617
|
+
checks: { [Name in keyof Checks]: ReadinessCheckResult };
|
|
502
618
|
}> {
|
|
503
619
|
const entries = await Promise.all(
|
|
504
|
-
(Object.entries(checks) as Array<[
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
|
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",
|
package/src/github-access.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
//
|
|
63
|
-
//
|
|
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 (
|
|
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 {
|
|
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,
|