@opengeni/core 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/core",
3
- "version": "3.2.2",
3
+ "version": "3.4.0",
4
4
  "description": "Opengeni framework-agnostic core: the domain, access, and billing layers (neutral access, off-HTTP V2 surface). Behavior-preserving extraction from apps/api — keeps Hono's HTTPException for error throwing (typed-errors cleanup deferred).",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -50,18 +50,18 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@modelcontextprotocol/sdk": "^1.29.0",
53
- "@opengeni/capabilities": "3.2.2",
54
- "@opengeni/codex": "3.2.2",
55
- "@opengeni/config": "3.2.2",
56
- "@opengeni/contracts": "3.2.2",
57
- "@opengeni/db": "3.2.2",
58
- "@opengeni/documents": "3.2.2",
59
- "@opengeni/events": "3.2.2",
60
- "@opengeni/network": "3.2.2",
61
- "@opengeni/observability": "3.2.2",
62
- "@opengeni/runtime": "3.2.2",
63
- "@opengeni/storage": "3.2.2",
64
- "@opengeni/vaults": "3.2.2",
53
+ "@opengeni/capabilities": "3.4.0",
54
+ "@opengeni/codex": "3.4.0",
55
+ "@opengeni/config": "3.4.0",
56
+ "@opengeni/contracts": "3.4.0",
57
+ "@opengeni/db": "3.4.0",
58
+ "@opengeni/documents": "3.4.0",
59
+ "@opengeni/events": "3.4.0",
60
+ "@opengeni/network": "3.4.0",
61
+ "@opengeni/observability": "3.4.0",
62
+ "@opengeni/runtime": "3.4.0",
63
+ "@opengeni/storage": "3.4.0",
64
+ "@opengeni/vaults": "3.4.0",
65
65
  "hono": "^4.12.18",
66
66
  "zod": "^4.2.1"
67
67
  },
