@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 ADDED
@@ -0,0 +1,281 @@
1
+ # snazi — message gate CLI (iMessage today)
2
+
3
+ > "No messages for you."
4
+
5
+ The **local gate**. An on-demand CLI that reads your messages through pluggable
6
+ **channel adapters** so an AI agent can be told *who* messaged without being able
7
+ to read *what* they said — unless the sender is on the server's approved list.
8
+ iMessage is the first channel; the architecture is built for more (Gmail,
9
+ Outlook, …). The CLI itself runs on macOS, Windows, and Linux (Node 18+); each
10
+ channel works wherever its adapter does (iMessage is macOS-only).
11
+
12
+ The base CLI runs **on demand** — no launchd job, no background process. The
13
+ agent invokes it when needed. It stores nothing locally and the server stores no
14
+ message content. Message text is read live from the channel's local store (for
15
+ iMessage, `~/Library/Messages/chat.db`) and printed only when the gate opens.
16
+
17
+ **Optional serve mode** (`snazi serve` or `snazi serve --install-daemon`) runs a
18
+ long-lived HTTP gate for remote agents over a private tailnet. See **Serve mode**
19
+ below.
20
+
21
+ ## How the gate works
22
+
23
+ ```
24
+ agent wants to know what's new
25
+
26
+
27
+ snazi list-new ──► reveals WHO + status + label (approved/denied/unknown)
28
+ │ (never the message text)
29
+ │ unknown sender? agent asks you: "approve +1555…?"
30
+ │ you approve in the dashboard, or by tapping a /decide link
31
+
32
+ snazi read <sender> ──► CLI asks server: is this sender approved?
33
+ ├─ approved → prints message text
34
+ └─ otherwise → "No messages for you."
35
+ ```
36
+
37
+ The approval decision lives entirely on the server (Supabase-backed,
38
+ per-account list). The CLI only *checks* it with a **read-only token** — it
39
+ cannot approve a sender or reveal content for a non-approved one.
40
+
41
+ ## Install
42
+
43
+ > **Not on npm yet.** Install from source (below). Once published,
44
+ > `npm install -g snazi` becomes the one-liner.
45
+
46
+ ### From source (works today)
47
+
48
+ macOS, Windows, or Linux (Node 18+):
49
+
50
+ ```bash
51
+ git clone https://github.com/chipallen2/snazi.git
52
+ cd snazi/packages/snazi
53
+ ./install.sh # npm install + build, links `snazi` onto your PATH
54
+ ```
55
+
56
+ On Windows (no bash), run this instead of `./install.sh`:
57
+
58
+ ```bash
59
+ npm install && npm run build && npm link
60
+ ```
61
+
62
+ Then configure and verify — no hand-edited JSON:
63
+
64
+ ```bash
65
+ snazi init # writes ~/.snazi/config.json (deployment URL + READ token)
66
+ snazi doctor # checks Node, config, connectivity, and channel access
67
+ ```
68
+
69
+ `snazi init` asks for two things: your **deployment URL** (default
70
+ `https://snazi.dev`) and your **account READ token** (sign up at `/signup`, then
71
+ copy it from the `/account` page). There is **no admin key** — approvals happen
72
+ in the dashboard or via a signed `/decide` link. For agents/CI, skip the prompts:
73
+
74
+ ```bash
75
+ snazi init --api-url https://snazi.dev --token <READ_TOKEN> --yes
76
+ ```
77
+
78
+ **macOS only — Full Disk Access.** To read iMessage, grant Full Disk Access to
79
+ your terminal (or the `node` binary) in System Settings → Privacy & Security →
80
+ Full Disk Access. `snazi doctor` flags it if missing.
81
+
82
+ ### Once published to npm
83
+
84
+ ```bash
85
+ npm install -g snazi
86
+ snazi init && snazi doctor
87
+ ```
88
+
89
+ ## Commands
90
+
91
+ | Command | What it does |
92
+ | --- | --- |
93
+ | `snazi init [--api-url <url>] [--token <tok>] [--channel <id>]` | Create or update `~/.snazi/config.json`. |
94
+ | `snazi doctor` | Diagnose Node, config, connectivity, and channel access. |
95
+ | `snazi list-new [--channel <id>] [--since <min>] [--fresh]` | Distinct inbound senders, counts, timestamps, approval status, and display label. **No text.** Default window 60 min. |
96
+ | `snazi read <sender> [--channel <id>] [--since <min>] [--fresh]` | Message text for one sender — **only if approved**. Otherwise errors with `No messages for you.` |
97
+ | `snazi check <sender> --channel <id> [--fresh]` | One sender's approval status and display label (`approved`/`denied`/`unknown`). |
98
+ | `snazi channels list` | Configured channels plus adapter availability on this machine. |
99
+ | `snazi channels add <channel>` | Add a channel (e.g. `snazi channels add imessage`). |
100
+ | `snazi cache clear` | Drop cached approval statuses (force fresh checks after a revocation). |
101
+ | `snazi status` | Config path, apiUrl, masked read token, channels, server reachability. |
102
+ | `snazi serve [--bind <ip>] [--port <n>]` | Start the read-only HTTP gate (see **Serve mode** below). |
103
+ | `snazi serve --install-daemon [--bind <ip>] [--port <n>]` | Install the launchd LaunchAgent for serve mode (macOS only). |
104
+ | `snazi remote-status` | Probe a remote serve's `/health` (`remoteUrl`). |
105
+ | `snazi remote-list-new [--channel <id>] [--since <min>]` | WHO messaged on the remote host + status + label. |
106
+ | `snazi remote-check <sender> --channel <id>` | One sender's status and label, via remote serve. |
107
+ | `snazi remote-read <sender> [--channel <id>] [--since <min>]` | Message text via remote serve — only if approved. |
108
+ | `snazi remote-resolve [<name>] --channel <id>` | Resolve a name → sender address(es). Empty name = full address book. |
109
+ | `snazi remote-label <sender> --name <name> --channel <id>` | Set a sender's display label (UPDATE-only; cannot open the gate). |
110
+
111
+ All output is JSON. Approval status is cached on disk for a short TTL (default 5
112
+ min; set `checkCacheTtlMs` in config or `SNAZI_CHECK_CACHE_TTL_MS`). Pass
113
+ `--fresh` on read/check/list-new to bypass the cache, or run `snazi cache clear`
114
+ right after you revoke someone.
115
+
116
+ ### Examples
117
+
118
+ ```bash
119
+ snazi list-new --since 180
120
+ # [
121
+ # { "sender": "+15551234567", "message_count": 3,
122
+ # "latest_at": "2026-06-23T22:10:04.000Z", "status": "unknown", "label": null }
123
+ # ]
124
+
125
+ snazi read "+15551234567"
126
+ # { "error": "Sender not approved. No messages for you.", "status": "unknown" }
127
+
128
+ # Approve in the dashboard, or mint a one-tap /decide link with your read token:
129
+ curl -s -H "x-api-key: $READ_TOKEN" \
130
+ "https://snazi.dev/api/decide-link?channel=imessage&sender=%2B15551234567&label=Mom"
131
+ # { "url": "https://snazi.dev/decide?owner=…&channel=imessage&sender=%2B15551234567&exp=…&sig=…", … }
132
+ # Tap Allow on that link, then:
133
+
134
+ snazi read "+15551234567"
135
+ # { "sender": "+15551234567", "status": "approved", "since_minutes": 60,
136
+ # "messages": [ { "date": "...", "text": "hey are we still on for lunch?" } ] }
137
+ ```
138
+
139
+ ## Serve mode — least-privilege HTTP gate over a tailnet
140
+
141
+ Sometimes the agent that wants to triage messages runs on a *different* machine
142
+ than the one signed into iMessage. SSH would work, but SSH grants a **full shell** —
143
+ far more than "let me read approved messages." `snazi serve` exposes **only** the
144
+ gated, read-only operations over HTTP so a remote trusted agent gets least
145
+ privilege.
146
+
147
+ ```
148
+ Agent host (remote client) Serve host (iMessage Mac)
149
+ ┌────────────────────┐ Tailscale tailnet ┌──────────────────────┐
150
+ │ snazi remote-read │ ───── HTTP ─────────► │ snazi serve │
151
+ │ (bearer token) │ 100.x:8787 │ /health (no auth) │
152
+ └────────────────────┘ │ /list-new (bearer) │
153
+ │ /check (bearer) │
154
+ │ /read (bearer) │
155
+ │ /resolve (bearer) │
156
+ │ POST /label (bearer) │
157
+ │ │ │
158
+ │ ▼ same gate (api) │
159
+ │ approved? → text │
160
+ │ else → "No │
161
+ │ messages │
162
+ │ for you." │
163
+ └──────────────────────┘
164
+ ```
165
+
166
+ ### Endpoints (all JSON)
167
+
168
+ | Method + path | Auth | Returns |
169
+ | --- | --- | --- |
170
+ | `GET /health` | none | `{ ok: true, version }` — connectivity probe only, no data. |
171
+ | `GET /list-new?channel=imessage&since=<min>` | bearer | `{ channel, since_minutes, senders: [{ sender, message_count, latest_at, status, label }] }`. On check failure: `status` is `unknown` and an `error` field describes the failure. **Never message text.** |
172
+ | `GET /check?sender=<addr>&channel=imessage` | bearer | `{ channel, sender, status, label }`. On check failure: HTTP 502 with `{ error }`. |
173
+ | `GET /read?sender=<addr>&channel=imessage&since=<min>` | bearer | `{ sender, channel, status, since_minutes, messages }` **only if approved**; otherwise `403 { error: "Sender not approved. No messages for you.", status }`. On check failure: HTTP 502 with `{ error }`. |
174
+ | `GET /resolve?name=<q>&channel=imessage` | bearer | `{ channel, query, matches: [{ sender_address, label, status }] }`. Empty/omitted `name` returns every labelled sender. **Never message text.** |
175
+ | `POST /label` body `{ sender, channel, name }` | bearer | Set a sender's display label via an UPDATE-only web endpoint. **Cannot create a row or change `status`**, so it cannot open the gate. 404 if the sender is not on the list yet. |
176
+
177
+ There is **no `approve`/`deny` over HTTP**. Approvals stay dashboard/`/decide`-only.
178
+ `POST /label` is the only write — label metadata only. Unknown path → `404`. Bad
179
+ params → `400`. Unsupported methods → `405`.
180
+
181
+ ### Security model
182
+
183
+ - **Tailnet-only.** Default bind is this host's Tailscale IP (`100.64.0.0/10`) if
184
+ present, else `127.0.0.1`. It **never** binds `0.0.0.0` — `--bind 0.0.0.0` is
185
+ refused. For loopback bind, front it with `tailscale serve` to reach it on the
186
+ tailnet over HTTPS.
187
+ - **Bearer token required** on every endpoint except `/health`. The token is
188
+ `serveToken` in config; comparison is constant-time (SHA-256 + `timingSafeEqual`)
189
+ and the token is never logged.
190
+ - **Read-only surface.** No shell, no arbitrary file reads, no path traversal —
191
+ only the same channel adapters the CLI uses. Params are validated
192
+ (`channel`/`sender` charset-checked, `since` clamped to ≤ 7 days).
193
+ - **Same gate.** `/read` calls the server list API (`api.ts`) *before* touching
194
+ any text — identical to `snazi read`. The gate is the product; it is not
195
+ bypassed.
196
+ - **No storage.** Content is read live from `chat.db` and returned in the
197
+ response only. Nothing is persisted on either side.
198
+
199
+ ### Config keys
200
+
201
+ Add to `~/.snazi/config.json` on the **serve host**:
202
+
203
+ ```json
204
+ {
205
+ "serveToken": "<openssl rand -hex 32>",
206
+ "serveBind": "100.64.0.10", // optional; default = tailnet IP else 127.0.0.1
207
+ "servePort": 8787 // optional; default 8787
208
+ }
209
+ ```
210
+
211
+ And on the **agent host** (remote client):
212
+
213
+ ```json
214
+ {
215
+ "remoteUrl": "http://100.64.0.10:8787",
216
+ "remoteToken": "<same value as serveToken on the serve host>"
217
+ }
218
+ ```
219
+
220
+ ### Run it
221
+
222
+ ```bash
223
+ # Foreground (binds tailnet 100.x if present, else 127.0.0.1):
224
+ snazi serve
225
+
226
+ # Explicit bind/port:
227
+ snazi serve --bind 100.64.0.10 --port 8787
228
+ ```
229
+
230
+ ### Run as a launchd service
231
+
232
+ ```bash
233
+ snazi serve --install-daemon # writes ~/Library/LaunchAgents/com.soup-nazi.snazi-serve.plist
234
+ launchctl load -w ~/Library/LaunchAgents/com.soup-nazi.snazi-serve.plist # start (RunAtLoad + KeepAlive)
235
+ launchctl unload -w ~/Library/LaunchAgents/com.soup-nazi.snazi-serve.plist # stop
236
+ ```
237
+
238
+ **Full Disk Access (required).** A launchd LaunchAgent runs in a context that
239
+ cannot read `~/Library/Messages/chat.db` unless the **node binary** has Full
240
+ Disk Access. `--install-daemon` prints the exact node path; add **that binary**
241
+ (not just Terminal) in **System Settings → Privacy & Security → Full Disk
242
+ Access**, then reload the agent. Without FDA, `/list-new` and `/read` return an
243
+ FDA error — the gate still holds; you just get no data.
244
+
245
+ ### Calling it
246
+
247
+ From the remote agent, either use the thin client subcommands:
248
+
249
+ ```bash
250
+ snazi remote-status
251
+ snazi remote-list-new --since 120
252
+ snazi remote-check "+15551234567" --channel imessage
253
+ snazi remote-read "+15551234567"
254
+ snazi remote-resolve "Dan" --channel imessage
255
+ snazi remote-label "+15551234567" --name "Dan" --channel imessage
256
+ ```
257
+
258
+ …or plain `curl` (bearer token in the header, never logged server-side):
259
+
260
+ ```bash
261
+ TOKEN=... # the serveToken
262
+ BASE=http://100.64.0.10:8787
263
+
264
+ curl -s "$BASE/health"
265
+ curl -s -H "Authorization: Bearer $TOKEN" "$BASE/list-new?channel=imessage&since=120"
266
+ curl -s -H "Authorization: Bearer $TOKEN" "$BASE/check?sender=%2B15551234567&channel=imessage"
267
+ curl -s -H "Authorization: Bearer $TOKEN" "$BASE/read?sender=%2B15551234567&channel=imessage"
268
+ curl -s -H "Authorization: Bearer $TOKEN" "$BASE/resolve?name=Dan&channel=imessage"
269
+ curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
270
+ -d '{"sender":"+15551234567","channel":"imessage","name":"Dan"}' "$BASE/label"
271
+ # Unknown/denied sender on /read → 403 { "error": "Sender not approved. No messages for you.", ... }
272
+ ```
273
+
274
+ ## Why this design
275
+
276
+ - **No prompt-injection surface from strangers.** The agent never sees content
277
+ from unknown senders, so a malicious text can't smuggle instructions to it.
278
+ - **No message storage anywhere.** Cheap and private. The server is a list, not
279
+ an inbox.
280
+ - **Extensible.** The same server list API works for other channels (e.g.
281
+ Gmail) — just add a channel and a new wrapper that calls `/api/senders/check`.
@@ -0,0 +1,46 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!--
3
+ snazi serve — launchd LaunchAgent.
4
+
5
+ This is a TEMPLATE. `snazi serve --install-daemon` substitutes the node
6
+ binary, CLI path, bind IP, port, and log dir, then writes the result to
7
+ ~/Library/LaunchAgents/com.soup-nazi.snazi-serve.plist.
8
+
9
+ IMPORTANT — Full Disk Access:
10
+ launchd starts this as YOU, but reading ~/Library/Messages/chat.db requires
11
+ Full Disk Access. Grant FDA to the node binary referenced below in
12
+ System Settings > Privacy & Security > Full Disk Access. Without it,
13
+ /list-new and /read return an FDA error (the gate still holds).
14
+ -->
15
+ <plist version="1.0">
16
+ <dict>
17
+ <key>Label</key>
18
+ <string>com.soup-nazi.snazi-serve</string>
19
+
20
+ <key>ProgramArguments</key>
21
+ <array>
22
+ <string>__NODE__</string>
23
+ <string>__CLI__</string>
24
+ <string>serve</string>
25
+ <string>--bind</string>
26
+ <string>__BIND__</string>
27
+ <string>--port</string>
28
+ <string>__PORT__</string>
29
+ </array>
30
+
31
+ <key>RunAtLoad</key>
32
+ <true/>
33
+
34
+ <key>KeepAlive</key>
35
+ <true/>
36
+
37
+ <key>ProcessType</key>
38
+ <string>Background</string>
39
+
40
+ <key>StandardOutPath</key>
41
+ <string>__LOGDIR__/snazi-serve.out.log</string>
42
+
43
+ <key>StandardErrorPath</key>
44
+ <string>__LOGDIR__/snazi-serve.err.log</string>
45
+ </dict>
46
+ </plist>
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeAddress = normalizeAddress;
4
+ /**
5
+ * Normalize a sender address so the SAME person resolves to the SAME row
6
+ * whether they were added via the dashboard, a /decide link, the CLI, or read
7
+ * straight out of chat.db.
8
+ *
9
+ * THIS FILE IS KEPT BYTE-FOR-BYTE IN SYNC with packages/web/src/lib/address.ts.
10
+ * Writes (dashboard/decide) and checks (CLI/serve) MUST key on identical
11
+ * strings, so if you change one copy, change the other to match.
12
+ *
13
+ * Rules (deterministic — applied identically on write AND on check):
14
+ * - Email (contains '@'): trimmed + lowercased.
15
+ * - Phone: reduced to E.164 ("+" + country code + national digits).
16
+ * * Anything starting with "+" is treated as E.164: "+1 (555) 123-4567"
17
+ * -> "+15551234567".
18
+ * * A bare national number is promoted to E.164 using a default country
19
+ * calling code (SNAZI_DEFAULT_COUNTRY_CODE, default "1" / NANP). chat.db
20
+ * hands us E.164 ("+1…"), but a human often types "(555) 123-4567";
21
+ * without promotion the two never match -> "approved but gate shut".
22
+ * A 10-digit number gets "+<cc>"; a number already prefixed with the CC
23
+ * (e.g. "1 555 123 4567") just gets the "+".
24
+ * * Numbers we can't confidently internationalize are left digit-only
25
+ * (no worse than before) rather than mis-prefixed.
26
+ *
27
+ * A literal "+" in a query string decodes to a space, so an E.164 number can
28
+ * arrive as " 15551234567"; we restore the leading "+" in that case.
29
+ *
30
+ * The default country code is operator-configurable because guessing it is
31
+ * locale-specific; international users should enter full "+" E.164 numbers.
32
+ */
33
+ function normalizeAddress(raw) {
34
+ const original = String(raw ?? '');
35
+ const s = original.trim();
36
+ if (s === '')
37
+ return '';
38
+ if (s.includes('@'))
39
+ return s.toLowerCase();
40
+ let plus = s.startsWith('+');
41
+ // A leading space (with no "+") means a literal "+" was decoded away.
42
+ if (!plus && /^\s\d/.test(original))
43
+ plus = true;
44
+ const digits = s.replace(/\D/g, '');
45
+ if (digits === '')
46
+ return s; // not phone-like; return trimmed original
47
+ if (plus)
48
+ return `+${digits}`;
49
+ // Bare national number → promote to E.164 with the default country code.
50
+ const cc = defaultCountryCode();
51
+ if (digits.length === 10)
52
+ return `+${cc}${digits}`;
53
+ if (cc && digits.length === 10 + cc.length && digits.startsWith(cc)) {
54
+ return `+${digits}`;
55
+ }
56
+ return digits; // can't confidently internationalize; leave digit-only
57
+ }
58
+ /** Default country calling code (digits only) used to promote bare numbers. */
59
+ function defaultCountryCode() {
60
+ return (process.env.SNAZI_DEFAULT_COUNTRY_CODE ?? '1').replace(/\D/g, '');
61
+ }
package/dist/api.js ADDED
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.checkSender = checkSender;
4
+ exports.listSenders = listSenders;
5
+ exports.buildLabelMap = buildLabelMap;
6
+ exports.setLabel = setLabel;
7
+ exports.ping = ping;
8
+ const address_1 = require("./address");
9
+ // Force every request to bypass any HTTP/runtime cache. Typed loosely because
10
+ // some @types/node versions omit `cache` from RequestInit.
11
+ const NO_STORE = { cache: 'no-store' };
12
+ /**
13
+ * Ask the server whether a sender is approved/denied/unknown.
14
+ * This is the gate: callers MUST check before revealing any content.
15
+ */
16
+ async function checkSender(cfg, channel, address) {
17
+ const url = `${cfg.apiUrl}/api/senders/check?channel=${encodeURIComponent(channel)}&address=${encodeURIComponent(address)}`;
18
+ const res = await fetch(url, {
19
+ headers: { 'x-api-key': cfg.apiKey },
20
+ ...NO_STORE,
21
+ });
22
+ if (!res.ok) {
23
+ throw new Error(`check failed: HTTP ${res.status} ${await res.text()}`);
24
+ }
25
+ const json = (await res.json());
26
+ return json.status ?? 'unknown';
27
+ }
28
+ /**
29
+ * Fetch the FULL sender list (address + label + status) for a channel using the
30
+ * READ key. Used by `snazi serve` to attach `label` to list-new/check results
31
+ * and to power /resolve. Reveals only the same address+label+status surface the
32
+ * dashboard read key already exposes — never message content.
33
+ */
34
+ async function listSenders(cfg, channel) {
35
+ const url = `${cfg.apiUrl}/api/senders?channel=${encodeURIComponent(channel)}`;
36
+ const res = await fetch(url, {
37
+ headers: { 'x-api-key': cfg.apiKey },
38
+ ...NO_STORE,
39
+ });
40
+ if (!res.ok) {
41
+ throw new Error(`list senders failed: HTTP ${res.status} ${await res.text()}`);
42
+ }
43
+ const json = (await res.json());
44
+ return Array.isArray(json.senders) ? json.senders : [];
45
+ }
46
+ /**
47
+ * Fetch the full sender list once and build an address→label map.
48
+ * Best-effort: on failure returns an empty map so labels show as null rather
49
+ * than breaking the (security-critical) status path.
50
+ */
51
+ async function buildLabelMap(cfg, channel) {
52
+ const map = new Map();
53
+ try {
54
+ const senders = await listSenders(cfg, channel);
55
+ for (const s of senders) {
56
+ map.set((0, address_1.normalizeAddress)(s.sender_address), s.label ?? null);
57
+ }
58
+ }
59
+ catch {
60
+ // Swallow: labels are non-critical display metadata.
61
+ }
62
+ return map;
63
+ }
64
+ /**
65
+ * Set (overwrite) a sender's display label using the READ key.
66
+ *
67
+ * This calls the web PATCH /api/senders/label endpoint, which performs an
68
+ * UPDATE only — it can NEVER insert a new row or change `status`. It is the
69
+ * single non-privileged write the read path can make: a label is display
70
+ * metadata and cannot open the gate. If the sender is not already on the list,
71
+ * the web endpoint returns 404 and this throws.
72
+ */
73
+ async function setLabel(cfg, channel, address, label) {
74
+ const res = await fetch(`${cfg.apiUrl}/api/senders/label`, {
75
+ method: 'PATCH',
76
+ headers: { 'content-type': 'application/json', 'x-api-key': cfg.apiKey },
77
+ body: JSON.stringify({
78
+ channel_id: channel,
79
+ sender_address: address,
80
+ label,
81
+ }),
82
+ ...NO_STORE,
83
+ });
84
+ if (!res.ok) {
85
+ throw new Error(`label failed: HTTP ${res.status} ${await res.text()}`);
86
+ }
87
+ return res.json();
88
+ }
89
+ /** Lightweight connectivity probe for `status`. */
90
+ async function ping(cfg) {
91
+ try {
92
+ const res = await fetch(`${cfg.apiUrl}/api/senders?channel=imessage`, {
93
+ headers: { 'x-api-key': cfg.apiKey },
94
+ ...NO_STORE,
95
+ });
96
+ return res.ok;
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
package/dist/cache.js ADDED
@@ -0,0 +1,173 @@
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.cacheTtlMs = cacheTtlMs;
37
+ exports.getCachedStatus = getCachedStatus;
38
+ exports.setCachedStatus = setCachedStatus;
39
+ exports.clearCache = clearCache;
40
+ exports.checkSenderCached = checkSenderCached;
41
+ /**
42
+ * Short-lived, on-disk cache for sender approval STATUS (never message text).
43
+ *
44
+ * Why disk and not memory: the CLI runs on demand and exits, so an in-memory
45
+ * cache would be empty on every invocation. A small JSON file in ~/.snazi lets
46
+ * repeated `read`/`check`/`list-new` calls (and a long-running `serve`) reuse a
47
+ * recent decision instead of hitting the API every time.
48
+ *
49
+ * What it caches: only DECIDED states (approved/denied), which reflect an
50
+ * explicit human decision that rarely flips. 'unknown' is NEVER cached, so a
51
+ * brand-new approval takes effect on the very next call.
52
+ *
53
+ * The trade-off (deliberate): a REVOCATION can take up to `ttl` to be seen by
54
+ * the agent. That is the whole point of the cache — approvals are sticky, and a
55
+ * few minutes of staleness on the rare "I just blocked them" case is acceptable.
56
+ * Use `fresh: true` (CLI `--fresh`) or `snazi cache clear` to force a live check
57
+ * the instant you revoke someone.
58
+ *
59
+ * Safe degradation: any cache read/write error falls back to a live server check.
60
+ * The cache can only skip a round-trip — it never fabricates an approval.
61
+ */
62
+ const fs = __importStar(require("fs"));
63
+ const os = __importStar(require("os"));
64
+ const path = __importStar(require("path"));
65
+ const api_1 = require("./api");
66
+ const address_1 = require("./address");
67
+ const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
68
+ /** Cache file path. SNAZI_CACHE_FILE overrides it (used by tests). */
69
+ function cacheFilePath() {
70
+ const override = process.env.SNAZI_CACHE_FILE;
71
+ if (override && override.trim() !== '')
72
+ return override;
73
+ return path.join(os.homedir(), '.snazi', 'check-cache.json');
74
+ }
75
+ /**
76
+ * Resolve the cache TTL in ms: env SNAZI_CHECK_CACHE_TTL_MS wins, then
77
+ * config.checkCacheTtlMs, then a 5-minute default. 0 (or negative) disables
78
+ * caching entirely (every check goes live).
79
+ */
80
+ function cacheTtlMs(cfg) {
81
+ const env = process.env.SNAZI_CHECK_CACHE_TTL_MS;
82
+ if (env !== undefined && env !== '') {
83
+ const n = Number(env);
84
+ if (Number.isFinite(n))
85
+ return Math.max(0, Math.floor(n));
86
+ }
87
+ if (typeof cfg.checkCacheTtlMs === 'number' && Number.isFinite(cfg.checkCacheTtlMs)) {
88
+ return Math.max(0, Math.floor(cfg.checkCacheTtlMs));
89
+ }
90
+ return DEFAULT_TTL_MS;
91
+ }
92
+ /** Cache key: normalized so "(555) 123-4567" and "+15551234567" share an entry. */
93
+ function cacheKey(channel, address) {
94
+ return `${channel}|${(0, address_1.normalizeAddress)(address)}`;
95
+ }
96
+ function readCache() {
97
+ try {
98
+ const raw = fs.readFileSync(cacheFilePath(), 'utf8');
99
+ const parsed = JSON.parse(raw);
100
+ return parsed && typeof parsed === 'object' ? parsed : {};
101
+ }
102
+ catch {
103
+ return {}; // missing/corrupt/unreadable -> treat as empty
104
+ }
105
+ }
106
+ function writeCache(data) {
107
+ try {
108
+ const file = cacheFilePath();
109
+ const dir = path.dirname(file);
110
+ if (!fs.existsSync(dir))
111
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
112
+ // Atomic: write a temp file then rename so readers never see a partial file.
113
+ const tmp = `${file}.${process.pid}.tmp`;
114
+ fs.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
115
+ fs.renameSync(tmp, file);
116
+ }
117
+ catch {
118
+ // Best-effort: a write failure must never break the gate.
119
+ }
120
+ }
121
+ /** Cached status if present AND unexpired, else undefined. */
122
+ function getCachedStatus(channel, address) {
123
+ const entry = readCache()[cacheKey(channel, address)];
124
+ if (!entry)
125
+ return undefined;
126
+ if (Date.now() >= entry.expiresAt)
127
+ return undefined;
128
+ return entry.status;
129
+ }
130
+ /** Store a status for `ttlMs`. Prunes expired entries. No-op when ttlMs <= 0. */
131
+ function setCachedStatus(channel, address, status, ttlMs) {
132
+ if (ttlMs <= 0)
133
+ return;
134
+ const data = readCache();
135
+ const now = Date.now();
136
+ for (const k of Object.keys(data)) {
137
+ if (now >= data[k].expiresAt)
138
+ delete data[k];
139
+ }
140
+ data[cacheKey(channel, address)] = { status, expiresAt: now + ttlMs };
141
+ writeCache(data);
142
+ }
143
+ /** Wipe the whole cache (used by `snazi cache clear`). */
144
+ function clearCache() {
145
+ try {
146
+ const file = cacheFilePath();
147
+ if (fs.existsSync(file))
148
+ fs.unlinkSync(file);
149
+ }
150
+ catch {
151
+ // Nothing to clear / not removable -> ignore.
152
+ }
153
+ }
154
+ /**
155
+ * Approval check with the short-lived cache in front of the live server gate.
156
+ *
157
+ * Reads a cached decided status when fresh; otherwise checks the server and
158
+ * caches approved/denied results. `unknown` is never cached. Pass `fresh: true`
159
+ * to bypass the cache for this call (and refresh it from the server).
160
+ */
161
+ async function checkSenderCached(cfg, channel, address, opts = {}) {
162
+ const ttl = cacheTtlMs(cfg);
163
+ if (!opts.fresh && ttl > 0) {
164
+ const hit = getCachedStatus(channel, address);
165
+ if (hit)
166
+ return hit;
167
+ }
168
+ const status = await (0, api_1.checkSender)(cfg, channel, address);
169
+ if (status === 'approved' || status === 'denied') {
170
+ setCachedStatus(channel, address, status, ttl);
171
+ }
172
+ return status;
173
+ }