@ciromaciel/contract 1.2.0 → 1.3.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.
Files changed (3) hide show
  1. package/README.md +8 -0
  2. package/package.json +3 -2
  3. package/src/outbox.js +237 -0
package/README.md CHANGED
@@ -133,6 +133,14 @@ Both return `{ ok: true, subject, email, name, scopes, claims }` or
133
133
  the `riligar-auth` issuer only. `jose` (5 or 6) is an **optional** peer, needed
134
134
  only by this entrypoint.
135
135
 
136
+ ### `@ciromaciel/contract/outbox`
137
+
138
+ ```js
139
+ import { OUTBOX_SQL, envelope, enqueueEvent, deliverDue, releaseHeld, retryDelivery } from '@ciromaciel/contract/outbox'
140
+ ```
141
+
142
+ The delivery queue every product shares. Each event is a row in `outbox` before it leaves, one per subscribed endpoint, signed with `./webhooks`. A 2xx is `sent`; 401 and 403 are `held` (probed hourly, released at once by `releaseHeld` when the secret changes); 410 is `failed`; anything else retries after 1 min, 5 min, 30 min, 2 h and 12 h. `OUTBOX_SQL` is the schema to put in the product's first migration. The product supplies a D1-shaped `db` and `decryptSecret(ciphertext)`.
143
+
136
144
  ## Links
137
145
 
138
146
  - [Website](https://ciromaciel.click)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciromaciel/contract",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A single response envelope, a closed catalogue of error codes, and helpers that make answering outside it impossible.",
@@ -15,7 +15,8 @@
15
15
  "./schemas": "./src/schemas.js",
16
16
  "./plan": "./src/plan/index.js",
17
17
  "./webhooks": "./src/webhooks.js",
18
- "./token": "./src/token.js"
18
+ "./token": "./src/token.js",
19
+ "./outbox": "./src/outbox.js"
19
20
  },
