@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.
- package/dist/access-grant-rls.d.ts +15 -0
- package/dist/app.js +1 -1
- package/dist/billing-stripe-customer.d.ts +18 -0
- package/dist/billing-subscription-store.d.ts +34 -0
- package/dist/billing-subscriptions.d.ts +64 -0
- package/dist/{chunk-PQBHZ2YH.js → chunk-6DZ4H5JQ.js} +3133 -1711
- package/dist/{chunk-PQBHZ2YH.js.map → chunk-6DZ4H5JQ.js.map} +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/integrations/marketplace-plugin.d.ts +43 -43
- package/dist/mcp/server.d.ts +11 -1
- package/dist/routes/browser-sessions.d.ts +23 -2
- package/dist/routes/sessions.d.ts +1 -0
- package/dist/sandbox/channel-a.d.ts +10 -1
- package/dist/site-publishing/agent-data-tools.d.ts +5 -5
- package/dist/site-publishing/agent-session.d.ts +3 -2
- package/dist/site-publishing/runtime.d.ts +7 -6
- package/dist/site-publishing/service.d.ts +8 -11
- package/dist/site-publishing/session-request-authority.d.ts +3 -0
- package/package.json +24 -24
- package/src/access-grant-rls.ts +99 -10
- package/src/app.ts +105 -43
- package/src/billing-stripe-customer.ts +40 -0
- package/src/billing-subscription-store.ts +121 -0
- package/src/billing-subscriptions.ts +759 -0
- package/src/http/sse.ts +5 -1
- package/src/index.ts +3 -0
- package/src/mcp/action-catalog.gen.ts +113 -0
- package/src/mcp/action-schema-imports.gen.ts +5 -1
- package/src/mcp/server.ts +28 -0
- package/src/routes/billing.ts +161 -21
- package/src/routes/browser-sessions.ts +420 -26
- package/src/routes/computer-sessions.ts +102 -39
- package/src/routes/insights-usage.ts +22 -0
- package/src/routes/organization-memberships.ts +12 -0
- package/src/routes/sessions.ts +30 -5
- package/src/routes/site-runtime.ts +62 -20
- package/src/sandbox/channel-a.ts +18 -1
- package/src/sandbox/metrics-ingestion.ts +1 -0
- package/src/site-frame-guard.ts +11 -2
- package/src/site-publishing/agent-data-tools.ts +16 -8
- package/src/site-publishing/agent-session.ts +24 -6
- package/src/site-publishing/runtime.ts +29 -7
- package/src/site-publishing/service.ts +65 -77
- package/src/site-publishing/session-request-authority.ts +14 -0
package/src/site-frame-guard.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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(
|
|
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 {
|
|
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 =
|
|
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 (
|
|
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:
|
|
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 {
|
|
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
|
|
63
|
-
*
|
|
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
|
|
800
|
-
* principal
|
|
801
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
+
type SiteCancellation = (
|
|
180
|
+
input: Parameters<typeof cancelPublishedSiteSession>[1],
|
|
181
|
+
) => Promise<unknown>;
|
|
182
|
+
const cancellationBySettings = new WeakMap<object, SiteCancellation>();
|
|
178
183
|
|
|
179
184
|
/**
|
|
180
|
-
*
|
|
181
|
-
*
|
|
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
|
|
188
|
+
export function setSitePublishingCancellation(
|
|
185
189
|
settings: ApiRouteDeps["settings"],
|
|
186
|
-
|
|
190
|
+
cancel: SiteCancellation,
|
|
187
191
|
): void {
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
653
|
+
...(body === undefined ? {} : { body }),
|
|
651
654
|
});
|
|
652
655
|
}
|
|
653
656
|
|
|
654
|
-
/**
|
|
655
|
-
const
|
|
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),
|
|
662
|
-
*
|
|
663
|
-
*
|
|
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
|
|
692
|
-
if (!
|
|
693
|
-
console.warn("[site-publishing] conversation cancel
|
|
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
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
-
|
|
748
|
-
|
|
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
|
+
}
|