@celilo/cli 0.13.3 → 0.14.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.
Files changed (88) hide show
  1. package/CELILO_CORE_MODULES.md +3 -0
  2. package/CELILO_SUBSYSTEMS.md +71 -2
  3. package/docs/ALERTING.md +298 -0
  4. package/docs/INDEX.md +103 -0
  5. package/drizzle/0016_trusted_sources.sql +10 -0
  6. package/drizzle/0017_alerting.sql +127 -0
  7. package/drizzle/meta/_journal.json +15 -1
  8. package/package.json +3 -2
  9. package/schemas/system_config.json +9 -0
  10. package/src/capabilities/well-known.ts +11 -1
  11. package/src/cli/commands/alerts-act.ts +107 -0
  12. package/src/cli/commands/alerts-list.ts +62 -0
  13. package/src/cli/commands/alerts-poll.ts +129 -0
  14. package/src/cli/commands/alerts-sweep.ts +156 -0
  15. package/src/cli/commands/module-list.ts +50 -3
  16. package/src/cli/commands/module-show.ts +11 -3
  17. package/src/cli/commands/monitor.ts +178 -0
  18. package/src/cli/commands/notify-config.ts +453 -0
  19. package/src/cli/commands/system-audit.ts +2 -0
  20. package/src/cli/commands/system-update.ts +1 -0
  21. package/src/cli/completion.ts +26 -0
  22. package/src/cli/generate-zsh-completion.ts +2 -0
  23. package/src/cli/index.ts +58 -0
  24. package/src/cli/tui/audit-state.ts +2 -0
  25. package/src/db/schema.ts +371 -2
  26. package/src/hooks/capability-loader.ts +158 -46
  27. package/src/hooks/capability-map-coverage.test.ts +101 -0
  28. package/src/manifest/schema.ts +77 -4
  29. package/src/services/alerting/ack.test.ts +212 -0
  30. package/src/services/alerting/ack.ts +119 -0
  31. package/src/services/alerting/builtin-monitors.test.ts +132 -0
  32. package/src/services/alerting/builtin-monitors.ts +84 -0
  33. package/src/services/alerting/builtin-source.ts +82 -0
  34. package/src/services/alerting/coverage-source.ts +38 -0
  35. package/src/services/alerting/deferral.test.ts +161 -0
  36. package/src/services/alerting/delivery-loop.test.ts +396 -0
  37. package/src/services/alerting/deploy-hooks.test.ts +125 -0
  38. package/src/services/alerting/deploy-hooks.ts +111 -0
  39. package/src/services/alerting/escalation.test.ts +207 -0
  40. package/src/services/alerting/escalation.ts +151 -0
  41. package/src/services/alerting/format.test.ts +193 -0
  42. package/src/services/alerting/format.ts +150 -0
  43. package/src/services/alerting/health-coverage.ts +81 -0
  44. package/src/services/alerting/inbound-poller.test.ts +298 -0
  45. package/src/services/alerting/inbound-poller.ts +236 -0
  46. package/src/services/alerting/inbound.test.ts +201 -0
  47. package/src/services/alerting/inbound.ts +112 -0
  48. package/src/services/alerting/interview-responder.test.ts +169 -0
  49. package/src/services/alerting/interview-responder.ts +158 -0
  50. package/src/services/alerting/keys.test.ts +155 -0
  51. package/src/services/alerting/keys.ts +190 -0
  52. package/src/services/alerting/monitors.ts +185 -0
  53. package/src/services/alerting/notification-responder.test.ts +290 -0
  54. package/src/services/alerting/notification-responder.ts +260 -0
  55. package/src/services/alerting/notifier.ts +219 -0
  56. package/src/services/alerting/people.ts +178 -0
  57. package/src/services/alerting/quiet-hours.test.ts +140 -0
  58. package/src/services/alerting/quiet-hours.ts +99 -0
  59. package/src/services/alerting/reconcile.test.ts +190 -0
  60. package/src/services/alerting/reconcile.ts +166 -0
  61. package/src/services/alerting/run-monitor.test.ts +185 -0
  62. package/src/services/alerting/run-monitor.ts +177 -0
  63. package/src/services/alerting/store.test.ts +222 -0
  64. package/src/services/alerting/store.ts +289 -0
  65. package/src/services/alerting/suppression.test.ts +228 -0
  66. package/src/services/alerting/suppression.ts +142 -0
  67. package/src/services/alerting/sweep-runner.test.ts +229 -0
  68. package/src/services/alerting/sweep-runner.ts +204 -0
  69. package/src/services/alerting/sweep.test.ts +61 -0
  70. package/src/services/alerting/sweep.ts +41 -0
  71. package/src/services/alerting/tokens.test.ts +152 -0
  72. package/src/services/alerting/tokens.ts +119 -0
  73. package/src/services/alerting/transport-loader.ts +48 -0
  74. package/src/services/audit/index.test.ts +1 -0
  75. package/src/services/audit/index.ts +3 -0
  76. package/src/services/audit/trusted-sources.test.ts +137 -0
  77. package/src/services/audit/trusted-sources.ts +124 -0
  78. package/src/services/audit/types.ts +2 -1
  79. package/src/services/firewall-reach.ts +83 -0
  80. package/src/services/health-runner.test.ts +50 -0
  81. package/src/services/health-runner.ts +116 -82
  82. package/src/services/machine-pool.ts +2 -1
  83. package/src/services/module-deploy.ts +17 -0
  84. package/src/services/system-config-validator.test.ts +31 -1
  85. package/src/services/trusted-sources.test.ts +221 -0
  86. package/src/services/trusted-sources.ts +159 -0
  87. package/src/services/update/orchestrator.test.ts +1 -0
  88. 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
+ }