@opengeni/api-router 3.2.2 → 3.3.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.
@@ -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
+ }