@opengeni/api-router 0.5.7 → 0.7.3

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.
@@ -12,6 +12,7 @@ import {
12
12
  FsMoveRequest,
13
13
  FsReadRequest,
14
14
  FsWriteRequest,
15
+ HumanInputRequestStatus,
15
16
  GitDiffRequest,
16
17
  GitLogRequest,
17
18
  GitShowRequest,
@@ -22,6 +23,12 @@ import {
22
23
  PtyResizeRequest,
23
24
  PtyWriteRequest,
24
25
  SessionControlRequest,
26
+ SESSION_EVENT_RAW_DELTA_TYPES,
27
+ SessionEventPayloadMode,
28
+ SessionEventReadDirection,
29
+ SessionEventReadMode,
30
+ SessionEventSemanticClass,
31
+ SessionEventType,
25
32
  SaveComposerDraftRequest,
26
33
  SteerSessionQueueItemRequest,
27
34
  SteerSessionMessageRequest,
@@ -30,8 +37,12 @@ import {
30
37
  UpdateSessionGoalRequest,
31
38
  UpdateSessionRequest,
32
39
  ViewerHeartbeatRequest,
40
+ WORKSPACE_CONTROL_ACTOR_MAX_BYTES,
41
+ workspaceControlUtf8Bytes,
33
42
  type SandboxBackend,
34
43
  type Session,
44
+ type SessionAuthorizationOperation,
45
+ type SessionQueueSnapshot,
35
46
  type TerminalPtyExitedPayload,
36
47
  type TerminalPtyOutputDeltaPayload,
37
48
  type TerminalPtyStartedPayload,
@@ -39,6 +50,7 @@ import {
39
50
  import { streamTokenDegraded } from "@opengeni/config";
40
51
  import {
41
52
  acceptSessionApprovalDecision,
53
+ acceptSessionHumanInputResponse,
42
54
  clearSessionGoal,
43
55
  clearSessionContext,
44
56
  closePtySession,
@@ -47,13 +59,17 @@ import {
47
59
  getSession,
48
60
  getSessionForSubject,
49
61
  getSessionGoal,
62
+ getSessionHumanInputRequest,
50
63
  getSessionQueueSnapshot,
51
64
  getStreamAcknowledgment,
52
65
  insertPtySession,
53
- listSessionEvents,
66
+ listSessionEventPage,
67
+ listSessionHumanInputRequests,
54
68
  listSessionIdsInGroup,
55
69
  listSessionsForSubject,
56
70
  listSessionTurns,
71
+ projectEffectiveControlForRelatedAccess,
72
+ projectSessionForRelatedAccess,
57
73
  recordStreamAcknowledgment,
58
74
  requestSessionCompaction,
59
75
  setSessionCodexPin,
@@ -71,21 +87,31 @@ import {
71
87
  SessionCommandIdempotencyError,
72
88
  SessionControlConflictError,
73
89
  SessionContextBusyError,
90
+ HumanInputResponseValidationError,
74
91
  latestWorkspaceCapture,
75
92
  workspaceCaptureAtRevision,
76
93
  type AppendEventInput,
77
94
  } from "@opengeni/db";
78
95
  import {
79
96
  appendAndPublishEvents,
97
+ boundSessionEventHttpPage,
80
98
  coalesceSessionEventDeltas,
81
99
  publishDurableSessionEvents,
82
100
  } from "@opengeni/events";
83
101
  import { z } from "zod";
84
102
  import { withChannelA } from "../sandbox/channel-a";
85
103
  import { negotiateCapabilities } from "@opengeni/runtime/sandbox";
86
- import type { Context, Hono } from "hono";
104
+ import type { Context, Hono, MiddlewareHandler } from "hono";
87
105
  import { HTTPException } from "hono/http-exception";
88
- import { requireAccessGrant } from "@opengeni/core";
106
+ import {
107
+ requireAccessGrant,
108
+ requireSessionAuthorization,
109
+ requireSessionAuthorizationListScope,
110
+ SESSION_AUTHORIZATION_DEFAULT_REAUTHORIZE_MS,
111
+ SessionAuthorizationDeniedError,
112
+ SessionAuthorizationUnavailableError,
113
+ type ResolvedSessionAuthorization,
114
+ } from "@opengeni/core";
89
115
  import type { ApiRouteDeps } from "@opengeni/core";
90
116
  import {
91
117
  attachViewer,
@@ -119,6 +145,60 @@ import { serveWorkspaceCapture, serveWorkspaceCaptureFile } from "./workspace-ca
119
145
 
120
146
  export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
121
147
  const { settings, db, bus, workflowClient, objectStorage } = deps;
148
+ const requestSessionAuthorization = new WeakMap<Request, ResolvedSessionAuthorization>();
149
+ const relatedSessionAccessFor = (c: Context): "target" | "root" =>
150
+ requestSessionAuthorization.get(c.req.raw)?.relatedSessionAccess ?? "root";
151
+ const projectQueueSnapshot = (
152
+ snapshot: SessionQueueSnapshot,
153
+ sessionId: string,
154
+ access: "target" | "root",
155
+ ): SessionQueueSnapshot => ({
156
+ ...snapshot,
157
+ effectiveControl: projectEffectiveControlForRelatedAccess(
158
+ snapshot.effectiveControl,
159
+ sessionId,
160
+ access,
161
+ ),
162
+ });
163
+
164
+ // A host-bound deployment has one fail-closed authorization seam for every
165
+ // HTTP session surface. Register it before the routes so a newly added path
166
+ // cannot accidentally inherit workspace access without an explicit operation
167
+ // classification. The long-lived event stream performs its own initial check
168
+ // and bounded reauthorization below.
169
+ const authorizeSessionHttp: MiddlewareHandler = async (c, next) => {
170
+ if (!deps.sessionAuthorization) {
171
+ await next();
172
+ return;
173
+ }
174
+ const workspaceId = c.req.param("workspaceId") ?? "";
175
+ const sessionId = c.req.param("sessionId") ?? "";
176
+ const operation = sessionAuthorizationOperationForHttp(
177
+ c.req.method,
178
+ new URL(c.req.url).pathname,
179
+ sessionId,
180
+ );
181
+ if (operation === "session.stream.read") {
182
+ await next();
183
+ return;
184
+ }
185
+ if (!operation) {
186
+ throw sessionAuthorizationHttpError(new SessionAuthorizationUnavailableError());
187
+ }
188
+ const grant = await requireAccessGrant(c, deps, workspaceId);
189
+ try {
190
+ const authorization = await requireSessionAuthorization(deps, grant, {
191
+ sessionId,
192
+ operation,
193
+ surface: "http",
194
+ });
195
+ if (authorization) requestSessionAuthorization.set(c.req.raw, authorization);
196
+ } catch (error) {
197
+ throw sessionAuthorizationHttpError(error);
198
+ }
199
+ await next();
200
+ };
201
+ app.use("/v1/workspaces/:workspaceId/sessions/:sessionId/*", authorizeSessionHttp);
122
202
 
123
203
  app.post("/v1/workspaces/:workspaceId/sessions", async (c) => {
124
204
  const workspaceId = c.req.param("workspaceId");
@@ -130,6 +210,12 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
130
210
  app.get("/v1/workspaces/:workspaceId/sessions", async (c) => {
131
211
  const workspaceId = c.req.param("workspaceId");
132
212
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
213
+ let authorizationScope;
214
+ try {
215
+ authorizationScope = await requireSessionAuthorizationListScope(deps, grant, "http");
216
+ } catch (error) {
217
+ throw sessionAuthorizationHttpError(error);
218
+ }
133
219
  const pageView = c.req.query("view") === "page";
134
220
  const query = sessionListQuery(c.req.query(), pageView);
135
221
  let page: Awaited<ReturnType<typeof listSessionsForSubject>>;
@@ -140,6 +226,7 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
140
226
  ...(query.cursor ? { cursor: query.cursor } : {}),
141
227
  ...(query.search ? { search: query.search } : {}),
142
228
  ...(query.parentSessionId !== undefined ? { parentSessionId: query.parentSessionId } : {}),
229
+ ...(authorizationScope ? { authorizationScope } : {}),
143
230
  });
144
231
  } catch (error) {
145
232
  if (error instanceof SessionListAccessError) {
@@ -150,6 +237,10 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
150
237
  }
151
238
  throw error;
152
239
  }
240
+ // The page body carries this fact directly. Preserve the historical array
241
+ // body for older clients while still making its older-pin omission visible
242
+ // to raw HTTP consumers without changing that response shape.
243
+ c.header("x-opengeni-pinned-truncated", page.pinnedTruncated === true ? "true" : "false");
153
244
  if (pageView) {
154
245
  return c.json(page);
155
246
  }
@@ -168,7 +259,13 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
168
259
  if (!z.string().uuid().safeParse(sessionId).success) {
169
260
  throw new HTTPException(404, { message: "session not found" });
170
261
  }
171
- const session = await getSessionForSubject(db, workspaceId, sessionId, grant.subjectId);
262
+ const session = await getSessionForSubject(
263
+ db,
264
+ workspaceId,
265
+ sessionId,
266
+ grant.subjectId,
267
+ relatedSessionAccessFor(c),
268
+ );
172
269
  if (!session) {
173
270
  throw new HTTPException(404, { message: "session not found" });
174
271
  }
@@ -199,14 +296,17 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
199
296
  if (!session) {
200
297
  throw new HTTPException(404, { message: "session not found" });
201
298
  }
202
- return c.json(session);
299
+ return c.json(projectSessionForRelatedAccess(session, relatedSessionAccessFor(c)));
203
300
  } catch (error) {
204
301
  if (error instanceof SessionPinAccessError) {
205
302
  throw new HTTPException(403, { message: error.message });
206
303
  }
207
304
  if (error instanceof SessionPinVersionConflictError) {
208
305
  return c.json(
209
- { message: "session pin changed in another client", current: error.current },
306
+ {
307
+ message: "session pin changed in another client",
308
+ current: error.current,
309
+ },
210
310
  409,
211
311
  );
212
312
  }
@@ -216,8 +316,8 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
216
316
 
217
317
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/lineage", async (c) => {
218
318
  const workspaceId = c.req.param("workspaceId");
219
- await requireAccessGrant(c, deps, workspaceId, "sessions:read");
220
- return c.json(await readSessionLineage(db, workspaceId, c.req.param("sessionId")));
319
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
320
+ return c.json(await readSessionLineage(deps, grant, c.req.param("sessionId")));
221
321
  });
222
322
 
223
323
  // Pin (or unpin) the session's Codex account. body { target: "auto" | "<id>" }:
@@ -232,7 +332,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
232
332
  const body = (await c.req.json()) as { target?: string };
233
333
  const target = typeof body.target === "string" ? body.target : "";
234
334
  if (!target) {
235
- throw new HTTPException(400, { message: 'target is required ("auto" or an account id)' });
335
+ throw new HTTPException(400, {
336
+ message: 'target is required ("auto" or an account id)',
337
+ });
236
338
  }
237
339
  const pinned = target === "auto" ? null : target;
238
340
  const mutation = await withCodexCapacityMutation(
@@ -245,7 +347,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
245
347
  );
246
348
  const ok = mutation.result;
247
349
  if (!ok) {
248
- throw new HTTPException(404, { message: "session or codex account not found" });
350
+ throw new HTTPException(404, {
351
+ message: "session or codex account not found",
352
+ });
249
353
  }
250
354
  await Promise.allSettled(
251
355
  mutation.wakeTargets.map((wake) =>
@@ -279,11 +383,17 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
279
383
  const sessionId = c.req.param("sessionId");
280
384
  await assertSessionExists(db, workspaceId, sessionId);
281
385
  const payload = UpdateSessionRequest.parse(await c.req.json());
282
- await updateSessionTitle({ db, bus }, workspaceId, sessionId, payload.title, "user");
386
+ const titleUpdate = await updateSessionTitle(deps, grant, sessionId, payload.title, "user");
283
387
  // A session-returning member route must preserve the caller's private pin
284
388
  // projection. Returning the generic mapSession() default here would reset a
285
389
  // pinned React consumer to false/version 0 after a harmless rename.
286
- const session = await getSessionForSubject(db, workspaceId, sessionId, grant.subjectId);
390
+ const session = await getSessionForSubject(
391
+ db,
392
+ workspaceId,
393
+ sessionId,
394
+ grant.subjectId,
395
+ titleUpdate.relatedSessionAccess,
396
+ );
287
397
  if (!session) {
288
398
  throw new HTTPException(404, { message: "session not found" });
289
399
  }
@@ -464,7 +574,10 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
464
574
  workflowId: requested.temporalWorkflowId,
465
575
  wakeRevision: requested.wakeRevision,
466
576
  });
467
- return c.json({ status: "pending", message: "Compaction will run at the next safe boundary." });
577
+ return c.json({
578
+ status: "pending",
579
+ message: "Compaction will run at the next safe boundary.",
580
+ });
468
581
  });
469
582
 
470
583
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/events", async (c) => {
@@ -472,22 +585,136 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
472
585
  await requireAccessGrant(c, deps, workspaceId, "sessions:read");
473
586
  const sessionId = c.req.param("sessionId");
474
587
  await assertSessionExists(db, workspaceId, sessionId);
475
- const after = eventSequence(c.req.query("after"), 0);
476
- const before = optionalEventSequence(c.req.query("before"));
588
+ const rawAfter = c.req.query("after");
589
+ const rawBefore = c.req.query("before");
590
+ const after = eventSequence(rawAfter, 0);
591
+ const before = optionalEventSequence(rawBefore);
477
592
  const compact = compactEvents(c.req.query("compact"));
478
- const limit = eventListLimit(c.req.query("limit"), compact ? 5000 : 2000);
479
- const events = await listSessionEvents(db, workspaceId, sessionId, {
593
+ const explicitReplay = rawAfter !== undefined || rawBefore !== undefined || compact;
594
+ const mode = eventEnumValue(
595
+ c.req.query("mode"),
596
+ SessionEventReadMode,
597
+ "mode",
598
+ explicitReplay ? "forensic" : "monitoring",
599
+ );
600
+ const latestClass = eventEnumValue(
601
+ c.req.query("latest"),
602
+ SessionEventSemanticClass,
603
+ "latest",
604
+ undefined,
605
+ );
606
+ if (
607
+ latestClass &&
608
+ ["includeTypes", "excludeTypes", "includeClasses", "excludeClasses"].some(
609
+ (name) => c.req.query(name) !== undefined,
610
+ )
611
+ ) {
612
+ throw new HTTPException(400, {
613
+ message: "latest cannot be combined with event filters",
614
+ });
615
+ }
616
+ const direction = latestClass
617
+ ? "before"
618
+ : eventEnumValue(
619
+ c.req.query("direction"),
620
+ SessionEventReadDirection,
621
+ "direction",
622
+ before !== undefined
623
+ ? "before"
624
+ : rawAfter !== undefined
625
+ ? "after"
626
+ : mode === "monitoring"
627
+ ? "before"
628
+ : "after",
629
+ );
630
+ const payloadMode = eventEnumValue(
631
+ c.req.query("payloadMode"),
632
+ SessionEventPayloadMode,
633
+ "payloadMode",
634
+ mode === "monitoring" ? "summary" : "full",
635
+ );
636
+ const includeTypes = eventEnumList(
637
+ c.req.query("includeTypes"),
638
+ SessionEventType,
639
+ "includeTypes",
640
+ );
641
+ const excludeTypes = eventEnumList(
642
+ c.req.query("excludeTypes"),
643
+ SessionEventType,
644
+ "excludeTypes",
645
+ );
646
+ const includeClasses = eventEnumList(
647
+ c.req.query("includeClasses"),
648
+ SessionEventSemanticClass,
649
+ "includeClasses",
650
+ );
651
+ const excludeClasses = eventEnumList(
652
+ c.req.query("excludeClasses"),
653
+ SessionEventSemanticClass,
654
+ "excludeClasses",
655
+ );
656
+ const limit = latestClass
657
+ ? 1
658
+ : eventListLimit(
659
+ c.req.query("limit"),
660
+ compact ? 5000 : mode === "monitoring" ? 250 : 2000,
661
+ mode === "monitoring" ? 40 : 500,
662
+ );
663
+ const dbPage = await listSessionEventPage(db, workspaceId, sessionId, {
480
664
  after,
481
665
  ...(before !== undefined ? { before } : {}),
482
666
  limit,
667
+ direction,
668
+ payloadMode,
669
+ includeTypes,
670
+ excludeTypes,
671
+ includeClasses: latestClass ? [latestClass] : includeClasses,
672
+ excludeClasses,
673
+ ...(mode === "monitoring" ? { defaultExcludeTypes: SESSION_EVENT_RAW_DELTA_TYPES } : {}),
674
+ });
675
+ const events = dbPage.events;
676
+ const projected = compact ? coalesceSessionEventDeltas(events) : events;
677
+ const page = boundSessionEventHttpPage(projected, {
678
+ direction,
483
679
  });
484
- return c.json(compact ? coalesceSessionEventDeltas(events) : events);
680
+ const hasMore = dbPage.hasMore || page.truncated;
681
+ c.header("X-OpenGeni-Page-Bytes", String(page.bytes));
682
+ c.header("X-OpenGeni-Page-Max-Bytes", String(1024 * 1024));
683
+ c.header("X-OpenGeni-Page-Truncated", String(hasMore));
684
+ c.header("X-OpenGeni-Has-More", String(hasMore));
685
+ c.header("X-OpenGeni-Event-Mode", mode);
686
+ c.header("X-OpenGeni-Event-Direction", direction);
687
+ c.header("X-OpenGeni-Payload-Mode", payloadMode);
688
+ c.header("X-OpenGeni-Forensic-Exact", String(mode === "forensic" && payloadMode === "full"));
689
+ const coveredFirst = page.events[0]?.sequence;
690
+ const coveredLast = page.events.at(-1)?.sequence;
691
+ if (coveredFirst !== undefined) c.header("X-OpenGeni-Covered-First", String(coveredFirst));
692
+ if (coveredLast !== undefined) c.header("X-OpenGeni-Covered-Last", String(coveredLast));
693
+ const truncatedBy = page.truncated ? "http_bytes" : dbPage.truncatedBy;
694
+ if (truncatedBy) c.header("X-OpenGeni-Truncated-By", truncatedBy);
695
+ if (page.nextSequence !== null) {
696
+ c.header(
697
+ direction === "before" ? "X-OpenGeni-Next-Before" : "X-OpenGeni-Next-After",
698
+ String(page.nextSequence),
699
+ );
700
+ }
701
+ return c.json(page.events);
485
702
  });
486
703
 
487
704
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/events/stream", async (c) => {
488
705
  const workspaceId = c.req.param("workspaceId");
489
- await requireAccessGrant(c, deps, workspaceId, "sessions:read");
706
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
490
707
  const sessionId = c.req.param("sessionId");
708
+ let authorization;
709
+ try {
710
+ authorization = await requireSessionAuthorization(deps, grant, {
711
+ sessionId,
712
+ operation: "session.stream.read",
713
+ surface: "stream",
714
+ });
715
+ } catch (error) {
716
+ throw sessionAuthorizationHttpError(error);
717
+ }
491
718
  await assertSessionExists(db, workspaceId, sessionId);
492
719
  const after = Number(c.req.query("after") ?? c.req.header("Last-Event-ID") ?? 0);
493
720
  return sseSessionStream(
@@ -497,6 +724,22 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
497
724
  sessionId,
498
725
  Number.isFinite(after) ? after : 0,
499
726
  c.req.raw.signal,
727
+ {
728
+ observability: deps.observability,
729
+ ...(authorization
730
+ ? {
731
+ reauthorizeAfterMs:
732
+ authorization.reauthorizeAfterMs ?? SESSION_AUTHORIZATION_DEFAULT_REAUTHORIZE_MS,
733
+ reauthorize: async () => {
734
+ await requireSessionAuthorization(deps, grant, {
735
+ sessionId,
736
+ operation: "session.stream.read",
737
+ surface: "stream",
738
+ });
739
+ },
740
+ }
741
+ : {}),
742
+ },
500
743
  );
501
744
  });
502
745
 
@@ -516,7 +759,7 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
516
759
  const sessionId = c.req.param("sessionId");
517
760
  const snapshot = await getSessionQueueSnapshot(db, workspaceId, sessionId);
518
761
  if (!snapshot) throw new HTTPException(404, { message: "session not found" });
519
- return c.json(snapshot);
762
+ return c.json(projectQueueSnapshot(snapshot, sessionId, relatedSessionAccessFor(c)));
520
763
  });
