@opengeni/core 0.4.10 → 0.8.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/src/index.ts CHANGED
@@ -44,6 +44,7 @@ export * from "./sandbox/routing";
44
44
 
45
45
  // Access layer (transport-neutral grant resolution + permission checks).
46
46
  export * from "./access";
47
+ export * from "./session-authorization";
47
48
 
48
49
  // Billing / usage-limit admission (checkLimit / requireLimit / recordWorkspaceUsage).
49
50
  export * from "./billing/limits";
@@ -409,7 +409,7 @@ export type RunOnResult = {
409
409
 
410
410
  /**
411
411
  * Run a ONE-OFF op against a SPECIFIC target WITHOUT changing the active pointer
412
- * (the dossier `run_on`). Only selfhosted targets are routable as a one-off here
412
+ * (the design `run_on`). Only selfhosted targets are routable as a one-off here
413
413
  * (a Modal target is the session's group box, reached via the normal Channel-A /
414
414
  * turn path — `run_on` is for reaching a NON-active enrolled machine without
415
415
  * swapping). The op is fenced under the target's enrollment, addressed to its
@@ -497,7 +497,7 @@ export type ProvisionResult =
497
497
  * cannot click the loud whole-machine consent itself).
498
498
  * - modal → create a first-class named modal `sandboxes` record (a swap target).
499
499
  * NOTE: the Modal BOX is materialized lazily when first swapped-to (Modal
500
- * lifecycle is owned by the lease — unchanged per dossier §21).
500
+ * lifecycle is owned by the lease — unchanged per).
501
501
  */
502
502
  export async function provisionSandbox(
503
503
  services: FleetServices,
@@ -62,7 +62,7 @@ export function relayConfigFromSettings(settings: Settings): SelfhostedRelayConf
62
62
  * the CONSUMER uses (`relayConfigFromSettings`) so producer + consumer always agree
63
63
  * on `/stream` — even when the configured URL omits it. An unconfigured relay maps to
64
64
  * `""` (graceful degrade: the agent reports no-relay rather than dialing a synthetic
65
- * host). Fixes preview AND managed prod with no agent rebuild (dossier §V5/§V6). */
65
+ * host). Fixes preview AND managed prod with no agent rebuild. */
66
66
  export function relayDialBaseFromSettings(settings: Settings): string {
67
67
  if (!settings.selfhostedRelayUrl?.trim()) return "";
68
68
  const { host, port, tls, path } = relayConfigFromSettings(settings);
@@ -0,0 +1,208 @@
1
+ import {
2
+ SessionAuthorizationActor,
3
+ SessionAuthorizationDecision,
4
+ SessionAuthorizationListScope,
5
+ type AccessGrant,
6
+ type SessionAuthorizationOperation,
7
+ type SessionAuthorizationSurface,
8
+ type SessionAuthorizationTarget,
9
+ } from "@opengeni/contracts";
10
+ import {
11
+ getSession,
12
+ getSessionRootId,
13
+ getSessionTurnForAttempt,
14
+ type Database,
15
+ } from "@opengeni/db";
16
+ import type { AppDependencies } from "./dependencies";
17
+
18
+ export type SessionAuthorizationDependencies = Pick<AppDependencies, "db" | "sessionAuthorization">;
19
+
20
+ /** Maximum time an omitted host hint leaves a live session stream unchecked. */
21
+ export const SESSION_AUTHORIZATION_DEFAULT_REAUTHORIZE_MS = 15_000;
22
+
23
+ export class SessionAuthorizationDeniedError extends Error {
24
+ readonly code = "SESSION_NOT_FOUND_OR_DENIED";
25
+
26
+ constructor(readonly reason: "not_found" | "forbidden" | "revoked" | "caller_stale") {
27
+ super("Session not found or access denied");
28
+ this.name = "SessionAuthorizationDeniedError";
29
+ }
30
+ }
31
+
32
+ export class SessionAuthorizationUnavailableError extends Error {
33
+ readonly code = "SESSION_AUTHORIZATION_UNAVAILABLE";
34
+
35
+ constructor(options?: ErrorOptions) {
36
+ super("Session authorization is unavailable", options);
37
+ this.name = "SessionAuthorizationUnavailableError";
38
+ }
39
+ }
40
+
41
+ export type ResolvedSessionAuthorization = {
42
+ actor: SessionAuthorizationActor;
43
+ target: SessionAuthorizationTarget;
44
+ relatedSessionAccess: "target" | "root";
45
+ reauthorizeAfterMs: number | null;
46
+ };
47
+
48
+ /**
49
+ * Resolve and enforce the host ACL for one session. The target and agent actor
50
+ * are reconstructed from workspace-scoped durable state. A request can supply
51
+ * an immediate target id and signed attempt claims, but can never nominate a
52
+ * lineage root or frozen initiator.
53
+ *
54
+ * Returns null when no host port is bound so standalone behavior stays byte-
55
+ * for-byte unchanged and pays no additional lineage lookup.
56
+ */
57
+ export async function requireSessionAuthorization(
58
+ deps: SessionAuthorizationDependencies,
59
+ grant: AccessGrant,
60
+ input: {
61
+ sessionId: string;
62
+ operation: SessionAuthorizationOperation;
63
+ surface: SessionAuthorizationSurface;
64
+ },
65
+ ): Promise<ResolvedSessionAuthorization | null> {
66
+ const port = deps.sessionAuthorization;
67
+ if (!port) return null;
68
+
69
+ const [actor, target] = await Promise.all([
70
+ resolveSessionAuthorizationActor(deps.db, grant),
71
+ resolveSessionAuthorizationTarget(deps.db, grant, input.sessionId),
72
+ ]);
73
+ let rawDecision: unknown;
74
+ try {
75
+ rawDecision = await port.authorizeSession({
76
+ accountId: grant.accountId,
77
+ workspaceId: grant.workspaceId,
78
+ actor,
79
+ target,
80
+ operation: input.operation,
81
+ surface: input.surface,
82
+ });
83
+ } catch (error) {
84
+ throw new SessionAuthorizationUnavailableError({ cause: error });
85
+ }
86
+ const parsed = SessionAuthorizationDecision.safeParse(rawDecision);
87
+ if (!parsed.success) {
88
+ throw new SessionAuthorizationUnavailableError({ cause: parsed.error });
89
+ }
90
+ if (!parsed.data.allowed) {
91
+ throw new SessionAuthorizationDeniedError(parsed.data.reason);
92
+ }
93
+ return {
94
+ actor,
95
+ target,
96
+ relatedSessionAccess: parsed.data.relatedSessionAccess ?? "target",
97
+ reauthorizeAfterMs: parsed.data.reauthorizeAfterMs ?? null,
98
+ };
99
+ }
100
+
101
+ /** Resolve the host's complete current list scope for an in-database query. */
102
+ export async function requireSessionAuthorizationListScope(
103
+ deps: SessionAuthorizationDependencies,
104
+ grant: AccessGrant,
105
+ surface: SessionAuthorizationSurface,
106
+ ): Promise<SessionAuthorizationListScope | null> {
107
+ const port = deps.sessionAuthorization;
108
+ if (!port) return null;
109
+ const actor = await resolveSessionAuthorizationActor(deps.db, grant);
110
+ let rawScope: unknown;
111
+ try {
112
+ rawScope = await port.resolveListScope({
113
+ accountId: grant.accountId,
114
+ workspaceId: grant.workspaceId,
115
+ actor,
116
+ surface,
117
+ });
118
+ } catch (error) {
119
+ throw new SessionAuthorizationUnavailableError({ cause: error });
120
+ }
121
+ const parsed = SessionAuthorizationListScope.safeParse(rawScope);
122
+ if (!parsed.success) {
123
+ throw new SessionAuthorizationUnavailableError({ cause: parsed.error });
124
+ }
125
+ if (parsed.data.kind === "all") return parsed.data;
126
+ return {
127
+ kind: "scoped",
128
+ rootSessionIds: [...new Set(parsed.data.rootSessionIds)],
129
+ sessionIds: [...new Set(parsed.data.sessionIds)],
130
+ };
131
+ }
132
+
133
+ async function resolveSessionAuthorizationTarget(
134
+ db: Database,
135
+ grant: AccessGrant,
136
+ sessionId: string,
137
+ ): Promise<SessionAuthorizationTarget> {
138
+ const session = await getSession(db, grant.workspaceId, sessionId);
139
+ if (!session || session.accountId !== grant.accountId) {
140
+ throw new SessionAuthorizationDeniedError("not_found");
141
+ }
142
+ let rootSessionId: string | null;
143
+ try {
144
+ rootSessionId = await getSessionRootId(db, grant.workspaceId, session.id);
145
+ } catch (error) {
146
+ throw new SessionAuthorizationUnavailableError({ cause: error });
147
+ }
148
+ if (!rootSessionId) {
149
+ throw new SessionAuthorizationDeniedError("not_found");
150
+ }
151
+ return { sessionId: session.id, rootSessionId };
152
+ }
153
+
154
+ async function resolveSessionAuthorizationActor(
155
+ db: Database,
156
+ grant: AccessGrant,
157
+ ): Promise<SessionAuthorizationActor> {
158
+ const callerSessionId = grant.metadata?.["sessionId"];
159
+ const turnId = grant.metadata?.["turnId"];
160
+ const attemptId = grant.metadata?.["attemptId"];
161
+ const executionGeneration = grant.metadata?.["executionGeneration"];
162
+ const hasAttemptClaim =
163
+ turnId !== undefined || attemptId !== undefined || executionGeneration !== undefined;
164
+ if (!hasAttemptClaim) {
165
+ return SessionAuthorizationActor.parse({
166
+ kind: "subject",
167
+ subjectId: grant.subjectId,
168
+ ...(grant.subjectLabel ? { subjectLabel: grant.subjectLabel } : {}),
169
+ });
170
+ }
171
+ if (
172
+ typeof callerSessionId !== "string" ||
173
+ typeof turnId !== "string" ||
174
+ typeof attemptId !== "string" ||
175
+ typeof executionGeneration !== "number" ||
176
+ !Number.isSafeInteger(executionGeneration) ||
177
+ executionGeneration < 1
178
+ ) {
179
+ throw new SessionAuthorizationDeniedError("caller_stale");
180
+ }
181
+ const [callerSession, turn, callerRootSessionId] = await Promise.all([
182
+ getSession(db, grant.workspaceId, callerSessionId),
183
+ getSessionTurnForAttempt(db, grant.workspaceId, callerSessionId, attemptId),
184
+ getSessionRootId(db, grant.workspaceId, callerSessionId).catch(() => null),
185
+ ]);
186
+ if (
187
+ !callerSession ||
188
+ callerSession.accountId !== grant.accountId ||
189
+ !turn ||
190
+ turn.id !== turnId ||
191
+ turn.executionGeneration !== executionGeneration ||
192
+ callerSession.activeTurnId !== turn.id ||
193
+ !callerRootSessionId
194
+ ) {
195
+ throw new SessionAuthorizationDeniedError("caller_stale");
196
+ }
197
+ return SessionAuthorizationActor.parse({
198
+ kind: "agent_attempt",
199
+ subjectId: grant.subjectId,
200
+ callerSessionId,
201
+ callerRootSessionId,
202
+ turnId,
203
+ attemptId,
204
+ executionGeneration,
205
+ initiator: turn.initiator,
206
+ initiatorContext: turn.initiatorContext,
207
+ });
208
+ }