@opengeni/api-router 0.11.1 → 0.11.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,32 +1,52 @@
1
- import { GitHubAppManifestCreate } from "@opengeni/contracts";
2
- import { deleteGitHubInstallationBinding } from "@opengeni/db";
3
1
  import {
2
+ GitHubAppManifestCreate,
3
+ type AccessGrant,
4
+ type GitHubInstallationBindingProof,
5
+ } from "@opengeni/contracts";
6
+ import {
7
+ bindAuthorizedGitHubInstallationRepositories,
8
+ deleteGitHubInstallationBinding,
9
+ GitHubInstallationAuthorityCommitError,
10
+ } from "@opengeni/db";
11
+ import {
12
+ authorizeGitHubInstallationBinding,
4
13
  buildGitHubAppManifest,
5
14
  convertGitHubAppManifest,
6
15
  createSignedState,
7
16
  envLinesFromGitHubManifestConversion,
8
17
  GitHubAppApiError,
9
18
  GitHubAppConfigurationError,
19
+ GitHubInstallationAuthorityError,
10
20
  githubAppMissingSettings,
21
+ githubOAuthAuthorizeUrl,
11
22
  organizationAppManifestUrl,
12
23
  personalAppManifestUrl,
13
24
  readSignedState,
14
25
  stateMaxAgeSeconds,
26
+ type GitHubSignedStatePayload,
15
27
  verifySignedState,
16
28
  } from "@opengeni/github";
17
29
  import type { Context, Hono } from "hono";
18
- import { setCookie } from "hono/cookie";
30
+ import { deleteCookie, setCookie } from "hono/cookie";
19
31
  import { HTTPException } from "hono/http-exception";
20
- import { requireAccessGrant } from "@opengeni/core";
32
+ import { hasPermission, requireAccessGrant } from "@opengeni/core";
21
33
  import type { ApiRouteDeps } from "@opengeni/core";
22
34
  import {
35
+ continuedGitHubBrowserGrantClaims,
36
+ githubBrowserBaseUrl,
37
+ githubBrowserGrantClaims,
38
+ githubBrowserGrantFromState,
39
+ } from "../github-browser-flow";
40
+ import {
41
+ githubBindingStatus,
23
42
  listWorkspaceGitHubInstallationBindings,
24
43
  listWorkspaceGitHubRepositories,
25
44
  } from "../github-access";
26
45
 
27
46
  const githubStateCookie = "opengeni_github_state";
28
- const installationBindingDisabledMessage =
29
- "Connecting a GitHub App installation is disabled until GitHub installation authority can be proven";
47
+ const githubBindingStateMaxAgeSeconds = 10 * 60;
48
+ const legacyInstallationChooserDisabledMessage =
49
+ "The legacy repository-admin GitHub installation chooser is disabled; use the GitHub owner-consent connect flow";
30
50
 
31
51
  export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
32
52
  const { db, settings, githubStateSecret } = deps;
@@ -36,23 +56,41 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
36
56
  const grant = await requireAccessGrant(c, deps, workspaceId, "github:use");
37
57
  const missing = githubAppMissingSettings(settings);
38
58
  const slug = settings.githubAppSlug?.trim() || null;
59
+ const installations =
60
+ missing.length === 0
61
+ ? await listWorkspaceGitHubInstallationBindings(deps, grant.workspaceId)
62
+ : [];
63
+ const status = githubBindingStatus(missing.length === 0, installations);
64
+ const canManage = hasPermission(grant.permissions, "github:manage");
65
+ const connectState =
66
+ missing.length === 0 && slug && canManage
67
+ ? createSignedState(githubStateSecret, {
68
+ accountId: grant.accountId,
69
+ workspaceId: grant.workspaceId,
70
+ intent: "installation_authority",
71
+ ...githubBrowserGrantClaims(settings, grant),
72
+ })
73
+ : null;
74
+ const connectUrl = connectState
75
+ ? `${openGeniBaseUrl(settings, c)}/v1/workspaces/${grant.workspaceId}/github/connect?state=${encodeURIComponent(connectState)}`
76
+ : null;
39
77
  return c.json({
40
78
  configured: missing.length === 0,
79
+ status,
41
80
  appId: settings.githubAppId ?? null,
42
81
  clientId: settings.githubClientId ?? null,
43
82
  appSlug: slug,
44
- // Kept nullable for SDK compatibility. GitHub's setup callback contains
45
- // a spoofable installation_id, while user-installation visibility and
46
- // repository admin permission do not prove that this human may bind it.
47
- installUrl: null,
48
- linkUrl: null,
49
- installations: await listWorkspaceGitHubInstallationBindings(deps, grant.workspaceId),
83
+ installUrl: connectUrl,
84
+ linkUrl: connectUrl,
85
+ installations,
50
86
  missing,
51
87
  });
