@chipallen2/snazi 0.1.0 → 0.1.1
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 +24 -24
- package/dist/address.js +30 -0
- package/dist/channels/imessage.js +11 -0
- package/dist/channels/index.js +27 -0
- package/dist/cli.js +77 -3
- package/dist/client.js +6 -0
- package/dist/doctor.js +6 -0
- package/dist/imessage-send.js +92 -0
- package/dist/server.js +65 -7
- 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
|
|
|
@@ -94,6 +83,7 @@ snazi init && snazi doctor
|
|
|
94
83
|
| `snazi doctor` | Diagnose Node, config, connectivity, and channel access. |
|
|
95
84
|
| `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
85
|
| `snazi read <sender> [--channel <id>] [--since <min>] [--fresh]` | Message text for one sender — **only if approved**. Otherwise errors with `No messages for you.` |
|
|
86
|
+
| `snazi send <recipient> --text <message> [--channel <id>]` | Send a message. **Never gated** — you can always send to anyone. |
|
|
97
87
|
| `snazi check <sender> --channel <id> [--fresh]` | One sender's approval status and display label (`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`). |
|
|
@@ -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
|
|
|
@@ -134,6 +125,9 @@ curl -s -H "x-api-key: $READ_TOKEN" \
|
|
|
134
125
|
snazi read "+15551234567"
|
|
135
126
|
# { "sender": "+15551234567", "status": "approved", "since_minutes": 60,
|
|
136
127
|
# "messages": [ { "date": "...", "text": "hey are we still on for lunch?" } ] }
|
|
128
|
+
|
|
129
|
+
snazi send "+15551234567" --text "On my way!"
|
|
130
|
+
# { "ok": true, "channel": "imessage", "recipient": "+15551234567" }
|
|
137
131
|
```
|
|
138
132
|
|
|
139
133
|
## Serve mode — least-privilege HTTP gate over a tailnet
|
|
@@ -154,6 +148,7 @@ privilege.
|
|
|
154
148
|
│ /read (bearer) │
|
|
155
149
|
│ /resolve (bearer) │
|
|
156
150
|
│ POST /label (bearer) │
|
|
151
|
+
│ POST /send (bearer) │
|
|
157
152
|
│ │ │
|
|
158
153
|
│ ▼ same gate (api) │
|
|
159
154
|
│ approved? → text │
|
|
@@ -171,6 +166,7 @@ privilege.
|
|
|
171
166
|
| `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
167
|
| `GET /check?sender=<addr>&channel=imessage` | bearer | `{ channel, sender, status, label }`. On check failure: HTTP 502 with `{ error }`. |
|
|
173
168
|
| `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 }`. |
|
|
169
|
+
| `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. |
|
|
174
170
|
| `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
171
|
| `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
172
|
|
|
@@ -190,9 +186,10 @@ params → `400`. Unsupported methods → `405`.
|
|
|
190
186
|
- **Read-only surface.** No shell, no arbitrary file reads, no path traversal —
|
|
191
187
|
only the same channel adapters the CLI uses. Params are validated
|
|
192
188
|
(`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.
|
|
189
|
+
- **Same gate for reading.** `/read` calls the server list API (`api.ts`) *before*
|
|
190
|
+
touching any text — identical to `snazi read`. The gate is the product; it is
|
|
191
|
+
not bypassed. **Sending is never gated** — `/send` and `snazi send` work for
|
|
192
|
+
any recipient.
|
|
196
193
|
- **No storage.** Content is read live from `chat.db` and returned in the
|
|
197
194
|
response only. Nothing is persisted on either side.
|
|
198
195
|
|
|
@@ -251,6 +248,7 @@ snazi remote-status
|
|
|
251
248
|
snazi remote-list-new --since 120
|
|
252
249
|
snazi remote-check "+15551234567" --channel imessage
|
|
253
250
|
snazi remote-read "+15551234567"
|
|
251
|
+
snazi remote-send "+15551234567" --text "On my way!"
|
|
254
252
|
snazi remote-resolve "Dan" --channel imessage
|
|
255
253
|
snazi remote-label "+15551234567" --name "Dan" --channel imessage
|
|
256
254
|
```
|
|
@@ -268,6 +266,8 @@ curl -s -H "Authorization: Bearer $TOKEN" "$BASE/read?sender=%2B15551234567&chan
|
|
|
268
266
|
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/resolve?name=Dan&channel=imessage"
|
|
269
267
|
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
|
270
268
|
-d '{"sender":"+15551234567","channel":"imessage","name":"Dan"}' "$BASE/label"
|
|
269
|
+
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
|
270
|
+
-d '{"recipient":"+15551234567","channel":"imessage","text":"On my way!"}' "$BASE/send"
|
|
271
271
|
# Unknown/denied sender on /read → 403 { "error": "Sender not approved. No messages for you.", ... }
|
|
272
272
|
```
|
|
273
273
|
|
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.
|
|
@@ -165,6 +166,41 @@ async function cmdCheck(args) {
|
|
|
165
166
|
return 1;
|
|
166
167
|
}
|
|
167
168
|
}
|
|
169
|
+
async function cmdSend(args) {
|
|
170
|
+
const positionals = args.filter((a) => !a.startsWith('--'));
|
|
171
|
+
const rawRecipient = positionals[0];
|
|
172
|
+
const text = flag(args, '--text');
|
|
173
|
+
if (!rawRecipient || text == null) {
|
|
174
|
+
out({
|
|
175
|
+
error: 'Usage: snazi send <recipient> --text <message> [--channel <id>]',
|
|
176
|
+
});
|
|
177
|
+
return 2;
|
|
178
|
+
}
|
|
179
|
+
const channel = flag(args, '--channel') ?? DEFAULT_CHANNEL;
|
|
180
|
+
let target;
|
|
181
|
+
try {
|
|
182
|
+
target = (0, address_1.validateRecipientAddress)(rawRecipient);
|
|
183
|
+
}
|
|
184
|
+
catch (e) {
|
|
185
|
+
out({ error: String(e instanceof Error ? e.message : e) });
|
|
186
|
+
return 2;
|
|
187
|
+
}
|
|
188
|
+
// Sending is NEVER gated — the soup nazi only blocks reading.
|
|
189
|
+
const { adapter, error } = (0, channels_1.resolveSendableAdapter)(channel);
|
|
190
|
+
if (!adapter?.sendMessage) {
|
|
191
|
+
out({ error });
|
|
192
|
+
return 1;
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
adapter.sendMessage(target, text);
|
|
196
|
+
out({ ok: true, channel, recipient: target });
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
out({ error: String(e instanceof Error ? e.message : e) });
|
|
201
|
+
return 1;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
168
204
|
async function cmdCache(args) {
|
|
169
205
|
const sub = args[0];
|
|
170
206
|
if (sub === 'clear') {
|
|
@@ -378,6 +414,36 @@ async function cmdRemoteLabel(args) {
|
|
|
378
414
|
return 1;
|
|
379
415
|
}
|
|
380
416
|
}
|
|
417
|
+
async function cmdRemoteSend(args) {
|
|
418
|
+
const positionals = args.filter((a) => !a.startsWith('--'));
|
|
419
|
+
const rawRecipient = positionals[0];
|
|
420
|
+
const text = flag(args, '--text');
|
|
421
|
+
if (!rawRecipient || text == null) {
|
|
422
|
+
out({
|
|
423
|
+
error: 'Usage: snazi remote-send <recipient> --text <message> [--channel <id>]',
|
|
424
|
+
});
|
|
425
|
+
return 2;
|
|
426
|
+
}
|
|
427
|
+
const channel = flag(args, '--channel') ?? DEFAULT_CHANNEL;
|
|
428
|
+
let target;
|
|
429
|
+
try {
|
|
430
|
+
target = (0, address_1.validateRecipientAddress)(rawRecipient);
|
|
431
|
+
}
|
|
432
|
+
catch (e) {
|
|
433
|
+
out({ error: String(e instanceof Error ? e.message : e) });
|
|
434
|
+
return 2;
|
|
435
|
+
}
|
|
436
|
+
const cfg = (0, config_1.loadConfig)();
|
|
437
|
+
try {
|
|
438
|
+
const { status, json } = await (0, client_1.remoteSend)(cfg, target, channel, text);
|
|
439
|
+
out(json);
|
|
440
|
+
return status >= 200 && status < 300 ? 0 : 1;
|
|
441
|
+
}
|
|
442
|
+
catch (e) {
|
|
443
|
+
out({ error: String(e instanceof Error ? e.message : e) });
|
|
444
|
+
return 1;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
381
447
|
async function cmdRemoteStatus() {
|
|
382
448
|
const cfg = (0, config_1.loadConfig)();
|
|
383
449
|
try {
|
|
@@ -415,6 +481,7 @@ Setup:
|
|
|
415
481
|
Usage:
|
|
416
482
|
snazi list-new [--channel <id>] [--since <minutes>] Show WHO messaged + approval status (default 60m)
|
|
417
483
|
snazi read <sender> [--channel <id>] [--since <min>] Show message text — only if sender is approved
|
|
484
|
+
snazi send <recipient> --text <message> [--channel <id>] Send a message (never gated)
|
|
418
485
|
snazi check <sender> --channel <id> Print one sender's approval status
|
|
419
486
|
snazi channels list List configured channels + adapter availability here
|
|
420
487
|
snazi channels add <channel> Add a channel (e.g. imessage)
|
|
@@ -430,7 +497,7 @@ Approvals are READ-ONLY here: approve/deny a sender in the web dashboard or via
|
|
|
430
497
|
a signed /decide link. The config token is a per-account READ token.
|
|
431
498
|
|
|
432
499
|
Serve mode (least-privilege HTTP gate for a remote agent over a tailnet):
|
|
433
|
-
snazi serve [--bind <ip>] [--port <n>] Start
|
|
500
|
+
snazi serve [--bind <ip>] [--port <n>] Start HTTP gate (/health,/list-new,/check,/read,POST /send)
|
|
434
501
|
snazi serve --install-daemon [--bind <ip>] [--port <n>] Install the launchd LaunchAgent (RunAtLoad/KeepAlive)
|
|
435
502
|
|
|
436
503
|
Remote client (the trusted agent side, calls a remote 'snazi serve'):
|
|
@@ -438,12 +505,13 @@ Remote client (the trusted agent side, calls a remote 'snazi serve'):
|
|
|
438
505
|
snazi remote-list-new [--channel <id>] [--since <min>] WHO messaged on the remote host + status
|
|
439
506
|
snazi remote-check <sender> --channel <id> One sender's status (remote)
|
|
440
507
|
snazi remote-read <sender> [--channel <id>] [--since <min>] Message text (remote) — only if approved
|
|
508
|
+
snazi remote-send <recipient> --text <msg> [--channel <id>] Send a message (remote; never gated)
|
|
441
509
|
snazi remote-resolve [<name>] [--channel <id>] Resolve a name → sender address(es) (empty = address book)
|
|
442
510
|
snazi remote-label <sender> --name <name> [--channel <id>] Set a sender's display name (label only; cannot open the gate)
|
|
443
511
|
|
|
444
512
|
The server manages an approve/deny list only. It stores no messages.
|
|
445
|
-
|
|
446
|
-
|
|
513
|
+
Reading is gated; sending is not. serve binds the tailnet IP (100.x) or
|
|
514
|
+
127.0.0.1 — never 0.0.0.0.`);
|
|
447
515
|
}
|
|
448
516
|
async function main() {
|
|
449
517
|
const argv = process.argv.slice(2);
|
|
@@ -463,6 +531,9 @@ async function main() {
|
|
|
463
531
|
case 'read':
|
|
464
532
|
code = await cmdRead(rest);
|
|
465
533
|
break;
|
|
534
|
+
case 'send':
|
|
535
|
+
code = await cmdSend(rest);
|
|
536
|
+
break;
|
|
466
537
|
case 'check':
|
|
467
538
|
code = await cmdCheck(rest);
|
|
468
539
|
break;
|
|
@@ -484,6 +555,9 @@ async function main() {
|
|
|
484
555
|
case 'remote-read':
|
|
485
556
|
code = await cmdRemoteRead(rest);
|
|
486
557
|
break;
|
|
558
|
+
case 'remote-send':
|
|
559
|
+
code = await cmdRemoteSend(rest);
|
|
560
|
+
break;
|
|
487
561
|
case 'remote-check':
|
|
488
562
|
code = await cmdRemoteCheck(rest);
|
|
489
563
|
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/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,7 @@ 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");
|
|
71
73
|
// Read version without importing JSON at compile time (keeps build simple).
|
|
72
74
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
73
75
|
const VERSION = (() => {
|
|
@@ -96,6 +98,8 @@ const SENDER_RE = /^[A-Za-z0-9_.+@-]+$/;
|
|
|
96
98
|
// packages/web/src/app/api/senders/label/route.ts (MAX_LABEL_LEN, LABEL_CTRL_RE).
|
|
97
99
|
// eslint-disable-next-line no-control-regex
|
|
98
100
|
const NAME_CTRL_RE = /[\u0000-\u001f\u007f]/;
|
|
101
|
+
// eslint-disable-next-line no-control-regex
|
|
102
|
+
const TEXT_CTRL_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
|
|
99
103
|
/**
|
|
100
104
|
* Find this host's Tailscale IP (CGNAT range 100.64.0.0/10) if present.
|
|
101
105
|
* Returns undefined if not on a tailnet.
|
|
@@ -209,6 +213,26 @@ function parseName(v, allowEmpty = false) {
|
|
|
209
213
|
throw new Error('Invalid name.');
|
|
210
214
|
return n;
|
|
211
215
|
}
|
|
216
|
+
function parseRecipient(v) {
|
|
217
|
+
const raw = (v ?? '').trim();
|
|
218
|
+
if (!raw)
|
|
219
|
+
throw new Error('Missing recipient.');
|
|
220
|
+
if (raw.length > MAX_SENDER_LEN)
|
|
221
|
+
throw new Error('Recipient too long.');
|
|
222
|
+
if (!SENDER_RE.test(raw))
|
|
223
|
+
throw new Error('Invalid recipient.');
|
|
224
|
+
return (0, address_1.validateRecipientAddress)(raw);
|
|
225
|
+
}
|
|
226
|
+
function parseText(v) {
|
|
227
|
+
const t = String(v ?? '');
|
|
228
|
+
if (!t.trim())
|
|
229
|
+
throw new Error('Missing text.');
|
|
230
|
+
if (t.length > imessage_send_1.MAX_MESSAGE_LEN)
|
|
231
|
+
throw new Error('Text too long.');
|
|
232
|
+
if (TEXT_CTRL_RE.test(t))
|
|
233
|
+
throw new Error('Invalid text.');
|
|
234
|
+
return t;
|
|
235
|
+
}
|
|
212
236
|
/** Read a request body with a hard size cap (fails closed on overflow). */
|
|
213
237
|
function readBody(req, maxBytes) {
|
|
214
238
|
return new Promise((resolve, reject) => {
|
|
@@ -368,6 +392,38 @@ async function handleLabel(cfg, rawBody) {
|
|
|
368
392
|
return { status: code, body: { error: `Label failed: ${msg}` } };
|
|
369
393
|
}
|
|
370
394
|
}
|
|
395
|
+
/**
|
|
396
|
+
* POST /send body: { recipient, channel, text }
|
|
397
|
+
* Sends an outbound message. NEVER gated by the approval list — the soup nazi
|
|
398
|
+
* only blocks reading.
|
|
399
|
+
*/
|
|
400
|
+
async function handleSend(_cfg, rawBody) {
|
|
401
|
+
let parsed;
|
|
402
|
+
try {
|
|
403
|
+
parsed = JSON.parse(rawBody || '{}');
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
return { status: 400, body: { error: 'Invalid JSON body.' } };
|
|
407
|
+
}
|
|
408
|
+
const b = (parsed ?? {});
|
|
409
|
+
const recipient = parseRecipient(typeof b.recipient === 'string' ? b.recipient : null);
|
|
410
|
+
const channel = parseChannel(typeof b.channel === 'string' ? b.channel : null);
|
|
411
|
+
const text = parseText(typeof b.text === 'string' ? b.text : null);
|
|
412
|
+
const { adapter, error } = (0, channels_1.resolveSendableAdapter)(channel);
|
|
413
|
+
if (!adapter?.sendMessage) {
|
|
414
|
+
return { status: 501, body: { error } };
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
adapter.sendMessage(recipient, text);
|
|
418
|
+
return { status: 200, body: { ok: true, channel, recipient } };
|
|
419
|
+
}
|
|
420
|
+
catch (e) {
|
|
421
|
+
return {
|
|
422
|
+
status: 502,
|
|
423
|
+
body: { error: String(e instanceof Error ? e.message : e) },
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
|
371
427
|
/** Build (but do not start) the HTTP server. Exposed for tests. */
|
|
372
428
|
function createServer(cfg) {
|
|
373
429
|
const token = cfg.serveToken ?? '';
|
|
@@ -383,23 +439,25 @@ function createServer(cfg) {
|
|
|
383
439
|
if (method === 'GET' && pathname === '/health') {
|
|
384
440
|
return sendJson(res, 200, { ok: true, version: VERSION });
|
|
385
441
|
}
|
|
386
|
-
// Only GET (reads) and POST /label
|
|
387
|
-
// are allowed on this surface.
|
|
442
|
+
// Only GET (reads) and POST /label, POST /send are allowed on this surface.
|
|
388
443
|
if (method !== 'GET' && method !== 'POST') {
|
|
389
444
|
return sendJson(res, 405, { error: 'Method not allowed.' });
|
|
390
445
|
}
|
|
391
|
-
// Everything past /health requires a valid bearer token — including
|
|
392
|
-
// POST /label write.
|
|
446
|
+
// Everything past /health requires a valid bearer token — including writes.
|
|
393
447
|
if (!bearerOk(req.headers['authorization'], token)) {
|
|
394
448
|
res.setHeader('www-authenticate', 'Bearer');
|
|
395
449
|
return sendJson(res, 401, { error: 'Unauthorized.' });
|
|
396
450
|
}
|
|
397
451
|
if (method === 'POST') {
|
|
452
|
+
const rawBody = await readBody(req, MAX_BODY_BYTES);
|
|
398
453
|
if (pathname === '/label') {
|
|
399
|
-
const rawBody = await readBody(req, MAX_BODY_BYTES);
|
|
400
454
|
const r = await handleLabel(cfg, rawBody);
|
|
401
455
|
return sendJson(res, r.status, r.body);
|
|
402
456
|
}
|
|
457
|
+
if (pathname === '/send') {
|
|
458
|
+
const r = await handleSend(cfg, rawBody);
|
|
459
|
+
return sendJson(res, r.status, r.body);
|
|
460
|
+
}
|
|
403
461
|
return sendJson(res, 404, { error: 'Not found.' });
|
|
404
462
|
}
|
|
405
463
|
switch (pathname) {
|
|
@@ -451,11 +509,11 @@ async function startServer(cfg, opts) {
|
|
|
451
509
|
const onTailnet = bind.startsWith('100.') || bind === detectTailscaleIp();
|
|
452
510
|
console.error(JSON.stringify({
|
|
453
511
|
ok: true,
|
|
454
|
-
msg: 'snazi serve listening (read
|
|
512
|
+
msg: 'snazi serve listening (gated read + ungated send)',
|
|
455
513
|
bind,
|
|
456
514
|
port,
|
|
457
515
|
version: VERSION,
|
|
458
|
-
surface: ['/health', '/list-new', '/check', '/read', '/resolve', 'POST /label'],
|
|
516
|
+
surface: ['/health', '/list-new', '/check', '/read', '/resolve', 'POST /label', 'POST /send'],
|
|
459
517
|
reachable_on: bind === '127.0.0.1'
|
|
460
518
|
? 'loopback only (front with `tailscale serve` for tailnet access)'
|
|
461
519
|
: onTailnet
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chipallen2/snazi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
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",
|
|
43
|
+
"release:patch": "npm version patch -m \"release: v%s\" && git push --follow-tags",
|
|
44
|
+
"release:minor": "npm version minor -m \"release: v%s\" && git push --follow-tags",
|
|
45
|
+
"release:major": "npm version major -m \"release: v%s\" && git push --follow-tags"
|
|
43
46
|
},
|
|
44
47
|
"optionalDependencies": {
|
|
45
48
|
"better-sqlite3": "^11.3.0"
|