@opengeni/api-router 0.2.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.
Files changed (41) hide show
  1. package/dist/app.d.ts +16 -0
  2. package/dist/app.js +35 -0
  3. package/dist/app.js.map +1 -0
  4. package/dist/chunk-XSYUDIX3.js +6331 -0
  5. package/dist/chunk-XSYUDIX3.js.map +1 -0
  6. package/dist/index.d.ts +19 -0
  7. package/dist/index.js +567 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +74 -0
  10. package/src/app.ts +351 -0
  11. package/src/auth/managed-auth.ts +237 -0
  12. package/src/http/auth.ts +92 -0
  13. package/src/http/common.ts +16 -0
  14. package/src/http/sse.ts +89 -0
  15. package/src/index.ts +362 -0
  16. package/src/mcp/documents.ts +57 -0
  17. package/src/mcp/server.ts +961 -0
  18. package/src/mcp/session-view.ts +281 -0
  19. package/src/routes/api-keys.ts +65 -0
  20. package/src/routes/billing.ts +495 -0
  21. package/src/routes/capabilities.ts +80 -0
  22. package/src/routes/codex.ts +393 -0
  23. package/src/routes/documents.ts +185 -0
  24. package/src/routes/enrollments.ts +357 -0
  25. package/src/routes/environments.ts +175 -0
  26. package/src/routes/files.ts +148 -0
  27. package/src/routes/github.ts +341 -0
  28. package/src/routes/install.ts +218 -0
  29. package/src/routes/machines.ts +107 -0
  30. package/src/routes/packs.ts +241 -0
  31. package/src/routes/scheduled-tasks.ts +126 -0
  32. package/src/routes/sessions.ts +1083 -0
  33. package/src/routes/social.ts +119 -0
  34. package/src/routes/workspaces.ts +206 -0
  35. package/src/sandbox/access.ts +89 -0
  36. package/src/sandbox/auth-callout.ts +178 -0
  37. package/src/sandbox/channel-a.ts +265 -0
  38. package/src/sandbox/enrollment.ts +498 -0
  39. package/src/sandbox/machines.ts +255 -0
  40. package/src/sandbox/metrics-ingestion.ts +289 -0
  41. package/src/sandbox/viewer.ts +993 -0