521
764
 
522
765
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/queue/:turnId/move", async (c) => {
@@ -526,14 +769,21 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
526
769
  await assertSessionExists(db, workspaceId, sessionId);
527
770
  const payload = MoveSessionQueueItemRequest.parse(await c.req.json());
528
771
  try {
529
- return c.json(
530
- await moveHumanQueuePrompt(
531
- { db, bus },
532
- { accountId: grant.accountId, workspaceId, sessionId, subjectId: grant.subjectId },
533
- c.req.param("turnId"),
534
- payload,
535
- ),
772
+ const response = await moveHumanQueuePrompt(
773
+ deps,
774
+ {
775
+ accountId: grant.accountId,
776
+ workspaceId,
777
+ sessionId,
778
+ subjectId: grant.subjectId,
779
+ },
780
+ c.req.param("turnId"),
781
+ payload,
536
782
  );
783
+ return c.json({
784
+ ...response,
785
+ snapshot: projectQueueSnapshot(response.snapshot, sessionId, relatedSessionAccessFor(c)),
786
+ });
537
787
  } catch (error) {
538
788
  return commandConflictResponse(c, error);
539
789
  }
@@ -546,14 +796,21 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
546
796
  await assertSessionExists(db, workspaceId, sessionId);
547
797
  const payload = EditSessionQueueItemRequest.parse(await c.req.json());
548
798
  try {
549
- return c.json(
550
- await editHumanQueuePrompt(
551
- { db, bus },
552
- { accountId: grant.accountId, workspaceId, sessionId, subjectId: grant.subjectId },
553
- c.req.param("turnId"),
554
- payload,
555
- ),
799
+ const response = await editHumanQueuePrompt(
800
+ deps,
801
+ {
802
+ accountId: grant.accountId,
803
+ workspaceId,
804
+ sessionId,
805
+ subjectId: grant.subjectId,
806
+ },
807
+ c.req.param("turnId"),
808
+ payload,
556
809
  );
810
+ return c.json({
811
+ ...response,
812
+ snapshot: projectQueueSnapshot(response.snapshot, sessionId, relatedSessionAccessFor(c)),
813
+ });
557
814
  } catch (error) {
558
815
  return commandConflictResponse(c, error);
559
816
  }
@@ -566,14 +823,21 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
566
823
  await assertSessionExists(db, workspaceId, sessionId);
567
824
  const payload = SteerSessionQueueItemRequest.parse(await c.req.json());
568
825
  try {
569
- return c.json(
570
- await steerHumanQueuePrompt(
571
- { db, bus },
572
- { accountId: grant.accountId, workspaceId, sessionId, subjectId: grant.subjectId },
573
- c.req.param("turnId"),
574
- payload,
575
- ),
826
+ const response = await steerHumanQueuePrompt(
827
+ deps,
828
+ {
829
+ accountId: grant.accountId,
830
+ workspaceId,
831
+ sessionId,
832
+ subjectId: grant.subjectId,
833
+ },
834
+ c.req.param("turnId"),
835
+ payload,
576
836
  );
837
+ return c.json({
838
+ ...response,
839
+ snapshot: projectQueueSnapshot(response.snapshot, sessionId, relatedSessionAccessFor(c)),
840
+ });
577
841
  } catch (error) {
578
842
  return commandConflictResponse(c, error);
579
843
  }
@@ -586,14 +850,21 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
586
850
  await assertSessionExists(db, workspaceId, sessionId);
587
851
  const payload = DeleteSessionQueueItemRequest.parse(await c.req.json());
588
852
  try {
589
- return c.json(
590
- await deleteHumanQueuePrompt(
591
- { db, bus },
592
- { accountId: grant.accountId, workspaceId, sessionId, subjectId: grant.subjectId },
593
- c.req.param("turnId"),
594
- payload,
595
- ),
853
+ const response = await deleteHumanQueuePrompt(
854
+ deps,
855
+ {
856
+ accountId: grant.accountId,
857
+ workspaceId,
858
+ sessionId,
859
+ subjectId: grant.subjectId,
860
+ },
861
+ c.req.param("turnId"),
862
+ payload,
596
863
  );
864
+ return c.json({
865
+ ...response,
866
+ snapshot: projectQueueSnapshot(response.snapshot, sessionId, relatedSessionAccessFor(c)),
867
+ });
597
868
  } catch (error) {
598
869
  return commandConflictResponse(c, error);
599
870
  }
@@ -604,7 +875,7 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
604
875
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
605
876
  const sessionId = c.req.param("sessionId");
606
877
  return c.json(
607
- await getHumanComposerDraft(db, {
878
+ await getHumanComposerDraft(deps, {
608
879
  accountId: grant.accountId,
609
880
  workspaceId,
610
881
  sessionId,
@@ -621,8 +892,13 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
621
892
  try {
622
893
  return c.json(
623
894
  await saveHumanComposerDraft(
624
- db,
625
- { accountId: grant.accountId, workspaceId, sessionId, subjectId: grant.subjectId },
895
+ deps,
896
+ {
897
+ accountId: grant.accountId,
898
+ workspaceId,
899
+ sessionId,
900
+ subjectId: grant.subjectId,
901
+ },
626
902
  payload,
627
903
  ),
628
904
  );
@@ -634,16 +910,33 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
634
910
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/control", async (c) => {
635
911
  const workspaceId = c.req.param("workspaceId");
636
912
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
913
+ if (workspaceControlUtf8Bytes(grant.subjectId) > WORKSPACE_CONTROL_ACTOR_MAX_BYTES) {
914
+ throw new HTTPException(400, { message: "workspace-control actor is too large" });
915
+ }
637
916
  const sessionId = c.req.param("sessionId");
638
- const payload = SessionControlRequest.parse(await c.req.json());
917
+ const parsed = SessionControlRequest.safeParse(await c.req.json().catch(() => null));
918
+ if (!parsed.success) {
919
+ throw new HTTPException(400, { message: "invalid session control request" });
920
+ }
639
921
  try {
640
- return c.json(
641
- await controlHumanSessionWorkstream(
642
- { db, bus, workflowClient },
643
- { accountId: grant.accountId, workspaceId, sessionId, subjectId: grant.subjectId },
644
- payload,
645
- ),
922
+ const response = await controlHumanSessionWorkstream(
923
+ deps,
924
+ {
925
+ accountId: grant.accountId,
926
+ workspaceId,
927
+ sessionId,
928
+ subjectId: grant.subjectId,
929
+ },
930
+ parsed.data,
646
931
  );
932
+ return c.json({
933
+ ...response,
934
+ effectiveControl: projectEffectiveControlForRelatedAccess(
935
+ response.effectiveControl,
936
+ sessionId,
937
+ relatedSessionAccessFor(c),
938
+ ),
939
+ });
647
940
  } catch (error) {
648
941
  return commandConflictResponse(c, error);
649
942
  }
@@ -658,6 +951,7 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
658
951
  const payload = SteerSessionMessageRequest.parse(raw);
659
952
  const result = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
660
953
  text: payload.text,
954
+ turnInstructions: payload.turnInstructions ?? null,
661
955
  resources: payload.resources,
662
956
  tools: payload.tools,
663
957
  toolsProvided: userMessagePayloadHasOwnProperty({ payload: raw }, "tools"),
@@ -681,9 +975,27 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
681
975
  const sessionId = c.req.param("sessionId");
682
976
  const rawEvent = await c.req.json();
683
977
  const event = ClientSessionEvent.parse(rawEvent);
978
+ const refinedOperation =
979
+ event.type === "user.approvalDecision"
980
+ ? "session.approval.write"
981
+ : event.type === "user.humanInputResponse"
982
+ ? "session.human_input.write"
983
+ : null;
984
+ if (refinedOperation) {
985
+ try {
986
+ await requireSessionAuthorization(deps, grant, {
987
+ sessionId,
988
+ operation: refinedOperation,
989
+ surface: "http",
990
+ });
991
+ } catch (error) {
992
+ throw sessionAuthorizationHttpError(error);
993
+ }
994
+ }
684
995
  if (event.type === "user.message") {
685
996
  const { accepted } = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
686
997
  text: event.payload.text,
998
+ turnInstructions: event.payload.turnInstructions ?? null,
687
999
  resources: event.payload.resources ?? [],
688
1000
  tools: event.payload.tools ?? [],
689
1001
  toolsProvided: userMessagePayloadHasOwnProperty(rawEvent, "tools"),
@@ -726,8 +1038,83 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
726
1038
  });
727
1039
  return c.json(accepted.event, 202);
728
1040
  }
1041
+
1042
+ if (event.type === "user.humanInputResponse") {
1043
+ let accepted;
1044
+ try {
1045
+ accepted = await acceptSessionHumanInputResponse(db, {
1046
+ accountId: grant.accountId,
1047
+ workspaceId,
1048
+ sessionId,
1049
+ requestId: event.payload.requestId,
1050
+ response: event.payload.response,
1051
+ respondedBy: grant.subjectId,
1052
+ clientEventId: event.clientEventId ?? null,
1053
+ });
1054
+ } catch (error) {
1055
+ if (error instanceof HumanInputResponseValidationError) {
1056
+ throw new HTTPException(error.code === "SKIP_NOT_ALLOWED" ? 409 : 422, {
1057
+ message: error.message,
1058
+ });
1059
+ }
1060
+ throw error;
1061
+ }
1062
+ if (accepted.action === "not_found") {
1063
+ throw new HTTPException(404, { message: "human-input request not found" });
1064
+ }
1065
+ await publishDurableSessionEvents(bus, workspaceId, sessionId, accepted.events);
1066
+ if (accepted.workflowWakeRevision !== null) {
1067
+ await workflowClient.signalApprovalDecision({
1068
+ accountId: grant.accountId,
1069
+ workspaceId,
1070
+ sessionId,
1071
+ eventId: accepted.events[0]?.id ?? event.payload.requestId,
1072
+ workflowId: workflowIdForSession(sessionId),
1073
+ workflowWakeRevision: accepted.workflowWakeRevision,
1074
+ });
1075
+ }
1076
+ if (accepted.action === "conflict") {
1077
+ throw new HTTPException(409, {
1078
+ message: `human-input request is ${accepted.request.status}`,
1079
+ });
1080
+ }
1081
+ return c.json(accepted.event, 202);
1082
+ }
1083
+ });
1084
+
1085
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/human-input-requests", async (c) => {
1086
+ const workspaceId = c.req.param("workspaceId");
1087
+ await requireAccessGrant(c, deps, workspaceId, "sessions:read");
1088
+ const sessionId = c.req.param("sessionId");
1089
+ await assertSessionExists(db, workspaceId, sessionId);
1090
+ const rawStatus = c.req.query("status");
1091
+ const status = rawStatus ? HumanInputRequestStatus.safeParse(rawStatus) : null;
1092
+ if (status && !status.success) {
1093
+ throw new HTTPException(400, { message: "invalid human-input request status" });
1094
+ }
1095
+ const requests = await listSessionHumanInputRequests(db, workspaceId, sessionId, {
1096
+ ...(status?.success ? { status: status.data } : {}),
1097
+ });
1098
+ return c.json({ requests });
729
1099
  });
730
1100
 
1101
+ app.get(
1102
+ "/v1/workspaces/:workspaceId/sessions/:sessionId/human-input-requests/:requestId",
1103
+ async (c) => {
1104
+ const workspaceId = c.req.param("workspaceId");
1105
+ await requireAccessGrant(c, deps, workspaceId, "sessions:read");
1106
+ const sessionId = c.req.param("sessionId");
1107
+ const request = await getSessionHumanInputRequest(
1108
+ db,
1109
+ workspaceId,
1110
+ sessionId,
1111
+ c.req.param("requestId"),
1112
+ );
1113
+ if (!request) throw new HTTPException(404, { message: "human-input request not found" });
1114
+ return c.json(request);
1115
+ },
1116
+ );
1117
+
731
1118
  // ── API-direct stream capabilities + viewer attach (P1.4) ─────────────────
732
1119
  //
733
1120
  // All IN-PROCESS: capability negotiation reads the descriptor + the group
@@ -781,6 +1168,7 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
781
1168
  { workspaceId, sandboxGroupId: session.sandboxGroupId },
782
1169
  );
783
1170
  const { shared, sharedSessionIds } = await resolveSharedExposure(workspaceId, session);
1171
+ const visibleSharedSessionIds = relatedSessionAccessFor(c) === "root" ? sharedSessionIds : [];
784
1172
  // Per-principal acknowledgment: A acknowledging does not consent for B. The
785
1173
  // un-redacted desktop stream ALWAYS requires the un-redacted ack; a shared box
786
1174
  // ADDITIONALLY requires the shared-exposure ack. Both must match the POST
@@ -872,13 +1260,13 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
872
1260
  // tracks the desktop tier + a desktop-capable backend.
873
1261
  computerUseEnabled: settings.computerUseEnabled,
874
1262
  computerUseReadOnly: settings.computerUseReadOnly,
875
- // Graceful degrade (I8/OD-8): if desktop is enabled but no stream-token
1263
+ // Graceful degrade (stream-token availability contract): if desktop is enabled but no stream-token
876
1264
  // secret is resolvable, the desktop cell reports transport:null rather
877
1265
  // than advertising a plane we can never authorize.
878
1266
  streamTokenSecretAvailable: !streamTokenDegraded(settings),
879
1267
  desktopAcknowledged: acknowledged,
880
1268
  shared,
881
- sharedSessionIds,
1269
+ sharedSessionIds: visibleSharedSessionIds,
882
1270
  // The minted live address (null when not unlocked/degraded). The resolver
883
1271
  // only folds it in when the desktop gates pass + the ack is present.
884
1272
  ...(desktopStream
@@ -960,7 +1348,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
960
1348
  }
961
1349
  const parsed = AcknowledgeStreamRequest.safeParse(await c.req.json().catch(() => ({})));
962
1350
  if (!parsed.success) {
963
- throw new HTTPException(400, { message: "invalid stream acknowledgment request" });
1351
+ throw new HTTPException(400, {
1352
+ message: "invalid stream acknowledgment request",
1353
+ });
964
1354
  }
965
1355
  const recorded = await recordStreamAcknowledgment(db, {
966
1356
  accountId: grant.accountId,
@@ -996,7 +1386,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
996
1386
  }
997
1387
  const parsed = AttachViewerRequest.safeParse(await c.req.json().catch(() => ({})));
998
1388
  if (!parsed.success) {
999
- throw new HTTPException(400, { message: "invalid viewer attach request" });
1389
+ throw new HTTPException(400, {
1390
+ message: "invalid viewer attach request",
1391
+ });
1000
1392
  }
1001
1393
  // Consent gate (P3.2 / addendum E.1): ONLY the un-redacted DESKTOP pixel plane
1002
1394
  // requires the calling principal's acknowledgment (recorded per group+subject;
@@ -1017,10 +1409,14 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1017
1409
  subjectId: grant.subjectId,
1018
1410
  });
1019
1411
  if (!ack?.acknowledgedUnredacted) {
1020
- throw new HTTPException(409, { message: "stream_acknowledgment_required" });
1412
+ throw new HTTPException(409, {
1413
+ message: "stream_acknowledgment_required",
1414
+ });
1021
1415
  }
1022
1416
  if (shared && !ack.acknowledgedShared) {
1023
- throw new HTTPException(409, { message: "shared_acknowledgment_required" });
1417
+ throw new HTTPException(409, {
1418
+ message: "shared_acknowledgment_required",
1419
+ });
1024
1420
  }
1025
1421
  }
1026
1422
  // SELFHOSTED ACTIVE: when the session's active sandbox is selfhosted, skip
@@ -1177,7 +1573,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1177
1573
  }
1178
1574
  const parsed = ViewerHeartbeatRequest.safeParse(await c.req.json().catch(() => ({})));
1179
1575
  if (!parsed.success) {
1180
- throw new HTTPException(400, { message: "viewer heartbeat requires { leaseEpoch }" });
1576
+ throw new HTTPException(400, {
1577
+ message: "viewer heartbeat requires { leaseEpoch }",
1578
+ });
1181
1579
  }
1182
1580
  const alive = await heartbeatViewer(
1183
1581
  { db, settings },
@@ -1240,7 +1638,10 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1240
1638
  idleGraceMs: settings.sandboxIdleGraceMs,
1241
1639
  });
