@celilo/cli 0.13.2 → 0.14.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/CELILO_CORE_MODULES.md +3 -0
- package/CELILO_SUBSYSTEMS.md +71 -2
- package/docs/ALERTING.md +298 -0
- package/docs/INDEX.md +103 -0
- package/drizzle/0016_trusted_sources.sql +10 -0
- package/drizzle/0017_alerting.sql +127 -0
- package/drizzle/meta/_journal.json +15 -1
- package/package.json +3 -2
- package/schemas/system_config.json +9 -0
- package/src/ansible/inventory.ts +2 -2
- package/src/cli/commands/alerts-act.ts +107 -0
- package/src/cli/commands/alerts-list.ts +62 -0
- package/src/cli/commands/alerts-poll.ts +129 -0
- package/src/cli/commands/alerts-sweep.ts +156 -0
- package/src/cli/commands/module-list.ts +50 -3
- package/src/cli/commands/monitor.ts +178 -0
- package/src/cli/commands/notify-config.ts +453 -0
- package/src/cli/commands/system-audit.ts +2 -0
- package/src/cli/commands/system-update.ts +1 -0
- package/src/cli/completion.ts +26 -0
- package/src/cli/generate-zsh-completion.ts +2 -0
- package/src/cli/index.ts +58 -0
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/db/schema.ts +358 -0
- package/src/hooks/capability-loader.ts +158 -46
- package/src/hooks/capability-map-coverage.test.ts +101 -0
- package/src/manifest/schema.ts +60 -1
- package/src/services/alerting/ack.test.ts +212 -0
- package/src/services/alerting/ack.ts +119 -0
- package/src/services/alerting/builtin-monitors.test.ts +132 -0
- package/src/services/alerting/builtin-monitors.ts +84 -0
- package/src/services/alerting/builtin-source.ts +82 -0
- package/src/services/alerting/coverage-source.ts +38 -0
- package/src/services/alerting/deferral.test.ts +161 -0
- package/src/services/alerting/delivery-loop.test.ts +396 -0
- package/src/services/alerting/deploy-hooks.test.ts +125 -0
- package/src/services/alerting/deploy-hooks.ts +111 -0
- package/src/services/alerting/escalation.test.ts +207 -0
- package/src/services/alerting/escalation.ts +151 -0
- package/src/services/alerting/format.test.ts +193 -0
- package/src/services/alerting/format.ts +150 -0
- package/src/services/alerting/health-coverage.ts +81 -0
- package/src/services/alerting/inbound-poller.test.ts +298 -0
- package/src/services/alerting/inbound-poller.ts +236 -0
- package/src/services/alerting/inbound.test.ts +201 -0
- package/src/services/alerting/inbound.ts +112 -0
- package/src/services/alerting/interview-responder.test.ts +169 -0
- package/src/services/alerting/interview-responder.ts +158 -0
- package/src/services/alerting/keys.test.ts +155 -0
- package/src/services/alerting/keys.ts +190 -0
- package/src/services/alerting/monitors.ts +185 -0
- package/src/services/alerting/notification-responder.test.ts +290 -0
- package/src/services/alerting/notification-responder.ts +260 -0
- package/src/services/alerting/notifier.ts +219 -0
- package/src/services/alerting/people.ts +178 -0
- package/src/services/alerting/quiet-hours.test.ts +140 -0
- package/src/services/alerting/quiet-hours.ts +99 -0
- package/src/services/alerting/reconcile.test.ts +190 -0
- package/src/services/alerting/reconcile.ts +166 -0
- package/src/services/alerting/run-monitor.test.ts +185 -0
- package/src/services/alerting/run-monitor.ts +177 -0
- package/src/services/alerting/store.test.ts +222 -0
- package/src/services/alerting/store.ts +289 -0
- package/src/services/alerting/suppression.test.ts +228 -0
- package/src/services/alerting/suppression.ts +142 -0
- package/src/services/alerting/sweep-runner.test.ts +229 -0
- package/src/services/alerting/sweep-runner.ts +204 -0
- package/src/services/alerting/sweep.test.ts +61 -0
- package/src/services/alerting/sweep.ts +41 -0
- package/src/services/alerting/tokens.test.ts +152 -0
- package/src/services/alerting/tokens.ts +119 -0
- package/src/services/alerting/transport-loader.ts +48 -0
- package/src/services/aspect-runner.ts +2 -2
- package/src/services/audit/index.test.ts +1 -0
- package/src/services/audit/index.ts +3 -0
- package/src/services/audit/trusted-sources.test.ts +137 -0
- package/src/services/audit/trusted-sources.ts +124 -0
- package/src/services/audit/types.ts +2 -1
- package/src/services/firewall-reach.ts +83 -0
- package/src/services/health-runner.test.ts +50 -0
- package/src/services/health-runner.ts +116 -82
- package/src/services/module-deploy.ts +32 -3
- package/src/services/ssh-key-manager.test.ts +14 -0
- package/src/services/ssh-key-manager.ts +12 -0
- package/src/services/system-config-validator.test.ts +31 -1
- package/src/services/trusted-sources.test.ts +221 -0
- package/src/services/trusted-sources.ts +159 -0
- package/src/services/update/orchestrator.test.ts +1 -0
- package/src/templates/generator.ts +6 -29
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inbound poller — reading replies back off a transport.
|
|
3
|
+
*
|
|
4
|
+
* celilo-mgr polls the transport; the transport never calls in. That direction
|
|
5
|
+
* matters: an inbound webhook would need public HTTPS, which means caddy, DNS
|
|
6
|
+
* and a certificate — so the ack path would depend on the very infrastructure
|
|
7
|
+
* being paged about. Polling needs none of it.
|
|
8
|
+
*
|
|
9
|
+
* Runs on a short interval (seconds, not the five-minute sweep) because "I
|
|
10
|
+
* texted ack and nothing happened" is the whole experience being bought here.
|
|
11
|
+
* It is an ordinary interval inside an already-supervised process, not a new
|
|
12
|
+
* bus primitive.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { InboundMessage, NotificationCapability } from '@celilo/capabilities';
|
|
16
|
+
import { and, eq, gt, isNull } from 'drizzle-orm';
|
|
17
|
+
import type { DbClient } from '../../db/client';
|
|
18
|
+
import { type Route, modules, notificationDeliveries, people, routes } from '../../db/schema';
|
|
19
|
+
import { loadCapabilityFunctions } from '../../hooks/capability-loader';
|
|
20
|
+
import { createCapturingLogger } from '../../hooks/logger';
|
|
21
|
+
import { type AckResult, acknowledgeAlert } from './ack';
|
|
22
|
+
import { interpretInbound } from './inbound';
|
|
23
|
+
import { parseInterviewAnswer } from './interview-responder';
|
|
24
|
+
import type { NotificationTransport } from './notifier';
|
|
25
|
+
import { composeAckBroadcastBody } from './notifier';
|
|
26
|
+
import { consumeDelivery } from './tokens';
|
|
27
|
+
|
|
28
|
+
export interface InboundPollDeps {
|
|
29
|
+
/** Receive from one transport module, or null when it cannot be reached. */
|
|
30
|
+
receiveFrom(
|
|
31
|
+
transportModuleId: string,
|
|
32
|
+
cursor: string | null,
|
|
33
|
+
): Promise<{ messages: InboundMessage[]; cursor: string | null } | null>;
|
|
34
|
+
/** Persisted receive cursor per transport. */
|
|
35
|
+
readCursor(transportModuleId: string): string | null;
|
|
36
|
+
writeCursor(transportModuleId: string, cursor: string | null): void;
|
|
37
|
+
now(): Date;
|
|
38
|
+
/**
|
|
39
|
+
* Transport for a route, so an ack can be broadcast to everyone else paged.
|
|
40
|
+
* Optional: an installation with no send path still acks correctly, it just
|
|
41
|
+
* cannot tell the others.
|
|
42
|
+
*/
|
|
43
|
+
transportFor?(route: Route): NotificationTransport;
|
|
44
|
+
/**
|
|
45
|
+
* Publish an interview answer against the waiting bus question. Optional:
|
|
46
|
+
* an installation with no responder attached still acks alerts, it just
|
|
47
|
+
* cannot answer deploy questions.
|
|
48
|
+
*/
|
|
49
|
+
answerInterview?(eventId: string, value: string): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface InboundPollReport {
|
|
53
|
+
transportsPolled: number;
|
|
54
|
+
messagesRead: number;
|
|
55
|
+
acked: number;
|
|
56
|
+
ignored: number;
|
|
57
|
+
rejected: number;
|
|
58
|
+
/** Routes told that someone else took the alert. */
|
|
59
|
+
broadcast: number;
|
|
60
|
+
/** Deploy questions answered from a phone. */
|
|
61
|
+
answered: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Transports that have at least one route pointing at them. */
|
|
65
|
+
export function transportsWithRoutes(db: DbClient): string[] {
|
|
66
|
+
const ids = new Set(
|
|
67
|
+
db
|
|
68
|
+
.select({ id: routes.transportModuleId })
|
|
69
|
+
.from(routes)
|
|
70
|
+
.where(eq(routes.enabled, true))
|
|
71
|
+
.all()
|
|
72
|
+
.map((r) => r.id),
|
|
73
|
+
);
|
|
74
|
+
return [...ids];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Poll every transport once and apply what came back.
|
|
79
|
+
*
|
|
80
|
+
* At-least-once is fine and exactly-once is not attempted: the cursor is
|
|
81
|
+
* persisted after processing, so a crash mid-batch replays it. Every action
|
|
82
|
+
* below is idempotent — consuming an already-consumed token finds nothing,
|
|
83
|
+
* and acking an already-acked alert re-writes the same row — so a replay is
|
|
84
|
+
* harmless. Trying for exactly-once would add machinery to prevent something
|
|
85
|
+
* that does not hurt.
|
|
86
|
+
*/
|
|
87
|
+
export async function pollInbound(db: DbClient, deps: InboundPollDeps): Promise<InboundPollReport> {
|
|
88
|
+
const report: InboundPollReport = {
|
|
89
|
+
transportsPolled: 0,
|
|
90
|
+
messagesRead: 0,
|
|
91
|
+
acked: 0,
|
|
92
|
+
ignored: 0,
|
|
93
|
+
rejected: 0,
|
|
94
|
+
broadcast: 0,
|
|
95
|
+
answered: 0,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
for (const transportId of transportsWithRoutes(db)) {
|
|
99
|
+
const received = await deps.receiveFrom(transportId, deps.readCursor(transportId));
|
|
100
|
+
report.transportsPolled++;
|
|
101
|
+
// A transport that cannot be reached is not an error here — its own
|
|
102
|
+
// health check is what reports that, and one dead transport must not stop
|
|
103
|
+
// the others from being read.
|
|
104
|
+
if (!received) continue;
|
|
105
|
+
|
|
106
|
+
for (const message of received.messages) {
|
|
107
|
+
report.messagesRead++;
|
|
108
|
+
|
|
109
|
+
const outcome = interpretInbound(db, {
|
|
110
|
+
senderAddress: message.senderAddress,
|
|
111
|
+
body: message.body,
|
|
112
|
+
now: deps.now(),
|
|
113
|
+
outstandingForRoute: (routeId) => outstandingAlertDeliveries(db, routeId, deps.now()),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (outcome.action === 'ignored') {
|
|
117
|
+
report.ignored++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (outcome.action === 'rejected') {
|
|
121
|
+
report.rejected++;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// An interview reply IS the answer rather than an acknowledgement —
|
|
126
|
+
// same token table, same sender check, different meaning for the body.
|
|
127
|
+
if (outcome.delivery.kind === 'interview') {
|
|
128
|
+
const value = parseInterviewAnswer(message.body, outcome.delivery.token);
|
|
129
|
+
if (!value || !deps.answerInterview) {
|
|
130
|
+
report.rejected++;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
consumeDelivery(db, outcome.delivery.id, deps.now());
|
|
134
|
+
deps.answerInterview(outcome.delivery.targetId, value);
|
|
135
|
+
report.answered++;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Consume first: a token is single-use, and a crash after acking but
|
|
140
|
+
// before consuming would let the same token act twice.
|
|
141
|
+
consumeDelivery(db, outcome.delivery.id, deps.now());
|
|
142
|
+
const ack = acknowledgeAlert(
|
|
143
|
+
db,
|
|
144
|
+
outcome.delivery.targetId,
|
|
145
|
+
outcome.route.personId,
|
|
146
|
+
deps.now(),
|
|
147
|
+
);
|
|
148
|
+
report.acked++;
|
|
149
|
+
|
|
150
|
+
// Everyone else who was paged is still expecting to act. Telling them is
|
|
151
|
+
// the entire point of a per-delivery token — it is what makes the reply
|
|
152
|
+
// identify a PERSON rather than just an alert.
|
|
153
|
+
if (ack) report.broadcast += await broadcastAck(db, ack, deps);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
deps.writeCursor(transportId, received.cursor);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return report;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Tell every other paged route who took the alert.
|
|
164
|
+
*
|
|
165
|
+
* Best-effort per route: one unreachable phone must not stop the others being
|
|
166
|
+
* told, and the ack itself has already been recorded either way.
|
|
167
|
+
*/
|
|
168
|
+
async function broadcastAck(db: DbClient, ack: AckResult, deps: InboundPollDeps): Promise<number> {
|
|
169
|
+
if (!deps.transportFor || ack.broadcastTo.length === 0) return 0;
|
|
170
|
+
|
|
171
|
+
const acknowledger = ack.alert.ackedBy
|
|
172
|
+
? db.select().from(people).where(eq(people.id, ack.alert.ackedBy)).get()?.name
|
|
173
|
+
: undefined;
|
|
174
|
+
|
|
175
|
+
let sent = 0;
|
|
176
|
+
for (const routeId of ack.broadcastTo) {
|
|
177
|
+
const route = db.select().from(routes).where(eq(routes.id, routeId)).get();
|
|
178
|
+
if (!route) continue;
|
|
179
|
+
try {
|
|
180
|
+
await deps.transportFor(route).send({
|
|
181
|
+
address: route.address,
|
|
182
|
+
body: composeAckBroadcastBody(ack.alert, acknowledger ?? 'Someone'),
|
|
183
|
+
});
|
|
184
|
+
sent++;
|
|
185
|
+
} catch {
|
|
186
|
+
// See above: the ack stands regardless of who could be reached.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return sent;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Live alert deliveries outstanding for a route — what a bare `ack` with no
|
|
194
|
+
* token could be referring to.
|
|
195
|
+
*/
|
|
196
|
+
function outstandingAlertDeliveries(db: DbClient, routeId: string, now: Date) {
|
|
197
|
+
return db
|
|
198
|
+
.select()
|
|
199
|
+
.from(notificationDeliveries)
|
|
200
|
+
.where(
|
|
201
|
+
and(
|
|
202
|
+
eq(notificationDeliveries.routeId, routeId),
|
|
203
|
+
eq(notificationDeliveries.kind, 'alert'),
|
|
204
|
+
isNull(notificationDeliveries.consumedAt),
|
|
205
|
+
gt(notificationDeliveries.expiresAt, now),
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
.all();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Build the real receive function for a transport.
|
|
213
|
+
*
|
|
214
|
+
* A transport with no `receive` is unidirectional — that is not an error, it
|
|
215
|
+
* just means replies cannot arrive, which the route's `can_ack` already
|
|
216
|
+
* records.
|
|
217
|
+
*/
|
|
218
|
+
export function makeReceiver(db: DbClient) {
|
|
219
|
+
return async (transportModuleId: string, cursor: string | null) => {
|
|
220
|
+
const module = db.select().from(modules).where(eq(modules.id, transportModuleId)).get();
|
|
221
|
+
if (!module) return null;
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const { logger } = createCapturingLogger();
|
|
225
|
+
const capabilities = await loadCapabilityFunctions(transportModuleId, db, logger);
|
|
226
|
+
const notification = (capabilities as Record<string, unknown>).notification as
|
|
227
|
+
| NotificationCapability
|
|
228
|
+
| undefined;
|
|
229
|
+
if (!notification?.receive) return null;
|
|
230
|
+
return await notification.receive(cursor);
|
|
231
|
+
} catch {
|
|
232
|
+
// Unreachable transport: the transport's own health check reports it.
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import type { DbClient } from '../../db/client';
|
|
6
|
+
import { type NotificationDelivery, modules, people, routes } from '../../db/schema';
|
|
7
|
+
import { setupTestDatabase } from '../../test-utils/setup-test-db';
|
|
8
|
+
import { interpretInbound, parseInbound } from './inbound';
|
|
9
|
+
import { mintDelivery } from './tokens';
|
|
10
|
+
|
|
11
|
+
const NOW = new Date('2026-07-28T03:00:00Z');
|
|
12
|
+
const TTL_MS = 24 * 60 * 60_000;
|
|
13
|
+
|
|
14
|
+
const PETER = '+15550001';
|
|
15
|
+
const WIFE = '+15550002';
|
|
16
|
+
const STRANGER = '+15559999';
|
|
17
|
+
|
|
18
|
+
describe('parseInbound', () => {
|
|
19
|
+
test.each(['ack', 'ACK', 'ok', 'K', ' ack ', '👍'])('%p is a bare ack', (body) => {
|
|
20
|
+
expect(parseInbound(body)).toEqual({ kind: 'bare_ack' });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('a bare token acknowledges', () => {
|
|
24
|
+
expect(parseInbound('K7QM2X')).toEqual({ kind: 'ack', token: 'K7QM2X' });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('a token typed in lower case still parses', () => {
|
|
28
|
+
expect(parseInbound('k7qm2x')).toEqual({ kind: 'ack', token: 'K7QM2X' });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// The verb is optional in v1 — people will type it anyway.
|
|
32
|
+
test('a trailing verb is accepted and ignored', () => {
|
|
33
|
+
expect(parseInbound('K7QM2X ack')).toEqual({ kind: 'ack', token: 'K7QM2X' });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test.each(['', ' ', 'what is going on', 'K7QM', 'K7QM2XY'])('%p is unrecognised', (body) => {
|
|
37
|
+
expect(parseInbound(body)).toEqual({ kind: 'unrecognised' });
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('interpretInbound', () => {
|
|
42
|
+
let dir: string;
|
|
43
|
+
let db: DbClient;
|
|
44
|
+
|
|
45
|
+
const outstanding: NotificationDelivery[] = [];
|
|
46
|
+
const context = (senderAddress: string, body: string) => ({
|
|
47
|
+
senderAddress,
|
|
48
|
+
body,
|
|
49
|
+
now: NOW,
|
|
50
|
+
outstandingForRoute: (routeId: string) => outstanding.filter((d) => d.routeId === routeId),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
beforeEach(async () => {
|
|
54
|
+
dir = mkdtempSync(join(tmpdir(), 'inbound-'));
|
|
55
|
+
const dbPath = join(dir, 'celilo.db');
|
|
56
|
+
process.env.CELILO_DB_PATH = dbPath;
|
|
57
|
+
db = await setupTestDatabase(dbPath);
|
|
58
|
+
outstanding.length = 0;
|
|
59
|
+
|
|
60
|
+
db.insert(modules)
|
|
61
|
+
.values({
|
|
62
|
+
id: 'signal',
|
|
63
|
+
name: 'Signal',
|
|
64
|
+
version: '1.0.0',
|
|
65
|
+
manifestData: {},
|
|
66
|
+
sourcePath: '/tmp/signal',
|
|
67
|
+
})
|
|
68
|
+
.run();
|
|
69
|
+
db.insert(people).values({ id: 'p1', name: 'peter', timezone: 'UTC' }).run();
|
|
70
|
+
db.insert(people).values({ id: 'p2', name: 'wife', timezone: 'UTC' }).run();
|
|
71
|
+
db.insert(routes)
|
|
72
|
+
.values({ id: 'r-peter', personId: 'p1', transportModuleId: 'signal', address: PETER })
|
|
73
|
+
.run();
|
|
74
|
+
db.insert(routes)
|
|
75
|
+
.values({ id: 'r-wife', personId: 'p2', transportModuleId: 'signal', address: WIFE })
|
|
76
|
+
.run();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
db.$client.close();
|
|
81
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
82
|
+
try {
|
|
83
|
+
rmSync(dir, { recursive: true, force: true });
|
|
84
|
+
} catch {
|
|
85
|
+
/* ignore */
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const mintFor = (routeId: string, targetId = 'alert-1') =>
|
|
90
|
+
mintDelivery(db, { kind: 'alert', targetId, routeId, now: NOW, ttlMs: TTL_MS });
|
|
91
|
+
|
|
92
|
+
test('a valid token from the right sender acknowledges', () => {
|
|
93
|
+
const delivery = mintFor('r-peter');
|
|
94
|
+
const outcome = interpretInbound(db, context(PETER, delivery.token));
|
|
95
|
+
expect(outcome).toMatchObject({ action: 'ack' });
|
|
96
|
+
expect(outcome.action === 'ack' && outcome.route.id).toBe('r-peter');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Factor two. A token glimpsed over a shoulder is useless from another phone.
|
|
100
|
+
test('a valid token from the WRONG sender is refused', () => {
|
|
101
|
+
const delivery = mintFor('r-peter');
|
|
102
|
+
expect(interpretInbound(db, context(WIFE, delivery.token))).toEqual({
|
|
103
|
+
action: 'rejected',
|
|
104
|
+
reason: 'wrong_sender',
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Silence, not a reply — a reply confirms the number is live and that this
|
|
109
|
+
// is a celilo instance.
|
|
110
|
+
test('a message from an unknown number is ignored, not answered', () => {
|
|
111
|
+
const delivery = mintFor('r-peter');
|
|
112
|
+
expect(interpretInbound(db, context(STRANGER, delivery.token))).toEqual({
|
|
113
|
+
action: 'ignored',
|
|
114
|
+
reason: 'unknown_sender',
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('an unknown token from a known sender is rejected', () => {
|
|
119
|
+
expect(interpretInbound(db, context(PETER, 'ZZZZZZ'))).toEqual({
|
|
120
|
+
action: 'rejected',
|
|
121
|
+
reason: 'unknown_token',
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('an expired token is rejected', () => {
|
|
126
|
+
const delivery = mintFor('r-peter');
|
|
127
|
+
const late = { ...context(PETER, delivery.token), now: new Date(NOW.getTime() + TTL_MS + 1) };
|
|
128
|
+
expect(interpretInbound(db, late)).toEqual({ action: 'rejected', reason: 'unknown_token' });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('gibberish from a known sender is rejected as unrecognised', () => {
|
|
132
|
+
expect(interpretInbound(db, context(PETER, 'what is going on'))).toEqual({
|
|
133
|
+
action: 'rejected',
|
|
134
|
+
reason: 'unrecognised',
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('bare ack', () => {
|
|
139
|
+
test('works when exactly one delivery is outstanding', () => {
|
|
140
|
+
outstanding.push(mintFor('r-peter'));
|
|
141
|
+
const outcome = interpretInbound(db, context(PETER, 'ack'));
|
|
142
|
+
expect(outcome).toMatchObject({ action: 'ack' });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Guessing which alert they meant would ack the wrong one.
|
|
146
|
+
test('is refused as ambiguous when several are outstanding', () => {
|
|
147
|
+
outstanding.push(mintFor('r-peter', 'alert-1'));
|
|
148
|
+
outstanding.push(mintFor('r-peter', 'alert-2'));
|
|
149
|
+
expect(interpretInbound(db, context(PETER, 'ack'))).toEqual({
|
|
150
|
+
action: 'rejected',
|
|
151
|
+
reason: 'ambiguous',
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('is refused when nothing is outstanding', () => {
|
|
156
|
+
expect(interpretInbound(db, context(PETER, 'ack'))).toEqual({
|
|
157
|
+
action: 'rejected',
|
|
158
|
+
reason: 'unknown_token',
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Another person's outstanding page is not this person's to ack blindly.
|
|
163
|
+
test('only considers the sender own outstanding deliveries', () => {
|
|
164
|
+
outstanding.push(mintFor('r-wife'));
|
|
165
|
+
expect(interpretInbound(db, context(PETER, 'ack'))).toEqual({
|
|
166
|
+
action: 'rejected',
|
|
167
|
+
reason: 'unknown_token',
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* An earlier cut ignored any verb after the token, so `<token> resolve`
|
|
175
|
+
* silently ACKNOWLEDGED — the operator believes they cleared the alert while it
|
|
176
|
+
* is still firing. Raised in review of #420.
|
|
177
|
+
*/
|
|
178
|
+
describe('reply verbs', () => {
|
|
179
|
+
test('a bare token acknowledges', () => {
|
|
180
|
+
expect(parseInbound('K7QM2X')).toEqual({ kind: 'ack', token: 'K7QM2X' });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('an explicit ack synonym acknowledges', () => {
|
|
184
|
+
for (const verb of ['ack', 'ok', 'k', '👍']) {
|
|
185
|
+
expect(parseInbound(`K7QM2X ${verb}`)).toEqual({ kind: 'ack', token: 'K7QM2X' });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Doing nothing and saying so beats doing the wrong thing quietly.
|
|
190
|
+
test('resolve is REJECTED rather than treated as an ack', () => {
|
|
191
|
+
expect(parseInbound('K7QM2X resolve')).toEqual({ kind: 'unrecognised' });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('silence is REJECTED rather than treated as an ack', () => {
|
|
195
|
+
expect(parseInbound('K7QM2X silence 2h')).toEqual({ kind: 'unrecognised' });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('a typo after the token is rejected, not guessed at', () => {
|
|
199
|
+
expect(parseInbound('K7QM2X akc')).toEqual({ kind: 'unrecognised' });
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound replies — turning a message from a phone into an acknowledgement.
|
|
3
|
+
*
|
|
4
|
+
* Authentication is two factors, both cheap:
|
|
5
|
+
*
|
|
6
|
+
* 1. the token must be valid, unexpired, and unconsumed
|
|
7
|
+
* 2. the SENDER must be the address that token was issued to
|
|
8
|
+
*
|
|
9
|
+
* Either alone is insufficient. A token seen over someone's shoulder is
|
|
10
|
+
* useless from another number; a known number is useless without a live token.
|
|
11
|
+
*
|
|
12
|
+
* A message from an address matching no route is discarded and logged WITHOUT
|
|
13
|
+
* a reply. Replying would confirm the number is live and that this is a celilo
|
|
14
|
+
* instance, which is free reconnaissance for anyone probing.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { eq } from 'drizzle-orm';
|
|
18
|
+
import type { DbClient } from '../../db/client';
|
|
19
|
+
import { type NotificationDelivery, type Route, routes } from '../../db/schema';
|
|
20
|
+
import { findLiveDelivery, normaliseToken } from './tokens';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* v1 grammar: a bare token acknowledges. `snooze` and `resolve` verbs are
|
|
24
|
+
* deferred — ack is what an operator does at 3am, and every extra verb is
|
|
25
|
+
* another thing to mistype under stress.
|
|
26
|
+
*
|
|
27
|
+
* A bare `ack` with no token is accepted when the sender has exactly one
|
|
28
|
+
* outstanding delivery, since the token adds nothing when there is no ambiguity.
|
|
29
|
+
*/
|
|
30
|
+
export type InboundIntent =
|
|
31
|
+
| { kind: 'ack'; token: string }
|
|
32
|
+
| { kind: 'bare_ack' }
|
|
33
|
+
| { kind: 'unrecognised' };
|
|
34
|
+
|
|
35
|
+
const ACK_VERB = /^(ack|ok|k|👍)$/iu;
|
|
36
|
+
const TOKEN_SHAPE = /^[0-9ABCDEFGHJKMNPQRSTVWXYZ]{6}$/;
|
|
37
|
+
|
|
38
|
+
export function parseInbound(body: string): InboundIntent {
|
|
39
|
+
const trimmed = body.trim();
|
|
40
|
+
if (!trimmed) return { kind: 'unrecognised' };
|
|
41
|
+
|
|
42
|
+
if (ACK_VERB.test(trimmed)) return { kind: 'bare_ack' };
|
|
43
|
+
|
|
44
|
+
const [first, ...rest] = trimmed.split(/\s+/);
|
|
45
|
+
const token = normaliseToken(first);
|
|
46
|
+
if (!TOKEN_SHAPE.test(token)) return { kind: 'unrecognised' };
|
|
47
|
+
|
|
48
|
+
// A verb after the token must be an ack synonym or nothing. An earlier cut
|
|
49
|
+
// ignored the verb entirely, which meant `<token> resolve` silently
|
|
50
|
+
// ACKNOWLEDGED — the operator believes they cleared the alert and it is
|
|
51
|
+
// still firing. Doing nothing and saying so is strictly better than doing
|
|
52
|
+
// the wrong thing quietly.
|
|
53
|
+
//
|
|
54
|
+
// `resolve` and `silence` are terminal operations, not reply verbs (design
|
|
55
|
+
// D10): every extra verb is another thing to mistype at 3am.
|
|
56
|
+
if (rest.length > 0 && !ACK_VERB.test(rest.join(' '))) {
|
|
57
|
+
return { kind: 'unrecognised' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { kind: 'ack', token };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type InboundOutcome =
|
|
64
|
+
| { action: 'ack'; delivery: NotificationDelivery; route: Route }
|
|
65
|
+
| { action: 'ignored'; reason: 'unknown_sender' }
|
|
66
|
+
| { action: 'rejected'; reason: 'unknown_token' | 'wrong_sender' | 'ambiguous' | 'unrecognised' };
|
|
67
|
+
|
|
68
|
+
function routeForAddress(db: DbClient, senderAddress: string): Route | undefined {
|
|
69
|
+
return db.select().from(routes).where(eq(routes.address, senderAddress)).get();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface InboundContext {
|
|
73
|
+
senderAddress: string;
|
|
74
|
+
body: string;
|
|
75
|
+
now: Date;
|
|
76
|
+
/** Live alert-kind deliveries outstanding for this route, newest first. */
|
|
77
|
+
outstandingForRoute(routeId: string): NotificationDelivery[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Decide what an inbound message means.
|
|
82
|
+
*
|
|
83
|
+
* Returns the action to take; the caller performs it. Keeping the decision
|
|
84
|
+
* separate is what lets every rejection path be asserted without a transport.
|
|
85
|
+
*/
|
|
86
|
+
export function interpretInbound(db: DbClient, context: InboundContext): InboundOutcome {
|
|
87
|
+
const route = routeForAddress(db, context.senderAddress);
|
|
88
|
+
|
|
89
|
+
// Unknown sender: drop silently. No reply, by design.
|
|
90
|
+
if (!route) return { action: 'ignored', reason: 'unknown_sender' };
|
|
91
|
+
|
|
92
|
+
const intent = parseInbound(context.body);
|
|
93
|
+
|
|
94
|
+
if (intent.kind === 'unrecognised') {
|
|
95
|
+
return { action: 'rejected', reason: 'unrecognised' };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (intent.kind === 'bare_ack') {
|
|
99
|
+
const outstanding = context.outstandingForRoute(route.id);
|
|
100
|
+
if (outstanding.length === 0) return { action: 'rejected', reason: 'unknown_token' };
|
|
101
|
+
if (outstanding.length > 1) return { action: 'rejected', reason: 'ambiguous' };
|
|
102
|
+
return { action: 'ack', delivery: outstanding[0], route };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const delivery = findLiveDelivery(db, intent.token, context.now);
|
|
106
|
+
if (!delivery) return { action: 'rejected', reason: 'unknown_token' };
|
|
107
|
+
|
|
108
|
+
// Factor two: a valid token replayed from a different number is refused.
|
|
109
|
+
if (delivery.routeId !== route.id) return { action: 'rejected', reason: 'wrong_sender' };
|
|
110
|
+
|
|
111
|
+
return { action: 'ack', delivery, route };
|
|
112
|
+
}
|