@volter/world-core 2.0.19 → 2.0.21
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/inject.cjs +18 -5
- package/dist/network-policy.cjs +4 -2
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/serve.d.ts +15 -5
- package/dist/src/serve.js +69 -28
- package/inject.cjs +18 -5
- package/network-policy.cjs +4 -2
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/serve.ts +74 -31
package/dist/inject.cjs
CHANGED
|
@@ -728,8 +728,9 @@ function socketTargetHosts(args, isTls) {
|
|
|
728
728
|
return hosts;
|
|
729
729
|
}
|
|
730
730
|
if (typeof first === 'string' && !/^\d+$/.test(first)) return hosts; // a unix socket path
|
|
731
|
-
hosts.push(typeof second === 'string' ? second : 'localhost');
|
|
732
731
|
const options = typeof second === 'object' && second !== null ? second : typeof third === 'object' && third !== null ? third : null;
|
|
732
|
+
// connect(port, host) names the host; connect(port, { host }) puts it in the options, which Node merges
|
|
733
|
+
hosts.push(typeof second === 'string' ? second : typeof options?.host === 'string' ? options.host : 'localhost');
|
|
733
734
|
if (isTls && options && options.servername) hosts.push(options.servername);
|
|
734
735
|
return hosts;
|
|
735
736
|
}
|
|
@@ -811,7 +812,15 @@ function patchedPromisesLookup(original) {
|
|
|
811
812
|
};
|
|
812
813
|
}
|
|
813
814
|
|
|
814
|
-
|
|
815
|
+
/** The port a net/tls connect names (`connect(port, host)`, `connect({ port, host })`), or undefined. */
|
|
816
|
+
function socketTargetPort(args) {
|
|
817
|
+
const [first] = args;
|
|
818
|
+
const raw = first !== null && typeof first === 'object' ? first.port : first;
|
|
819
|
+
const port = Number(raw);
|
|
820
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : undefined;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function socketRefusal(hostname, port, isTls) {
|
|
815
824
|
const host = String(hostname || '').toLowerCase();
|
|
816
825
|
if (host === WORLD_ADDRESS[4] || host === WORLD_ADDRESS[6]) {
|
|
817
826
|
return `[twin-inject] refused a socket to ${host}, the address this World's DNS gives the names it routes: `
|
|
@@ -831,8 +840,11 @@ function socketRefusal(hostname) {
|
|
|
831
840
|
return `[twin-inject] refused a raw socket to ${host}, which this world routes to the application (volter-world app-url --host): `
|
|
832
841
|
+ 'the client opened its own connection instead of going through http/https or fetch, so it could not be routed.';
|
|
833
842
|
}
|
|
834
|
-
|
|
835
|
-
|
|
843
|
+
// the destination as an origin, its port and its protocol included: a policy that lists https://host allows TLS to
|
|
844
|
+
// host:443, never another port or a plaintext connection to it
|
|
845
|
+
const authority = `${isIP(host) === 6 ? `[${host}]` : host}${port !== undefined && !(isTls && port === 443) ? `:${port}` : ''}`;
|
|
846
|
+
const url = (() => { try { return new URL(`${isTls ? 'https' : 'http'}://${authority}/`); } catch { return null; } })();
|
|
847
|
+
if (url && shouldBlockUntwinned(url)) return `[twin-inject] blocked untwinned external socket to ${authority}`;
|
|
836
848
|
return null;
|
|
837
849
|
}
|
|
838
850
|
|
|
@@ -844,8 +856,9 @@ function refusedSocket(message) {
|
|
|
844
856
|
|
|
845
857
|
function patchedConnect(originalConnect, isTls) {
|
|
846
858
|
return function patchedSocketConnect(...args) {
|
|
859
|
+
const port = socketTargetPort(args);
|
|
847
860
|
for (const host of socketTargetHosts(args, isTls)) {
|
|
848
|
-
const refusal = socketRefusal(host);
|
|
861
|
+
const refusal = socketRefusal(host, port, isTls);
|
|
849
862
|
if (refusal) return refusedSocket(refusal);
|
|
850
863
|
}
|
|
851
864
|
return originalConnect.apply(this, args);
|
package/dist/network-policy.cjs
CHANGED
|
@@ -66,9 +66,11 @@ function isWorldInternalHost(hostname) {
|
|
|
66
66
|
const v4 = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(h);
|
|
67
67
|
if (v4) {
|
|
68
68
|
const a = Number(v4[1]); const b = Number(v4[2]);
|
|
69
|
-
|
|
69
|
+
// not link-local 169.254/16: it holds a cloud's instance metadata (169.254.169.254), the box's credentials
|
|
70
|
+
return a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
|
70
71
|
}
|
|
71
|
-
|
|
72
|
+
// nor fe80::/10, nor a cloud's IPv6 metadata address (fd00:ec2::254)
|
|
73
|
+
return h.includes(':') && (h.startsWith('fc') || h.startsWith('fd')) && h !== 'fd00:ec2::254';
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
/**
|
package/dist/src/index.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ export { SqlWorldStore } from './world-store-sql.js';
|
|
|
33
33
|
export type { SqlExec } from './world-store-sql.js';
|
|
34
34
|
export { FsBlobStore, MemoryBlobStore, blobDigest, getActiveBlobStore, setActiveBlobStore, withBlobStore, readBlobRange, } from './blob-store.js';
|
|
35
35
|
export type { BlobStore } from './blob-store.js';
|
|
36
|
-
export { applyTwinWrite, applyTwinWriteAtomic, createTwinServer, journalTwinRequest, twinRequestCredentials, readTwinRequestJournal, resolveTwinRead, twinRequestJournalEnabled, twinRequestJournalPath, twinResources, } from './serve.js';
|
|
36
|
+
export { applyTwinWrite, applyTwinWriteAtomic, createTwinServer, journalTwinRequest, twinRequestJournalFailures, twinRequestCredentials, readTwinRequestJournal, resolveTwinRead, twinRequestJournalEnabled, twinRequestJournalPath, twinResources, } from './serve.js';
|
|
37
37
|
export type { AtomicTwinWriteDecision, TwinRequestJournalEntry, TwinResource, TwinWriteInput, TwinWriteResult, } from './serve.js';
|
|
38
38
|
export { createTwinProxy } from './proxy.js';
|
|
39
39
|
export type { TwinProxy, TwinProxyOptions, VendorRoute } from './proxy.js';
|
package/dist/src/index.js
CHANGED
|
@@ -55,7 +55,7 @@ export { SqlWorldStore } from "./world-store-sql.js";
|
|
|
55
55
|
// The blob seam (runtime contract R11): byte storage behind byte-carrying handlers, so a
|
|
56
56
|
// serverless namespace puts bytes in object storage while local worlds keep today's layout.
|
|
57
57
|
export { FsBlobStore, MemoryBlobStore, blobDigest, getActiveBlobStore, setActiveBlobStore, withBlobStore, readBlobRange, } from "./blob-store.js";
|
|
58
|
-
export { applyTwinWrite, applyTwinWriteAtomic, createTwinServer, journalTwinRequest, twinRequestCredentials, readTwinRequestJournal, resolveTwinRead, twinRequestJournalEnabled, twinRequestJournalPath, twinResources, } from "./serve.js";
|
|
58
|
+
export { applyTwinWrite, applyTwinWriteAtomic, createTwinServer, journalTwinRequest, twinRequestJournalFailures, twinRequestCredentials, readTwinRequestJournal, resolveTwinRead, twinRequestJournalEnabled, twinRequestJournalPath, twinResources, } from "./serve.js";
|
|
59
59
|
export { createTwinProxy } from "./proxy.js";
|
|
60
60
|
export { forkTwin, isFork, readForkMeta, } from "./fork.js";
|
|
61
61
|
export { appendAction, appendTransactionCommit, checkPrecondition, confirmAction, listActions, listTransactionCommits, pendingActions, pushablePendingActions, isTwinBookkeeping, pendingTransactionCommits, projectResources, projectOwnerResources, OwnerStoreAmbiguousError, revertAction, TwinActionPreconditionError, subjectAliases, resolveSubjectId, runWithCorrelationId, currentCorrelationId } from "./actions.js";
|
package/dist/src/serve.d.ts
CHANGED
|
@@ -24,9 +24,18 @@ export type TwinRequestJournalEntry = {
|
|
|
24
24
|
ms?: number;
|
|
25
25
|
/** credential-looking headers/query params that arrived, named + fingerprinted, never quoted. */
|
|
26
26
|
credentials?: TwinCredentialShape[];
|
|
27
|
+
/** `request`: written BEFORE the request is served (an audit device's request entry, so a request whose
|
|
28
|
+
* audit cannot be written is refused); its outcome is the entry without a phase written after. */
|
|
29
|
+
phase?: 'request';
|
|
27
30
|
};
|
|
28
|
-
/** The journal read back: every entry kept for a service, oldest first (an empty list when the journal is switched off or empty).
|
|
29
|
-
|
|
31
|
+
/** The journal read back: every entry kept for a service, oldest first (an empty list when the journal is switched off or empty).
|
|
32
|
+
* With `since` (epoch ms), segments that ended before it are not read. */
|
|
33
|
+
export declare function readTwinRequestJournal(service: string, root?: string, options?: {
|
|
34
|
+
since?: number;
|
|
35
|
+
}): TwinRequestJournalEntry[];
|
|
36
|
+
/** Journal lines this process failed to keep, since it started, for one service's journal under `root`
|
|
37
|
+
* (a World's request report sums its own twins and links). */
|
|
38
|
+
export declare function twinRequestJournalFailures(service: string, root?: string): number;
|
|
30
39
|
/** Non-reversible, stable fingerprint. 64 bits of sha256 — enough that two distinct credentials
|
|
31
40
|
* never collide in a journal, far too little to walk back to a real key. */
|
|
32
41
|
export declare function credentialFingerprint(value: string): string;
|
|
@@ -41,10 +50,11 @@ export declare function twinRequestJournalEnabled(env?: Record<string, string |
|
|
|
41
50
|
/** Where a service's request journal lives: beside its `actions.jsonl`. */
|
|
42
51
|
export declare function twinRequestJournalPath(service: string, root?: string): string;
|
|
43
52
|
/**
|
|
44
|
-
* Append one
|
|
45
|
-
*
|
|
53
|
+
* Append one request to the journal — a no-op only when VOLTER_TWIN_REQUEST_JOURNAL=0. Never throws;
|
|
54
|
+
* answers whether the line was kept (true when the journal is switched off: no audit was chosen), so a
|
|
55
|
+
* caller that must not act unaudited (a link) can refuse.
|
|
46
56
|
*/
|
|
47
|
-
export declare function journalTwinRequest(service: string, entry: TwinRequestJournalEntry, root?: string):
|
|
57
|
+
export declare function journalTwinRequest(service: string, entry: TwinRequestJournalEntry, root?: string): boolean;
|
|
48
58
|
export declare const TWIN_JOURNAL_IDENTITY: unique symbol;
|
|
49
59
|
export type TwinJournalIdentity = {
|
|
50
60
|
service: string;
|
package/dist/src/serve.js
CHANGED
|
@@ -18,27 +18,60 @@ import { hashFieldValue } from "./hash.js";
|
|
|
18
18
|
import { appendActionIfAbsent, appendActionOccurrence, decideAndAppendAction, projectResources } from "./actions.js";
|
|
19
19
|
import { observeWorldPaths, worldPaths } from "./storage.js";
|
|
20
20
|
import { getActiveWorldStore } from "./world-store.js";
|
|
21
|
-
/** The journal
|
|
22
|
-
|
|
21
|
+
/** The journal's closed segments, oldest first: `requests.jsonl.<n>` beside the live file. */
|
|
22
|
+
function journalSegments(path) {
|
|
23
|
+
const base = path.slice(path.lastIndexOf('/') + 1);
|
|
24
|
+
return getActiveWorldStore().list(dirname(path))
|
|
25
|
+
.map((name) => (name.startsWith(`${base}.`) ? Number(name.slice(base.length + 1)) : NaN))
|
|
26
|
+
.filter((n) => Number.isInteger(n) && n > 0)
|
|
27
|
+
.sort((a, b) => a - b)
|
|
28
|
+
.map((n) => `${path}.${n}`);
|
|
29
|
+
}
|
|
30
|
+
/** The journal read back: every entry kept for a service, oldest first (an empty list when the journal is switched off or empty).
|
|
31
|
+
* With `since` (epoch ms), segments that ended before it are not read. */
|
|
32
|
+
export function readTwinRequestJournal(service, root, options = {}) {
|
|
23
33
|
const path = twinRequestJournalPath(service, root);
|
|
24
|
-
const
|
|
25
|
-
for (const file of [`${path}.1`, path]) {
|
|
34
|
+
const parse = (file) => {
|
|
26
35
|
const text = getActiveWorldStore().read(file);
|
|
27
36
|
if (!text)
|
|
28
|
-
|
|
37
|
+
return [];
|
|
38
|
+
const rows = [];
|
|
29
39
|
for (const line of text.split('\n')) {
|
|
30
40
|
if (!line)
|
|
31
41
|
continue;
|
|
32
42
|
try {
|
|
33
|
-
|
|
43
|
+
rows.push(JSON.parse(line));
|
|
34
44
|
}
|
|
35
|
-
catch { /* a torn line
|
|
45
|
+
catch { /* a torn line */ }
|
|
36
46
|
}
|
|
47
|
+
return rows;
|
|
48
|
+
};
|
|
49
|
+
const kept = [parse(path)];
|
|
50
|
+
// newest first, stopping at the first segment that ended before `since`
|
|
51
|
+
for (const file of journalSegments(path).reverse()) {
|
|
52
|
+
const rows = parse(file);
|
|
53
|
+
const last = rows[rows.length - 1]?.at;
|
|
54
|
+
if (options.since !== undefined && last && Date.parse(last) < options.since)
|
|
55
|
+
break;
|
|
56
|
+
kept.unshift(rows);
|
|
57
|
+
}
|
|
58
|
+
return kept.flat();
|
|
59
|
+
}
|
|
60
|
+
/** Lines not kept, per journal: keyed by the journal's own path, so a process serving many Worlds
|
|
61
|
+
* (a host; an isolate holding several) answers for each World alone. */
|
|
62
|
+
const journalFailures = new Map();
|
|
63
|
+
/** Journal lines this process failed to keep, since it started, for one service's journal under `root`
|
|
64
|
+
* (a World's request report sums its own twins and links). */
|
|
65
|
+
export function twinRequestJournalFailures(service, root) {
|
|
66
|
+
try {
|
|
67
|
+
return journalFailures.get(twinRequestJournalPath(service, root)) ?? 0;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return 0;
|
|
37
71
|
}
|
|
38
|
-
return out;
|
|
39
72
|
}
|
|
40
|
-
/** ~1 MB
|
|
41
|
-
*
|
|
73
|
+
/** ~1 MB per segment: tens of thousands of entries (a shape line is ~80 bytes), and a report over a
|
|
74
|
+
* recent period reads only the segments that reach into it. */
|
|
42
75
|
const REQUEST_JOURNAL_MAX_BYTES = 1_000_000;
|
|
43
76
|
/**
|
|
44
77
|
* Does this header/param NAME look like it carries a credential? Deliberately a generic word
|
|
@@ -112,34 +145,42 @@ export function twinRequestJournalPath(service, root) {
|
|
|
112
145
|
return join(dirname(worldPaths(service, root).events), 'requests.jsonl');
|
|
113
146
|
}
|
|
114
147
|
/**
|
|
115
|
-
* Append one
|
|
116
|
-
*
|
|
148
|
+
* Append one request to the journal — a no-op only when VOLTER_TWIN_REQUEST_JOURNAL=0. Never throws;
|
|
149
|
+
* answers whether the line was kept (true when the journal is switched off: no audit was chosen), so a
|
|
150
|
+
* caller that must not act unaudited (a link) can refuse.
|
|
117
151
|
*/
|
|
118
152
|
export function journalTwinRequest(service, entry, root) {
|
|
119
153
|
if (!twinRequestJournalEnabled())
|
|
120
|
-
return;
|
|
154
|
+
return true;
|
|
155
|
+
const line = JSON.stringify({
|
|
156
|
+
at: entry.at ?? new Date().toISOString(),
|
|
157
|
+
...(entry.phase ? { phase: entry.phase } : {}),
|
|
158
|
+
method: entry.method.toUpperCase(),
|
|
159
|
+
path: entry.path.split('?')[0], // the query STRING never lands; its credential params do, named + fingerprinted
|
|
160
|
+
status: entry.status,
|
|
161
|
+
...(typeof entry.ms === 'number' ? { ms: entry.ms } : {}),
|
|
162
|
+
...(entry.credentials?.length ? { credentials: entry.credentials } : {}),
|
|
163
|
+
});
|
|
164
|
+
let path;
|
|
121
165
|
try {
|
|
122
166
|
const store = getActiveWorldStore();
|
|
123
|
-
|
|
167
|
+
path = twinRequestJournalPath(service, root);
|
|
124
168
|
store.mkdir(dirname(path));
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
//
|
|
128
|
-
|
|
169
|
+
if ((store.stat(path)?.size ?? 0) >= REQUEST_JOURNAL_MAX_BYTES) {
|
|
170
|
+
// the live file closes into the next segment; no segment is ever overwritten or removed (a crash between
|
|
171
|
+
// the two writes leaves the live lines in both: repeated in the next segment, never lost)
|
|
172
|
+
const next = Number(journalSegments(path).pop()?.slice(path.length + 1) ?? 0) + 1;
|
|
173
|
+
store.writeAtomic(`${path}.${next}`, store.read(path) ?? '');
|
|
129
174
|
store.write(path, '');
|
|
130
175
|
}
|
|
131
|
-
const line = JSON.stringify({
|
|
132
|
-
at: entry.at ?? new Date().toISOString(),
|
|
133
|
-
method: entry.method.toUpperCase(),
|
|
134
|
-
path: entry.path.split('?')[0], // the query STRING never lands; its credential params do, named + fingerprinted
|
|
135
|
-
status: entry.status,
|
|
136
|
-
...(typeof entry.ms === 'number' ? { ms: entry.ms } : {}),
|
|
137
|
-
...(entry.credentials?.length ? { credentials: entry.credentials } : {}),
|
|
138
|
-
});
|
|
139
176
|
store.append(path, `${line}\n`);
|
|
177
|
+
return true;
|
|
140
178
|
}
|
|
141
|
-
catch {
|
|
142
|
-
|
|
179
|
+
catch (error) {
|
|
180
|
+
const key = path ?? `${root ?? ''}/${service}`;
|
|
181
|
+
journalFailures.set(key, (journalFailures.get(key) ?? 0) + 1);
|
|
182
|
+
console.error(`[request-journal] ${service}: a line was not kept: ${error.message}`);
|
|
183
|
+
return false;
|
|
143
184
|
}
|
|
144
185
|
}
|
|
145
186
|
// ── how a wrapped `Bun.serve` learns which twin it is ──────────────────────────────────────────
|
package/inject.cjs
CHANGED
|
@@ -728,8 +728,9 @@ function socketTargetHosts(args, isTls) {
|
|
|
728
728
|
return hosts;
|
|
729
729
|
}
|
|
730
730
|
if (typeof first === 'string' && !/^\d+$/.test(first)) return hosts; // a unix socket path
|
|
731
|
-
hosts.push(typeof second === 'string' ? second : 'localhost');
|
|
732
731
|
const options = typeof second === 'object' && second !== null ? second : typeof third === 'object' && third !== null ? third : null;
|
|
732
|
+
// connect(port, host) names the host; connect(port, { host }) puts it in the options, which Node merges
|
|
733
|
+
hosts.push(typeof second === 'string' ? second : typeof options?.host === 'string' ? options.host : 'localhost');
|
|
733
734
|
if (isTls && options && options.servername) hosts.push(options.servername);
|
|
734
735
|
return hosts;
|
|
735
736
|
}
|
|
@@ -811,7 +812,15 @@ function patchedPromisesLookup(original) {
|
|
|
811
812
|
};
|
|
812
813
|
}
|
|
813
814
|
|
|
814
|
-
|
|
815
|
+
/** The port a net/tls connect names (`connect(port, host)`, `connect({ port, host })`), or undefined. */
|
|
816
|
+
function socketTargetPort(args) {
|
|
817
|
+
const [first] = args;
|
|
818
|
+
const raw = first !== null && typeof first === 'object' ? first.port : first;
|
|
819
|
+
const port = Number(raw);
|
|
820
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : undefined;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function socketRefusal(hostname, port, isTls) {
|
|
815
824
|
const host = String(hostname || '').toLowerCase();
|
|
816
825
|
if (host === WORLD_ADDRESS[4] || host === WORLD_ADDRESS[6]) {
|
|
817
826
|
return `[twin-inject] refused a socket to ${host}, the address this World's DNS gives the names it routes: `
|
|
@@ -831,8 +840,11 @@ function socketRefusal(hostname) {
|
|
|
831
840
|
return `[twin-inject] refused a raw socket to ${host}, which this world routes to the application (volter-world app-url --host): `
|
|
832
841
|
+ 'the client opened its own connection instead of going through http/https or fetch, so it could not be routed.';
|
|
833
842
|
}
|
|
834
|
-
|
|
835
|
-
|
|
843
|
+
// the destination as an origin, its port and its protocol included: a policy that lists https://host allows TLS to
|
|
844
|
+
// host:443, never another port or a plaintext connection to it
|
|
845
|
+
const authority = `${isIP(host) === 6 ? `[${host}]` : host}${port !== undefined && !(isTls && port === 443) ? `:${port}` : ''}`;
|
|
846
|
+
const url = (() => { try { return new URL(`${isTls ? 'https' : 'http'}://${authority}/`); } catch { return null; } })();
|
|
847
|
+
if (url && shouldBlockUntwinned(url)) return `[twin-inject] blocked untwinned external socket to ${authority}`;
|
|
836
848
|
return null;
|
|
837
849
|
}
|
|
838
850
|
|
|
@@ -844,8 +856,9 @@ function refusedSocket(message) {
|
|
|
844
856
|
|
|
845
857
|
function patchedConnect(originalConnect, isTls) {
|
|
846
858
|
return function patchedSocketConnect(...args) {
|
|
859
|
+
const port = socketTargetPort(args);
|
|
847
860
|
for (const host of socketTargetHosts(args, isTls)) {
|
|
848
|
-
const refusal = socketRefusal(host);
|
|
861
|
+
const refusal = socketRefusal(host, port, isTls);
|
|
849
862
|
if (refusal) return refusedSocket(refusal);
|
|
850
863
|
}
|
|
851
864
|
return originalConnect.apply(this, args);
|
package/network-policy.cjs
CHANGED
|
@@ -66,9 +66,11 @@ function isWorldInternalHost(hostname) {
|
|
|
66
66
|
const v4 = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(h);
|
|
67
67
|
if (v4) {
|
|
68
68
|
const a = Number(v4[1]); const b = Number(v4[2]);
|
|
69
|
-
|
|
69
|
+
// not link-local 169.254/16: it holds a cloud's instance metadata (169.254.169.254), the box's credentials
|
|
70
|
+
return a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
|
70
71
|
}
|
|
71
|
-
|
|
72
|
+
// nor fe80::/10, nor a cloud's IPv6 metadata address (fd00:ec2::254)
|
|
73
|
+
return h.includes(':') && (h.startsWith('fc') || h.startsWith('fd')) && h !== 'fd00:ec2::254';
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-core",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.21",
|
|
4
4
|
"description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
package/src/index.ts
CHANGED
package/src/serve.ts
CHANGED
|
@@ -25,9 +25,11 @@ import { getActiveWorldStore } from './world-store.ts';
|
|
|
25
25
|
// `actions.jsonl` records MUTATIONS only, so a twin's READS are invisible to `volter-world tail`
|
|
26
26
|
// — an empty-catalog GET that 404s leaves no trace, and "did the app even ask?" is unanswerable.
|
|
27
27
|
// The journal is the minimal honest answer: a per-twin `requests.jsonl` beside `actions.jsonl`, one
|
|
28
|
-
// line per served HTTP request, kept unless switched off (VOLTER_TWIN_REQUEST_JOURNAL=0).
|
|
29
|
-
// at ~1 MB the file
|
|
30
|
-
//
|
|
28
|
+
// line per served HTTP request, kept unless switched off (VOLTER_TWIN_REQUEST_JOURNAL=0). It is an
|
|
29
|
+
// audit trail, so nothing in it is ever deleted: at ~1 MB the live file closes into the next numbered
|
|
30
|
+
// segment (`requests.jsonl.1`, `.2`, … oldest first) and a fresh one starts. An append that fails is
|
|
31
|
+
// said on stderr and counted per journal (`twinRequestJournalFailures`), never silent. `volter-world tail
|
|
32
|
+
// --requests` merges the live lines.
|
|
31
33
|
//
|
|
32
34
|
// WHAT A LINE CARRIES. `{at, method, path, status}` — the shape — plus, when the request presented
|
|
33
35
|
// one, the CREDENTIAL SHAPE: which header/query-param NAMES carried a credential-looking value,
|
|
@@ -82,21 +84,54 @@ export type TwinRequestJournalEntry = {
|
|
|
82
84
|
ms?: number;
|
|
83
85
|
/** credential-looking headers/query params that arrived, named + fingerprinted, never quoted. */
|
|
84
86
|
credentials?: TwinCredentialShape[];
|
|
87
|
+
/** `request`: written BEFORE the request is served (an audit device's request entry, so a request whose
|
|
88
|
+
* audit cannot be written is refused); its outcome is the entry without a phase written after. */
|
|
89
|
+
phase?: 'request';
|
|
85
90
|
};
|
|
86
91
|
|
|
87
|
-
/** The journal
|
|
88
|
-
|
|
92
|
+
/** The journal's closed segments, oldest first: `requests.jsonl.<n>` beside the live file. */
|
|
93
|
+
function journalSegments(path: string): string[] {
|
|
94
|
+
const base = path.slice(path.lastIndexOf('/') + 1);
|
|
95
|
+
return getActiveWorldStore().list(dirname(path))
|
|
96
|
+
.map((name) => (name.startsWith(`${base}.`) ? Number(name.slice(base.length + 1)) : NaN))
|
|
97
|
+
.filter((n) => Number.isInteger(n) && n > 0)
|
|
98
|
+
.sort((a, b) => a - b)
|
|
99
|
+
.map((n) => `${path}.${n}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The journal read back: every entry kept for a service, oldest first (an empty list when the journal is switched off or empty).
|
|
103
|
+
* With `since` (epoch ms), segments that ended before it are not read. */
|
|
104
|
+
export function readTwinRequestJournal(service: string, root?: string, options: { since?: number } = {}): TwinRequestJournalEntry[] {
|
|
89
105
|
const path = twinRequestJournalPath(service, root);
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
for (const line of text.split('\n')) { if (!line) continue; try {
|
|
106
|
+
const parse = (file: string): TwinRequestJournalEntry[] => {
|
|
107
|
+
const text = getActiveWorldStore().read(file); if (!text) return [];
|
|
108
|
+
const rows: TwinRequestJournalEntry[] = [];
|
|
109
|
+
for (const line of text.split('\n')) { if (!line) continue; try { rows.push(JSON.parse(line) as TwinRequestJournalEntry); } catch { /* a torn line */ } }
|
|
110
|
+
return rows;
|
|
111
|
+
};
|
|
112
|
+
const kept: TwinRequestJournalEntry[][] = [parse(path)];
|
|
113
|
+
// newest first, stopping at the first segment that ended before `since`
|
|
114
|
+
for (const file of journalSegments(path).reverse()) {
|
|
115
|
+
const rows = parse(file);
|
|
116
|
+
const last = rows[rows.length - 1]?.at;
|
|
117
|
+
if (options.since !== undefined && last && Date.parse(last) < options.since) break;
|
|
118
|
+
kept.unshift(rows);
|
|
94
119
|
}
|
|
95
|
-
return
|
|
120
|
+
return kept.flat();
|
|
96
121
|
}
|
|
97
122
|
|
|
98
|
-
/**
|
|
99
|
-
*
|
|
123
|
+
/** Lines not kept, per journal: keyed by the journal's own path, so a process serving many Worlds
|
|
124
|
+
* (a host; an isolate holding several) answers for each World alone. */
|
|
125
|
+
const journalFailures = new Map<string, number>();
|
|
126
|
+
/** Journal lines this process failed to keep, since it started, for one service's journal under `root`
|
|
127
|
+
* (a World's request report sums its own twins and links). */
|
|
128
|
+
export function twinRequestJournalFailures(service: string, root?: string): number {
|
|
129
|
+
try { return journalFailures.get(twinRequestJournalPath(service, root)) ?? 0; } catch { return 0; }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
/** ~1 MB per segment: tens of thousands of entries (a shape line is ~80 bytes), and a report over a
|
|
134
|
+
* recent period reads only the segments that reach into it. */
|
|
100
135
|
const REQUEST_JOURNAL_MAX_BYTES = 1_000_000;
|
|
101
136
|
|
|
102
137
|
/**
|
|
@@ -168,32 +203,40 @@ export function twinRequestJournalPath(service: string, root?: string): string {
|
|
|
168
203
|
}
|
|
169
204
|
|
|
170
205
|
/**
|
|
171
|
-
* Append one
|
|
172
|
-
*
|
|
206
|
+
* Append one request to the journal — a no-op only when VOLTER_TWIN_REQUEST_JOURNAL=0. Never throws;
|
|
207
|
+
* answers whether the line was kept (true when the journal is switched off: no audit was chosen), so a
|
|
208
|
+
* caller that must not act unaudited (a link) can refuse.
|
|
173
209
|
*/
|
|
174
|
-
export function journalTwinRequest(service: string, entry: TwinRequestJournalEntry, root?: string):
|
|
175
|
-
if (!twinRequestJournalEnabled()) return;
|
|
210
|
+
export function journalTwinRequest(service: string, entry: TwinRequestJournalEntry, root?: string): boolean {
|
|
211
|
+
if (!twinRequestJournalEnabled()) return true;
|
|
212
|
+
const line = JSON.stringify({
|
|
213
|
+
at: entry.at ?? new Date().toISOString(),
|
|
214
|
+
...(entry.phase ? { phase: entry.phase } : {}),
|
|
215
|
+
method: entry.method.toUpperCase(),
|
|
216
|
+
path: entry.path.split('?')[0], // the query STRING never lands; its credential params do, named + fingerprinted
|
|
217
|
+
status: entry.status,
|
|
218
|
+
...(typeof entry.ms === 'number' ? { ms: entry.ms } : {}),
|
|
219
|
+
...(entry.credentials?.length ? { credentials: entry.credentials } : {}),
|
|
220
|
+
});
|
|
221
|
+
let path: string | undefined;
|
|
176
222
|
try {
|
|
177
223
|
const store = getActiveWorldStore();
|
|
178
|
-
|
|
224
|
+
path = twinRequestJournalPath(service, root);
|
|
179
225
|
store.mkdir(dirname(path));
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
//
|
|
183
|
-
|
|
226
|
+
if ((store.stat(path)?.size ?? 0) >= REQUEST_JOURNAL_MAX_BYTES) {
|
|
227
|
+
// the live file closes into the next segment; no segment is ever overwritten or removed (a crash between
|
|
228
|
+
// the two writes leaves the live lines in both: repeated in the next segment, never lost)
|
|
229
|
+
const next = Number(journalSegments(path).pop()?.slice(path.length + 1) ?? 0) + 1;
|
|
230
|
+
store.writeAtomic(`${path}.${next}`, store.read(path) ?? '');
|
|
184
231
|
store.write(path, '');
|
|
185
232
|
}
|
|
186
|
-
const line = JSON.stringify({
|
|
187
|
-
at: entry.at ?? new Date().toISOString(),
|
|
188
|
-
method: entry.method.toUpperCase(),
|
|
189
|
-
path: entry.path.split('?')[0], // the query STRING never lands; its credential params do, named + fingerprinted
|
|
190
|
-
status: entry.status,
|
|
191
|
-
...(typeof entry.ms === 'number' ? { ms: entry.ms } : {}),
|
|
192
|
-
...(entry.credentials?.length ? { credentials: entry.credentials } : {}),
|
|
193
|
-
});
|
|
194
233
|
store.append(path, `${line}\n`);
|
|
195
|
-
|
|
196
|
-
|
|
234
|
+
return true;
|
|
235
|
+
} catch (error) {
|
|
236
|
+
const key = path ?? `${root ?? ''}/${service}`;
|
|
237
|
+
journalFailures.set(key, (journalFailures.get(key) ?? 0) + 1);
|
|
238
|
+
console.error(`[request-journal] ${service}: a line was not kept: ${(error as Error).message}`);
|
|
239
|
+
return false;
|
|
197
240
|
}
|
|
198
241
|
}
|
|
199
242
|
|