@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/dist/index.d.ts +103 -138
- package/dist/index.js +792 -154
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
- package/src/access/index.ts +4 -0
- package/src/application/session-commands.ts +190 -38
- package/src/billing/limits.ts +20 -1
- package/src/dependencies.ts +26 -2
- package/src/domain/capabilities.ts +171 -3
- package/src/domain/resources.ts +89 -30
- package/src/domain/sessions.ts +409 -93
- package/src/index.ts +1 -0
- package/src/sandbox/fleet.ts +2 -2
- package/src/sandbox/routing.ts +1 -1
- package/src/session-authorization.ts +208 -0
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";
|
package/src/sandbox/fleet.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
500
|
+
* lifecycle is owned by the lease — unchanged per).
|
|
501
501
|
*/
|
|
502
502
|
export async function provisionSandbox(
|
|
503
503
|
services: FleetServices,
|
package/src/sandbox/routing.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
+
}
|