20
21
  "scripts": {
21
22
  "test": "bun test tests/"
package/src/outbox.js ADDED
@@ -0,0 +1,237 @@
1
+ // ============================================================================
2
+ // @ciromaciel/contract/outbox — the delivery queue every product shares
3
+ // ============================================================================
4
+ //
5
+ // import { OUTBOX_SQL, enqueueEvent, deliverDue, releaseHeld } from '@ciromaciel/contract/outbox'
6
+ //
7
+ // Every event a product emits leaves through webhook endpoints the account
8
+ // registers, signed with `./webhooks`. WRITTEN BEFORE IT LEAVES: each delivery
9
+ // is a row in `outbox` first (one per endpoint); trying it right away is a
10
+ // courtesy, and the cron is the guarantee.
11
+ //
12
+ // WHAT HAPPENS TO AN ATTEMPT:
13
+ //
14
+ // 2xx sent
15
+ // 401, 403 held: almost always a rotated secret or a revoked key on
16
+ // the other side. Retrying five times would only kill the
17
+ // delivery in 14 hours, while the fix is one paste away.
18
+ // It waits, probed once an hour, and leaves on its own
19
+ // when the endpoint answers 2xx again — or at once when
20
+ // the secret is replaced here (`releaseHeld`).
21
+ // 410 failed: the receiver said this address is gone.
22
+ // anything else retried after 1 min, 5 min, 30 min, 2 h and 12 h, then
23
+ // failed — visible on the screen and resendable by hand.
24
+ //
25
+ // WHY IT LIVES HERE. Videos wrote it, Community copied it, and four products
26
+ // were about to copy it again. One fix should not become six.
27
+ //
28
+ // IT ASKS THE PRODUCT FOR TWO THINGS ONLY: a D1-shaped `db` (prepare, bind,
29
+ // first, all, run, batch) and a `decryptSecret(ciphertext)` that returns the
30
+ // endpoint's `whsec_`. Envelopes are built by the product with `envelope()`.
31
+
32
+ import { webhookHeaders } from './webhooks.js'
33
+
34
+ /** Seconds before each retry of an ordinary failure. */
35
+ export const RETRY_DELAYS = [60, 300, 1800, 7200, 43_200]
36
+
37
+ /** How often a held delivery is probed, in seconds. */
38
+ export const HOLD_PROBE_SECONDS = 3600
39
+
40
+ const DELIVERY_TIMEOUT_MS = 10_000
41
+
42
+ /**
43
+ * The two tables, as products put them in their first migration. Same columns
44
+ * as Videos' `0002_webhook_endpoints.sql`, plus the `held` status.
45
+ */
46
+ export const OUTBOX_SQL = `
47
+ CREATE TABLE webhook_endpoints (
48
+ id TEXT PRIMARY KEY,
49
+ account_id TEXT NOT NULL,
50
+ url TEXT NOT NULL,
51
+ description TEXT,
52
+ events TEXT NOT NULL DEFAULT '["*"]', -- JSON array of types, or ["*"]
53
+ secret_ciphertext TEXT NOT NULL,
54
+ secret_prefix TEXT NOT NULL,
55
+ secret_source TEXT NOT NULL DEFAULT 'generated', -- generated | provided
56
+ is_active INTEGER NOT NULL DEFAULT 1,
57
+ last_status INTEGER,
58
+ last_at INTEGER,
59
+ created_at INTEGER NOT NULL,
60
+ updated_at INTEGER NOT NULL
61
+ );
62
+ CREATE INDEX idx_webhook_endpoints_account ON webhook_endpoints (account_id, created_at);
63
+
64
+ CREATE TABLE outbox (
65
+ id TEXT PRIMARY KEY,
66
+ account_id TEXT NOT NULL,
67
+ target TEXT NOT NULL, -- webhook_endpoints.id
68
+ event_id TEXT NOT NULL, -- x-ccm-id: resending is a no-op on the other side
69
+ type TEXT NOT NULL,
70
+ subject_id TEXT, -- what the event is about (a response, a link), to list deliveries per item
71
+ body TEXT NOT NULL,
72
+ status TEXT NOT NULL DEFAULT 'pending', -- pending | sent | held | failed
73
+ attempts INTEGER NOT NULL DEFAULT 0,
74
+ next_at INTEGER NOT NULL,
75
+ last_status INTEGER,
76
+ last_error TEXT,
77
+ created_at INTEGER NOT NULL,
78
+ sent_at INTEGER,
79
+ UNIQUE (target, event_id)
80
+ );
81
+ CREATE INDEX idx_outbox_due ON outbox (status, next_at);
82
+ CREATE INDEX idx_outbox_account ON outbox (account_id, created_at DESC);
83
+ CREATE INDEX idx_outbox_target ON outbox (target, created_at DESC);
84
+ CREATE INDEX idx_outbox_subject ON outbox (account_id, subject_id);
85
+ `
86
+
87
+ /** Whether an endpoint's subscription covers `type`. */
88
+ export function subscribes(endpoint, type) {
89
+ let events
90
+ try {
91
+ events = JSON.parse(endpoint.events ?? '["*"]')
92
+ } catch {
93
+ events = ['*']
94
+ }
95
+ return events.includes('*') || events.includes(type)
96
+ }
97
+
98
+ /**
99
+ * The envelope People reads from any source: `{type, timestamp, data}`, the
100
+ * person in `data.person`, where they came through in `data.channel`, the rest
101
+ * in `data.properties`. Empty person fields are dropped.
102
+ */
103
+ export function envelope({ type, at, channel = 'web', person = null, properties = {} }) {
104
+ const data = { channel, properties }
105
+ if (person) {
106
+ const clean = Object.fromEntries(Object.entries(person).filter(([, value]) => value !== undefined && value !== null && value !== ''))
107
+ if (Object.keys(clean).length) data.person = clean
108
+ }
109
+ return JSON.stringify({ type, timestamp: new Date(at * 1000).toISOString(), data })
110
+ }
111
+
112
+ /**
113
+ * What one attempt turns into. `attempts` counts this one.
114
+ *
115
+ * @returns {{status: 'sent'|'pending'|'held'|'failed', nextIn: number|null}}
116
+ */
117
+ export function classifyAttempt({ ok, status }, attempts) {
118
+ if (ok) return { status: 'sent', nextIn: null }
119
+ if (status === 401 || status === 403) return { status: 'held', nextIn: HOLD_PROBE_SECONDS }
120
+ if (status === 410) return { status: 'failed', nextIn: null }
121
+ if (attempts > RETRY_DELAYS.length) return { status: 'failed', nextIn: null }
122
+ return { status: 'pending', nextIn: RETRY_DELAYS[attempts - 1] }
123
+ }
124
+
125
+ /**
126
+ * Queues one event for every active endpoint of the account subscribed to its
127
+ * type. Returns the outbox ids, so the caller can try them at once.
128
+ *
129
+ * @param {object} db D1 or anything shaped like it
130
+ * @param {{accountId: string, eventId: string, type: string, body: string, at: number, subjectId?: string}} event
131
+ */
132
+ export async function enqueueEvent(db, { accountId, eventId, type, body, at, subjectId = null }) {
133
+ const { results: active } = await db.prepare('SELECT id, events FROM webhook_endpoints WHERE account_id = ? AND is_active = 1').bind(accountId).all()
134
+ const targets = active.filter(endpoint => subscribes(endpoint, type))
135
+ if (!targets.length) return []
136
+ const ids = []
137
+ const statements = targets.map(endpoint => {
138
+ const id = crypto.randomUUID()
139
+ ids.push(id)
140
+ return db
141
+ .prepare('INSERT OR IGNORE INTO outbox (id, account_id, target, event_id, type, subject_id, body, next_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
142
+ .bind(id, accountId, endpoint.id, eventId, type, subjectId, body, at, at)
143
+ })
144
+ await db.batch(statements)
145
+ return ids
146
+ }
147
+
148
+ async function post(fetchImpl, url, headers, body, userAgent) {
149
+ const started = Date.now()
150
+ try {
151
+ const response = await fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': userAgent, ...headers }, body, signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS) })
152
+ return { status: response.status, ok: response.ok, ms: Date.now() - started, error: response.ok ? null : (await response.text()).slice(0, 300) }
153
+ } catch (error) {
154
+ return { status: 0, ok: false, ms: Date.now() - started, error: error?.name === 'TimeoutError' ? 'Sem resposta em 10 s.' : 'Não foi possível conectar.' }
155
+ }
156
+ }
157
+
158
+ /**
159
+ * One signed POST to an endpoint: a delivery, or the "Enviar teste" button.
160
+ *
161
+ * @param {object} endpoint a `webhook_endpoints` row
162
+ * @param {{eventId: string, body: string}} delivery
163
+ * @param {{decryptSecret: (ciphertext: string) => Promise<string>, fetch?: typeof fetch, userAgent?: string}} options
164
+ */
165
+ export async function sendSigned(endpoint, { eventId, body }, { decryptSecret, fetch: fetchImpl = fetch, userAgent = 'ciromaciel-webhooks/1.0' }) {
166
+ const secret = await decryptSecret(endpoint.secret_ciphertext)
167
+ return post(fetchImpl, endpoint.url, await webhookHeaders(secret, { id: eventId, body }), body, userAgent)
168
+ }
169
+
170
+ /**
171
+ * Delivers due rows: those `ids`, or every pending or held row whose time has
172
+ * come. Returns counts for the cron's log.
173
+ *
174
+ * @param {object} db
175
+ * @param {{now: number, ids?: string[]|null, limit?: number, decryptSecret: Function, fetch?: typeof fetch, userAgent?: string}} options
176
+ */
177
+ export async function deliverDue(db, { now, ids = null, limit = 50, decryptSecret, fetch: fetchImpl = fetch, userAgent }) {
178
+ const rows = ids
179
+ ? ids.length
180
+ ? (
181
+ await db
182
+ .prepare(`SELECT * FROM outbox WHERE status IN ('pending', 'held') AND id IN (${ids.map(() => '?').join(',')})`)
183
+ .bind(...ids)
184
+ .all()
185
+ ).results
186
+ : []
187
+ : (await db.prepare("SELECT * FROM outbox WHERE status IN ('pending', 'held') AND next_at <= ? ORDER BY next_at LIMIT ?").bind(now, limit).all()).results
188
+ const endpoints = new Map()
189
+ const counts = { due: rows.length, sent: 0, held: 0, failed: 0, retrying: 0 }
190
+ for (const row of rows) {
191
+ if (!endpoints.has(row.target)) endpoints.set(row.target, await db.prepare('SELECT * FROM webhook_endpoints WHERE id = ? AND account_id = ?').bind(row.target, row.account_id).first())
192
+ const endpoint = endpoints.get(row.target)
193
+ if (!endpoint || !endpoint.is_active) {
194
+ await db
195
+ .prepare("UPDATE outbox SET status = 'failed', last_error = ? WHERE id = ?")
196
+ .bind(endpoint ? 'Endpoint desligado.' : 'Endpoint removido.', row.id)
197
+ .run()
198
+ counts.failed++
199
+ continue
200
+ }
201
+ const result = await sendSigned(endpoint, { eventId: row.event_id, body: row.body }, { decryptSecret, fetch: fetchImpl, userAgent })
202
+ // A held row probing again does not spend the ordinary retries.
203
+ const attempts = row.status === 'held' ? row.attempts : row.attempts + 1
204
+ const next = classifyAttempt(result, Math.max(attempts, 1))
205
+ if (next.status === 'sent') {
206
+ await db.prepare("UPDATE outbox SET status = 'sent', attempts = ?, last_status = ?, last_error = NULL, sent_at = ? WHERE id = ?").bind(row.attempts + 1, result.status, now, row.id).run()
207
+ counts.sent++
208
+ } else if (next.status === 'failed') {
209
+ await db.prepare("UPDATE outbox SET status = 'failed', attempts = ?, last_status = ?, last_error = ? WHERE id = ?").bind(row.attempts + 1, result.status, result.error, row.id).run()
210
+ counts.failed++
211
+ } else {
212
+ await db
213
+ .prepare('UPDATE outbox SET status = ?, attempts = ?, last_status = ?, last_error = ?, next_at = ? WHERE id = ?')
214
+ .bind(next.status, attempts, result.status, result.error, now + next.nextIn, row.id)
215
+ .run()
216
+ if (next.status === 'held') counts.held++
217
+ else counts.retrying++
218
+ }
219
+ await db.prepare('UPDATE webhook_endpoints SET last_status = ?, last_at = ? WHERE id = ?').bind(result.status, now, endpoint.id).run()
220
+ }
221
+ return counts
222
+ }
223
+
224
+ /**
225
+ * Lets held deliveries of an endpoint go now — called when its secret or URL
226
+ * changes here, so the fix is felt at once instead of at the next probe.
227
+ */
228
+ export async function releaseHeld(db, { accountId, endpointId, now }) {
229
+ const result = await db.prepare("UPDATE outbox SET status = 'pending', next_at = ? WHERE account_id = ? AND target = ? AND status = 'held'").bind(now, accountId, endpointId).run()
230
+ return result?.meta?.changes ?? 0
231
+ }
232
+
233
+ /** Puts one failed or held delivery back in line, for the "Reenviar" button. */
234
+ export async function retryDelivery(db, { accountId, id, now }) {
235
+ const result = await db.prepare("UPDATE outbox SET status = 'pending', attempts = 0, next_at = ? WHERE id = ? AND account_id = ? AND status IN ('failed', 'held')").bind(now, id, accountId).run()
236
+ return (result?.meta?.changes ?? 0) > 0
237
+ }