@ifc-lite/collab-server 0.2.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.
Files changed (75) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +47 -0
  3. package/dist/audit-log.d.ts +77 -0
  4. package/dist/audit-log.d.ts.map +1 -0
  5. package/dist/audit-log.js +105 -0
  6. package/dist/audit-log.js.map +1 -0
  7. package/dist/auth.d.ts +24 -0
  8. package/dist/auth.d.ts.map +1 -0
  9. package/dist/auth.js +15 -0
  10. package/dist/auth.js.map +1 -0
  11. package/dist/bin.d.ts +3 -0
  12. package/dist/bin.d.ts.map +1 -0
  13. package/dist/bin.js +34 -0
  14. package/dist/bin.js.map +1 -0
  15. package/dist/blob-route.d.ts +58 -0
  16. package/dist/blob-route.d.ts.map +1 -0
  17. package/dist/blob-route.js +132 -0
  18. package/dist/blob-route.js.map +1 -0
  19. package/dist/index.d.ts +18 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +19 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/metrics.d.ts +56 -0
  24. package/dist/metrics.d.ts.map +1 -0
  25. package/dist/metrics.js +161 -0
  26. package/dist/metrics.js.map +1 -0
  27. package/dist/path-locks.d.ts +39 -0
  28. package/dist/path-locks.d.ts.map +1 -0
  29. package/dist/path-locks.js +176 -0
  30. package/dist/path-locks.js.map +1 -0
  31. package/dist/persistence-redis.d.ts +52 -0
  32. package/dist/persistence-redis.d.ts.map +1 -0
  33. package/dist/persistence-redis.js +50 -0
  34. package/dist/persistence-redis.js.map +1 -0
  35. package/dist/persistence-s3.d.ts +81 -0
  36. package/dist/persistence-s3.d.ts.map +1 -0
  37. package/dist/persistence-s3.js +183 -0
  38. package/dist/persistence-s3.js.map +1 -0
  39. package/dist/persistence.d.ts +41 -0
  40. package/dist/persistence.d.ts.map +1 -0
  41. package/dist/persistence.js +108 -0
  42. package/dist/persistence.js.map +1 -0
  43. package/dist/rate-limit.d.ts +29 -0
  44. package/dist/rate-limit.d.ts.map +1 -0
  45. package/dist/rate-limit.js +36 -0
  46. package/dist/rate-limit.js.map +1 -0
  47. package/dist/replay-protect.d.ts +51 -0
  48. package/dist/replay-protect.d.ts.map +1 -0
  49. package/dist/replay-protect.js +134 -0
  50. package/dist/replay-protect.js.map +1 -0
  51. package/dist/retention.d.ts +32 -0
  52. package/dist/retention.d.ts.map +1 -0
  53. package/dist/retention.js +112 -0
  54. package/dist/retention.js.map +1 -0
  55. package/dist/room-manager.d.ts +138 -0
  56. package/dist/room-manager.d.ts.map +1 -0
  57. package/dist/room-manager.js +380 -0
  58. package/dist/room-manager.js.map +1 -0
  59. package/dist/secure-bundle.d.ts +15 -0
  60. package/dist/secure-bundle.d.ts.map +1 -0
  61. package/dist/secure-bundle.js +49 -0
  62. package/dist/secure-bundle.js.map +1 -0
  63. package/dist/secure-server.d.ts +49 -0
  64. package/dist/secure-server.d.ts.map +1 -0
  65. package/dist/secure-server.js +86 -0
  66. package/dist/secure-server.js.map +1 -0
  67. package/dist/server.d.ts +59 -0
  68. package/dist/server.d.ts.map +1 -0
  69. package/dist/server.js +196 -0
  70. package/dist/server.js.map +1 -0
  71. package/dist/snapshot-worker.d.ts +32 -0
  72. package/dist/snapshot-worker.d.ts.map +1 -0
  73. package/dist/snapshot-worker.js +87 -0
  74. package/dist/snapshot-worker.js.map +1 -0
  75. package/package.json +66 -0