@@ -0,0 +1,119 @@
1
+ import {
2
+ CreateSocialConnectionRequest,
3
+ CreateSocialPostRequest,
4
+ } from "@opengeni/contracts";
5
+ import {
6
+ createSocialConnection,
7
+ createSocialPost,
8
+ listSocialConnections,
9
+ listSocialPosts,
10
+ } from "@opengeni/db";
11
+ import type { Hono } from "hono";
12
+ import { HTTPException } from "hono/http-exception";
13
+ import { z } from "zod";
14
+ import { requireAccessGrant } from "@opengeni/core";
15
+ import type { ApiRouteDeps } from "@opengeni/core";
16
+ import { boundedLimit } from "../http/common";
17
+
18
+ export function registerSocialRoutes(app: Hono, deps: ApiRouteDeps): void {
19
+ const { db } = deps;
20
+
21
+ app.get("/v1/workspaces/:workspaceId/social/connections", async (c) => {
22
+ const workspaceId = c.req.param("workspaceId");
23
+ await requireAccessGrant(c, deps, workspaceId, "workspace:read");
24
+ return c.json(await listSocialConnections(db, workspaceId, boundedLimit(c.req.query("limit"))));
25
+ });
26
+
27
+ app.post("/v1/workspaces/:workspaceId/social/connections", async (c) => {
28
+ const workspaceId = c.req.param("workspaceId");
29
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
30
+ const payload = CreateSocialConnectionRequest.parse(await c.req.json());
31
+ try {
32
+ return c.json(await createSocialConnection(db, {
33
+ accountId: grant.accountId,
34
+ workspaceId,
35
+ provider: payload.provider,
36
+ accountHandle: payload.accountHandle,
37
+ accountName: payload.accountName ?? null,
38
+ externalAccountId: payload.externalAccountId ?? null,
39
+ status: payload.status,
40
+ scopes: payload.scopes,
41
+ credentialRef: payload.credentialRef ?? null,
42
+ tokenMetadata: payload.tokenMetadata,
43
+ metadata: payload.metadata,
44
+ }), 201);
45
+ } catch (error) {
46
+ throw socialHttpException(error);
47
+ }
48
+ });
49
+
50
+ app.get("/v1/workspaces/:workspaceId/social/posts", async (c) => {
51
+ const workspaceId = c.req.param("workspaceId");
52
+ await requireAccessGrant(c, deps, workspaceId, "workspace:read");
53
+ const since = parseSince(c.req.query("since"));
54
+ const connectionIds = parseConnectionIds(c.req.query("connectionIds") ?? c.req.query("connectionId"));
55
+ return c.json(await listSocialPosts(db, {
56
+ workspaceId,
57
+ ...(connectionIds?.length ? { connectionIds } : {}),
58
+ ...(since ? { since } : {}),
59
+ limit: boundedLimit(c.req.query("limit")),
60
+ }));
61
+ });
62
+
63
+ app.post("/v1/workspaces/:workspaceId/social/posts", async (c) => {
64
+ const workspaceId = c.req.param("workspaceId");
65
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
66
+ const payload = CreateSocialPostRequest.parse(await c.req.json());
67
+ try {
68
+ return c.json(await createSocialPost(db, {
69
+ accountId: grant.accountId,
70
+ workspaceId,
71
+ connectionId: payload.connectionId,
72
+ externalPostId: payload.externalPostId ?? null,
73
+ url: payload.url ?? null,
74
+ authorHandle: payload.authorHandle ?? null,
75
+ text: payload.text,
76
+ publishedAt: new Date(payload.publishedAt),
77
+ metrics: payload.metrics,
78
+ raw: payload.raw,
79
+ }), 201);
80
+ } catch (error) {
81
+ throw socialHttpException(error);
82
+ }
83
+ });
84
+ }
85
+
86
+ function parseSince(raw: string | undefined): Date | undefined {
87
+ if (!raw) {
88
+ return undefined;
89
+ }
90
+ const since = new Date(raw);
91
+ if (Number.isNaN(since.getTime())) {
92
+ throw new HTTPException(422, { message: "since must be an ISO date-time" });
93
+ }
94
+ return since;
95
+ }
96
+
97
+ function parseConnectionIds(raw: string | undefined): string[] | undefined {
98
+ if (!raw) {
99
+ return undefined;
100
+ }
101
+ const values = raw.split(",").map((value) => value.trim()).filter(Boolean);
102
+ const parsed = z.array(z.string().uuid()).safeParse(values);
103
+ if (!parsed.success) {
104
+ throw new HTTPException(422, { message: "connectionIds must be a comma-separated list of UUIDs" });
105
+ }
106
+ const ids = parsed.data;
107
+ return [...new Set(ids)];
108
+ }
109
+
110
+ function socialHttpException(error: unknown): HTTPException {
111
+ const message = error instanceof Error ? error.message : String(error);
112
+ if (message.includes("not found")) {
113
+ return new HTTPException(404, { message });
114
+ }
115
+ if (message.includes("duplicate key")) {
116
+ return new HTTPException(409, { message: "social connection or post already exists" });
117
+ }
118
+ return new HTTPException(500, { message });
119
+ }
@@ -0,0 +1,206 @@
1
+ import {
2
+ AddWorkspaceMemberRequest,
3
+ CreateWorkspaceRequest,
4
+ ListWorkspaceMembersResponse,
5
+ UpdateWorkspaceMemberRequest,
6
+ UpdateWorkspaceRequest,
7
+ Workspace,
8
+ WorkspaceMember,
9
+ type AccessContext,
10
+ type Permission,
11
+ } from "@opengeni/contracts";
12
+ import {
13
+ allWorkspacePermissions,
14
+ countActiveSessionsForWorkspace,
15
+ countWorkspacesForAccount,
16
+ createWorkspace,
17
+ deleteWorkspace,
18
+ getManagedUserByEmail,
19
+ grantWorkspaceAccess,
20
+ listScheduledTasks,
21
+ listWorkspaceMembers,
22
+ listWorkspacesForSubject,
23
+ removeWorkspaceMember,
24
+ requireWorkspace,
25
+ updateWorkspace,
26
+ } from "@opengeni/db";
27
+ import type { Hono } from "hono";
28
+ import { HTTPException } from "hono/http-exception";
29
+ import { hasPermission, requireAccessContext, requireAccessGrant } from "@opengeni/core";
30
+ import { requireLimit } from "@opengeni/core";
31
+ import type { ApiRouteDeps } from "@opengeni/core";
32
+ import { assertWorkspaceDeletable, assertWorkspaceMemberRemovable, resolveMemberSubjectId } from "@opengeni/core";
33
+
34
+ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
35
+ app.get("/v1/access/me", async (c) => {
36
+ return c.json(await requireAccessContext(c, deps));
37
+ });
38
+
39
+ app.get("/v1/workspaces", async (c) => {
40
+ const context = await requireAccessContext(c, deps);
41
+ const readableWorkspaceIds = [...new Set(context.workspaceGrants
42
+ .filter((grant) => hasPermission(grant.permissions, "workspace:read"))
43
+ .map((grant) => grant.workspaceId))];
44
+ if (readableWorkspaceIds.length > 0) {
45
+ const workspaces = await Promise.all(readableWorkspaceIds.map((workspaceId) => requireWorkspace(deps.db, workspaceId)));
46
+ return c.json(workspaces.map((workspace) => Workspace.parse(workspace)));
47
+ }
48
+ return c.json((await listWorkspacesForSubject(deps.db, context.subjectId)).map((workspace) => Workspace.parse(workspace)));
49
+ });
50
+
51
+ app.post("/v1/workspaces", async (c) => {
52
+ const context = await requireAccessContext(c, deps);
53
+ const payload = CreateWorkspaceRequest.parse(await c.req.json());
54
+ const accountId = payload.accountId ?? context.defaultAccountId;
55
+ if (!accountId) {
56
+ throw new HTTPException(409, { message: "account selection is required" });
57
+ }
58
+ requireAccountPermission(context, accountId, "workspace:create");
59
+ await requireLimit(deps, { accountId, action: "workspace:create", quantity: 1 });
60
+ const workspace = await createWorkspace(deps.db, {
61
+ accountId,
62
+ name: payload.name.trim(),
63
+ slug: payload.slug?.trim() || null,
64
+ externalSource: payload.externalSource ?? null,
65
+ externalId: payload.externalId ?? null,
66
+ ...(payload.agentInstructions !== undefined ? { agentInstructions: normalizeAgentInstructions(payload.agentInstructions) } : {}),
67
+ });
68
+ await grantWorkspaceAccess(deps.db, {
69
+ accountId,
70
+ workspaceId: workspace.id,
71
+ subjectId: context.subjectId,
72
+ role: "owner",
73
+ permissions: allWorkspacePermissions,
74
+ ...(context.subjectLabel ? { subjectLabel: context.subjectLabel } : {}),
75
+ });
76
+ return c.json(Workspace.parse(workspace), 201);
77
+ });
78
+
79
+ app.get("/v1/workspaces/:workspaceId", async (c) => {
80
+ const workspaceId = c.req.param("workspaceId");
81
+ await requireAccessGrant(c, deps, workspaceId, "workspace:read");
82
+ return c.json(Workspace.parse(await requireWorkspace(deps.db, workspaceId)));
83
+ });
84
+
85
+ app.patch("/v1/workspaces/:workspaceId", async (c) => {
86
+ const workspaceId = c.req.param("workspaceId");
87
+ await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
88
+ const payload = UpdateWorkspaceRequest.parse(await c.req.json());
89
+ const workspace = await updateWorkspace(deps.db, workspaceId, {
90
+ ...(payload.name !== undefined ? { name: payload.name.trim() } : {}),
91
+ ...(payload.slug !== undefined ? { slug: payload.slug?.trim() || null } : {}),
92
+ ...(payload.agentInstructions !== undefined ? { agentInstructions: normalizeAgentInstructions(payload.agentInstructions) } : {}),
93
+ });
94
+ return c.json(Workspace.parse(workspace));
95
+ });
96
+
97
+ app.delete("/v1/workspaces/:workspaceId", async (c) => {
98
+ const workspaceId = c.req.param("workspaceId");
99
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
100
+ // Refuse before any external/DB mutation: never delete the account's only
101
+ // workspace, and never delete while a session could still be running in
102
+ // Temporal (there is no clean per-session terminate to call, so deleting
103
+ // the row would orphan the workflow — the operator must stop them first).
104
+ const [workspaceCountForAccount, activeSessionCount] = await Promise.all([
105
+ countWorkspacesForAccount(deps.db, grant.accountId),
106
+ countActiveSessionsForWorkspace(deps.db, workspaceId),
107
+ ]);
108
+ assertWorkspaceDeletable({ workspaceCountForAccount, activeSessionCount });
109
+ // Clean external Temporal state the FK cascade can't reach: every scheduled
110
+ // task's schedule (best-effort, mirroring the scheduled-task delete path).
111
+ const tasks = await listScheduledTasks(deps.db, workspaceId, 1000);
112
+ await Promise.all(tasks.map((task) =>
113
+ deps.workflowClient.deleteScheduledTaskSchedule({ temporalScheduleId: task.temporalScheduleId }).catch(() => undefined),
114
+ ));
115
+ await deleteWorkspace(deps.db, workspaceId);
116
+ return c.body(null, 204);
117
+ });
118
+
119
+ // --- Members ("People with access") ---------------------------------------
120
+
121
+ app.get("/v1/workspaces/:workspaceId/members", async (c) => {
122
+ const workspaceId = c.req.param("workspaceId");
123
+ await requireAccessGrant(c, deps, workspaceId, "workspace:read");
124
+ const members = await listWorkspaceMembers(deps.db, workspaceId);
125
+ return c.json(ListWorkspaceMembersResponse.parse({ members }));
126
+ });
127
+
128
+ app.post("/v1/workspaces/:workspaceId/members", async (c) => {
129
+ const workspaceId = c.req.param("workspaceId");
130
+ const grant = await requireAccessGrant(c, deps, workspaceId, "members:manage");
131
+ const payload = AddWorkspaceMemberRequest.parse(await c.req.json());
132
+ const email = payload.email.trim();
133
+ // Email invites for not-yet-registered users are deferred: an unknown email
134
+ // resolves to null, which resolveMemberSubjectId turns into a 404.
135
+ const subjectId = resolveMemberSubjectId(await getManagedUserByEmail(deps.db, email));
136
+ await grantWorkspaceAccess(deps.db, {
137
+ accountId: grant.accountId,
138
+ workspaceId,
139
+ subjectId,
140
+ subjectLabel: email,
141
+ ...(payload.role !== undefined ? { role: payload.role } : {}),
142
+ permissions: payload.permissions,
143
+ });
144
+ const members = await listWorkspaceMembers(deps.db, workspaceId);
145
+ const member = members.find((candidate) => candidate.subjectId === subjectId);
146
+ if (!member) {
147
+ throw new HTTPException(500, { message: "failed to add member" });
148
+ }
149
+ return c.json(WorkspaceMember.parse(member), 201);
150
+ });
151
+
152
+ app.patch("/v1/workspaces/:workspaceId/members/:subjectId", async (c) => {
153
+ const workspaceId = c.req.param("workspaceId");
154
+ const grant = await requireAccessGrant(c, deps, workspaceId, "members:manage");
155
+ const subjectId = decodeURIComponent(c.req.param("subjectId"));
156
+ const payload = UpdateWorkspaceMemberRequest.parse(await c.req.json());
157
+ const existing = await listWorkspaceMembers(deps.db, workspaceId);
158
+ const current = existing.find((member) => member.subjectId === subjectId);
159
+ if (!current) {
160
+ throw new HTTPException(404, { message: "member not found" });
161
+ }
162
+ await grantWorkspaceAccess(deps.db, {
163
+ accountId: grant.accountId,
164
+ workspaceId,
165
+ subjectId,
166
+ ...(current.subjectLabel ? { subjectLabel: current.subjectLabel } : {}),
167
+ role: payload.role ?? current.role,
168
+ permissions: payload.permissions,
169
+ });
170
+ const members = await listWorkspaceMembers(deps.db, workspaceId);
171
+ const member = members.find((candidate) => candidate.subjectId === subjectId);
172
+ if (!member) {
173
+ throw new HTTPException(500, { message: "failed to update member" });
174
+ }
175
+ return c.json(WorkspaceMember.parse(member));
176
+ });
177
+
178
+ app.delete("/v1/workspaces/:workspaceId/members/:subjectId", async (c) => {
179
+ const workspaceId = c.req.param("workspaceId");
180
+ const grant = await requireAccessGrant(c, deps, workspaceId, "members:manage");
181
+ const subjectId = decodeURIComponent(c.req.param("subjectId"));
182
+ const members = await listWorkspaceMembers(deps.db, workspaceId);
183
+ // Never remove yourself, and never remove the last administering member.
184
+ assertWorkspaceMemberRemovable({ members, subjectId, callerSubjectId: grant.subjectId });
185
+ await removeWorkspaceMember(deps.db, workspaceId, subjectId);
186
+ return c.body(null, 204);
187
+ });
188
+ }
189
+
190
+ // A persona override that is null or trims to empty collapses to null (use the
191
+ // deployment default). Otherwise the template is stored verbatim so the runtime
192
+ // can substitute the non-bypassable CORE at its {{core}} marker.
193
+ function normalizeAgentInstructions(value: string | null): string | null {
194
+ if (value === null) {
195
+ return null;
196
+ }
197
+ const trimmed = value.trim();
198
+ return trimmed.length > 0 ? trimmed : null;
199
+ }
200
+
201
+ function requireAccountPermission(context: AccessContext, accountId: string, permission: Permission): void {
202
+ const grant = context.accountGrants.find((candidate) => candidate.accountId === accountId);
203
+ if (!grant || (!grant.permissions.includes(permission) && !grant.permissions.includes("account:admin"))) {
204
+ throw new HTTPException(403, { message: `missing permission: ${permission}` });
205
+ }
206
+ }
@@ -0,0 +1,89 @@
1
+ // apps/api/src/sandbox/access.ts — the API-tier sandbox access seam.
2
+ //
3
+ // This is the foundation of the API-DIRECT control plane
4
+ // (docs/design/sandbox-surfacing): the apps/api process constructs its OWN
5
+ // sandbox client and resumes boxes by id IN-PROCESS, so non-turn ops (viewer
6
+ // attach, FS/git reads, tunnel URL mint) never touch Temporal or a worker.
7
+ //
8
+ // IMPORT DISCIPLINE (enforced by apps/api/test/sandbox-access-import-guard.test.ts):
9
+ // apps/api accesses sandbox construction/resume symbols ONLY via the
10
+ // agent-loop-free leaf `@opengeni/runtime/sandbox` — NEVER the bare
11
+ // `@opengeni/runtime` barrel (which pulls the @openai/agents agent loop into
12
+ // the API process). This file is the single chokepoint for that import.
13
+ import { createSandboxClient } from "@opengeni/runtime/sandbox";
14
+ import type { Settings } from "@opengeni/config";
15
+
16
+ // The structural shapes the API needs from a provider sandbox client now live in
17
+ // @opengeni/core (`sandbox-types.ts`) — `dependencies.ts` (also in core)
18
+ // references them as the `sandboxClient` / `resumeBoxById` provider seams, so
19
+ // core is their single owner. We re-export them from here so existing apps/api
20
+ // importers (and the `@opengeni/runtime/sandbox` value implementation below)
21
+ // keep the same import site. (Mirrors @openai/agents/sandbox's SandboxClient
22
+ // without importing the agent-loop barrel.)
23
+ export type {
24
+ ApiSandboxSession,
25
+ ApiSandboxClient,
26
+ ResumeBoxByIdInput,
27
+ ResumedSandboxSession,
28
+ } from "@opengeni/core";
29
+ import type {
30
+ ApiSandboxClient,
31
+ ApiSandboxSession,
32
+ ResumeBoxByIdInput,
33
+ ResumedSandboxSession,
34
+ } from "@opengeni/core";
35
+
36
+ export class SandboxResumeError extends Error {
37
+ constructor(message: string, readonly cause?: unknown) {
38
+ super(message);
39
+ this.name = "SandboxResumeError";
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Construct the API process's own sandbox client from settings, agent-loop-free.
45
+ * Returns undefined when `sandboxBackend=none` (no box to touch). The Modal
46
+ * token + app name are read from settings (already parsed by getSettings and
47
+ * present in the API runtime env), so the client can resume Modal boxes by id.
48
+ */
49
+ export function createApiSandboxClient(settings: Settings): ApiSandboxClient | undefined {
50
+ const client = createSandboxClient(settings) as ApiSandboxClient | undefined;
51
+ return client;
52
+ }
53
+
54
+ /**
55
+ * Build the `resumeBoxById` helper bound to the API's sandbox client. Given a
56
+ * backend + a serialized resume_state envelope, it resumes the box and returns
57
+ * a live session for one in-process op. The caller drives exec/readFile and then
58
+ * drops the handle (resume → use → drop); it does NOT own the box.
59
+ */
60
+ export function makeResumeBoxById(client: ApiSandboxClient | undefined): (input: ResumeBoxByIdInput) => Promise<ResumedSandboxSession> {
61
+ return async ({ backend, resumeState }: ResumeBoxByIdInput): Promise<ResumedSandboxSession> => {
62
+ if (!client) {
63
+ throw new SandboxResumeError(
64
+ "The API sandbox client is not configured (sandboxBackend=none); cannot resume a box by id.",
65
+ );
66
+ }
67
+ if (client.backendId !== backend) {
68
+ throw new SandboxResumeError(
69
+ `Resume backend "${backend}" does not match the API sandbox client backend "${client.backendId}"; a cross-backend resume_state envelope cannot be deserialized.`,
70
+ );
71
+ }
72
+ if (!client.deserializeSessionState || !client.resume) {
73
+ throw new SandboxResumeError(
74
+ `The configured sandbox backend "${client.backendId}" does not support resume-by-id (no deserializeSessionState/resume).`,
75
+ );
76
+ }
77
+ let session: ApiSandboxSession;
78
+ try {
79
+ const state = await client.deserializeSessionState(resumeState);
80
+ session = await client.resume(state);
81
+ } catch (error) {
82
+ throw new SandboxResumeError(
83
+ `Failed to resume sandbox box by id on backend "${backend}": ${error instanceof Error ? error.message : String(error)}`,
84
+ error,
85
+ );
86
+ }
87
+ return session;
88
+ };
89
+ }
@@ -0,0 +1,178 @@
1
+ // apps/api/src/sandbox/auth-callout.ts — the NATS AUTH-CALLOUT responder (the
2
+ // bring-your-own-compute M-AUTH tenancy boundary; dossier §10.1 NATS Accounts per
3
+ // workspace + §17 the isolation smoke + §19 the NATS-Accounts-misconfig leak risk).
4
+ //
5
+ // THE BOUNDARY THIS CLOSES: an external agent connects to NATS presenting its
6
+ // `oge_` enrollment bearer as the connect auth-token. nats-server (configured with
7
+ // `auth_callout`) issues an authorization request on $SYS.REQ.USER.AUTH. THIS
8
+ // responder:
9
+ // 1. decodes the authorization request (the `user_nkey` the response must scope
10
+ // to, the `server_id` for the response `aud`, and the presented `auth_token`);
11
+ // 2. VALIDATES the bearer with verifyEnrollmentBearer (HMAC, via
12
+ // resolveEnrollmentSigningSecret) — an invalid/expired/forged bearer is denied;
13
+ // 3. confirms the enrollment is still ACTIVE in the DB (a revoked machine is
14
+ // denied even with a still-unexpired bearer);
15
+ // 4. signs a NATS user JWT granting pub/sub ONLY `agent.<ws>.>` + `_INBOX.>`
16
+ // (deny-all-else by an allow-list) and returns it inside a signed
17
+ // authorization-response JWT.
18
+ //
19
+ // That per-subject scope is the per-workspace ISOLATION: workspace A's agent is
20
+ // cryptographically incapable of pub/sub on `agent.B.>`. nats-server enforces the
21
+ // signed permission set; the boundary does not rely on subject naming alone (the M4
22
+ // transport test proved the CODE constructs scoped subjects; THIS proves the SERVER
23
+ // refuses cross-workspace access).
24
+ //
25
+ // SECURITY (§18): the bearer value + the account signing seed are NEVER logged. A
26
+ // validation failure → a DENIAL response (the server refuses the connection); a
27
+ // responder error → the request is left UNANSWERED (fail-closed; the server denies
28
+ // on its callout timeout). The bearer's `exp` caps the minted credential's life so a
29
+ // revoked/expired enrollment cannot outlive its bearer.
30
+
31
+ import { resolveEnrollmentSigningSecret, type NatsCalloutConfig, type Settings } from "@opengeni/config";
32
+ import { verifyEnrollmentBearer } from "@opengeni/contracts";
33
+ import { getEnrollment, type Database } from "@opengeni/db";
34
+ import {
35
+ createResponderConnection,
36
+ decodeAuthRequest,
37
+ mintAuthResponse,
38
+ mintUserJwt,
39
+ workspaceAgentPermissions,
40
+ type ResponderConnection,
41
+ } from "@opengeni/events";
42
+ import type { Observability } from "@opengeni/observability";
43
+
44
+ /** The NATS subject nats-server publishes authorization requests on (ADR-26). */
45
+ export const AUTH_CALLOUT_SUBJECT = "$SYS.REQ.USER.AUTH";
46
+
47
+ export interface AuthCalloutDeps {
48
+ db: Database;
49
+ settings: Settings;
50
+ callout: NatsCalloutConfig;
51
+ observability?: Observability;
52
+ }
53
+
54
+ /**
55
+ * The pure validate→scoped-JWT decision, isolated from the NATS transport so it is
56
+ * unit-testable. Given the raw authorization-request JWT bytes, returns the signed
57
+ * authorization-response JWT bytes to reply with — a GRANT (embedding a scoped user
58
+ * JWT) on success, a DENIAL (carrying `nats.error`, no user JWT) otherwise. NEVER
59
+ * throws on a bad/invalid request: every failure becomes a signed denial (the
60
+ * server then refuses the connection cleanly).
61
+ */
62
+ export async function handleAuthorizationRequest(
63
+ deps: AuthCalloutDeps,
64
+ requestBytes: Uint8Array,
65
+ ): Promise<Uint8Array> {
66
+ const requestJwt = Buffer.from(requestBytes).toString("utf8");
67
+ const decoded = decodeAuthRequest(requestJwt);
68
+ if (!decoded) {
69
+ // A malformed request we cannot even read the user_nkey/server_id from — there
70
+ // is nothing to scope a response to. Leave it for the server's timeout by
71
+ // throwing (the transport leaves it unanswered, fail-closed).
72
+ deps.observability?.warn?.("auth-callout: undecodable authorization request", {});
73
+ throw new Error("undecodable authorization request");
74
+ }
75
+
76
+ const deny = (reason: string): Uint8Array => {
77
+ // A SIGNED denial: the server reads `nats.error` and refuses the connection.
78
+ const response = mintAuthResponse({
79
+ userPublicKey: decoded.userNkey,
80
+ serverId: decoded.serverId,
81
+ accountSeed: deps.callout.accountSeed,
82
+ error: reason,
83
+ });
84
+ return Buffer.from(response, "utf8");
85
+ };
86
+
87
+ const bearer = decoded.authToken;
88
+ if (!bearer) {
89
+ return deny("missing enrollment bearer");
90
+ }
91
+
92
+ const secret = resolveEnrollmentSigningSecret(deps.settings);
93
+ if (!secret) {
94
+ // The credential plane is off for this deployment — deny rather than mint an
95
+ // unscoped credential. (The responder should not even be running in this case,
96
+ // but fail-closed regardless.)
97
+ return deny("enrollment credential plane disabled");
98
+ }
99
+
100
+ const claims = await verifyEnrollmentBearer(secret, bearer);
101
+ if (!claims) {
102
+ // Invalid signature / malformed / expired bearer. NEVER log the bearer value.
103
+ deps.observability?.warn?.("auth-callout: rejected an invalid enrollment bearer", {});
104
+ return deny("invalid or expired enrollment bearer");
105
+ }
106
+
107
+ // Confirm the enrollment is still ACTIVE — a revoked machine is denied even with a
108
+ // still-unexpired bearer (the revoke path flips status; this re-checks at connect).
109
+ const enrollment = await getEnrollment(deps.db, claims.workspaceId, claims.enrollmentId);
110
+ if (!enrollment || enrollment.status !== "active") {
111
+ deps.observability?.warn?.("auth-callout: denied a revoked or unknown enrollment", {
112
+ workspaceId: claims.workspaceId,
113
+ agentId: claims.agentId,
114
+ });
115
+ return deny("enrollment is not active");
116
+ }
117
+
118
+ // Belt-and-braces: the bearer's agentId/enrollmentId must match the row we found.
119
+ // (verifyEnrollmentBearer already binds them; this guards a future schema where
120
+ // agentId != enrollmentId.)
121
+ if (enrollment.id !== claims.enrollmentId) {
122
+ return deny("enrollment identity mismatch");
123
+ }
124
+
125
+ // GRANT: a user JWT scoped to ONLY this workspace's agent subtree + the reply
126
+ // inbox. This allow-list IS the per-workspace isolation boundary.
127
+ const permissions = workspaceAgentPermissions(claims.workspaceId);
128
+ const userJwt = mintUserJwt({
129
+ userPublicKey: decoded.userNkey,
130
+ accountSeed: deps.callout.accountSeed,
131
+ name: claims.agentId,
132
+ permissions,
133
+ // Server-config-mode placement: the embedded user JWT's `aud` is the account
134
+ // the user binds to (the configured `auth_callout.account`). All agents +
135
+ // the privileged control plane share this account so subjects route; the
136
+ // per-workspace isolation is carried by the subject permissions above.
137
+ audienceAccount: deps.callout.accountName,
138
+ // Tie the credential's life to the bearer's remaining life: a revoked/expired
139
+ // enrollment cannot outlive its bearer at the NATS layer either.
140
+ expiresAtSeconds: claims.exp,
141
+ });
142
+ const response = mintAuthResponse({
143
+ userPublicKey: decoded.userNkey,
144
+ serverId: decoded.serverId,
145
+ accountSeed: deps.callout.accountSeed,
146
+ userJwt,
147
+ });
148
+ deps.observability?.info?.("auth-callout: granted a workspace-scoped NATS credential", {
149
+ workspaceId: claims.workspaceId,
150
+ agentId: claims.agentId,
151
+ });
152
+ return Buffer.from(response, "utf8");
153
+ }
154
+
155
+ /**
156
+ * Start the auth-callout responder: open a SEPARATE NATS connection authenticated
157
+ * as the callout `auth_users` user, subscribe $SYS.REQ.USER.AUTH, and answer every
158
+ * authorization request via {@link handleAuthorizationRequest}. Returns a handle
159
+ * whose `close()` drains the connection. Gated by the caller (sandboxSelfhostedEnabled
160
+ * + a resolvable callout config); a deployment without the callout plane never starts
161
+ * it.
162
+ */
163
+ export async function startAuthCalloutResponder(
164
+ deps: AuthCalloutDeps,
165
+ natsUrl: string,
166
+ ): Promise<ResponderConnection> {
167
+ const connection = await createResponderConnection(
168
+ natsUrl,
169
+ { kind: "user-password", user: deps.callout.user, pass: deps.callout.password },
170
+ AUTH_CALLOUT_SUBJECT,
171
+ (bytes) => handleAuthorizationRequest(deps, bytes),
172
+ { name: "opengeni-auth-callout" },
173
+ );
174
+ deps.observability?.info?.("OpenGeni NATS auth-callout responder started", {
175
+ subject: AUTH_CALLOUT_SUBJECT,
176
+ });
177
+ return connection;
178
+ }