@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/dist/access/index.d.ts +13 -0
- package/dist/application/site-session-cancellation.d.ts +14 -0
- package/dist/domain/insights-usage.d.ts +24 -1
- package/dist/goal-admission.d.ts +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +242 -98
- package/dist/index.js.map +1 -1
- package/package.json +13 -13
- package/src/access/index.ts +100 -0
- package/src/application/site-session-cancellation.ts +47 -0
- package/src/domain/capabilities.ts +44 -16
- package/src/domain/file-owner.ts +1 -0
- package/src/domain/insights-usage.ts +20 -1
- package/src/domain/session-tool-policy.ts +3 -0
- package/src/index.ts +1 -0
- package/src/sandbox/fleet.ts +1 -0
- package/src/session-authorization.ts +113 -81
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/core",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
54
|
-
"@opengeni/codex": "3.
|
|
55
|
-
"@opengeni/config": "3.
|
|
56
|
-
"@opengeni/contracts": "3.
|
|
57
|
-
"@opengeni/db": "3.
|
|
58
|
-
"@opengeni/documents": "3.
|
|
59
|
-
"@opengeni/events": "3.
|
|
60
|
-
"@opengeni/network": "3.
|
|
61
|
-
"@opengeni/observability": "3.
|
|
62
|
-
"@opengeni/runtime": "3.
|
|
63
|
-
"@opengeni/storage": "3.
|
|
64
|
-
"@opengeni/vaults": "3.
|
|
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
|
},
|
package/src/access/index.ts
CHANGED
|
@@ -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
|
-
] =
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
)
|
|
118
|
-
|
|
119
|
-
|
|
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(
|
package/src/domain/file-owner.ts
CHANGED
|
@@ -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 {
|
|
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";
|
package/src/sandbox/fleet.ts
CHANGED
|
@@ -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
|
-
|
|
18
|
-
getSessionAuthorizationTargetProjection,
|
|
17
|
+
currentSiteViewerRlsContext,
|
|
19
18
|
getSession,
|
|
20
|
-
|
|
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
|
|
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
|
-
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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 =
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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<
|
|
513
|
-
|
|
560
|
+
): Promise<SessionAuthorizationFacts> {
|
|
561
|
+
return await reusableAuthorizationRead(
|
|
514
562
|
reads,
|
|
515
563
|
db,
|
|
516
|
-
[
|
|
517
|
-
|
|
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
|
-
|
|
527
|
-
|
|
583
|
+
|
|
584
|
+
function resolveSessionAuthorizationActor(
|
|
528
585
|
grant: AccessGrant,
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
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:
|