@@ -0,0 +1,108 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+ /**
5
+ * Pluggable persistence interface.
6
+ *
7
+ * v0.2 ships two implementations:
8
+ * - `MemoryPersistence` (default for tests)
9
+ * - `FilePersistence` (default for `start()` — append-only log per room)
10
+ *
11
+ * v0.5 will add S3 + Redis variants per spec §12.2.
12
+ */
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
+ export class MemoryPersistence {
16
+ logs = new Map();
17
+ async load(roomId) {
18
+ const arr = this.logs.get(roomId);
19
+ if (!arr || arr.length === 0)
20
+ return null;
21
+ return concat(arr);
22
+ }
23
+ async append(roomId, update) {
24
+ const arr = this.logs.get(roomId) ?? [];
25
+ arr.push(update);
26
+ this.logs.set(roomId, arr);
27
+ }
28
+ async compact(roomId, mergedState) {
29
+ this.logs.set(roomId, [mergedState]);
30
+ }
31
+ async drop(roomId) {
32
+ this.logs.delete(roomId);
33
+ }
34
+ }
35
+ /**
36
+ * Append-only file-per-room persistence.
37
+ *
38
+ * Update layout: each `append()` writes one frame `[length:u32][bytes]`
39
+ * to `<dataDir>/<sanitizedRoomId>.log`. `load()` reads the file and
40
+ * returns the concatenated payload.
41
+ *
42
+ * Compaction rewrites the log atomically by writing to a temp file then
43
+ * renaming.
44
+ */
45
+ export class FilePersistence {
46
+ dataDir;
47
+ constructor(opts) {
48
+ this.dataDir = opts.dataDir;
49
+ fs.mkdirSync(this.dataDir, { recursive: true });
50
+ }
51
+ logPath(roomId) {
52
+ const safe = roomId.replace(/[^a-zA-Z0-9._-]/g, '_');
53
+ return path.join(this.dataDir, `${safe}.log`);
54
+ }
55
+ async load(roomId) {
56
+ const file = this.logPath(roomId);
57
+ if (!fs.existsSync(file))
58
+ return null;
59
+ const buf = await fs.promises.readFile(file);
60
+ if (buf.byteLength === 0)
61
+ return null;
62
+ const frames = [];
63
+ let offset = 0;
64
+ while (offset + 4 <= buf.byteLength) {
65
+ const len = buf.readUInt32LE(offset);
66
+ offset += 4;
67
+ if (offset + len > buf.byteLength)
68
+ break;
69
+ frames.push(new Uint8Array(buf.buffer, buf.byteOffset + offset, len));
70
+ offset += len;
71
+ }
72
+ if (frames.length === 0)
73
+ return null;
74
+ return concat(frames);
75
+ }
76
+ async append(roomId, update) {
77
+ const file = this.logPath(roomId);
78
+ const header = Buffer.alloc(4);
79
+ header.writeUInt32LE(update.byteLength, 0);
80
+ const body = Buffer.concat([header, Buffer.from(update)]);
81
+ await fs.promises.appendFile(file, body);
82
+ }
83
+ async compact(roomId, mergedState) {
84
+ const file = this.logPath(roomId);
85
+ const tmp = `${file}.tmp`;
86
+ const header = Buffer.alloc(4);
87
+ header.writeUInt32LE(mergedState.byteLength, 0);
88
+ await fs.promises.writeFile(tmp, Buffer.concat([header, Buffer.from(mergedState)]));
89
+ await fs.promises.rename(tmp, file);
90
+ }
91
+ async drop(roomId) {
92
+ const file = this.logPath(roomId);
93
+ if (fs.existsSync(file)) {
94
+ await fs.promises.unlink(file);
95
+ }
96
+ }
97
+ }
98
+ function concat(arr) {
99
+ const total = arr.reduce((n, a) => n + a.byteLength, 0);
100
+ const out = new Uint8Array(total);
101
+ let o = 0;
102
+ for (const a of arr) {
103
+ out.set(a, o);
104
+ o += a.byteLength;
105
+ }
106
+ return out;
107
+ }
108
+ //# sourceMappingURL=persistence.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"persistence.js","sourceRoot":"","sources":["../src/persistence.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Per-peer write budget — open problem #10.
3
+ *
4
+ * AI agents (and over-eager scripts) can outwrite humans 100× in a
5
+ * sustained session. The server gates per-connection writes with a token
6
+ * bucket so a single agent peer can't starve real-time human edits.
7
+ *
8
+ * Default thresholds are generous; production deployments should tune
9
+ * them per role (humans get higher, service accounts lower).
10
+ */
11
+ export interface RateLimitOptions {
12
+ /** Capacity of the token bucket — burst size. Default 200. */
13
+ capacity?: number;
14
+ /** Refill rate in tokens per second. Default 60. */
15
+ refillPerSecond?: number;
16
+ }
17
+ export interface RateLimiter {
18
+ /**
19
+ * Try to consume `n` tokens. Returns true if allowed, false if the
20
+ * bucket is empty.
21
+ */
22
+ tryConsume(n?: number): boolean;
23
+ /** Current available tokens (rounded). */
24
+ available(): number;
25
+ /** Reset the bucket to full. */
26
+ reset(): void;
27
+ }
28
+ export declare function createRateLimiter(opts?: RateLimitOptions): RateLimiter;
29
+ //# sourceMappingURL=rate-limit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../src/rate-limit.ts"],"names":[],"mappings":"AAIA;;;;;;;;;GASG;AAEH,MAAM,WAAW,gBAAgB;IAC/B,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,oDAAoD;IACpD,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,WAAW;IAC1B;;;OAGG;IACH,UAAU,CAAC,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;IAChC,0CAA0C;IAC1C,SAAS,IAAI,MAAM,CAAC;IACpB,gCAAgC;IAChC,KAAK,IAAI,IAAI,CAAC;CACf;AAED,wBAAgB,iBAAiB,CAAC,IAAI,GAAE,gBAAqB,GAAG,WAAW,CAiC1E"}
@@ -0,0 +1,36 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+ export function createRateLimiter(opts = {}) {
5
+ const capacity = opts.capacity ?? 200;
6
+ const refill = opts.refillPerSecond ?? 60;
7
+ let tokens = capacity;
8
+ let lastRefill = Date.now();
9
+ const top = () => {
10
+ const now = Date.now();
11
+ const elapsed = (now - lastRefill) / 1000;
12
+ if (elapsed > 0) {
13
+ tokens = Math.min(capacity, tokens + elapsed * refill);
14
+ lastRefill = now;
15
+ }
16
+ };
17
+ return {
18
+ tryConsume(n = 1) {
19
+ top();
20
+ if (tokens >= n) {
21
+ tokens -= n;
22
+ return true;
23
+ }
24
+ return false;
25
+ },
26
+ available() {
27
+ top();
28
+ return Math.floor(tokens);
29
+ },
30
+ reset() {
31
+ tokens = capacity;
32
+ lastRefill = Date.now();
33
+ },
34
+ };
35
+ }
36
+ //# sourceMappingURL=rate-limit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit.js","sourceRoot":"","sources":["../src/rate-limit.ts"],"names":[],"mappings":"AAAA;;+DAE+D;AAgC/D,MAAM,UAAU,iBAAiB,CAAC,OAAyB,EAAE;IAC3D,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,IAAI,GAAG,CAAC;IACtC,MAAM,MAAM,GAAG,IAAI,CAAC,eAAe,IAAI,EAAE,CAAC;IAC1C,IAAI,MAAM,GAAG,QAAQ,CAAC;IACtB,IAAI,UAAU,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAE5B,MAAM,GAAG,GAAG,GAAG,EAAE;QACf,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACvB,MAAM,OAAO,GAAG,CAAC,GAAG,GAAG,UAAU,CAAC,GAAG,IAAI,CAAC;QAC1C,IAAI,OAAO,GAAG,CAAC,EAAE,CAAC;YAChB,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,GAAG,MAAM,CAAC,CAAC;YACvD,UAAU,GAAG,GAAG,CAAC;QACnB,CAAC;IACH,CAAC,CAAC;IAEF,OAAO;QACL,UAAU,CAAC,CAAC,GAAG,CAAC;YACd,GAAG,EAAE,CAAC;YACN,IAAI,MAAM,IAAI,CAAC,EAAE,CAAC;gBAChB,MAAM,IAAI,CAAC,CAAC;gBACZ,OAAO,IAAI,CAAC;YACd,CAAC;YACD,OAAO,KAAK,CAAC;QACf,CAAC;QACD,SAAS;YACP,GAAG,EAAE,CAAC;YACN,OAAO,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;QAC5B,CAAC;QACD,KAAK;YACH,MAAM,GAAG,QAAQ,CAAC;YAClB,UAAU,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QAC1B,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,51 @@
1
+ export interface UpdateEnvelope {
2
+ clientId: number;
3
+ clock: number;
4
+ payload: Uint8Array;
5
+ hmac: string;
6
+ }
7
+ export interface ReplayDecision {
8
+ ok: boolean;
9
+ reason?: 'bad-mac' | 'replay' | 'malformed';
10
+ }
11
+ export interface ReplayProtector {
12
+ /**
13
+ * Verify `envelope` against the configured secret + the tracked
14
+ * highest-seen clock. Increments the tracked clock on success.
15
+ */
16
+ verify(envelope: UpdateEnvelope): ReplayDecision;
17
+ /** Get the highest accepted clock for a clientId (0 if unseen). */
18
+ highestClock(clientId: number): number;
19
+ /** Reset internal state (tests / room recycling). */
20
+ reset(): void;
21
+ }
22
+ export interface ReplayProtectorOptions {
23
+ /** HMAC-SHA256 secret. Must match the client. */
24
+ secret: string;
25
+ /** Maximum clock drift accepted in either direction (default 0 = strict monotonic). */
26
+ driftTolerance?: number;
27
+ }
28
+ export declare function createReplayProtector(opts: ReplayProtectorOptions): ReplayProtector;
29
+ /**
30
+ * Compute an HMAC-SHA256 hex digest over `(clientId || clock || payload)`.
31
+ * Exposed so clients in tests (and non-Node environments) can produce
32
+ * matching tags from the same canonical encoding.
33
+ */
34
+ export declare function computeHmac(secret: Buffer, clientId: number, clock: number, payload: Uint8Array): string;
35
+ export declare function decodeSignedFrame(frame: Uint8Array): UpdateEnvelope | null;
36
+ export declare function encodeSignedFrame(envelope: UpdateEnvelope): Uint8Array;
37
+ /**
38
+ * Adapter from a `ReplayProtector` to the server's `VerifyMessageFn`
39
+ * shape. Frames whose first byte is the `SIGNED_TAG` are parsed and
40
+ * verified; messages that aren't tagged are passed through (for clients
41
+ * that don't sign). To force-reject unsigned messages, set
42
+ * `requireSigned: true`.
43
+ */
44
+ export declare function verifyWithReplayProtector(protector: ReplayProtector, options?: {
45
+ requireSigned?: boolean;
46
+ }): (msg: Uint8Array) => {
47
+ ok: boolean;
48
+ reason?: string;
49
+ payload?: Uint8Array;
50
+ };
51
+ //# sourceMappingURL=replay-protect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replay-protect.d.ts","sourceRoot":"","sources":["../src/replay-protect.ts"],"names":[],"mappings":"AAwBA,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,UAAU,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,CAAC,EAAE,SAAS,GAAG,QAAQ,GAAG,WAAW,CAAC;CAC7C;AAED,MAAM,WAAW,eAAe;IAC9B;;;OAGG;IACH,MAAM,CAAC,QAAQ,EAAE,cAAc,GAAG,cAAc,CAAC;IACjD,mEAAmE;IACnE,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC;IACvC,qDAAqD;IACrD,KAAK,IAAI,IAAI,CAAC;CACf;AAED,MAAM,WAAW,sBAAsB;IACrC,iDAAiD;IACjD,MAAM,EAAE,MAAM,CAAC;IACf,uFAAuF;IACvF,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,sBAAsB,GAAG,eAAe,CAoCnF;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,GAAG,MAAM,CAOxG;AAyBD,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,UAAU,GAAG,cAAc,GAAG,IAAI,CAS1E;AAED,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,cAAc,GAAG,UAAU,CAUtE;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CACvC,SAAS,EAAE,eAAe,EAC1B,OAAO,GAAE;IAAE,aAAa,CAAC,EAAE,OAAO,CAAA;CAAO,GACxC,CAAC,GAAG,EAAE,UAAU,KAAK;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,UAAU,CAAA;CAAE,CAY7E"}
@@ -0,0 +1,134 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+ /**
5
+ * Anti-replay verification (open problem #8).
6
+ *
7
+ * Every authenticated peer can send Y updates over the websocket. The
8
+ * server validates the JWT on connect, but unless the bytes themselves
9
+ * are signed, a malicious peer with a valid token can replay or forge
10
+ * updates.
11
+ *
12
+ * This module ships an opt-in HMAC-SHA256 verifier:
13
+ * - Client side (out of scope here): tag each update with
14
+ * `H = hmac_sha256(secret, clientId || clock || update)`.
15
+ * - Server side: validate `H` and enforce monotonic clocks per
16
+ * clientId so old payloads can't be replayed.
17
+ *
18
+ * The verifier is provided as a pure function so deployers wire it into
19
+ * their handler pipeline alongside the existing `canWrite` role check.
20
+ */
21
+ import * as crypto from 'node:crypto';
22
+ export function createReplayProtector(opts) {
23
+ const secret = Buffer.from(opts.secret, 'utf8');
24
+ const drift = opts.driftTolerance ?? 0;
25
+ const seen = new Map();
26
+ return {
27
+ verify(envelope) {
28
+ if (typeof envelope.clientId !== 'number' ||
29
+ typeof envelope.clock !== 'number' ||
30
+ !(envelope.payload instanceof Uint8Array) ||
31
+ typeof envelope.hmac !== 'string') {
32
+ return { ok: false, reason: 'malformed' };
33
+ }
34
+ const expected = computeHmac(secret, envelope.clientId, envelope.clock, envelope.payload);
35
+ if (!constantTimeEqual(envelope.hmac, expected)) {
36
+ return { ok: false, reason: 'bad-mac' };
37
+ }
38
+ const last = seen.get(envelope.clientId) ?? 0;
39
+ if (envelope.clock + drift <= last) {
40
+ return { ok: false, reason: 'replay' };
41
+ }
42
+ seen.set(envelope.clientId, envelope.clock);
43
+ return { ok: true };
44
+ },
45
+ highestClock(clientId) {
46
+ return seen.get(clientId) ?? 0;
47
+ },
48
+ reset() {
49
+ seen.clear();
50
+ },
51
+ };
52
+ }
53
+ /**
54
+ * Compute an HMAC-SHA256 hex digest over `(clientId || clock || payload)`.
55
+ * Exposed so clients in tests (and non-Node environments) can produce
56
+ * matching tags from the same canonical encoding.
57
+ */
58
+ export function computeHmac(secret, clientId, clock, payload) {
59
+ const header = Buffer.alloc(16);
60
+ header.writeUInt32BE(0, 0);
61
+ header.writeUInt32BE(clientId >>> 0, 4);
62
+ header.writeUInt32BE(0, 8);
63
+ header.writeUInt32BE(clock >>> 0, 12);
64
+ return crypto.createHmac('sha256', secret).update(header).update(payload).digest('hex');
65
+ }
66
+ function constantTimeEqual(a, b) {
67
+ if (a.length !== b.length)
68
+ return false;
69
+ try {
70
+ return crypto.timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ /* ------------------------------------------------------------------ */
77
+ /* Wire integration */
78
+ /* ------------------------------------------------------------------ */
79
+ /**
80
+ * Default envelope decoder for messages of the form
81
+ * `[1B SIGNED_TAG = 0xff][4B clientId][4B clock][32B HMAC hex'd ascii][N B payload]`.
82
+ *
83
+ * Total framing overhead is 1 + 4 + 4 + 64 + N = 73 + N bytes — small
84
+ * relative to typical Y updates. Apps with their own envelope can
85
+ * write a custom verifier instead.
86
+ */
87
+ const SIGNED_TAG = 0xff;
88
+ export function decodeSignedFrame(frame) {
89
+ if (frame.byteLength < 1 + 4 + 4 + 64)
90
+ return null;
91
+ if (frame[0] !== SIGNED_TAG)
92
+ return null;
93
+ const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
94
+ const clientId = view.getUint32(1, false);
95
+ const clock = view.getUint32(5, false);
96
+ const hmac = new TextDecoder('ascii').decode(frame.subarray(9, 9 + 64));
97
+ const payload = frame.subarray(9 + 64);
98
+ return { clientId, clock, payload, hmac };
99
+ }
100
+ export function encodeSignedFrame(envelope) {
101
+ const payload = envelope.payload;
102
+ const out = new Uint8Array(1 + 4 + 4 + 64 + payload.byteLength);
103
+ out[0] = SIGNED_TAG;
104
+ const view = new DataView(out.buffer);
105
+ view.setUint32(1, envelope.clientId >>> 0, false);
106
+ view.setUint32(5, envelope.clock >>> 0, false);
107
+ out.set(new TextEncoder().encode(envelope.hmac), 9);
108
+ out.set(payload, 9 + 64);
109
+ return out;
110
+ }
111
+ /**
112
+ * Adapter from a `ReplayProtector` to the server's `VerifyMessageFn`
113
+ * shape. Frames whose first byte is the `SIGNED_TAG` are parsed and
114
+ * verified; messages that aren't tagged are passed through (for clients
115
+ * that don't sign). To force-reject unsigned messages, set
116
+ * `requireSigned: true`.
117
+ */
118
+ export function verifyWithReplayProtector(protector, options = {}) {
119
+ return (msg) => {
120
+ if (msg.byteLength === 0 || msg[0] !== SIGNED_TAG) {
121
+ if (options.requireSigned)
122
+ return { ok: false, reason: 'unsigned' };
123
+ return { ok: true };
124
+ }
125
+ const envelope = decodeSignedFrame(msg);
126
+ if (!envelope)
127
+ return { ok: false, reason: 'malformed' };
128
+ const result = protector.verify(envelope);
129
+ if (!result.ok)
130
+ return { ok: false, reason: result.reason };
131
+ return { ok: true, payload: envelope.payload };
132
+ };
133
+ }
134
+ //# sourceMappingURL=replay-protect.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replay-protect.js","sourceRoot":"","sources":["../src/replay-protect.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,32 @@
1
+ export interface RetentionPolicy {
2
+ /** Keep the raw append-only log for this many days. Default 90. */
3
+ fullLogDays?: number;
4
+ /** Keep compacted snapshots for this many days. Default 365 * 5. */
5
+ snapshotsDays?: number;
6
+ /** Optional cap on total bytes per room. Soft — we trim oldest first. */
7
+ maxBytesPerRoom?: number;
8
+ }
9
+ export declare const DEFAULT_RETENTION: Required<Omit<RetentionPolicy, 'maxBytesPerRoom'>> & Pick<RetentionPolicy, 'maxBytesPerRoom'>;
10
+ export interface RetentionDecision {
11
+ /** Files to delete entirely. */
12
+ drop: string[];
13
+ /** Bytes that will be reclaimed once `drop` is processed. */
14
+ reclaimBytes: number;
15
+ }
16
+ /**
17
+ * Plan retention for a directory of room logs/snapshots.
18
+ *
19
+ * Naming convention used by `FilePersistence`:
20
+ * - `<roomId>.log` ← active log (never deleted by this fn)
21
+ * - `<roomId>.log.<isoStamp>` ← rotated logs (auditable)
22
+ * - `<roomId>.snap.<isoStamp>` ← periodic snapshots (kept longer)
23
+ *
24
+ * Tools shipping their own naming convention can pass a custom matcher
25
+ * via the `classify` option.
26
+ */
27
+ export declare function planRetention(dir: string, policy?: RetentionPolicy, options?: {
28
+ classify?: (file: string) => 'active' | 'log' | 'snapshot' | 'unknown';
29
+ }): RetentionDecision;
30
+ /** Apply a `RetentionDecision` to disk. Returns the bytes actually freed. */
31
+ export declare function applyRetention(decision: RetentionDecision): Promise<number>;
32
+ //# sourceMappingURL=retention.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"retention.d.ts","sourceRoot":"","sources":["../src/retention.ts"],"names":[],"mappings":"AAoBA,MAAM,WAAW,eAAe;IAC9B,mEAAmE;IACnE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,oEAAoE;IACpE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,yEAAyE;IACzE,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,eAAO,MAAM,iBAAiB,EAAE,QAAQ,CAAC,IAAI,CAAC,eAAe,EAAE,iBAAiB,CAAC,CAAC,GAChF,IAAI,CAAC,eAAe,EAAE,iBAAiB,CAIxC,CAAC;AAEF,MAAM,WAAW,iBAAiB;IAChC,gCAAgC;IAChC,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,6DAA6D;IAC7D,YAAY,EAAE,MAAM,CAAC;CACtB;AASD;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAC3B,GAAG,EAAE,MAAM,EACX,MAAM,GAAE,eAAoB,EAC5B,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,QAAQ,GAAG,KAAK,GAAG,UAAU,GAAG,SAAS,CAAA;CAAO,GACvF,iBAAiB,CAiDnB;AAED,6EAA6E;AAC7E,wBAAsB,cAAc,CAAC,QAAQ,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,CAejF"}
@@ -0,0 +1,112 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+ /**
5
+ * Retention policy (spec §12.2 + open problem #9).
6
+ *
7
+ * Storing every Y update for years × every project is expensive. The
8
+ * default policy we ship: keep the full update log for `fullLogDays`
9
+ * (90 by default), then keep only periodic snapshots after that.
10
+ *
11
+ * The server's `RoomManager` already compacts every `compactEvery`
12
+ * updates; this module adds the "drop old log frames" step that runs
13
+ * out-of-band. It is written as pure functions so a deployment can
14
+ * trigger it from cron, a queue, or a Lambda — wherever fits.
15
+ */
16
+ import * as fs from 'node:fs';
17
+ import * as path from 'node:path';
18
+ export const DEFAULT_RETENTION = {
19
+ fullLogDays: 90,
20
+ snapshotsDays: 365 * 5,
21
+ maxBytesPerRoom: undefined,
22
+ };
23
+ /**
24
+ * Plan retention for a directory of room logs/snapshots.
25
+ *
26
+ * Naming convention used by `FilePersistence`:
27
+ * - `<roomId>.log` ← active log (never deleted by this fn)
28
+ * - `<roomId>.log.<isoStamp>` ← rotated logs (auditable)
29
+ * - `<roomId>.snap.<isoStamp>` ← periodic snapshots (kept longer)
30
+ *
31
+ * Tools shipping their own naming convention can pass a custom matcher
32
+ * via the `classify` option.
33
+ */
34
+ export function planRetention(dir, policy = {}, options = {}) {
35
+ if (!fs.existsSync(dir))
36
+ return { drop: [], reclaimBytes: 0 };
37
+ const cfg = {
38
+ ...DEFAULT_RETENTION,
39
+ ...policy,
40
+ };
41
+ const classify = options.classify ?? defaultClassify;
42
+ const now = Date.now();
43
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
44
+ const facts = [];
45
+ for (const entry of entries) {
46
+ if (!entry.isFile())
47
+ continue;
48
+ const filePath = path.join(dir, entry.name);
49
+ const stat = fs.statSync(filePath);
50
+ facts.push({ filePath, bytes: stat.size, ageMs: now - stat.mtimeMs });
51
+ }
52
+ const drop = [];
53
+ let reclaim = 0;
54
+ for (const f of facts) {
55
+ const kind = classify(path.basename(f.filePath));
56
+ if (kind === 'active' || kind === 'unknown')
57
+ continue;
58
+ const ageDays = f.ageMs / (1000 * 60 * 60 * 24);
59
+ const maxDays = kind === 'snapshot' ? cfg.snapshotsDays : cfg.fullLogDays;
60
+ if (ageDays > maxDays) {
61
+ drop.push(f.filePath);
62
+ reclaim += f.bytes;
63
+ }
64
+ }
65
+ if (cfg.maxBytesPerRoom != null) {
66
+ // Trim from oldest until under cap.
67
+ const remaining = facts
68
+ .filter((f) => !drop.includes(f.filePath))
69
+ .sort((a, b) => b.ageMs - a.ageMs);
70
+ const total = remaining.reduce((s, f) => s + f.bytes, 0);
71
+ let over = total - cfg.maxBytesPerRoom;
72
+ while (over > 0 && remaining.length > 0) {
73
+ const oldest = remaining.shift();
74
+ const kind = classify(path.basename(oldest.filePath));
75
+ if (kind === 'active')
76
+ continue;
77
+ drop.push(oldest.filePath);
78
+ reclaim += oldest.bytes;
79
+ over -= oldest.bytes;
80
+ }
81
+ }
82
+ return { drop, reclaimBytes: reclaim };
83
+ }
84
+ /** Apply a `RetentionDecision` to disk. Returns the bytes actually freed. */
85
+ export async function applyRetention(decision) {
86
+ let freed = 0;
87
+ for (const file of decision.drop) {
88
+ try {
89
+ const stat = await fs.promises.stat(file);
90
+ await fs.promises.unlink(file);
91
+ freed += stat.size;
92
+ }
93
+ catch (err) {
94
+ if (err.code !== 'ENOENT') {
95
+ // eslint-disable-next-line no-console
96
+ console.error('[collab-server] retention unlink failed:', err);
97
+ }
98
+ }
99
+ }
100
+ return freed;
101
+ }
102
+ /** Default classifier matching `FilePersistence` naming. */
103
+ function defaultClassify(name) {
104
+ if (/\.log$/.test(name))
105
+ return 'active';
106
+ if (/\.log\..+$/.test(name))
107
+ return 'log';
108
+ if (/\.snap\..+$/.test(name))
109
+ return 'snapshot';
110
+ return 'unknown';
111
+ }
112
+ //# sourceMappingURL=retention.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"retention.js","sourceRoot":"","sources":["../src/retention.ts"],"names":[],"mappings":"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"}