@@ -37,6 +37,7 @@ import {
37
37
  listWorkspacesForSubject,
38
38
  findActiveApiKeyByHash,
39
39
  getWorkspaceGrant,
40
+ subjectHasLiveWorkspaceAuthorityInScope,
40
41
  requireWorkspace,
41
42
  resolveNamedManagedPersonalWorkspaceGrant,
42
43
  type Database,
@@ -216,6 +217,7 @@ const delegatedHumanAuthorizationSchema = z.object({
216
217
 
217
218
  const delegatedHumanRequests = new WeakMap<Request, DelegatedHumanAuthorization>();
218
219
  const delegatedHumanContexts = new WeakMap<AccessContext, DelegatedHumanAuthorization>();
220
+ const verifiedSiteViewerProofs = new WeakSet<DelegatedHumanAuthorization>();
219
221
  const delegatedHumanProfileSchema = z.object({
220
222
  id: z.string(),
221
223
  name: z.string().nullable(),
@@ -283,6 +285,48 @@ export function stampDelegatedHumanAuthorization(
283
285
  delegatedHumanRequests.set(request, Object.freeze(proof));
284
286
  }
285
287
 
288
+ /**
289
+ * Verified published-Site dispatch only: the caller has verified the signed
290
+ * gateway, served host and opaque viewer cookie. This is exact-workspace
291
+ * ownership for session routes, not a console login or a generic Site bearer.
292
+ * Native people use the existing delegated-human resolver; external owning
293
+ * users reload their existing membership without provisioning a new identity.
294
+ */
295
+ export function stampSiteViewerSessionAuthorization(
296
+ request: Request,
297
+ input: {
298
+ organizationId: string;
299
+ workspaceId: string;
300
+ subjectId: string;
301
+ permissions: Permission[];
302
+ },
303
+ ): void {
304
+ if (input.subjectId.startsWith("user:")) {
305
+ stampDelegatedHumanAuthorization(request, {
306
+ ...input,
307
+ workspaceScope: { kind: "selected", workspaceIds: [input.workspaceId] },
308
+ });
309
+ return;
310
+ }
311
+ if (delegatedHumanRequests.has(request) || accessResolvedRequests.has(request))
312
+ throw new HTTPException(403, { message: "request access authorization already resolved" });
313
+ const parsed = delegatedHumanAuthorizationSchema
314
+ .extend({
315
+ subjectId: z
316
+ .string()
317
+ .max(1024)
318
+ .regex(/^external_user:[^\s\u0000-\u001f\u007f]+$/),
319
+ })
320
+ .parse({ ...input, workspaceScope: { kind: "selected", workspaceIds: [input.workspaceId] } });
321
+ if (parsed.workspaceScope.kind !== "selected") throw new Error("Site workspace scope required");
322
+ Object.freeze(parsed.permissions);
323
+ Object.freeze(parsed.workspaceScope.workspaceIds);
324
+ Object.freeze(parsed.workspaceScope);
325
+ const proof = Object.freeze(parsed);
326
+ verifiedSiteViewerProofs.add(proof);
327
+ delegatedHumanRequests.set(request, proof);
328
+ }
329
+
286
330
  /** Token prefix of personal API keys, which act as the person who made them. */
287
331
  export const PERSONAL_API_KEY_TOKEN_PREFIX = "ogp_";
288
332
 
@@ -1521,6 +1565,62 @@ async function resolveDelegatedHumanAccessContext(
1521
1565
  message: "delegated humans require native organization access",
1522
1566
  });
1523
1567
  }
1568
+ if (verifiedSiteViewerProofs.has(proof)) {
1569
+ const workspaceId =
1570
+ proof.workspaceScope.kind === "selected" && proof.workspaceScope.workspaceIds.length === 1
1571
+ ? proof.workspaceScope.workspaceIds[0]
1572
+ : null;
1573
+ if (!workspaceId)
1574
+ throw new HTTPException(403, { message: "Site viewer workspace unavailable" });
1575
+ const grant = await withWorkspaceSubjectRls(
1576
+ deps.db,
1577
+ workspaceId,
1578
+ proof.subjectId,
1579
+ async (tx) => {
1580
+ if (
1581
+ !(await subjectHasLiveWorkspaceAuthorityInScope(tx, {
1582
+ accountId: proof.organizationId,
1583
+ workspaceId,
1584
+ subjectId: proof.subjectId,
1585
+ }))
1586
+ )
1587
+ return null;
1588
+ return getWorkspaceGrant(tx, proof.subjectId, workspaceId, {
1589
+ principalKind: "human_session",
1590
+ });
1591
+ },
1592
+ );
1593
+ if (!grant || grant.accountId !== proof.organizationId)
1594
+ throw new HTTPException(403, { message: "Site viewer membership unavailable" });
1595
+ const bounded = Object.freeze({
1596
+ ...grant,
1597
+ permissionMode: "explicit" as const,
1598
+ permissions: Object.freeze(
1599
+ explicitPermissions(
1600
+ Permission.options.filter(
1601
+ (permission) =>
1602
+ hasPermission(grant.permissions, permission) &&
1603
+ hasPermission(proof.permissions, permission, "explicit"),
1604
+ ),
1605
+ ),
1606
+ ) as Permission[],
1607
+ });
1608
+ const accountGrant = Object.freeze({
1609
+ accountId: proof.organizationId,
1610
+ subjectId: proof.subjectId,
1611
+ permissions: Object.freeze([] as Permission[]) as Permission[],
1612
+ });
1613
+ const context: AccessContext = Object.freeze({
1614
+ mode: "managed",
1615
+ subjectId: proof.subjectId,
1616
+ accountGrants: Object.freeze([accountGrant]) as AccountGrant[],
1617
+ workspaceGrants: Object.freeze([bounded]) as AccessGrant[],
1618
+ defaultAccountId: proof.organizationId,
1619
+ defaultWorkspaceId: workspaceId,
1620
+ });
1621
+ delegatedHumanContexts.set(context, proof);
1622
+ return context;
1623
+ }
1524
1624
  const userId = proof.subjectId.slice("user:".length);
1525
1625
  const profiles = await getManagedUserProfilesByIds(deps.db, [userId]);
1526
1626
  const parsedProfile = delegatedHumanProfileSchema.safeParse(
@@ -0,0 +1,47 @@
1
+ import { cancelSiteRuntimeConversation, type Database } from "@opengeni/db";
2
+ import {
3
+ publishDurableSessionEvents,
4
+ publishDurableWorkspaceControlEvent,
5
+ type EventBus,
6
+ } from "@opengeni/events";
7
+ import type { SessionWorkflowClient } from "../dependencies";
8
+
9
+ /** Internal host lifecycle, not a session-read/control API or a viewer grant. */
10
+ export async function cancelPublishedSiteSession(
11
+ deps: {
12
+ db: Database;
13
+ bus: EventBus;
14
+ workflowClient: Pick<SessionWorkflowClient, "requestSessionWorkflowWakeDispatch">;
15
+ },
16
+ input: Parameters<typeof cancelSiteRuntimeConversation>[1],
17
+ ) {
18
+ const result = await cancelSiteRuntimeConversation(deps.db, input);
19
+ if (!result) return null;
20
+ // Capture only this command's event envelopes under its transaction witness.
21
+ // Never re-open private transcript visibility to do post-commit fanout.
22
+ const bySession = new Map<string, typeof result.events>();
23
+ for (const event of result.events) {
24
+ const group = bySession.get(event.sessionId) ?? [];
25
+ group.push(event);
26
+ bySession.set(event.sessionId, group);
27
+ }
28
+ const effects = await Promise.allSettled([
29
+ ...[...bySession].map(([sessionId, events]) =>
30
+ publishDurableSessionEvents(deps.bus, input.workspaceId, sessionId, events),
31
+ ),
32
+ ...(result.workspaceEvent
33
+ ? [publishDurableWorkspaceControlEvent(deps.bus, input.workspaceId, result.workspaceEvent)]
34
+ : []),
35
+ ...(result.wakeCount > 0 ? [deps.workflowClient.requestSessionWorkflowWakeDispatch()] : []),
36
+ ]);
37
+ if (effects.some((effect) => effect.status === "rejected"))
38
+ console.warn(
39
+ "[site-lifecycle] post-commit fanout failed; durable events and wake outbox retained",
40
+ );
41
+ return {
42
+ receiptId: result.receiptId,
43
+ replay: result.replay,
44
+ cancelledSessionCount: result.cancelledSessionCount,
45
+ cancelledTurnCount: result.cancelledTurnCount,
46
+ };
47
+ }
@@ -43,6 +43,7 @@ import {
43
43
  getCapabilityCatalogItem,
44
44
  getCapabilityInstallation,
45
45
  getConnectionMetadata,
46
+ isTransactionHandle,
46
47
  readCodexCutoverDisposition,
47
48
  resolveSubscriptionCoreCodexAppsDesignation,
48
49
  rlsContextForWorkspace,
@@ -93,6 +94,28 @@ export async function buildCapabilityCatalog(input: {
93
94
  /** The workspace's organization, when the caller knows it (skips a lookup). */
94
95
  accountId?: string;
95
96
  }): Promise<CapabilityCatalogResponse> {
97
+ // Scoped readers use savepoints and transaction-local actor settings. They
98
+ // must not overlap on a caller's transaction: one reader can otherwise
99
+ // restore another reader's subject instead of the enclosing caller's.
100
+ // Independent pool reads keep their existing parallel execution.
101
+ const serial = isTransactionHandle(input.db);
102
+ const reads = [
103
+ () => listCapabilityCatalogItems(input.db, input.workspaceId),
104
+ () => listCapabilityInstallations(input.db, input.workspaceId),
105
+ () => listSocialConnections(input.db, input.workspaceId, 500, input.subjectId),
106
+ () => listConnectionsMetadata(input.db, input.workspaceId, null),
107
+ () => discoverCuratedSkillLibraryItems(),
108
+ () => listInstalledSkills(input.db, input.workspaceId),
109
+ () =>
110
+ input.settings.codexConnectedAppsEnabled
111
+ ? resolveCodexAppsDesignationForRun(
112
+ input.db,
113
+ input.workspaceId,
114
+ input.accountId ? { accountId: input.accountId } : {},
115
+ )
116
+ : Promise.resolve(null),
117
+ () => listEnabledMcpCapabilityServers(input.db, input.workspaceId),
118
+ ] as const;
96
119
  const [
97
120
  persistedItems,
98
121
  capabilityInstallations,
@@ -102,22 +125,27 @@ export async function buildCapabilityCatalog(input: {
102
125
  installedSkills,
103
126
  codexAppsDesignation,
104
127
  runnableMcpServers,
105
- ] = await Promise.all([
106
- listCapabilityCatalogItems(input.db, input.workspaceId),
107
- listCapabilityInstallations(input.db, input.workspaceId),
108
- listSocialConnections(input.db, input.workspaceId, 500, input.subjectId),
109
- listConnectionsMetadata(input.db, input.workspaceId, null),
110
- discoverCuratedSkillLibraryItems(),
111
- listInstalledSkills(input.db, input.workspaceId),
112
- input.settings.codexConnectedAppsEnabled
113
- ? resolveCodexAppsDesignationForRun(
114
- input.db,
115
- input.workspaceId,
116
- input.accountId ? { accountId: input.accountId } : {},
117
- )
118
- : Promise.resolve(null),
119
- listEnabledMcpCapabilityServers(input.db, input.workspaceId),
120
- ]);
128
+ ] = serial
129
+ ? ([
130
+ await reads[0](),
131
+ await reads[1](),
132
+ await reads[2](),
133
+ await reads[3](),
134
+ await reads[4](),
135
+ await reads[5](),
136
+ await reads[6](),
137
+ await reads[7](),
138
+ ] as const)
139
+ : await Promise.all([
140
+ reads[0](),
141
+ reads[1](),
142
+ reads[2](),
143
+ reads[3](),
144
+ reads[4](),
145
+ reads[5](),
146
+ reads[6](),
147
+ reads[7](),
148
+ ]);
121
149
  const runnableCapabilityIds = new Set(runnableMcpServers.map((server) => server.capabilityId));
122
150
  const operatorOAuthClientConfigured = operatorOAuthClientResolver(input.settings);
123
151
  const catalogInstallations = capabilityInstallations.filter(
@@ -62,6 +62,7 @@ export async function fileOwnerContextForAgent(
62
62
  },
63
63
  subjectId: grant.subjectId,
64
64
  initiatingHumanSubjectId: attempt.initiatingHumanSubjectId,
65
+ agentRootSessionId: attempt.callerRootSessionId,
65
66
  privateFileOwnerSubjectId: snapshot.defaultScope === "personal" ? snapshot.subjectId : null,
66
67
  };
67
68
  }
@@ -4,8 +4,27 @@ import type {
4
4
  InsightsUsageQuery,
5
5
  InsightsUsageRange,
6
6
  InsightsUsageResponse,
7
+ InsightsSiteUsageQuery,
7
8
  } from "@opengeni/contracts/insights-usage";
8
- import { readInsightsCalls, readInsightsUsage, type Database } from "@opengeni/db";
9
+ import {
10
+ readInsightsCalls,
11
+ readInsightsUsage,
12
+ readInsightsSiteUsage,
13
+ type Database,
14
+ } from "@opengeni/db";
15
+
16
+ export async function getInsightsSiteUsage(
17
+ db: Database,
18
+ input: {
19
+ accountId: string;
20
+ workspaceId: string;
21
+ query: InsightsSiteUsageQuery;
22
+ viewerSubjectId: string | null;
23
+ now?: Date;
24
+ },
25
+ ) {
26
+ return readInsightsSiteUsage(db, { ...input, now: input.now ?? new Date() });
27
+ }
9
28
 
10
29
  export type InsightsQueryScope = {
11
30
  accountId: string;
@@ -589,6 +589,9 @@ export function sessionEffectiveToolProjectionInput(
589
589
  "request_human_input",
590
590
  "list_models",
591
591
  ...webSearchPlan.providerTools,
592
+ // Interaction entry tools stay visible even with progressive disclosure;
593
+ // the remaining authorized family tools are disclosed after opening.
594
+ ...firstPartyMcpTools.filter((name) => name === "browser_open" || name === "computer_open"),
592
595
  ...(toolRefs.some((ref) => ref.id === "opengeni" && ref.eager === true) ||
593
596
  !progressiveDisclosure
594
597
  ? firstPartyMcpTools.filter((name) => !interactionNames.has(name) || !progressiveDisclosure)
package/src/index.ts CHANGED
@@ -149,6 +149,7 @@ export * from "./domain/organization-membership-lifecycle";
149
149
  export * from "./application/new-session-drafts";
150
150
  export * from "./application/composer-submit";
151
151
  export * from "./application/session-commands";
152
+ export * from "./application/site-session-cancellation";
152
153
  export * from "./application/archived-session-imports";
153
154
  export * from "./application/session-tenancy";
154
155
  export * from "./application/organization-privacy";
@@ -985,6 +985,7 @@ function runOnOperationAdmission(
985
985
  operationResourcePolicySupported: enrollment.agentCapabilities.operationResourcePolicy === true,
986
986
  operationCpuQuotaSupported: enrollment.agentCapabilities.operationCpuQuota === true,
987
987
  transactionalFsWriteSupported: enrollment.agentCapabilities.transactionalFsWrite === true,
988
+ browserControlHttpSupported: enrollment.agentCapabilities.browserControlHttp === true,
988
989
  };
989
990
  }
990
991
 
@@ -14,13 +14,12 @@ import {
14
14
  import { AsyncLocalStorage } from "node:async_hooks";
15
15
  import {
16
16
  currentSessionRlsActorIdentityKey,
17
- getSessionAuthorityProjection,
18
- getSessionAuthorizationTargetProjection,
17
+ currentSiteViewerRlsContext,
19
18
  getSession,
20
- getSessionTurnForAttempt,
21
- getSlackInteractionSessionAccessForSession,
19
+ getSessionAuthorizationFacts,
22
20
  withSessionRlsActorContext,
23
21
  type Database,
22
+ type SessionAuthorizationFacts,
24
23
  type SessionRlsActorContext,
25
24
  } from "@opengeni/db";
26
25
  import type { AppDependencies } from "./dependencies";
@@ -199,10 +198,6 @@ export function agentAccessListScopeForViewer(
199
198
  };
200
199
  }
201
200
 
202
- type ResolvedSessionAuthorizationTarget = {
203
- target: SessionAuthorizationTarget;
204
- };
205
-
206
201
  /**
207
202
  * Whether a grant acts with live agent-attempt authority: an explicit
208
203
  * `agent_attempt` principal kind, or (for legacy tokens without one) any
@@ -237,6 +232,7 @@ export function sessionRlsActorForAuthorization(
237
232
  ? {
238
233
  subjectId: authorization.actor.subjectId,
239
234
  initiatingHumanSubjectId: authorization.actor.initiatingHumanSubjectId,
235
+ agentRootSessionId: authorization.actor.callerRootSessionId,
240
236
  }
241
237
  : { subjectId: authorization.actor.subjectId };
242
238
  }
@@ -259,7 +255,11 @@ export async function requireLiveAgentAttemptAuthorization(
259
255
  callerSessionId: string,
260
256
  ): Promise<Extract<SessionAuthorizationActor, { kind: "agent_attempt" }>> {
261
257
  const reads = claimSessionAuthorizationReads();
262
- const { actor } = await resolveSessionAuthorizationActor(db, grant, reads);
258
+ const claims = agentAttemptClaims(grant);
259
+ const facts = claims
260
+ ? await readSessionAuthorizationFacts(db, grant, claims.callerSessionId, claims, reads)
261
+ : null;
262
+ const { actor } = resolveSessionAuthorizationActor(grant, claims, facts);
263
263
  if (actor.kind !== "agent_attempt" || actor.callerSessionId !== callerSessionId) {
264
264
  throw new SessionAuthorizationDeniedError("caller_stale");
265
265
  }
@@ -289,18 +289,13 @@ export async function requireSessionAuthorization(
289
289
  const reads = claimSessionAuthorizationReads();
290
290
  const port = deps.sessionAuthorization;
291
291
  const isAgentAttempt = grantHasAgentAttemptAuthority(grant);
292
- // Callers can supply a transaction handle. Both reads open nested RLS
293
- // savepoints, which must not race on the same PostgreSQL connection.
294
- const slackAccess = await getSlackInteractionSessionAccessForSession(deps.db, {
295
- accountId: grant.accountId,
296
- workspaceId: grant.workspaceId,
297
- sessionId: input.sessionId,
298
- });
299
- const authority = await getSessionAuthorityProjection(
300
- deps.db,
301
- grant.workspaceId,
302
- input.sessionId,
303
- );
292
+ const claims = agentAttemptClaims(grant);
293
+ // One scoped read returns the target's authority, its Slack ownership and,
294
+ // for an agent attempt, the caller session and its live turn. Every check
295
+ // below decides from these facts; none of them reads again.
296
+ const facts = await readSessionAuthorizationFacts(deps.db, grant, input.sessionId, claims, reads);
297
+ const authority = facts.target;
298
+ const slackAccess = authority?.slackAccess ?? null;
304
299
 
305
300
  // The projection is read under the caller's own RLS context, so a missing
306
301
  // session and one the caller may not see (another member's private session)
@@ -312,6 +307,24 @@ export async function requireSessionAuthorization(
312
307
  throw new SessionAuthorizationDeniedError("not_found");
313
308
  }
314
309
 
310
+ // Also enforce the durable Site audience for hosts using scoped (owner-role)
311
+ // databases. The request-local viewer proof is never taken from Site JSON.
312
+ if (authority?.siteAccessRootId) {
313
+ const viewer = currentSiteViewerRlsContext();
314
+ const actor = resolveSessionAuthorizationActor(grant, claims, facts).actor;
315
+ const allowed =
316
+ actor.kind === "agent_attempt"
317
+ ? (authority.siteOwnerSubjectId !== null &&
318
+ authority.siteOwnerSubjectId === actor.initiatingHumanSubjectId) ||
319
+ (actor.callerRootSessionId === authority.rootSessionId &&
320
+ authority.siteAccessRootId === authority.rootSessionId)
321
+ : authority.siteOwnerSubjectId === actor.subjectId ||
322
+ (actor.subjectId === `site:${authority.siteId}` &&
323
+ viewer?.siteId === authority.siteId &&
324
+ viewer.principal === authority.siteViewerPrincipal);
325
+ if (!allowed) throw new SessionAuthorizationDeniedError("not_found");
326
+ }
327
+
315
328
  // A request from a Site page (an HTTP frame request, or a tool the page
316
329
  // called) may act on a session only when that same Site, or for a frame
317
330
  // without a published Site the same mounted frame, started it. Anything
@@ -335,15 +348,15 @@ export async function requireSessionAuthorization(
335
348
  }
336
349
  if (!authority) throw new SessionAuthorizationDeniedError("not_found");
337
350
 
338
- const resolvedActor = await resolveSessionAuthorizationActor(deps.db, grant, reads);
339
- const resolvedTarget = await resolveSessionAuthorizationTarget(
340
- deps.db,
341
- grant,
342
- input.sessionId,
343
- reads,
344
- );
351
+ const resolvedActor = resolveSessionAuthorizationActor(grant, claims, facts);
352
+ if (authority.accountId !== grant.accountId) {
353
+ throw new SessionAuthorizationDeniedError("not_found");
354
+ }
345
355
  const actor = resolvedActor.actor;
346
- const target = resolvedTarget.target;
356
+ const target: SessionAuthorizationTarget = {
357
+ sessionId: authority.sessionId,
358
+ rootSessionId: authority.rootSessionId,
359
+ };
347
360
  const agentRelatedSessionAccess =
348
361
  actor.kind === "agent_attempt"
349
362
  ? relatedSessionAccessForAgentAttempt(actor, target.sessionId)
@@ -462,7 +475,11 @@ export async function requireSessionAuthorizationListScope(
462
475
  const port = deps.sessionAuthorization;
463
476
  const isAgentAttempt = grantHasAgentAttemptAuthority(grant);
464
477
  if (!port && !isAgentAttempt) return null;
465
- const { actor, callerAccess } = await resolveSessionAuthorizationActor(deps.db, grant, reads);
478
+ const claims = agentAttemptClaims(grant);
479
+ const facts = claims
480
+ ? await readSessionAuthorizationFacts(deps.db, grant, claims.callerSessionId, claims, reads)
481
+ : null;
482
+ const { actor, callerAccess } = resolveSessionAuthorizationActor(grant, claims, facts);
466
483
  // Standalone agents retain compact workspace discovery only while the signed
467
484
  // caller attempt is still the exact live attempt, and only within their own
468
485
  // declared agent-access reach (migration 0427). The viewer is derived from
@@ -504,36 +521,72 @@ export async function requireSessionAuthorizationListScope(
504
521
  return viewer ? agentAccessListScopeForViewer(viewer, hostScope) : hostScope;
505
522
  }
506
523
 
507
- async function resolveSessionAuthorizationTarget(
524
+ type AgentAttemptClaims = {
525
+ callerSessionId: string;
526
+ turnId: string;
527
+ attemptId: string;
528
+ executionGeneration: number;
529
+ };
530
+
531
+ /**
532
+ * The signed attempt claims of an agent-attempt grant, or null for any other
533
+ * principal and for malformed claims (which the actor check then refuses).
534
+ */
535
+ function agentAttemptClaims(grant: AccessGrant): AgentAttemptClaims | null {
536
+ if (!grantHasAgentAttemptAuthority(grant)) return null;
537
+ const callerSessionId = grant.metadata?.["sessionId"];
538
+ const turnId = grant.metadata?.["turnId"];
539
+ const attemptId = grant.metadata?.["attemptId"];
540
+ const executionGeneration = grant.metadata?.["executionGeneration"];
541
+ if (
542
+ typeof callerSessionId !== "string" ||
543
+ typeof turnId !== "string" ||
544
+ typeof attemptId !== "string" ||
545
+ typeof executionGeneration !== "number" ||
546
+ !Number.isSafeInteger(executionGeneration) ||
547
+ executionGeneration < 1
548
+ ) {
549
+ return null;
550
+ }
551
+ return { callerSessionId, turnId, attemptId, executionGeneration };
552
+ }
553
+
554
+ async function readSessionAuthorizationFacts(
508
555
  db: Database,
509
556
  grant: AccessGrant,
510
557
  sessionId: string,
558
+ claims: AgentAttemptClaims | null,
511
559
  reads: SessionAuthorizationReads | null,
512
- ): Promise<ResolvedSessionAuthorizationTarget> {
513
- const session = await reusableAuthorizationRead(
560
+ ): Promise<SessionAuthorizationFacts> {
561
+ return await reusableAuthorizationRead(
514
562
  reads,
515
563
  db,
516
- ["target-identity", grant.workspaceId, sessionId],
517
- () => getSessionAuthorizationTargetProjection(db, grant.workspaceId, sessionId),
564
+ [
565
+ "authorization-facts",
566
+ grant.accountId,
567
+ grant.workspaceId,
568
+ sessionId,
569
+ claims?.callerSessionId ?? "",
570
+ claims?.attemptId ?? "",
571
+ ],
572
+ () =>
573
+ getSessionAuthorizationFacts(db, {
574
+ accountId: grant.accountId,
575
+ workspaceId: grant.workspaceId,
576
+ sessionId,
577
+ caller: claims
578
+ ? { sessionId: claims.callerSessionId, attemptId: claims.attemptId }
579
+ : undefined,
580
+ }),
518
581
  );
519
- if (!session || session.accountId !== grant.accountId) {
520
- throw new SessionAuthorizationDeniedError("not_found");
521
- }
522
- return {
523
- target: { sessionId: session.id, rootSessionId: session.rootSessionId },
524
- };
525
582
  }
526
- async function resolveSessionAuthorizationActor(
527
- db: Database,
583
+
584
+ function resolveSessionAuthorizationActor(
528
585
  grant: AccessGrant,
529
- reads: SessionAuthorizationReads | null,
530
- ): Promise<ResolvedSessionAuthorizationActor> {
531
- const callerSessionId = grant.metadata?.["sessionId"];
532
- const turnId = grant.metadata?.["turnId"];
533
- const attemptId = grant.metadata?.["attemptId"];
534
- const executionGeneration = grant.metadata?.["executionGeneration"];
535
- const isAgentAttempt = grantHasAgentAttemptAuthority(grant);
536
- if (!isAgentAttempt) {
586
+ claims: AgentAttemptClaims | null,
587
+ facts: SessionAuthorizationFacts | null,
588
+ ): ResolvedSessionAuthorizationActor {
589
+ if (!grantHasAgentAttemptAuthority(grant)) {
537
590
  return {
538
591
  actor: SessionAuthorizationActor.parse({
539
592
  kind: "subject",
@@ -544,36 +597,15 @@ async function resolveSessionAuthorizationActor(
544
597
  callerAccess: null,
545
598
  };
546
599
  }
600
+ const callerSession = facts?.caller?.session ?? null;
601
+ const turn = facts?.caller?.turn ?? null;
547
602
  if (
548
- typeof callerSessionId !== "string" ||
549
- typeof turnId !== "string" ||
550
- typeof attemptId !== "string" ||
551
- typeof executionGeneration !== "number" ||
552
- !Number.isSafeInteger(executionGeneration) ||
553
- executionGeneration < 1
554
- ) {
555
- throw new SessionAuthorizationDeniedError("caller_stale");
556
- }
557
- // These scoped reads can also open nested savepoints on a transaction handle.
558
- const callerSession = await reusableAuthorizationRead(
559
- reads,
560
- db,
561
- ["session", grant.workspaceId, callerSessionId],
562
- () => getSession(db, grant.workspaceId, callerSessionId),
563
- );
564
- const turn = await reusableAuthorizationRead(
565
- reads,
566
- db,
567
- ["turn", grant.workspaceId, callerSessionId, attemptId],
568
- () => getSessionTurnForAttempt(db, grant.workspaceId, callerSessionId, attemptId),
569
- );
570
- if (
603
+ !claims ||
571
604
  !callerSession ||
572
605
  callerSession.accountId !== grant.accountId ||
573
606
  !turn ||
574
- turn.id !== turnId ||
575
- turn.executionGeneration !== executionGeneration ||
576
- callerSession.activeTurnId !== turn.id
607
+ turn.id !== claims.turnId ||
608
+ turn.executionGeneration !== claims.executionGeneration
577
609
  ) {
578
610
  throw new SessionAuthorizationDeniedError("caller_stale");
579
611
  }
@@ -581,11 +613,11 @@ async function resolveSessionAuthorizationActor(
581
613
  actor: SessionAuthorizationActor.parse({
582
614
  kind: "agent_attempt",
583
615
  subjectId: grant.subjectId,
584
- callerSessionId,
616
+ callerSessionId: claims.callerSessionId,
585
617
  callerRootSessionId: callerSession.rootSessionId,
586
- turnId,
587
- attemptId,
588
- executionGeneration,
618
+ turnId: claims.turnId,
619
+ attemptId: claims.attemptId,
620
+ executionGeneration: claims.executionGeneration,
589
621
  initiator: turn.initiator,
590
622
  initiatorContext: turn.initiatorContext,
591
623
  initiatingHumanSubjectId: