@chipallen2/snazi 0.1.0 → 0.2.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 +57 -31
- package/dist/address.js +30 -0
- package/dist/channels/imessage.js +11 -0
- package/dist/channels/index.js +27 -0
- package/dist/cli.js +86 -4
- package/dist/client.js +6 -0
- package/dist/contacts.js +337 -0
- package/dist/doctor.js +6 -0
- package/dist/imessage-send.js +92 -0
- package/dist/server.js +105 -9
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -40,32 +40,18 @@ cannot approve a sender or reveal content for a non-approved one.
|
|
|
40
40
|
|
|
41
41
|
## Install
|
|
42
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
43
|
macOS, Windows, or Linux (Node 18+):
|
|
49
44
|
|
|
50
45
|
```bash
|
|
51
|
-
|
|
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
|
|
46
|
+
npm install -g @chipallen2/snazi
|
|
65
47
|
snazi init # writes ~/.snazi/config.json (deployment URL + READ token)
|
|
66
48
|
snazi doctor # checks Node, config, connectivity, and channel access
|
|
67
49
|
```
|
|
68
50
|
|
|
51
|
+
> The npm package is **scoped** (`@chipallen2/snazi`) but the command you run is
|
|
52
|
+
> just `snazi`. (The bare name `snazi` was too close to an existing npm package
|
|
53
|
+
> to publish unscoped.)
|
|
54
|
+
|
|
69
55
|
`snazi init` asks for two things: your **deployment URL** (default
|
|
70
56
|
`https://snazi.dev`) and your **account READ token** (sign up at `/signup`, then
|
|
71
57
|
copy it from the `/account` page). There is **no admin key** — approvals happen
|
|
@@ -79,10 +65,13 @@ snazi init --api-url https://snazi.dev --token <READ_TOKEN> --yes
|
|
|
79
65
|
your terminal (or the `node` binary) in System Settings → Privacy & Security →
|
|
80
66
|
Full Disk Access. `snazi doctor` flags it if missing.
|
|
81
67
|
|
|
82
|
-
###
|
|
68
|
+
### From source (contributors)
|
|
83
69
|
|
|
84
70
|
```bash
|
|
85
|
-
|
|
71
|
+
git clone https://github.com/chipallen2/snazi.git
|
|
72
|
+
cd snazi/packages/snazi
|
|
73
|
+
./install.sh # npm install + build, links `snazi` onto your PATH
|
|
74
|
+
# Windows (no bash): npm install && npm run build && npm link
|
|
86
75
|
snazi init && snazi doctor
|
|
87
76
|
```
|
|
88
77
|
|
|
@@ -92,9 +81,10 @@ snazi init && snazi doctor
|
|
|
92
81
|
| --- | --- |
|
|
93
82
|
| `snazi init [--api-url <url>] [--token <tok>] [--channel <id>]` | Create or update `~/.snazi/config.json`. |
|
|
94
83
|
| `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,
|
|
84
|
+
| `snazi list-new [--channel <id>] [--since <min>] [--fresh]` | Distinct inbound senders, counts, timestamps, approval status, display label, and local Contacts `contact_name`. **No text.** Default window 60 min. |
|
|
96
85
|
| `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
|
|
86
|
+
| `snazi send <recipient> --text <message> [--channel <id>]` | Send a message. **Never gated** — you can always send to anyone. |
|
|
87
|
+
| `snazi check <sender> --channel <id> [--fresh]` | One sender's approval status, display label, and local Contacts `contact_name` (`approved`/`denied`/`unknown`). |
|
|
98
88
|
| `snazi channels list` | Configured channels plus adapter availability on this machine. |
|
|
99
89
|
| `snazi channels add <channel>` | Add a channel (e.g. `snazi channels add imessage`). |
|
|
100
90
|
| `snazi cache clear` | Drop cached approval statuses (force fresh checks after a revocation). |
|
|
@@ -105,6 +95,7 @@ snazi init && snazi doctor
|
|
|
105
95
|
| `snazi remote-list-new [--channel <id>] [--since <min>]` | WHO messaged on the remote host + status + label. |
|
|
106
96
|
| `snazi remote-check <sender> --channel <id>` | One sender's status and label, via remote serve. |
|
|
107
97
|
| `snazi remote-read <sender> [--channel <id>] [--since <min>]` | Message text via remote serve — only if approved. |
|
|
98
|
+
| `snazi remote-send <recipient> --text <message> [--channel <id>]` | Send a message via remote serve — never gated. |
|
|
108
99
|
| `snazi remote-resolve [<name>] --channel <id>` | Resolve a name → sender address(es). Empty name = full address book. |
|
|
109
100
|
| `snazi remote-label <sender> --name <name> --channel <id>` | Set a sender's display label (UPDATE-only; cannot open the gate). |
|
|
110
101
|
|
|
@@ -119,7 +110,8 @@ right after you revoke someone.
|
|
|
119
110
|
snazi list-new --since 180
|
|
120
111
|
# [
|
|
121
112
|
# { "sender": "+15551234567", "message_count": 3,
|
|
122
|
-
# "latest_at": "2026-06-23T22:10:04.000Z", "status": "unknown",
|
|
113
|
+
# "latest_at": "2026-06-23T22:10:04.000Z", "status": "unknown",
|
|
114
|
+
# "label": null, "contact_name": "Jenny Tutone" }
|
|
123
115
|
# ]
|
|
124
116
|
|
|
125
117
|
snazi read "+15551234567"
|
|
@@ -134,6 +126,9 @@ curl -s -H "x-api-key: $READ_TOKEN" \
|
|
|
134
126
|
snazi read "+15551234567"
|
|
135
127
|
# { "sender": "+15551234567", "status": "approved", "since_minutes": 60,
|
|
136
128
|
# "messages": [ { "date": "...", "text": "hey are we still on for lunch?" } ] }
|
|
129
|
+
|
|
130
|
+
snazi send "+15551234567" --text "On my way!"
|
|
131
|
+
# { "ok": true, "channel": "imessage", "recipient": "+15551234567" }
|
|
137
132
|
```
|
|
138
133
|
|
|
139
134
|
## Serve mode — least-privilege HTTP gate over a tailnet
|
|
@@ -154,6 +149,7 @@ privilege.
|
|
|
154
149
|
│ /read (bearer) │
|
|
155
150
|
│ /resolve (bearer) │
|
|
156
151
|
│ POST /label (bearer) │
|
|
152
|
+
│ POST /send (bearer) │
|
|
157
153
|
│ │ │
|
|
158
154
|
│ ▼ same gate (api) │
|
|
159
155
|
│ approved? → text │
|
|
@@ -168,16 +164,42 @@ privilege.
|
|
|
168
164
|
| Method + path | Auth | Returns |
|
|
169
165
|
| --- | --- | --- |
|
|
170
166
|
| `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
|
-
| `
|
|
167
|
+
| `GET /list-new?channel=imessage&since=<min>` | bearer | `{ channel, since_minutes, senders: [{ sender, message_count, latest_at, status, label, contact_name }] }`. On check failure: `status` is `unknown` and an `error` field describes the failure. **Never message text.** |
|
|
168
|
+
| `GET /check?sender=<addr>&channel=imessage` | bearer | `{ channel, sender, status, label, contact_name }`. On check failure: HTTP 502 with `{ error }`. |
|
|
169
|
+
| `GET /read?sender=<addr>&channel=imessage&since=<min>` | bearer | `{ sender, channel, status, since_minutes, contact_name, messages }` **only if approved**; otherwise `403 { error: "Sender not approved. No messages for you.", status }`. On check failure: HTTP 502 with `{ error }`. |
|
|
170
|
+
| `POST /send` body `{ recipient, channel, text }` | bearer | Send an outbound message. **Never gated** — you can always send to anyone. Returns `{ ok: true, channel, recipient }` on success. |
|
|
171
|
+
| `GET /resolve?name=<q>&channel=imessage` | bearer | `{ channel, query, matches: [{ sender_address, label, status, contact_name }] }`. Empty/omitted `name` returns every labelled sender. **Never message text.** |
|
|
175
172
|
| `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
173
|
|
|
177
174
|
There is **no `approve`/`deny` over HTTP**. Approvals stay dashboard/`/decide`-only.
|
|
178
175
|
`POST /label` is the only write — label metadata only. Unknown path → `404`. Bad
|
|
179
176
|
params → `400`. Unsupported methods → `405`.
|
|
180
177
|
|
|
178
|
+
### `contact_name` — local macOS Contacts enrichment (display only)
|
|
179
|
+
|
|
180
|
+
`/list-new`, `/check`, `/resolve` (and the `200` body of `/read`) include a
|
|
181
|
+
`contact_name` for each sender: the matching name from the serve host's **local
|
|
182
|
+
macOS Contacts** (AddressBook), looked up by phone/email. It is attached for
|
|
183
|
+
**every** sender **regardless of approval status**, so you can see *who* an
|
|
184
|
+
`unknown`/`denied` caller is without reading their messages.
|
|
185
|
+
|
|
186
|
+
- **Display-only, never a gate.** `contact_name` **never** affects `status`,
|
|
187
|
+
approval, or the read gate. Reading is still allowed **solely** when
|
|
188
|
+
`status === 'approved'` — a known contact name does **not** open the gate.
|
|
189
|
+
- **Separate from `label`.** `label` = the name you set on your snazi.dev
|
|
190
|
+
account (privileged). `contact_name` = read locally from macOS Contacts. Both
|
|
191
|
+
fields are kept separate in the JSON; `null` when there's no match.
|
|
192
|
+
- **Untrusted text.** A contact name is stripped of control characters and
|
|
193
|
+
length-capped (≤64) before it's ever returned — it can't carry a
|
|
194
|
+
terminal/log-injection payload.
|
|
195
|
+
- **Degrades silently.** If Contacts is unreadable (no permission, non-macOS,
|
|
196
|
+
native module missing), `contact_name` is simply `null` and nothing breaks.
|
|
197
|
+
|
|
198
|
+
**Contacts access on the serve host.** Reading the AddressBook DB needs the node
|
|
199
|
+
binary to have **Contacts** access (or **Full Disk Access**, which already
|
|
200
|
+
covers the AddressBook database). Full Disk Access is the simplest option since
|
|
201
|
+
you already grant it for iMessage; without it `contact_name` just stays `null`.
|
|
202
|
+
|
|
181
203
|
### Security model
|
|
182
204
|
|
|
183
205
|
- **Tailnet-only.** Default bind is this host's Tailscale IP (`100.64.0.0/10`) if
|
|
@@ -190,9 +212,10 @@ params → `400`. Unsupported methods → `405`.
|
|
|
190
212
|
- **Read-only surface.** No shell, no arbitrary file reads, no path traversal —
|
|
191
213
|
only the same channel adapters the CLI uses. Params are validated
|
|
192
214
|
(`channel`/`sender` charset-checked, `since` clamped to ≤ 7 days).
|
|
193
|
-
- **Same gate.** `/read` calls the server list API (`api.ts`) *before*
|
|
194
|
-
any text — identical to `snazi read`. The gate is the product; it is
|
|
195
|
-
bypassed.
|
|
215
|
+
- **Same gate for reading.** `/read` calls the server list API (`api.ts`) *before*
|
|
216
|
+
touching any text — identical to `snazi read`. The gate is the product; it is
|
|
217
|
+
not bypassed. **Sending is never gated** — `/send` and `snazi send` work for
|
|
218
|
+
any recipient.
|
|
196
219
|
- **No storage.** Content is read live from `chat.db` and returned in the
|
|
197
220
|
response only. Nothing is persisted on either side.
|
|
198
221
|
|
|
@@ -251,6 +274,7 @@ snazi remote-status
|
|
|
251
274
|
snazi remote-list-new --since 120
|
|
252
275
|
snazi remote-check "+15551234567" --channel imessage
|
|
253
276
|
snazi remote-read "+15551234567"
|
|
277
|
+
snazi remote-send "+15551234567" --text "On my way!"
|
|
254
278
|
snazi remote-resolve "Dan" --channel imessage
|
|
255
279
|
snazi remote-label "+15551234567" --name "Dan" --channel imessage
|
|
256
280
|
```
|
|
@@ -268,6 +292,8 @@ curl -s -H "Authorization: Bearer $TOKEN" "$BASE/read?sender=%2B15551234567&chan
|
|
|
268
292
|
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/resolve?name=Dan&channel=imessage"
|
|
269
293
|
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
|
270
294
|
-d '{"sender":"+15551234567","channel":"imessage","name":"Dan"}' "$BASE/label"
|
|
295
|
+
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
|
296
|
+
-d '{"recipient":"+15551234567","channel":"imessage","text":"On my way!"}' "$BASE/send"
|
|
271
297
|
# Unknown/denied sender on /read → 403 { "error": "Sender not approved. No messages for you.", ... }
|
|
272
298
|
```
|
|
273
299
|
|
package/dist/address.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.normalizeAddress = normalizeAddress;
|
|
4
|
+
exports.validateRecipientAddress = validateRecipientAddress;
|
|
4
5
|
/**
|
|
5
6
|
* Normalize a sender address so the SAME person resolves to the SAME row
|
|
6
7
|
* whether they were added via the dashboard, a /decide link, the CLI, or read
|
|
@@ -59,3 +60,32 @@ function normalizeAddress(raw) {
|
|
|
59
60
|
function defaultCountryCode() {
|
|
60
61
|
return (process.env.SNAZI_DEFAULT_COUNTRY_CODE ?? '1').replace(/\D/g, '');
|
|
61
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Validate and normalize a recipient address for outbound send.
|
|
65
|
+
* Phone numbers must normalize to E.164; emails must look like emails.
|
|
66
|
+
* Throws with a clear message on invalid input.
|
|
67
|
+
*/
|
|
68
|
+
function validateRecipientAddress(raw) {
|
|
69
|
+
const original = String(raw ?? '').trim();
|
|
70
|
+
if (!original) {
|
|
71
|
+
throw new Error('Missing recipient.');
|
|
72
|
+
}
|
|
73
|
+
const normalized = normalizeAddress(raw);
|
|
74
|
+
if (!normalized) {
|
|
75
|
+
throw new Error('Missing recipient.');
|
|
76
|
+
}
|
|
77
|
+
if (normalized.includes('@')) {
|
|
78
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) {
|
|
79
|
+
throw new Error('Invalid email address.');
|
|
80
|
+
}
|
|
81
|
+
return normalized;
|
|
82
|
+
}
|
|
83
|
+
if (!normalized.startsWith('+')) {
|
|
84
|
+
throw new Error('Invalid recipient. Use a phone number (+15551234567) or email address.');
|
|
85
|
+
}
|
|
86
|
+
// E.164: + followed by 7–15 digits (country code never starts with 0).
|
|
87
|
+
if (!/^\+[1-9]\d{6,14}$/.test(normalized)) {
|
|
88
|
+
throw new Error('Invalid phone number. Use E.164 (+15551234567) or a 10-digit national number.');
|
|
89
|
+
}
|
|
90
|
+
return normalized;
|
|
91
|
+
}
|
|
@@ -44,4 +44,15 @@ exports.imessageAdapter = {
|
|
|
44
44
|
readMessagesFrom(sender, sinceMinutes) {
|
|
45
45
|
return chatdb().readMessagesFrom(sender, sinceMinutes);
|
|
46
46
|
},
|
|
47
|
+
sendAvailability() {
|
|
48
|
+
// Lazy require keeps non-macOS installs free of send-side deps at import time.
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
50
|
+
const send = require('../imessage-send');
|
|
51
|
+
return send.probeSendAvailability();
|
|
52
|
+
},
|
|
53
|
+
sendMessage(recipient, text) {
|
|
54
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
55
|
+
const send = require('../imessage-send');
|
|
56
|
+
send.sendIMessage(recipient, text);
|
|
57
|
+
},
|
|
47
58
|
};
|
package/dist/channels/index.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.getAdapter = getAdapter;
|
|
4
4
|
exports.listAdapters = listAdapters;
|
|
5
5
|
exports.resolveReadableAdapter = resolveReadableAdapter;
|
|
6
|
+
exports.resolveSendableAdapter = resolveSendableAdapter;
|
|
6
7
|
const imessage_1 = require("./imessage");
|
|
7
8
|
const ADAPTERS = new Map([imessage_1.imessageAdapter].map((a) => [a.id, a]));
|
|
8
9
|
/** Look up a registered adapter by channel id, or undefined. */
|
|
@@ -37,3 +38,29 @@ function resolveReadableAdapter(channel) {
|
|
|
37
38
|
}
|
|
38
39
|
return { adapter };
|
|
39
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolve a channel id to an adapter that can SEND on this host.
|
|
43
|
+
* Never throws. Sending is never gated by the approval list.
|
|
44
|
+
*/
|
|
45
|
+
function resolveSendableAdapter(channel) {
|
|
46
|
+
const adapter = getAdapter(channel);
|
|
47
|
+
if (!adapter) {
|
|
48
|
+
const known = listAdapters()
|
|
49
|
+
.map((a) => a.id)
|
|
50
|
+
.join(', ');
|
|
51
|
+
return {
|
|
52
|
+
error: `Unknown channel '${channel}'. Known channels: ${known || '(none)'}.`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (!adapter.sendMessage) {
|
|
56
|
+
return { error: `Channel '${channel}' does not support sending.` };
|
|
57
|
+
}
|
|
58
|
+
const availability = adapter.sendAvailability?.() ?? { available: true };
|
|
59
|
+
if (!availability.available) {
|
|
60
|
+
const detail = availability.detail ? ` ${availability.detail}` : '';
|
|
61
|
+
return {
|
|
62
|
+
error: `Channel '${channel}' cannot send on this machine: ${availability.reason ?? 'unavailable'}.${detail}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return { adapter };
|
|
66
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
11
11
|
* - doctor : diagnose Node, config, connectivity, and per-channel access.
|
|
12
12
|
* - list-new : reveals WHO sent recent messages + their approval status. Never WHAT.
|
|
13
13
|
* - read : reveals message TEXT for ONE sender, but ONLY if approved by the server.
|
|
14
|
+
* - send : sends a message to ANY recipient (never gated).
|
|
14
15
|
* - check : prints a single sender's approval status.
|
|
15
16
|
* - channels : list/add configured channels + show adapter availability here.
|
|
16
17
|
* - status : prints config + platform + server connectivity.
|
|
@@ -33,6 +34,7 @@ const address_1 = require("./address");
|
|
|
33
34
|
const api_1 = require("./api");
|
|
34
35
|
const cache_1 = require("./cache");
|
|
35
36
|
const channels_1 = require("./channels");
|
|
37
|
+
const contacts_1 = require("./contacts");
|
|
36
38
|
const server_1 = require("./server");
|
|
37
39
|
const client_1 = require("./client");
|
|
38
40
|
const daemon_1 = require("./daemon");
|
|
@@ -84,6 +86,8 @@ async function cmdListNew(args) {
|
|
|
84
86
|
}
|
|
85
87
|
const senders = adapter.listInboundSenders(since);
|
|
86
88
|
const labels = await (0, api_1.buildLabelMap)(cfg, channel);
|
|
89
|
+
// Local macOS Contacts enrichment (display-only; best-effort, empty on fail).
|
|
90
|
+
const contacts = (0, contacts_1.buildContactIndex)();
|
|
87
91
|
const results = [];
|
|
88
92
|
for (const s of senders) {
|
|
89
93
|
let status = 'unknown';
|
|
@@ -99,7 +103,10 @@ async function cmdListNew(args) {
|
|
|
99
103
|
message_count: s.message_count,
|
|
100
104
|
latest_at: s.latest_at,
|
|
101
105
|
status,
|
|
106
|
+
// `label` = snazi.dev account name; `contact_name` = local Contacts name.
|
|
107
|
+
// Both kept as SEPARATE fields; contact_name never affects the gate.
|
|
102
108
|
label: labels.get((0, address_1.normalizeAddress)(s.sender)) ?? null,
|
|
109
|
+
contact_name: contacts.get(s.sender),
|
|
103
110
|
};
|
|
104
111
|
if (checkError)
|
|
105
112
|
entry.error = checkError;
|
|
@@ -157,7 +164,44 @@ async function cmdCheck(args) {
|
|
|
157
164
|
const status = await (0, cache_1.checkSenderCached)(cfg, channel, target, { fresh });
|
|
158
165
|
const labels = await (0, api_1.buildLabelMap)(cfg, channel);
|
|
159
166
|
const label = labels.get(target) ?? null;
|
|
160
|
-
|
|
167
|
+
// Display-only Contacts name; separate field, never gates reading.
|
|
168
|
+
const contact_name = (0, contacts_1.buildContactIndex)().get(target);
|
|
169
|
+
out({ channel, sender: target, status, label, contact_name });
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
out({ error: String(e instanceof Error ? e.message : e) });
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async function cmdSend(args) {
|
|
178
|
+
const positionals = args.filter((a) => !a.startsWith('--'));
|
|
179
|
+
const rawRecipient = positionals[0];
|
|
180
|
+
const text = flag(args, '--text');
|
|
181
|
+
if (!rawRecipient || text == null) {
|
|
182
|
+
out({
|
|
183
|
+
error: 'Usage: snazi send <recipient> --text <message> [--channel <id>]',
|
|
184
|
+
});
|
|
185
|
+
return 2;
|
|
186
|
+
}
|
|
187
|
+
const channel = flag(args, '--channel') ?? DEFAULT_CHANNEL;
|
|
188
|
+
let target;
|
|
189
|
+
try {
|
|
190
|
+
target = (0, address_1.validateRecipientAddress)(rawRecipient);
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
out({ error: String(e instanceof Error ? e.message : e) });
|
|
194
|
+
return 2;
|
|
195
|
+
}
|
|
196
|
+
// Sending is NEVER gated — the soup nazi only blocks reading.
|
|
197
|
+
const { adapter, error } = (0, channels_1.resolveSendableAdapter)(channel);
|
|
198
|
+
if (!adapter?.sendMessage) {
|
|
199
|
+
out({ error });
|
|
200
|
+
return 1;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
adapter.sendMessage(target, text);
|
|
204
|
+
out({ ok: true, channel, recipient: target });
|
|
161
205
|
return 0;
|
|
162
206
|
}
|
|
163
207
|
catch (e) {
|
|
@@ -378,6 +422,36 @@ async function cmdRemoteLabel(args) {
|
|
|
378
422
|
return 1;
|
|
379
423
|
}
|
|
380
424
|
}
|
|
425
|
+
async function cmdRemoteSend(args) {
|
|
426
|
+
const positionals = args.filter((a) => !a.startsWith('--'));
|
|
427
|
+
const rawRecipient = positionals[0];
|
|
428
|
+
const text = flag(args, '--text');
|
|
429
|
+
if (!rawRecipient || text == null) {
|
|
430
|
+
out({
|
|
431
|
+
error: 'Usage: snazi remote-send <recipient> --text <message> [--channel <id>]',
|
|
432
|
+
});
|
|
433
|
+
return 2;
|
|
434
|
+
}
|
|
435
|
+
const channel = flag(args, '--channel') ?? DEFAULT_CHANNEL;
|
|
436
|
+
let target;
|
|
437
|
+
try {
|
|
438
|
+
target = (0, address_1.validateRecipientAddress)(rawRecipient);
|
|
439
|
+
}
|
|
440
|
+
catch (e) {
|
|
441
|
+
out({ error: String(e instanceof Error ? e.message : e) });
|
|
442
|
+
return 2;
|
|
443
|
+
}
|
|
444
|
+
const cfg = (0, config_1.loadConfig)();
|
|
445
|
+
try {
|
|
446
|
+
const { status, json } = await (0, client_1.remoteSend)(cfg, target, channel, text);
|
|
447
|
+
out(json);
|
|
448
|
+
return status >= 200 && status < 300 ? 0 : 1;
|
|
449
|
+
}
|
|
450
|
+
catch (e) {
|
|
451
|
+
out({ error: String(e instanceof Error ? e.message : e) });
|
|
452
|
+
return 1;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
381
455
|
async function cmdRemoteStatus() {
|
|
382
456
|
const cfg = (0, config_1.loadConfig)();
|
|
383
457
|
try {
|
|
@@ -415,6 +489,7 @@ Setup:
|
|
|
415
489
|
Usage:
|
|
416
490
|
snazi list-new [--channel <id>] [--since <minutes>] Show WHO messaged + approval status (default 60m)
|
|
417
491
|
snazi read <sender> [--channel <id>] [--since <min>] Show message text — only if sender is approved
|
|
492
|
+
snazi send <recipient> --text <message> [--channel <id>] Send a message (never gated)
|
|
418
493
|
snazi check <sender> --channel <id> Print one sender's approval status
|
|
419
494
|
snazi channels list List configured channels + adapter availability here
|
|
420
495
|
snazi channels add <channel> Add a channel (e.g. imessage)
|
|
@@ -430,7 +505,7 @@ Approvals are READ-ONLY here: approve/deny a sender in the web dashboard or via
|
|
|
430
505
|
a signed /decide link. The config token is a per-account READ token.
|
|
431
506
|
|
|
432
507
|
Serve mode (least-privilege HTTP gate for a remote agent over a tailnet):
|
|
433
|
-
snazi serve [--bind <ip>] [--port <n>] Start
|
|
508
|
+
snazi serve [--bind <ip>] [--port <n>] Start HTTP gate (/health,/list-new,/check,/read,POST /send)
|
|
434
509
|
snazi serve --install-daemon [--bind <ip>] [--port <n>] Install the launchd LaunchAgent (RunAtLoad/KeepAlive)
|
|
435
510
|
|
|
436
511
|
Remote client (the trusted agent side, calls a remote 'snazi serve'):
|
|
@@ -438,12 +513,13 @@ Remote client (the trusted agent side, calls a remote 'snazi serve'):
|
|
|
438
513
|
snazi remote-list-new [--channel <id>] [--since <min>] WHO messaged on the remote host + status
|
|
439
514
|
snazi remote-check <sender> --channel <id> One sender's status (remote)
|
|
440
515
|
snazi remote-read <sender> [--channel <id>] [--since <min>] Message text (remote) — only if approved
|
|
516
|
+
snazi remote-send <recipient> --text <msg> [--channel <id>] Send a message (remote; never gated)
|
|
441
517
|
snazi remote-resolve [<name>] [--channel <id>] Resolve a name → sender address(es) (empty = address book)
|
|
442
518
|
snazi remote-label <sender> --name <name> [--channel <id>] Set a sender's display name (label only; cannot open the gate)
|
|
443
519
|
|
|
444
520
|
The server manages an approve/deny list only. It stores no messages.
|
|
445
|
-
|
|
446
|
-
|
|
521
|
+
Reading is gated; sending is not. serve binds the tailnet IP (100.x) or
|
|
522
|
+
127.0.0.1 — never 0.0.0.0.`);
|
|
447
523
|
}
|
|
448
524
|
async function main() {
|
|
449
525
|
const argv = process.argv.slice(2);
|
|
@@ -463,6 +539,9 @@ async function main() {
|
|
|
463
539
|
case 'read':
|
|
464
540
|
code = await cmdRead(rest);
|
|
465
541
|
break;
|
|
542
|
+
case 'send':
|
|
543
|
+
code = await cmdSend(rest);
|
|
544
|
+
break;
|
|
466
545
|
case 'check':
|
|
467
546
|
code = await cmdCheck(rest);
|
|
468
547
|
break;
|
|
@@ -484,6 +563,9 @@ async function main() {
|
|
|
484
563
|
case 'remote-read':
|
|
485
564
|
code = await cmdRemoteRead(rest);
|
|
486
565
|
break;
|
|
566
|
+
case 'remote-send':
|
|
567
|
+
code = await cmdRemoteSend(rest);
|
|
568
|
+
break;
|
|
487
569
|
case 'remote-check':
|
|
488
570
|
code = await cmdRemoteCheck(rest);
|
|
489
571
|
break;
|
package/dist/client.js
CHANGED
|
@@ -5,6 +5,7 @@ exports.remoteRead = remoteRead;
|
|
|
5
5
|
exports.remoteResolve = remoteResolve;
|
|
6
6
|
exports.remoteLabel = remoteLabel;
|
|
7
7
|
exports.remoteCheck = remoteCheck;
|
|
8
|
+
exports.remoteSend = remoteSend;
|
|
8
9
|
exports.remoteHealth = remoteHealth;
|
|
9
10
|
const NO_STORE = { cache: 'no-store' };
|
|
10
11
|
function remoteBase(cfg) {
|
|
@@ -88,6 +89,11 @@ async function remoteCheck(cfg, sender, channel) {
|
|
|
88
89
|
const q = `/check?sender=${encodeURIComponent(sender)}&channel=${encodeURIComponent(channel)}`;
|
|
89
90
|
return getJson(url, token, q);
|
|
90
91
|
}
|
|
92
|
+
/** Remote equivalent of `snazi send` (never gated). */
|
|
93
|
+
async function remoteSend(cfg, recipient, channel, text) {
|
|
94
|
+
const { url, token } = remoteBase(cfg);
|
|
95
|
+
return postJson(url, token, '/send', { recipient, channel, text });
|
|
96
|
+
}
|
|
91
97
|
/** Connectivity probe against a remote serve `/health`. */
|
|
92
98
|
async function remoteHealth(cfg) {
|
|
93
99
|
if (!cfg.remoteUrl) {
|
package/dist/contacts.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
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.sanitizeContactName = sanitizeContactName;
|
|
37
|
+
exports.addressBookDbPaths = addressBookDbPaths;
|
|
38
|
+
exports.buildContactIndex = buildContactIndex;
|
|
39
|
+
exports.probeContacts = probeContacts;
|
|
40
|
+
/**
|
|
41
|
+
* macOS Contacts (AddressBook) name enrichment — DISPLAY METADATA ONLY.
|
|
42
|
+
*
|
|
43
|
+
* Reads the local macOS AddressBook SQLite DB(s) read-only to build a
|
|
44
|
+
* Map<normalizedAddress, displayName> so the serve host can attach a
|
|
45
|
+
* `contact_name` to each sender it reports. This is purely cosmetic.
|
|
46
|
+
*
|
|
47
|
+
* SECURITY INVARIANTS (must hold everywhere this module is used):
|
|
48
|
+
* 1. `contact_name` is DISPLAY-ONLY. It MUST NEVER influence approval
|
|
49
|
+
* status, the read gate, or routing. Reading message text stays gated
|
|
50
|
+
* solely by `status === 'approved'` over in server.ts/handleRead.
|
|
51
|
+
* 2. A contact name is UNTRUSTED display text: every name is stripped of
|
|
52
|
+
* control characters and length-capped (see sanitizeContactName) exactly
|
|
53
|
+
* like the server's parseName/NAME_CTRL_RE handling, so it can never carry
|
|
54
|
+
* a terminal/log-injection payload. It is never executed or interpreted.
|
|
55
|
+
* 3. If Contacts is unavailable (no DB, no permission, better-sqlite3
|
|
56
|
+
* missing, or non-macOS) every export DEGRADES to "empty" and NEVER
|
|
57
|
+
* throws. The gate keeps working with zero Contacts access.
|
|
58
|
+
*
|
|
59
|
+
* This mirrors chatdb.ts: better-sqlite3 (a native module) is required LAZILY
|
|
60
|
+
* inside functions, and the DB path honors env overrides so tests can point at
|
|
61
|
+
* a synthetic AddressBook:
|
|
62
|
+
* - SNAZI_ADDRESSBOOK_DB : a single .abcddb file (used directly; tests).
|
|
63
|
+
* - SNAZI_ADDRESSBOOK_DIR : base AddressBook dir to scan (defaults to the
|
|
64
|
+
* real ~/Library/Application Support/AddressBook).
|
|
65
|
+
*/
|
|
66
|
+
const os = __importStar(require("os"));
|
|
67
|
+
const path = __importStar(require("path"));
|
|
68
|
+
const fs = __importStar(require("fs"));
|
|
69
|
+
const address_1 = require("./address");
|
|
70
|
+
// Keep in sync with server.ts MAX_NAME_LEN / parseName: a contact name is the
|
|
71
|
+
// same kind of untrusted, length-capped, control-char-free display text.
|
|
72
|
+
const MAX_CONTACT_NAME_LEN = 64;
|
|
73
|
+
// eslint-disable-next-line no-control-regex
|
|
74
|
+
const NAME_CTRL_RE = /[\u0000-\u001f\u007f]/g;
|
|
75
|
+
/**
|
|
76
|
+
* Sanitize a raw Contacts name into safe display text, or null if there is
|
|
77
|
+
* nothing usable left. Strips ALL control chars (defends terminal/log
|
|
78
|
+
* injection) and hard-caps the length. Mirrors the server's name handling —
|
|
79
|
+
* the difference is we STRIP rather than reject, so one weird contact never
|
|
80
|
+
* breaks enrichment for everyone else.
|
|
81
|
+
*/
|
|
82
|
+
function sanitizeContactName(raw) {
|
|
83
|
+
if (raw == null)
|
|
84
|
+
return null;
|
|
85
|
+
// Remove control chars first, then collapse surrounding whitespace.
|
|
86
|
+
const stripped = String(raw).replace(NAME_CTRL_RE, '').trim();
|
|
87
|
+
if (stripped === '')
|
|
88
|
+
return null;
|
|
89
|
+
const capped = stripped.slice(0, MAX_CONTACT_NAME_LEN).trim();
|
|
90
|
+
return capped === '' ? null : capped;
|
|
91
|
+
}
|
|
92
|
+
/** Default macOS AddressBook base directory. */
|
|
93
|
+
function defaultAddressBookDir() {
|
|
94
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'AddressBook');
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Resolve every AddressBook .abcddb file to union across. Honors env overrides.
|
|
98
|
+
* Never throws — returns [] on any filesystem hiccup.
|
|
99
|
+
*
|
|
100
|
+
* Real layout (any/all may exist):
|
|
101
|
+
* <base>/AddressBook-v22.abcddb
|
|
102
|
+
* <base>/Sources/<UUID>/AddressBook-v22.abcddb (one per account/source)
|
|
103
|
+
*/
|
|
104
|
+
function addressBookDbPaths() {
|
|
105
|
+
try {
|
|
106
|
+
const single = process.env.SNAZI_ADDRESSBOOK_DB;
|
|
107
|
+
if (single && single.trim() !== '') {
|
|
108
|
+
return fs.existsSync(single) ? [single] : [];
|
|
109
|
+
}
|
|
110
|
+
const base = process.env.SNAZI_ADDRESSBOOK_DIR &&
|
|
111
|
+
process.env.SNAZI_ADDRESSBOOK_DIR.trim() !== ''
|
|
112
|
+
? process.env.SNAZI_ADDRESSBOOK_DIR
|
|
113
|
+
: defaultAddressBookDir();
|
|
114
|
+
const found = [];
|
|
115
|
+
const topLevel = path.join(base, 'AddressBook-v22.abcddb');
|
|
116
|
+
if (fs.existsSync(topLevel))
|
|
117
|
+
found.push(topLevel);
|
|
118
|
+
const sourcesDir = path.join(base, 'Sources');
|
|
119
|
+
let sources = [];
|
|
120
|
+
try {
|
|
121
|
+
sources = fs.readdirSync(sourcesDir);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
sources = [];
|
|
125
|
+
}
|
|
126
|
+
for (const sub of sources) {
|
|
127
|
+
const p = path.join(sourcesDir, sub, 'AddressBook-v22.abcddb');
|
|
128
|
+
if (fs.existsSync(p))
|
|
129
|
+
found.push(p);
|
|
130
|
+
}
|
|
131
|
+
// Deterministic union order so "first non-empty name wins" is stable.
|
|
132
|
+
return [...new Set(found)].sort();
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** Lazy native require — only loaded when we actually touch Contacts. */
|
|
139
|
+
function loadSqlite() {
|
|
140
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
141
|
+
return require('better-sqlite3');
|
|
142
|
+
}
|
|
143
|
+
/** Last 10 digits of an address, or '' if it isn't a >=10-digit phone. */
|
|
144
|
+
function last10(address) {
|
|
145
|
+
const digits = address.replace(/\D/g, '');
|
|
146
|
+
return digits.length >= 10 ? digits.slice(-10) : '';
|
|
147
|
+
}
|
|
148
|
+
/** Empty index used on every failure/degradation path. */
|
|
149
|
+
const EMPTY_INDEX = {
|
|
150
|
+
size: 0,
|
|
151
|
+
get() {
|
|
152
|
+
return null;
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
function makeIndex(byNorm, byLast10) {
|
|
156
|
+
return {
|
|
157
|
+
size: byNorm.size,
|
|
158
|
+
get(address) {
|
|
159
|
+
const norm = (0, address_1.normalizeAddress)(address);
|
|
160
|
+
if (norm === '')
|
|
161
|
+
return null;
|
|
162
|
+
const exact = byNorm.get(norm);
|
|
163
|
+
if (exact)
|
|
164
|
+
return exact;
|
|
165
|
+
// Phone fallback: match by trailing 10 digits. This MUST be restricted to
|
|
166
|
+
// phone-like inputs: an email such as "john5551234567@gmail.com" still
|
|
167
|
+
// contains >=10 digits, and without this guard it would false-match a
|
|
168
|
+
// phone contact's trailing-10 key — attaching a TRUSTED contact name to an
|
|
169
|
+
// UNTRUSTED email address (a decide-time display-spoofing vector). Emails
|
|
170
|
+
// resolve by exact normalized address ONLY.
|
|
171
|
+
if (norm.includes('@'))
|
|
172
|
+
return null;
|
|
173
|
+
const tail = last10(norm);
|
|
174
|
+
if (tail !== '') {
|
|
175
|
+
const byTail = byLast10.get(tail);
|
|
176
|
+
if (byTail)
|
|
177
|
+
return byTail;
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/** Compose a record's display name: "First Last" -> nickname -> organization. */
|
|
184
|
+
function recordDisplayName(r) {
|
|
185
|
+
const first = (r.ZFIRSTNAME ?? '').trim();
|
|
186
|
+
const last = (r.ZLASTNAME ?? '').trim();
|
|
187
|
+
const full = `${first} ${last}`.trim();
|
|
188
|
+
if (full !== '')
|
|
189
|
+
return full;
|
|
190
|
+
const nick = (r.ZNICKNAME ?? '').trim();
|
|
191
|
+
if (nick !== '')
|
|
192
|
+
return nick;
|
|
193
|
+
const org = (r.ZORGANIZATION ?? '').trim();
|
|
194
|
+
if (org !== '')
|
|
195
|
+
return org;
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Index one AddressBook DB into the provided maps. "First non-empty name wins":
|
|
200
|
+
* we never overwrite an existing key, so union order (sorted db paths, then
|
|
201
|
+
* Z_PK order) is deterministic. Best-effort: any failure on a single DB is
|
|
202
|
+
* swallowed so the rest still contribute.
|
|
203
|
+
*/
|
|
204
|
+
function indexOneDb(dbPath, byNorm, byLast10) {
|
|
205
|
+
const Database = loadSqlite();
|
|
206
|
+
let db;
|
|
207
|
+
try {
|
|
208
|
+
db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
209
|
+
const records = db
|
|
210
|
+
.prepare('SELECT Z_PK, ZFIRSTNAME, ZLASTNAME, ZORGANIZATION, ZNICKNAME FROM ZABCDRECORD')
|
|
211
|
+
.all();
|
|
212
|
+
const nameByPk = new Map();
|
|
213
|
+
for (const r of records) {
|
|
214
|
+
const name = sanitizeContactName(recordDisplayName(r));
|
|
215
|
+
if (name)
|
|
216
|
+
nameByPk.set(r.Z_PK, name);
|
|
217
|
+
}
|
|
218
|
+
const addKey = (map, key, name) => {
|
|
219
|
+
if (key === '')
|
|
220
|
+
return;
|
|
221
|
+
if (!map.has(key))
|
|
222
|
+
map.set(key, name); // first non-empty wins
|
|
223
|
+
};
|
|
224
|
+
// Phones — normalize, plus a last-10 fallback key.
|
|
225
|
+
const phones = db
|
|
226
|
+
.prepare('SELECT ZOWNER, ZFULLNUMBER FROM ZABCDPHONENUMBER WHERE ZFULLNUMBER IS NOT NULL')
|
|
227
|
+
.all();
|
|
228
|
+
for (const p of phones) {
|
|
229
|
+
if (p.ZOWNER == null)
|
|
230
|
+
continue;
|
|
231
|
+
const name = nameByPk.get(p.ZOWNER);
|
|
232
|
+
if (!name)
|
|
233
|
+
continue;
|
|
234
|
+
const norm = (0, address_1.normalizeAddress)(p.ZFULLNUMBER);
|
|
235
|
+
addKey(byNorm, norm, name);
|
|
236
|
+
addKey(byLast10, last10(norm), name);
|
|
237
|
+
}
|
|
238
|
+
// Emails — normalize (trim+lowercase).
|
|
239
|
+
const emails = db
|
|
240
|
+
.prepare('SELECT ZOWNER, ZADDRESS FROM ZABCDEMAILADDRESS WHERE ZADDRESS IS NOT NULL')
|
|
241
|
+
.all();
|
|
242
|
+
for (const e of emails) {
|
|
243
|
+
if (e.ZOWNER == null)
|
|
244
|
+
continue;
|
|
245
|
+
const name = nameByPk.get(e.ZOWNER);
|
|
246
|
+
if (!name)
|
|
247
|
+
continue;
|
|
248
|
+
addKey(byNorm, (0, address_1.normalizeAddress)(e.ZADDRESS), name);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// Schema variance, locked DB, missing permission — skip this source.
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
try {
|
|
256
|
+
db?.close();
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
// ignore close errors
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Build a ContactIndex from every available local AddressBook DB.
|
|
265
|
+
* NEVER throws — returns an empty index when Contacts can't be read for ANY
|
|
266
|
+
* reason (non-macOS, no DB, no permission, better-sqlite3 missing). Callers
|
|
267
|
+
* should treat a miss as `contact_name: null` and carry on.
|
|
268
|
+
*/
|
|
269
|
+
function buildContactIndex() {
|
|
270
|
+
try {
|
|
271
|
+
const paths = addressBookDbPaths();
|
|
272
|
+
if (paths.length === 0)
|
|
273
|
+
return EMPTY_INDEX;
|
|
274
|
+
const byNorm = new Map();
|
|
275
|
+
const byLast10 = new Map();
|
|
276
|
+
for (const p of paths)
|
|
277
|
+
indexOneDb(p, byNorm, byLast10);
|
|
278
|
+
if (byNorm.size === 0)
|
|
279
|
+
return EMPTY_INDEX;
|
|
280
|
+
return makeIndex(byNorm, byLast10);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return EMPTY_INDEX;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Non-throwing readability probe (mirrors chatdb.probeChatDb). Reports whether
|
|
288
|
+
* at least one AddressBook DB exists and can be opened+queried read-only.
|
|
289
|
+
* Purely diagnostic — enrichment itself always degrades silently.
|
|
290
|
+
*/
|
|
291
|
+
function probeContacts() {
|
|
292
|
+
try {
|
|
293
|
+
const paths = addressBookDbPaths();
|
|
294
|
+
if (paths.length === 0) {
|
|
295
|
+
return { ok: false, reason: 'No macOS AddressBook database found.' };
|
|
296
|
+
}
|
|
297
|
+
let Database;
|
|
298
|
+
try {
|
|
299
|
+
Database = loadSqlite();
|
|
300
|
+
}
|
|
301
|
+
catch (e) {
|
|
302
|
+
return {
|
|
303
|
+
ok: false,
|
|
304
|
+
reason: `better-sqlite3 unavailable: ${String(e instanceof Error ? e.message : e)}`,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
for (const p of paths) {
|
|
308
|
+
let db;
|
|
309
|
+
try {
|
|
310
|
+
db = new Database(p, { readonly: true, fileMustExist: true });
|
|
311
|
+
db.prepare('SELECT 1 FROM ZABCDRECORD LIMIT 1').get();
|
|
312
|
+
return { ok: true };
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
// try the next source
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
try {
|
|
319
|
+
db?.close();
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
// ignore
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
ok: false,
|
|
328
|
+
reason: 'AddressBook database(s) present but not readable (grant Contacts / Full Disk Access).',
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
catch (e) {
|
|
332
|
+
return {
|
|
333
|
+
ok: false,
|
|
334
|
+
reason: `Contacts probe failed: ${String(e instanceof Error ? e.message : e)}`,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
package/dist/doctor.js
CHANGED
|
@@ -62,15 +62,21 @@ async function runDoctor() {
|
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
64
|
const av = adapter.availability();
|
|
65
|
+
const sendAv = adapter.sendAvailability?.();
|
|
65
66
|
if (!av.available) {
|
|
66
67
|
warnings.push(`Channel '${id}' is not readable locally: ${av.reason}`);
|
|
67
68
|
}
|
|
69
|
+
if (adapter.sendMessage && sendAv && !sendAv.available) {
|
|
70
|
+
warnings.push(`Channel '${id}' cannot send locally: ${sendAv.reason}`);
|
|
71
|
+
}
|
|
68
72
|
return {
|
|
69
73
|
id,
|
|
70
74
|
known: true,
|
|
71
75
|
available: av.available,
|
|
72
76
|
reason: av.reason ?? null,
|
|
73
77
|
detail: av.detail ?? null,
|
|
78
|
+
send_available: sendAv?.available ?? null,
|
|
79
|
+
send_reason: sendAv?.reason ?? null,
|
|
74
80
|
};
|
|
75
81
|
});
|
|
76
82
|
const report = {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_MESSAGE_LEN = void 0;
|
|
4
|
+
exports.escapeAppleScriptString = escapeAppleScriptString;
|
|
5
|
+
exports.probeSendAvailability = probeSendAvailability;
|
|
6
|
+
exports.sendIMessage = sendIMessage;
|
|
7
|
+
/**
|
|
8
|
+
* Send iMessage via Messages.app (macOS only).
|
|
9
|
+
*
|
|
10
|
+
* Outbound messages are NEVER gated by the approval list — the soup nazi only
|
|
11
|
+
* blocks reading. Sending uses AppleScript (`osascript`) and does not require
|
|
12
|
+
* Full Disk Access to chat.db; it may require Automation permission for Messages.
|
|
13
|
+
*/
|
|
14
|
+
const child_process_1 = require("child_process");
|
|
15
|
+
const address_1 = require("./address");
|
|
16
|
+
exports.MAX_MESSAGE_LEN = 10000;
|
|
17
|
+
/** Escape a string for embedding in an AppleScript double-quoted literal. */
|
|
18
|
+
function escapeAppleScriptString(s) {
|
|
19
|
+
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
20
|
+
}
|
|
21
|
+
const AUTOMATION_HINT = 'Grant Automation permission for Messages in System Settings > Privacy & ' +
|
|
22
|
+
'Security > Automation (or allow the terminal/node to control Messages).';
|
|
23
|
+
/** Can iMessage be sent from this host right now? */
|
|
24
|
+
function probeSendAvailability() {
|
|
25
|
+
if (process.platform !== 'darwin') {
|
|
26
|
+
return {
|
|
27
|
+
available: false,
|
|
28
|
+
reason: `iMessage can only be sent on macOS (this host is ${process.platform}).`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const out = (0, child_process_1.execFileSync)('osascript', ['-e', 'application "Messages" exists'], { encoding: 'utf8', timeout: 5000 }).trim();
|
|
33
|
+
if (out !== 'true') {
|
|
34
|
+
return {
|
|
35
|
+
available: false,
|
|
36
|
+
reason: 'Messages.app is not installed on this Mac.',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return { available: true };
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
return {
|
|
43
|
+
available: false,
|
|
44
|
+
reason: `Cannot reach Messages.app: ${String(e instanceof Error ? e.message : e)}`,
|
|
45
|
+
detail: AUTOMATION_HINT,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Send one iMessage. Throws on validation or delivery failure.
|
|
51
|
+
* Never checks the approval list — sending is always allowed.
|
|
52
|
+
*/
|
|
53
|
+
function sendIMessage(rawRecipient, text) {
|
|
54
|
+
const recipient = (0, address_1.validateRecipientAddress)(rawRecipient);
|
|
55
|
+
if (recipient.length > 128) {
|
|
56
|
+
throw new Error('Recipient too long.');
|
|
57
|
+
}
|
|
58
|
+
const body = String(text ?? '');
|
|
59
|
+
if (!body.trim()) {
|
|
60
|
+
throw new Error('Missing message text.');
|
|
61
|
+
}
|
|
62
|
+
if (body.length > exports.MAX_MESSAGE_LEN) {
|
|
63
|
+
throw new Error(`Message too long (max ${exports.MAX_MESSAGE_LEN} characters).`);
|
|
64
|
+
}
|
|
65
|
+
// Block control chars (except common whitespace) to keep logs/terminals safe.
|
|
66
|
+
// eslint-disable-next-line no-control-regex
|
|
67
|
+
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(body)) {
|
|
68
|
+
throw new Error('Invalid message text.');
|
|
69
|
+
}
|
|
70
|
+
if (process.platform !== 'darwin') {
|
|
71
|
+
throw new Error(`iMessage can only be sent on macOS (this host is ${process.platform}).`);
|
|
72
|
+
}
|
|
73
|
+
const escapedRecipient = escapeAppleScriptString(recipient);
|
|
74
|
+
const escapedText = escapeAppleScriptString(body);
|
|
75
|
+
const script = [
|
|
76
|
+
'tell application "Messages"',
|
|
77
|
+
' set targetService to 1st service whose service type = iMessage',
|
|
78
|
+
` set targetBuddy to participant "${escapedRecipient}" of targetService`,
|
|
79
|
+
` send "${escapedText}" to targetBuddy`,
|
|
80
|
+
'end tell',
|
|
81
|
+
].join('\n');
|
|
82
|
+
try {
|
|
83
|
+
(0, child_process_1.execFileSync)('osascript', ['-e', script], { encoding: 'utf8', timeout: 30000 });
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
const msg = String(e instanceof Error ? e.message : e);
|
|
87
|
+
if (/Not authorized|(-1743)/.test(msg)) {
|
|
88
|
+
throw new Error(`Messages automation denied. ${AUTOMATION_HINT}`);
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`Send failed: ${msg}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -53,6 +53,7 @@ exports.startServer = startServer;
|
|
|
53
53
|
* GET /check?sender&channel -> { status, label } (bearer)
|
|
54
54
|
* GET /resolve?name&channel -> name->address address book (bearer)
|
|
55
55
|
* POST /label {sender,channel,name}-> set a sender's display label (bearer)
|
|
56
|
+
* POST /send {recipient,channel,text} -> send a message (bearer, never gated)
|
|
56
57
|
*
|
|
57
58
|
* It REUSES the same gate (api.ts) and the same DB reader (chatdb.ts) as the
|
|
58
59
|
* CLI. There is no approve/deny here — APPROVAL mutations stay CLI/dashboard-
|
|
@@ -68,6 +69,26 @@ const api_1 = require("./api");
|
|
|
68
69
|
const cache_1 = require("./cache");
|
|
69
70
|
const channels_1 = require("./channels");
|
|
70
71
|
const address_1 = require("./address");
|
|
72
|
+
const imessage_send_1 = require("./imessage-send");
|
|
73
|
+
const contacts_1 = require("./contacts");
|
|
74
|
+
/**
|
|
75
|
+
* Build the macOS Contacts index for ONE request, best-effort. NEVER throws:
|
|
76
|
+
* any failure (no DB, no permission, non-macOS, better-sqlite3 missing) yields
|
|
77
|
+
* an empty index so enrichment degrades to `contact_name: null` and the gate
|
|
78
|
+
* keeps working with zero Contacts access.
|
|
79
|
+
*
|
|
80
|
+
* SECURITY: the returned name is DISPLAY-ONLY. It is never consulted by the
|
|
81
|
+
* read gate (handleRead checks `status === 'approved'` and nothing else) and
|
|
82
|
+
* is already sanitized (control-char-stripped + length-capped) by contacts.ts.
|
|
83
|
+
*/
|
|
84
|
+
function contactIndexForRequest() {
|
|
85
|
+
try {
|
|
86
|
+
return (0, contacts_1.buildContactIndex)();
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return { size: 0, get: () => null };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
71
92
|
// Read version without importing JSON at compile time (keeps build simple).
|
|
72
93
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
73
94
|
const VERSION = (() => {
|
|
@@ -96,6 +117,8 @@ const SENDER_RE = /^[A-Za-z0-9_.+@-]+$/;
|
|
|
96
117
|
// packages/web/src/app/api/senders/label/route.ts (MAX_LABEL_LEN, LABEL_CTRL_RE).
|
|
97
118
|
// eslint-disable-next-line no-control-regex
|
|
98
119
|
const NAME_CTRL_RE = /[\u0000-\u001f\u007f]/;
|
|
120
|
+
// eslint-disable-next-line no-control-regex
|
|
121
|
+
const TEXT_CTRL_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
|
|
99
122
|
/**
|
|
100
123
|
* Find this host's Tailscale IP (CGNAT range 100.64.0.0/10) if present.
|
|
101
124
|
* Returns undefined if not on a tailnet.
|
|
@@ -209,6 +232,26 @@ function parseName(v, allowEmpty = false) {
|
|
|
209
232
|
throw new Error('Invalid name.');
|
|
210
233
|
return n;
|
|
211
234
|
}
|
|
235
|
+
function parseRecipient(v) {
|
|
236
|
+
const raw = (v ?? '').trim();
|
|
237
|
+
if (!raw)
|
|
238
|
+
throw new Error('Missing recipient.');
|
|
239
|
+
if (raw.length > MAX_SENDER_LEN)
|
|
240
|
+
throw new Error('Recipient too long.');
|
|
241
|
+
if (!SENDER_RE.test(raw))
|
|
242
|
+
throw new Error('Invalid recipient.');
|
|
243
|
+
return (0, address_1.validateRecipientAddress)(raw);
|
|
244
|
+
}
|
|
245
|
+
function parseText(v) {
|
|
246
|
+
const t = String(v ?? '');
|
|
247
|
+
if (!t.trim())
|
|
248
|
+
throw new Error('Missing text.');
|
|
249
|
+
if (t.length > imessage_send_1.MAX_MESSAGE_LEN)
|
|
250
|
+
throw new Error('Text too long.');
|
|
251
|
+
if (TEXT_CTRL_RE.test(t))
|
|
252
|
+
throw new Error('Invalid text.');
|
|
253
|
+
return t;
|
|
254
|
+
}
|
|
212
255
|
/** Read a request body with a hard size cap (fails closed on overflow). */
|
|
213
256
|
function readBody(req, maxBytes) {
|
|
214
257
|
return new Promise((resolve, reject) => {
|
|
@@ -237,6 +280,8 @@ async function handleListNew(cfg, url) {
|
|
|
237
280
|
const senders = adapter.listInboundSenders(since);
|
|
238
281
|
// One list fetch -> address->label map (best-effort; null on any failure).
|
|
239
282
|
const labels = await (0, api_1.buildLabelMap)(cfg, channel);
|
|
283
|
+
// Build the Contacts index ONCE per request (best-effort, empty on failure).
|
|
284
|
+
const contacts = contactIndexForRequest();
|
|
240
285
|
const results = [];
|
|
241
286
|
for (const s of senders) {
|
|
242
287
|
let status = 'unknown';
|
|
@@ -252,7 +297,12 @@ async function handleListNew(cfg, url) {
|
|
|
252
297
|
message_count: s.message_count,
|
|
253
298
|
latest_at: s.latest_at,
|
|
254
299
|
status,
|
|
300
|
+
// `label` = user's snazi.dev account-set name (privileged display).
|
|
255
301
|
label: labels.get((0, address_1.normalizeAddress)(s.sender)) ?? null,
|
|
302
|
+
// `contact_name` = local macOS Contacts name (display-only metadata).
|
|
303
|
+
// Kept SEPARATE from `label`; included regardless of approval status.
|
|
304
|
+
// It NEVER affects `status` or the read gate.
|
|
305
|
+
contact_name: contacts.get(s.sender),
|
|
256
306
|
};
|
|
257
307
|
if (checkError)
|
|
258
308
|
entry.error = checkError;
|
|
@@ -281,15 +331,20 @@ async function handleRead(cfg, url) {
|
|
|
281
331
|
};
|
|
282
332
|
}
|
|
283
333
|
if (status !== 'approved') {
|
|
334
|
+
// GATE: reading is denied SOLELY on approval status. `contact_name` is
|
|
335
|
+
// deliberately NOT consulted here — a known Contacts name must never open
|
|
336
|
+
// the gate. We don't even compute it on the denied path.
|
|
284
337
|
return {
|
|
285
338
|
status: 403,
|
|
286
339
|
body: { error: 'Sender not approved. No messages for you.', status },
|
|
287
340
|
};
|
|
288
341
|
}
|
|
289
342
|
const messages = adapter.readMessagesFrom(sender, since);
|
|
343
|
+
// Gate already passed; attach display-only Contacts name (best-effort).
|
|
344
|
+
const contact_name = contactIndexForRequest().get(sender);
|
|
290
345
|
return {
|
|
291
346
|
status: 200,
|
|
292
|
-
body: { sender, channel, status, since_minutes: since, messages },
|
|
347
|
+
body: { sender, channel, status, since_minutes: since, contact_name, messages },
|
|
293
348
|
};
|
|
294
349
|
}
|
|
295
350
|
async function handleCheck(cfg, url) {
|
|
@@ -308,7 +363,10 @@ async function handleCheck(cfg, url) {
|
|
|
308
363
|
// Best-effort label lookup for this one address (display only).
|
|
309
364
|
const labels = await (0, api_1.buildLabelMap)(cfg, channel);
|
|
310
365
|
const label = labels.get(sender) ?? null;
|
|
311
|
-
|
|
366
|
+
// Display-only Contacts name. Kept separate from `label`; included no matter
|
|
367
|
+
// the approval status. It does NOT (and must not) affect `status`.
|
|
368
|
+
const contact_name = contactIndexForRequest().get(sender);
|
|
369
|
+
return { status: 200, body: { channel, sender, status, label, contact_name } };
|
|
312
370
|
}
|
|
313
371
|
/**
|
|
314
372
|
* GET /resolve?name=<q>&channel=<id>
|
|
@@ -322,6 +380,8 @@ async function handleResolve(cfg, url) {
|
|
|
322
380
|
const query = parseName(url.searchParams.get('name'), true);
|
|
323
381
|
const needle = query.toLowerCase();
|
|
324
382
|
const senders = await (0, api_1.listSenders)(cfg, channel);
|
|
383
|
+
// Build the Contacts index ONCE for this request (best-effort, empty on fail).
|
|
384
|
+
const contacts = contactIndexForRequest();
|
|
325
385
|
const matches = senders
|
|
326
386
|
.filter((s) => {
|
|
327
387
|
if (s.label == null || s.label === '')
|
|
@@ -334,6 +394,8 @@ async function handleResolve(cfg, url) {
|
|
|
334
394
|
sender_address: s.sender_address,
|
|
335
395
|
label: s.label,
|
|
336
396
|
status: s.status,
|
|
397
|
+
// Display-only macOS Contacts name; separate from `label`, never gates.
|
|
398
|
+
contact_name: contacts.get(s.sender_address),
|
|
337
399
|
}));
|
|
338
400
|
return { status: 200, body: { channel, query, matches } };
|
|
339
401
|
}
|
|
@@ -368,6 +430,38 @@ async function handleLabel(cfg, rawBody) {
|
|
|
368
430
|
return { status: code, body: { error: `Label failed: ${msg}` } };
|
|
369
431
|
}
|
|
370
432
|
}
|
|
433
|
+
/**
|
|
434
|
+
* POST /send body: { recipient, channel, text }
|
|
435
|
+
* Sends an outbound message. NEVER gated by the approval list — the soup nazi
|
|
436
|
+
* only blocks reading.
|
|
437
|
+
*/
|
|
438
|
+
async function handleSend(_cfg, rawBody) {
|
|
439
|
+
let parsed;
|
|
440
|
+
try {
|
|
441
|
+
parsed = JSON.parse(rawBody || '{}');
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
return { status: 400, body: { error: 'Invalid JSON body.' } };
|
|
445
|
+
}
|
|
446
|
+
const b = (parsed ?? {});
|
|
447
|
+
const recipient = parseRecipient(typeof b.recipient === 'string' ? b.recipient : null);
|
|
448
|
+
const channel = parseChannel(typeof b.channel === 'string' ? b.channel : null);
|
|
449
|
+
const text = parseText(typeof b.text === 'string' ? b.text : null);
|
|
450
|
+
const { adapter, error } = (0, channels_1.resolveSendableAdapter)(channel);
|
|
451
|
+
if (!adapter?.sendMessage) {
|
|
452
|
+
return { status: 501, body: { error } };
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
adapter.sendMessage(recipient, text);
|
|
456
|
+
return { status: 200, body: { ok: true, channel, recipient } };
|
|
457
|
+
}
|
|
458
|
+
catch (e) {
|
|
459
|
+
return {
|
|
460
|
+
status: 502,
|
|
461
|
+
body: { error: String(e instanceof Error ? e.message : e) },
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
}
|
|
371
465
|
/** Build (but do not start) the HTTP server. Exposed for tests. */
|
|
372
466
|
function createServer(cfg) {
|
|
373
467
|
const token = cfg.serveToken ?? '';
|
|
@@ -383,23 +477,25 @@ function createServer(cfg) {
|
|
|
383
477
|
if (method === 'GET' && pathname === '/health') {
|
|
384
478
|
return sendJson(res, 200, { ok: true, version: VERSION });
|
|
385
479
|
}
|
|
386
|
-
// Only GET (reads) and POST /label
|
|
387
|
-
// are allowed on this surface.
|
|
480
|
+
// Only GET (reads) and POST /label, POST /send are allowed on this surface.
|
|
388
481
|
if (method !== 'GET' && method !== 'POST') {
|
|
389
482
|
return sendJson(res, 405, { error: 'Method not allowed.' });
|
|
390
483
|
}
|
|
391
|
-
// Everything past /health requires a valid bearer token — including
|
|
392
|
-
// POST /label write.
|
|
484
|
+
// Everything past /health requires a valid bearer token — including writes.
|
|
393
485
|
if (!bearerOk(req.headers['authorization'], token)) {
|
|
394
486
|
res.setHeader('www-authenticate', 'Bearer');
|
|
395
487
|
return sendJson(res, 401, { error: 'Unauthorized.' });
|
|
396
488
|
}
|
|
397
489
|
if (method === 'POST') {
|
|
490
|
+
const rawBody = await readBody(req, MAX_BODY_BYTES);
|
|
398
491
|
if (pathname === '/label') {
|
|
399
|
-
const rawBody = await readBody(req, MAX_BODY_BYTES);
|
|
400
492
|
const r = await handleLabel(cfg, rawBody);
|
|
401
493
|
return sendJson(res, r.status, r.body);
|
|
402
494
|
}
|
|
495
|
+
if (pathname === '/send') {
|
|
496
|
+
const r = await handleSend(cfg, rawBody);
|
|
497
|
+
return sendJson(res, r.status, r.body);
|
|
498
|
+
}
|
|
403
499
|
return sendJson(res, 404, { error: 'Not found.' });
|
|
404
500
|
}
|
|
405
501
|
switch (pathname) {
|
|
@@ -451,11 +547,11 @@ async function startServer(cfg, opts) {
|
|
|
451
547
|
const onTailnet = bind.startsWith('100.') || bind === detectTailscaleIp();
|
|
452
548
|
console.error(JSON.stringify({
|
|
453
549
|
ok: true,
|
|
454
|
-
msg: 'snazi serve listening (read
|
|
550
|
+
msg: 'snazi serve listening (gated read + ungated send)',
|
|
455
551
|
bind,
|
|
456
552
|
port,
|
|
457
553
|
version: VERSION,
|
|
458
|
-
surface: ['/health', '/list-new', '/check', '/read', '/resolve', 'POST /label'],
|
|
554
|
+
surface: ['/health', '/list-new', '/check', '/read', '/resolve', 'POST /label', 'POST /send'],
|
|
459
555
|
reachable_on: bind === '127.0.0.1'
|
|
460
556
|
? 'loopback only (front with `tailscale serve` for tailnet access)'
|
|
461
557
|
: onTailnet
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chipallen2/snazi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://snazi.dev",
|
|
@@ -39,7 +39,10 @@
|
|
|
39
39
|
"build": "tsc",
|
|
40
40
|
"start": "node dist/cli.js",
|
|
41
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"
|
|
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 && node test/send.test.cjs && node test/contacts.test.cjs && node test/serve-contacts.test.cjs",
|
|
43
|
+
"release:patch": "bash scripts/release.sh patch",
|
|
44
|
+
"release:minor": "bash scripts/release.sh minor",
|
|
45
|
+
"release:major": "bash scripts/release.sh major"
|
|
43
46
|
},
|
|
44
47
|
"optionalDependencies": {
|
|
45
48
|
"better-sqlite3": "^11.3.0"
|