@chipallen2/snazi 0.1.1 → 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 +33 -7
- package/dist/cli.js +9 -1
- package/dist/contacts.js +337 -0
- package/dist/server.js +40 -2
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -81,10 +81,10 @@ snazi init && snazi doctor
|
|
|
81
81
|
| --- | --- |
|
|
82
82
|
| `snazi init [--api-url <url>] [--token <tok>] [--channel <id>]` | Create or update `~/.snazi/config.json`. |
|
|
83
83
|
| `snazi doctor` | Diagnose Node, config, connectivity, and channel access. |
|
|
84
|
-
| `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. |
|
|
85
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
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
|
|
87
|
+
| `snazi check <sender> --channel <id> [--fresh]` | One sender's approval status, display label, and local Contacts `contact_name` (`approved`/`denied`/`unknown`). |
|
|
88
88
|
| `snazi channels list` | Configured channels plus adapter availability on this machine. |
|
|
89
89
|
| `snazi channels add <channel>` | Add a channel (e.g. `snazi channels add imessage`). |
|
|
90
90
|
| `snazi cache clear` | Drop cached approval statuses (force fresh checks after a revocation). |
|
|
@@ -110,7 +110,8 @@ right after you revoke someone.
|
|
|
110
110
|
snazi list-new --since 180
|
|
111
111
|
# [
|
|
112
112
|
# { "sender": "+15551234567", "message_count": 3,
|
|
113
|
-
# "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" }
|
|
114
115
|
# ]
|
|
115
116
|
|
|
116
117
|
snazi read "+15551234567"
|
|
@@ -163,17 +164,42 @@ privilege.
|
|
|
163
164
|
| Method + path | Auth | Returns |
|
|
164
165
|
| --- | --- | --- |
|
|
165
166
|
| `GET /health` | none | `{ ok: true, version }` — connectivity probe only, no data. |
|
|
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.** |
|
|
167
|
-
| `GET /check?sender=<addr>&channel=imessage` | bearer | `{ channel, sender, status, label }`. On check failure: HTTP 502 with `{ error }`. |
|
|
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 }`. |
|
|
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 }`. |
|
|
169
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. |
|
|
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.** |
|
|
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.** |
|
|
171
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. |
|
|
172
173
|
|
|
173
174
|
There is **no `approve`/`deny` over HTTP**. Approvals stay dashboard/`/decide`-only.
|
|
174
175
|
`POST /label` is the only write — label metadata only. Unknown path → `404`. Bad
|
|
175
176
|
params → `400`. Unsupported methods → `405`.
|
|
176
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
|
+
|
|
177
203
|
### Security model
|
|
178
204
|
|
|
179
205
|
- **Tailnet-only.** Default bind is this host's Tailscale IP (`100.64.0.0/10`) if
|
package/dist/cli.js
CHANGED
|
@@ -34,6 +34,7 @@ const address_1 = require("./address");
|
|
|
34
34
|
const api_1 = require("./api");
|
|
35
35
|
const cache_1 = require("./cache");
|
|
36
36
|
const channels_1 = require("./channels");
|
|
37
|
+
const contacts_1 = require("./contacts");
|
|
37
38
|
const server_1 = require("./server");
|
|
38
39
|
const client_1 = require("./client");
|
|
39
40
|
const daemon_1 = require("./daemon");
|
|
@@ -85,6 +86,8 @@ async function cmdListNew(args) {
|
|
|
85
86
|
}
|
|
86
87
|
const senders = adapter.listInboundSenders(since);
|
|
87
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)();
|
|
88
91
|
const results = [];
|
|
89
92
|
for (const s of senders) {
|
|
90
93
|
let status = 'unknown';
|
|
@@ -100,7 +103,10 @@ async function cmdListNew(args) {
|
|
|
100
103
|
message_count: s.message_count,
|
|
101
104
|
latest_at: s.latest_at,
|
|
102
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.
|
|
103
108
|
label: labels.get((0, address_1.normalizeAddress)(s.sender)) ?? null,
|
|
109
|
+
contact_name: contacts.get(s.sender),
|
|
104
110
|
};
|
|
105
111
|
if (checkError)
|
|
106
112
|
entry.error = checkError;
|
|
@@ -158,7 +164,9 @@ async function cmdCheck(args) {
|
|
|
158
164
|
const status = await (0, cache_1.checkSenderCached)(cfg, channel, target, { fresh });
|
|
159
165
|
const labels = await (0, api_1.buildLabelMap)(cfg, channel);
|
|
160
166
|
const label = labels.get(target) ?? null;
|
|
161
|
-
|
|
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 });
|
|
162
170
|
return 0;
|
|
163
171
|
}
|
|
164
172
|
catch (e) {
|
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/server.js
CHANGED
|
@@ -70,6 +70,25 @@ const cache_1 = require("./cache");
|
|
|
70
70
|
const channels_1 = require("./channels");
|
|
71
71
|
const address_1 = require("./address");
|
|
72
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
|
+
}
|
|
73
92
|
// Read version without importing JSON at compile time (keeps build simple).
|
|
74
93
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
75
94
|
const VERSION = (() => {
|
|
@@ -261,6 +280,8 @@ async function handleListNew(cfg, url) {
|
|
|
261
280
|
const senders = adapter.listInboundSenders(since);
|
|
262
281
|
// One list fetch -> address->label map (best-effort; null on any failure).
|
|
263
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();
|
|
264
285
|
const results = [];
|
|
265
286
|
for (const s of senders) {
|
|
266
287
|
let status = 'unknown';
|
|
@@ -276,7 +297,12 @@ async function handleListNew(cfg, url) {
|
|
|
276
297
|
message_count: s.message_count,
|
|
277
298
|
latest_at: s.latest_at,
|
|
278
299
|
status,
|
|
300
|
+
// `label` = user's snazi.dev account-set name (privileged display).
|
|
279
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),
|
|
280
306
|
};
|
|
281
307
|
if (checkError)
|
|
282
308
|
entry.error = checkError;
|
|
@@ -305,15 +331,20 @@ async function handleRead(cfg, url) {
|
|
|
305
331
|
};
|
|
306
332
|
}
|
|
307
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.
|
|
308
337
|
return {
|
|
309
338
|
status: 403,
|
|
310
339
|
body: { error: 'Sender not approved. No messages for you.', status },
|
|
311
340
|
};
|
|
312
341
|
}
|
|
313
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);
|
|
314
345
|
return {
|
|
315
346
|
status: 200,
|
|
316
|
-
body: { sender, channel, status, since_minutes: since, messages },
|
|
347
|
+
body: { sender, channel, status, since_minutes: since, contact_name, messages },
|
|
317
348
|
};
|
|
318
349
|
}
|
|
319
350
|
async function handleCheck(cfg, url) {
|
|
@@ -332,7 +363,10 @@ async function handleCheck(cfg, url) {
|
|
|
332
363
|
// Best-effort label lookup for this one address (display only).
|
|
333
364
|
const labels = await (0, api_1.buildLabelMap)(cfg, channel);
|
|
334
365
|
const label = labels.get(sender) ?? null;
|
|
335
|
-
|
|
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 } };
|
|
336
370
|
}
|
|
337
371
|
/**
|
|
338
372
|
* GET /resolve?name=<q>&channel=<id>
|
|
@@ -346,6 +380,8 @@ async function handleResolve(cfg, url) {
|
|
|
346
380
|
const query = parseName(url.searchParams.get('name'), true);
|
|
347
381
|
const needle = query.toLowerCase();
|
|
348
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();
|
|
349
385
|
const matches = senders
|
|
350
386
|
.filter((s) => {
|
|
351
387
|
if (s.label == null || s.label === '')
|
|
@@ -358,6 +394,8 @@ async function handleResolve(cfg, url) {
|
|
|
358
394
|
sender_address: s.sender_address,
|
|
359
395
|
label: s.label,
|
|
360
396
|
status: s.status,
|
|
397
|
+
// Display-only macOS Contacts name; separate from `label`, never gates.
|
|
398
|
+
contact_name: contacts.get(s.sender_address),
|
|
361
399
|
}));
|
|
362
400
|
return { status: 200, body: { channel, query, matches } };
|
|
363
401
|
}
|
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,10 +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 && node test/send.test.cjs",
|
|
43
|
-
"release:patch": "
|
|
44
|
-
"release:minor": "
|
|
45
|
-
"release:major": "
|
|
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"
|
|
46
46
|
},
|
|
47
47
|
"optionalDependencies": {
|
|
48
48
|
"better-sqlite3": "^11.3.0"
|