@moxxy/channel-kit 0.27.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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/dist/frame-pump.d.ts +77 -0
  3. package/dist/frame-pump.d.ts.map +1 -0
  4. package/dist/frame-pump.js +118 -0
  5. package/dist/frame-pump.js.map +1 -0
  6. package/dist/index.d.ts +19 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +19 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/ingest/dedupe.d.ts +31 -0
  11. package/dist/ingest/dedupe.d.ts.map +1 -0
  12. package/dist/ingest/dedupe.js +66 -0
  13. package/dist/ingest/dedupe.js.map +1 -0
  14. package/dist/ingest/http-server.d.ts +76 -0
  15. package/dist/ingest/http-server.d.ts.map +1 -0
  16. package/dist/ingest/http-server.js +101 -0
  17. package/dist/ingest/http-server.js.map +1 -0
  18. package/dist/pairing/host-code.d.ts +94 -0
  19. package/dist/pairing/host-code.d.ts.map +1 -0
  20. package/dist/pairing/host-code.js +107 -0
  21. package/dist/pairing/host-code.js.map +1 -0
  22. package/dist/pairing/tofu.d.ts +33 -0
  23. package/dist/pairing/tofu.d.ts.map +1 -0
  24. package/dist/pairing/tofu.js +41 -0
  25. package/dist/pairing/tofu.js.map +1 -0
  26. package/dist/permission.d.ts +28 -0
  27. package/dist/permission.d.ts.map +1 -0
  28. package/dist/permission.js +17 -0
  29. package/dist/permission.js.map +1 -0
  30. package/dist/plain-turn-renderer.d.ts +17 -0
  31. package/dist/plain-turn-renderer.d.ts.map +1 -0
  32. package/dist/plain-turn-renderer.js +28 -0
  33. package/dist/plain-turn-renderer.js.map +1 -0
  34. package/dist/secrets.d.ts +18 -0
  35. package/dist/secrets.d.ts.map +1 -0
  36. package/dist/secrets.js +16 -0
  37. package/dist/secrets.js.map +1 -0
  38. package/dist/turn.d.ts +96 -0
  39. package/dist/turn.d.ts.map +1 -0
  40. package/dist/turn.js +106 -0
  41. package/dist/turn.js.map +1 -0
  42. package/dist/voice-reply.d.ts +139 -0
  43. package/dist/voice-reply.d.ts.map +1 -0
  44. package/dist/voice-reply.js +333 -0
  45. package/dist/voice-reply.js.map +1 -0
  46. package/package.json +54 -0
  47. package/src/frame-pump.test.ts +209 -0
  48. package/src/frame-pump.ts +154 -0
  49. package/src/index.ts +66 -0
  50. package/src/ingest/dedupe.test.ts +58 -0
  51. package/src/ingest/dedupe.ts +66 -0
  52. package/src/ingest/http-server.test.ts +120 -0
  53. package/src/ingest/http-server.ts +173 -0
  54. package/src/pairing/host-code.test.ts +121 -0
  55. package/src/pairing/host-code.ts +159 -0
  56. package/src/pairing/tofu.test.ts +62 -0
  57. package/src/pairing/tofu.ts +60 -0
  58. package/src/permission.test.ts +69 -0
  59. package/src/permission.ts +46 -0
  60. package/src/plain-turn-renderer.ts +31 -0
  61. package/src/secrets.test.ts +59 -0
  62. package/src/secrets.ts +26 -0
  63. package/src/turn.test.ts +165 -0
  64. package/src/turn.ts +162 -0
  65. package/src/voice-reply.test.ts +316 -0
  66. package/src/voice-reply.ts +449 -0
