@opengeni/core 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/core",
3
- "version": "3.2.2",
3
+ "version": "3.3.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.3.0",
54
+ "@opengeni/codex": "3.3.0",
55
+ "@opengeni/config": "3.3.0",
56
+ "@opengeni/contracts": "3.3.0",
57
+ "@opengeni/db": "3.3.0",
58
+ "@opengeni/documents": "3.3.0",
59
+ "@opengeni/events": "3.3.0",
60
+ "@opengeni/network": "3.3.0",
61
+ "@opengeni/observability": "3.3.0",
62
+ "@opengeni/runtime": "3.3.0",
63
+ "@opengeni/storage": "3.3.0",
64
+ "@opengeni/vaults": "3.3.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
+ }
@@ -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;
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,6 +14,7 @@ import {
14
14
  import { AsyncLocalStorage } from "node:async_hooks";
15
15
  import {
16
16
  currentSessionRlsActorIdentityKey,
17
+ currentSiteViewerRlsContext,
17
18
  getSessionAuthorityProjection,
18
19
  getSessionAuthorizationTargetProjection,
19
20
  getSession,
@@ -237,6 +238,7 @@ export function sessionRlsActorForAuthorization(
237
238
  ? {
238
239
  subjectId: authorization.actor.subjectId,
239
240
  initiatingHumanSubjectId: authorization.actor.initiatingHumanSubjectId,
241
+ agentRootSessionId: authorization.actor.callerRootSessionId,
240
242
  }
241
243
  : { subjectId: authorization.actor.subjectId };
242
244
  }
@@ -312,6 +314,25 @@ export async function requireSessionAuthorization(
312
314
  throw new SessionAuthorizationDeniedError("not_found");
313
315
  }
314
316
 
317
+ // Also enforce the durable Site audience for hosts using scoped (owner-role)
318
+ // databases. The request-local viewer proof is never taken from Site JSON.
319
+ if (authority?.siteAccessRootId) {
320
+ const viewer = currentSiteViewerRlsContext();
321
+ const resolved = await resolveSessionAuthorizationActor(deps.db, grant, reads);
322
+ const actor = resolved.actor;
323
+ const allowed =
324
+ actor.kind === "agent_attempt"
325
+ ? (authority.siteOwnerSubjectId !== null &&
326
+ authority.siteOwnerSubjectId === actor.initiatingHumanSubjectId) ||
327
+ (actor.callerRootSessionId === authority.rootSessionId &&
328
+ authority.siteAccessRootId === authority.rootSessionId)
329
+ : authority.siteOwnerSubjectId === actor.subjectId ||
330
+ (actor.subjectId === `site:${authority.siteId}` &&
331
+ viewer?.siteId === authority.siteId &&
332
+ viewer.principal === authority.siteViewerPrincipal);
333
+ if (!allowed) throw new SessionAuthorizationDeniedError("not_found");
334
+ }
335
+
315
336
  // A request from a Site page (an HTTP frame request, or a tool the page
316
337
  // called) may act on a session only when that same Site, or for a frame
317
338
  // without a published Site the same mounted frame, started it. Anything