52
88
  });
53
89
 
54
- // Retain the entry route so already-issued links fail closed with an
55
- // explicit terminal response instead of falling through to another intent.
90
+ // The signed state is a short browser handoff minted only for an OpenGeni
91
+ // github:manage grant. GitHub remains responsible for installation/config
92
+ // consent; the later OAuth callback independently proves current owner
93
+ // authority before any workspace binding write.
56
94
  app.get("/v1/workspaces/:workspaceId/github/connect", async (c) => {
57
95
  const workspaceId = c.req.param("workspaceId");
58
96
  const state = c.req.query("state");
@@ -60,10 +98,28 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
60
98
  throw new HTTPException(400, { message: "missing GitHub installation state" });
61
99
  }
62
100
  const statePayload = readSignedState(state, githubStateSecret);
63
- if (!statePayload || statePayload.workspaceId !== workspaceId) {
101
+ if (
102
+ !statePayload ||
103
+ statePayload.intent !== "installation_authority" ||
104
+ statePayload.workspaceId !== workspaceId ||
105
+ typeof statePayload.accountId !== "string" ||
106
+ !isFreshGitHubBindingState(statePayload)
107
+ ) {
64
108
  throw new HTTPException(400, { message: "invalid or expired GitHub installation state" });
65
109
  }
66
- throw installationBindingDisabled();
110
+ const slug = settings.githubAppSlug?.trim();
111
+ if (!slug || githubAppMissingSettings(settings).length > 0) {
112
+ throw new HTTPException(409, {
113
+ message: JSON.stringify({
114
+ message: "GitHub App is not configured",
115
+ missing: githubAppMissingSettings(settings),
116
+ }),
117
+ });
118
+ }
119
+ setGitHubStateCookie(c, deps, state);
120
+ return c.redirect(
121
+ `https://github.com/apps/${slug}/installations/new?state=${encodeURIComponent(state)}`,
122
+ );
67
123
  });
68
124
 
