@tagma/sdk 0.5.2 → 0.6.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.
@@ -1,224 +1,224 @@
1
- import type { ApprovalGateway, ApprovalEvent } from '../approval';
2
-
3
- // ═══ WebSocket Approval Adapter ═══
4
- //
5
- // Bridges the ApprovalGateway to WebSocket clients (e.g. a frontend UI).
6
- // Mirrors the stdin-approval adapter pattern: subscribe to gateway events,
7
- // forward them as JSON to all connected clients, and call gateway.resolve()
8
- // when a client sends a resolution message.
9
- //
10
- // Protocol — server → client:
11
- // { type: 'pending', requests: ApprovalRequest[] } ← sent on connect
12
- // { type: 'approval_requested', request: ApprovalRequest }
13
- // { type: 'approval_resolved', request: ApprovalRequest, decision: ApprovalDecision }
14
- // { type: 'approval_expired', request: ApprovalRequest }
15
- // { type: 'approval_aborted', request: ApprovalRequest, reason: string }
16
- //
17
- // Protocol — client → server:
18
- // { type: 'resolve', approvalId: string, outcome: 'approved'|'rejected',
19
- // actor?: string, reason?: string }
20
-
21
- export interface WebSocketApprovalAdapterOptions {
22
- port?: number; // default: 3000
23
- hostname?: string; // default: 'localhost'
24
- /**
25
- * M11: shared secret required from the client during the WebSocket
26
- * upgrade. The token can be supplied either as the `?token=` query
27
- * parameter or in the `x-tagma-token` request header. When set, any
28
- * upgrade request that fails the check is rejected with HTTP 401 and
29
- * never reaches the WebSocket layer (so a misconfigured client cannot
30
- * exhaust rate-limit slots either). Leave undefined for backward
31
- * compatibility with localhost-only deployments.
32
- */
33
- token?: string;
34
- /**
35
- * M11: opt-out of origin checking. Defaults to false, meaning Origin
36
- * headers are restricted to loopback hosts (localhost / 127.0.0.1 / ::1).
37
- * Requests without an Origin header are still allowed so non-browser local
38
- * clients can connect. Set true only for trusted reverse-proxy setups.
39
- */
40
- allowAnyOrigin?: boolean;
41
- }
42
-
43
- export interface WebSocketApprovalAdapter {
44
- readonly port: number;
45
- readonly detach: () => void;
46
- }
47
-
48
- // Maximum allowed message payload (bytes) to prevent DoS via oversized messages.
49
- const MAX_PAYLOAD_BYTES = 4_096;
50
- // Per-client rate limit: at most this many messages per window.
51
- const RATE_LIMIT_MAX = 10;
52
- const RATE_LIMIT_WINDOW_MS = 1_000;
53
-
54
- export function attachWebSocketApprovalAdapter(
55
- gateway: ApprovalGateway,
56
- options: WebSocketApprovalAdapterOptions = {},
57
- ): WebSocketApprovalAdapter {
58
- const port = options.port ?? 3000;
59
- const hostname = options.hostname ?? 'localhost';
60
- const requiredToken = options.token ?? null;
61
- const enforceOriginCheck = options.allowAnyOrigin !== true;
62
-
63
- function isLoopbackOrigin(origin: string): boolean {
64
- try {
65
- const host = new URL(origin).hostname.toLowerCase();
66
- return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
67
- } catch {
68
- return false;
69
- }
70
- }
71
-
72
- type WS = import('bun').ServerWebSocket<unknown>;
73
- const clients = new Set<WS>();
74
- const clientRates = new Map<WS, { count: number; resetAt: number }>();
75
-
76
- function broadcast(msg: unknown): void {
77
- const text = JSON.stringify(msg);
78
- for (const ws of clients) {
79
- ws.send(text);
80
- }
81
- }
82
-
83
- const unsubscribe = gateway.subscribe((event: ApprovalEvent) => {
84
- switch (event.type) {
85
- case 'requested':
86
- broadcast({ type: 'approval_requested', request: event.request });
87
- break;
88
- case 'resolved':
89
- broadcast({ type: 'approval_resolved', request: event.request, decision: event.decision });
90
- break;
91
- case 'expired':
92
- broadcast({ type: 'approval_expired', request: event.request });
93
- break;
94
- case 'aborted':
95
- broadcast({ type: 'approval_aborted', request: event.request, reason: event.reason });
96
- break;
97
- }
98
- });
99
-
100
- const server = Bun.serve({
101
- port,
102
- hostname,
103
-
104
- fetch(req, server) {
105
- if (enforceOriginCheck) {
106
- const origin = req.headers.get('origin');
107
- if (origin && !isLoopbackOrigin(origin)) {
108
- return new Response('forbidden origin', { status: 403 });
109
- }
110
- }
111
- // M11: enforce token before any upgrade so an unauthenticated client
112
- // can't even open a socket. Tokens may arrive via header or query.
113
- if (requiredToken !== null) {
114
- const headerToken = req.headers.get('x-tagma-token') ?? '';
115
- let queryToken = '';
116
- try {
117
- queryToken = new URL(req.url).searchParams.get('token') ?? '';
118
- } catch {
119
- /* malformed URL — leave queryToken empty */
120
- }
121
- const presented = headerToken || queryToken;
122
- if (presented !== requiredToken) {
123
- return new Response('unauthorized', { status: 401 });
124
- }
125
- }
126
- if (server.upgrade(req)) return undefined;
127
- return new Response('tagma-sdk WebSocket approval endpoint', { status: 426 });
128
- },
129
-
130
- websocket: {
131
- open(ws) {
132
- clients.add(ws);
133
- // Sync current pending approvals to newly connected client.
134
- ws.send(JSON.stringify({ type: 'pending', requests: gateway.pending() }));
135
- },
136
-
137
- message(ws, raw) {
138
- const rawStr = typeof raw === 'string' ? raw : raw.toString();
139
-
140
- // Payload size guard — reject oversized messages before parsing.
141
- if (rawStr.length > MAX_PAYLOAD_BYTES) {
142
- ws.send(JSON.stringify({ type: 'error', message: 'message too large' }));
143
- return;
144
- }
145
-
146
- // Per-client rate limit.
147
- const now = Date.now();
148
- const rate = clientRates.get(ws) ?? { count: 0, resetAt: now + RATE_LIMIT_WINDOW_MS };
149
- if (now >= rate.resetAt) {
150
- rate.count = 0;
151
- rate.resetAt = now + RATE_LIMIT_WINDOW_MS;
152
- }
153
- rate.count++;
154
- clientRates.set(ws, rate);
155
- if (rate.count > RATE_LIMIT_MAX) {
156
- ws.send(JSON.stringify({ type: 'error', message: 'rate limit exceeded' }));
157
- return;
158
- }
159
-
160
- let msg: unknown;
161
- try {
162
- msg = JSON.parse(rawStr);
163
- } catch {
164
- ws.send(JSON.stringify({ type: 'error', message: 'invalid JSON' }));
165
- return;
166
- }
167
-
168
- if (!isResolveMessage(msg)) {
169
- ws.send(JSON.stringify({ type: 'error', message: 'unknown message type' }));
170
- return;
171
- }
172
-
173
- const ok = gateway.resolve(msg.approvalId, {
174
- outcome: msg.outcome,
175
- actor: msg.actor ?? 'websocket',
176
- reason: msg.reason,
177
- });
178
-
179
- if (!ok) {
180
- ws.send(
181
- JSON.stringify({
182
- type: 'error',
183
- message: `approval ${msg.approvalId} not found or already resolved`,
184
- }),
185
- );
186
- }
187
- },
188
-
189
- close(ws) {
190
- clients.delete(ws);
191
- clientRates.delete(ws);
192
- },
193
- },
194
- });
195
-
196
- return {
197
- port: server.port!,
198
- detach() {
199
- unsubscribe();
200
- clients.clear();
201
- server.stop(true);
202
- },
203
- };
204
- }
205
-
206
- // ── Type guard ──
207
-
208
- interface ResolveMessage {
209
- type: 'resolve';
210
- approvalId: string;
211
- outcome: 'approved' | 'rejected';
212
- actor?: string;
213
- reason?: string;
214
- }
215
-
216
- function isResolveMessage(v: unknown): v is ResolveMessage {
217
- if (typeof v !== 'object' || v === null) return false;
218
- const m = v as Record<string, unknown>;
219
- return (
220
- m['type'] === 'resolve' &&
221
- typeof m['approvalId'] === 'string' &&
222
- (m['outcome'] === 'approved' || m['outcome'] === 'rejected')
223
- );
224
- }
1
+ import type { ApprovalGateway, ApprovalEvent } from '../approval';
2
+
3
+ // ═══ WebSocket Approval Adapter ═══
4
+ //
5
+ // Bridges the ApprovalGateway to WebSocket clients (e.g. a frontend UI).
6
+ // Mirrors the stdin-approval adapter pattern: subscribe to gateway events,
7
+ // forward them as JSON to all connected clients, and call gateway.resolve()
8
+ // when a client sends a resolution message.
9
+ //
10
+ // Protocol — server → client:
11
+ // { type: 'pending', requests: ApprovalRequest[] } ← sent on connect
12
+ // { type: 'approval_requested', request: ApprovalRequest }
13
+ // { type: 'approval_resolved', request: ApprovalRequest, decision: ApprovalDecision }
14
+ // { type: 'approval_expired', request: ApprovalRequest }
15
+ // { type: 'approval_aborted', request: ApprovalRequest, reason: string }
16
+ //
17
+ // Protocol — client → server:
18
+ // { type: 'resolve', approvalId: string, outcome: 'approved'|'rejected',
19
+ // actor?: string, reason?: string }
20
+
21
+ export interface WebSocketApprovalAdapterOptions {
22
+ port?: number; // default: 3000
23
+ hostname?: string; // default: 'localhost'
24
+ /**
25
+ * M11: shared secret required from the client during the WebSocket
26
+ * upgrade. The token can be supplied either as the `?token=` query
27
+ * parameter or in the `x-tagma-token` request header. When set, any
28
+ * upgrade request that fails the check is rejected with HTTP 401 and
29
+ * never reaches the WebSocket layer (so a misconfigured client cannot
30
+ * exhaust rate-limit slots either). Leave undefined for backward
31
+ * compatibility with localhost-only deployments.
32
+ */
33
+ token?: string;
34
+ /**
35
+ * M11: opt-out of origin checking. Defaults to false, meaning Origin
36
+ * headers are restricted to loopback hosts (localhost / 127.0.0.1 / ::1).
37
+ * Requests without an Origin header are still allowed so non-browser local
38
+ * clients can connect. Set true only for trusted reverse-proxy setups.
39
+ */
40
+ allowAnyOrigin?: boolean;
41
+ }
42
+
43
+ export interface WebSocketApprovalAdapter {
44
+ readonly port: number;
45
+ readonly detach: () => void;
46
+ }
47
+
48
+ // Maximum allowed message payload (bytes) to prevent DoS via oversized messages.
49
+ const MAX_PAYLOAD_BYTES = 4_096;
50
+ // Per-client rate limit: at most this many messages per window.
51
+ const RATE_LIMIT_MAX = 10;
52
+ const RATE_LIMIT_WINDOW_MS = 1_000;
53
+
54
+ export function attachWebSocketApprovalAdapter(
55
+ gateway: ApprovalGateway,
56
+ options: WebSocketApprovalAdapterOptions = {},
57
+ ): WebSocketApprovalAdapter {
58
+ const port = options.port ?? 3000;
59
+ const hostname = options.hostname ?? 'localhost';
60
+ const requiredToken = options.token ?? null;
61
+ const enforceOriginCheck = options.allowAnyOrigin !== true;
62
+
63
+ function isLoopbackOrigin(origin: string): boolean {
64
+ try {
65
+ const host = new URL(origin).hostname.toLowerCase();
66
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ type WS = import('bun').ServerWebSocket<unknown>;
73
+ const clients = new Set<WS>();
74
+ const clientRates = new Map<WS, { count: number; resetAt: number }>();
75
+
76
+ function broadcast(msg: unknown): void {
77
+ const text = JSON.stringify(msg);
78
+ for (const ws of clients) {
79
+ ws.send(text);
80
+ }
81
+ }
82
+
83
+ const unsubscribe = gateway.subscribe((event: ApprovalEvent) => {
84
+ switch (event.type) {
85
+ case 'requested':
86
+ broadcast({ type: 'approval_requested', request: event.request });
87
+ break;
88
+ case 'resolved':
89
+ broadcast({ type: 'approval_resolved', request: event.request, decision: event.decision });
90
+ break;
91
+ case 'expired':
92
+ broadcast({ type: 'approval_expired', request: event.request });
93
+ break;
94
+ case 'aborted':
95
+ broadcast({ type: 'approval_aborted', request: event.request, reason: event.reason });
96
+ break;
97
+ }
98
+ });
99
+
100
+ const server = Bun.serve({
101
+ port,
102
+ hostname,
103
+
104
+ fetch(req, server) {
105
+ if (enforceOriginCheck) {
106
+ const origin = req.headers.get('origin');
107
+ if (origin && !isLoopbackOrigin(origin)) {
108
+ return new Response('forbidden origin', { status: 403 });
109
+ }
110
+ }
111
+ // M11: enforce token before any upgrade so an unauthenticated client
112
+ // can't even open a socket. Tokens may arrive via header or query.
113
+ if (requiredToken !== null) {
114
+ const headerToken = req.headers.get('x-tagma-token') ?? '';
115
+ let queryToken = '';
116
+ try {
117
+ queryToken = new URL(req.url).searchParams.get('token') ?? '';
118
+ } catch {
119
+ /* malformed URL — leave queryToken empty */
120
+ }
121
+ const presented = headerToken || queryToken;
122
+ if (presented !== requiredToken) {
123
+ return new Response('unauthorized', { status: 401 });
124
+ }
125
+ }
126
+ if (server.upgrade(req)) return undefined;
127
+ return new Response('tagma-sdk WebSocket approval endpoint', { status: 426 });
128
+ },
129
+
130
+ websocket: {
131
+ open(ws) {
132
+ clients.add(ws);
133
+ // Sync current pending approvals to newly connected client.
134
+ ws.send(JSON.stringify({ type: 'pending', requests: gateway.pending() }));
135
+ },
136
+
137
+ message(ws, raw) {
138
+ const rawStr = typeof raw === 'string' ? raw : raw.toString();
139
+
140
+ // Payload size guard — reject oversized messages before parsing.
141
+ if (rawStr.length > MAX_PAYLOAD_BYTES) {
142
+ ws.send(JSON.stringify({ type: 'error', message: 'message too large' }));
143
+ return;
144
+ }
145
+
146
+ // Per-client rate limit.
147
+ const now = Date.now();
148
+ const rate = clientRates.get(ws) ?? { count: 0, resetAt: now + RATE_LIMIT_WINDOW_MS };
149
+ if (now >= rate.resetAt) {
150
+ rate.count = 0;
151
+ rate.resetAt = now + RATE_LIMIT_WINDOW_MS;
152
+ }
153
+ rate.count++;
154
+ clientRates.set(ws, rate);
155
+ if (rate.count > RATE_LIMIT_MAX) {
156
+ ws.send(JSON.stringify({ type: 'error', message: 'rate limit exceeded' }));
157
+ return;
158
+ }
159
+
160
+ let msg: unknown;
161
+ try {
162
+ msg = JSON.parse(rawStr);
163
+ } catch {
164
+ ws.send(JSON.stringify({ type: 'error', message: 'invalid JSON' }));
165
+ return;
166
+ }
167
+
168
+ if (!isResolveMessage(msg)) {
169
+ ws.send(JSON.stringify({ type: 'error', message: 'unknown message type' }));
170
+ return;
171
+ }
172
+
173
+ const ok = gateway.resolve(msg.approvalId, {
174
+ outcome: msg.outcome,
175
+ actor: msg.actor ?? 'websocket',
176
+ reason: msg.reason,
177
+ });
178
+
179
+ if (!ok) {
180
+ ws.send(
181
+ JSON.stringify({
182
+ type: 'error',
183
+ message: `approval ${msg.approvalId} not found or already resolved`,
184
+ }),
185
+ );
186
+ }
187
+ },
188
+
189
+ close(ws) {
190
+ clients.delete(ws);
191
+ clientRates.delete(ws);
192
+ },
193
+ },
194
+ });
195
+
196
+ return {
197
+ port: server.port!,
198
+ detach() {
199
+ unsubscribe();
200
+ clients.clear();
201
+ server.stop(true);
202
+ },
203
+ };
204
+ }
205
+
206
+ // ── Type guard ──
207
+
208
+ interface ResolveMessage {
209
+ type: 'resolve';
210
+ approvalId: string;
211
+ outcome: 'approved' | 'rejected';
212
+ actor?: string;
213
+ reason?: string;
214
+ }
215
+
216
+ function isResolveMessage(v: unknown): v is ResolveMessage {
217
+ if (typeof v !== 'object' || v === null) return false;
218
+ const m = v as Record<string, unknown>;
219
+ return (
220
+ m['type'] === 'resolve' &&
221
+ typeof m['approvalId'] === 'string' &&
222
+ (m['outcome'] === 'approved' || m['outcome'] === 'rejected')
223
+ );
224
+ }