1242
1640
  // null ⇒ the lease was already cold-and-reaped (revoke is an idempotent no-op).
1243
- return c.json({ liveness: result?.liveness ?? null, refcount: result?.refcount ?? null });
1641
+ return c.json({
1642
+ liveness: result?.liveness ?? null,
1643
+ refcount: result?.refcount ?? null,
1644
+ });
1244
1645
  },
1245
1646
  );
1246
1647
 
@@ -1281,12 +1682,19 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1281
1682
  if (!session) {
1282
1683
  throw new HTTPException(404, { message: "session not found" });
1283
1684
  }
1284
- return { accountId: grant.accountId, workspaceId, session, subjectId: grant.subjectId };
1685
+ return {
1686
+ accountId: grant.accountId,
1687
+ workspaceId,
1688
+ session,
1689
+ subjectId: grant.subjectId,
1690
+ };
1285
1691
  }
1286
1692
 
1287
1693
  async function parseChannelABody<T>(
1288
1694
  c: Context,
1289
- schema: { safeParse: (v: unknown) => { success: true; data: T } | { success: false } },
1695
+ schema: {
1696
+ safeParse: (v: unknown) => { success: true; data: T } | { success: false };
1697
+ },
1290
1698
  ): Promise<T> {
1291
1699
  const raw = await c.req.json().catch(() => undefined);
1292
1700
  const result = schema.safeParse(raw ?? {});
@@ -1416,7 +1824,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1416
1824
  const sessionId = c.req.param("sessionId") ?? "";
1417
1825
  const path = c.req.query("path");
1418
1826
  if (!path) {
1419
- throw new HTTPException(400, { message: "path query parameter is required" });
1827
+ throw new HTTPException(400, {
1828
+ message: "path query parameter is required",
1829
+ });
1420
1830
  }
1421
1831
  const session = await getSession(db, workspaceId, sessionId);
1422
1832
  if (!session) {
@@ -1431,7 +1841,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1431
1841
  if (revisionParam !== undefined && revisionParam !== "") {
1432
1842
  const revision = Number(revisionParam);
1433
1843
  if (!Number.isInteger(revision) || revision < 0) {
1434
- throw new HTTPException(400, { message: "revision must be a non-negative integer" });
1844
+ throw new HTTPException(400, {
1845
+ message: "revision must be a non-negative integer",
1846
+ });
1435
1847
  }
1436
1848
  row = await workspaceCaptureAtRevision(db, workspaceId, sessionId, revision);
1437
1849
  } else {
@@ -1503,7 +1915,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1503
1915
  throw new HTTPException(404, { message: "pty not found or closed" });
1504
1916
  }
1505
1917
  if (pty.execSessionId === null) {
1506
- throw new HTTPException(409, { message: "interactive terminal unsupported on this backend" });
1918
+ throw new HTTPException(409, {
1919
+ message: "interactive terminal unsupported on this backend",
1920
+ });
1507
1921
  }
1508
1922
  let seq = 1;
1509
1923
  await withChannelA({ db, settings, bus }, ctx, async ({ service }) => {
@@ -1565,7 +1979,11 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1565
1979
  workspaceId: ctx.workspaceId,
1566
1980
  ptyId: req.ptyId,
1567
1981
  });
1568
- const exited: TerminalPtyExitedPayload = { ptyId: req.ptyId, exitCode: 0, reason: "exit" };
1982
+ const exited: TerminalPtyExitedPayload = {
1983
+ ptyId: req.ptyId,
1984
+ exitCode: 0,
1985
+ reason: "exit",
1986
+ };
1569
1987
  await appendAndPublishEvents(db, bus, ctx.workspaceId, ctx.session.id, [
1570
1988
  { type: "terminal.pty.exited", payload: exited },
1571
1989
  ]);
@@ -1574,14 +1992,157 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
1574
1992
  });