69
125
  app.get("/v1/workspaces/:workspaceId/github/repositories", async (c) => {
@@ -177,31 +233,153 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
177
233
  const statePayload = readSignedState(state, githubStateSecret);
178
234
  if (
179
235
  !statePayload ||
236
+ statePayload.intent !== "installation_authority" ||
180
237
  typeof statePayload.accountId !== "string" ||
181
- typeof statePayload.workspaceId !== "string"
238
+ typeof statePayload.workspaceId !== "string" ||
239
+ !isFreshGitHubBindingState(statePayload)
182
240
  ) {
183
241
  throw new HTTPException(400, { message: "invalid or expired GitHub installation state" });
184
242
  }
185
- throw installationBindingDisabled();
243
+ requireGitHubStateCookie(c, state);
244
+ const grant = await requireGitHubManageGrant(c, deps, statePayload.workspaceId, statePayload);
245
+ if (grant.accountId !== statePayload.accountId) {
246
+ throw new HTTPException(403, {
247
+ message: "GitHub installation state does not match this workspace",
248
+ });
249
+ }
250
+ const setupAction = c.req.query("setup_action");
251
+ if (setupAction === "request") {
252
+ return c.html(githubSetupPendingHtml());
253
+ }
254
+ if (setupAction !== "install" && setupAction !== "update") {
255
+ throw new HTTPException(400, { message: "unsupported GitHub setup action" });
256
+ }
257
+ const installationId = parsePositiveInteger(c.req.query("installation_id"));
258
+ if (installationId === null) {
259
+ throw new HTTPException(400, { message: "missing or invalid GitHub installation_id" });
260
+ }
261
+ const clientId = settings.githubClientId?.trim();
262
+ if (!clientId) {
263
+ throw new HTTPException(409, {
264
+ message: JSON.stringify({
265
+ message: "GitHub App is not configured",
266
+ missing: ["OPENGENI_GITHUB_CLIENT_ID"],
267
+ }),
268
+ });
269
+ }
270
+ const oauthState = createSignedState(githubStateSecret, {
271
+ accountId: grant.accountId,
272
+ workspaceId: grant.workspaceId,
273
+ installationId,
274
+ intent: "installation_authority_oauth",
275
+ ...continuedGitHubBrowserGrantClaims(statePayload),
276
+ });
277
+ setGitHubStateCookie(c, deps, oauthState);
278
+ return c.redirect(
279
+ githubOAuthAuthorizeUrl({
280
+ clientId,
281
+ state: oauthState,
282
+ redirectUri: `${openGeniBaseUrl(settings, c)}/v1/github/oauth/callback`,
283
+ }),
284
+ );
186
285
  };
187
286
 
188
287
  app.get("/v1/github/setup", handleGitHubInstallCallback);
189
288
  app.get("/v1/github/install/callback", handleGitHubInstallCallback);
190
289
 
191
290
  app.get("/v1/github/oauth/callback", async (c) => {
291
+ const code = c.req.query("code");
192
292
  const state = c.req.query("state");
293
+ if (!code) {
294
+ throw new HTTPException(400, { message: "missing GitHub OAuth code" });
295
+ }
193
296
  if (!state) {
194
297
  throw new HTTPException(400, { message: "missing GitHub OAuth state" });
195
298
  }
196
299
  const statePayload = readSignedState(state, githubStateSecret);
197
300
  if (
198
301
  !statePayload ||
302
+ statePayload.intent !== "installation_authority_oauth" ||
199
303
  typeof statePayload.accountId !== "string" ||
200
- typeof statePayload.workspaceId !== "string"
304
+ typeof statePayload.workspaceId !== "string" ||
305
+ !isFreshGitHubBindingState(statePayload)
201
306
  ) {
202
307
  throw new HTTPException(400, { message: "invalid or expired GitHub OAuth state" });
203
308
  }
204
- throw installationBindingDisabled();
309
+ const installationId = parsePositiveInteger(String(statePayload.installationId ?? ""));
310
+ if (installationId === null) {
311
+ throw new HTTPException(400, { message: "invalid GitHub installation id" });
312
+ }
313
+ requireGitHubStateCookie(c, state);
314
+ const grant = await requireGitHubManageGrant(c, deps, statePayload.workspaceId, statePayload);
315
+ if (grant.accountId !== statePayload.accountId) {
316
+ throw new HTTPException(403, {
317
+ message: "GitHub OAuth state does not match this workspace",
318
+ });
319
+ }
320
+ let proof;
321
+ try {
322
+ proof = deps.githubAppApi?.authorizeInstallationBinding
323
+ ? await deps.githubAppApi.authorizeInstallationBinding({ code, installationId })
324
+ : deps.githubAppApi
325
+ ? null
326
+ : await authorizeGitHubInstallationBinding(settings, { code, installationId });
327
+ } catch (error) {
328
+ throw githubAuthorityHttpError(error);
329
+ }
330
+ if (!proof) {
331
+ throw new HTTPException(409, {
332
+ message:
333
+ "The configured GitHub provider cannot prove personal-owner or organization-owner authority",
334
+ });
335
+ }
336
+ if (!isConsistentGitHubBindingProof(proof, installationId)) {
337
+ throw new HTTPException(409, { message: "GitHub installation proof is stale or invalid" });
338
+ }
339
+ const repositoryIds = [...new Set(proof.repositories.map((repository) => repository.id))];
340
+ if (repositoryIds.length !== proof.repositories.length) {
341
+ throw new HTTPException(409, { message: "GitHub returned duplicate repository identities" });
342
+ }
343
+ // The provider contract revalidates organization ownership after its final
344
+ // repository read, so this commit-near timestamp records that live check.
345
+ const authorityCheckedAt = new Date();
346
+ const expiresAt = new Date((statePayload.iat + githubBindingStateMaxAgeSeconds) * 1_000);
347
+ let bound;
348
+ try {
349
+ bound = await bindAuthorizedGitHubInstallationRepositories(db, {
350
+ accountId: grant.accountId,
351
+ workspaceId: grant.workspaceId,
352
+ installationId,
353
+ githubAccountId: proof.installation.accountId,
354
+ accountLogin: proof.installation.accountLogin,
355
+ accountType: proof.installation.accountType,
356
+ linkedBySubjectId: grant.subjectId,
357
+ githubActorId: proof.actorId,
358
+ githubActorLogin: proof.actorLogin,
359
+ authorityKind: proof.authorityKind,
360
+ authorityCheckedAt,
361
+ authorityExpiresAt: expiresAt,
362
+ authorityNonce: statePayload.nonce,
363
+ repositoryIds,
364
+ });
365
+ } catch (error) {
366
+ if (error instanceof GitHubInstallationAuthorityCommitError) {
367
+ throw new HTTPException(409, { message: error.message });
368
+ }
369
+ throw error;
370
+ }
371
+ if (!bound) {
372
+ throw new HTTPException(409, {
373
+ message: "GitHub installation authorization was already used",
374
+ });
375
+ }
376
+ deleteCookie(c, githubStateCookie, { path: "/v1" });
377
+ return c.html(
378
+ githubSetupSuccessHtml(
379
+ proof.installation.accountLogin ?? `installation ${installationId}`,
380
+ openGeniReturnUrl(settings, c, grant.workspaceId),
381
+ ),
382
+ );
205
383
  });
206
384
 
207
385
  app.post("/v1/workspaces/:workspaceId/github/installations", async (c) => {
@@ -220,12 +398,12 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
220
398
  ) {
221
399
  throw new HTTPException(400, { message: "invalid or expired GitHub OAuth state" });
222
400
  }
223
- throw installationBindingDisabled();
401
+ throw legacyInstallationChooserDisabled();
224
402
  });
