@chipallen2/snazi 0.1.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.
- package/README.md +281 -0
- package/com.soup-nazi.snazi-serve.plist +46 -0
- package/dist/address.js +61 -0
- package/dist/api.js +101 -0
- package/dist/cache.js +173 -0
- package/dist/channels/imessage.js +47 -0
- package/dist/channels/index.js +39 -0
- package/dist/channels/types.js +16 -0
- package/dist/chatdb.js +202 -0
- package/dist/cli.js +516 -0
- package/dist/client.js +106 -0
- package/dist/config.js +110 -0
- package/dist/daemon.js +89 -0
- package/dist/doctor.js +99 -0
- package/dist/init.js +155 -0
- package/dist/server.js +466 -0
- package/package.json +52 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.DEFAULT_PORT = void 0;
|
|
37
|
+
exports.detectTailscaleIp = detectTailscaleIp;
|
|
38
|
+
exports.resolveBind = resolveBind;
|
|
39
|
+
exports.createServer = createServer;
|
|
40
|
+
exports.startServer = startServer;
|
|
41
|
+
/**
|
|
42
|
+
* snazi serve — least-privilege HTTP gate.
|
|
43
|
+
*
|
|
44
|
+
* "No messages for you."
|
|
45
|
+
*
|
|
46
|
+
* Exposes ONLY the read-only gated message operations over HTTP so a REMOTE
|
|
47
|
+
* trusted agent (reachable only over a private Tailscale tailnet) can use them
|
|
48
|
+
* without an SSH shell. This is deliberately a tiny, read-only surface:
|
|
49
|
+
*
|
|
50
|
+
* GET /health -> { ok, version } (no auth)
|
|
51
|
+
* GET /list-new?channel&since -> WHO + status + label (bearer)
|
|
52
|
+
* GET /read?sender&channel&since -> text ONLY if approved (bearer)
|
|
53
|
+
* GET /check?sender&channel -> { status, label } (bearer)
|
|
54
|
+
* GET /resolve?name&channel -> name->address address book (bearer)
|
|
55
|
+
* POST /label {sender,channel,name}-> set a sender's display label (bearer)
|
|
56
|
+
*
|
|
57
|
+
* It REUSES the same gate (api.ts) and the same DB reader (chatdb.ts) as the
|
|
58
|
+
* CLI. There is no approve/deny here — APPROVAL mutations stay CLI/dashboard-
|
|
59
|
+
* only. The ONLY write this surface can make is POST /label, which sets a
|
|
60
|
+
* sender's non-privileged display name via an UPDATE-only web endpoint: it can
|
|
61
|
+
* never create a row or change `status`, so it cannot open the gate. There is
|
|
62
|
+
* no shell, no arbitrary file access, no path that bypasses the gate.
|
|
63
|
+
*/
|
|
64
|
+
const http = __importStar(require("http"));
|
|
65
|
+
const crypto = __importStar(require("crypto"));
|
|
66
|
+
const os = __importStar(require("os"));
|
|
67
|
+
const api_1 = require("./api");
|
|
68
|
+
const cache_1 = require("./cache");
|
|
69
|
+
const channels_1 = require("./channels");
|
|
70
|
+
const address_1 = require("./address");
|
|
71
|
+
// Read version without importing JSON at compile time (keeps build simple).
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
73
|
+
const VERSION = (() => {
|
|
74
|
+
try {
|
|
75
|
+
// dist/server.js -> ../package.json
|
|
76
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
77
|
+
return require('../package.json').version ?? '0.0.0';
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return '0.0.0';
|
|
81
|
+
}
|
|
82
|
+
})();
|
|
83
|
+
exports.DEFAULT_PORT = 8787;
|
|
84
|
+
const DEFAULT_CHANNEL = 'imessage';
|
|
85
|
+
const MAX_SINCE_MIN = 7 * 24 * 60; // 7 days
|
|
86
|
+
const DEFAULT_SINCE_MIN = 60;
|
|
87
|
+
const MAX_SENDER_LEN = 128;
|
|
88
|
+
const MAX_NAME_LEN = 64;
|
|
89
|
+
// Cap POST bodies hard: /label only needs a few short fields.
|
|
90
|
+
const MAX_BODY_BYTES = 4 * 1024;
|
|
91
|
+
const CHANNEL_RE = /^[a-z0-9_-]+$/i;
|
|
92
|
+
// iMessage senders are phone numbers (+1555…) or emails. Keep it tight.
|
|
93
|
+
const SENDER_RE = /^[A-Za-z0-9_.+@-]+$/;
|
|
94
|
+
// Names are free-form human text but must not carry control chars (defends
|
|
95
|
+
// log/terminal injection) and are length-capped. Keep in sync with
|
|
96
|
+
// packages/web/src/app/api/senders/label/route.ts (MAX_LABEL_LEN, LABEL_CTRL_RE).
|
|
97
|
+
// eslint-disable-next-line no-control-regex
|
|
98
|
+
const NAME_CTRL_RE = /[\u0000-\u001f\u007f]/;
|
|
99
|
+
/**
|
|
100
|
+
* Find this host's Tailscale IP (CGNAT range 100.64.0.0/10) if present.
|
|
101
|
+
* Returns undefined if not on a tailnet.
|
|
102
|
+
*/
|
|
103
|
+
function detectTailscaleIp() {
|
|
104
|
+
const ifaces = os.networkInterfaces();
|
|
105
|
+
for (const addrs of Object.values(ifaces)) {
|
|
106
|
+
if (!addrs)
|
|
107
|
+
continue;
|
|
108
|
+
for (const a of addrs) {
|
|
109
|
+
if (a.family !== 'IPv4' || a.internal)
|
|
110
|
+
continue;
|
|
111
|
+
const parts = a.address.split('.').map((n) => parseInt(n, 10));
|
|
112
|
+
// 100.64.0.0/10 => first octet 100, second octet 64..127
|
|
113
|
+
if (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) {
|
|
114
|
+
return a.address;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Resolve the bind address with the security policy:
|
|
122
|
+
* flag/config override -> tailscale IP -> 127.0.0.1
|
|
123
|
+
* NEVER 0.0.0.0 (or ::) — that would expose the gate beyond the tailnet.
|
|
124
|
+
*/
|
|
125
|
+
function resolveBind(cfg, opts) {
|
|
126
|
+
const requested = opts.bind ?? cfg.serveBind;
|
|
127
|
+
if (requested) {
|
|
128
|
+
const r = requested.trim();
|
|
129
|
+
if (r === '0.0.0.0' || r === '::' || r === '*') {
|
|
130
|
+
throw new Error(`Refusing to bind ${r}: that exposes the gate to every network. ` +
|
|
131
|
+
`Bind your Tailscale 100.x IP (tailnet-only) or 127.0.0.1 (with 'tailscale serve').`);
|
|
132
|
+
}
|
|
133
|
+
return r;
|
|
134
|
+
}
|
|
135
|
+
return detectTailscaleIp() ?? '127.0.0.1';
|
|
136
|
+
}
|
|
137
|
+
function resolvePort(cfg, opts) {
|
|
138
|
+
const p = opts.port ?? cfg.servePort ?? exports.DEFAULT_PORT;
|
|
139
|
+
if (!Number.isInteger(p) || p < 1 || p > 65535) {
|
|
140
|
+
throw new Error(`Invalid port: ${p}`);
|
|
141
|
+
}
|
|
142
|
+
return p;
|
|
143
|
+
}
|
|
144
|
+
/** Constant-time bearer check. Never logs the token. */
|
|
145
|
+
function bearerOk(authHeader, expected) {
|
|
146
|
+
if (!expected)
|
|
147
|
+
return false;
|
|
148
|
+
if (!authHeader || !authHeader.startsWith('Bearer '))
|
|
149
|
+
return false;
|
|
150
|
+
// Exact token after the "Bearer " prefix — no trimming, so stray whitespace
|
|
151
|
+
// fails closed rather than silently authenticating.
|
|
152
|
+
const provided = authHeader.slice('Bearer '.length);
|
|
153
|
+
// Hash both to fixed length so we never branch on length and never throw on
|
|
154
|
+
// a length mismatch inside timingSafeEqual.
|
|
155
|
+
const a = crypto.createHash('sha256').update(provided).digest();
|
|
156
|
+
const b = crypto.createHash('sha256').update(expected).digest();
|
|
157
|
+
return crypto.timingSafeEqual(a, b);
|
|
158
|
+
}
|
|
159
|
+
function sendJson(res, status, body) {
|
|
160
|
+
const payload = JSON.stringify(body);
|
|
161
|
+
res.writeHead(status, {
|
|
162
|
+
'content-type': 'application/json; charset=utf-8',
|
|
163
|
+
'content-length': Buffer.byteLength(payload),
|
|
164
|
+
'cache-control': 'no-store',
|
|
165
|
+
// This is a private API surface; deny embedding/sniffing.
|
|
166
|
+
'x-content-type-options': 'nosniff',
|
|
167
|
+
});
|
|
168
|
+
res.end(payload);
|
|
169
|
+
}
|
|
170
|
+
function parseChannel(v) {
|
|
171
|
+
const c = (v ?? DEFAULT_CHANNEL).trim();
|
|
172
|
+
if (!CHANNEL_RE.test(c))
|
|
173
|
+
throw new Error('Invalid channel.');
|
|
174
|
+
return c;
|
|
175
|
+
}
|
|
176
|
+
function parseSender(v) {
|
|
177
|
+
const raw = (v ?? '').trim();
|
|
178
|
+
if (!raw)
|
|
179
|
+
throw new Error('Missing sender.');
|
|
180
|
+
if (raw.length > MAX_SENDER_LEN)
|
|
181
|
+
throw new Error('Sender too long.');
|
|
182
|
+
if (!SENDER_RE.test(raw))
|
|
183
|
+
throw new Error('Invalid sender.');
|
|
184
|
+
// Normalize so reads/checks key on the same string the server stored.
|
|
185
|
+
return (0, address_1.normalizeAddress)(raw);
|
|
186
|
+
}
|
|
187
|
+
function parseSince(v) {
|
|
188
|
+
if (v == null || v === '')
|
|
189
|
+
return DEFAULT_SINCE_MIN;
|
|
190
|
+
const n = parseInt(v, 10);
|
|
191
|
+
if (Number.isNaN(n) || n <= 0)
|
|
192
|
+
throw new Error('Invalid since (minutes).');
|
|
193
|
+
return Math.min(n, MAX_SINCE_MIN);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Validate a display name. `allowEmpty` lets /resolve treat a missing/blank
|
|
197
|
+
* name as "return the whole address book" rather than an error.
|
|
198
|
+
*/
|
|
199
|
+
function parseName(v, allowEmpty = false) {
|
|
200
|
+
const n = (v ?? '').trim();
|
|
201
|
+
if (!n) {
|
|
202
|
+
if (allowEmpty)
|
|
203
|
+
return '';
|
|
204
|
+
throw new Error('Missing name.');
|
|
205
|
+
}
|
|
206
|
+
if (n.length > MAX_NAME_LEN)
|
|
207
|
+
throw new Error('Name too long.');
|
|
208
|
+
if (NAME_CTRL_RE.test(n))
|
|
209
|
+
throw new Error('Invalid name.');
|
|
210
|
+
return n;
|
|
211
|
+
}
|
|
212
|
+
/** Read a request body with a hard size cap (fails closed on overflow). */
|
|
213
|
+
function readBody(req, maxBytes) {
|
|
214
|
+
return new Promise((resolve, reject) => {
|
|
215
|
+
let size = 0;
|
|
216
|
+
const chunks = [];
|
|
217
|
+
req.on('data', (c) => {
|
|
218
|
+
size += c.length;
|
|
219
|
+
if (size > maxBytes) {
|
|
220
|
+
reject(new Error('Body too large.'));
|
|
221
|
+
req.destroy();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
chunks.push(c);
|
|
225
|
+
});
|
|
226
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
227
|
+
req.on('error', reject);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
/** List inbound senders with approval status and display labels (no message text). */
|
|
231
|
+
async function handleListNew(cfg, url) {
|
|
232
|
+
const channel = parseChannel(url.searchParams.get('channel'));
|
|
233
|
+
const since = parseSince(url.searchParams.get('since'));
|
|
234
|
+
const { adapter, error } = (0, channels_1.resolveReadableAdapter)(channel);
|
|
235
|
+
if (!adapter)
|
|
236
|
+
return { status: 501, body: { error } };
|
|
237
|
+
const senders = adapter.listInboundSenders(since);
|
|
238
|
+
// One list fetch -> address->label map (best-effort; null on any failure).
|
|
239
|
+
const labels = await (0, api_1.buildLabelMap)(cfg, channel);
|
|
240
|
+
const results = [];
|
|
241
|
+
for (const s of senders) {
|
|
242
|
+
let status = 'unknown';
|
|
243
|
+
let checkError;
|
|
244
|
+
try {
|
|
245
|
+
status = await (0, cache_1.checkSenderCached)(cfg, channel, s.sender);
|
|
246
|
+
}
|
|
247
|
+
catch (e) {
|
|
248
|
+
checkError = String(e instanceof Error ? e.message : e);
|
|
249
|
+
}
|
|
250
|
+
const entry = {
|
|
251
|
+
sender: s.sender,
|
|
252
|
+
message_count: s.message_count,
|
|
253
|
+
latest_at: s.latest_at,
|
|
254
|
+
status,
|
|
255
|
+
label: labels.get((0, address_1.normalizeAddress)(s.sender)) ?? null,
|
|
256
|
+
};
|
|
257
|
+
if (checkError)
|
|
258
|
+
entry.error = checkError;
|
|
259
|
+
results.push(entry);
|
|
260
|
+
}
|
|
261
|
+
return { status: 200, body: { channel, since_minutes: since, senders: results } };
|
|
262
|
+
}
|
|
263
|
+
async function handleRead(cfg, url) {
|
|
264
|
+
const sender = parseSender(url.searchParams.get('sender'));
|
|
265
|
+
const channel = parseChannel(url.searchParams.get('channel'));
|
|
266
|
+
const since = parseSince(url.searchParams.get('since'));
|
|
267
|
+
// Resolve the local source first so an unsupported channel/platform fails
|
|
268
|
+
// clearly (and we never touch the network or any message text).
|
|
269
|
+
const { adapter, error } = (0, channels_1.resolveReadableAdapter)(channel);
|
|
270
|
+
if (!adapter)
|
|
271
|
+
return { status: 501, body: { error } };
|
|
272
|
+
// GATE: check approval BEFORE touching any message text.
|
|
273
|
+
let status;
|
|
274
|
+
try {
|
|
275
|
+
status = await (0, cache_1.checkSenderCached)(cfg, channel, sender);
|
|
276
|
+
}
|
|
277
|
+
catch (e) {
|
|
278
|
+
return {
|
|
279
|
+
status: 502,
|
|
280
|
+
body: { error: `Approval check failed: ${String(e instanceof Error ? e.message : e)}` },
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (status !== 'approved') {
|
|
284
|
+
return {
|
|
285
|
+
status: 403,
|
|
286
|
+
body: { error: 'Sender not approved. No messages for you.', status },
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const messages = adapter.readMessagesFrom(sender, since);
|
|
290
|
+
return {
|
|
291
|
+
status: 200,
|
|
292
|
+
body: { sender, channel, status, since_minutes: since, messages },
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
async function handleCheck(cfg, url) {
|
|
296
|
+
const sender = parseSender(url.searchParams.get('sender'));
|
|
297
|
+
const channel = parseChannel(url.searchParams.get('channel'));
|
|
298
|
+
let status;
|
|
299
|
+
try {
|
|
300
|
+
status = await (0, cache_1.checkSenderCached)(cfg, channel, sender);
|
|
301
|
+
}
|
|
302
|
+
catch (e) {
|
|
303
|
+
return {
|
|
304
|
+
status: 502,
|
|
305
|
+
body: { error: `Approval check failed: ${String(e instanceof Error ? e.message : e)}` },
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
// Best-effort label lookup for this one address (display only).
|
|
309
|
+
const labels = await (0, api_1.buildLabelMap)(cfg, channel);
|
|
310
|
+
const label = labels.get(sender) ?? null;
|
|
311
|
+
return { status: 200, body: { channel, sender, status, label } };
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* GET /resolve?name=<q>&channel=<id>
|
|
315
|
+
* Name->address "address book" lookup. Match = case-insensitive substring of a
|
|
316
|
+
* sender's label against the query. Empty/omitted name -> every sender that has
|
|
317
|
+
* a non-null label. Reveals only address+label+status (same sensitivity as
|
|
318
|
+
* /list-new) — never message text.
|
|
319
|
+
*/
|
|
320
|
+
async function handleResolve(cfg, url) {
|
|
321
|
+
const channel = parseChannel(url.searchParams.get('channel'));
|
|
322
|
+
const query = parseName(url.searchParams.get('name'), true);
|
|
323
|
+
const needle = query.toLowerCase();
|
|
324
|
+
const senders = await (0, api_1.listSenders)(cfg, channel);
|
|
325
|
+
const matches = senders
|
|
326
|
+
.filter((s) => {
|
|
327
|
+
if (s.label == null || s.label === '')
|
|
328
|
+
return false;
|
|
329
|
+
if (needle === '')
|
|
330
|
+
return true; // whole address book
|
|
331
|
+
return s.label.toLowerCase().includes(needle);
|
|
332
|
+
})
|
|
333
|
+
.map((s) => ({
|
|
334
|
+
sender_address: s.sender_address,
|
|
335
|
+
label: s.label,
|
|
336
|
+
status: s.status,
|
|
337
|
+
}));
|
|
338
|
+
return { status: 200, body: { channel, query, matches } };
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* POST /label body: { sender, channel, name }
|
|
342
|
+
* Sets a sender's display label via the UPDATE-only web endpoint. This is the
|
|
343
|
+
* ONLY write this read-only gate can make. It is structurally incapable of
|
|
344
|
+
* creating a row or changing `status`, so it CANNOT open the gate — a label is
|
|
345
|
+
* non-privileged display metadata. Reading is always re-gated by status per
|
|
346
|
+
* address, so a wrong/forged label can never reveal message text.
|
|
347
|
+
*/
|
|
348
|
+
async function handleLabel(cfg, rawBody) {
|
|
349
|
+
let parsed;
|
|
350
|
+
try {
|
|
351
|
+
parsed = JSON.parse(rawBody || '{}');
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return { status: 400, body: { error: 'Invalid JSON body.' } };
|
|
355
|
+
}
|
|
356
|
+
const b = (parsed ?? {});
|
|
357
|
+
const sender = parseSender(typeof b.sender === 'string' ? b.sender : null);
|
|
358
|
+
const channel = parseChannel(typeof b.channel === 'string' ? b.channel : null);
|
|
359
|
+
const name = parseName(typeof b.name === 'string' ? b.name : null);
|
|
360
|
+
try {
|
|
361
|
+
const result = await (0, api_1.setLabel)(cfg, channel, sender, name);
|
|
362
|
+
return { status: 200, body: { ok: true, channel, sender, label: name, result } };
|
|
363
|
+
}
|
|
364
|
+
catch (e) {
|
|
365
|
+
const msg = String(e instanceof Error ? e.message : e);
|
|
366
|
+
// Surface the web 404 (sender not on the list) as a 404 to the caller.
|
|
367
|
+
const code = /HTTP 404/.test(msg) ? 404 : 502;
|
|
368
|
+
return { status: code, body: { error: `Label failed: ${msg}` } };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/** Build (but do not start) the HTTP server. Exposed for tests. */
|
|
372
|
+
function createServer(cfg) {
|
|
373
|
+
const token = cfg.serveToken ?? '';
|
|
374
|
+
return http.createServer((req, res) => {
|
|
375
|
+
void (async () => {
|
|
376
|
+
try {
|
|
377
|
+
const method = req.method ?? 'GET';
|
|
378
|
+
// Parse against a dummy base; we only use pathname + searchParams.
|
|
379
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
380
|
+
const pathname = url.pathname.replace(/\/+$/, '') || '/';
|
|
381
|
+
// /health is unauthenticated (connectivity probe only — no data).
|
|
382
|
+
// GET only — no body, no data.
|
|
383
|
+
if (method === 'GET' && pathname === '/health') {
|
|
384
|
+
return sendJson(res, 200, { ok: true, version: VERSION });
|
|
385
|
+
}
|
|
386
|
+
// Only GET (reads) and POST /label (the single non-privileged write)
|
|
387
|
+
// are allowed on this surface.
|
|
388
|
+
if (method !== 'GET' && method !== 'POST') {
|
|
389
|
+
return sendJson(res, 405, { error: 'Method not allowed.' });
|
|
390
|
+
}
|
|
391
|
+
// Everything past /health requires a valid bearer token — including the
|
|
392
|
+
// POST /label write.
|
|
393
|
+
if (!bearerOk(req.headers['authorization'], token)) {
|
|
394
|
+
res.setHeader('www-authenticate', 'Bearer');
|
|
395
|
+
return sendJson(res, 401, { error: 'Unauthorized.' });
|
|
396
|
+
}
|
|
397
|
+
if (method === 'POST') {
|
|
398
|
+
if (pathname === '/label') {
|
|
399
|
+
const rawBody = await readBody(req, MAX_BODY_BYTES);
|
|
400
|
+
const r = await handleLabel(cfg, rawBody);
|
|
401
|
+
return sendJson(res, r.status, r.body);
|
|
402
|
+
}
|
|
403
|
+
return sendJson(res, 404, { error: 'Not found.' });
|
|
404
|
+
}
|
|
405
|
+
switch (pathname) {
|
|
406
|
+
case '/list-new': {
|
|
407
|
+
const r = await handleListNew(cfg, url);
|
|
408
|
+
return sendJson(res, r.status, r.body);
|
|
409
|
+
}
|
|
410
|
+
case '/read': {
|
|
411
|
+
const r = await handleRead(cfg, url);
|
|
412
|
+
return sendJson(res, r.status, r.body);
|
|
413
|
+
}
|
|
414
|
+
case '/check': {
|
|
415
|
+
const r = await handleCheck(cfg, url);
|
|
416
|
+
return sendJson(res, r.status, r.body);
|
|
417
|
+
}
|
|
418
|
+
case '/resolve': {
|
|
419
|
+
const r = await handleResolve(cfg, url);
|
|
420
|
+
return sendJson(res, r.status, r.body);
|
|
421
|
+
}
|
|
422
|
+
default:
|
|
423
|
+
return sendJson(res, 404, { error: 'Not found.' });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
catch (e) {
|
|
427
|
+
// Validation/handler errors → 400 with a safe message (never the token).
|
|
428
|
+
return sendJson(res, 400, {
|
|
429
|
+
error: String(e instanceof Error ? e.message : e),
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
})();
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
/** Start `snazi serve`. Resolves when the server is listening. */
|
|
436
|
+
async function startServer(cfg, opts) {
|
|
437
|
+
if (!cfg.serveToken) {
|
|
438
|
+
throw new Error('serveToken not set in ~/.snazi/config.json. Add a strong random token ' +
|
|
439
|
+
'(e.g. `openssl rand -hex 32`) before exposing the gate over HTTP.');
|
|
440
|
+
}
|
|
441
|
+
const bind = resolveBind(cfg, opts);
|
|
442
|
+
const port = resolvePort(cfg, opts);
|
|
443
|
+
const server = createServer(cfg);
|
|
444
|
+
await new Promise((resolve, reject) => {
|
|
445
|
+
server.once('error', reject);
|
|
446
|
+
server.listen(port, bind, () => {
|
|
447
|
+
server.off('error', reject);
|
|
448
|
+
resolve();
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
const onTailnet = bind.startsWith('100.') || bind === detectTailscaleIp();
|
|
452
|
+
console.error(JSON.stringify({
|
|
453
|
+
ok: true,
|
|
454
|
+
msg: 'snazi serve listening (read-only gate)',
|
|
455
|
+
bind,
|
|
456
|
+
port,
|
|
457
|
+
version: VERSION,
|
|
458
|
+
surface: ['/health', '/list-new', '/check', '/read', '/resolve', 'POST /label'],
|
|
459
|
+
reachable_on: bind === '127.0.0.1'
|
|
460
|
+
? 'loopback only (front with `tailscale serve` for tailnet access)'
|
|
461
|
+
: onTailnet
|
|
462
|
+
? 'tailnet only (100.x)'
|
|
463
|
+
: `custom bind ${bind}`,
|
|
464
|
+
}));
|
|
465
|
+
return { bind, port, server };
|
|
466
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chipallen2/snazi",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "On-demand local gate for your messages. Reveals WHO contacted you; only reveals WHAT for approved senders. iMessage today, pluggable channels next.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://snazi.dev",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/chipallen2/snazi.git",
|
|
10
|
+
"directory": "packages/snazi"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/chipallen2/snazi/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"type": "commonjs",
|
|
19
|
+
"bin": {
|
|
20
|
+
"snazi": "dist/cli.js"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"com.soup-nazi.snazi-serve.plist",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"imessage",
|
|
32
|
+
"cli",
|
|
33
|
+
"ai-agent",
|
|
34
|
+
"prompt-injection",
|
|
35
|
+
"gate",
|
|
36
|
+
"macos"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc",
|
|
40
|
+
"start": "node dist/cli.js",
|
|
41
|
+
"prepare": "npm run build",
|
|
42
|
+
"test": "npm run build && node test/address.test.cjs && node test/cache.test.cjs && node test/both-sides.test.cjs && node test/serve-names.test.cjs && node test/channels.test.cjs"
|
|
43
|
+
},
|
|
44
|
+
"optionalDependencies": {
|
|
45
|
+
"better-sqlite3": "^11.3.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/better-sqlite3": "^7.6.11",
|
|
49
|
+
"@types/node": "^20.14.0",
|
|
50
|
+
"typescript": "^5.5.3"
|
|
51
|
+
}
|
|
52
|
+
}
|