@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,498 @@
1
+ // apps/api/src/sandbox/enrollment.ts — the API-DIRECT enrollment device-flow seam
2
+ // (M5 of the bring-your-own-compute mega-PR; dossier §10.2 enrollment + §18 LOUD
3
+ // consent). This is the service layer the routes (routes/enrollments.ts) call — it
4
+ // mirrors the channel-a.ts / viewer.ts split (a thin route over a focused service).
5
+ //
6
+ // THE FLOW (OAuth 2.0 device-authorization, RFC 8628):
7
+ // 1. start (agent-side, user-unauthenticated, rate-limited): the agent presents
8
+ // its ed25519 pubkey + os/arch + requested whole-machine exposure +
9
+ // can-offer-display + requests-screen-control. We mint an unguessable
10
+ // device_code (the poll key) + a short user_code (the user types) and persist a
11
+ // short-TTL, SINGLE-USE pending row. Returns DeviceAuthStart.
12
+ // 2. approve (USER-authenticated, workspace-gated): the LOUD CONSENT step. We
13
+ // record WHO consented WHEN to WHAT (whole-machine mandatory + screen-control
14
+ // per allow_screen_control) and, in one txn, createEnrollment + createSandbox
15
+ // (an enrollments row AND a sandboxes row appear — acceptance #2). Idempotent
16
+ // via the M2 upsert.
17
+ // 3. poll (agent-side, with device_code): pending → {pending}; approved → the
18
+ // EnrollmentCredentials (agent_id + workspace + a SIGNED bearer the agent
19
+ // presents to the control plane + the subject prefix agent.<ws>.<id> + a
20
+ // placeholder for the per-workspace NATS Account creds [infra-deferred]);
21
+ // denied/expired/disabled → the typed state.
22
+ //
23
+ // SECURITY (dossier §18): device_code/user_code are CSPRNG-unguessable + short-TTL +
24
+ // single-use; approve is strictly workspace-gated (the route asserts the grant); the
25
+ // signing secret value is NEVER logged. Rate-limiting of start/poll is enforced at
26
+ // the route. The consent record (who/when/what) lives on the request row.
27
+ //
28
+ // FLAG GATE: the whole feature is behind sandboxSelfhostedEnabled (default OFF) —
29
+ // when off the routes 404 (the surface is invisible) and boot is unaffected.
30
+
31
+ import { randomBytes } from "node:crypto";
32
+ import {
33
+ resolveEnrollmentSigningSecret,
34
+ resolveRelayTokenSecret,
35
+ type Settings,
36
+ } from "@opengeni/config";
37
+ import {
38
+ DeviceEnrollmentState,
39
+ signEnrollmentBearer,
40
+ signEnrollToken,
41
+ signRelayToken,
42
+ verifyEnrollToken,
43
+ type DeviceEnrollmentLookupResponse,
44
+ type DeviceEnrollmentPollResponse,
45
+ type DeviceEnrollmentStartResponse,
46
+ type EnrollmentCredentialsResponse,
47
+ type EnrollTokenExchangeResponse,
48
+ type MintEnrollTokenResponse,
49
+ } from "@opengeni/contracts";
50
+ import {
51
+ approveDeviceEnrollmentRequest,
52
+ consumeDeviceEnrollmentRequest,
53
+ createDeviceEnrollmentRequest,
54
+ denyDeviceEnrollmentRequest,
55
+ finalizeEnrollmentByToken,
56
+ getDeviceEnrollmentRequestByDeviceCode,
57
+ getEnrollment,
58
+ getPendingDeviceEnrollmentRequestByUserCode,
59
+ getPendingDeviceEnrollmentRequestByUserCodeGlobal,
60
+ type Database,
61
+ type DeviceEnrollmentRequestRecord,
62
+ type EnrollmentOs,
63
+ } from "@opengeni/db";
64
+ import { relayDialBaseFromSettings } from "@opengeni/core";
65
+
66
+ // The device-flow timing knobs (RFC 8628). Short TTL + a poll interval the agent
67
+ // must honor (the route rate-limits to the same cadence). These mirror the proto's
68
+ // DeviceAuthStartResponse interval/expiry fields.
69
+ export const DEVICE_CODE_TTL_SECONDS = 600; // 10 minutes
70
+ export const DEVICE_POLL_INTERVAL_SECONDS = 5;
71
+ // The bearer the agent presents to the NATS auth-callout. A bring-your-own-compute
72
+ // machine is PERSISTENT (unlike an ephemeral Modal box, whose lifetime ~= an agent
73
+ // token's hour), so this is long-lived — 30 days, matching the relay token below —
74
+ // and re-minted on every poll/re-enroll. The old 1-hour value (sized for a Modal
75
+ // box) caused a self-hosted agent to drop PERMANENTLY one hour after connecting: the
76
+ // bearer expired and the auth-callout rejected every reconnect ("re-enroll may be
77
+ // required"). A long-lived bearer is safe because the auth-callout RE-CHECKS the
78
+ // enrollment status on every (re)connect (auth-callout.ts) — a revoked machine is
79
+ // denied regardless of bearer life — exactly as the long-lived relay token relies on.
80
+ export const ENROLLMENT_BEARER_TTL_SECONDS = 30 * 24 * 3600;
81
+ // The relay PRODUCER token (the `ogr_` token; M8b/dossier §10.5) is ENROLLMENT-scoped,
82
+ // NOT per-stream: the agent presents it on every channel registration for the life
83
+ // of its run, and the producer side has no per-viewer epoch fence (that is the
84
+ // VIEWER's `ogs_` token's job). So it is long-lived — 30 days — re-minted on every
85
+ // poll/re-enroll. The relay re-verifies it (authenticity + the channel-key ws+agent
86
+ // scope) on every StreamOpen; a revoked enrollment's machine goes offline at the
87
+ // control plane regardless, so a long-lived relay token cannot reach a dead agent.
88
+ export const RELAY_TOKEN_TTL_SECONDS = 30 * 24 * 3600;
89
+ // The headless enroll token (`oget_`; design 11 §A2.1) TTL. 1h: long enough to
90
+ // script a fleet rollout, short enough to bound exposure of a workspace-scoped
91
+ // secret that IS the grant (no human approve). Re-mintable by an authorized user.
92
+ export const ENROLL_TOKEN_TTL_SECONDS = 3600;
93
+
94
+ export type EnrollmentServices = {
95
+ db: Database;
96
+ settings: Settings;
97
+ };
98
+
99
+ /** A workspace-scoped flow START context (the route resolves the workspace the
100
+ * agent's flow binds to from the deployment edge / a workspace hint). */
101
+ export type DeviceStartInput = {
102
+ accountId: string;
103
+ workspaceId: string;
104
+ publicKey: string;
105
+ os: EnrollmentOs;
106
+ arch: string;
107
+ machineName?: string | null;
108
+ canOfferDisplay: boolean;
109
+ requestsScreenControl: boolean;
110
+ // Where the user goes to approve (same origin as the request).
111
+ verificationOrigin: string;
112
+ };
113
+
114
+ // A CSPRNG opaque token (URL-safe base64, no padding) for the device_code. 32
115
+ // bytes = 256 bits of entropy — unguessable.
116
+ function mintDeviceCode(): string {
117
+ return randomBytes(32).toString("base64url");
118
+ }
119
+
120
+ // A short, human-typeable user_code: 8 chars from an unambiguous alphabet (no
121
+ // 0/O/1/I), grouped XXXX-XXXX. CSPRNG-drawn (rejection-free via modulo over a
122
+ // 32-char alphabet, which divides 256 evenly enough; we draw extra bytes and map).
123
+ const USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // 32 chars
124
+ function mintUserCode(): string {
125
+ const bytes = randomBytes(8);
126
+ let out = "";
127
+ for (let i = 0; i < 8; i += 1) {
128
+ out += USER_CODE_ALPHABET[bytes[i]! % USER_CODE_ALPHABET.length];
129
+ }
130
+ return `${out.slice(0, 4)}-${out.slice(4, 8)}`;
131
+ }
132
+
133
+ /**
134
+ * START a device-flow: persist a short-TTL single-use pending request + return the
135
+ * DeviceAuthStart. Retries the user_code mint on the (astronomically rare) partial-
136
+ * unique collision among live pending rows.
137
+ */
138
+ export async function startDeviceEnrollment(
139
+ services: EnrollmentServices,
140
+ input: DeviceStartInput,
141
+ ): Promise<DeviceEnrollmentStartResponse> {
142
+ const { db } = services;
143
+ const expiresAt = new Date(Date.now() + DEVICE_CODE_TTL_SECONDS * 1000);
144
+
145
+ let request: DeviceEnrollmentRequestRecord | undefined;
146
+ let lastError: unknown;
147
+ for (let attempt = 0; attempt < 5 && !request; attempt += 1) {
148
+ const deviceCode = mintDeviceCode();
149
+ const userCode = mintUserCode();
150
+ try {
151
+ request = await createDeviceEnrollmentRequest(db, {
152
+ accountId: input.accountId,
153
+ workspaceId: input.workspaceId,
154
+ deviceCode,
155
+ userCode,
156
+ pubkey: input.publicKey,
157
+ os: input.os,
158
+ arch: input.arch,
159
+ machineName: input.machineName ?? null,
160
+ requestedExposure: "whole-machine",
161
+ canOfferDisplay: input.canOfferDisplay,
162
+ requestsScreenControl: input.requestsScreenControl,
163
+ expiresAt,
164
+ });
165
+ } catch (error) {
166
+ // A unique-violation on the live user_code (or device_code) → re-mint + retry.
167
+ lastError = error;
168
+ }
169
+ }
170
+ if (!request) {
171
+ throw lastError instanceof Error ? lastError : new Error("failed to start device enrollment");
172
+ }
173
+
174
+ const base = input.verificationOrigin.replace(/\/$/, "");
175
+ const verificationUri = `${base}/device`;
176
+ const verificationUriComplete = `${verificationUri}?user_code=${encodeURIComponent(request.userCode)}`;
177
+ return {
178
+ deviceCode: request.deviceCode,
179
+ userCode: request.userCode,
180
+ verificationUri,
181
+ verificationUriComplete,
182
+ intervalSeconds: DEVICE_POLL_INTERVAL_SECONDS,
183
+ expiresInSeconds: DEVICE_CODE_TTL_SECONDS,
184
+ };
185
+ }
186
+
187
+ /** APPROVE a flow by user_code (the LOUD consent step). Returns the resulting
188
+ * enrollment + sandbox ids, or null when no LIVE pending request matches the code
189
+ * in this workspace (an unknown/expired/already-terminal code). */
190
+ export async function approveDeviceEnrollment(
191
+ services: EnrollmentServices,
192
+ input: {
193
+ accountId: string;
194
+ workspaceId: string;
195
+ userCode: string;
196
+ allowScreenControl: boolean;
197
+ approvedBySubjectId: string;
198
+ approvedBySubjectLabel?: string | null;
199
+ },
200
+ ): Promise<{ enrollmentId: string; sandboxId: string; allowScreenControl: boolean } | null> {
201
+ const { db } = services;
202
+ const pending = await getPendingDeviceEnrollmentRequestByUserCode(db, input.workspaceId, input.userCode);
203
+ if (!pending) {
204
+ return null;
205
+ }
206
+ // A generated, human-readable sandbox name (the machine name, or a fallback).
207
+ const sandboxName = (pending.machineName?.trim() || `${pending.os} machine`).slice(0, 256);
208
+ const result = await approveDeviceEnrollmentRequest(db, {
209
+ accountId: input.accountId,
210
+ workspaceId: input.workspaceId,
211
+ requestId: pending.id,
212
+ allowScreenControl: input.allowScreenControl,
213
+ approvedBySubjectId: input.approvedBySubjectId,
214
+ approvedBySubjectLabel: input.approvedBySubjectLabel ?? null,
215
+ sandboxName,
216
+ });
217
+ if (!result.approved || !result.enrollment || !result.sandbox) {
218
+ return null;
219
+ }
220
+ return {
221
+ enrollmentId: result.enrollment.id,
222
+ sandboxId: result.sandbox.id,
223
+ allowScreenControl: result.enrollment.allowScreenControl,
224
+ };
225
+ }
226
+
227
+ /** LOOK UP a pending flow by user_code GLOBALLY (the click-Grant approve page;
228
+ * design 11 §B.1). Returns the resolved pending record (carrying its workspaceId)
229
+ * or null when no live pending row matches the code. The ROUTE authorizes the
230
+ * caller against the resolved workspaceId (enrollments:read) BEFORE exposing
231
+ * anything — a failed grant OR a null here both surface as 404 so cross-workspace
232
+ * existence is never revealed. This does NOT consume the request. */
233
+ export async function lookupDeviceEnrollment(
234
+ services: EnrollmentServices,
235
+ input: { userCode: string },
236
+ ): Promise<DeviceEnrollmentRequestRecord | null> {
237
+ const { db } = services;
238
+ return await getPendingDeviceEnrollmentRequestByUserCodeGlobal(db, input.userCode);
239
+ }
240
+
241
+ /** Project a resolved pending record to the presentational lookup response (no
242
+ * secrets, no device_code) the approve screen (EnrollmentConsent) renders. */
243
+ export function toLookupResponse(record: DeviceEnrollmentRequestRecord): DeviceEnrollmentLookupResponse {
244
+ return {
245
+ workspaceId: record.workspaceId,
246
+ userCode: record.userCode,
247
+ machine: {
248
+ machineName: record.machineName,
249
+ os: record.os,
250
+ arch: record.arch,
251
+ canOfferDisplay: record.canOfferDisplay,
252
+ requestsScreenControl: record.requestsScreenControl,
253
+ },
254
+ expiresAt: record.expiresAt,
255
+ };
256
+ }
257
+
258
+ /** DENY a flow by user_code (the explicit "no" at the approve page; design 11
259
+ * §B.2). Workspace-scoped (the route asserts the grant). Returns whether a pending
260
+ * row was flipped to denied (false for an unknown / already-terminal code). */
261
+ export async function denyDeviceEnrollment(
262
+ services: EnrollmentServices,
263
+ input: { accountId: string; workspaceId: string; userCode: string },
264
+ ): Promise<{ denied: boolean }> {
265
+ const { db } = services;
266
+ const pending = await getPendingDeviceEnrollmentRequestByUserCode(db, input.workspaceId, input.userCode);
267
+ if (!pending) {
268
+ return { denied: false };
269
+ }
270
+ return await denyDeviceEnrollmentRequest(db, {
271
+ accountId: input.accountId,
272
+ workspaceId: input.workspaceId,
273
+ requestId: pending.id,
274
+ });
275
+ }
276
+
277
+ /** MINT a headless enroll token (design 11 §A2.2). Signs an `oget_` token bound to
278
+ * the workspace + account + the screen-control consent, with a 1h TTL. Returns
279
+ * null when the credential plane is disabled (no signing secret) so the route can
280
+ * mirror poll's "disabled" handling. The token value is NEVER logged. */
281
+ export async function mintEnrollToken(
282
+ services: EnrollmentServices,
283
+ input: { accountId: string; workspaceId: string; allowScreenControl: boolean },
284
+ ): Promise<MintEnrollTokenResponse | null> {
285
+ const { settings } = services;
286
+ const secret = resolveEnrollmentSigningSecret(settings);
287
+ if (!secret) {
288
+ return null;
289
+ }
290
+ const nowSeconds = Math.floor(Date.now() / 1000);
291
+ const exp = nowSeconds + ENROLL_TOKEN_TTL_SECONDS;
292
+ const token = await signEnrollToken(secret, {
293
+ typ: "enroll",
294
+ workspaceId: input.workspaceId,
295
+ accountId: input.accountId,
296
+ allowScreenControl: input.allowScreenControl,
297
+ iat: nowSeconds,
298
+ exp,
299
+ });
300
+ return {
301
+ token,
302
+ expiresAt: new Date(exp * 1000).toISOString(),
303
+ expiresInSeconds: ENROLL_TOKEN_TTL_SECONDS,
304
+ };
305
+ }
306
+
307
+ /** Distinguishes the two exchange failure modes for the route. */
308
+ export type ExchangeEnrollTokenResult =
309
+ | { ok: true; credentials: EnrollTokenExchangeResponse["credentials"] }
310
+ | { ok: false; reason: "disabled" }
311
+ | { ok: false; reason: "invalid" };
312
+
313
+ /** EXCHANGE a headless enroll token (design 11 §A2.3) — the UNAUTHENTICATED path
314
+ * where the token IS the auth. Verifies the `oget_` token, then performs the SAME
315
+ * finalize as approve (upsert enrollment + ensure selfhosted sandbox,
316
+ * consentedWholeMachine=true, consentedScreenControl=token.allowScreenControl) and
317
+ * builds the IDENTICAL EnrollmentCredentials the poll authorized branch returns.
318
+ * Returns reason "disabled" when no signing secret (mirror poll), "invalid" when
319
+ * the token fails verification (the route 401s). */
320
+ export async function exchangeEnrollToken(
321
+ services: EnrollmentServices,
322
+ input: {
323
+ token: string;
324
+ publicKey: string;
325
+ os: EnrollmentOs;
326
+ arch: string;
327
+ machineName?: string | null;
328
+ canOfferDisplay: boolean;
329
+ },
330
+ ): Promise<ExchangeEnrollTokenResult> {
331
+ const { db, settings } = services;
332
+ const secret = resolveEnrollmentSigningSecret(settings);
333
+ if (!secret) {
334
+ // The credential plane is off for this deployment — surface disabled, never a
335
+ // 500, never an unsigned credential (mirror poll's disabled handling).
336
+ return { ok: false, reason: "disabled" };
337
+ }
338
+ const claims = await verifyEnrollToken(secret, input.token);
339
+ if (!claims) {
340
+ // Bad prefix / signature / typ / expired — the token is not a valid grant.
341
+ return { ok: false, reason: "invalid" };
342
+ }
343
+
344
+ // The SAME finalize as approve, but driven by the token's claims (no pending row).
345
+ const sandboxName = (input.machineName?.trim() || `${input.os} machine`).slice(0, 256);
346
+ const { enrollment } = await finalizeEnrollmentByToken(db, {
347
+ accountId: claims.accountId,
348
+ workspaceId: claims.workspaceId,
349
+ pubkey: input.publicKey,
350
+ hasDisplay: input.canOfferDisplay,
351
+ // The token's allowScreenControl is the AUTHORITATIVE consent (NOT the agent's
352
+ // requestsScreenControl) — it was baked in at mint by the authorizing user.
353
+ allowScreenControl: claims.allowScreenControl,
354
+ os: input.os,
355
+ arch: input.arch,
356
+ sandboxName,
357
+ });
358
+
359
+ const credentials = await buildEnrollmentCredentials(services, {
360
+ secret,
361
+ workspaceId: claims.workspaceId,
362
+ agentId: enrollment.id,
363
+ consentedScreenControl: enrollment.allowScreenControl,
364
+ });
365
+ return { ok: true, credentials };
366
+ }
367
+
368
+ /**
369
+ * POLL a flow by device_code. Resolves the state machine:
370
+ * - unknown code → "expired" (do not leak existence; an unknown code
371
+ * behaves like an expired one to the agent).
372
+ * - pending + within TTL → "pending".
373
+ * - pending + past TTL → "expired".
374
+ * - denied → "denied".
375
+ * - approved | consumed → "authorized" + the EnrollmentCredentials (the
376
+ * approved row is flipped to consumed; a legitimate
377
+ * re-poll of a consumed row still returns the creds).
378
+ * When the credential plane is disabled (no resolvable signing secret), an
379
+ * otherwise-authorized poll returns "disabled" so the agent surfaces a clear reason
380
+ * rather than half-enrolling.
381
+ */
382
+ export async function pollDeviceEnrollment(
383
+ services: EnrollmentServices,
384
+ input: { deviceCode: string },
385
+ ): Promise<DeviceEnrollmentPollResponse> {
386
+ const { db, settings } = services;
387
+ const request = await getDeviceEnrollmentRequestByDeviceCode(db, input.deviceCode);
388
+ if (!request) {
389
+ return { state: "expired" };
390
+ }
391
+ if (request.status === "denied") {
392
+ return { state: "denied" };
393
+ }
394
+ if (request.status === "pending") {
395
+ if (new Date(request.expiresAt).getTime() <= Date.now()) {
396
+ return { state: "expired" };
397
+ }
398
+ return { state: "pending" };
399
+ }
400
+
401
+ // approved | consumed → AUTHORIZED. Build the credentials.
402
+ if (!request.enrollmentId) {
403
+ // Defensive: an approved row must carry the enrollment id.
404
+ return { state: "expired" };
405
+ }
406
+ const secret = resolveEnrollmentSigningSecret(settings);
407
+ if (!secret) {
408
+ // The credential plane is off for this deployment (no signing secret) — surface
409
+ // a clear disabled state, never a 500 and never an unsigned credential.
410
+ return { state: "disabled" };
411
+ }
412
+
413
+ const enrollment = await getEnrollment(db, request.workspaceId, request.enrollmentId);
414
+ if (!enrollment || enrollment.status !== "active") {
415
+ // The machine was revoked between approve and poll — treat as denied.
416
+ return { state: "denied" };
417
+ }
418
+
419
+ const credentials = await buildEnrollmentCredentials(services, {
420
+ secret,
421
+ workspaceId: request.workspaceId,
422
+ agentId: enrollment.id,
423
+ consentedScreenControl: enrollment.allowScreenControl,
424
+ });
425
+
426
+ // Single-use: flip approved → consumed (idempotent; a re-poll of an already-
427
+ // consumed row still returns the creds above — the agent may legitimately retry).
428
+ if (request.status === "approved") {
429
+ await consumeDeviceEnrollmentRequest(db, {
430
+ accountId: request.accountId,
431
+ workspaceId: request.workspaceId,
432
+ requestId: request.id,
433
+ });
434
+ }
435
+
436
+ return { state: DeviceEnrollmentState.enum.authorized, credentials };
437
+ }
438
+
439
+ /** Build the EnrollmentCredentials the poll returns: the signed `oge_` bearer +
440
+ * the Account-scoped subject prefix + the connect info. The bearer-as-NATS-token
441
+ * model (M-AUTH) closes the M5 placeholder: the agent presents the bearer as the
442
+ * connect AUTH TOKEN, nats-server's auth-callout responder validates it and mints a
443
+ * workspace-scoped user JWT, so there is NO per-machine NATS creds file to ship.
444
+ * `natsAccountCreds` is therefore vestigial — kept (proto-additive) and set to the
445
+ * bearer so an agent using it as the connect-token credential works uniformly. */
446
+ async function buildEnrollmentCredentials(
447
+ services: EnrollmentServices,
448
+ input: { secret: string; workspaceId: string; agentId: string; consentedScreenControl: boolean },
449
+ ): Promise<EnrollmentCredentialsResponse> {
450
+ const { settings } = services;
451
+ // The control-plane subject prefix the agent subscribes to: agent.<ws>.<id>.
452
+ const subjectPrefix = `agent.${input.workspaceId}.${input.agentId}`;
453
+ const nowSeconds = Math.floor(Date.now() / 1000);
454
+ const exp = nowSeconds + ENROLLMENT_BEARER_TTL_SECONDS;
455
+ const bearer = await signEnrollmentBearer(input.secret, {
456
+ workspaceId: input.workspaceId,
457
+ agentId: input.agentId,
458
+ enrollmentId: input.agentId,
459
+ subjectPrefix,
460
+ exp,
461
+ });
462
+ const natsUrls = settings.selfhostedNatsUrl ? [settings.selfhostedNatsUrl] : [];
463
+ // Mint the agent's relay PRODUCER token (M8b) when the relay-token plane is
464
+ // configured. The relay verifies it (the `ogr_` envelope) and pairs the producer
465
+ // with the viewer. Absent secret → empty token (graceful degrade; the stream plane
466
+ // is unavailable until the secret is provisioned via ops-repo IaC). The token binds
467
+ // (workspaceId, agentId) so the agent can only register ITS OWN channels.
468
+ const relayTokenSecret = resolveRelayTokenSecret(settings);
469
+ const relayToken = relayTokenSecret
470
+ ? await signRelayToken(relayTokenSecret, {
471
+ workspaceId: input.workspaceId,
472
+ agentId: input.agentId,
473
+ exp: nowSeconds + RELAY_TOKEN_TTL_SECONDS,
474
+ })
475
+ : "";
476
+ return {
477
+ agentId: input.agentId,
478
+ workspaceId: input.workspaceId,
479
+ bearer,
480
+ subjectPrefix,
481
+ natsUrls,
482
+ // Hand the agent the canonical `/stream` dial base, NOT the raw configured URL.
483
+ // The agent's relay producer appends only its routing query and assumes the base
484
+ // already carries the relay's `/stream` route; a path-less base 400s the dial and
485
+ // makes the terminal/desktop streams unreachable (dossier §V5/§V6).
486
+ relayUrl: relayDialBaseFromSettings(settings),
487
+ relayToken,
488
+ // M-AUTH closes the placeholder: there is NO per-machine NATS Account creds
489
+ // file. The agent presents the BEARER as the NATS connect auth-token; the
490
+ // server's auth-callout responder validates it and mints a workspace-scoped
491
+ // user JWT. We echo the bearer here so a consumer reading this (vestigial) field
492
+ // as the connect credential still works — the value IS the bearer.
493
+ natsAccountCreds: bearer,
494
+ updatePublicKey: settings.agentUpdatePublicKey ?? "",
495
+ consentedWholeMachine: true,
496
+ consentedScreenControl: input.consentedScreenControl,
497
+ };
498
+ }