@lumenflow/packs-sidekick 4.24.0 → 5.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.
Files changed (71) hide show
  1. package/dist/channel-ingress.d.ts +46 -0
  2. package/dist/channel-ingress.d.ts.map +1 -0
  3. package/dist/channel-ingress.js +90 -0
  4. package/dist/channel-ingress.js.map +1 -0
  5. package/dist/manifest.d.ts +17 -0
  6. package/dist/manifest.d.ts.map +1 -1
  7. package/dist/manifest.js +67 -0
  8. package/dist/manifest.js.map +1 -1
  9. package/dist/sidekick-events.d.ts +166 -0
  10. package/dist/sidekick-events.d.ts.map +1 -0
  11. package/dist/sidekick-events.js +288 -0
  12. package/dist/sidekick-events.js.map +1 -0
  13. package/dist/src/adapters/cloud-queue.d.ts +20 -0
  14. package/dist/src/adapters/cloud-queue.d.ts.map +1 -0
  15. package/dist/src/adapters/cloud-queue.js +90 -0
  16. package/dist/src/adapters/cloud-queue.js.map +1 -0
  17. package/dist/src/adapters/control-plane-bridge.adapter.d.ts +31 -0
  18. package/dist/src/adapters/control-plane-bridge.adapter.d.ts.map +1 -0
  19. package/dist/src/adapters/control-plane-bridge.adapter.js +263 -0
  20. package/dist/src/adapters/control-plane-bridge.adapter.js.map +1 -0
  21. package/dist/src/adapters/filesystem-bridge.adapter.d.ts +11 -0
  22. package/dist/src/adapters/filesystem-bridge.adapter.d.ts.map +1 -0
  23. package/dist/src/adapters/filesystem-bridge.adapter.js +161 -0
  24. package/dist/src/adapters/filesystem-bridge.adapter.js.map +1 -0
  25. package/dist/src/channel-ingress.d.ts +46 -0
  26. package/dist/src/channel-ingress.d.ts.map +1 -0
  27. package/dist/src/channel-ingress.js +90 -0
  28. package/dist/src/channel-ingress.js.map +1 -0
  29. package/dist/src/domain/channel.types.d.ts +79 -0
  30. package/dist/src/domain/channel.types.d.ts.map +1 -0
  31. package/dist/src/domain/channel.types.js +4 -0
  32. package/dist/src/domain/channel.types.js.map +1 -0
  33. package/dist/src/ports/channel-bridge.port.d.ts +49 -0
  34. package/dist/src/ports/channel-bridge.port.d.ts.map +1 -0
  35. package/dist/src/ports/channel-bridge.port.js +18 -0
  36. package/dist/src/ports/channel-bridge.port.js.map +1 -0
  37. package/dist/src/routines/commit.d.ts +21 -0
  38. package/dist/src/routines/commit.d.ts.map +1 -0
  39. package/dist/src/routines/commit.js +42 -0
  40. package/dist/src/routines/commit.js.map +1 -0
  41. package/dist/src/sidekick-events.d.ts +166 -0
  42. package/dist/src/sidekick-events.d.ts.map +1 -0
  43. package/dist/src/sidekick-events.js +288 -0
  44. package/dist/src/sidekick-events.js.map +1 -0
  45. package/dist/tool-impl/channel-tools.d.ts.map +1 -1
  46. package/dist/tool-impl/channel-tools.js +24 -0
  47. package/dist/tool-impl/channel-tools.js.map +1 -1
  48. package/dist/tool-impl/memory-tools.d.ts.map +1 -1
  49. package/dist/tool-impl/memory-tools.js +7 -0
  50. package/dist/tool-impl/memory-tools.js.map +1 -1
  51. package/dist/tool-impl/routine-commit.d.ts +3 -0
  52. package/dist/tool-impl/routine-commit.d.ts.map +1 -0
  53. package/dist/tool-impl/routine-commit.js +69 -0
  54. package/dist/tool-impl/routine-commit.js.map +1 -0
  55. package/dist/tool-impl/routine-tools.d.ts.map +1 -1
  56. package/dist/tool-impl/routine-tools.js +50 -7
  57. package/dist/tool-impl/routine-tools.js.map +1 -1
  58. package/dist/tool-impl/runtime-context.d.ts +4 -0
  59. package/dist/tool-impl/runtime-context.d.ts.map +1 -1
  60. package/dist/tool-impl/runtime-context.js.map +1 -1
  61. package/dist/tool-impl/storage.d.ts.map +1 -1
  62. package/dist/tool-impl/storage.js +3 -0
  63. package/dist/tool-impl/storage.js.map +1 -1
  64. package/dist/tool-impl/system-tools.d.ts.map +1 -1
  65. package/dist/tool-impl/system-tools.js +2 -0
  66. package/dist/tool-impl/system-tools.js.map +1 -1
  67. package/dist/tool-impl/task-tools.d.ts.map +1 -1
  68. package/dist/tool-impl/task-tools.js +30 -0
  69. package/dist/tool-impl/task-tools.js.map +1 -1
  70. package/manifest.yaml +88 -0
  71. package/package.json +4 -2