@@ -0,0 +1,173 @@
1
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
2
+ import type { AddressInfo } from 'node:net';
3
+ import { readRequestBody } from '@moxxy/sdk/server';
4
+
5
+ /**
6
+ * HTTP scaffold for inbound-webhook channels (Slack Events API today; Discord /
7
+ * WhatsApp / Signal-style ingest next). Binds a `node:http` server to an
8
+ * ephemeral loopback port; the channel exposes it publicly via a tunnel.
9
+ *
10
+ * The scaffold owns the gates that must run BEFORE any payload is trusted
11
+ * (skill A8/A46) and before the session is ever touched:
12
+ * 1. routing — POST-only on `eventsPath` (+ an optional GET health probe);
13
+ * anything else is 404.
14
+ * 2. raw body read, size-capped → 413 (the raw bytes are what signature
15
+ * schemes verify — never a reserialized JSON).
16
+ * 3. the `verify` hook → 401. Provider-specific signature schemes (Slack's
17
+ * HMAC, Discord's ed25519, ...) stay in the channel as this hook.
18
+ * 4. every handler error is caught → 500, so a bad request can never
19
+ * escalate to a process-level uncaughtException.
20
+ *
21
+ * Everything AFTER verification — schema validation, handshake echoes, dedupe,
22
+ * the immediate ACK, and fire-and-forget turn dispatch — is the channel's
23
+ * `handleVerified`, which owns writing the response.
24
+ */
25
+
26
+ export type IngestVerdict = { readonly ok: true } | { readonly ok: false; readonly reason: string };
27
+
28
+ export interface IngestLogger {
29
+ info?(msg: string, meta?: Record<string, unknown>): void;
30
+ warn?(msg: string, meta?: Record<string, unknown>): void;
31
+ }
32
+
33
+ export interface IngestHttpServerOptions {
34
+ /** Bind host. Default 127.0.0.1 (loopback; a tunnel provides public reach). */
35
+ readonly host?: string;
36
+ /** POST endpoint the provider delivers events to (e.g. '/slack/events'). */
37
+ readonly eventsPath: string;
38
+ /** Optional GET liveness probe (e.g. '/slack/health'). */
39
+ readonly healthPath?: string;
40
+ /** Health probe response body. Default `{ status: 'ok' }`. */
41
+ readonly healthBody?: () => unknown;
42
+ /** Max request body size in bytes. Default 1MB. */
43
+ readonly maxBodyBytes?: number;
44
+ /** Short channel label prefixed onto log lines (e.g. 'slack'). */
45
+ readonly label: string;
46
+ /** Signature gate over the EXACT raw bytes, before any parsing. */
47
+ readonly verify: (input: {
48
+ rawBody: Buffer;
49
+ headers: IncomingMessage['headers'];
50
+ }) => IngestVerdict;
51
+ /**
52
+ * The channel-specific pipeline for a verified delivery (parse → dedupe →
53
+ * ACK → dispatch). MUST write the response.
54
+ */
55
+ readonly handleVerified: (
56
+ rawBody: Buffer,
57
+ req: IncomingMessage,
58
+ res: ServerResponse,
59
+ ) => Promise<void>;
60
+ readonly logger?: IngestLogger;
61
+ }
62
+
63
+ export interface IngestHttpServerHandle {
64
+ readonly host: string;
65
+ readonly port: number;
66
+ stop(): Promise<void>;
67
+ }
68
+
69
+ export function respondJson(res: ServerResponse, status: number, body: unknown): void {
70
+ res.writeHead(status, { 'content-type': 'application/json' });
71
+ res.end(JSON.stringify(body));
72
+ }
73
+
74
+ export class IngestHttpServer {
75
+ private server: Server | null = null;
76
+ private readonly host: string;
77
+ private readonly maxBodyBytes: number;
78
+ private boundPort = 0;
79
+
80
+ constructor(private readonly opts: IngestHttpServerOptions) {
81
+ this.host = opts.host ?? '127.0.0.1';
82
+ this.maxBodyBytes = opts.maxBodyBytes ?? 1024 * 1024;
83
+ }
84
+
85
+ get port(): number {
86
+ return this.boundPort;
87
+ }
88
+
89
+ /** Bind on an ephemeral loopback port. Resolves once listening. */
90
+ async start(): Promise<IngestHttpServerHandle> {
91
+ if (this.server) {
92
+ return { host: this.host, port: this.boundPort, stop: () => this.stop() };
93
+ }
94
+ const server = createServer((req, res) => {
95
+ this.handle(req, res).catch((err) => {
96
+ this.opts.logger?.warn?.(`${this.opts.label}: handler threw`, { err: String(err) });
97
+ if (!res.headersSent) {
98
+ respondJson(res, 500, { error: 'internal' });
99
+ } else {
100
+ try {
101
+ res.end();
102
+ } catch {
103
+ /* ignore */
104
+ }
105
+ }
106
+ });
107
+ });
108
+ this.server = server;
109
+
110
+ await new Promise<void>((resolve, reject) => {
111
+ const onError = (err: Error): void => reject(err);
112
+ server.once('error', onError);
113
+ // port 0 → OS assigns an ephemeral port; we read it back for the tunnel.
114
+ server.listen(0, this.host, () => {
115
+ server.off('error', onError);
116
+ const addr = server.address();
117
+ this.boundPort = addr && typeof addr === 'object' ? (addr as AddressInfo).port : 0;
118
+ this.opts.logger?.info?.(`${this.opts.label}: ingest listening`, {
119
+ host: this.host,
120
+ port: this.boundPort,
121
+ });
122
+ resolve();
123
+ });
124
+ });
125
+
126
+ return { host: this.host, port: this.boundPort, stop: () => this.stop() };
127
+ }
128
+
129
+ async stop(): Promise<void> {
130
+ const s = this.server;
131
+ if (!s) return;
132
+ this.server = null;
133
+ await new Promise<void>((resolve) => s.close(() => resolve()));
134
+ }
135
+
136
+ private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
137
+ const url = (req.url ?? '/').split('?')[0] ?? '/';
138
+
139
+ // Liveness probe (a proxy relay / an operator can hit this).
140
+ if (this.opts.healthPath && req.method === 'GET' && url === this.opts.healthPath) {
141
+ respondJson(res, 200, this.opts.healthBody?.() ?? { status: 'ok' });
142
+ return;
143
+ }
144
+
145
+ if (req.method !== 'POST' || url !== this.opts.eventsPath) {
146
+ respondJson(res, 404, { error: 'not_found' });
147
+ return;
148
+ }
149
+
150
+ // Raw body bytes (needed for signature verification; BEFORE JSON.parse).
151
+ let raw: Buffer;
152
+ try {
153
+ raw = await readRequestBody(req, this.maxBodyBytes);
154
+ } catch (err) {
155
+ this.opts.logger?.warn?.(`${this.opts.label}: rejected oversized body`, {
156
+ limit: this.maxBodyBytes,
157
+ err: err instanceof Error ? err.message : String(err),
158
+ });
159
+ respondJson(res, 413, { error: 'payload_too_large' });
160
+ return;
161
+ }
162
+
163
+ // Signature gate.
164
+ const verdict = this.opts.verify({ rawBody: raw, headers: req.headers });
165
+ if (!verdict.ok) {
166
+ this.opts.logger?.warn?.(`${this.opts.label}: rejected delivery`, { reason: verdict.reason });
167
+ respondJson(res, 401, { error: 'verification_failed' });
168
+ return;
169
+ }
170
+
171
+ await this.opts.handleVerified(raw, req, res);
172
+ }
173
+ }
@@ -0,0 +1,121 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ clearHostCodePairing,
4
+ createHostCodeState,
5
+ greetPeer,
6
+ isPeerAuthorized,
7
+ openHostCodeWindow,
8
+ submitPeerCode,
9
+ } from './host-code.js';
10
+
11
+ describe('host-code pairing machine', () => {
12
+ it('starts idle', () => {
13
+ const s = createHostCodeState<number>();
14
+ expect(s.phase).toBe('idle');
15
+ expect(s.authorizedPeer).toBeNull();
16
+ });
17
+
18
+ it('starts paired when an authorized peer is restored', () => {
19
+ const s = createHostCodeState({ authorizedPeer: 42 });
20
+ expect(s.phase).toBe('paired');
21
+ expect(isPeerAuthorized(s, 42)).toBe(true);
22
+ expect(isPeerAuthorized(s, 99)).toBe(false);
23
+ });
24
+
25
+ it('openHostCodeWindow arms the window with the caller-minted code', () => {
26
+ const { state, code } = openHostCodeWindow(createHostCodeState<number>(), { code: '123456' });
27
+ expect(state.phase).toBe('awaiting-host-code');
28
+ expect(code).toBe('123456');
29
+ expect(state.code).toBe('123456');
30
+ // No TTL by default — the window lives as long as the channel runs unpaired.
31
+ expect(state.expiresAt).toBeNull();
32
+ });
33
+
34
+ it('submitPeerCode pairs the presenting peer on the correct code', () => {
35
+ const { state } = openHostCodeWindow(createHostCodeState<number>(), { code: '123456' });
36
+ const r = submitPeerCode(state, 1, '123456');
37
+ expect(r.action.kind).toBe('paired');
38
+ expect(r.state.phase).toBe('paired');
39
+ expect(r.state.authorizedPeer).toBe(1);
40
+ expect(isPeerAuthorized(r.state, 1)).toBe(true);
41
+ });
42
+
43
+ it('accepts the code with surrounding whitespace', () => {
44
+ const { state } = openHostCodeWindow(createHostCodeState<number>(), { code: '123456' });
45
+ const r = submitPeerCode(state, 7, ' 123 456 ');
46
+ expect(r.action.kind).toBe('paired');
47
+ expect(r.state.authorizedPeer).toBe(7);
48
+ });
49
+
50
+ it('reports a mismatch on a wrong or non-code message', () => {
51
+ const { state } = openHostCodeWindow(createHostCodeState<number>(), { code: '123456' });
52
+ expect(submitPeerCode(state, 1, '654321').action.kind).toBe('mismatch');
53
+ expect(submitPeerCode(state, 1, 'hello!').action.kind).toBe('mismatch');
54
+ expect(submitPeerCode(state, 1, '').action.kind).toBe('mismatch');
55
+ });
56
+
57
+ it('reports not-pending when no window is open', () => {
58
+ const r = submitPeerCode(createHostCodeState<number>(), 1, '123456');
59
+ expect(r.action.kind).toBe('not-pending');
60
+ });
61
+
62
+ it('acknowledges the already-paired peer (idempotent)', () => {
63
+ const r = submitPeerCode(createHostCodeState({ authorizedPeer: 5 }), 5, '123456');
64
+ expect(r.action.kind).toBe('still-paired');
65
+ });
66
+
67
+ it('rejects a different peer once one is paired', () => {
68
+ const r = submitPeerCode(createHostCodeState({ authorizedPeer: 5 }), 6, '123456');
69
+ expect(r.action.kind).toBe('rejected-foreign-peer');
70
+ });
71
+
72
+ it('expires after the optional TTL window', () => {
73
+ const t0 = 1_000_000;
74
+ const { state } = openHostCodeWindow(createHostCodeState<number>(), {
75
+ code: '123456',
76
+ now: t0,
77
+ ttlMs: 1000,
78
+ });
79
+ const r = submitPeerCode(state, 1, '123456', t0 + 2000);
80
+ expect(r.action.kind).toBe('expired');
81
+ expect(r.state.phase).toBe('expired');
82
+ });
83
+
84
+ it('works with string peers (thread / JID style ids)', () => {
85
+ const { state } = openHostCodeWindow(createHostCodeState<string>(), { code: '987654' });
86
+ const r = submitPeerCode(state, 'chat:abc', '987654');
87
+ expect(r.action.kind).toBe('paired');
88
+ expect(isPeerAuthorized(r.state, 'chat:abc')).toBe(true);
89
+ expect(isPeerAuthorized(r.state, 'chat:zzz')).toBe(false);
90
+ });
91
+
92
+ it('clearHostCodePairing forgets everything', () => {
93
+ const s = clearHostCodePairing(createHostCodeState({ authorizedPeer: 5 }));
94
+ expect(s.phase).toBe('idle');
95
+ expect(s.authorizedPeer).toBeNull();
96
+ expect(s.code).toBeNull();
97
+ });
98
+ });
99
+
100
+ describe('greetPeer (bare hello, no code presented)', () => {
101
+ it('nudges toward starting pairing when no window is open', () => {
102
+ expect(greetPeer(createHostCodeState<number>(), 1).action.kind).toBe('no-window');
103
+ });
104
+
105
+ it('nudges toward the QR / code while a window is open', () => {
106
+ const { state } = openHostCodeWindow(createHostCodeState<number>(), { code: '123456' });
107
+ expect(greetPeer(state, 1).action.kind).toBe('window-open-hint');
108
+ });
109
+
110
+ it('acknowledges the already-paired peer', () => {
111
+ expect(greetPeer(createHostCodeState({ authorizedPeer: 7 }), 7).action.kind).toBe(
112
+ 'still-paired',
113
+ );
114
+ });
115
+
116
+ it('rejects a different peer when one is paired', () => {
117
+ expect(greetPeer(createHostCodeState({ authorizedPeer: 7 }), 999).action.kind).toBe(
118
+ 'rejected-foreign-peer',
119
+ );
120
+ });
121
+ });
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Host-issued-code pairing — the pure state machine behind Telegram's QR /
3
+ * deep-link chat pairing, generic over the peer identifier (a Telegram chat id,
4
+ * a Discord DM channel, a WhatsApp JID, ...).
5
+ *
6
+ * A control surface (desktop Channels panel, `moxxy channels <name> pair` in a
7
+ * terminal) opens a window with {@link openHostCodeWindow}: the code is minted
8
+ * UP FRONT by the caller so the surface can embed it in a deep link and render
9
+ * that as a QR. The user scans / opens the link (the messenger round-trips the
10
+ * code back to the bot) or simply sends the code as a message; the channel
11
+ * calls {@link submitPeerCode}, and on match that peer is authorized.
12
+ *
13
+ * Security property: "prove you can see the host's screen" — the code only ever
14
+ * lives on the surface. No TTL by default: the window's lifetime is the channel
15
+ * process's lifetime while unpaired; a caller may still bound it with `ttlMs`.
16
+ *
17
+ * Decisions carry semantic `kind`s only — user-facing messages (and their
18
+ * channel-specific wording, e.g. "run `moxxy channels telegram pair`") are
19
+ * mapped by each channel.
20
+ */
21
+
22
+ export type HostCodePhase =
23
+ | 'idle'
24
+ // Host generated a code and is waiting for a peer to present it.
25
+ | 'awaiting-host-code'
26
+ | 'paired'
27
+ | 'expired';
28
+
29
+ export interface HostCodeState<P> {
30
+ readonly phase: HostCodePhase;
31
+ readonly code: string | null;
32
+ readonly expiresAt: number | null;
33
+ readonly authorizedPeer: P | null;
34
+ }
35
+
36
+ export type HostCodeAction<P> =
37
+ /** The presented code matched — the peer is now authorized. */
38
+ | { readonly kind: 'paired'; readonly peer: P }
39
+ /** The already-authorized peer showed up again (idempotent; greet it). */
40
+ | { readonly kind: 'still-paired'; readonly peer: P }
41
+ /** A DIFFERENT peer while one is authorized — access denied. */
42
+ | { readonly kind: 'rejected-foreign-peer' }
43
+ /** Bare hello while a window is open — nudge toward the QR / code. */
44
+ | { readonly kind: 'window-open-hint' }
45
+ /** Bare hello with no window open — nudge toward starting pairing. */
46
+ | { readonly kind: 'no-window' }
47
+ /** A code was presented but no window is open. */
48
+ | { readonly kind: 'not-pending' }
49
+ /** The window's optional TTL elapsed. */
50
+ | { readonly kind: 'expired' }
51
+ /** The presented code didn't match. */
52
+ | { readonly kind: 'mismatch' };
53
+
54
+ export interface HostCodeDecision<P> {
55
+ readonly state: HostCodeState<P>;
56
+ readonly action: HostCodeAction<P>;
57
+ }
58
+
59
+ export function createHostCodeState<P>(
60
+ opts: { authorizedPeer?: P | null } = {},
61
+ ): HostCodeState<P> {
62
+ return {
63
+ phase: opts.authorizedPeer != null ? 'paired' : 'idle',
64
+ code: null,
65
+ expiresAt: null,
66
+ authorizedPeer: opts.authorizedPeer ?? null,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Open a host-issued pairing window with a pre-minted code (the caller mints it
72
+ * so it can embed the code in the deep link / QR it shows the user).
73
+ */
74
+ export function openHostCodeWindow<P>(
75
+ state: HostCodeState<P>,
76
+ opts: { code: string; now?: number; ttlMs?: number | null },
77
+ ): { state: HostCodeState<P>; code: string } {
78
+ const ttlMs = opts.ttlMs ?? null;
79
+ const now = opts.now ?? Date.now();
80
+ return {
81
+ state: {
82
+ ...state,
83
+ phase: 'awaiting-host-code',
84
+ code: opts.code,
85
+ expiresAt: ttlMs == null ? null : now + ttlMs,
86
+ },
87
+ code: opts.code,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * A peer said hello WITHOUT presenting a code (e.g. a bare Telegram `/start` —
93
+ * the user opened the chat manually rather than via the pairing deep link).
94
+ */
95
+ export function greetPeer<P>(state: HostCodeState<P>, peer: P): HostCodeDecision<P> {
96
+ if (state.authorizedPeer === peer && state.phase === 'paired') {
97
+ return { state, action: { kind: 'still-paired', peer } };
98
+ }
99
+ if (state.authorizedPeer !== null && state.authorizedPeer !== peer) {
100
+ return { state, action: { kind: 'rejected-foreign-peer' } };
101
+ }
102
+ if (state.phase === 'awaiting-host-code') {
103
+ return { state, action: { kind: 'window-open-hint' } };
104
+ }
105
+ return { state, action: { kind: 'no-window' } };
106
+ }
107
+
108
+ /**
109
+ * A peer PRESENTED a code (deep-link payload or a plain message). Whitespace is
110
+ * stripped before comparing; on match the peer is authorized.
111
+ */
112
+ export function submitPeerCode<P>(
113
+ state: HostCodeState<P>,
114
+ peer: P,
115
+ rawCode: string,
116
+ now: number = Date.now(),
117
+ ): HostCodeDecision<P> {
118
+ if (state.phase === 'paired' && state.authorizedPeer === peer) {
119
+ return { state, action: { kind: 'still-paired', peer } };
120
+ }
121
+ if (state.authorizedPeer !== null && state.authorizedPeer !== peer) {
122
+ return { state, action: { kind: 'rejected-foreign-peer' } };
123
+ }
124
+ if (state.phase !== 'awaiting-host-code') {
125
+ return { state, action: { kind: 'not-pending' } };
126
+ }
127
+ if (state.expiresAt !== null && now > state.expiresAt) {
128
+ return {
129
+ state: { ...state, phase: 'expired', code: null, expiresAt: null },
130
+ action: { kind: 'expired' },
131
+ };
132
+ }
133
+ const normalized = rawCode.replace(/\s+/g, '');
134
+ if (!normalized || normalized !== state.code) {
135
+ return { state, action: { kind: 'mismatch' } };
136
+ }
137
+ return {
138
+ state: {
139
+ phase: 'paired',
140
+ code: null,
141
+ expiresAt: null,
142
+ authorizedPeer: peer,
143
+ },
144
+ action: { kind: 'paired', peer },
145
+ };
146
+ }
147
+
148
+ export function isPeerAuthorized<P>(state: HostCodeState<P>, peer: P): boolean {
149
+ return state.phase === 'paired' && state.authorizedPeer === peer;
150
+ }
151
+
152
+ export function clearHostCodePairing<P>(_state: HostCodeState<P>): HostCodeState<P> {
153
+ return {
154
+ phase: 'idle',
155
+ code: null,
156
+ expiresAt: null,
157
+ authorizedPeer: null,
158
+ };
159
+ }
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { TofuPairingWindow } from './tofu.js';
3
+
4
+ interface Candidate {
5
+ readonly teamId: string;
6
+ readonly channelId: string;
7
+ }
8
+
9
+ describe('TofuPairingWindow', () => {
10
+ it('ignores candidates while disarmed', () => {
11
+ const w = new TofuPairingWindow<Candidate>();
12
+ const seen: Candidate[] = [];
13
+ w.onCandidate((c) => seen.push(c));
14
+ expect(w.offer({ teamId: 'T1', channelId: 'C1' })).toBe(false);
15
+ expect(seen).toHaveLength(0);
16
+ });
17
+
18
+ it('captures and consumes candidates while armed', () => {
19
+ const w = new TofuPairingWindow<Candidate>();
20
+ const seen: Candidate[] = [];
21
+ w.onCandidate((c) => seen.push(c));
22
+ w.arm();
23
+ expect(w.isArmed).toBe(true);
24
+ expect(w.offer({ teamId: 'T1', channelId: 'C1' })).toBe(true);
25
+ expect(seen).toEqual([{ teamId: 'T1', channelId: 'C1' }]);
26
+ });
27
+
28
+ it('stays armed until disarmed (operator confirms out of band)', () => {
29
+ const w = new TofuPairingWindow<Candidate>();
30
+ w.arm();
31
+ expect(w.offer({ teamId: 'T1', channelId: 'C1' })).toBe(true);
32
+ expect(w.offer({ teamId: 'T2', channelId: 'C2' })).toBe(true);
33
+ w.disarm();
34
+ expect(w.isArmed).toBe(false);
35
+ expect(w.offer({ teamId: 'T3', channelId: 'C3' })).toBe(false);
36
+ });
37
+
38
+ it('unsubscribe stops a listener', () => {
39
+ const w = new TofuPairingWindow<Candidate>();
40
+ const seen: Candidate[] = [];
41
+ const unsubscribe = w.onCandidate((c) => seen.push(c));
42
+ w.arm();
43
+ w.offer({ teamId: 'T1', channelId: 'C1' });
44
+ unsubscribe();
45
+ w.offer({ teamId: 'T2', channelId: 'C2' });
46
+ expect(seen).toEqual([{ teamId: 'T1', channelId: 'C1' }]);
47
+ });
48
+
49
+ it('a throwing listener is reported and never breaks the offer', () => {
50
+ const onListenerError = vi.fn();
51
+ const w = new TofuPairingWindow<Candidate>({ onListenerError });
52
+ const seen: Candidate[] = [];
53
+ w.onCandidate(() => {
54
+ throw new Error('boom');
55
+ });
56
+ w.onCandidate((c) => seen.push(c));
57
+ w.arm();
58
+ expect(w.offer({ teamId: 'T1', channelId: 'C1' })).toBe(true);
59
+ expect(onListenerError).toHaveBeenCalledTimes(1);
60
+ expect(seen).toHaveLength(1);
61
+ });
62
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Trust-on-first-use pairing window — the mechanism behind Slack's pair flow,
3
+ * generic over the candidate shape (Slack: `{ teamId, channelId }`).
4
+ *
5
+ * A pair flow arms the window, then the channel's ingest path `offer`s every
6
+ * verified inbound event's origin. While armed, the first candidate is captured
7
+ * (listeners — the interactive pair flow — are notified and ask the operator to
8
+ * confirm) and CONSUMED, so the very first message just establishes trust
9
+ * rather than driving a turn. Confirmation + persistence stay with the channel
10
+ * (it owns the vault key format); it calls `disarm()` once authorized.
11
+ */
12
+ export interface TofuPairingWindowOptions {
13
+ /** Observe a listener throwing (channels log it); never propagates. */
14
+ readonly onListenerError?: (err: unknown) => void;
15
+ }
16
+
17
+ export class TofuPairingWindow<C> {
18
+ private armed = false;
19
+ private readonly listeners = new Set<(candidate: C) => void>();
20
+ private readonly opts: TofuPairingWindowOptions;
21
+
22
+ constructor(opts: TofuPairingWindowOptions = {}) {
23
+ this.opts = opts;
24
+ }
25
+
26
+ get isArmed(): boolean {
27
+ return this.armed;
28
+ }
29
+
30
+ arm(): void {
31
+ this.armed = true;
32
+ }
33
+
34
+ disarm(): void {
35
+ this.armed = false;
36
+ }
37
+
38
+ /** Subscribe to captured candidates. Returns an unsubscribe function. */
39
+ onCandidate(listener: (candidate: C) => void): () => void {
40
+ this.listeners.add(listener);
41
+ return () => this.listeners.delete(listener);
42
+ }
43
+
44
+ /**
45
+ * Present a verified inbound origin. While armed, notifies listeners and
46
+ * returns true — the event was consumed by pairing and must NOT also drive a
47
+ * turn. Returns false when no window is armed.
48
+ */
49
+ offer(candidate: C): boolean {
50
+ if (!this.armed) return false;
51
+ for (const listener of this.listeners) {
52
+ try {
53
+ listener(candidate);
54
+ } catch (err) {
55
+ this.opts.onListenerError?.(err);
56
+ }
57
+ }
58
+ return true;
59
+ }
60
+ }
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { PendingToolCall, PermissionContext } from '@moxxy/sdk';
3
+ import { createAuditedAllowListResolver } from './permission.js';
4
+
5
+ const ctx: PermissionContext = { sessionId: 's1' };
6
+ function call(name: string): PendingToolCall {
7
+ return { callId: `c_${name}`, name, input: {} };
8
+ }
9
+
10
+ describe('createAuditedAllowListResolver', () => {
11
+ it('auto-approves a listed tool and denies an unlisted one', async () => {
12
+ const r = createAuditedAllowListResolver({
13
+ name: 'test-allow-list',
14
+ allowedTools: ['Read', 'Grep'],
15
+ allToolNames: ['Read', 'Grep', 'Bash', 'Write'],
16
+ });
17
+ expect((await r.check(call('Read'), ctx)).mode).not.toBe('deny');
18
+ expect((await r.check(call('Bash'), ctx)).mode).toBe('deny');
19
+ });
20
+
21
+ it('denies everything when the allow-list is empty (read-only)', async () => {
22
+ const r = createAuditedAllowListResolver({
23
+ name: 'test-allow-list',
24
+ allowedTools: [],
25
+ allToolNames: ['Read', 'Bash'],
26
+ });
27
+ expect((await r.check(call('Read'), ctx)).mode).toBe('deny');
28
+ expect((await r.check(call('Bash'), ctx)).mode).toBe('deny');
29
+ });
30
+
31
+ it('expands "*" to every registered tool name', async () => {
32
+ const r = createAuditedAllowListResolver({
33
+ name: 'test-allow-list',
34
+ allowedTools: ['*'],
35
+ allToolNames: ['Read', 'Bash'],
36
+ });
37
+ expect((await r.check(call('Read'), ctx)).mode).not.toBe('deny');
38
+ expect((await r.check(call('Bash'), ctx)).mode).not.toBe('deny');
39
+ // A name not in the registry is still denied even under '*'.
40
+ expect((await r.check(call('NotRegistered'), ctx)).mode).toBe('deny');
41
+ });
42
+
43
+ it('fires the audit hook for approvals only, flagging wildcard expansion', async () => {
44
+ const onAutoApprove = vi.fn();
45
+ const r = createAuditedAllowListResolver({
46
+ name: 'test-allow-list',
47
+ allowedTools: ['*'],
48
+ allToolNames: ['Read'],
49
+ onAutoApprove,
50
+ });
51
+ await r.check(call('Read'), ctx);
52
+ expect(onAutoApprove).toHaveBeenCalledTimes(1);
53
+ expect(onAutoApprove.mock.calls[0]?.[0]).toMatchObject({ name: 'Read' });
54
+ expect(onAutoApprove.mock.calls[0]?.[1]).toEqual({ wildcard: true });
55
+
56
+ onAutoApprove.mockClear();
57
+ await r.check(call('Denied'), ctx);
58
+ expect(onAutoApprove).not.toHaveBeenCalled();
59
+ });
60
+
61
+ it('carries the given resolver name', () => {
62
+ const r = createAuditedAllowListResolver({
63
+ name: 'my-channel-allow-list',
64
+ allowedTools: [],
65
+ allToolNames: [],
66
+ });
67
+ expect(r.name).toBe('my-channel-allow-list');
68
+ });
69
+ });
@@ -0,0 +1,46 @@
1
+ import { createAllowListResolver } from '@moxxy/sdk';
2
+ import type { PendingToolCall, PermissionResolver } from '@moxxy/sdk';
3
+
4
+ /**
5
+ * Autonomous allow-list permission resolver with an audit hook — the shared
6
+ * wiring for hands-off channels (Slack-style: no human in the loop; the
7
+ * operator declares trust upfront via an `allowedTools` list).
8
+ *
9
+ * Reuses the SDK's {@link createAllowListResolver} (exact-name match →
10
+ * `allow_session`, else `deny`) rather than re-implementing the trust check,
11
+ * and adds the two things every autonomous channel needs:
12
+ *
13
+ * - `'*'` expansion — `['*']` means "allow every registered tool", expanded
14
+ * against `allToolNames` at channel-start time (mirrors the CLI's
15
+ * `--allow-all`). An empty list denies everything (effectively read-only).
16
+ * - an audit hook — `onAutoApprove` fires for every non-denied call so an
17
+ * autonomous run leaves a trail of what it ran (channels log it).
18
+ */
19
+ export interface AuditedAllowListOptions {
20
+ /** Resolver name surfaced in permission events (e.g. 'slack-allow-list'). */
21
+ readonly name: string;
22
+ readonly allowedTools: ReadonlyArray<string>;
23
+ /** Every registered tool name — the expansion target for `'*'`. */
24
+ readonly allToolNames: ReadonlyArray<string>;
25
+ readonly onAutoApprove?: (
26
+ call: PendingToolCall,
27
+ info: { readonly wildcard: boolean },
28
+ ) => void;
29
+ }
30
+
31
+ export function createAuditedAllowListResolver(opts: AuditedAllowListOptions): PermissionResolver {
32
+ const wildcard = opts.allowedTools.includes('*');
33
+ const effective = wildcard ? [...opts.allToolNames] : [...opts.allowedTools];
34
+ const inner = createAllowListResolver(effective);
35
+
36
+ return {
37
+ name: opts.name,
38
+ async check(call, ctx) {
39
+ const decision = await inner.check(call, ctx);
40
+ if (decision.mode !== 'deny') {
41
+ opts.onAutoApprove?.(call, { wildcard });
42
+ }
43
+ return decision;
44
+ },
45
+ };
46
+ }