@opengeni/api-router 3.2.2 → 3.4.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 (45) hide show
  1. package/dist/access-grant-rls.d.ts +15 -0
  2. package/dist/app.js +1 -1
  3. package/dist/billing-stripe-customer.d.ts +18 -0
  4. package/dist/billing-subscription-store.d.ts +34 -0
  5. package/dist/billing-subscriptions.d.ts +64 -0
  6. package/dist/{chunk-PQBHZ2YH.js → chunk-6DZ4H5JQ.js} +3133 -1711
  7. package/dist/{chunk-PQBHZ2YH.js.map → chunk-6DZ4H5JQ.js.map} +1 -1
  8. package/dist/index.js +4 -2
  9. package/dist/index.js.map +1 -1
  10. package/dist/integrations/marketplace-plugin.d.ts +43 -43
  11. package/dist/mcp/server.d.ts +11 -1
  12. package/dist/routes/browser-sessions.d.ts +23 -2
  13. package/dist/routes/sessions.d.ts +1 -0
  14. package/dist/sandbox/channel-a.d.ts +10 -1
  15. package/dist/site-publishing/agent-data-tools.d.ts +5 -5
  16. package/dist/site-publishing/agent-session.d.ts +3 -2
  17. package/dist/site-publishing/runtime.d.ts +7 -6
  18. package/dist/site-publishing/service.d.ts +8 -11
  19. package/dist/site-publishing/session-request-authority.d.ts +3 -0
  20. package/package.json +24 -24
  21. package/src/access-grant-rls.ts +99 -10
  22. package/src/app.ts +105 -43
  23. package/src/billing-stripe-customer.ts +40 -0
  24. package/src/billing-subscription-store.ts +121 -0
  25. package/src/billing-subscriptions.ts +759 -0
  26. package/src/http/sse.ts +5 -1
  27. package/src/index.ts +3 -0
  28. package/src/mcp/action-catalog.gen.ts +113 -0
  29. package/src/mcp/action-schema-imports.gen.ts +5 -1
  30. package/src/mcp/server.ts +28 -0
  31. package/src/routes/billing.ts +161 -21
  32. package/src/routes/browser-sessions.ts +420 -26
  33. package/src/routes/computer-sessions.ts +102 -39
  34. package/src/routes/insights-usage.ts +22 -0
  35. package/src/routes/organization-memberships.ts +12 -0
  36. package/src/routes/sessions.ts +30 -5
  37. package/src/routes/site-runtime.ts +62 -20
  38. package/src/sandbox/channel-a.ts +18 -1
  39. package/src/sandbox/metrics-ingestion.ts +1 -0
  40. package/src/site-frame-guard.ts +11 -2
  41. package/src/site-publishing/agent-data-tools.ts +16 -8
  42. package/src/site-publishing/agent-session.ts +24 -6
  43. package/src/site-publishing/runtime.ts +29 -7
  44. package/src/site-publishing/service.ts +65 -77
  45. package/src/site-publishing/session-request-authority.ts +14 -0
@@ -11,7 +11,7 @@ import {
11
11
  withSiteSessionOrigin,
12
12
  type ApiRouteDeps,
13
13
  } from "@opengeni/core";
14
- import { getSession } from "@opengeni/db";
14
+ import { getSession, getSessionAuthorityProjection } from "@opengeni/db";
15
15
  import type { Context, Next } from "hono";
16
16
  import { HTTPException } from "hono/http-exception";
17
17
  import { ApiHttpError } from "./http/api-error";
@@ -78,10 +78,19 @@ export function siteFrameGuard(deps: ApiRouteDeps) {
78
78
  throw new HTTPException(404, { message: "Unsupported Site session API path" });
79
79
  }