@@ -0,0 +1,263 @@
1
+ // Copyright (c) 2026 Hellmai Ltd
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ /**
4
+ * WU-2737 (INIT-060 WU-7b, ADR-013 §ChannelBridge):
5
+ * Control-plane ChannelBridge adapter.
6
+ *
7
+ * Cloud-backed implementation of the ChannelBridge port. Codes directly
8
+ * against the contract published in
9
+ * `docs/operations/coordination/channel-bridge-cloud-stub.md` (WU-2736):
10
+ *
11
+ * POST /api/v1/workspaces/{workspace_id}/sidekick/channel
12
+ *
13
+ * Responsibilities:
14
+ * - Outbound `send()` POSTs each envelope with `Bearer <enrollment_token>`.
15
+ * - At-least-once delivery scoped by `(channel_id, envelope.id)` dedup key;
16
+ * safe to replay on transport failure (cloud returns `deduped: true`).
17
+ * - Backpressure split (ADR-013 §4):
18
+ * - `kind: "ephemeral"` fails silently on 5xx / 429 / network errors
19
+ * (no outbox, no retry, no elevated logging).
20
+ * - `kind: "queue"` parks in the local outbox on any non-2xx and drains
21
+ * FIFO on reconnect / explicit `flush()` / `disconnect()`.
22
+ * - Inbound `receive()` drains a loopback-friendly poll endpoint. The stub
23
+ * contract (WU-2736) does not yet freeze the inbound wire shape; the
24
+ * adapter exposes `pollIntervalMs` + a minimal GET fetcher so a mock
25
+ * server or future coord-stub-signed inbound path can plug in without
26
+ * changing the port.
27
+ * - `register()` is idempotent on BridgeConfig identity (deterministic id
28
+ * from provider/name/options hash — matches the filesystem adapter pattern).
29
+ * - `disconnect()` flushes queued envelopes before resolving; subsequent
30
+ * `send()` on the same channel id rejects (port contract).
31
+ */
32
+ import { createHash } from 'node:crypto';
33
+ import { setTimeout as delay } from 'node:timers/promises';
34
+ import { CloudOutbox } from './cloud-queue.js';
35
+ // ---------------------------------------------------------------------------
36
+ // Internal helpers
37
+ // ---------------------------------------------------------------------------
38
+ const DEFAULT_POLL_INTERVAL_MS = 1000;
39
+ const QUEUED_REASON = 'queued_for_replay';
40
+ const DROPPED_EPHEMERAL_REASON = 'dropped_ephemeral';
41
+ const INVALID_ENVELOPE_REASON = 'invalid_envelope';
42
+ const DISCONNECTED_REASON = 'disconnected';
43
+ function hashOptions(options) {
44
+ const payload = JSON.stringify(options ?? {}, Object.keys(options ?? {}).sort());
45
+ return createHash('sha256').update(payload).digest('hex').slice(0, 16);
46
+ }
47
+ function mintChannelId(config) {
48
+ const payload = `${config.provider}::${config.name}::${hashOptions(config.options)}`;
49
+ const digest = createHash('sha256').update(payload).digest('hex').slice(0, 24);
50
+ return `chan-${digest}`;
51
+ }
52
+ function safeParseJson(text) {
53
+ try {
54
+ return JSON.parse(text);
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ // ---------------------------------------------------------------------------
61
+ // Adapter factory
62
+ // ---------------------------------------------------------------------------
63
+ export function createControlPlaneChannelBridge(options) {
64
+ const baseUrl = options.baseUrl.replace(/\/+$/, '');
65
+ const workspaceId = options.workspaceId;
66
+ const tokenProvider = options.tokenProvider;
67
+ const outbox = new CloudOutbox(options.outboxDir);
68
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
69
+ const fetchImpl = options.fetchImpl ?? fetch;
70
+ const registry = new Map();
71
+ const disconnected = new Set();
72
+ function postUrl() {
73
+ return `${baseUrl}/api/v1/workspaces/${encodeURIComponent(workspaceId)}/sidekick/channel`;
74
+ }
75
+ function inboundUrl(channelId) {
76
+ return `${postUrl()}?channel_id=${encodeURIComponent(channelId)}`;
77
+ }
78
+ async function authHeaders() {
79
+ const token = await tokenProvider();
80
+ return {
81
+ 'Content-Type': 'application/json',
82
+ Authorization: `Bearer ${token}`,
83
+ };
84
+ }
85
+ async function postOnce(channelId, envelope) {
86
+ let response;
87
+ try {
88
+ response = await fetchImpl(postUrl(), {
89
+ method: 'POST',
90
+ headers: await authHeaders(),
91
+ body: JSON.stringify({ channel_id: channelId, envelope }),
92
+ });
93
+ }
94
+ catch (error) {
95
+ return { kind: 'network_error', cause: error };
96
+ }
97
+ const text = await response.text();
98
+ if (response.ok) {
99
+ const body = safeParseJson(text) ?? {};
100
+ return { kind: 'accepted', body };
101
+ }
102
+ const errorBody = safeParseJson(text) ?? {};
103
+ const retryAfterHeader = response.headers.get('retry-after');
104
+ const retryAfterSeconds = retryAfterHeader ? Number.parseInt(retryAfterHeader, 10) : undefined;
105
+ // 4xx non-retryable per contract §Error Response Shape — everything
106
+ // other than 429 is a correctness bug / auth failure we don't retry.
107
+ if (response.status >= 400 && response.status < 500 && response.status !== 429) {
108
+ return { kind: 'non_retryable', status: response.status, error: errorBody.error };
109
+ }
110
+ return {
111
+ kind: 'retryable',
112
+ status: response.status,
113
+ error: errorBody.error,
114
+ retryAfterSeconds: Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : undefined,
115
+ };
116
+ }
117
+ function nonRetryableReason(error) {
118
+ // Contract codes map 1:1 onto SendResult.reason for observability. Unknown
119
+ // codes collapse into a stable catch-all so audit logs stay readable.
120
+ const code = error?.code;
121
+ if (code === 'invalid_envelope')
122
+ return INVALID_ENVELOPE_REASON;
123
+ if (code)
124
+ return code;
125
+ return 'rejected_non_retryable';
126
+ }
127
+ async function drainOutbox() {
128
+ const pending = await outbox.list();
129
+ for (const record of pending) {
130
+ // Cast: outbox entries are keyed by the same branded ids we minted.
131
+ const cid = record.channel_id;
132
+ const outcome = await postOnce(cid, record.envelope);
133
+ if (outcome.kind === 'accepted') {
134
+ await outbox.remove(record._filename);
135
+ continue;
136
+ }
137
+ if (outcome.kind === 'non_retryable') {
138
+ // Correctness bug on a parked envelope — drop it so the outbox does
139
+ // not grow unbounded on permanent errors (matches contract table:
140
+ // `invalid_envelope` / `workspace_mismatch` are log-and-drop).
141
+ await outbox.remove(record._filename);
142
+ continue;
143
+ }
144
+ // Retryable / network error — stop the drain; caller retries later.
145
+ return;
146
+ }
147
+ }
148
+ async function sendInternal(channelId, envelope) {
149
+ // Opportunistic drain: a successful path clears any previous parked
150
+ // envelopes before the new one POSTs, preserving FIFO order under the
151
+ // "replay-on-reconnect" rule (§4).
152
+ if (envelope.kind === 'queue') {
153
+ await drainOutbox();
154
+ }
155
+ const outcome = await postOnce(channelId, envelope);
156
+ if (outcome.kind === 'accepted') {
157
+ const body = outcome.body;
158
+ const result = {
159
+ accepted: body.accepted ?? true,
160
+ };
161
+ if (body.delivery_id !== undefined) {
162
+ result.delivery_id = body.delivery_id;
163
+ }
164
+ if (body.deduped === true) {
165
+ result.deduped = true;
166
+ }
167
+ if (body.reason !== undefined) {
168
+ result.reason = body.reason;
169
+ }
170
+ return result;
171
+ }
172
+ if (outcome.kind === 'non_retryable') {
173
+ // Both kinds: log-and-drop per §Error Response Shape.
174
+ return {
175
+ accepted: false,
176
+ reason: nonRetryableReason(outcome.error),
177
+ };
178
+ }
179
+ // Retryable (5xx / 429) OR network error: split by envelope.kind.
180
+ if (envelope.kind === 'ephemeral') {
181
+ // Fail-silent per §4; no outbox, no throw.
182
+ return { accepted: false, reason: DROPPED_EPHEMERAL_REASON };
183
+ }
184
+ // queue: park for replay.
185
+ await outbox.enqueue(channelId, envelope);
186
+ return { accepted: false, reason: QUEUED_REASON };
187
+ }
188
+ // -------------------------------------------------------------------------
189
+ // Port methods
190
+ // -------------------------------------------------------------------------
191
+ return {
192
+ async register(bridgeConfig) {
193
+ const key = `${bridgeConfig.provider}::${bridgeConfig.name}::${hashOptions(bridgeConfig.options)}`;
194
+ const existing = registry.get(key);
195
+ if (existing) {
196
+ return existing;
197
+ }
198
+ const id = mintChannelId(bridgeConfig);
199
+ registry.set(key, id);
200
+ return id;
201
+ },
202
+ async send(channelId, envelope) {
203
+ if (disconnected.has(channelId)) {
204
+ throw new Error(`ControlPlaneChannelBridge: channel ${channelId} is disconnected; send rejected (reason=${DISCONNECTED_REASON}).`);
205
+ }
206
+ return sendInternal(channelId, envelope);
207
+ },
208
+ async *receive(channelId) {
209
+ // Poll-based inbound. One pass drains whatever is currently queued on
210
+ // the cloud side; the loop continues until `disconnect()` flips the
211
+ // flag so the iterator terminates without hanging (port contract).
212
+ while (!disconnected.has(channelId)) {
213
+ let response;
214
+ try {
215
+ response = await fetchImpl(inboundUrl(channelId), {
216
+ method: 'GET',
217
+ headers: await authHeaders(),
218
+ });
219
+ }
220
+ catch {
221
+ // Transient network error — back off and retry.
222
+ await delay(pollIntervalMs);
223
+ continue;
224
+ }
225
+ if (!response.ok) {
226
+ // 4xx/5xx on poll: back off. Errors here do NOT terminate the
227
+ // iterator; the port contract treats the iterator as long-lived
228
+ // until `disconnect()`.
229
+ await delay(pollIntervalMs);
230
+ continue;
231
+ }
232
+ const text = await response.text();
233
+ const body = safeParseJson(text) ?? {};
234
+ const envelopes = body.envelopes ?? [];
235
+ for (const env of envelopes) {
236
+ if (disconnected.has(channelId)) {
237
+ return;
238
+ }
239
+ yield env;
240
+ }
241
+ if (envelopes.length === 0) {
242
+ await delay(pollIntervalMs);
243
+ }
244
+ }
245
+ },
246
+ async disconnect(channelId) {
247
+ // Flush outbox before closing so queued envelopes for any channel have
248
+ // a chance to drain (port contract: "queued envelopes flush before
249
+ // this resolves").
250
+ try {
251
+ await drainOutbox();
252
+ }
253
+ catch {
254
+ // Drain best-effort; disconnect must always resolve.
255
+ }
256
+ disconnected.add(channelId);
257
+ },
258
+ async flush() {
259
+ await drainOutbox();
260
+ },
261
+ };
262
+ }
263
+ //# sourceMappingURL=control-plane-bridge.adapter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"control-plane-bridge.adapter.js","sourceRoot":"","sources":["../../../src/adapters/control-plane-bridge.adapter.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ import type { ChannelBridge } from '../ports/channel-bridge.port.js';
2
+ export interface FilesystemChannelBridgeOptions {
3
+ /**
4
+ * Directory under which the bridge materialises channel state. Tests set
5
+ * this to a tmpdir; in production the caller passes
6
+ * `.lumenflow/state/packs/sidekick/` (or equivalent per ADR-013).
7
+ */
8
+ rootDir: string;
9
+ }
10
+ export declare function createFilesystemChannelBridge(options: FilesystemChannelBridgeOptions): ChannelBridge;
11
+ //# sourceMappingURL=filesystem-bridge.adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"filesystem-bridge.adapter.d.ts","sourceRoot":"","sources":["../../../src/adapters/filesystem-bridge.adapter.ts"],"names":[],"mappings":"AAmCA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AAMrE,MAAM,WAAW,8BAA8B;IAC7C;;;;OAIG;IACH,OAAO,EAAE,MAAM,CAAC;CACjB;AAoFD,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,8BAA8B,GACtC,aAAa,CAyFf"}
@@ -0,0 +1,161 @@
1
+ // Copyright (c) 2026 Hellmai Ltd
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ /**
4
+ * WU-2735 (INIT-060 WU-7a, ADR-013 §ChannelBridge):
5
+ * Filesystem implementation of the ChannelBridge port.
6
+ *
7
+ * Backing layout:
8
+ *
9
+ * <rootDir>/channels/<channel-id>/envelopes.jsonl
10
+ * <rootDir>/channels/<channel-id>/config.json
11
+ * <rootDir>/registry.json (BridgeConfig identity → ChannelId)
12
+ *
13
+ * Writes are atomic: a new envelope is written to a per-pid tmp file and
14
+ * `rename(2)`'d on top of a sibling, then append to the JSONL via
15
+ * `appendFile` with `O_APPEND` semantics (Node delegates to the kernel, which
16
+ * guarantees single-write atomicity for writes below PIPE_BUF; our JSON lines
17
+ * sit well below that for realistic envelopes and we additionally bound the
18
+ * write under a per-channel in-process mutex).
19
+ *
20
+ * The adapter is deliberately minimal: the sidekick pack's transport registry
21
+ * (`channel-transports.ts`) remains the runtime dispatch path for the existing
22
+ * `channel:send` tool; WU-2735 lands the port/adapter skeleton only.
23
+ */
24
+ import { createHash } from 'node:crypto';
25
+ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
26
+ import path from 'node:path';
27
+ // ---------------------------------------------------------------------------
28
+ // Internal helpers
29
+ // ---------------------------------------------------------------------------
30
+ const REGISTRY_FILENAME = 'registry.json';
31
+ const CHANNELS_SUBDIR = 'channels';
32
+ const ENVELOPES_FILENAME = 'envelopes.jsonl';
33
+ const CONFIG_FILENAME = 'config.json';
34
+ function hashOptions(options) {
35
+ const payload = JSON.stringify(options ?? {}, Object.keys(options ?? {}).sort());
36
+ return createHash('sha256').update(payload).digest('hex').slice(0, 16);
37
+ }
38
+ function mintChannelId(config) {
39
+ // Deterministic id per (provider, name, options) — stable across bridge
40
+ // instances backed by the same rootDir so `register` is idempotent even on
41
+ // cold start without reading the registry.
42
+ const payload = `${config.provider}::${config.name}::${hashOptions(config.options)}`;
43
+ const digest = createHash('sha256').update(payload).digest('hex').slice(0, 24);
44
+ return `chan-${digest}`;
45
+ }
46
+ async function readRegistry(registryPath) {
47
+ try {
48
+ const raw = await readFile(registryPath, 'utf8');
49
+ return JSON.parse(raw);
50
+ }
51
+ catch (error) {
52
+ const err = error;
53
+ if (err.code === 'ENOENT') {
54
+ return { entries: [] };
55
+ }
56
+ throw error;
57
+ }
58
+ }
59
+ async function writeRegistryAtomic(registryPath, registry) {
60
+ await mkdir(path.dirname(registryPath), { recursive: true });
61
+ const tmpPath = `${registryPath}.tmp-${process.pid}-${Date.now()}`;
62
+ await writeFile(tmpPath, JSON.stringify(registry, null, 2), 'utf8');
63
+ const { rename } = await import('node:fs/promises');
64
+ await rename(tmpPath, registryPath);
65
+ }
66
+ // Per-bridge-instance lock map so concurrent send() calls on a single channel
67
+ // serialise their appends. Filesystem atomicity still holds under crash, but
68
+ // the mutex ensures emit order is preserved (ADR-013 §3).
69
+ function createLockGate() {
70
+ const chains = new Map();
71
+ return {
72
+ async withLock(key, fn) {
73
+ const previous = chains.get(key) ?? Promise.resolve();
74
+ const next = previous.then(fn, fn);
75
+ chains.set(key, next.then(() => undefined, () => undefined));
76
+ return next;
77
+ },
78
+ };
79
+ }
80
+ // ---------------------------------------------------------------------------
81
+ // Adapter factory
82
+ // ---------------------------------------------------------------------------
83
+ export function createFilesystemChannelBridge(options) {
84
+ const rootDir = path.resolve(options.rootDir);
85
+ const registryPath = path.join(rootDir, REGISTRY_FILENAME);
86
+ const lockGate = createLockGate();
87
+ const disconnected = new Set();
88
+ function channelDir(id) {
89
+ return path.join(rootDir, CHANNELS_SUBDIR, id);
90
+ }
91
+ async function ensureRegistered(config) {
92
+ const registry = await readRegistry(registryPath);
93
+ const id = mintChannelId(config);
94
+ const optionsHash = hashOptions(config.options);
95
+ const existing = registry.entries.find((entry) => entry.id === id);
96
+ if (existing) {
97
+ return existing.id;
98
+ }
99
+ registry.entries.push({
100
+ id,
101
+ provider: config.provider,
102
+ name: config.name,
103
+ options_hash: optionsHash,
104
+ });
105
+ await writeRegistryAtomic(registryPath, registry);
106
+ const dir = channelDir(id);
107
+ await mkdir(dir, { recursive: true });
108
+ await writeFile(path.join(dir, CONFIG_FILENAME), JSON.stringify({ provider: config.provider, name: config.name }, null, 2), 'utf8');
109
+ return id;
110
+ }
111
+ async function assertActive(channelId) {
112
+ if (disconnected.has(channelId)) {
113
+ throw new Error(`ChannelBridge: channel ${channelId} is disconnected; send rejected.`);
114
+ }
115
+ const registry = await readRegistry(registryPath);
116
+ if (!registry.entries.some((entry) => entry.id === channelId)) {
117
+ throw new Error(`ChannelBridge: channel ${channelId} is not registered.`);
118
+ }
119
+ }
120
+ return {
121
+ async register(bridgeConfig) {
122
+ return ensureRegistered(bridgeConfig);
123
+ },
124
+ async send(channelId, envelope) {
125
+ await assertActive(channelId);
126
+ const envelopesPath = path.join(channelDir(channelId), ENVELOPES_FILENAME);
127
+ await lockGate.withLock(channelId, async () => {
128
+ await mkdir(path.dirname(envelopesPath), { recursive: true });
129
+ await appendFile(envelopesPath, `${JSON.stringify(envelope)}\n`, 'utf8');
130
+ });
131
+ return { accepted: true, delivery_id: envelope.id };
132
+ },
133
+ async *receive(channelId) {
134
+ if (disconnected.has(channelId)) {
135
+ return;
136
+ }
137
+ const envelopesPath = path.join(channelDir(channelId), ENVELOPES_FILENAME);
138
+ let raw;
139
+ try {
140
+ raw = await readFile(envelopesPath, 'utf8');
141
+ }
142
+ catch (error) {
143
+ const err = error;
144
+ if (err.code === 'ENOENT') {
145
+ return;
146
+ }
147
+ throw error;
148
+ }
149
+ for (const line of raw.split('\n')) {
150
+ if (line.length === 0) {
151
+ continue;
152
+ }
153
+ yield JSON.parse(line);
154
+ }
155
+ },
156
+ async disconnect(channelId) {
157
+ disconnected.add(channelId);
158
+ },
159
+ };
160
+ }
161
+ //# sourceMappingURL=filesystem-bridge.adapter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"filesystem-bridge.adapter.js","sourceRoot":"","sources":["../../../src/adapters/filesystem-bridge.adapter.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,46 @@
1
+ declare const ENVELOPE_FROM_SOURCE_TOKEN: "token";
2
+ export interface PhoneChannelIngressInput {
3
+ /**
4
+ * Raw request body as parsed from JSON. Opaque record; the sanitizer does
5
+ * not inspect `body`, only the `from` attribution fields.
6
+ */
7
+ body: Readonly<Record<string, unknown>>;
8
+ /**
9
+ * Authoritative identity claim resolved from the bearer token. Required:
10
+ * without a token subject, cloud cannot attribute the message, and the
11
+ * ingress path rejects the request.
12
+ */
13
+ authoritativeFrom: string;
14
+ }
15
+ export interface PhoneChannelIngressEnvelope {
16
+ /**
17
+ * Sanitised request body with any body-level `from` stripped. Everything
18
+ * else the caller supplied is preserved so downstream code (channel.send,
19
+ * routing) can consume arbitrary payload fields.
20
+ */
21
+ body: Record<string, unknown>;
22
+ /** Authoritative attribution string (`{workspace_id}:phone:{device_id}`). */
23
+ from: string;
24
+ /** Always `'token'` — cloud-safe invariant per ADR-013 §5. */
25
+ from_source: typeof ENVELOPE_FROM_SOURCE_TOKEN;
26
+ }
27
+ export declare class PhoneChannelIngressError extends Error {
28
+ readonly statusCode: number;
29
+ constructor(message: string, statusCode: number);
30
+ }
31
+ /**
32
+ * Sanitize an inbound phone-channel request. Returns a cleaned envelope with
33
+ * the authoritative `from` baked in. Throws `PhoneChannelIngressError` (HTTP
34
+ * 400) when the authoritative `from` is missing — the surface translates the
35
+ * error to a 400/403 response.
36
+ */
37
+ export declare function sanitizePhoneChannelIngress(input: PhoneChannelIngressInput): PhoneChannelIngressEnvelope;
38
+ /**
39
+ * Convenience: reports whether the given `from` subject matches the
40
+ * phone-device grammar (`{workspace_id}:phone:{device_id}`). Useful to
41
+ * tell workspace-scoped actors from phone-device actors in the audit log
42
+ * without reaching for the enrollment parser.
43
+ */
44
+ export declare function isPhoneSubject(subject: string): boolean;
45
+ export {};
46
+ //# sourceMappingURL=channel-ingress.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel-ingress.d.ts","sourceRoot":"","sources":["../../src/channel-ingress.ts"],"names":[],"mappings":"AAyBA,QAAA,MAAM,0BAA0B,EAAG,OAAgB,CAAC;AAEpD,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACxC;;;;OAIG;IACH,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,2BAA2B;IAC1C;;;;OAIG;IACH,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,6EAA6E;IAC7E,IAAI,EAAE,MAAM,CAAC;IACb,8DAA8D;IAC9D,WAAW,EAAE,OAAO,0BAA0B,CAAC;CAChD;AAED,qBAAa,wBAAyB,SAAQ,KAAK;IACjD,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;gBAEhB,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM;CAKhD;AAED;;;;;GAKG;AACH,wBAAgB,2BAA2B,CACzC,KAAK,EAAE,wBAAwB,GAC9B,2BAA2B,CA2B7B;AAkBD;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAavD"}
@@ -0,0 +1,90 @@
1
+ // Copyright (c) 2026 Hellmai Ltd
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ /**
4
+ * WU-2731 (INIT-060 phase 3, ADR-013 §5 Identity):
5
+ * Pure inbound-channel ingress sanitizer.
6
+ *
7
+ * ADR-013 §5 rules, enforced here:
8
+ *
9
+ * - The authoritative `from` value for an inbound phone POST comes from the
10
+ * authenticated token subject (`{workspace_id}:phone:{device_id}`). Cloud
11
+ * MUST NOT trust a `from` field supplied in the request body.
12
+ *
13
+ * - Body-supplied `from` (and any alias under `metadata.from`) is stripped
14
+ * before the envelope reaches domain code. The surface that calls this
15
+ * module is expected to pass the resolved `from` separately; attempting to
16
+ * ingest a body-only envelope (no authoritative from) fails closed.
17
+ *
18
+ * The module is HTTP-independent: both the kernel tool-api surface and the
19
+ * future control-plane adapter (WU-2737) call the same sanitizer so the
20
+ * ignore-body-from rule is enforced in one place.
21
+ */
22
+ const ENVELOPE_BODY_FIELD_FROM = 'from';
23
+ const ENVELOPE_METADATA_KEY = 'metadata';
24
+ const ENVELOPE_FROM_SOURCE_TOKEN = 'token';
25
+ export class PhoneChannelIngressError extends Error {
26
+ statusCode;
27
+ constructor(message, statusCode) {
28
+ super(message);
29
+ this.name = 'PhoneChannelIngressError';
30
+ this.statusCode = statusCode;
31
+ }
32
+ }
33
+ /**
34
+ * Sanitize an inbound phone-channel request. Returns a cleaned envelope with
35
+ * the authoritative `from` baked in. Throws `PhoneChannelIngressError` (HTTP
36
+ * 400) when the authoritative `from` is missing — the surface translates the
37
+ * error to a 400/403 response.
38
+ */
39
+ export function sanitizePhoneChannelIngress(input) {
40
+ if (typeof input.authoritativeFrom !== 'string' || input.authoritativeFrom.length === 0) {
41
+ throw new PhoneChannelIngressError('Inbound phone channel requires authoritative from (token subject); body-only attribution is rejected.', 400);
42
+ }
43
+ const sanitizedBody = stripKey(input.body, ENVELOPE_BODY_FIELD_FROM);
44
+ const rawMetadata = sanitizedBody[ENVELOPE_METADATA_KEY];
45
+ if (rawMetadata !== null &&
46
+ typeof rawMetadata === 'object' &&
47
+ !Array.isArray(rawMetadata) &&
48
+ ENVELOPE_BODY_FIELD_FROM in rawMetadata) {
49
+ sanitizedBody[ENVELOPE_METADATA_KEY] = stripKey(rawMetadata, ENVELOPE_BODY_FIELD_FROM);
50
+ }
51
+ return {
52
+ body: sanitizedBody,
53
+ from: input.authoritativeFrom,
54
+ from_source: ENVELOPE_FROM_SOURCE_TOKEN,
55
+ };
56
+ }
57
+ /**
58
+ * Copy `record` omitting the given key. Implemented with a filtered
59
+ * `Object.entries` rather than `delete` to satisfy the linter's ban on
60
+ * dynamic-delete (which would otherwise be a no-op for literal keys but
61
+ * fails the rule uniformly).
62
+ */
63
+ function stripKey(record, key) {
64
+ const result = {};
65
+ for (const [entryKey, entryValue] of Object.entries(record)) {
66
+ if (entryKey !== key) {
67
+ result[entryKey] = entryValue;
68
+ }
69
+ }
70
+ return result;
71
+ }
72
+ /**
73
+ * Convenience: reports whether the given `from` subject matches the
74
+ * phone-device grammar (`{workspace_id}:phone:{device_id}`). Useful to
75
+ * tell workspace-scoped actors from phone-device actors in the audit log
76
+ * without reaching for the enrollment parser.
77
+ */
78
+ export function isPhoneSubject(subject) {
79
+ const parts = subject.split(':');
80
+ if (parts.length !== 3) {
81
+ return false;
82
+ }
83
+ const [workspace, kind, device] = parts;
84
+ return (kind === 'phone' &&
85
+ typeof workspace === 'string' &&
86
+ workspace.length > 0 &&
87
+ typeof device === 'string' &&
88
+ device.length > 0);
89
+ }
90
+ //# sourceMappingURL=channel-ingress.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel-ingress.js","sourceRoot":"","sources":["../../src/channel-ingress.ts"],"names":[],"mappings":"AAAA,iCAAiC;AACjC,yCAAyC;AAEzC;;;;;;;;;;;;;;;;;;GAkBG;AAEH,MAAM,wBAAwB,GAAG,MAAe,CAAC;AACjD,MAAM,qBAAqB,GAAG,UAAmB,CAAC;AAClD,MAAM,0BAA0B,GAAG,OAAgB,CAAC;AA6BpD,MAAM,OAAO,wBAAyB,SAAQ,KAAK;IACxC,UAAU,CAAS;IAE5B,YAAY,OAAe,EAAE,UAAkB;QAC7C,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,0BAA0B,CAAC;QACvC,IAAI,CAAC,UAAU,GAAG,UAAU,CAAC;IAC/B,CAAC;CACF;AAED;;;;;GAKG;AACH,MAAM,UAAU,2BAA2B,CACzC,KAA+B;IAE/B,IAAI,OAAO,KAAK,CAAC,iBAAiB,KAAK,QAAQ,IAAI,KAAK,CAAC,iBAAiB,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACxF,MAAM,IAAI,wBAAwB,CAChC,uGAAuG,EACvG,GAAG,CACJ,CAAC;IACJ,CAAC;IAED,MAAM,aAAa,GAAG,QAAQ,CAAC,KAAK,CAAC,IAAI,EAAE,wBAAwB,CAAC,CAAC;IACrE,MAAM,WAAW,GAAG,aAAa,CAAC,qBAAqB,CAAC,CAAC;IACzD,IACE,WAAW,KAAK,IAAI;QACpB,OAAO,WAAW,KAAK,QAAQ;QAC/B,CAAC,KAAK,CAAC,OAAO,CAAC,WAAW,CAAC;QAC3B,wBAAwB,IAAK,WAAuC,EACpE,CAAC;QACD,aAAa,CAAC,qBAAqB,CAAC,GAAG,QAAQ,CAC7C,WAAsC,EACtC,wBAAwB,CACzB,CAAC;IACJ,CAAC;IAED,OAAO;QACL,IAAI,EAAE,aAAa;QACnB,IAAI,EAAE,KAAK,CAAC,iBAAiB;QAC7B,WAAW,EAAE,0BAA0B;KACxC,CAAC;AACJ,CAAC;AAED;;;;;GAKG;AACH,SAAS,QAAQ,CAAC,MAAyC,EAAE,GAAW;IACtE,MAAM,MAAM,GAA4B,EAAE,CAAC;IAC3C,KAAK,MAAM,CAAC,QAAQ,EAAE,UAAU,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;QAC5D,IAAI,QAAQ,KAAK,GAAG,EAAE,CAAC;YACrB,MAAM,CAAC,QAAQ,CAAC,GAAG,UAAU,CAAC;QAChC,CAAC;IACH,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAAC,OAAe;IAC5C,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACjC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACvB,OAAO,KAAK,CAAC;IACf,CAAC;IACD,MAAM,CAAC,SAAS,EAAE,IAAI,EAAE,MAAM,CAAC,GAAG,KAAK,CAAC;IACxC,OAAO,CACL,IAAI,KAAK,OAAO;QAChB,OAAO,SAAS,KAAK,QAAQ;QAC7B,SAAS,CAAC,MAAM,GAAG,CAAC;QACpB,OAAO,MAAM,KAAK,QAAQ;QAC1B,MAAM,CAAC,MAAM,GAAG,CAAC,CAClB,CAAC;AACJ,CAAC"}