pilotswarm-sdk 0.5.57 → 0.5.59
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/api/README.md +6 -0
- package/api/index.d.ts +25 -3
- package/api/index.js +2 -0
- package/api/src/admin-diagnostics.d.ts +4 -0
- package/api/src/admin-diagnostics.js +45 -0
- package/api/src/admin-scope.d.ts +12 -0
- package/api/src/admin-scope.js +39 -0
- package/api/src/api-client.js +214 -1
- package/api/src/http-api-transport.js +20 -1
- package/api/src/protocol.js +3 -2
- package/api/src/session-authz.d.ts +5 -3
- package/api/src/session-authz.js +10 -8
- package/dist/agent-loader.d.ts +12 -1
- package/dist/agent-loader.d.ts.map +1 -1
- package/dist/agent-loader.js +36 -5
- package/dist/agent-loader.js.map +1 -1
- package/dist/agent-manager-tools.d.ts +3 -0
- package/dist/agent-manager-tools.d.ts.map +1 -1
- package/dist/agent-manager-tools.js +19 -11
- package/dist/agent-manager-tools.js.map +1 -1
- package/dist/agent-package-format.d.ts +1 -0
- package/dist/agent-package-format.d.ts.map +1 -1
- package/dist/agent-package-format.js +8 -4
- package/dist/agent-package-format.js.map +1 -1
- package/dist/cms-migrations.d.ts.map +1 -1
- package/dist/cms-migrations.js +265 -0
- package/dist/cms-migrations.js.map +1 -1
- package/dist/cms.d.ts +58 -0
- package/dist/cms.d.ts.map +1 -1
- package/dist/cms.js +88 -0
- package/dist/cms.js.map +1 -1
- package/dist/copilot-client.d.ts +22 -0
- package/dist/copilot-client.d.ts.map +1 -0
- package/dist/copilot-client.js +52 -0
- package/dist/copilot-client.js.map +1 -0
- package/dist/inspect-tools.d.ts +4 -0
- package/dist/inspect-tools.d.ts.map +1 -1
- package/dist/inspect-tools.js +55 -8
- package/dist/inspect-tools.js.map +1 -1
- package/dist/live-turn.d.ts +60 -0
- package/dist/live-turn.d.ts.map +1 -0
- package/dist/live-turn.js +262 -0
- package/dist/live-turn.js.map +1 -0
- package/dist/managed-session.d.ts.map +1 -1
- package/dist/managed-session.js +137 -1
- package/dist/managed-session.js.map +1 -1
- package/dist/management-client.d.ts +10 -0
- package/dist/management-client.d.ts.map +1 -1
- package/dist/management-client.js +17 -3
- package/dist/management-client.js.map +1 -1
- package/dist/orchestration/agents.d.ts.map +1 -1
- package/dist/orchestration/agents.js +3 -1
- package/dist/orchestration/agents.js.map +1 -1
- package/dist/orchestration/index.d.ts +2 -2
- package/dist/orchestration/index.js +1 -1
- package/dist/orchestration/queue.d.ts.map +1 -1
- package/dist/orchestration/queue.js +5 -0
- package/dist/orchestration/queue.js.map +1 -1
- package/dist/orchestration/runtime.d.ts +3 -1
- package/dist/orchestration/runtime.d.ts.map +1 -1
- package/dist/orchestration/runtime.js +6 -1
- package/dist/orchestration/runtime.js.map +1 -1
- package/dist/orchestration/state.d.ts +2 -0
- package/dist/orchestration/state.d.ts.map +1 -1
- package/dist/orchestration/state.js.map +1 -1
- package/dist/orchestration/turn.d.ts +1 -1
- package/dist/orchestration/turn.d.ts.map +1 -1
- package/dist/orchestration/turn.js +49 -7
- package/dist/orchestration/turn.js.map +1 -1
- package/dist/orchestration-registry.d.ts.map +1 -1
- package/dist/orchestration-registry.js +4 -2
- package/dist/orchestration-registry.js.map +1 -1
- package/dist/orchestration-version.d.ts +1 -1
- package/dist/orchestration-version.js +1 -1
- package/dist/orchestration.d.ts +2 -2
- package/dist/orchestration.js +1 -1
- package/dist/orchestration_1_0_46.d.ts +1 -1
- package/dist/orchestration_1_0_47.d.ts +1 -1
- package/dist/orchestration_1_0_48.d.ts +1 -1
- package/dist/orchestration_1_0_49.d.ts +1 -1
- package/dist/orchestration_1_0_50.d.ts +1 -1
- package/dist/orchestration_1_0_71/agents.d.ts +61 -0
- package/dist/orchestration_1_0_71/agents.d.ts.map +1 -0
- package/dist/orchestration_1_0_71/agents.js +840 -0
- package/dist/orchestration_1_0_71/agents.js.map +1 -0
- package/dist/orchestration_1_0_71/index.d.ts +28 -0
- package/dist/orchestration_1_0_71/index.d.ts.map +1 -0
- package/dist/orchestration_1_0_71/index.js +13 -0
- package/dist/orchestration_1_0_71/index.js.map +1 -0
- package/dist/orchestration_1_0_71/lifecycle.d.ts +51 -0
- package/dist/orchestration_1_0_71/lifecycle.d.ts.map +1 -0
- package/dist/orchestration_1_0_71/lifecycle.js +973 -0
- package/dist/orchestration_1_0_71/lifecycle.js.map +1 -0
- package/dist/orchestration_1_0_71/queue.d.ts +20 -0
- package/dist/orchestration_1_0_71/queue.d.ts.map +1 -0
- package/dist/orchestration_1_0_71/queue.js +875 -0
- package/dist/orchestration_1_0_71/queue.js.map +1 -0
- package/dist/orchestration_1_0_71/runtime.d.ts +29 -0
- package/dist/orchestration_1_0_71/runtime.d.ts.map +1 -0
- package/dist/orchestration_1_0_71/runtime.js +225 -0
- package/dist/orchestration_1_0_71/runtime.js.map +1 -0
- package/dist/orchestration_1_0_71/state.d.ts +212 -0
- package/dist/orchestration_1_0_71/state.d.ts.map +1 -0
- package/dist/orchestration_1_0_71/state.js +178 -0
- package/dist/orchestration_1_0_71/state.js.map +1 -0
- package/dist/orchestration_1_0_71/turn.d.ts +34 -0
- package/dist/orchestration_1_0_71/turn.d.ts.map +1 -0
- package/dist/orchestration_1_0_71/turn.js +1392 -0
- package/dist/orchestration_1_0_71/turn.js.map +1 -0
- package/dist/orchestration_1_0_71/utils.d.ts +39 -0
- package/dist/orchestration_1_0_71/utils.d.ts.map +1 -0
- package/dist/orchestration_1_0_71/utils.js +322 -0
- package/dist/orchestration_1_0_71/utils.js.map +1 -0
- package/dist/provider-store.d.ts +3 -3
- package/dist/provider-store.d.ts.map +1 -1
- package/dist/provider-store.js +6 -6
- package/dist/provider-store.js.map +1 -1
- package/dist/provider-tools.d.ts +1 -0
- package/dist/provider-tools.d.ts.map +1 -1
- package/dist/provider-tools.js +4 -3
- package/dist/provider-tools.js.map +1 -1
- package/dist/regen-worker.d.ts.map +1 -1
- package/dist/regen-worker.js +3 -3
- package/dist/regen-worker.js.map +1 -1
- package/dist/session-manager.d.ts +7 -6
- package/dist/session-manager.d.ts.map +1 -1
- package/dist/session-manager.js +54 -32
- package/dist/session-manager.js.map +1 -1
- package/dist/session-proxy.d.ts +7 -1
- package/dist/session-proxy.d.ts.map +1 -1
- package/dist/session-proxy.js +44 -10
- package/dist/session-proxy.js.map +1 -1
- package/dist/system-agents.d.ts.map +1 -1
- package/dist/system-agents.js +9 -3
- package/dist/system-agents.js.map +1 -1
- package/dist/types.d.ts +5 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/web/generated-op-methods.d.ts +8 -0
- package/dist/web/generated-op-methods.d.ts.map +1 -1
- package/dist/web/generated-op-methods.js +2 -0
- package/dist/web/generated-op-methods.js.map +1 -1
- package/dist/web/web-management-client.d.ts +7 -0
- package/dist/web/web-management-client.d.ts.map +1 -1
- package/dist/web/web-management-client.js +3 -0
- package/dist/web/web-management-client.js.map +1 -1
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +7 -1
- package/dist/worker.js.map +1 -1
- package/package.json +5 -5
package/api/README.md
CHANGED
|
@@ -21,8 +21,14 @@ const api = new ApiClient({
|
|
|
21
21
|
const session = await api.call("createSession", { model: "anthropic:claude-sonnet-4-6" });
|
|
22
22
|
await api.call("sendMessage", { sessionId: session.sessionId, prompt: "hello" });
|
|
23
23
|
const unsubscribe = api.subscribeSession(session.sessionId, (event) => console.log(event));
|
|
24
|
+
const unsubscribeLive = api.subscribeLive(session.sessionId, "turn", (update) => console.log(update));
|
|
24
25
|
```
|
|
25
26
|
|
|
27
|
+
`subscribeLive` carries ephemeral last-value topics such as in-progress turn
|
|
28
|
+
text. It resubscribes automatically, ignores duplicate sequence values, and
|
|
29
|
+
refetches a topic snapshot with `getLive` after a sequence gap. Live values
|
|
30
|
+
are never part of the durable session event log.
|
|
31
|
+
|
|
26
32
|
## Modules
|
|
27
33
|
|
|
28
34
|
- `src/protocol.js` — the **operations table**: every JSON operation's name,
|
package/api/index.d.ts
CHANGED
|
@@ -62,6 +62,23 @@ export interface ApiClientOptions {
|
|
|
62
62
|
WebSocketImpl?: unknown;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** Generic ephemeral topic delivery; never a durable replay event. */
|
|
66
|
+
export type LiveUpdate = {
|
|
67
|
+
sessionId: string;
|
|
68
|
+
topic: string;
|
|
69
|
+
updatedAt?: string;
|
|
70
|
+
} & ({ kind: "snapshot" | "patch"; seq: number; data: Record<string, unknown> }
|
|
71
|
+
| { kind: "signal"; seq?: number | null }
|
|
72
|
+
| { kind: "unavailable" });
|
|
73
|
+
|
|
74
|
+
export interface LiveStateRow {
|
|
75
|
+
topic: string;
|
|
76
|
+
seq: number;
|
|
77
|
+
payload: Record<string, unknown>;
|
|
78
|
+
updatedBy: string;
|
|
79
|
+
updatedAt: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
65
82
|
export declare class ApiClient {
|
|
66
83
|
constructor(options: ApiClientOptions);
|
|
67
84
|
apiUrl: string;
|
|
@@ -81,6 +98,7 @@ export declare class ApiClient {
|
|
|
81
98
|
start(): Promise<void>;
|
|
82
99
|
stop(): Promise<void>;
|
|
83
100
|
subscribeSession(sessionId: string, handler: (event: unknown) => void, onResubscribe?: () => void): () => void;
|
|
101
|
+
subscribeLive(sessionId: string, topic: string, handler: (update: LiveUpdate) => void): () => void;
|
|
84
102
|
subscribeLogs(handler: (entry: unknown) => void): () => void;
|
|
85
103
|
}
|
|
86
104
|
|
|
@@ -97,12 +115,16 @@ export interface HttpApiTransportOptions extends ApiClientOptions {
|
|
|
97
115
|
host?: HttpApiTransportHost;
|
|
98
116
|
}
|
|
99
117
|
|
|
118
|
+
export { AdminScope, ADMIN_SCOPE_POLICY_VERSION, ADMIN_SCOPES, loadAdminScope, validateAdminScope, adminCanAccessResource, adminCapabilities } from "./src/admin-scope.js";
|
|
119
|
+
export * from "./src/admin-diagnostics.js";
|
|
120
|
+
|
|
100
121
|
export declare class HttpApiTransport {
|
|
101
122
|
constructor(options: HttpApiTransportOptions);
|
|
102
123
|
api: ApiClient;
|
|
103
124
|
bootstrap: any;
|
|
104
125
|
start(): Promise<void>;
|
|
105
126
|
stop(): Promise<void>;
|
|
127
|
+
getLive(sessionId: string, topics?: string[]): Promise<LiveStateRow[]>;
|
|
106
128
|
[method: string]: any;
|
|
107
129
|
}
|
|
108
130
|
|
|
@@ -134,14 +156,14 @@ export type SessionAccessClass =
|
|
|
134
156
|
export declare const SESSION_VISIBILITY_VALUES: readonly string[];
|
|
135
157
|
export declare function normalizeVisibility(value: unknown, fallback: string): string;
|
|
136
158
|
export declare function systemSessionsReadable(env?: Record<string, string | undefined>): boolean;
|
|
137
|
-
export declare function relationFor(snapshot: SessionAccessSnapshot | null, opts?: { isAdmin?: boolean }): "owner" | "admin" | "collaborator";
|
|
159
|
+
export declare function relationFor(snapshot: SessionAccessSnapshot | null, opts?: { isAdmin?: boolean; adminScope?: import("./src/admin-scope.js").AdminScope }): "owner" | "admin" | "collaborator";
|
|
138
160
|
export declare function evaluateSessionAccess(
|
|
139
161
|
accessClass: SessionAccessClass,
|
|
140
162
|
snapshot: SessionAccessSnapshot | null,
|
|
141
|
-
opts?: { isAdmin?: boolean; systemReadable?: boolean },
|
|
163
|
+
opts?: { isAdmin?: boolean; systemReadable?: boolean; adminScope?: import("./src/admin-scope.js").AdminScope },
|
|
142
164
|
): SessionAccessDecision;
|
|
143
165
|
/** Archive reads are owner-or-admin ONLY — never a share. See proposal §15 A3. */
|
|
144
166
|
export declare function evaluateArchiveAccess(
|
|
145
167
|
snapshot: SessionAccessSnapshot | null,
|
|
146
|
-
opts?: { isAdmin?: boolean },
|
|
168
|
+
opts?: { isAdmin?: boolean; adminScope?: import("./src/admin-scope.js").AdminScope },
|
|
147
169
|
): SessionAccessDecision;
|
package/api/index.js
CHANGED
|
@@ -21,6 +21,8 @@ export {
|
|
|
21
21
|
evaluateArchiveAccess,
|
|
22
22
|
} from "./src/session-authz.js";
|
|
23
23
|
export { ApiClient } from "./src/api-client.js";
|
|
24
|
+
export { ADMIN_SCOPE_POLICY_VERSION, ADMIN_SCOPES, loadAdminScope, validateAdminScope, adminCanAccessResource, adminCapabilities } from "./src/admin-scope.js";
|
|
25
|
+
export * from "./src/admin-diagnostics.js";
|
|
24
26
|
export { createCanvasLiveMirror, jsonMergePatch } from "./src/canvas-live-mirror.js";
|
|
25
27
|
export { HttpApiTransport } from "./src/http-api-transport.js";
|
|
26
28
|
export {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Content-free operational projections. Use only for a restricted viewer. */
|
|
2
|
+
export function projectUserAccounting(stats) {
|
|
3
|
+
return { ...stats, users: (stats.users || []).map((user) => ({
|
|
4
|
+
...user, sessionIds: [],
|
|
5
|
+
byModel: (user.byModel || []).map((model) => ({ ...model, sessionIds: [] })),
|
|
6
|
+
})) };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function projectFleetAccounting(stats) {
|
|
10
|
+
const groups = new Map();
|
|
11
|
+
for (const row of stats.byAgent || []) {
|
|
12
|
+
const key = row.model ?? null;
|
|
13
|
+
const group = groups.get(key) || { agentId: null, model: key };
|
|
14
|
+
for (const [field, value] of Object.entries(row)) {
|
|
15
|
+
if (typeof value === "number" && field !== "cacheHitRatio") group[field] = (group[field] || 0) + value;
|
|
16
|
+
}
|
|
17
|
+
group.cacheHitRatio = group.totalTokensInput > 0 ? group.totalTokensCacheRead / group.totalTokensInput : null;
|
|
18
|
+
groups.set(key, group);
|
|
19
|
+
}
|
|
20
|
+
return { ...stats, byAgent: [...groups.values()], contentRedacted: true };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function projectAgentWorkerState(row) {
|
|
24
|
+
const installed = Object.values(row.installed || {});
|
|
25
|
+
return { workerNodeId: row.workerNodeId, epoch: row.epoch, updatedAt: row.updatedAt,
|
|
26
|
+
installed: {}, installedCount: installed.length,
|
|
27
|
+
errorCount: installed.filter((entry) => entry?.status === "error").length,
|
|
28
|
+
contentRedacted: true };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function projectWorker(row) {
|
|
32
|
+
const health = {};
|
|
33
|
+
for (const field of ["uptimeS", "rssBytes", "heapUsedBytes", "eventLoopDelayP99Ms", "activeSessions"]) {
|
|
34
|
+
if (typeof row.health?.[field] === "number") health[field] = row.health[field];
|
|
35
|
+
}
|
|
36
|
+
for (const field of ["orchestrationSlots", "workerSlots"]) {
|
|
37
|
+
if (typeof row.health?.[field]?.total === "number") health[field] = { total: row.health[field].total };
|
|
38
|
+
}
|
|
39
|
+
const packages = row.state?.["agent-packages"] || {};
|
|
40
|
+
return { workerNodeId: row.workerNodeId, pool: row.pool, phase: row.phase,
|
|
41
|
+
registeredAt: row.registeredAt, updatedAt: row.updatedAt,
|
|
42
|
+
info: { sdkVersion: row.info?.sdkVersion, authz: row.info?.authz }, health,
|
|
43
|
+
state: { "agent-packages": projectAgentWorkerState({ ...packages, workerNodeId: row.workerNodeId }) },
|
|
44
|
+
contentRedacted: true };
|
|
45
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type AdminScope = "unrestricted" | "cluster";
|
|
2
|
+
export declare const ADMIN_SCOPE_POLICY_VERSION: number;
|
|
3
|
+
export declare const ADMIN_SCOPES: readonly AdminScope[];
|
|
4
|
+
export declare function loadAdminScope(env?: Record<string, string | undefined>): AdminScope;
|
|
5
|
+
export declare function validateAdminScope(env: Record<string, string | undefined>, options?: { authenticationEnabled?: boolean }): AdminScope;
|
|
6
|
+
export declare function adminCanAccessResource(isAdmin: boolean | undefined, adminScope?: AdminScope, isSystem?: boolean): boolean;
|
|
7
|
+
export declare function adminCapabilities(isAdmin: boolean, adminScope?: AdminScope): {
|
|
8
|
+
clusterManagement: boolean;
|
|
9
|
+
fleetAccounting: boolean;
|
|
10
|
+
userResourceBypass: boolean;
|
|
11
|
+
systemSessionAdmin: boolean;
|
|
12
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Deployment policy, independent of the authenticated admin role. */
|
|
2
|
+
export const ADMIN_SCOPE_POLICY_VERSION = 1;
|
|
3
|
+
export const ADMIN_SCOPES = Object.freeze(["unrestricted", "cluster"]);
|
|
4
|
+
|
|
5
|
+
export function loadAdminScope(env = (typeof process !== "undefined" ? process.env : {})) {
|
|
6
|
+
const scope = String(env.AUTHZ_ADMIN_SCOPE ?? "unrestricted").trim().toLowerCase();
|
|
7
|
+
if (!ADMIN_SCOPES.includes(scope)) {
|
|
8
|
+
throw new Error("AUTHZ_ADMIN_SCOPE must be cluster or unrestricted.");
|
|
9
|
+
}
|
|
10
|
+
return scope;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Workers validate enforcement; the portal additionally validates its actual auth provider. */
|
|
14
|
+
export function validateAdminScope(env, { authenticationEnabled } = {}) {
|
|
15
|
+
const scope = loadAdminScope(env);
|
|
16
|
+
if (scope === "cluster") {
|
|
17
|
+
if (!["1", "true", "yes", "on"].includes(String(env.AUTHZ_ENFORCE_OWNERSHIP ?? "").trim().toLowerCase())) {
|
|
18
|
+
throw new Error("AUTHZ_ADMIN_SCOPE=cluster requires AUTHZ_ENFORCE_OWNERSHIP=true.");
|
|
19
|
+
}
|
|
20
|
+
if (authenticationEnabled === false) {
|
|
21
|
+
throw new Error("AUTHZ_ADMIN_SCOPE=cluster requires authentication.");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return scope;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** System-target access is deliberately unchanged in phase 1. */
|
|
28
|
+
export function adminCanAccessResource(isAdmin, adminScope = "unrestricted", isSystem = false) {
|
|
29
|
+
return Boolean(isAdmin && (adminScope === "unrestricted" || isSystem));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function adminCapabilities(isAdmin, adminScope = "unrestricted") {
|
|
33
|
+
return {
|
|
34
|
+
clusterManagement: Boolean(isAdmin),
|
|
35
|
+
fleetAccounting: Boolean(isAdmin),
|
|
36
|
+
userResourceBypass: adminCanAccessResource(isAdmin, adminScope),
|
|
37
|
+
systemSessionAdmin: Boolean(isAdmin),
|
|
38
|
+
};
|
|
39
|
+
}
|
package/api/src/api-client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { API_PREFIX, ApiError, WS_PATH, artifactDownloadPath, buildOperationRequest } from "./protocol.js";
|
|
2
|
+
import { jsonMergePatch } from "./canvas-live-mirror.js";
|
|
2
3
|
|
|
3
4
|
const RECONNECT_DELAY_MS = 1500;
|
|
4
5
|
|
|
@@ -66,6 +67,11 @@ export class ApiClient {
|
|
|
66
67
|
this.sessionResubscribeHandlers = new Map();
|
|
67
68
|
this.logSubscribers = new Set();
|
|
68
69
|
this.canvasSubscribers = new Map();
|
|
70
|
+
this.liveSubscribers = new Map();
|
|
71
|
+
this.liveSequences = new Map();
|
|
72
|
+
this.liveRefetches = new Map();
|
|
73
|
+
this.liveValues = new Map();
|
|
74
|
+
this.liveRefetchTargets = new Map();
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
// ── HTTP ────────────────────────────────────────────────────────────
|
|
@@ -212,13 +218,22 @@ export class ApiClient {
|
|
|
212
218
|
this.socket = null;
|
|
213
219
|
this.socketOpenPromise = null;
|
|
214
220
|
this.sessionSubscribers.clear();
|
|
221
|
+
this.sessionResubscribeHandlers.clear();
|
|
215
222
|
this.logSubscribers.clear();
|
|
216
223
|
this.canvasSubscribers.clear();
|
|
224
|
+
this.liveSubscribers.clear();
|
|
225
|
+
this.liveSequences.clear();
|
|
226
|
+
this.liveRefetches.clear();
|
|
227
|
+
this.liveValues.clear();
|
|
228
|
+
this.liveRefetchTargets.clear();
|
|
217
229
|
}
|
|
218
230
|
|
|
219
231
|
scheduleReconnect() {
|
|
220
232
|
if (this.stopped || this.reconnectTimer) return;
|
|
221
|
-
if (this.sessionSubscribers.size === 0
|
|
233
|
+
if (this.sessionSubscribers.size === 0
|
|
234
|
+
&& this.logSubscribers.size === 0
|
|
235
|
+
&& this.canvasSubscribers.size === 0
|
|
236
|
+
&& this.liveSubscribers.size === 0) return;
|
|
222
237
|
this.reconnectTimer = setTimeout(() => {
|
|
223
238
|
this.reconnectTimer = null;
|
|
224
239
|
this.ensureSocket().catch(() => {});
|
|
@@ -262,6 +277,24 @@ export class ApiClient {
|
|
|
262
277
|
socket.addEventListener("message", (event) => {
|
|
263
278
|
try {
|
|
264
279
|
const message = JSON.parse(String(event.data || ""));
|
|
280
|
+
if (message.type === "error" && message.code === "ACCESS_REVOKED" && message.sessionId) {
|
|
281
|
+
for (const key of this.liveValues.keys()) {
|
|
282
|
+
if (key.startsWith(`${message.sessionId}\u0000`)) this.liveValues.delete(key);
|
|
283
|
+
}
|
|
284
|
+
for (const key of this.liveSubscribers.keys()) {
|
|
285
|
+
const [sessionId, topic] = key.split("\u0000");
|
|
286
|
+
if (sessionId !== message.sessionId) continue;
|
|
287
|
+
// Use the normal invalidation path: besides the
|
|
288
|
+
// retained value it invalidates an in-flight gap
|
|
289
|
+
// fetch, so its older authorized reply cannot
|
|
290
|
+
// repopulate a revoked live-only subscription.
|
|
291
|
+
void this.handleLiveMessage({ kind: "unavailable", sessionId, topic, accessRevoked: true });
|
|
292
|
+
}
|
|
293
|
+
for (const handler of this.sessionSubscribers.get(message.sessionId) || []) {
|
|
294
|
+
handler({ eventType: "session.access_revoked", sessionId: message.sessionId });
|
|
295
|
+
}
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
265
298
|
if (message.type === "sessionEvent") {
|
|
266
299
|
const handlers = this.sessionSubscribers.get(message.sessionId);
|
|
267
300
|
if (handlers) {
|
|
@@ -280,6 +313,10 @@ export class ApiClient {
|
|
|
280
313
|
}
|
|
281
314
|
return;
|
|
282
315
|
}
|
|
316
|
+
if (message.type === "live") {
|
|
317
|
+
void this.handleLiveMessage(message);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
283
320
|
// A canvas-scope error (plane unavailable, authz change,
|
|
284
321
|
// server rollback) releases the mirror's takeover of that
|
|
285
322
|
// session — otherwise legacy events would stay suppressed
|
|
@@ -291,12 +328,25 @@ export class ApiClient {
|
|
|
291
328
|
for (const handler of handlers) handler({ kind: "unavailable", sessionId: message.sessionId });
|
|
292
329
|
}
|
|
293
330
|
}
|
|
331
|
+
if (message.type === "error" && message.scope === "live" && message.sessionId) {
|
|
332
|
+
for (const key of this.liveSubscribers.keys()) {
|
|
333
|
+
if (!key.startsWith(`${message.sessionId}\u0000`)) continue;
|
|
334
|
+
void this.handleLiveMessage({ kind: "unavailable", sessionId: message.sessionId, topic: key.split("\u0000")[1] });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
294
337
|
} catch {}
|
|
295
338
|
});
|
|
296
339
|
|
|
297
340
|
socket.addEventListener("close", (event) => {
|
|
298
341
|
this.socket = null;
|
|
299
342
|
this.socketOpenPromise = null;
|
|
343
|
+
for (const [key, handlers] of this.liveSubscribers) {
|
|
344
|
+
const [sessionId, topic] = key.split("\u0000");
|
|
345
|
+
for (const handler of handlers) {
|
|
346
|
+
try { handler({ kind: "unavailable", sessionId, topic }); } catch {}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
this.liveValues.clear();
|
|
300
350
|
if (event.code === 4401) {
|
|
301
351
|
this.onUnauthorized();
|
|
302
352
|
return;
|
|
@@ -342,7 +392,114 @@ export class ApiClient {
|
|
|
342
392
|
}
|
|
343
393
|
}
|
|
344
394
|
|
|
395
|
+
async handleLiveMessage(message) {
|
|
396
|
+
const key = `${message.sessionId}\u0000${message.topic}`;
|
|
397
|
+
const handlers = this.liveSubscribers.get(key);
|
|
398
|
+
if (!handlers || handlers.size === 0) return;
|
|
399
|
+
if (message.kind === "unavailable") {
|
|
400
|
+
this.liveSequences.delete(key);
|
|
401
|
+
this.liveValues.delete(key);
|
|
402
|
+
this.liveRefetches.delete(key);
|
|
403
|
+
this.liveRefetchTargets.delete(key);
|
|
404
|
+
for (const handler of handlers) { try { handler(message); } catch {} }
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (message.kind === "signal") {
|
|
408
|
+
for (const handler of handlers) {
|
|
409
|
+
try { handler(message); } catch {}
|
|
410
|
+
}
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const seq = Number(message.seq);
|
|
415
|
+
if (!Number.isFinite(seq) || seq < 1) return;
|
|
416
|
+
const previous = this.liveSequences.get(key);
|
|
417
|
+
if (previous != null && seq === previous) return;
|
|
418
|
+
if (message.kind === "snapshot" && previous != null && seq < previous) return;
|
|
419
|
+
|
|
420
|
+
// A patch is meaningful only against its immediate predecessor. The
|
|
421
|
+
// subscribe burst is a snapshot, so no predecessor means a missed
|
|
422
|
+
// burst too. Collapse concurrent gaps into one authoritative read.
|
|
423
|
+
if (message.kind === "patch" && (previous == null || seq !== previous + 1)) {
|
|
424
|
+
if (seq > Number(this.liveRefetchTargets.get(key)?.seq || 0)) {
|
|
425
|
+
this.liveRefetchTargets.set(key, message);
|
|
426
|
+
}
|
|
427
|
+
if (!this.liveRefetches.has(key)) {
|
|
428
|
+
const allowSequenceReset = previous != null && seq < previous;
|
|
429
|
+
const refetch = this.call("getLive", {
|
|
430
|
+
sessionId: message.sessionId,
|
|
431
|
+
topics: [message.topic],
|
|
432
|
+
}).then((rows) => {
|
|
433
|
+
const row = (rows || []).find((candidate) => candidate?.topic === message.topic);
|
|
434
|
+
const currentHandlers = this.liveSubscribers.get(key);
|
|
435
|
+
// The last subscriber may have left and a new subscription
|
|
436
|
+
// for the same key may already exist. Do not let the old
|
|
437
|
+
// request inject its snapshot into that new subscription.
|
|
438
|
+
if (!row || currentHandlers !== handlers || currentHandlers.size === 0
|
|
439
|
+
|| this.liveRefetches.get(key) !== refetch) return;
|
|
440
|
+
const snapshotSeq = Number(row.seq);
|
|
441
|
+
if (!Number.isFinite(snapshotSeq) || snapshotSeq < 1) return;
|
|
442
|
+
const seen = this.liveSequences.get(key);
|
|
443
|
+
if (seen != null && (
|
|
444
|
+
(!allowSequenceReset && snapshotSeq <= seen)
|
|
445
|
+
|| (allowSequenceReset && (snapshotSeq === seen || (snapshotSeq < seen && seen !== previous)))
|
|
446
|
+
)) return;
|
|
447
|
+
this.liveSequences.set(key, snapshotSeq);
|
|
448
|
+
const snapshot = {
|
|
449
|
+
type: "live",
|
|
450
|
+
sessionId: message.sessionId,
|
|
451
|
+
topic: message.topic,
|
|
452
|
+
seq: snapshotSeq,
|
|
453
|
+
kind: "snapshot",
|
|
454
|
+
data: row.payload || {},
|
|
455
|
+
...(row.updatedAt ? { updatedAt: row.updatedAt } : {}),
|
|
456
|
+
};
|
|
457
|
+
this.liveValues.set(key, snapshot);
|
|
458
|
+
for (const handler of currentHandlers) {
|
|
459
|
+
try { handler(snapshot); } catch {}
|
|
460
|
+
}
|
|
461
|
+
}).catch(() => {
|
|
462
|
+
// A later notification retries the authoritative read;
|
|
463
|
+
// durable session events remain the product fallback.
|
|
464
|
+
}).finally(() => {
|
|
465
|
+
// A replacement subscription can already have started a
|
|
466
|
+
// newer refetch for this key; do not erase its guard.
|
|
467
|
+
if (this.liveRefetches.get(key) === refetch) {
|
|
468
|
+
this.liveRefetches.delete(key);
|
|
469
|
+
const latest = this.liveRefetchTargets.get(key);
|
|
470
|
+
this.liveRefetchTargets.delete(key);
|
|
471
|
+
// A notification that arrived DURING the read may be
|
|
472
|
+
// newer than its snapshot. Do not lose that last tick.
|
|
473
|
+
if (latest && latest !== message && this.liveSubscribers.get(key) === handlers) {
|
|
474
|
+
void this.handleLiveMessage(latest);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
this.liveRefetches.set(key, refetch);
|
|
479
|
+
}
|
|
480
|
+
await this.liveRefetches.get(key);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
this.liveSequences.set(key, seq);
|
|
485
|
+
this.liveValues.set(key, {
|
|
486
|
+
...message, kind: "snapshot",
|
|
487
|
+
data: message.kind === "patch"
|
|
488
|
+
? jsonMergePatch(this.liveValues.get(key)?.data || {}, message.data || {})
|
|
489
|
+
: message.data,
|
|
490
|
+
});
|
|
491
|
+
for (const handler of handlers) {
|
|
492
|
+
try { handler(message); } catch {}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
345
496
|
resubscribeAll(isReconnect = false) {
|
|
497
|
+
if (isReconnect) {
|
|
498
|
+
this.liveSequences.clear();
|
|
499
|
+
this.liveValues.clear();
|
|
500
|
+
this.liveRefetches.clear();
|
|
501
|
+
this.liveRefetchTargets.clear();
|
|
502
|
+
}
|
|
346
503
|
for (const sessionId of this.sessionSubscribers.keys()) {
|
|
347
504
|
this.socketSend({ type: "subscribeSession", sessionId });
|
|
348
505
|
// On a RECONNECT, live delivery resumes but events emitted during
|
|
@@ -363,6 +520,15 @@ export class ApiClient {
|
|
|
363
520
|
for (const sessionId of this.canvasSubscribers.keys()) {
|
|
364
521
|
this.socketSend({ type: "subscribeCanvas", sessionId });
|
|
365
522
|
}
|
|
523
|
+
const liveBySession = new Map();
|
|
524
|
+
for (const key of this.liveSubscribers.keys()) {
|
|
525
|
+
const [sessionId, topic] = key.split("\u0000");
|
|
526
|
+
if (!liveBySession.has(sessionId)) liveBySession.set(sessionId, []);
|
|
527
|
+
liveBySession.get(sessionId).push(topic);
|
|
528
|
+
}
|
|
529
|
+
for (const [sessionId, topics] of liveBySession) {
|
|
530
|
+
this.socketSend({ type: "subscribeLive", sessionId, topics });
|
|
531
|
+
}
|
|
366
532
|
}
|
|
367
533
|
|
|
368
534
|
/**
|
|
@@ -432,6 +598,53 @@ export class ApiClient {
|
|
|
432
598
|
};
|
|
433
599
|
}
|
|
434
600
|
|
|
601
|
+
/** Subscribe to one generic live-plane topic for a session. */
|
|
602
|
+
subscribeLive(sessionId, topic, handler) {
|
|
603
|
+
const normalizedSessionId = String(sessionId || "").trim();
|
|
604
|
+
const normalizedTopic = String(topic || "").trim();
|
|
605
|
+
if (!normalizedSessionId || !/^[a-z][a-z0-9_.:-]{0,63}$/.test(normalizedTopic)) {
|
|
606
|
+
throw new Error("subscribeLive requires a session id and valid topic");
|
|
607
|
+
}
|
|
608
|
+
const key = `${normalizedSessionId}\u0000${normalizedTopic}`;
|
|
609
|
+
const alreadySubscribed = this.liveSubscribers.has(key);
|
|
610
|
+
if (!alreadySubscribed && [...this.liveSubscribers.keys()].filter((candidate) => candidate.startsWith(`${normalizedSessionId}\u0000`)).length >= 16) {
|
|
611
|
+
throw new Error("At most 16 live topics per session are supported");
|
|
612
|
+
}
|
|
613
|
+
if (!this.liveSubscribers.has(key)) this.liveSubscribers.set(key, new Set());
|
|
614
|
+
const handlers = this.liveSubscribers.get(key);
|
|
615
|
+
handlers.add(handler);
|
|
616
|
+
const topics = [];
|
|
617
|
+
for (const candidate of this.liveSubscribers.keys()) {
|
|
618
|
+
const [sid, liveTopic] = candidate.split("\u0000");
|
|
619
|
+
if (sid === normalizedSessionId) topics.push(liveTopic);
|
|
620
|
+
}
|
|
621
|
+
if (!alreadySubscribed) this.announceSubscription({ type: "subscribeLive", sessionId: normalizedSessionId, topics });
|
|
622
|
+
else if (this.liveValues.has(key)) {
|
|
623
|
+
try { handler(this.liveValues.get(key)); } catch {}
|
|
624
|
+
}
|
|
625
|
+
let active = true;
|
|
626
|
+
return () => {
|
|
627
|
+
if (!active) return;
|
|
628
|
+
active = false;
|
|
629
|
+
handlers.delete(handler);
|
|
630
|
+
if (handlers.size > 0 || this.liveSubscribers.get(key) !== handlers) return;
|
|
631
|
+
this.liveSubscribers.delete(key);
|
|
632
|
+
this.liveSequences.delete(key);
|
|
633
|
+
this.liveRefetches.delete(key);
|
|
634
|
+
this.liveRefetchTargets.delete(key);
|
|
635
|
+
this.liveValues.delete(key);
|
|
636
|
+
const remaining = [];
|
|
637
|
+
for (const candidate of this.liveSubscribers.keys()) {
|
|
638
|
+
const [sid, liveTopic] = candidate.split("\u0000");
|
|
639
|
+
if (sid === normalizedSessionId) remaining.push(liveTopic);
|
|
640
|
+
}
|
|
641
|
+
this.socketSend({ type: "unsubscribeLive", sessionId: normalizedSessionId });
|
|
642
|
+
if (remaining.length > 0) {
|
|
643
|
+
this.socketSend({ type: "subscribeLive", sessionId: normalizedSessionId, topics: remaining });
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
435
648
|
subscribeLogs(handler) {
|
|
436
649
|
this.logSubscribers.add(handler);
|
|
437
650
|
this.announceSubscription({ type: "subscribeLogs" });
|
|
@@ -105,7 +105,8 @@ export class HttpApiTransport {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
getAuthContext() {
|
|
108
|
-
|
|
108
|
+
if (this.bootstrap?.auth) return { ...this.bootstrap.auth, adminScope: this.bootstrap.authz?.adminScope ?? "unrestricted" };
|
|
109
|
+
return {
|
|
109
110
|
principal: null,
|
|
110
111
|
authorization: { allowed: false, role: null, reason: "Auth context unavailable", matchedGroups: [] },
|
|
111
112
|
};
|
|
@@ -496,6 +497,10 @@ export class HttpApiTransport {
|
|
|
496
497
|
return this.api.call("getCanvasLive", { sessionId });
|
|
497
498
|
}
|
|
498
499
|
|
|
500
|
+
async getLive(sessionId, topics) {
|
|
501
|
+
return this.api.call("getLive", { sessionId, topics });
|
|
502
|
+
}
|
|
503
|
+
|
|
499
504
|
// The canvas KV store (door 1 — signed-in browser).
|
|
500
505
|
async readCanvasKv(sessionId, slot, query = {}) {
|
|
501
506
|
return this.api.call("readCanvasKv", {
|
|
@@ -667,9 +672,23 @@ export class HttpApiTransport {
|
|
|
667
672
|
}
|
|
668
673
|
}
|
|
669
674
|
});
|
|
675
|
+
const unsubscribeLive = this.api.subscribeLive(sessionId, "turn", (message) => {
|
|
676
|
+
if (message?.kind === "signal") return;
|
|
677
|
+
const data = message?.kind === "unavailable" ? { phase: "idle" } : message?.data;
|
|
678
|
+
if (!data || typeof data !== "object") return;
|
|
679
|
+
handler({
|
|
680
|
+
eventType: "assistant.live_tick",
|
|
681
|
+
sessionId,
|
|
682
|
+
transient: true,
|
|
683
|
+
liveSeq: Number(message.seq) || 0,
|
|
684
|
+
liveUpdatedAt: message.updatedAt,
|
|
685
|
+
data,
|
|
686
|
+
});
|
|
687
|
+
});
|
|
670
688
|
return () => {
|
|
671
689
|
unsubscribeEvents();
|
|
672
690
|
unsubscribeCanvas();
|
|
691
|
+
unsubscribeLive();
|
|
673
692
|
const handlers = this._canvasEmitHandlers.get(sessionId);
|
|
674
693
|
if (handlers) {
|
|
675
694
|
handlers.delete(handler);
|
package/api/src/protocol.js
CHANGED
|
@@ -41,8 +41,8 @@ export const API_VERSION = 1;
|
|
|
41
41
|
export const WS_PATH = "/api/v1/ws";
|
|
42
42
|
|
|
43
43
|
/** WebSocket message vocabulary (same as the legacy /portal-ws, minus theme). */
|
|
44
|
-
export const WS_CLIENT_MESSAGES = ["subscribeSession", "unsubscribeSession", "subscribeLogs", "unsubscribeLogs"];
|
|
45
|
-
export const WS_SERVER_MESSAGES = ["ready", "subscribedSession", "sessionEvent", "subscribedLogs", "logEntry", "error"];
|
|
44
|
+
export const WS_CLIENT_MESSAGES = ["subscribeSession", "unsubscribeSession", "subscribeLive", "unsubscribeLive", "subscribeLogs", "unsubscribeLogs"];
|
|
45
|
+
export const WS_SERVER_MESSAGES = ["ready", "subscribedSession", "sessionEvent", "subscribedLive", "live", "subscribedLogs", "logEntry", "error"];
|
|
46
46
|
|
|
47
47
|
/** Error code used when an SDK web-mode method has no API equivalent. */
|
|
48
48
|
export const WEB_MODE_UNSUPPORTED = "WEB_MODE_UNSUPPORTED";
|
|
@@ -114,6 +114,7 @@ export const OPERATIONS = [
|
|
|
114
114
|
{ name: "getSessionEvents", access: "session:read", method: "GET", path: "/management/sessions/:sessionId/events", params: { sessionId: path("sessionId"), afterSeq: query("number"), limit: query("number"), eventTypes: query("json") }, summary: "Session events after a sequence number (reconnect catch-up). Optional eventTypes (JSON string array) narrows to those event types server-side." },
|
|
115
115
|
{ name: "getSessionEventsBefore", access: "session:read", method: "GET", path: "/management/sessions/:sessionId/events-before", params: { sessionId: path("sessionId"), beforeSeq: query("number"), limit: query("number"), eventTypes: query("json") }, summary: "Older session events for history paging. Optional eventTypes (JSON string array) narrows to those event types server-side (chat transcript paging)." },
|
|
116
116
|
{ name: "getCanvasLive", access: "session:read", method: "GET", path: "/management/sessions/:sessionId/canvas-live", params: { sessionId: path("sessionId") }, summary: "The canvas data plane's last-value rows: current doc pointer + latest merged tick per slot. Snapshot source for live canvas subscriptions; empty when the deployment predates the plane." },
|
|
117
|
+
{ name: "getLive", access: "session:read", method: "GET", path: "/management/sessions/:sessionId/live", params: { sessionId: path("sessionId"), topics: query("json") }, summary: "Current retained values from the ephemeral live plane, optionally filtered by topic." },
|
|
117
118
|
// The canvas KV store (interactive-canvas-apps Part C). canvas:read and
|
|
118
119
|
// canvas:write are their OWN access classes: both gate on session read
|
|
119
120
|
// (a canvas reader may hold no session write), and whether a read-only
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* gate that always passes.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import type { AdminScope } from "./admin-scope.js";
|
|
12
|
+
|
|
11
13
|
export interface SessionAccessSnapshot {
|
|
12
14
|
rootSessionId?: string;
|
|
13
15
|
isSystem: boolean;
|
|
@@ -42,19 +44,19 @@ export declare function systemSessionsReadable(env?: Record<string, string | und
|
|
|
42
44
|
|
|
43
45
|
export declare function relationFor(
|
|
44
46
|
snapshot: SessionAccessSnapshot | null,
|
|
45
|
-
opts?: { isAdmin?: boolean },
|
|
47
|
+
opts?: { isAdmin?: boolean; adminScope?: AdminScope },
|
|
46
48
|
): "owner" | "admin" | "collaborator";
|
|
47
49
|
|
|
48
50
|
export declare function evaluateSessionAccess(
|
|
49
51
|
accessClass: SessionAccessClass,
|
|
50
52
|
snapshot: SessionAccessSnapshot | null,
|
|
51
|
-
opts?: { isAdmin?: boolean; systemReadable?: boolean },
|
|
53
|
+
opts?: { isAdmin?: boolean; systemReadable?: boolean; adminScope?: AdminScope },
|
|
52
54
|
): SessionAccessDecision;
|
|
53
55
|
|
|
54
56
|
/** Archive reads are owner-or-admin ONLY — never a share. See proposal §15 A3. */
|
|
55
57
|
export declare function evaluateArchiveAccess(
|
|
56
58
|
snapshot: SessionAccessSnapshot | null,
|
|
57
|
-
opts?: { isAdmin?: boolean },
|
|
59
|
+
opts?: { isAdmin?: boolean; adminScope?: AdminScope },
|
|
58
60
|
): SessionAccessDecision;
|
|
59
61
|
|
|
60
62
|
/** A sign-in role observation read from the users table. */
|
package/api/src/session-authz.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { adminCanAccessResource } from "./admin-scope.js";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Session-tree access predicate — the ONE implementation, shared by every
|
|
3
5
|
* surface that has to answer "may this principal touch this session?".
|
|
@@ -54,9 +56,9 @@ function ownerLabel(snapshot) {
|
|
|
54
56
|
* The caller's relation to a session tree, recorded on message payloads and
|
|
55
57
|
* shown to the agent in multi-writer sessions.
|
|
56
58
|
*/
|
|
57
|
-
export function relationFor(snapshot, { isAdmin } = {}) {
|
|
59
|
+
export function relationFor(snapshot, { isAdmin, adminScope = "unrestricted" } = {}) {
|
|
58
60
|
if (snapshot?.viewerIsOwner) return "owner";
|
|
59
|
-
if (isAdmin) return "admin";
|
|
61
|
+
if (adminCanAccessResource(isAdmin, adminScope, snapshot?.isSystem)) return "admin";
|
|
60
62
|
return "collaborator";
|
|
61
63
|
}
|
|
62
64
|
|
|
@@ -68,14 +70,14 @@ export function relationFor(snapshot, { isAdmin } = {}) {
|
|
|
68
70
|
* @param {{isAdmin?: boolean, systemReadable?: boolean}} [opts]
|
|
69
71
|
* @returns {{allowed: boolean, notFound?: boolean, reason?: string, breakGlass?: boolean}}
|
|
70
72
|
*/
|
|
71
|
-
export function evaluateSessionAccess(accessClass, snapshot, { isAdmin = false, systemReadable = true } = {}) {
|
|
73
|
+
export function evaluateSessionAccess(accessClass, snapshot, { isAdmin = false, systemReadable = true, adminScope = "unrestricted" } = {}) {
|
|
72
74
|
if (!snapshot) {
|
|
73
75
|
// Missing/deleted session: let the underlying operation produce its
|
|
74
76
|
// own not-found; nothing to protect.
|
|
75
77
|
return { allowed: true };
|
|
76
78
|
}
|
|
77
79
|
|
|
78
|
-
if (isAdmin) {
|
|
80
|
+
if (adminCanAccessResource(isAdmin, adminScope, snapshot.isSystem)) {
|
|
79
81
|
// Admins pass everything; flag break-glass when this would have been
|
|
80
82
|
// invisible to a plain user in the same position.
|
|
81
83
|
const wouldBeInvisible = !snapshot.viewerIsOwner
|
|
@@ -120,7 +122,7 @@ export function evaluateSessionAccess(accessClass, snapshot, { isAdmin = false,
|
|
|
120
122
|
// manage / destroy / share: owner only (admin handled above).
|
|
121
123
|
return snapshot.viewerIsOwner
|
|
122
124
|
? { allowed: true }
|
|
123
|
-
: { allowed: false, reason: `Only the session owner (${ownerLabel(snapshot)}) or an admin can do this.` };
|
|
125
|
+
: { allowed: false, reason: `Only the session owner (${ownerLabel(snapshot)})${adminScope === "unrestricted" ? " or an admin" : ""} can do this.` };
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
/**
|
|
@@ -137,9 +139,9 @@ export function evaluateSessionAccess(accessClass, snapshot, { isAdmin = false,
|
|
|
137
139
|
* @param {{isAdmin?: boolean}} [opts]
|
|
138
140
|
* @returns {{allowed: boolean, notFound?: boolean, reason?: string, breakGlass?: boolean}}
|
|
139
141
|
*/
|
|
140
|
-
export function evaluateArchiveAccess(snapshot, { isAdmin = false } = {}) {
|
|
142
|
+
export function evaluateArchiveAccess(snapshot, { isAdmin = false, adminScope = "unrestricted" } = {}) {
|
|
141
143
|
if (!snapshot) return { allowed: true };
|
|
142
|
-
if (isAdmin) {
|
|
144
|
+
if (adminCanAccessResource(isAdmin, adminScope, snapshot.isSystem)) {
|
|
143
145
|
// Reading someone else's raw session state is exactly what the
|
|
144
146
|
// authz_audit table calls a break-glass read.
|
|
145
147
|
return { allowed: true, breakGlass: !snapshot.viewerIsOwner };
|
|
@@ -149,7 +151,7 @@ export function evaluateArchiveAccess(snapshot, { isAdmin = false } = {}) {
|
|
|
149
151
|
return {
|
|
150
152
|
allowed: false,
|
|
151
153
|
reason:
|
|
152
|
-
"Session archives are available to the session owner and administrators only. "
|
|
154
|
+
(adminScope === "cluster" ? "Session archives are available to the session owner only. " : "Session archives are available to the session owner and administrators only. ")
|
|
153
155
|
+ "A share grants the conversation, not the raw session state behind it.",
|
|
154
156
|
};
|
|
155
157
|
}
|