nebula-notebook 0.2.15 → 0.2.17
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/assets/{errorwidget-B2XZ3hoa.js → errorwidget-t1RLHQYw.js} +1 -1
- package/dist/assets/{index-DqHzn4vg.js → index-BMuaZuus.js} +1 -1
- package/dist/assets/index-Bvhz0ltE.css +32 -0
- package/dist/assets/{index-NfxQwPBq.js → index-Cq1BG1_T.js} +205 -200
- package/dist/assets/{index-a9gKLTlv.js → index-DTGJj27s.js} +1 -1
- package/dist/assets/{index-CV0ZPmTH.js → index-DzeeB-u3.js} +1 -1
- package/dist/assets/{services-shim-D_SeYcB9.js → services-shim-Dg9Zi6xv.js} +1 -1
- package/dist/index.html +2 -2
- package/node-server/dist/auth/auth-middleware.js +3 -1
- package/node-server/dist/cluster/kernel-proxy.d.ts +15 -0
- package/node-server/dist/cluster/kernel-proxy.js +75 -0
- package/node-server/dist/fs/fs-service.js +104 -8
- package/node-server/dist/fs/index.d.ts +1 -0
- package/node-server/dist/fs/index.js +1 -0
- package/node-server/dist/fs/sealed-path.d.ts +41 -0
- package/node-server/dist/fs/sealed-path.js +228 -0
- package/node-server/dist/fs/types.d.ts +4 -2
- package/node-server/dist/fs/types.js +2 -2
- package/node-server/dist/index.js +24 -13
- package/node-server/dist/kernel/kernel-service.d.ts +2 -2
- package/node-server/dist/kernel/kernel-service.js +15 -5
- package/node-server/dist/kernel/types.d.ts +16 -0
- package/node-server/dist/notebook/headless-handler.d.ts +10 -0
- package/node-server/dist/notebook/headless-handler.js +64 -10
- package/node-server/dist/notebook/operation-router.js +13 -2
- package/node-server/dist/provenance/canonical-json.d.ts +36 -0
- package/node-server/dist/provenance/canonical-json.js +218 -0
- package/node-server/dist/provenance/provenance-store.d.ts +63 -0
- package/node-server/dist/provenance/provenance-store.js +598 -0
- package/node-server/dist/provenance/replay-seal-service.d.ts +223 -0
- package/node-server/dist/provenance/replay-seal-service.js +1702 -0
- package/node-server/dist/provenance/types.d.ts +65 -0
- package/node-server/dist/provenance/types.js +2 -0
- package/node-server/dist/routes/fs.js +27 -0
- package/node-server/dist/routes/kernel.js +15 -0
- package/node-server/dist/routes/notebook.js +54 -0
- package/node-server/dist/routes/replay-seal.d.ts +9 -0
- package/node-server/dist/routes/replay-seal.js +66 -0
- package/node-server/dist/scheduler/allocation-service.d.ts +3 -2
- package/node-server/dist/scheduler/allocation-service.js +45 -2
- package/node-server/dist/scheduler/arch.d.ts +30 -0
- package/node-server/dist/scheduler/arch.js +113 -0
- package/node-server/dist/scheduler/job-template.d.ts +11 -0
- package/node-server/dist/scheduler/mock-scheduler.d.ts +1 -0
- package/node-server/dist/scheduler/mock-scheduler.js +3 -0
- package/node-server/dist/scheduler/slurm-scheduler.d.ts +1 -0
- package/node-server/dist/scheduler/slurm-scheduler.js +34 -0
- package/node-server/dist/scheduler/types.d.ts +9 -0
- package/node-server/dist/scheduler/util.d.ts +6 -0
- package/node-server/dist/scheduler/util.js +16 -0
- package/node-server/dist/server/bind-host.d.ts +12 -0
- package/node-server/dist/server/bind-host.js +56 -0
- package/node-server/dist/server/cors-origin.d.ts +1 -0
- package/node-server/dist/server/cors-origin.js +32 -0
- package/package.json +1 -1
- package/dist/assets/index-Czch8hB-.css +0 -32
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Manages Jupyter kernel sessions - spawning, execution, and lifecycle.
|
|
5
5
|
* Uses ZeroMQ for kernel communication following the Jupyter messaging protocol.
|
|
6
6
|
*/
|
|
7
|
-
import { KernelOutput, ExecutionResult, ExecutionQueueInfo, StartKernelOptions, SessionInfo, KernelServiceConfig } from './types';
|
|
7
|
+
import { KernelOutput, ExecutionResult, ExecutionQueueInfo, InternalExecutionOptions, StartKernelOptions, SessionInfo, KernelServiceConfig } from './types';
|
|
8
8
|
import { SessionStore } from './session-store';
|
|
9
9
|
import { KernelSpec } from './kernelspec';
|
|
10
10
|
/** Kernel-originated comm event surfaced to onComm listeners. */
|
|
@@ -256,7 +256,7 @@ export declare class KernelService {
|
|
|
256
256
|
/**
|
|
257
257
|
* Execute code in a kernel session
|
|
258
258
|
*/
|
|
259
|
-
executeCode(sessionId: string, code: string, onOutput: (output: KernelOutput, cellId?: string | null) => Promise<void>, onQueueInfo?: (info: ExecutionQueueInfo) => void, cellId?: string | null): Promise<ExecutionResult>;
|
|
259
|
+
executeCode(sessionId: string, code: string, onOutput: (output: KernelOutput, cellId?: string | null) => Promise<void>, onQueueInfo?: (info: ExecutionQueueInfo) => void, cellId?: string | null, internalOptions?: InternalExecutionOptions): Promise<ExecutionResult>;
|
|
260
260
|
private enqueueExecution;
|
|
261
261
|
/**
|
|
262
262
|
* Serialize shell socket SENDS. Receives are owned by the unified shell
|
|
@@ -635,6 +635,12 @@ class KernelService {
|
|
|
635
635
|
*/
|
|
636
636
|
async startKernel(options = {}) {
|
|
637
637
|
const kernelName = options.kernelName || 'python3';
|
|
638
|
+
if (options.internalEnv
|
|
639
|
+
&& (Object.keys(options.internalEnv).some((key) => key !== 'PYTHONNOUSERSITE')
|
|
640
|
+
|| (options.internalEnv.PYTHONNOUSERSITE !== undefined
|
|
641
|
+
&& options.internalEnv.PYTHONNOUSERSITE !== '1'))) {
|
|
642
|
+
throw new Error('Unsupported internal kernel environment override');
|
|
643
|
+
}
|
|
638
644
|
// env:<pythonPath> kernels raw-launch a Python environment directly
|
|
639
645
|
// (VSCode-style) — no kernelspec registration involved. Preflight here so
|
|
640
646
|
// callers get a crisp, coded error instead of a spawn failure + ready
|
|
@@ -672,7 +678,7 @@ class KernelService {
|
|
|
672
678
|
const cwd = options.cwd || (normalizedFilePath ? path.dirname(normalizedFilePath) : process.cwd());
|
|
673
679
|
const proc = (0, child_process_1.spawn)(argv[0], argv.slice(1), {
|
|
674
680
|
cwd,
|
|
675
|
-
env: { ...process.env, ...spec.env },
|
|
681
|
+
env: { ...process.env, ...spec.env, ...options.internalEnv },
|
|
676
682
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
677
683
|
});
|
|
678
684
|
// Log kernel stdout/stderr for debugging
|
|
@@ -1408,7 +1414,7 @@ class KernelService {
|
|
|
1408
1414
|
/**
|
|
1409
1415
|
* Execute code in a kernel session
|
|
1410
1416
|
*/
|
|
1411
|
-
async executeCode(sessionId, code, onOutput, onQueueInfo, cellId) {
|
|
1417
|
+
async executeCode(sessionId, code, onOutput, onQueueInfo, cellId, internalOptions) {
|
|
1412
1418
|
// Polyfill `!command` lines for kernels whose language lacks them (see
|
|
1413
1419
|
// rewriteShellLines) — rewritten code still runs IN the kernel process,
|
|
1414
1420
|
// so cwd/env/node are the kernel's, exactly like IPython's native `!`.
|
|
@@ -1433,7 +1439,7 @@ class KernelService {
|
|
|
1433
1439
|
for (const o of stored) {
|
|
1434
1440
|
await onOutput(o, cellId);
|
|
1435
1441
|
}
|
|
1436
|
-
});
|
|
1442
|
+
}, internalOptions?.storeHistory !== false);
|
|
1437
1443
|
return { ...result, ...queueInfo };
|
|
1438
1444
|
}
|
|
1439
1445
|
catch (err) {
|
|
@@ -1506,7 +1512,7 @@ class KernelService {
|
|
|
1506
1512
|
}
|
|
1507
1513
|
return message;
|
|
1508
1514
|
}
|
|
1509
|
-
async executeCodeInternal(sessionId, code, onOutput) {
|
|
1515
|
+
async executeCodeInternal(sessionId, code, onOutput, storeHistory = true) {
|
|
1510
1516
|
const session = this.sessions.get(sessionId);
|
|
1511
1517
|
if (!session) {
|
|
1512
1518
|
throw new Error(`Session ${sessionId} not found`);
|
|
@@ -1537,7 +1543,7 @@ class KernelService {
|
|
|
1537
1543
|
const content = {
|
|
1538
1544
|
code,
|
|
1539
1545
|
silent: false,
|
|
1540
|
-
store_history:
|
|
1546
|
+
store_history: storeHistory,
|
|
1541
1547
|
user_expressions: {},
|
|
1542
1548
|
allow_stdin: false,
|
|
1543
1549
|
stop_on_error: true,
|
|
@@ -1571,6 +1577,10 @@ class KernelService {
|
|
|
1571
1577
|
else if (msgType === 'execute_result' || msgType === 'display_data') {
|
|
1572
1578
|
const output = this.formatDisplayData(msgContent.data || {}, msgContent.metadata || undefined);
|
|
1573
1579
|
if (output) {
|
|
1580
|
+
output.jupyterOutputType = msgType;
|
|
1581
|
+
if (msgType === 'execute_result' && Number.isSafeInteger(msgContent.execution_count)) {
|
|
1582
|
+
output.jupyterExecutionCount = msgContent.execution_count;
|
|
1583
|
+
}
|
|
1574
1584
|
await onOutput(output);
|
|
1575
1585
|
}
|
|
1576
1586
|
}
|
|
@@ -82,6 +82,14 @@ export interface KernelOutput {
|
|
|
82
82
|
mimeBundle?: MimeBundle;
|
|
83
83
|
metadata?: Record<string, JsonValue>;
|
|
84
84
|
preferredMimeType?: string;
|
|
85
|
+
/** Exact protocol shape retained for immutable replay sealing. */
|
|
86
|
+
jupyterOutputType?: 'execute_result' | 'display_data';
|
|
87
|
+
/** Present for execute_result protocol messages. */
|
|
88
|
+
jupyterExecutionCount?: number;
|
|
89
|
+
}
|
|
90
|
+
export interface InternalExecutionOptions {
|
|
91
|
+
/** Server-authored probes use false so they cannot perturb notebook counts. */
|
|
92
|
+
storeHistory?: false;
|
|
85
93
|
}
|
|
86
94
|
export interface SequencedKernelOutput {
|
|
87
95
|
seq: number;
|
|
@@ -114,6 +122,14 @@ export interface StartKernelOptions {
|
|
|
114
122
|
kernelName?: string;
|
|
115
123
|
cwd?: string;
|
|
116
124
|
filePath?: string;
|
|
125
|
+
/**
|
|
126
|
+
* Server-only launch hardening. This is intentionally not accepted by the
|
|
127
|
+
* public kernel HTTP route; replay sealing uses it to exclude user-site
|
|
128
|
+
* packages before the Python process starts.
|
|
129
|
+
*/
|
|
130
|
+
internalEnv?: {
|
|
131
|
+
PYTHONNOUSERSITE?: '1';
|
|
132
|
+
};
|
|
117
133
|
}
|
|
118
134
|
/**
|
|
119
135
|
* Session info returned by API
|
|
@@ -138,6 +138,16 @@ export declare class HeadlessOperationHandler {
|
|
|
138
138
|
* Used by startAgentSession to inform agent what changed between sessions.
|
|
139
139
|
*/
|
|
140
140
|
getUpdatesSince(notebookPath: string, sinceTimestamp: number): UpdateSummary[];
|
|
141
|
+
/**
|
|
142
|
+
* Get-or-create the execution session for a notebook: on its bound cluster
|
|
143
|
+
* server when a binding exists (proxied session id), locally otherwise.
|
|
144
|
+
* A dead binding falls back to a fresh local kernel — allocations end
|
|
145
|
+
* routinely (walltime, cancel), and that fallback is the documented
|
|
146
|
+
* "falls back to a new kernel on next use" behavior.
|
|
147
|
+
*/
|
|
148
|
+
private resolveKernelSession;
|
|
149
|
+
/** Start (get-or-create) a kernel on the bound server; null if it is gone. */
|
|
150
|
+
private startBoundKernel;
|
|
141
151
|
private startKernelOp;
|
|
142
152
|
private shutdownKernelOp;
|
|
143
153
|
private restartKernelOp;
|
|
@@ -49,6 +49,7 @@ const registry_1 = require("../fs/notebook-formats/registry");
|
|
|
49
49
|
const cell_metadata_1 = require("./cell-metadata");
|
|
50
50
|
const undoRedoManager_1 = require("./undoRedoManager");
|
|
51
51
|
const kernelspec_1 = require("../kernel/kernelspec");
|
|
52
|
+
const kernel_proxy_1 = require("../cluster/kernel-proxy");
|
|
52
53
|
function copyCellOutput(output) {
|
|
53
54
|
return {
|
|
54
55
|
type: (output.type || 'stdout'),
|
|
@@ -1345,11 +1346,16 @@ class HeadlessOperationHandler {
|
|
|
1345
1346
|
error: 'Kernel service not available. Make sure the Node.js server is properly initialized.',
|
|
1346
1347
|
};
|
|
1347
1348
|
}
|
|
1348
|
-
// Get or create a kernel session for this notebook
|
|
1349
|
+
// Get or create a kernel session for this notebook. A kernel preference
|
|
1350
|
+
// with a serverId means the notebook is bound to a cluster peer (compute
|
|
1351
|
+
// allocation) — the kernel must live THERE, not on this server: resolving
|
|
1352
|
+
// locally would run cells on the login node, and on a cross-arch
|
|
1353
|
+
// allocation the local ipykernel probe can't even run the env's python.
|
|
1349
1354
|
const requestedSessionId = operation.sessionId ?? operation.session_id;
|
|
1350
|
-
const
|
|
1355
|
+
const kernelPref = this.kernelService.getNotebookKernelPreference(notebookPath);
|
|
1356
|
+
const preferredKernelName = kernelPref?.kernelName || 'python3';
|
|
1351
1357
|
let sessionId = null;
|
|
1352
|
-
if (requestedSessionId && this.kernelService.hasSession(requestedSessionId)) {
|
|
1358
|
+
if (requestedSessionId && (this.kernelService.hasSession(requestedSessionId) || (0, kernel_proxy_1.isProxiedSession)(requestedSessionId))) {
|
|
1353
1359
|
sessionId = requestedSessionId;
|
|
1354
1360
|
}
|
|
1355
1361
|
else {
|
|
@@ -1357,8 +1363,7 @@ class HeadlessOperationHandler {
|
|
|
1357
1363
|
}
|
|
1358
1364
|
if (!sessionId) {
|
|
1359
1365
|
try {
|
|
1360
|
-
|
|
1361
|
-
sessionId = result.sessionId;
|
|
1366
|
+
sessionId = await this.resolveKernelSession(notebookPath, preferredKernelName, kernelPref?.serverId ?? null);
|
|
1362
1367
|
}
|
|
1363
1368
|
catch (err) {
|
|
1364
1369
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
@@ -1409,9 +1414,11 @@ class HeadlessOperationHandler {
|
|
|
1409
1414
|
// Create execution promise
|
|
1410
1415
|
const executeTask = async () => {
|
|
1411
1416
|
try {
|
|
1412
|
-
const result =
|
|
1413
|
-
|
|
1414
|
-
|
|
1417
|
+
const result = (0, kernel_proxy_1.isProxiedSession)(sessionId)
|
|
1418
|
+
? await (0, kernel_proxy_1.executeRemoteCode)(sessionId, code, outputCallback, actualCellId)
|
|
1419
|
+
: await this.kernelService.executeCode(sessionId, code, outputCallback, (info) => {
|
|
1420
|
+
queueInfo = info;
|
|
1421
|
+
}, actualCellId);
|
|
1415
1422
|
executionCount = result.executionCount;
|
|
1416
1423
|
if (!queueInfo && result.queuePosition !== undefined && result.queueLength !== undefined) {
|
|
1417
1424
|
queueInfo = { queuePosition: result.queuePosition, queueLength: result.queueLength };
|
|
@@ -1579,14 +1586,61 @@ class HeadlessOperationHandler {
|
|
|
1579
1586
|
// -------------------------------------------------------------------------
|
|
1580
1587
|
// Kernel Operations
|
|
1581
1588
|
// -------------------------------------------------------------------------
|
|
1589
|
+
/**
|
|
1590
|
+
* Get-or-create the execution session for a notebook: on its bound cluster
|
|
1591
|
+
* server when a binding exists (proxied session id), locally otherwise.
|
|
1592
|
+
* A dead binding falls back to a fresh local kernel — allocations end
|
|
1593
|
+
* routinely (walltime, cancel), and that fallback is the documented
|
|
1594
|
+
* "falls back to a new kernel on next use" behavior.
|
|
1595
|
+
*/
|
|
1596
|
+
async resolveKernelSession(notebookPath, kernelName, boundServerId) {
|
|
1597
|
+
if (boundServerId) {
|
|
1598
|
+
const remote = await this.startBoundKernel(notebookPath, kernelName, boundServerId);
|
|
1599
|
+
if (remote)
|
|
1600
|
+
return remote.sessionId;
|
|
1601
|
+
}
|
|
1602
|
+
const result = await this.kernelService.getOrCreateKernel(notebookPath, kernelName);
|
|
1603
|
+
return result.sessionId;
|
|
1604
|
+
}
|
|
1605
|
+
/** Start (get-or-create) a kernel on the bound server; null if it is gone. */
|
|
1606
|
+
async startBoundKernel(notebookPath, kernelName, serverId) {
|
|
1607
|
+
try {
|
|
1608
|
+
return await (0, kernel_proxy_1.startRemoteKernel)(serverId, kernelName, notebookPath);
|
|
1609
|
+
}
|
|
1610
|
+
catch (err) {
|
|
1611
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1612
|
+
console.warn(`[Headless] Bound server ${serverId} unavailable (${msg}) — falling back to a local kernel`);
|
|
1613
|
+
return null;
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1582
1616
|
async startKernelOp(operation, notebookPath) {
|
|
1583
1617
|
if (!this.kernelService) {
|
|
1584
1618
|
return { success: false, error: 'Kernel service not available' };
|
|
1585
1619
|
}
|
|
1586
1620
|
const kernelName = operation.kernelName || 'python3';
|
|
1587
1621
|
try {
|
|
1588
|
-
|
|
1589
|
-
|
|
1622
|
+
// Bound notebooks start their kernel on the bound server, and the
|
|
1623
|
+
// binding must survive the preference re-save — dropping serverId here
|
|
1624
|
+
// silently unbound notebooks from their allocations.
|
|
1625
|
+
const boundServerId = this.kernelService.getNotebookKernelPreference(notebookPath)?.serverId ?? null;
|
|
1626
|
+
let sessionId;
|
|
1627
|
+
let created;
|
|
1628
|
+
if (boundServerId) {
|
|
1629
|
+
const remote = await this.startBoundKernel(notebookPath, kernelName, boundServerId);
|
|
1630
|
+
if (remote) {
|
|
1631
|
+
sessionId = remote.sessionId;
|
|
1632
|
+
created = remote.created;
|
|
1633
|
+
this.kernelService.saveNotebookKernelPreference(notebookPath, kernelName, boundServerId);
|
|
1634
|
+
}
|
|
1635
|
+
else {
|
|
1636
|
+
({ sessionId, created } = await this.kernelService.getOrCreateKernel(notebookPath, kernelName));
|
|
1637
|
+
this.kernelService.saveNotebookKernelPreference(notebookPath, kernelName);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
else {
|
|
1641
|
+
({ sessionId, created } = await this.kernelService.getOrCreateKernel(notebookPath, kernelName));
|
|
1642
|
+
this.kernelService.saveNotebookKernelPreference(notebookPath, kernelName);
|
|
1643
|
+
}
|
|
1590
1644
|
const spec = (0, kernelspec_1.getKernelSpec)(kernelName);
|
|
1591
1645
|
const metadataResult = await this.fsService.updateNotebookMetadata(notebookPath, {
|
|
1592
1646
|
kernelspec: {
|
|
@@ -49,8 +49,10 @@ const os = __importStar(require("os"));
|
|
|
49
49
|
const ws_1 = require("ws");
|
|
50
50
|
const cell_hash_1 = require("./cell-hash");
|
|
51
51
|
const registry_1 = require("../fs/notebook-formats/registry");
|
|
52
|
+
const sealed_path_1 = require("../fs/sealed-path");
|
|
52
53
|
const AGENT_LOCK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
53
54
|
const UI_STALE_TIMEOUT_MS = 45 * 1000; // 45 seconds
|
|
55
|
+
const READ_ONLY_OPERATIONS = new Set(['readCell', 'readCellOutput', 'searchCells', 'readNotebook', 'getUpdatesSince']);
|
|
54
56
|
function normalizeNotebookPath(notebookPath) {
|
|
55
57
|
// IMPORTANT: This must match how the kernel service normalizes file paths.
|
|
56
58
|
// If the UI registers `~/foo.ipynb` but the kernel associates the session
|
|
@@ -320,6 +322,16 @@ class OperationRouter {
|
|
|
320
322
|
const normalizedPath = normalizeNotebookPath(notebookPath);
|
|
321
323
|
const opType = operation.type;
|
|
322
324
|
const agentId = operation.agentId;
|
|
325
|
+
const sealInfo = (0, sealed_path_1.classifySealedPath)(normalizedPath);
|
|
326
|
+
if (sealInfo.sealed && !READ_ONLY_OPERATIONS.has(opType)) {
|
|
327
|
+
return {
|
|
328
|
+
success: false,
|
|
329
|
+
error: `Cannot ${opType || 'modify notebook'}: sealed evidence ${sealInfo.sealId} is read-only`,
|
|
330
|
+
code: 'sealed_read_only',
|
|
331
|
+
seal_id: sealInfo.sealId,
|
|
332
|
+
path: sealInfo.canonicalPath,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
323
335
|
// For createNotebook, route to ANY connected UI (not path-specific)
|
|
324
336
|
// This allows the UI to open the new notebook in a new tab.
|
|
325
337
|
// EXCEPT text notebook formats (.py/.qmd): the UI's create handler only
|
|
@@ -375,10 +387,9 @@ class OperationRouter {
|
|
|
375
387
|
return { ...result, backend };
|
|
376
388
|
}
|
|
377
389
|
// Enforce agent session for write operations
|
|
378
|
-
const readOnlyOps = new Set(['readCell', 'readCellOutput', 'searchCells', 'readNotebook', 'getUpdatesSince']);
|
|
379
390
|
const sessionOps = new Set(['startAgentSession', 'endAgentSession']);
|
|
380
391
|
const creationOps = new Set(['createNotebook']); // Operations that create files (don't require session)
|
|
381
|
-
const isWrite = !
|
|
392
|
+
const isWrite = !READ_ONLY_OPERATIONS.has(opType) && !sessionOps.has(opType) && !creationOps.has(opType);
|
|
382
393
|
if (isWrite) {
|
|
383
394
|
if (!isLocked) {
|
|
384
395
|
console.log(` -> BLOCKED: Write requires active agent session`);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type CanonicalJsonValue = null | boolean | number | string | CanonicalJsonValue[] | {
|
|
2
|
+
[key: string]: CanonicalJsonValue;
|
|
3
|
+
};
|
|
4
|
+
/**
|
|
5
|
+
* Cross-runtime hash profile used by every structured Nebula provenance hash.
|
|
6
|
+
*
|
|
7
|
+
* This value is included in the encoded root, so future profiles cannot share
|
|
8
|
+
* a digest with this one even when their value encodings happen to match.
|
|
9
|
+
*/
|
|
10
|
+
export declare const CANONICAL_HASH_PROFILE: "nebula-canonical-hash-v1";
|
|
11
|
+
/**
|
|
12
|
+
* Serialize a JSON value deterministically.
|
|
13
|
+
*
|
|
14
|
+
* Object keys are sorted recursively. Values that regular JSON.stringify
|
|
15
|
+
* silently drops or coerces are rejected: provenance hashes must never depend
|
|
16
|
+
* on an implicit lossy conversion.
|
|
17
|
+
*/
|
|
18
|
+
export declare function canonicalJson(value: unknown): string;
|
|
19
|
+
export declare function sha256Hex(value: string | Buffer | Uint8Array): string;
|
|
20
|
+
/**
|
|
21
|
+
* Convert a JSON-like value to an injective, language-neutral typed tree.
|
|
22
|
+
*
|
|
23
|
+
* - Object keys use Unicode scalar/code-point order rather than JavaScript's
|
|
24
|
+
* UTF-16 code-unit order.
|
|
25
|
+
* - Safe integral numbers share one integer representation.
|
|
26
|
+
* - Every other finite Number, including -0 and unsafe integral Numbers, is
|
|
27
|
+
* represented by its exact big-endian IEEE-754 binary64 bits.
|
|
28
|
+
* - Strings containing unpaired UTF-16 surrogates are rejected.
|
|
29
|
+
*
|
|
30
|
+
* The returned tree itself contains only JSON values, and contains no dynamic
|
|
31
|
+
* object keys, so regular JSON serialization is deterministic cross-runtime.
|
|
32
|
+
*/
|
|
33
|
+
export declare function canonicalizeForHash(value: unknown): CanonicalJsonValue;
|
|
34
|
+
/** Serialize the typed cross-runtime representation used as SHA-256 input. */
|
|
35
|
+
export declare function hashCanonicalJson(value: unknown): string;
|
|
36
|
+
export declare function sha256Canonical(value: unknown): string;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CANONICAL_HASH_PROFILE = void 0;
|
|
4
|
+
exports.canonicalJson = canonicalJson;
|
|
5
|
+
exports.sha256Hex = sha256Hex;
|
|
6
|
+
exports.canonicalizeForHash = canonicalizeForHash;
|
|
7
|
+
exports.hashCanonicalJson = hashCanonicalJson;
|
|
8
|
+
exports.sha256Canonical = sha256Canonical;
|
|
9
|
+
const crypto_1 = require("crypto");
|
|
10
|
+
/**
|
|
11
|
+
* Cross-runtime hash profile used by every structured Nebula provenance hash.
|
|
12
|
+
*
|
|
13
|
+
* This value is included in the encoded root, so future profiles cannot share
|
|
14
|
+
* a digest with this one even when their value encodings happen to match.
|
|
15
|
+
*/
|
|
16
|
+
exports.CANONICAL_HASH_PROFILE = 'nebula-canonical-hash-v1';
|
|
17
|
+
/**
|
|
18
|
+
* Serialize a JSON value deterministically.
|
|
19
|
+
*
|
|
20
|
+
* Object keys are sorted recursively. Values that regular JSON.stringify
|
|
21
|
+
* silently drops or coerces are rejected: provenance hashes must never depend
|
|
22
|
+
* on an implicit lossy conversion.
|
|
23
|
+
*/
|
|
24
|
+
function canonicalJson(value) {
|
|
25
|
+
return serializeCanonical(value, new WeakSet(), '$');
|
|
26
|
+
}
|
|
27
|
+
function sha256Hex(value) {
|
|
28
|
+
return (0, crypto_1.createHash)('sha256').update(value).digest('hex');
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Convert a JSON-like value to an injective, language-neutral typed tree.
|
|
32
|
+
*
|
|
33
|
+
* - Object keys use Unicode scalar/code-point order rather than JavaScript's
|
|
34
|
+
* UTF-16 code-unit order.
|
|
35
|
+
* - Safe integral numbers share one integer representation.
|
|
36
|
+
* - Every other finite Number, including -0 and unsafe integral Numbers, is
|
|
37
|
+
* represented by its exact big-endian IEEE-754 binary64 bits.
|
|
38
|
+
* - Strings containing unpaired UTF-16 surrogates are rejected.
|
|
39
|
+
*
|
|
40
|
+
* The returned tree itself contains only JSON values, and contains no dynamic
|
|
41
|
+
* object keys, so regular JSON serialization is deterministic cross-runtime.
|
|
42
|
+
*/
|
|
43
|
+
function canonicalizeForHash(value) {
|
|
44
|
+
return [
|
|
45
|
+
exports.CANONICAL_HASH_PROFILE,
|
|
46
|
+
encodeForHash(value, new WeakSet(), '$'),
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
/** Serialize the typed cross-runtime representation used as SHA-256 input. */
|
|
50
|
+
function hashCanonicalJson(value) {
|
|
51
|
+
return canonicalJson(canonicalizeForHash(value));
|
|
52
|
+
}
|
|
53
|
+
function sha256Canonical(value) {
|
|
54
|
+
return sha256Hex(hashCanonicalJson(value));
|
|
55
|
+
}
|
|
56
|
+
function encodeForHash(value, ancestors, location) {
|
|
57
|
+
if (value === null)
|
|
58
|
+
return ['null'];
|
|
59
|
+
switch (typeof value) {
|
|
60
|
+
case 'boolean':
|
|
61
|
+
return ['boolean', value];
|
|
62
|
+
case 'string':
|
|
63
|
+
assertUnicodeScalarString(value, location);
|
|
64
|
+
return ['string', value];
|
|
65
|
+
case 'number':
|
|
66
|
+
if (!Number.isFinite(value)) {
|
|
67
|
+
throw new TypeError(`Canonical hash requires finite numbers at ${location}`);
|
|
68
|
+
}
|
|
69
|
+
if (Number.isSafeInteger(value) && !Object.is(value, -0)) {
|
|
70
|
+
return ['integer', value];
|
|
71
|
+
}
|
|
72
|
+
return ['float64', float64Hex(value)];
|
|
73
|
+
case 'undefined':
|
|
74
|
+
throw new TypeError(`Canonical hash cannot encode undefined at ${location}`);
|
|
75
|
+
case 'bigint':
|
|
76
|
+
case 'function':
|
|
77
|
+
case 'symbol':
|
|
78
|
+
throw new TypeError(`Canonical hash cannot encode ${typeof value} at ${location}`);
|
|
79
|
+
case 'object':
|
|
80
|
+
break;
|
|
81
|
+
default:
|
|
82
|
+
throw new TypeError(`Canonical hash cannot encode value at ${location}`);
|
|
83
|
+
}
|
|
84
|
+
const objectValue = value;
|
|
85
|
+
if (ancestors.has(objectValue)) {
|
|
86
|
+
throw new TypeError(`Canonical hash cannot encode a cyclic value at ${location}`);
|
|
87
|
+
}
|
|
88
|
+
ancestors.add(objectValue);
|
|
89
|
+
try {
|
|
90
|
+
if (Array.isArray(value)) {
|
|
91
|
+
const items = [];
|
|
92
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
93
|
+
if (!(index in value)) {
|
|
94
|
+
throw new TypeError(`Canonical hash cannot encode a sparse array at ${location}[${index}]`);
|
|
95
|
+
}
|
|
96
|
+
items.push(encodeForHash(value[index], ancestors, `${location}[${index}]`));
|
|
97
|
+
}
|
|
98
|
+
if (Object.getOwnPropertySymbols(value).length > 0) {
|
|
99
|
+
throw new TypeError(`Canonical hash cannot encode symbol keys at ${location}`);
|
|
100
|
+
}
|
|
101
|
+
return ['array', items];
|
|
102
|
+
}
|
|
103
|
+
const prototype = Object.getPrototypeOf(value);
|
|
104
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
105
|
+
throw new TypeError(`Canonical hash requires a plain object at ${location}`);
|
|
106
|
+
}
|
|
107
|
+
if (Object.getOwnPropertySymbols(value).length > 0) {
|
|
108
|
+
throw new TypeError(`Canonical hash cannot encode symbol keys at ${location}`);
|
|
109
|
+
}
|
|
110
|
+
const record = value;
|
|
111
|
+
const pairs = [];
|
|
112
|
+
const keys = Object.keys(record);
|
|
113
|
+
for (const key of keys)
|
|
114
|
+
assertUnicodeScalarString(key, `${location} object key`);
|
|
115
|
+
keys.sort(compareUnicodeCodePoints);
|
|
116
|
+
for (const key of keys) {
|
|
117
|
+
pairs.push([
|
|
118
|
+
key,
|
|
119
|
+
encodeForHash(record[key], ancestors, `${location}.${key}`),
|
|
120
|
+
]);
|
|
121
|
+
}
|
|
122
|
+
return ['object', pairs];
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
ancestors.delete(objectValue);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function float64Hex(value) {
|
|
129
|
+
const bytes = Buffer.allocUnsafe(8);
|
|
130
|
+
bytes.writeDoubleBE(value, 0);
|
|
131
|
+
return bytes.toString('hex');
|
|
132
|
+
}
|
|
133
|
+
function assertUnicodeScalarString(value, location) {
|
|
134
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
135
|
+
const codeUnit = value.charCodeAt(index);
|
|
136
|
+
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
|
|
137
|
+
const following = value.charCodeAt(index + 1);
|
|
138
|
+
if (!(following >= 0xdc00 && following <= 0xdfff)) {
|
|
139
|
+
throw new TypeError(`Canonical hash requires Unicode scalar strings at ${location}`);
|
|
140
|
+
}
|
|
141
|
+
index += 1;
|
|
142
|
+
}
|
|
143
|
+
else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
|
|
144
|
+
throw new TypeError(`Canonical hash requires Unicode scalar strings at ${location}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function compareUnicodeCodePoints(left, right) {
|
|
149
|
+
let leftIndex = 0;
|
|
150
|
+
let rightIndex = 0;
|
|
151
|
+
while (leftIndex < left.length && rightIndex < right.length) {
|
|
152
|
+
const leftPoint = left.codePointAt(leftIndex);
|
|
153
|
+
const rightPoint = right.codePointAt(rightIndex);
|
|
154
|
+
if (leftPoint !== rightPoint)
|
|
155
|
+
return leftPoint - rightPoint;
|
|
156
|
+
leftIndex += leftPoint > 0xffff ? 2 : 1;
|
|
157
|
+
rightIndex += rightPoint > 0xffff ? 2 : 1;
|
|
158
|
+
}
|
|
159
|
+
return (left.length - leftIndex) - (right.length - rightIndex);
|
|
160
|
+
}
|
|
161
|
+
function serializeCanonical(value, ancestors, location) {
|
|
162
|
+
if (value === null)
|
|
163
|
+
return 'null';
|
|
164
|
+
switch (typeof value) {
|
|
165
|
+
case 'boolean':
|
|
166
|
+
return value ? 'true' : 'false';
|
|
167
|
+
case 'string':
|
|
168
|
+
return JSON.stringify(value);
|
|
169
|
+
case 'number':
|
|
170
|
+
if (!Number.isFinite(value)) {
|
|
171
|
+
throw new TypeError(`Canonical JSON requires finite numbers at ${location}`);
|
|
172
|
+
}
|
|
173
|
+
// JSON.stringify canonicalizes -0 to 0, which is the desired JSON value.
|
|
174
|
+
return JSON.stringify(value);
|
|
175
|
+
case 'undefined':
|
|
176
|
+
throw new TypeError(`Canonical JSON cannot encode undefined at ${location}`);
|
|
177
|
+
case 'bigint':
|
|
178
|
+
case 'function':
|
|
179
|
+
case 'symbol':
|
|
180
|
+
throw new TypeError(`Canonical JSON cannot encode ${typeof value} at ${location}`);
|
|
181
|
+
case 'object':
|
|
182
|
+
break;
|
|
183
|
+
default:
|
|
184
|
+
throw new TypeError(`Canonical JSON cannot encode value at ${location}`);
|
|
185
|
+
}
|
|
186
|
+
const objectValue = value;
|
|
187
|
+
if (ancestors.has(objectValue)) {
|
|
188
|
+
throw new TypeError(`Canonical JSON cannot encode a cyclic value at ${location}`);
|
|
189
|
+
}
|
|
190
|
+
ancestors.add(objectValue);
|
|
191
|
+
try {
|
|
192
|
+
if (Array.isArray(value)) {
|
|
193
|
+
const items = [];
|
|
194
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
195
|
+
if (!(index in value)) {
|
|
196
|
+
throw new TypeError(`Canonical JSON cannot encode a sparse array at ${location}[${index}]`);
|
|
197
|
+
}
|
|
198
|
+
items.push(serializeCanonical(value[index], ancestors, `${location}[${index}]`));
|
|
199
|
+
}
|
|
200
|
+
return `[${items.join(',')}]`;
|
|
201
|
+
}
|
|
202
|
+
const prototype = Object.getPrototypeOf(value);
|
|
203
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
204
|
+
throw new TypeError(`Canonical JSON requires a plain object at ${location}`);
|
|
205
|
+
}
|
|
206
|
+
if (Object.getOwnPropertySymbols(value).length > 0) {
|
|
207
|
+
throw new TypeError(`Canonical JSON cannot encode symbol keys at ${location}`);
|
|
208
|
+
}
|
|
209
|
+
const record = value;
|
|
210
|
+
const properties = Object.keys(record)
|
|
211
|
+
.sort()
|
|
212
|
+
.map((key) => `${JSON.stringify(key)}:${serializeCanonical(record[key], ancestors, `${location}.${key}`)}`);
|
|
213
|
+
return `{${properties.join(',')}}`;
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
ancestors.delete(objectValue);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ProvenanceAppendResult, ProvenanceBlobReference, ProvenanceBlobVerification, ProvenanceEvent, ProvenanceEventInput, ProvenanceVerification } from './types';
|
|
2
|
+
export declare class ProvenanceIntegrityError extends Error {
|
|
3
|
+
readonly code = "PROVENANCE_INTEGRITY_ERROR";
|
|
4
|
+
readonly failedLine?: number;
|
|
5
|
+
constructor(message: string, failedLine?: number);
|
|
6
|
+
}
|
|
7
|
+
export declare class IdempotencyConflictError extends Error {
|
|
8
|
+
readonly code = "IDEMPOTENCY_CONFLICT";
|
|
9
|
+
constructor(key: string);
|
|
10
|
+
}
|
|
11
|
+
export declare class BlobIntegrityError extends Error {
|
|
12
|
+
readonly code = "PROVENANCE_BLOB_INTEGRITY_ERROR";
|
|
13
|
+
constructor(message: string);
|
|
14
|
+
}
|
|
15
|
+
export interface ProvenanceStoreOptions {
|
|
16
|
+
/** fsync ledger/blob writes. Defaults to true. */
|
|
17
|
+
durable?: boolean;
|
|
18
|
+
/** Test seam; production events always use the server clock. */
|
|
19
|
+
clock?: () => Date;
|
|
20
|
+
/** Test seam; production events use cryptographically random UUIDs. */
|
|
21
|
+
idFactory?: () => string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Server-owned append-only scientific provenance storage.
|
|
25
|
+
*
|
|
26
|
+
* Undo/redo history remains a separate, client-replaceable projection. This
|
|
27
|
+
* store writes one canonical event per JSONL line and binds every event to the
|
|
28
|
+
* previous event hash. All mutations for a notebook are serialized in-process.
|
|
29
|
+
*/
|
|
30
|
+
export declare class ProvenanceStore {
|
|
31
|
+
private static readonly writeQueues;
|
|
32
|
+
private readonly durable;
|
|
33
|
+
private readonly clock;
|
|
34
|
+
private readonly idFactory;
|
|
35
|
+
constructor(options?: ProvenanceStoreOptions);
|
|
36
|
+
getLedgerPath(notebookPath: string): string;
|
|
37
|
+
getBlobPath(notebookPath: string, sha256: string): string;
|
|
38
|
+
append(notebookPath: string, input: ProvenanceEventInput): Promise<ProvenanceAppendResult>;
|
|
39
|
+
read(notebookPath: string): Promise<ProvenanceEvent[]>;
|
|
40
|
+
verify(notebookPath: string): Promise<ProvenanceVerification>;
|
|
41
|
+
findByIdempotencyKey(notebookPath: string, idempotencyKey: string): Promise<ProvenanceEvent | null>;
|
|
42
|
+
putBlob(notebookPath: string, value: string | Buffer | Uint8Array): Promise<ProvenanceBlobReference>;
|
|
43
|
+
readBlob(notebookPath: string, sha256: string): Promise<Buffer>;
|
|
44
|
+
verifyBlob(notebookPath: string, sha256: string): Promise<ProvenanceBlobVerification>;
|
|
45
|
+
private normalizeNotebookPath;
|
|
46
|
+
private normalizeInput;
|
|
47
|
+
private canonicalClone;
|
|
48
|
+
private hashRequest;
|
|
49
|
+
private requestEnvelope;
|
|
50
|
+
private inputFromEvent;
|
|
51
|
+
private readVerified;
|
|
52
|
+
private validateEventShape;
|
|
53
|
+
private appendLine;
|
|
54
|
+
private ledgerNeedsNewline;
|
|
55
|
+
private syncDirectory;
|
|
56
|
+
private readIfExists;
|
|
57
|
+
private assertBlobBytes;
|
|
58
|
+
private toBuffer;
|
|
59
|
+
private assertSha256;
|
|
60
|
+
private runSerialized;
|
|
61
|
+
private isNodeError;
|
|
62
|
+
}
|
|
63
|
+
export type { ProvenanceActor, ProvenanceAppendResult, ProvenanceBlobReference, ProvenanceBlobVerification, ProvenanceEvent, ProvenanceEventInput, ProvenanceVerification, } from './types';
|