@nikelyh/offscript 0.0.0-stage → 1.0.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/LICENSE +201 -0
- package/README.md +148 -2
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/dist/packages/application/assign-investigation/execute.js +34 -0
- package/dist/packages/application/assign-investigation/ports.js +1 -0
- package/dist/packages/application/assign-investigation/resolve-context.js +41 -0
- package/dist/packages/application/check-target.js +4 -0
- package/dist/packages/application/complete-investigation/execute.js +54 -0
- package/dist/packages/application/complete-investigation/ports.js +1 -0
- package/dist/packages/application/complete-investigation/resolve-context.js +48 -0
- package/dist/packages/application/configure-simulation/execute.js +29 -0
- package/dist/packages/application/configure-simulation/ports.js +1 -0
- package/dist/packages/application/configure-simulation/profiles.js +16 -0
- package/dist/packages/application/delete-configured-simulation.js +11 -0
- package/dist/packages/application/errors.js +8 -0
- package/dist/packages/application/index.js +11 -0
- package/dist/packages/application/outbox/dispatch.js +48 -0
- package/dist/packages/application/outbox/ports.js +16 -0
- package/dist/packages/application/recover-simulation/execute.js +31 -0
- package/dist/packages/application/recover-simulation/ports.js +1 -0
- package/dist/packages/application/request-investigation/execute.js +38 -0
- package/dist/packages/application/request-investigation/ports.js +1 -0
- package/dist/packages/application/request-investigation/resolve-context.js +41 -0
- package/dist/packages/browser/browser-manager.js +91 -0
- package/dist/packages/browser/dom-snapshot.js +186 -0
- package/dist/packages/browser/index.js +5 -0
- package/dist/packages/browser/scope-guard.js +73 -0
- package/dist/packages/browser/session.js +209 -0
- package/dist/packages/browser/tools/browser-tools.js +192 -0
- package/dist/packages/browser/types.js +1 -0
- package/dist/packages/contracts/errors.js +8 -0
- package/dist/packages/contracts/event-envelope.js +28 -0
- package/dist/packages/contracts/event-validation.js +44 -0
- package/dist/packages/contracts/events.js +1 -0
- package/dist/packages/contracts/finding-assessment.js +128 -0
- package/dist/packages/contracts/index.js +15 -0
- package/dist/packages/contracts/investigation-events.js +43 -0
- package/dist/packages/contracts/investigation-summary.js +60 -0
- package/dist/packages/contracts/mappers.js +17 -0
- package/dist/packages/contracts/observation-summary.js +25 -0
- package/dist/packages/contracts/semantic-events.js +41 -0
- package/dist/packages/contracts/serialization.js +18 -0
- package/dist/packages/contracts/simulation-configuration.js +17 -0
- package/dist/packages/contracts/simulation-execution.js +62 -0
- package/dist/packages/contracts/simulation-results.js +52 -0
- package/dist/packages/contracts/simulation-setup.js +60 -0
- package/dist/packages/contracts/summaries.js +42 -0
- package/dist/packages/contracts/target-connection.js +36 -0
- package/dist/packages/contracts/validation.js +42 -0
- package/dist/packages/domain/agent.js +23 -0
- package/dist/packages/domain/environment.js +1 -0
- package/dist/packages/domain/errors.js +14 -0
- package/dist/packages/domain/identity.js +16 -0
- package/dist/packages/domain/index.js +10 -0
- package/dist/packages/domain/investigation-result.js +19 -0
- package/dist/packages/domain/investigation.js +70 -0
- package/dist/packages/domain/observation.js +19 -0
- package/dist/packages/domain/profile.js +19 -0
- package/dist/packages/domain/simulation.js +61 -0
- package/dist/packages/domain/timestamp.js +9 -0
- package/dist/packages/orchestration/browser-exploration-factory.js +135 -0
- package/dist/packages/orchestration/controlled-demo-factory.js +26 -0
- package/dist/packages/orchestration/deterministic-browser-runner.js +92 -0
- package/dist/packages/orchestration/deterministic-runner.js +69 -0
- package/dist/packages/orchestration/event-bridge.js +27 -0
- package/dist/packages/orchestration/guard-inference.js +44 -0
- package/dist/packages/orchestration/index.js +7 -0
- package/dist/packages/orchestration/investigation-reaction.js +53 -0
- package/dist/packages/orchestration/investigation-store.js +96 -0
- package/dist/packages/orchestration/memory-outbox.js +63 -0
- package/dist/packages/orchestration/multi-mode-factory.js +12 -0
- package/dist/packages/orchestration/outbox-publisher.js +13 -0
- package/dist/packages/orchestration/session-storage.js +52 -0
- package/dist/packages/orchestration/session.js +176 -0
- package/dist/packages/orchestration/situations.js +26 -0
- package/dist/packages/orchestration/state.js +32 -0
- package/dist/packages/orchestration/tools/arguments.js +13 -0
- package/dist/packages/orchestration/tools/browser-exploration-tools.js +154 -0
- package/dist/packages/orchestration/tools/browser-interaction-tools.js +74 -0
- package/dist/packages/orchestration/tools/exploration-tools.js +119 -0
- package/dist/packages/persistence/codecs/investigation.js +30 -0
- package/dist/packages/persistence/codecs/simulation.js +69 -0
- package/dist/packages/persistence/codecs/values.js +24 -0
- package/dist/packages/persistence/connection.js +29 -0
- package/dist/packages/persistence/errors.js +8 -0
- package/dist/packages/persistence/index.js +22 -0
- package/dist/packages/persistence/migrate.js +40 -0
- package/dist/packages/persistence/migrations/0000_initial_domain_storage.sql +92 -0
- package/dist/packages/persistence/migrations/0001_transactional_outbox.sql +26 -0
- package/dist/packages/persistence/migrations/0002_simulation_configuration.sql +7 -0
- package/dist/packages/persistence/migrations/meta/0000_snapshot.json +637 -0
- package/dist/packages/persistence/migrations/meta/0001_snapshot.json +812 -0
- package/dist/packages/persistence/migrations/meta/0002_snapshot.json +859 -0
- package/dist/packages/persistence/migrations/meta/_journal.json +27 -0
- package/dist/packages/persistence/outbox/committer.js +54 -0
- package/dist/packages/persistence/outbox/store.js +66 -0
- package/dist/packages/persistence/repositories/configurations.js +41 -0
- package/dist/packages/persistence/repositories/delete-configuration.js +20 -0
- package/dist/packages/persistence/repositories/investigation-writes.js +72 -0
- package/dist/packages/persistence/repositories/investigations.js +27 -0
- package/dist/packages/persistence/repositories/observations.js +36 -0
- package/dist/packages/persistence/repositories/simulation-writes.js +39 -0
- package/dist/packages/persistence/repositories/simulations.js +23 -0
- package/dist/packages/persistence/schema/configurations.js +10 -0
- package/dist/packages/persistence/schema/index.js +5 -0
- package/dist/packages/persistence/schema/investigations.js +53 -0
- package/dist/packages/persistence/schema/observations.js +17 -0
- package/dist/packages/persistence/schema/outbox.js +26 -0
- package/dist/packages/persistence/schema/simulations.js +29 -0
- package/dist/packages/projects/index.js +2 -0
- package/dist/packages/projects/inspect.js +45 -0
- package/dist/packages/projects/suggest-target.js +34 -0
- package/dist/packages/projects/target-probe.js +61 -0
- package/dist/packages/runtime/deadline.js +11 -0
- package/dist/packages/runtime/evidence-store.js +76 -0
- package/dist/packages/runtime/execution-coordinator.js +223 -0
- package/dist/packages/runtime/execution-settlement.js +50 -0
- package/dist/packages/runtime/execution-types.js +1 -0
- package/dist/packages/runtime/host-config.js +26 -0
- package/dist/packages/runtime/host-storage.js +46 -0
- package/dist/packages/runtime/host.js +2 -0
- package/dist/packages/runtime/index.js +1 -0
- package/dist/packages/runtime/local-host.js +280 -0
- package/dist/packages/runtime/outbox-dispatcher.js +61 -0
- package/dist/packages/runtime/session-registry.js +42 -0
- package/dist/packages/server/configuration-routes.js +85 -0
- package/dist/packages/server/contracts.js +13 -0
- package/dist/packages/server/index.js +2 -0
- package/dist/packages/server/security.js +38 -0
- package/dist/packages/server/server.js +44 -0
- package/dist/packages/server/studio-assets.js +44 -0
- package/dist/packages/server/studio-session.js +28 -0
- package/dist/scripts/server/access-file.js +21 -0
- package/dist/scripts/server/cli-results.js +126 -0
- package/dist/scripts/server/open-browser.js +13 -0
- package/dist/scripts/server/run.js +76 -0
- package/dist/scripts/server/startup-error.js +17 -0
- package/dist/scripts/server/studio-entry-file.js +22 -0
- package/dist/studio/assets/index-Dh5KHl3J.css +1 -0
- package/dist/studio/assets/index-uXpu7a13.js +9 -0
- package/dist/studio/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/dist/studio/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/dist/studio/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/studio/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/studio/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/dist/studio/assets/space-grotesk-latin-ext-wght-normal-D9tNdqV9.woff2 +0 -0
- package/dist/studio/assets/space-grotesk-latin-wght-normal-BhU9QXUp.woff2 +0 -0
- package/dist/studio/assets/space-grotesk-vietnamese-wght-normal-D0rl6rjA.woff2 +0 -0
- package/dist/studio/brand/logo-perspectives.svg +1 -0
- package/dist/studio/favicon.svg +1 -0
- package/dist/studio/index.html +14 -0
- package/dist/studio/licenses/jetbrains-mono.txt +93 -0
- package/dist/studio/licenses/space-grotesk.txt +93 -0
- package/dist/studio/participants/badger.png +0 -0
- package/dist/studio/participants/compass.png +0 -0
- package/dist/studio/participants/fox.png +0 -0
- package/dist/studio/participants/owl.png +0 -0
- package/dist/studio/participants/raven.png +0 -0
- package/dist/studio/participants/sparrow.png +0 -0
- package/offscript.mjs +104 -0
- package/package.json +100 -4
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { openPersistence } from "../persistence/index.js";
|
|
3
|
+
import { createLocalServer, createStudioSession } from "../server/index.js";
|
|
4
|
+
import { simulationId } from "../domain/index.js";
|
|
5
|
+
import { createOutboxDispatcher } from "./outbox-dispatcher.js";
|
|
6
|
+
import { localHostConfig } from "./host-config.js";
|
|
7
|
+
import { hostStorage } from "./host-storage.js";
|
|
8
|
+
import { sessionRegistry } from "./session-registry.js";
|
|
9
|
+
import { withinDeadline } from "./deadline.js";
|
|
10
|
+
import { ApplicationError, executeConfigureSimulation, executeCheckTarget, executeDeleteConfiguredSimulation, executeRecoverSimulation } from "../application/index.js";
|
|
11
|
+
import { inspectProject, createTargetProbe } from "../projects/index.js";
|
|
12
|
+
import { ContractValidationError, evaluateFindingAssessment, parseLocalTargetUrl } from "../contracts/index.js";
|
|
13
|
+
import { executionCoordinator } from "./execution-coordinator.js";
|
|
14
|
+
import { createEvidenceStore } from "./evidence-store.js";
|
|
15
|
+
export function createLocalHost(options) {
|
|
16
|
+
const config = localHostConfig(options);
|
|
17
|
+
const onDiagnostic = options.onDiagnostic;
|
|
18
|
+
const token = randomBytes(32).toString("base64url");
|
|
19
|
+
const studioSession = config.studioDirectory ? createStudioSession() : undefined;
|
|
20
|
+
const evidenceStore = createEvidenceStore(config.dataDirectory);
|
|
21
|
+
const registry = sessionRegistry();
|
|
22
|
+
const deleting = new Set();
|
|
23
|
+
const probe = createTargetProbe();
|
|
24
|
+
const probeLifetime = new AbortController();
|
|
25
|
+
let phase = "starting";
|
|
26
|
+
let port;
|
|
27
|
+
let origin;
|
|
28
|
+
let closing = false;
|
|
29
|
+
let coordinator;
|
|
30
|
+
let persistence;
|
|
31
|
+
let guarded;
|
|
32
|
+
let server;
|
|
33
|
+
let dispatcher;
|
|
34
|
+
let starting;
|
|
35
|
+
let closingTask;
|
|
36
|
+
let releasing;
|
|
37
|
+
function release() {
|
|
38
|
+
if (releasing)
|
|
39
|
+
return releasing;
|
|
40
|
+
phase = "stopping";
|
|
41
|
+
probeLifetime.abort();
|
|
42
|
+
registry.stopAdmission();
|
|
43
|
+
releasing = (async () => {
|
|
44
|
+
const errors = [];
|
|
45
|
+
try {
|
|
46
|
+
await server?.close();
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
errors.push(error);
|
|
50
|
+
}
|
|
51
|
+
const results = await Promise.allSettled([registry.close(), coordinator?.close(), dispatcher?.stop()]);
|
|
52
|
+
for (const result of results)
|
|
53
|
+
if (result.status === "rejected")
|
|
54
|
+
errors.push(result.reason);
|
|
55
|
+
// Stop publication before denying late storage calls. Already admitted DB
|
|
56
|
+
// operations finish before the connection closes; provider work is separate.
|
|
57
|
+
await guarded?.drain();
|
|
58
|
+
try {
|
|
59
|
+
persistence?.close();
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
errors.push(error);
|
|
63
|
+
}
|
|
64
|
+
phase = "stopped";
|
|
65
|
+
if (errors.length)
|
|
66
|
+
throw new AggregateError(errors, "Local host cleanup failed.");
|
|
67
|
+
})();
|
|
68
|
+
return releasing;
|
|
69
|
+
}
|
|
70
|
+
async function initialize() {
|
|
71
|
+
try {
|
|
72
|
+
persistence = openPersistence({ projectRoot: config.projectRoot, dataDirectory: config.dataDirectory });
|
|
73
|
+
persistence.migrate();
|
|
74
|
+
guarded = hostStorage(persistence);
|
|
75
|
+
coordinator = executionCoordinator({
|
|
76
|
+
storage: guarded.storage,
|
|
77
|
+
prepareSession: (id, create) => registry.add(id, create),
|
|
78
|
+
factory: options.sessionFactory,
|
|
79
|
+
evidenceStore,
|
|
80
|
+
});
|
|
81
|
+
server = createLocalServer({ token, port: () => port, allowedOrigins: config.allowedOrigins, phase: () => phase,
|
|
82
|
+
configurations: api,
|
|
83
|
+
...(config.studioDirectory && studioSession ? { studioDirectory: config.studioDirectory, studioSession } : {}),
|
|
84
|
+
});
|
|
85
|
+
await server.ready();
|
|
86
|
+
if (closing)
|
|
87
|
+
throw new Error("Host closed during startup.");
|
|
88
|
+
origin = await server.listen({ host: "127.0.0.1", port: config.port });
|
|
89
|
+
const address = server.server.address();
|
|
90
|
+
if (!address || typeof address === "string")
|
|
91
|
+
throw new Error("Expected a TCP listener.");
|
|
92
|
+
port = address.port;
|
|
93
|
+
if (closing)
|
|
94
|
+
throw new Error("Host closed during startup.");
|
|
95
|
+
dispatcher = createOutboxDispatcher({ store: guarded.storage.outbox, publisher: registry.publisher,
|
|
96
|
+
publishTimeoutMs: 1_000,
|
|
97
|
+
onError() {
|
|
98
|
+
if (!closing)
|
|
99
|
+
phase = "failed";
|
|
100
|
+
try {
|
|
101
|
+
onDiagnostic?.("OUTBOX_FAILED");
|
|
102
|
+
}
|
|
103
|
+
catch { /* Diagnostics must not break cleanup. */ }
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
phase = "ready";
|
|
107
|
+
dispatcher.start();
|
|
108
|
+
return origin;
|
|
109
|
+
}
|
|
110
|
+
catch (cause) {
|
|
111
|
+
try {
|
|
112
|
+
await release();
|
|
113
|
+
}
|
|
114
|
+
catch (cleanup) {
|
|
115
|
+
throw new AggregateError([cause, cleanup], "Local host startup and cleanup failed.");
|
|
116
|
+
}
|
|
117
|
+
throw new Error("Local host startup failed.", { cause });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const api = Object.freeze({
|
|
121
|
+
config,
|
|
122
|
+
phase: () => phase,
|
|
123
|
+
start() {
|
|
124
|
+
if (closing || phase === "stopped")
|
|
125
|
+
return Promise.reject(new Error("Host cannot restart after closing."));
|
|
126
|
+
starting ??= initialize();
|
|
127
|
+
return starting;
|
|
128
|
+
},
|
|
129
|
+
credentials() {
|
|
130
|
+
if (phase !== "ready" || origin === undefined)
|
|
131
|
+
throw new Error("Host is not ready.");
|
|
132
|
+
return Object.freeze({ origin, token });
|
|
133
|
+
},
|
|
134
|
+
studioEntry() {
|
|
135
|
+
if (phase !== "ready" || !origin || !studioSession)
|
|
136
|
+
throw new Error("Studio is not available.");
|
|
137
|
+
return Object.freeze({ origin, entry: studioSession.issueEntry() });
|
|
138
|
+
},
|
|
139
|
+
async listConfigurations() {
|
|
140
|
+
const storage = guarded?.storage;
|
|
141
|
+
if (phase !== "ready" || closing || !storage)
|
|
142
|
+
throw new Error("Host is not accepting configuration reads.");
|
|
143
|
+
return storage.configurations.listConfigurations();
|
|
144
|
+
},
|
|
145
|
+
async inspectProject() {
|
|
146
|
+
if (phase !== "ready" || closing)
|
|
147
|
+
throw new Error("Host is not accepting project inspection.");
|
|
148
|
+
return inspectProject(config.projectRoot);
|
|
149
|
+
},
|
|
150
|
+
async checkTarget(url, signal) {
|
|
151
|
+
if (phase !== "ready" || closing)
|
|
152
|
+
throw new Error("Host is not accepting connection checks.");
|
|
153
|
+
const target = new URL(parseLocalTargetUrl(url));
|
|
154
|
+
if (Number(target.port || (target.protocol === "https:" ? 443 : 80)) === port) {
|
|
155
|
+
throw new ContractValidationError("target.url", "Select the application port, not OFFSCRIPT's port.");
|
|
156
|
+
}
|
|
157
|
+
return executeCheckTarget(url, probe, AbortSignal.any([probeLifetime.signal, ...(signal ? [signal] : [])]));
|
|
158
|
+
},
|
|
159
|
+
async configureSimulation(input) {
|
|
160
|
+
const storage = guarded?.storage;
|
|
161
|
+
if (phase !== "ready" || closing || !storage)
|
|
162
|
+
throw new Error("Host is not accepting simulation configuration.");
|
|
163
|
+
return executeConfigureSimulation({ ...input, projectRoot: config.projectRoot }, {
|
|
164
|
+
projects: { inspectProject }, configurations: { ...storage.configurations,
|
|
165
|
+
async createConfiguredSimulation(configuration, simulation) {
|
|
166
|
+
if (phase !== "ready" || closing)
|
|
167
|
+
throw new Error("Host closed before configuration commit.");
|
|
168
|
+
return storage.configurations.createConfiguredSimulation(configuration, simulation);
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
},
|
|
173
|
+
async findConfiguration(id) {
|
|
174
|
+
const storage = guarded?.storage;
|
|
175
|
+
if (phase !== "ready" || closing || !storage)
|
|
176
|
+
throw new Error("Host is not accepting configuration reads.");
|
|
177
|
+
return storage.configurations.findConfiguration(simulationId(id));
|
|
178
|
+
},
|
|
179
|
+
async deleteConfiguration(id) {
|
|
180
|
+
simulationId(id);
|
|
181
|
+
const storage = guarded?.storage;
|
|
182
|
+
if (phase !== "ready" || closing || !storage)
|
|
183
|
+
throw new Error("Host is not accepting deletion requests.");
|
|
184
|
+
if (registry.has(id) || deleting.has(id) || coordinator?.hasActive(id))
|
|
185
|
+
return "not-allowed";
|
|
186
|
+
deleting.add(id);
|
|
187
|
+
try {
|
|
188
|
+
const result = await executeDeleteConfiguredSimulation(id, storage.configurations);
|
|
189
|
+
if (result === "deleted")
|
|
190
|
+
void evidenceStore.deleteSimulationEvidence(id);
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
deleting.delete(id);
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
async startSimulation(id, mode) {
|
|
198
|
+
if (phase !== "ready" || closing)
|
|
199
|
+
throw new Error("Host is not accepting execution commands.");
|
|
200
|
+
if (!coordinator)
|
|
201
|
+
throw new Error("No execution session factory configured on this host.");
|
|
202
|
+
return coordinator.start(simulationId(id), mode);
|
|
203
|
+
},
|
|
204
|
+
async stopSimulation(id) {
|
|
205
|
+
if (phase !== "ready" || closing)
|
|
206
|
+
throw new Error("Host is not accepting execution commands.");
|
|
207
|
+
if (!coordinator)
|
|
208
|
+
throw new Error("No execution session factory configured on this host.");
|
|
209
|
+
return coordinator.stop(simulationId(id));
|
|
210
|
+
},
|
|
211
|
+
async inspectExecution(id) {
|
|
212
|
+
if (phase !== "ready" || closing)
|
|
213
|
+
throw new Error("Host is not accepting execution inspection.");
|
|
214
|
+
if (!coordinator)
|
|
215
|
+
throw new Error("No execution session factory configured on this host.");
|
|
216
|
+
return coordinator.inspect(simulationId(id));
|
|
217
|
+
},
|
|
218
|
+
async recoverSimulation(id, action, reason) {
|
|
219
|
+
if (phase !== "ready" || closing)
|
|
220
|
+
throw new Error("Host is not accepting recovery commands.");
|
|
221
|
+
const storage = guarded?.storage;
|
|
222
|
+
if (!storage)
|
|
223
|
+
throw new Error("Storage not initialized.");
|
|
224
|
+
if (coordinator?.hasActive(simulationId(id))) {
|
|
225
|
+
throw new ApplicationError("EXECUTION_ALREADY_ACTIVE", "Cannot recover an actively executing simulation.");
|
|
226
|
+
}
|
|
227
|
+
await executeRecoverSimulation({ simulationId: simulationId(id), action, reason }, storage.simulations);
|
|
228
|
+
return coordinator?.inspect(simulationId(id)) ?? Object.freeze({
|
|
229
|
+
simulationId: id, phase: action === "cancel" ? "cancelled" : "failed",
|
|
230
|
+
turnsCount: 0, activeAgents: [],
|
|
231
|
+
});
|
|
232
|
+
},
|
|
233
|
+
async getSimulationResults(id) {
|
|
234
|
+
const storage = guarded?.storage;
|
|
235
|
+
if (phase !== "ready" || closing || !storage)
|
|
236
|
+
throw new Error("Host is not accepting results queries.");
|
|
237
|
+
const observations = await storage.observations.listObservations(simulationId(id));
|
|
238
|
+
const investigations = await storage.investigations.listInvestigations(simulationId(id));
|
|
239
|
+
const completed = investigations.filter((inv) => inv.lifecycle.status === "completed");
|
|
240
|
+
const findings = completed.map(inv => evaluateFindingAssessment(inv, observations));
|
|
241
|
+
return Object.freeze({
|
|
242
|
+
simulationId: id,
|
|
243
|
+
observations,
|
|
244
|
+
investigations,
|
|
245
|
+
findings,
|
|
246
|
+
});
|
|
247
|
+
},
|
|
248
|
+
async getEvidence(id, evidenceId) {
|
|
249
|
+
if (phase !== "ready" || closing)
|
|
250
|
+
throw new Error("Host is not accepting evidence queries.");
|
|
251
|
+
return evidenceStore.getEvidence(simulationId(id), evidenceId);
|
|
252
|
+
},
|
|
253
|
+
prepareSession(id, create) {
|
|
254
|
+
simulationId(id);
|
|
255
|
+
const storage = guarded?.storage;
|
|
256
|
+
if (phase !== "ready" || closing || !storage)
|
|
257
|
+
throw new Error("Host is not accepting sessions.");
|
|
258
|
+
if (deleting.has(id))
|
|
259
|
+
throw new Error("Simulation deletion is in progress.");
|
|
260
|
+
return registry.add(id, () => create(Object.freeze({ storage, outboxMode: "external" })));
|
|
261
|
+
},
|
|
262
|
+
close() {
|
|
263
|
+
if (closingTask)
|
|
264
|
+
return closingTask;
|
|
265
|
+
if (phase === "stopped") {
|
|
266
|
+
closing = true;
|
|
267
|
+
closingTask = releasing ?? Promise.resolve();
|
|
268
|
+
return closingTask;
|
|
269
|
+
}
|
|
270
|
+
closing = true;
|
|
271
|
+
probeLifetime.abort();
|
|
272
|
+
registry.stopAdmission();
|
|
273
|
+
phase = "stopping";
|
|
274
|
+
const cleanup = (async () => { await starting?.catch(() => { }); await release(); })();
|
|
275
|
+
closingTask = withinDeadline(cleanup, config.shutdownTimeoutMs);
|
|
276
|
+
return closingTask;
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
return api;
|
|
280
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { dispatchOutboxBatch, validateDispatch } from "../application/index.js";
|
|
3
|
+
export function createOutboxDispatcher(options) {
|
|
4
|
+
const dispatch = Object.freeze({ ...(options.dispatch ?? { leaseMs: 30_000, batchSize: 10, maxAttempts: 5, retryBaseMs: 100, retryMaxMs: 10_000, deferMs: 250 }) });
|
|
5
|
+
validateDispatch(dispatch);
|
|
6
|
+
const timeout = options.publishTimeoutMs ?? Math.min(5_000, dispatch.leaseMs - 1);
|
|
7
|
+
const pollMs = options.pollMs ?? 25;
|
|
8
|
+
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout >= dispatch.leaseMs || !Number.isSafeInteger(pollMs) || pollMs < 1 || pollMs > 2_147_483_647)
|
|
9
|
+
throw new Error("Invalid dispatcher timing.");
|
|
10
|
+
const publisher = {
|
|
11
|
+
async publish(event) {
|
|
12
|
+
let timer;
|
|
13
|
+
try {
|
|
14
|
+
return await Promise.race([Promise.resolve().then(() => options.publisher.publish(event)),
|
|
15
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Outbox publication timed out.")), timeout); })]);
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
let running;
|
|
23
|
+
let timer;
|
|
24
|
+
let started = false;
|
|
25
|
+
let stopped = false;
|
|
26
|
+
function runOnce() {
|
|
27
|
+
if (stopped)
|
|
28
|
+
return Promise.reject(new Error("Dispatcher is stopped."));
|
|
29
|
+
if (running)
|
|
30
|
+
return running;
|
|
31
|
+
running = dispatchOutboxBatch({ store: options.store, publisher, now: options.now ?? Date.now, token: options.token ?? randomUUID }, dispatch)
|
|
32
|
+
.finally(() => { running = undefined; });
|
|
33
|
+
return running;
|
|
34
|
+
}
|
|
35
|
+
async function tick() {
|
|
36
|
+
try {
|
|
37
|
+
await runOnce();
|
|
38
|
+
}
|
|
39
|
+
catch (cause) {
|
|
40
|
+
try {
|
|
41
|
+
options.onError?.(cause);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
stopped = true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
if (!stopped) {
|
|
49
|
+
timer = setTimeout(() => { void tick(); }, pollMs);
|
|
50
|
+
timer.unref();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return Object.freeze({ runOnce,
|
|
55
|
+
start() { if (!started && !stopped) {
|
|
56
|
+
started = true;
|
|
57
|
+
void tick();
|
|
58
|
+
} },
|
|
59
|
+
async stop() { stopped = true; clearTimeout(timer); await running; },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function sessionRegistry() {
|
|
2
|
+
const sessions = new Map();
|
|
3
|
+
const preparing = new Set();
|
|
4
|
+
let accepting = true;
|
|
5
|
+
const publisher = {
|
|
6
|
+
async publish(event) {
|
|
7
|
+
const session = accepting ? sessions.get(event.simulationId) : undefined;
|
|
8
|
+
if (!session || !session.canAcceptEvent(event))
|
|
9
|
+
return "deferred";
|
|
10
|
+
session.publishEvent(event);
|
|
11
|
+
return "accepted";
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
return {
|
|
15
|
+
publisher,
|
|
16
|
+
has(id) { return sessions.has(id) || preparing.has(id); },
|
|
17
|
+
add(id, create) {
|
|
18
|
+
if (!accepting)
|
|
19
|
+
throw new Error("Host is closing.");
|
|
20
|
+
if (sessions.has(id) || preparing.has(id))
|
|
21
|
+
throw new Error("Simulation already has a hosted session.");
|
|
22
|
+
preparing.add(id);
|
|
23
|
+
try {
|
|
24
|
+
const session = create();
|
|
25
|
+
sessions.set(id, session);
|
|
26
|
+
return session;
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
preparing.delete(id);
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
stopAdmission() { accepting = false; },
|
|
33
|
+
async close() {
|
|
34
|
+
accepting = false;
|
|
35
|
+
const results = await Promise.allSettled([...sessions.values()].map(session => Promise.resolve().then(() => session.close())));
|
|
36
|
+
sessions.clear();
|
|
37
|
+
const failures = results.filter(result => result.status === "rejected");
|
|
38
|
+
if (failures.length)
|
|
39
|
+
throw new AggregateError(failures.map(result => result.reason), "Session shutdown failed.");
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { simulationId } from "../domain/index.js";
|
|
2
|
+
import { parseSimulationSetup, parseStartSimulationRequest } from "../contracts/index.js";
|
|
3
|
+
import { parseLocalTargetUrl } from "../contracts/index.js";
|
|
4
|
+
export function configurationRoutes(server, services) {
|
|
5
|
+
server.get("/api/project", async () => services.inspectProject());
|
|
6
|
+
server.post("/api/target/check", { bodyLimit: 4096 }, async (request, reply) => {
|
|
7
|
+
const body = request.body;
|
|
8
|
+
if (!body || typeof body !== "object" || Array.isArray(body) || !('url' in body) || Object.keys(body).some(key => key !== "url")) {
|
|
9
|
+
return reply.code(400).send({ error: "INVALID_REQUEST" });
|
|
10
|
+
}
|
|
11
|
+
const abort = new AbortController();
|
|
12
|
+
const disconnect = () => { if (!reply.raw.writableFinished)
|
|
13
|
+
abort.abort(); };
|
|
14
|
+
reply.raw.once("close", disconnect);
|
|
15
|
+
try {
|
|
16
|
+
return await services.checkTarget(parseLocalTargetUrl(body.url), abort.signal);
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
reply.raw.off("close", disconnect);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
server.get("/api/simulations", async () => services.listConfigurations());
|
|
23
|
+
server.delete("/api/simulations/:id", async (request, reply) => {
|
|
24
|
+
const result = await services.deleteConfiguration(simulationId(request.params.id));
|
|
25
|
+
if (result === "not-found")
|
|
26
|
+
return reply.code(404).send({ error: "NOT_FOUND" });
|
|
27
|
+
if (result === "not-allowed")
|
|
28
|
+
return reply.code(409).send({ error: "DELETION_NOT_ALLOWED" });
|
|
29
|
+
return reply.code(204).send();
|
|
30
|
+
});
|
|
31
|
+
server.post("/api/simulations", { bodyLimit: 16_384 }, async (request, reply) => {
|
|
32
|
+
const body = request.body;
|
|
33
|
+
if (body === null || typeof body !== "object" || Array.isArray(body) ||
|
|
34
|
+
Reflect.ownKeys(body).some(key => key !== "simulationId" && key !== "setup") || !("simulationId" in body) || !("setup" in body) || typeof body.simulationId !== "string") {
|
|
35
|
+
return reply.code(400).send({ error: "INVALID_REQUEST" });
|
|
36
|
+
}
|
|
37
|
+
const result = await services.configureSimulation({ simulationId: simulationId(body.simulationId), setup: parseSimulationSetup(body.setup) });
|
|
38
|
+
return reply.code(201).send(result.configuration);
|
|
39
|
+
});
|
|
40
|
+
server.get("/api/simulations/:id", async (request, reply) => {
|
|
41
|
+
const value = await services.findConfiguration(simulationId(request.params.id));
|
|
42
|
+
if (!value)
|
|
43
|
+
return reply.code(404).send({ error: "NOT_FOUND" });
|
|
44
|
+
return value;
|
|
45
|
+
});
|
|
46
|
+
server.post("/api/simulations/:id/start", { bodyLimit: 4096 }, async (request, reply) => {
|
|
47
|
+
const { mode } = parseStartSimulationRequest(request.body);
|
|
48
|
+
const status = await services.startSimulation(simulationId(request.params.id), mode);
|
|
49
|
+
return reply.code(200).send(status);
|
|
50
|
+
});
|
|
51
|
+
server.get("/api/simulations/:id/execution", async (request) => {
|
|
52
|
+
return services.inspectExecution(simulationId(request.params.id));
|
|
53
|
+
});
|
|
54
|
+
server.get("/api/simulations/:id/results", async (request, reply) => {
|
|
55
|
+
const id = simulationId(request.params.id);
|
|
56
|
+
const value = await services.findConfiguration(id);
|
|
57
|
+
if (!value)
|
|
58
|
+
return reply.code(404).send({ error: "NOT_FOUND" });
|
|
59
|
+
return services.getSimulationResults(id);
|
|
60
|
+
});
|
|
61
|
+
server.get("/api/simulations/:id/evidence/:evidenceId", async (request, reply) => {
|
|
62
|
+
if (!services.getEvidence)
|
|
63
|
+
return reply.code(404).send({ error: "NOT_FOUND" });
|
|
64
|
+
const evId = request.params.evidenceId;
|
|
65
|
+
if (!evId || evId.includes("..") || evId.includes("/") || evId.includes("\\")) {
|
|
66
|
+
return reply.code(400).send({ error: "INVALID_REQUEST" });
|
|
67
|
+
}
|
|
68
|
+
const evidence = await services.getEvidence(simulationId(request.params.id), evId);
|
|
69
|
+
if (!evidence)
|
|
70
|
+
return reply.code(404).send({ error: "NOT_FOUND" });
|
|
71
|
+
return reply.type(evidence.contentType).header("Cache-Control", "private, no-cache, no-store, must-revalidate").send(evidence.data);
|
|
72
|
+
});
|
|
73
|
+
server.post("/api/simulations/:id/stop", async (request) => {
|
|
74
|
+
return services.stopSimulation(simulationId(request.params.id));
|
|
75
|
+
});
|
|
76
|
+
server.post("/api/simulations/:id/recover", { bodyLimit: 4096 }, async (request, reply) => {
|
|
77
|
+
if (!services.recoverSimulation)
|
|
78
|
+
return reply.code(501).send({ error: "NOT_IMPLEMENTED" });
|
|
79
|
+
const id = simulationId(request.params.id);
|
|
80
|
+
const body = request.body;
|
|
81
|
+
const action = body?.action === "cancel" ? "cancel" : "fail";
|
|
82
|
+
const status = await services.recoverSimulation(id, action, body?.reason);
|
|
83
|
+
return reply.code(200).send(status);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const status = (values) => ({
|
|
2
|
+
type: "object", additionalProperties: false, required: ["status"],
|
|
3
|
+
properties: { status: { type: "string", enum: values } },
|
|
4
|
+
});
|
|
5
|
+
export const errorSchema = {
|
|
6
|
+
type: "object", additionalProperties: false, required: ["error"],
|
|
7
|
+
properties: { error: { type: "string", enum: ["UNAUTHORIZED", "HOST_NOT_ALLOWED", "ORIGIN_NOT_ALLOWED", "NOT_FOUND", "INVALID_REQUEST", "INTERNAL_ERROR"] } },
|
|
8
|
+
};
|
|
9
|
+
const errors = { 400: errorSchema, 401: errorSchema, 403: errorSchema, 404: errorSchema, 500: errorSchema };
|
|
10
|
+
export const healthSchemas = {
|
|
11
|
+
live: { response: { ...errors, 200: status(["alive"]) } },
|
|
12
|
+
ready: { response: { ...errors, 200: status(["ready"]), 503: status(["not-ready"]) } },
|
|
13
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { publicStudioPath } from "./studio-assets.js";
|
|
3
|
+
export function secureLocalAccess(server, options) {
|
|
4
|
+
const digest = (text) => createHash("sha256").update(text).digest();
|
|
5
|
+
const expected = digest(options.token);
|
|
6
|
+
const allowed = new Set(options.allowedOrigins);
|
|
7
|
+
server.addHook("onRequest", async (request, reply) => {
|
|
8
|
+
reply.header("Cache-Control", "no-store");
|
|
9
|
+
reply.header("X-Content-Type-Options", "nosniff");
|
|
10
|
+
const port = options.port();
|
|
11
|
+
const authority = port === 80 ? "" : `:${port}`;
|
|
12
|
+
const hosts = new Set([`127.0.0.1${authority}`, `localhost${authority}`]);
|
|
13
|
+
if (port === undefined || !hosts.has(request.headers.host ?? "")) {
|
|
14
|
+
return reply.code(403).send({ error: "HOST_NOT_ALLOWED" });
|
|
15
|
+
}
|
|
16
|
+
const origin = request.headers.origin;
|
|
17
|
+
const pathname = request.url.split("?")[0] ?? "";
|
|
18
|
+
const entryPost = options.studioSession !== undefined && pathname === "/studio/session" && request.method === "POST";
|
|
19
|
+
// Public assets contain no project data. A local-file redirect can retain
|
|
20
|
+
// cross-site fetch metadata even after the credential exchange succeeds.
|
|
21
|
+
if (options.studioSession && publicStudioPath(request.method, pathname))
|
|
22
|
+
return;
|
|
23
|
+
if ((origin !== undefined && !(entryPost && origin === "null") && !allowed.has(origin) && origin !== `http://${request.headers.host}`) ||
|
|
24
|
+
(origin === undefined && !entryPost && request.headers["sec-fetch-site"] === "cross-site")) {
|
|
25
|
+
return reply.code(403).send({ error: "ORIGIN_NOT_ALLOWED" });
|
|
26
|
+
}
|
|
27
|
+
if (options.studioSession && (entryPost || publicStudioPath(request.method, pathname)))
|
|
28
|
+
return;
|
|
29
|
+
if (pathname.startsWith("/api/") && request.headers["x-offscript-client"] === "studio" && options.studioSession?.accepts(request.headers.cookie))
|
|
30
|
+
return;
|
|
31
|
+
const authorization = request.headers.authorization;
|
|
32
|
+
const token = authorization?.match(/^Bearer ([A-Za-z0-9_-]{43})$/i)?.[1];
|
|
33
|
+
if (!token || !timingSafeEqual(expected, digest(token))) {
|
|
34
|
+
reply.header("WWW-Authenticate", "Bearer");
|
|
35
|
+
return reply.code(401).send({ error: "UNAUTHORIZED" });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import Fastify from "fastify";
|
|
2
|
+
import { errorSchema, healthSchemas } from "./contracts.js";
|
|
3
|
+
import { secureLocalAccess } from "./security.js";
|
|
4
|
+
import { ContractValidationError } from "../contracts/index.js";
|
|
5
|
+
import { DomainError } from "../domain/index.js";
|
|
6
|
+
import { ApplicationError } from "../application/index.js";
|
|
7
|
+
import { configurationRoutes } from "./configuration-routes.js";
|
|
8
|
+
import { studioAssets } from "./studio-assets.js";
|
|
9
|
+
export function createLocalServer(options) {
|
|
10
|
+
const server = Fastify({ logger: false, trustProxy: false, bodyLimit: 1024,
|
|
11
|
+
requestTimeout: 5_000, connectionTimeout: 5_000, forceCloseConnections: true, exposeHeadRoutes: false });
|
|
12
|
+
server.addSchema({ $id: "host-error", ...errorSchema });
|
|
13
|
+
secureLocalAccess(server, options);
|
|
14
|
+
server.setErrorHandler((error, _request, reply) => {
|
|
15
|
+
if (error instanceof ContractValidationError)
|
|
16
|
+
return reply.code(400).send({ error: "INVALID_CONFIGURATION", field: error.path });
|
|
17
|
+
if (error instanceof DomainError)
|
|
18
|
+
return reply.code(400).send({ error: "INVALID_CONFIGURATION" });
|
|
19
|
+
if (error instanceof ApplicationError) {
|
|
20
|
+
if (error.code === "SIMULATION_ALREADY_EXISTS")
|
|
21
|
+
return reply.code(409).send({ error: "SIMULATION_ALREADY_EXISTS" });
|
|
22
|
+
if (error.code === "SIMULATION_NOT_FOUND")
|
|
23
|
+
return reply.code(404).send({ error: "NOT_FOUND" });
|
|
24
|
+
if (error.code === "EXECUTION_ALREADY_ACTIVE" || error.code === "INVALID_EXECUTION_STATE") {
|
|
25
|
+
return reply.code(409).send({ error: "EXECUTION_CONFLICT" });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const code = error && typeof error === "object" && "statusCode" in error ? error.statusCode : undefined;
|
|
29
|
+
const status = typeof code === "number" && code >= 400 && code < 500 ? 400 : 500;
|
|
30
|
+
return reply.code(status).send({ error: status === 400 ? "INVALID_REQUEST" : "INTERNAL_ERROR" });
|
|
31
|
+
});
|
|
32
|
+
if (options.configurations)
|
|
33
|
+
configurationRoutes(server, options.configurations);
|
|
34
|
+
if (options.studioDirectory && options.studioSession)
|
|
35
|
+
studioAssets(server, options.studioDirectory, options.studioSession);
|
|
36
|
+
server.setNotFoundHandler((_request, reply) => reply.code(404).send({ error: "NOT_FOUND" }));
|
|
37
|
+
server.get("/health/live", { schema: healthSchemas.live }, async () => ({ status: "alive" }));
|
|
38
|
+
server.get("/health/ready", { schema: healthSchemas.ready }, async (_request, reply) => {
|
|
39
|
+
if (options.phase() !== "ready")
|
|
40
|
+
return reply.code(503).send({ status: "not-ready" });
|
|
41
|
+
return { status: "ready" };
|
|
42
|
+
});
|
|
43
|
+
return server;
|
|
44
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import send from "@fastify/send";
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export function publicStudioPath(method, pathname) {
|
|
5
|
+
return method === "GET" && (["/", "/simulations/new", "/favicon.svg"].includes(pathname) ||
|
|
6
|
+
["/assets/", "/participants/", "/brand/"].some(prefix => pathname.startsWith(prefix)));
|
|
7
|
+
}
|
|
8
|
+
export function studioAssets(server, root, session) {
|
|
9
|
+
if (!statSync(join(root, "index.html")).isFile())
|
|
10
|
+
throw new Error("Build Studio before starting its host.");
|
|
11
|
+
async function file(request, reply, path) {
|
|
12
|
+
const encodedPath = path.split("/").map(encodeURIComponent).join("/");
|
|
13
|
+
const asset = await send(request.raw, encodedPath, { root, dotfiles: "deny", cacheControl: false, index: false });
|
|
14
|
+
if (asset.type !== "file") {
|
|
15
|
+
asset.stream.destroy();
|
|
16
|
+
const status = asset.statusCode >= 500 ? 500 : 404;
|
|
17
|
+
return reply.code(status).send({ error: status === 500 ? "INTERNAL_ERROR" : "NOT_FOUND" });
|
|
18
|
+
}
|
|
19
|
+
return reply.code(asset.statusCode).headers(asset.headers).send(asset.stream);
|
|
20
|
+
}
|
|
21
|
+
for (const path of ["/", "/simulations/new"])
|
|
22
|
+
server.get(path, (request, reply) => file(request, reply, "index.html"));
|
|
23
|
+
server.get("/favicon.svg", (request, reply) => file(request, reply, "favicon.svg"));
|
|
24
|
+
for (const prefix of ["assets", "participants", "brand"]) {
|
|
25
|
+
server.get(`/${prefix}/*`, (request, reply) => {
|
|
26
|
+
const path = request.params["*"];
|
|
27
|
+
if (!path || path.includes("..") || path.includes("\\") || path.startsWith("/"))
|
|
28
|
+
return reply.code(404).send({ error: "NOT_FOUND" });
|
|
29
|
+
return file(request, reply, `${prefix}/${path}`);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
server.addContentTypeParser("application/x-www-form-urlencoded", { parseAs: "string" }, (_request, body, done) => {
|
|
33
|
+
const fields = new URLSearchParams(String(body));
|
|
34
|
+
done(null, fields.getAll("entry").length === 1 ? { entry: fields.get("entry") } : {});
|
|
35
|
+
});
|
|
36
|
+
server.post("/studio/session", async (request, reply) => {
|
|
37
|
+
const body = request.body;
|
|
38
|
+
const token = session.exchange(body && typeof body === "object" && "entry" in body ? body.entry : undefined);
|
|
39
|
+
if (!token)
|
|
40
|
+
return reply.code(401).send({ error: "UNAUTHORIZED" });
|
|
41
|
+
reply.header("Set-Cookie", `offscript_session=${token}; HttpOnly; SameSite=Strict; Path=/api`);
|
|
42
|
+
return reply.code(303).header("Location", "/").send();
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
export function createStudioSession() {
|
|
3
|
+
const digest = (value) => createHash("sha256").update(value).digest();
|
|
4
|
+
let entry;
|
|
5
|
+
let session;
|
|
6
|
+
return Object.freeze({
|
|
7
|
+
issueEntry() {
|
|
8
|
+
const token = randomBytes(32).toString("base64url");
|
|
9
|
+
entry = { digest: digest(token), expiresAt: Date.now() + 300_000 };
|
|
10
|
+
return token;
|
|
11
|
+
},
|
|
12
|
+
exchange(value) {
|
|
13
|
+
if (typeof value !== "string" || value.length !== 43 || !entry || entry.expiresAt <= Date.now() || !timingSafeEqual(entry.digest, digest(value)))
|
|
14
|
+
return undefined;
|
|
15
|
+
entry = undefined;
|
|
16
|
+
const token = randomBytes(32).toString("base64url");
|
|
17
|
+
session = digest(token);
|
|
18
|
+
return token;
|
|
19
|
+
},
|
|
20
|
+
accepts(cookie) {
|
|
21
|
+
if (!cookie || !session)
|
|
22
|
+
return false;
|
|
23
|
+
const values = cookie.split(";").map(value => value.trim()).filter(value => value.startsWith("offscript_session="));
|
|
24
|
+
const token = values[0]?.slice("offscript_session=".length);
|
|
25
|
+
return values.length === 1 && token?.length === 43 && timingSafeEqual(session, digest(token));
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
}
|