225
403
  }
226
404
 
227
- function installationBindingDisabled(): HTTPException {
228
- return new HTTPException(410, { message: installationBindingDisabledMessage });
405
+ function legacyInstallationChooserDisabled(): HTTPException {
406
+ return new HTTPException(410, { message: legacyInstallationChooserDisabledMessage });
229
407
  }
230
408
 
231
409
  function setGitHubStateCookie(c: Context, deps: ApiRouteDeps, state: string): void {
@@ -238,6 +416,74 @@ function setGitHubStateCookie(c: Context, deps: ApiRouteDeps, state: string): vo
238
416
  });
239
417
  }
240
418
 
419
+ function requireGitHubStateCookie(c: Context, state: string): void {
420
+ if (!allCookieValues(c, githubStateCookie).includes(state)) {
421
+ throw new HTTPException(400, {
422
+ message: "invalid or expired GitHub installation browser state",
423
+ });
424
+ }
425
+ }
426
+
427
+ async function requireGitHubManageGrant(
428
+ c: Context,
429
+ deps: ApiRouteDeps,
430
+ workspaceId: string,
431
+ expectedState: GitHubSignedStatePayload,
432
+ ): Promise<AccessGrant> {
433
+ try {
434
+ return await requireAccessGrant(c, deps, workspaceId, "github:manage");
435
+ } catch (error) {
436
+ if (!(error instanceof HTTPException) || error.status !== 401) {
437
+ throw error;
438
+ }
439
+ const grant = githubBrowserGrantFromState(deps.settings, expectedState, workspaceId);
440
+ if (grant) {
441
+ return grant;
442
+ }
443
+ throw error;
444
+ }
445
+ }
446
+
447
+ function allCookieValues(c: Context, name: string): string[] {
448
+ const prefix = `${name}=`;
449
+ return (c.req.header("cookie") ?? "")
450
+ .split(";")
451
+ .map((part) => part.trim())
452
+ .filter((part) => part.startsWith(prefix))
453
+ .map((part) => {
454
+ const raw = part.slice(prefix.length);
455
+ try {
456
+ return decodeURIComponent(raw);
457
+ } catch {
458
+ return raw;
459
+ }
460
+ });
461
+ }
462
+
463
+ function githubAuthorityHttpError(error: unknown): HTTPException {
464
+ if (error instanceof HTTPException) {
465
+ return error;
466
+ }
467
+ if (error instanceof GitHubInstallationAuthorityError) {
468
+ if (error.reason === "authority_denied") {
469
+ return new HTTPException(403, { message: error.message });
470
+ }
471
+ if (error.reason === "installation_missing") {
472
+ return new HTTPException(404, { message: error.message });
473
+ }
474
+ return new HTTPException(409, { message: error.message });
475
+ }
476
+ if (error instanceof GitHubAppConfigurationError) {
477
+ return new HTTPException(409, {
478
+ message: JSON.stringify({ message: error.message, missing: error.missing }),
479
+ });
480
+ }
481
+ if (error instanceof GitHubAppApiError) {
482
+ return new HTTPException(502, { message: error.message });
483
+ }
484
+ return new HTTPException(502, { message: "GitHub authority verification failed" });
485
+ }
486
+
241
487
  function isSecureRequest(c: Context, deps: ApiRouteDeps): boolean {
242
488
  return (
243
489
  deps.settings.publicBaseUrl?.startsWith("https://") ||
@@ -252,6 +498,14 @@ function githubSuccessHtml(envLines: string[]): string {
252
498
  return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GitHub App Created</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(760px,calc(100vw - 32px));border:1px solid #27272a;border-radius:8px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px;line-height:1.2}p{margin:0 0 18px;color:#d4d4d8}.env-header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:22px 0 8px}.env-header h2{margin:0;font-size:13px;line-height:1.2;text-transform:uppercase;letter-spacing:.08em;color:#a1a1aa}pre{white-space:pre-wrap;word-break:break-word;max-height:380px;overflow:auto;background:#09090b;border:1px solid #27272a;border-radius:8px;padding:16px;font-size:13px;line-height:1.5}button{display:inline-flex;align-items:center;justify-content:center;min-height:36px;border-radius:6px;border:1px solid #3f3f46;padding:0 12px;background:#f4f4f5;color:#09090b;font:600 14px system-ui,sans-serif;cursor:pointer}button:disabled{cursor:not-allowed;opacity:.7}</style></head><body><main><h1>GitHub App created</h1><p>Add these values to .env, then restart API and worker.</p><div class="env-header"><h2>Environment variables</h2><button id="copy-env" type="button">Copy env</button></div><pre id="env-lines">${escaped}</pre><script>(()=>{const button=document.getElementById("copy-env");const env=document.getElementById("env-lines");async function copyText(text){if(navigator.clipboard&&window.isSecureContext){await navigator.clipboard.writeText(text);return;}const area=document.createElement("textarea");area.value=text;area.setAttribute("readonly","");area.style.position="fixed";area.style.inset="-9999px";document.body.append(area);area.select();document.execCommand("copy");area.remove();}button?.addEventListener("click",async()=>{try{await copyText(env?.textContent||"");button.textContent="Copied";setTimeout(()=>button.textContent="Copy env",1600);}catch{button.textContent="Copy failed";setTimeout(()=>button.textContent="Copy env",2200);}});})();</script></main></body></html>`;
253
499
  }
254
500
 
501
+ function githubSetupSuccessHtml(account: string, returnUrl: string): string {
502
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GitHub App Connected</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(640px,calc(100vw - 32px));border:1px solid #27272a;border-radius:8px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px;line-height:1.2}p{margin:0 0 18px;color:#d4d4d8}.button{display:inline-flex;align-items:center;justify-content:center;min-height:36px;border-radius:6px;border:1px solid #3f3f46;padding:0 12px;background:#f4f4f5;color:#09090b;font:600 14px system-ui,sans-serif;text-decoration:none}</style></head><body><main><h1>GitHub App connected</h1><p>${escapeHtml(account)} is now available to this OpenGeni workspace through an explicit repository allowlist.</p><a class="button" href="${escapeHtml(returnUrl)}">Back to OpenGeni</a></main></body></html>`;
503
+ }
504
+
505
+ function githubSetupPendingHtml(): string {
506
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GitHub App Requested</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(640px,calc(100vw - 32px));border:1px solid #27272a;border-radius:8px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px;line-height:1.2}p{margin:0;color:#d4d4d8}</style></head><body><main><h1>GitHub App request sent</h1><p>A GitHub organization owner must approve the installation. OpenGeni has not created a workspace binding.</p></main></body></html>`;
507
+ }
508
+
255
509
  function parsePositiveInteger(value: string | undefined | null): number | null {
256
510
  if (!value || !/^\d+$/.test(value)) {
257
511
  return null;
@@ -260,6 +514,46 @@ function parsePositiveInteger(value: string | undefined | null): number | null {
260
514
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
261
515
  }
262
516
 
517
+ function isFreshGitHubBindingState(payload: GitHubSignedStatePayload): boolean {
518
+ const age = Math.floor(Date.now() / 1_000) - payload.iat;
519
+ return age >= 0 && age < githubBindingStateMaxAgeSeconds;
520
+ }
521
+
522
+ function isConsistentGitHubBindingProof(
523
+ proof: GitHubInstallationBindingProof,
524
+ installationId: number,
525
+ ): boolean {
526
+ const installation = proof.installation;
527
+ if (
528
+ installation.installationId !== installationId ||
529
+ !Number.isSafeInteger(installation.accountId) ||
530
+ installation.accountId <= 0 ||
531
+ !installation.accountLogin?.trim() ||
532
+ installation.suspended ||
533
+ !Number.isSafeInteger(proof.actorId) ||
534
+ proof.actorId <= 0 ||
535
+ !proof.actorLogin.trim() ||
536
+ proof.repositories.length === 0
537
+ ) {
538
+ return false;
539
+ }
540
+ if (
541
+ proof.authorityKind === "personal_owner"
542
+ ? installation.accountType !== "User" || proof.actorId !== installation.accountId
543
+ : installation.accountType !== "Organization"
544
+ ) {
545
+ return false;
546
+ }
547
+ return proof.repositories.every(
548
+ (repository) =>
549
+ Number.isSafeInteger(repository.id) &&
550
+ repository.id > 0 &&
551
+ repository.installationId === installationId &&
552
+ repository.accountLogin === installation.accountLogin &&
553
+ repository.accountType === installation.accountType,
554
+ );
555
+ }
556
+
263
557
  function escapeHtml(value: string): string {
264
558
  return value.replace(
265
559
  /[&<>"']/g,
@@ -273,3 +567,17 @@ function escapeHtml(value: string): string {
273
567
  })[char] ?? char,
274
568
  );
275
569
  }
570
+
571
+ function openGeniReturnUrl(
572
+ settings: ApiRouteDeps["settings"],
573
+ c: Context,
574
+ workspaceId: string,
575
+ ): string {
576
+ const url = new URL(openGeniBaseUrl(settings, c) || new URL(c.req.url).origin);
577
+ url.searchParams.set("workspaceId", workspaceId);
578
+ return url.toString();
579
+ }
580
+
581
+ function openGeniBaseUrl(settings: ApiRouteDeps["settings"], c: Context): string {
582
+ return githubBrowserBaseUrl(settings, new URL(c.req.url).origin);
583
+ }
@@ -144,6 +144,7 @@ const VERSION_SEG = /^v[A-Za-z0-9][A-Za-z0-9._-]*$/;
144
144
 
145
145
  export function registerInstallRoutes(app: Hono, deps: ApiRouteDeps): void {
146
146
  const releasesBase = deps.settings.agentReleasesBaseUrl.replace(/\/+$/, "");
147
+ const stableAgentTag = `agent-v${deps.settings.agentStableVersion}`;
147
148
 
148
149
  for (const [path, { file, contentType }] of Object.entries(TEXT_ASSETS)) {
149
150
  app.get(path, async (c) => {
@@ -185,20 +186,18 @@ export function registerInstallRoutes(app: Hono, deps: ApiRouteDeps): void {
185
186
  return new Response(null, { status: 302, headers: { location: redirectUrl } });
186
187
  }
187
188
 
188
- // `latest` → the BAKED binary if present, else the dedicated moving
189
- // `agent-latest` GitHub Release. We deliberately do NOT use GitHub's
190
- // repo-global `releases/latest` alias here: in this monorepo that alias is
191
- // perpetually shadowed by the frequent changesets package releases (e.g.
192
- // `@opengeni/contracts@x.y.z`), which carry no agent binaries 404. The
193
- // `agent-latest` release is maintained by .github/workflows/agent-release.yml
194
- // as a moving tag that always points at the newest signed mac/windows
195
- // binaries (+ checksums, install scripts, minisign pubkey).
189
+ // `latest` → the BAKED binary if present, else the operator-selected immutable
190
+ // `agent-v<version>` release. We deliberately do NOT use GitHub's repo-global
191
+ // `releases/latest` alias here: in this monorepo that alias is shadowed by
192
+ // frequent changesets package releases, which carry no agent binaries. The
193
+ // explicit stable-version setting also makes promotion and rollback auditable
194
+ // without moving or deleting a provider tag.
196
195
  app.get("/agent/latest/:asset", async (c) => {
197
196
  const asset = c.req.param("asset");
198
197
  if (!ASSET_NAME.test(asset)) {
199
198
  throw new HTTPException(400, { message: "invalid asset name" });
200
199
  }
201
- return serveAsset(asset, `${releasesBase}/download/agent-latest/${asset}`);
200
+ return serveAsset(asset, `${releasesBase}/download/${stableAgentTag}/${asset}`);
202
201
  });
203
202
 
204
203
  // The version segment is the literal `v<ver>` (e.g. `v1.2.3`) — Hono cannot bind
@@ -42,6 +42,7 @@ import {
42
42
  UpdateSessionGoalRequest,
43
43
  UpdateSessionMcpApprovalPolicyRequest,
44
44
  UpdateSessionRequest,
45
+ UpdateSessionToolPolicyRequest,
45
46
  ViewerHeartbeatRequest,
46
47
  WORKSPACE_CONTROL_ACTOR_MAX_BYTES,
47
48
  workspaceControlUtf8Bytes,
@@ -97,6 +98,7 @@ import {
97
98
  NewSessionDraftConflictError,
98
99
  SessionCommandIdempotencyError,
99
100
  SessionControlConflictError,
101
+ SessionToolPolicyVersionConflictError,
100
102
  SessionContextBusyError,
101
103
  HumanInputResponseValidationError,
102
104
  latestWorkspaceCapture,
@@ -156,6 +158,7 @@ import {
156
158
  sessionSpawnDenialEnvelope,
157
159
  steerHumanQueuePrompt,
158
160
  updateSessionMcpApprovalPolicy,
161
+ updateSessionToolPolicy,
159
162
  updateSessionTitle,
160
163
  workflowIdForSession,
161
164
  sessionWithEffectiveToolPolicy,
@@ -671,6 +674,29 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
671
674
  },
672
675
  );
673
676
 
677
+ app.put("/v1/workspaces/:workspaceId/sessions/:sessionId/tool-policy", async (c) => {
678
+ const workspaceId = c.req.param("workspaceId");
679
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
680
+ const sessionId = c.req.param("sessionId");
681
+ const payload = UpdateSessionToolPolicyRequest.parse(await c.req.json().catch(() => null));
682
+ try {
683
+ const session = await updateSessionToolPolicy(deps, grant, sessionId, payload);
684
+ return c.json(await withEffectivePolicy(deps, workspaceId, session));
685
+ } catch (error) {
686
+ if (error instanceof SessionToolPolicyVersionConflictError) {
687
+ return c.json(
688
+ {
689
+ code: error.code,
690
+ message: error.message,
691
+ currentVersion: error.currentVersion,
692
+ },
693
+ 409,
694
+ );
695
+ }
696
+ throw error;
697
+ }
698
+ });
699
+
674
700
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
675
701
  const workspaceId = c.req.param("workspaceId");
676
702
  await requireAccessGrant(c, deps, workspaceId, "sessions:read");
@@ -2406,6 +2432,7 @@ export function sessionAuthorizationOperationForHttp(
2406
2432
  return null;
2407
2433
  }
2408
2434
  if (suffix === "/pin" && verb === "PUT") return "session.pin.write";
2435
+ if (suffix === "/tool-policy" && verb === "PUT") return "session.tool_policy.write";
2409
2436
  if (/^\/mcp-servers\/[^/]+\/approval-policy$/.test(suffix) && verb === "PATCH") {
2410
2437
  return "session.mcp.approval_policy.write";
2411
2438
  }