1575
1993
  }
1576
1994
 
1577
- function eventListLimit(raw: string | undefined, max = 2000): number {
1578
- const limit = Number(raw ?? 500);
1995
+ function eventListLimit(raw: string | undefined, max = 2000, fallback = 500): number {
1996
+ const limit = Number(raw ?? fallback);
1579
1997
  if (!Number.isFinite(limit)) {
1580
- return 500;
1998
+ return fallback;
1581
1999
  }
1582
2000
  return Math.min(max, Math.max(1, Math.floor(limit)));
1583
2001
  }
1584
2002
 
2003
+ /**
2004
+ * Map every mounted session-addressed HTTP path to the host-neutral operation
2005
+ * the embedding port authorizes. Returning null is deliberately fail-closed in
2006
+ * host-managed mode; standalone deployments never consult this classifier.
2007
+ */
2008
+ export function sessionAuthorizationOperationForHttp(
2009
+ method: string,
2010
+ pathname: string,
2011
+ sessionId: string,
2012
+ ): SessionAuthorizationOperation | null {
2013
+ const marker = `/sessions/${sessionId}`;
2014
+ const markerAt = pathname.indexOf(marker);
2015
+ if (markerAt < 0) return null;
2016
+ const suffix = pathname.slice(markerAt + marker.length);
2017
+ const verb = method.toUpperCase();
2018
+
2019
+ if (suffix === "") {
2020
+ if (verb === "GET") return "session.read";
2021
+ if (verb === "PATCH") return "session.title.write";
2022
+ return null;
2023
+ }
2024
+ if (suffix === "/pin" && verb === "PUT") return "session.pin.write";
2025
+ if (suffix === "/lineage" && verb === "GET") return "session.lineage.read";
2026
+ if (suffix === "/codex-account" && verb === "POST") {
2027
+ return "session.codex_account.write";
2028
+ }
2029
+ if (suffix === "/goal") {
2030
+ return verb === "GET"
2031
+ ? "session.goal.read"
2032
+ : ["PATCH", "DELETE"].includes(verb)
2033
+ ? "session.goal.write"
2034
+ : null;
2035
+ }
2036
+ if (suffix === "/context/clear" || suffix === "/context/compact") {
2037
+ return verb === "POST" ? "session.context.write" : null;
2038
+ }
2039
+ if (suffix === "/events/stream" && verb === "GET") return "session.stream.read";
2040
+ if (suffix === "/events") {
2041
+ if (verb === "GET") return "session.events.read";
2042
+ if (verb === "POST") return "session.append";
2043
+ return null;
2044
+ }
2045
+ if (suffix === "/turns" && verb === "GET") return "session.turns.read";
2046
+ if (suffix === "/queue" && verb === "GET") return "session.queue.read";
2047
+ if (suffix.startsWith("/queue/") && verb === "POST") return "session.queue.control";
2048
+ if (suffix === "/composer-draft") {
2049
+ if (verb === "GET") return "session.composer.read";
2050
+ if (verb === "PUT") return "session.composer.write";
2051
+ return null;
2052
+ }
2053
+ if (suffix === "/control" && verb === "POST") return "session.control";
2054
+ if (suffix === "/steer" && verb === "POST") return "session.steer";
2055
+ if (suffix === "/human-input-requests" && verb === "GET") {
2056
+ return "session.human_input.read";
2057
+ }
2058
+ if (suffix.startsWith("/human-input-requests/") && verb === "GET") {
2059
+ return "session.human_input.read";
2060
+ }
2061
+ if (suffix === "/stream-capabilities" && verb === "GET") return "session.viewer.read";
2062
+ if (suffix === "/stream-capabilities/acknowledge" && verb === "POST") {
2063
+ return "session.stream.acknowledge";
2064
+ }
2065
+ if (suffix === "/viewers" && verb === "POST") return "session.viewer.control";
2066
+ if (suffix.startsWith("/viewers/") && ["POST", "DELETE"].includes(verb)) {
2067
+ return "session.viewer.control";
2068
+ }
2069
+ if (suffix === "/fs/list" || suffix === "/fs/read") {
2070
+ return verb === "POST" ? "session.files.read" : null;
2071
+ }
2072
+ if (["/fs/write", "/fs/delete", "/fs/move", "/fs/mkdir"].includes(suffix)) {
2073
+ return verb === "POST" ? "session.files.write" : null;
2074
+ }
2075
+ if (suffix.startsWith("/git/") && verb === "POST") return "session.git.read";
2076
+ if ((suffix === "/workspace/capture" || suffix === "/workspace/capture/file") && verb === "GET") {
2077
+ return "session.capture.read";
2078
+ }
2079
+ if (suffix === "/terminal/exec" && verb === "POST") return "session.terminal.control";
2080
+ if (suffix === "/terminal/pty" && verb === "POST") return "session.terminal.control";
2081
+ if (suffix.startsWith("/terminal/pty/") && verb === "POST") {
2082
+ return "session.terminal.control";
2083
+ }
2084
+ return null;
2085
+ }
2086
+
2087
+ function sessionAuthorizationHttpError(error: unknown): HTTPException {
2088
+ if (error instanceof SessionAuthorizationDeniedError) {
2089
+ return new HTTPException(404, { message: "session not found" });
2090
+ }
2091
+ if (error instanceof SessionAuthorizationUnavailableError) {
2092
+ return new HTTPException(503, { message: "session authorization is unavailable" });
2093
+ }
2094
+ if (error instanceof HTTPException) return error;
2095
+ throw error;
2096
+ }
2097
+
2098
+ function eventEnumValue<T extends string>(
2099
+ raw: string | undefined,
2100
+ schema: { safeParse(value: unknown): { success: boolean; data?: T } },
2101
+ name: string,
2102
+ fallback: T,
2103
+ ): T;
2104
+ function eventEnumValue<T extends string>(
2105
+ raw: string | undefined,
2106
+ schema: { safeParse(value: unknown): { success: boolean; data?: T } },
2107
+ name: string,
2108
+ fallback: undefined,
2109
+ ): T | undefined;
2110
+ function eventEnumValue<T extends string>(
2111
+ raw: string | undefined,
2112
+ schema: { safeParse(value: unknown): { success: boolean; data?: T } },
2113
+ name: string,
2114
+ fallback: T | undefined,
2115
+ ): T | undefined {
2116
+ if (raw === undefined) return fallback;
2117
+ const parsed = schema.safeParse(raw);
2118
+ if (!parsed.success) {
2119
+ throw new HTTPException(400, { message: `${name} is invalid` });
2120
+ }
2121
+ return parsed.data as T;
2122
+ }
2123
+
2124
+ function eventEnumList<T extends string>(
2125
+ raw: string | undefined,
2126
+ schema: { safeParse(value: unknown): { success: boolean; data?: T } },
2127
+ name: string,
2128
+ ): T[] {
2129
+ if (raw === undefined || raw.trim() === "") return [];
2130
+ const values = raw
2131
+ .split(",")
2132
+ .map((value) => value.trim())
2133
+ .filter(Boolean);
2134
+ if (values.length > 100) {
2135
+ throw new HTTPException(400, { message: `${name} accepts at most 100 values` });
2136
+ }
2137
+ return values.map((value) => {
2138
+ const parsed = schema.safeParse(value);
2139
+ if (!parsed.success) {
2140
+ throw new HTTPException(400, { message: `${name} contains an invalid value` });
2141
+ }
2142
+ return parsed.data as T;
2143
+ });
2144
+ }
2145
+
1585
2146
  function sessionListQuery(
1586
2147
  query: Record<string, string>,
1587
2148
  allowCursor = true,
@@ -1611,7 +2172,9 @@ function sessionListQuery(
1611
2172
  }
1612
2173
  const search = query.search?.trim();
1613
2174
  if (search && search.length > 200) {
1614
- throw new HTTPException(400, { message: "search must be at most 200 characters" });
2175
+ throw new HTTPException(400, {
2176
+ message: "search must be at most 200 characters",
2177
+ });
1615
2178
  }
1616
2179
  return {
1617
2180
  limit: query.limit,