80
80
  if (match.sessionId !== null) {
81
+ const authority = await getSessionAuthorityProjection(
82
+ deps.db,
83
+ grant.workspaceId,
84
+ match.sessionId,
85
+ );
81
86
  const session = await getSession(deps.db, grant.workspaceId, match.sessionId).catch(
82
87
  () => null,
83
88
  );
84
- if (!session || !siteOriginsMatch(origin, siteSessionOriginFromMetadata(session.metadata)))
89
+ if (
90
+ !authority?.siteAccessRootId ||
91
+ !session ||
92
+ !siteOriginsMatch(origin, siteSessionOriginFromMetadata(session.metadata))
93
+ )
85
94
  throw new HTTPException(404, { message: "session not found" });
86
95
  }
87
96
  if (c.req.method !== "GET" && match.operation.id !== "sessions.create") {
@@ -1,10 +1,12 @@
1
1
  import {
2
2
  siteHostname,
3
+ permissionsRequiredByFirstPartyTools,
3
4
  type FirstPartyMcpToolName,
5
+ type Permission,
4
6
  type SiteLimitProfileKind,
5
7
  } from "@opengeni/contracts";
6
8
  import { resolveFirstPartyMcpToolPolicy } from "@opengeni/config";
7
- import type { ApiRouteDeps } from "@opengeni/core";
9
+ import { hasPermission, type ApiRouteDeps } from "@opengeni/core";
8
10
  import {
9
11
  consumeSiteRuntimeRate,
10
12
  findSiteRuntimeAgentConversation,
@@ -47,12 +49,6 @@ export const SITE_AGENT_MEMBER_SQL_TOOLS = Object.freeze([
47
49
  "site_sql_query",
48
50
  ] as const satisfies readonly FirstPartyMcpToolName[]);
49
51
 
50
- /** The first-party permissions those tools need (Site Data writes need publish). */
51
- export const SITE_AGENT_DATA_TOOL_PERMISSIONS = Object.freeze([
52
- "artifacts:read",
53
- "artifacts:publish",
54
- ] as const);
55
-
56
52
  export const SITE_AGENT_OTHER_SITE_MESSAGE = "This Site's assistant can only use this Site's data";
57
53
  export const SITE_AGENT_MANAGEMENT_MESSAGE =
58
54
  "This Site's assistant can use the Site's data, not manage the Site";
@@ -64,13 +60,23 @@ export function siteAgentDataToolNames(input: {
64
60
  siteSql: boolean;
65
61
  /** The deployment's first-party tool allowlist. */
66
62
  allowed: readonly string[];
63
+ /** Live member ceiling; omitted only where the native caller filters tools itself. */
64
+ memberPermissions?: readonly Permission[];
67
65
  }): FirstPartyMcpToolName[] {
68
66
  const names: FirstPartyMcpToolName[] = [
69
67
  ...SITE_AGENT_DATA_TOOLS,
70
68
  ...(input.siteSql ? SITE_AGENT_SQL_TOOLS : []),
71
69
  ...(input.siteSql && input.viewerKind === "member" ? SITE_AGENT_MEMBER_SQL_TOOLS : []),
72
70
  ];
73
- return names.filter((name) => input.allowed.includes(name));
71
+ return names.filter(
72
+ (name) =>
73
+ input.allowed.includes(name) &&
74
+ (input.viewerKind !== "member" ||
75
+ input.memberPermissions === undefined ||
76
+ permissionsRequiredByFirstPartyTools([name]).every((permission) =>
77
+ hasPermission(input.memberPermissions!, permission),
78
+ )),
79
+ );
74
80
  }
75
81
 
76
82
  /** Whether Site SQL is offered on this deployment and turned on for the Site. */
@@ -90,11 +96,13 @@ export async function siteAgentDataToolsForStart(
90
96
  scope: { accountId: string; workspaceId: string },
91
97
  siteId: string,
92
98
  viewerKind: SiteRuntimeConversationViewerKind,
99
+ memberPermissions?: readonly Permission[],
93
100
  ): Promise<FirstPartyMcpToolName[]> {
94
101
  return siteAgentDataToolNames({
95
102
  viewerKind,
96
103
  siteSql: await siteAgentSiteSqlOn(deps, scope, siteId),
97
104
  allowed: resolveFirstPartyMcpToolPolicy(deps.settings).allowed,
105
+ ...(memberPermissions === undefined ? {} : { memberPermissions }),
98
106
  });
99
107
  }
100
108
 
@@ -1,11 +1,16 @@
1
- import { resolveAgentConfig, type SessionToolPolicy, type ToolRef } from "@opengeni/contracts";
1
+ import {
2
+ permissionsRequiredByFirstPartyTools,
3
+ resolveAgentConfig,
4
+ type Permission,
5
+ type SessionToolPolicy,
6
+ type ToolRef,
7
+ } from "@opengeni/contracts";
2
8
  import { agentConfigDeploymentPolicy, resolveFirstPartyMcpToolPolicy } from "@opengeni/config";
3
9
  import { siteSessionOriginFromMetadata, type ApiRouteDeps } from "@opengeni/core";
4
10
  import { appendSessionEventsWithLockedSessionUpdate, getSiteSqlDatabase } from "@opengeni/db";
5
11
  import { publishDurableSessionEvents } from "@opengeni/events";
6
12
  import {
7
13
  resolveSiteAgentDataContext,
8
- SITE_AGENT_DATA_TOOL_PERMISSIONS,
9
14
  siteAgentDataToolNames,
10
15
  siteAgentInstructions,
11
16
  } from "./agent-data-tools";
@@ -14,7 +19,7 @@ import type { SiteRuntimeViewer } from "./viewer";
14
19
  import { sitePublishingError } from "./service";
15
20
  import { siteSqlOffered } from "../site-sql/gateway";
16
21
 
17
- export const SITE_AGENT_DEFAULTS_VERSION = 1;
22
+ export const SITE_AGENT_DEFAULTS_VERSION = 2;
18
23
 
19
24
  /**
20
25
  * Before admitting more work, converge pre-redesign published-host sessions to
@@ -33,6 +38,7 @@ export async function normalizeSiteAgentSession(
33
38
  viewer: SiteRuntimeViewer,
34
39
  sessionId: string,
35
40
  retainedToolIds: readonly string[],
41
+ memberPermissions?: readonly Permission[],
36
42
  ): Promise<void> {
37
43
  const data = await resolveSiteAgentDataContext(deps, host.scope, sessionId);
38
44
  if (
@@ -55,7 +61,9 @@ export async function normalizeSiteAgentSession(
55
61
  viewerKind: viewer.kind,
56
62
  siteSql: sqlEnabled,
57
63
  allowed: resolveFirstPartyMcpToolPolicy(deps.settings).allowed,
64
+ ...(memberPermissions === undefined ? {} : { memberPermissions }),
58
65
  });
66
+ const dataToolPermissions = permissionsRequiredByFirstPartyTools(dataTools);
59
67
  const config = resolveAgentConfig({
60
68
  creator: "api",
61
69
  request: {
@@ -83,7 +91,12 @@ export async function normalizeSiteAgentSession(
83
91
  (session) => {
84
92
  if (
85
93
  session.parentSessionId ||
86
- session.createdBy.subjectId !== `site:${host.site.id}` ||
94
+ (session.createdBy.subjectId !== `site:${host.site.id}` &&
95
+ !(
96
+ viewer.kind === "member" &&
97
+ session.createdBy.kind === "subject" &&
98
+ session.createdBy.subjectId === viewer.subjectId
99
+ )) ||
87
100
  siteSessionOriginFromMetadata(session.metadata)?.siteId !== host.site.id
88
101
  )
89
102
  throw sitePublishingError(
@@ -97,7 +110,12 @@ export async function normalizeSiteAgentSession(
97
110
  raw && typeof raw === "object" && !Array.isArray(raw)
98
111
  ? (raw as Record<string, unknown>)
99
112
  : {};
100
- if (stamp.agentDefaultsVersion === SITE_AGENT_DEFAULTS_VERSION) return { events: [] };
113
+ if (
114
+ stamp.agentDefaultsVersion === SITE_AGENT_DEFAULTS_VERSION &&
115
+ session.firstPartyMcpTools.join(",") === dataTools.join(",") &&
116
+ session.firstPartyMcpPermissions?.join(",") === dataToolPermissions.join(",")
117
+ )
118
+ return { events: [] };
101
119
  // Never restore workspace defaults or add a newly declared connector to
102
120
  // an old conversation. Preserve only its still-authorized exact refs.
103
121
  const allowed = new Set(["opengeni", ...retainedToolIds]);
@@ -126,7 +144,7 @@ export async function normalizeSiteAgentSession(
126
144
  ],
127
145
  update: {
128
146
  firstPartyMcpTools: dataTools,
129
- firstPartyMcpPermissions: [...SITE_AGENT_DATA_TOOL_PERMISSIONS],
147
+ firstPartyMcpPermissions: dataToolPermissions,
130
148
  agentConfig: config,
131
149
  instructions,
132
150
  tools,
@@ -9,7 +9,11 @@ import {
9
9
  type SiteLimits,
10
10
  type SiteViewerCapabilities,
11
11
  } from "@opengeni/contracts";
12
- import { sessionUsageLimitsProducerEnabled, type ApiRouteDeps } from "@opengeni/core";
12
+ import {
13
+ sessionUsageLimitsProducerEnabled,
14
+ stampSiteViewerSessionAuthorization,
15
+ type ApiRouteDeps,
16
+ } from "@opengeni/core";
13
17
  import {
14
18
  consumeSiteRuntimeRate,
15
19
  findSiteHostname,
@@ -40,6 +44,7 @@ import {
40
44
  type SiteRuntimeReservation,
41
45
  } from "@opengeni/db";
42
46
  import type { SiteGatewayFields } from "./gateway-signature";
47
+ import { bindSiteSessionRequestAuthority } from "./session-request-authority";
43
48
  import { effectiveSiteLimits, siteViewerLimits, sourceSiteLimitProfile } from "./limits";
44
49
  import {
45
50
  siteLimitInputs,
@@ -59,8 +64,8 @@ import {
59
64
  /**
60
65
  * Site-runtime: the narrow API a Site gateway calls for one published Site
61
66
  * host, after the gateway signature passed. Everything here works for exactly
62
- * one Site: the host names it, the viewer is resolved for it, and the agent
63
- * principal is `site:<siteId>`.
67
+ * one Site: the host names it and the viewer is resolved for it. Members retain
68
+ * their human identity; outsiders use the viewer-bound `site:<siteId>` principal.
64
69
  */
65
70
 
66
71
  export type SiteRuntimeDeps = Pick<ApiRouteDeps, "db" | "settings" | "objectStorage">;
@@ -219,6 +224,7 @@ export async function resolveSiteRuntimeViewer(
219
224
  /** What a viewer cannot do on a Site: 403 with `details.code` `not_available_for_viewer`. */
220
225
  export type SiteViewerFeature =
221
226
  | "agent"
227
+ | "private_conversations"
222
228
  | "agent_model"
223
229
  | "workspace_tools"
224
230
  | "site_data"
@@ -796,9 +802,9 @@ export async function meterSiteRuntime(
796
802
  // --- in-process dispatch ------------------------------------------------------------
797
803
 
798
804
  /**
799
- * Run an ordinary session route in this process as the Site's service
800
- * principal (`site:<siteId>`), with a 60-second delegated token carrying only
801
- * session permissions and the viewer as its display label. The Site and the
805
+ * Run an ordinary session route in this process as the verified member, or
806
+ * the Site service principal for an outsider. The latter uses a 60-second
807
+ * delegated token plus the separate trusted viewer audience. The Site and the
802
808
  * served version ride along as the request's Site origin, so new sessions and
803
809
  * turns record where they came from. The route authorizes as usual.
804
810
  */
@@ -812,14 +818,30 @@ export async function siteRuntimeSessionRequest(
812
818
  path: string;
813
819
  body?: unknown;
814
820
  extraPermissions?: readonly Permission[];
821
+ reauthorize: () => Promise<void>;
815
822
  },
816
823
  ): Promise<Request> {
817
- return await siteServiceSessionRequest(
824
+ const request = await siteServiceSessionRequest(
818
825
  deps,
819
826
  host.scope,
820
827
  { id: host.site.id, versionId: host.versionId },
821
828
  { ...input, label: `${siteRuntimeViewerLabel(viewer)} on ${host.hostname}` },
822
829
  );
830
+ bindSiteSessionRequestAuthority(request, input.reauthorize);
831
+ if (viewer.kind === "member" && viewer.subjectId && viewer.sessionId) {
832
+ // The gateway already verified the host and opaque Site cookie. Native
833
+ // resolution reloads this person's live membership, roles and models.
834
+ // This is delegated ownership, never fabricated managed-cookie proof.
835
+ stampSiteViewerSessionAuthorization(request, {
836
+ organizationId: host.scope.accountId,
837
+ subjectId: viewer.subjectId,
838
+ permissions: [
839
+ ...new Set([...SITE_RUNTIME_AGENT_PERMISSIONS, ...(input.extraPermissions ?? [])]),
840
+ ],
841
+ workspaceId: host.scope.workspaceId,
842
+ });
843
+ }
844
+ return request;
823
845
  }
824
846
 
825
847
  /** A forwarded response without cookies (site-runtime never sets any). */
@@ -1,4 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
2
3
  import {
3
4
  normalizeSitesDomain,
4
5
  OPENGENI_API_CONTRACT_HEADER,
@@ -23,6 +24,7 @@ import {
23
24
  siteAgentClamp,
24
25
  siteViewerForGrant,
25
26
  type ApiRouteDeps,
27
+ type cancelPublishedSiteSession,
26
28
  } from "@opengeni/core";
27
29
  import {
28
30
  bumpSitePublicationGeneration,
@@ -38,7 +40,7 @@ import {
38
40
  readSiteRuntimeUsage,
39
41
  recordSitePublicationScan,
40
42
  getPublishedSite,
41
- listActiveSiteRuntimeConversations,
43
+ listSiteRuntimeCancellationCandidates,
42
44
  revokeSiteViewerSessions,
43
45
  SitePublishingError,
44
46
  SITE_RUNTIME_SITE_PRINCIPAL,
@@ -174,24 +176,20 @@ export const SITE_EFFECT_CONCURRENCY = 4;
174
176
  const SITE_EFFECT_QUEUE_LIMIT = 2_000;
175
177
 
176
178
  const effectsBySettings = new WeakMap<object, SitePublishingEffects>();
177
- const dispatchBySettings = new WeakMap<object, (request: Request) => Promise<Response>>();
179
+ type SiteCancellation = (
180
+ input: Parameters<typeof cancelPublishedSiteSession>[1],
181
+ ) => Promise<unknown>;
182
+ const cancellationBySettings = new WeakMap<object, SiteCancellation>();
178
183
 
179
184
  /**
180
- * The app's in-process dispatch, registered with the site-runtime routes, so
181
- * publication changes can cancel a Site's conversations through the ordinary
182
- * session routes. Without it (the operator script) cancellation is skipped.
185
+ * Internal host lifecycle adapter. This never grants the Site service bearer
186
+ * ordinary session access and never impersonates a conversation's viewer.
183
187
  */
184
- export function setSitePublishingDispatch(
188
+ export function setSitePublishingCancellation(
185
189
  settings: ApiRouteDeps["settings"],
186
- dispatch: (request: Request) => Promise<Response>,
190
+ cancel: SiteCancellation,
187
191
  ): void {
188
- dispatchBySettings.set(settings, dispatch);
189
- }
190
-
191
- export function sitePublishingDispatch(
192
- settings: ApiRouteDeps["settings"],
193
- ): ((request: Request) => Promise<Response>) | null {
194
- return dispatchBySettings.get(settings) ?? null;
192
+ cancellationBySettings.set(settings, cancel);
195
193
  }
196
194
 
197
195
  type SitePublishingEffectsOptions = {
@@ -643,26 +641,29 @@ export async function siteServiceSessionRequest(
643
641
  "x-opengeni-site-id": site.id,
644
642
  });
645
643
  if (site.versionId) headers.set("x-opengeni-site-version", site.versionId);
646
- if (input.body !== undefined) headers.set("content-type", "application/json");
644
+ const body = input.body === undefined ? undefined : JSON.stringify(input.body);
645
+ if (body !== undefined) {
646
+ headers.set("content-type", "application/json");
647
+ // Preserve the exact Request carrying verified-human dispatch proof.
648
+ headers.set("content-length", String(Buffer.byteLength(body)));
649
+ }
647
650
  return new Request(new URL(input.path, input.origin), {
648
651
  method: input.method,
649
652
  headers,
650
- ...(input.body === undefined ? {} : { body: JSON.stringify(input.body) }),
653
+ ...(body === undefined ? {} : { body }),
651
654
  });
652
655
  }
653
656
 
654
- /** Conversations cancelled per stop (newest activity first); the rest end on their own caps. */
655
- const STOP_CONVERSATIONS_LIMIT = 200;
657
+ /** Bound each database page, not the complete cancellation operation. */
658
+ const STOP_CONVERSATIONS_PAGE_SIZE = 200;
656
659
 
657
660
  /**
658
661
  * Stop a Site's viewers after a takedown, unpublish or narrower access:
659
662
  * revoke every site-session now, and after commit cancel the Site's agent
660
663
  * conversations that may still be working (in-flight turns and queued
661
- * messages), as the Site itself through the ordinary control route. The
662
- * gateway refuses new work at once regardless: every site-runtime call
663
- * re-checks the publication. Without an in-process dispatch (the operator
664
- * script) the cancellation is skipped and logged; per-conversation caps
665
- * still bound what running turns can spend.
664
+ * messages), through the native cancel-only lifecycle adapter. The original
665
+ * actor survives queued background execution. No transcript access is granted.
666
+ * Every page is drained, including old conversations; there is no age cutoff.
666
667
  */
667
668
  export async function stopSiteViewers(
668
669
  deps: SitePublishingDeps,
@@ -688,68 +689,55 @@ export async function stopSiteViewers(
688
689
  }
689
690
  }
690
691
  if (options.cancelConversations === false) return { revokedSessions };
691
- const dispatch = sitePublishingDispatch(deps.settings);
692
- if (!dispatch) {
693
- console.warn("[site-publishing] conversation cancel skipped: no in-process dispatch", {
692
+ const cancel = cancellationBySettings.get(deps.settings);
693
+ if (!cancel) {
694
+ console.warn("[site-publishing] conversation cancel unavailable: no lifecycle adapter", {
694
695
  siteId,
695
696
  });
696
697
  return { revokedSessions };
697
698
  }
698
699
  const effects = sitePublishingEffects(deps.settings);
699
- const origin = deps.settings.publicBaseUrl ?? "http://localhost";
700
- effects.schedule("cancel conversations", async () => {
701
- const sessionIds = await listActiveSiteRuntimeConversations(deps.db, scope, {
702
- siteId,
703
- limit: STOP_CONVERSATIONS_LIMIT,
704
- ...(options.toolsNarrowedTo ? { toolsNarrowedTo: options.toolsNarrowedTo } : {}),
705
- });
706
- if (sessionIds.length === 0) return;
707
- // The Site acts with its own origin (a Site and one of its versions).
708
- const versionId = (await getPublishedSite(deps.db, scope, siteId))?.currentVersionId ?? null;
709
- if (!versionId) {
710
- console.warn("[site-publishing] conversation cancel skipped: the Site has no version", {
711
- siteId,
712
- });
713
- return;
714
- }
715
- const operation = crypto.randomUUID();
716
- for (const sessionId of sessionIds) {
717
- try {
718
- const response = await dispatch(
719
- await siteServiceSessionRequest(
720
- deps,
721
- scope,
722
- { id: siteId, versionId },
723
- {
724
- origin,
725
- method: "POST",
726
- path: `/v1/workspaces/${scope.workspaceId}/sessions/${sessionId}/control`,
727
- body: {
728
- action: "cancel",
729
- clientEventId: `site-stop:${operation}:${sessionId}`.slice(0, 200),
730
- reason: options.reason.slice(0, 500),
731
- },
732
- label: "Published Site",
733
- },
734
- ),
735
- );
736
- if (!response.ok)
737
- console.warn("[site-publishing] could not cancel a Site conversation", {
738
- siteId,
739
- sessionId,
740
- status: response.status,
741
- body: (await response.text().catch(() => "")).slice(0, 300),
742
- });
743
- else await response.body?.cancel().catch(() => undefined);
744
- } catch (error) {
745
- console.warn("[site-publishing] could not cancel a Site conversation", {
700
+ const runInContext = AsyncLocalStorage.snapshot();
701
+ const operation = crypto.randomUUID();
702
+ const startedBefore = new Date();
703
+ const run = () =>
704
+ runInContext(async () => {
705
+ let after: string | undefined;
706
+ let failed = 0;
707
+ for (;;) {
708
+ const sessionIds = await listSiteRuntimeCancellationCandidates(deps.db, scope, {
746
709
  siteId,
747
- sessionId,
748
- error: error instanceof Error ? error.message : String(error),
710
+ limit: STOP_CONVERSATIONS_PAGE_SIZE,
711
+ startedBefore,
712
+ ...(after ? { after } : {}),
713
+ ...(options.toolsNarrowedTo ? { toolsNarrowedTo: options.toolsNarrowedTo } : {}),
749
714
  });
715
+ if (sessionIds.length === 0) break;
716
+ for (const sessionId of sessionIds) {
717
+ try {
718
+ await cancel({
719
+ ...scope,
720
+ siteId,
721
+ sessionId,
722
+ operationKey: `site-stop:${operation}:${sessionId}`,
723
+ reason: options.reason.slice(0, 500),
724
+ });
725
+ } catch (error) {
726
+ failed += 1;
727
+ console.warn("[site-publishing] could not cancel a Site conversation", {
728
+ siteId,
729
+ sessionId,
730
+ error: error instanceof Error ? error.message : String(error),
731
+ });
732
+ }
733
+ }
734
+ after = sessionIds.at(-1)!;
750
735
  }
751
- }
752
- });
736
+ if (failed > 0)
737
+ throw new Error(`Site lifecycle cancellation failed for ${failed} conversations`);
738
+ });
739
+ // A saturated best-effort effects queue must not silently drop cancellation.
740
+ if (!effects.schedule("cancel conversations", run)) await run();
753
741
  return { revokedSessions };
754
742
  }
755
743
 
@@ -0,0 +1,14 @@
1
+ /** Exact in-process request proof, installed only by the verified Site gateway. */
2
+ const authorities = new WeakMap<Request, () => Promise<void>>();
3
+
4
+ export function bindSiteSessionRequestAuthority(
5
+ request: Request,
6
+ reauthorize: () => Promise<void>,
7
+ ): void {
8
+ authorities.set(request, reauthorize);
9
+ }
10
+
11
+ /** Periodic SSE reauthorization must check the Site cookie, not only its delegate. */
12
+ export async function reauthorizeSiteSessionRequest(request: Request): Promise<void> {
13
+ await authorities.get(request)?.();
14
+ }