@ciromaciel/contract 1.2.0 → 1.4.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 CHANGED
@@ -133,6 +133,22 @@ 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
+
144
+ ### `@ciromaciel/contract/feedback`
145
+
146
+ ```js
147
+ import { createFeedback, FEEDBACK_SQL } from '@ciromaciel/contract/feedback'
148
+ ```
149
+
150
+ "Sugerir ou relatar", the corner button of every panel (`FeedbackWidget` in `@ciromaciel/dashboard-kit`), on the worker side. `createFeedback({ product, prefix, error, requirePerson, locate, fields })` returns `handleRoutes` (`GET`/`POST /api/feedback`, behind the login), `handleHooks` (the Tasks' signed answers at `/feedback/tasks` and the build job's news at `/feedback/build`) and `runCron` (resend what the Tasks did not take; resend and then report what the triage never took). A request carries `<prefix>-<6 hex>` (`DK-1A2B3C`) and its thread is `<product>:<ref>`. `FEEDBACK_SQL` is the table; the product adds its own columns (`deck_id`) and names them in `locate().columns`. Only a person sends, and `locate` names only the account's own things. Env: `FEEDBACK_URL`, `FEEDBACK_SECRET`, `TASKS_WEBHOOK_SECRET`, `BUILD_SECRET`.
151
+
136
152
  ## Links
137
153
 
138
154
  - [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.4.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,9 @@
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",
20
+ "./feedback": "./src/feedback.js"
19
21
  },
20
22
  "scripts": {
21
23
  "test": "bun test tests/"
@@ -0,0 +1,417 @@
1
+ // ============================================================================
2
+ // @ciromaciel/contract/feedback — "Sugerir ou relatar", the same in every product
3
+ // ============================================================================
4
+ //
5
+ // import { createFeedback, FEEDBACK_SQL } from '@ciromaciel/contract/feedback'
6
+ //
7
+ // const feedback = createFeedback({
8
+ // product: 'decks', prefix: 'DK',
9
+ // error: (code, message, details) => new ContractError(code, message, details),
10
+ // requirePerson: auth => requirePerson(auth, ContractError, CODES, 'Mandar uma sugestão'),
11
+ // locate: async ({ db, auth, body }) => ({ columns: { deck_id: … }, where: { deckTitle: … } }),
12
+ // fields: row => ({ deckId: row.deck_id }),
13
+ // })
14
+ //
15
+ // The panel's corner button (dashboard-kit FeedbackWidget) is the door to the improvement agent
16
+ // (manager/docs/2026-10-11-AGENTE-DE-MELHORIAS.html). Blogs was the pilot, 11/10/2026; this module
17
+ // is its routes/feedback.js with the product's pieces passed in, so the second product is a config
18
+ // and not a copy.
19
+ //
20
+ // POST /api/feedback { kind: problem|idea, happens, wish?, context, …the product's ids }
21
+ // GET /api/feedback the account's own, newest first, with where each one is
22
+ // POST /feedback/tasks the Tasks' signed request.created / request.answered (the way back)
23
+ // POST /feedback/build the build job's signed news: building, review (with the preview), live
24
+ //
25
+ // The person writes only what happens and what they would like. WHERE comes from the panel
26
+ // (`context`: the screen, the address, the browser, the window) and from the product (`locate`:
27
+ // the names of its own things, only when they are the account's): the agent starts knowing the
28
+ // place, not asking.
29
+ //
30
+ // The row is the record. When FEEDBACK_URL and FEEDBACK_SECRET are set (the Tasks' source
31
+ // "Melhorias dos produtos"), a signed `feedback.created` goes there after the answer; a failed
32
+ // send is tried again by the product's cron (`forwardPending`), and a feedback the triage never
33
+ // took is sent once more and then reported stuck (`watchTriage`). Only a person sends.
34
+ //
35
+ // ENV the product's worker reads: FEEDBACK_URL, FEEDBACK_SECRET (the source), TASKS_WEBHOOK_SECRET
36
+ // (the Tasks' outgoing webhook, the way back), BUILD_SECRET (the build job's news).
37
+
38
+ import { CODES } from './codes.js'
39
+ import { collection, created, ok, page } from './responses.js'
40
+ import { envelope, sendSigned } from './outbox.js'
41
+ import { verifyWebhook } from './webhooks.js'
42
+
43
+ /**
44
+ * The table every product keeps. A product adds its own columns for its things (Blogs: blog_id,
45
+ * post_id; Decks: deck_id) in its migration, and names them in `locate().columns`.
46
+ */
47
+ export const FEEDBACK_SQL = `
48
+ CREATE TABLE IF NOT EXISTS feedback (
49
+ id TEXT PRIMARY KEY,
50
+ account_id TEXT NOT NULL,
51
+ number INTEGER NOT NULL,
52
+ kind TEXT NOT NULL,
53
+ happens TEXT NOT NULL,
54
+ wish TEXT NOT NULL DEFAULT '',
55
+ context TEXT NOT NULL DEFAULT '{}',
56
+ reporter_name TEXT NOT NULL DEFAULT '',
57
+ reporter_email TEXT NOT NULL DEFAULT '',
58
+ status TEXT NOT NULL DEFAULT 'received',
59
+ status_note TEXT NOT NULL DEFAULT '',
60
+ forwarded_at INTEGER,
61
+ forward_status INTEGER,
62
+ retries INTEGER NOT NULL DEFAULT 0,
63
+ retried_at INTEGER,
64
+ stuck_at INTEGER,
65
+ created_at INTEGER NOT NULL,
66
+ updated_at INTEGER NOT NULL,
67
+ UNIQUE (account_id, number)
68
+ );
69
+ CREATE INDEX IF NOT EXISTS idx_feedback_account ON feedback (account_id, created_at DESC);
70
+ CREATE INDEX IF NOT EXISTS idx_feedback_forward ON feedback (forwarded_at, created_at);
71
+ `
72
+
73
+ export const FEEDBACK_KINDS = ['problem', 'idea']
74
+
75
+ export const FEEDBACK_STATUS_LABELS = {
76
+ received: 'Recebido',
77
+ triaged: 'Em análise',
78
+ approved: 'Aprovado',
79
+ building: 'Em construção',
80
+ review: 'Em revisão',
81
+ live: 'No ar',
82
+ declined: 'Não vamos fazer',
83
+ needs_info: 'Precisa de mais detalhes',
84
+ }
85
+
86
+ /** How long a feedback may sit in "Recebido" after the Tasks took it before the next step. */
87
+ export const STUCK_AFTER = 15 * 60
88
+
89
+ /** Requests about a feedback that are not its triage: their creation moves nothing. */
90
+ const FOLLOW_UP = / · (Revisar|A construção parou|A sugestão travou)\b/
91
+
92
+ /** The Ciro's choice in the Tasks, as the status the person sees. */
93
+ const OPTION_STATUS = { 'Aprovar e construir': 'approved', 'Pedir mais detalhes à pessoa': 'needs_info', 'Não vamos fazer': 'declined' }
94
+
95
+ /** The choices that start the build job: the first approval builds; "Tentar de novo" answers a build that stopped. */
96
+ const OPTION_JOB = { 'Aprovar e construir': 'build', 'Tentar de novo': 'build' }
97
+ const RESEND_OPTION = 'Mandar de novo ao agente'
98
+
99
+ /** The review's native answers (Tasks: kind review), as the job they start. */
100
+ const REVIEW_JOB = { approved: 'ship', changes: 'revise', refused: 'discard' }
101
+
102
+ const TRIAGE_PHASE = 'Triagem'
103
+ const BUILD_STATUSES = ['building', 'review', 'live', 'approved', 'declined']
104
+ const MAX_TEXT = 4000
105
+ const CONTEXT_KEYS = { screen: 80, section: 40, path: 500, url: 1000, viewport: 20, browser: 300, language: 20, panelVersion: 40, timezone: 60 }
106
+ const COLUMN = /^[a-z][a-z0-9_]{0,40}$/
107
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
108
+
109
+ const text = (value, max) =>
110
+ String(value ?? '')
111
+ .replace(/\r\n?/g, '\n')
112
+ .trim()
113
+ .slice(0, max)
114
+ const parse = (value, fallback) => {
115
+ try {
116
+ return value ? JSON.parse(value) : fallback
117
+ } catch {
118
+ return fallback
119
+ }
120
+ }
121
+ const isOk = status => status >= 200 && status < 300
122
+ export const isUuid = value => typeof value === 'string' && UUID.test(value)
123
+
124
+ /** What the panel says about where it happened, kept to the known keys and sizes. */
125
+ export function cleanContext(value) {
126
+ const input = value && typeof value === 'object' ? value : {}
127
+ return Object.fromEntries(
128
+ Object.entries(CONTEXT_KEYS)
129
+ .map(([key, max]) => [key, text(input[key], max)])
130
+ .filter(([, v]) => v)
131
+ )
132
+ }
133
+
134
+ /**
135
+ * The feedback of one product.
136
+ *
137
+ * product 'blogs' — the event's product, the thread's key (`blogs:BL-…`), the machines' groups
138
+ * prefix 'BL' — the code a request carries across systems: BL- and the first 6 hex of the id
139
+ * error (code, message, details) => Error the product's worker turns into its failure()
140
+ * requirePerson auth => void, throws when it is not a person (a key or an agent never sends)
141
+ * locate async ({ db, auth, body }) => { columns, where }: the product's columns of the row
142
+ * and the names added to the context; only what is the account's (never another's name)
143
+ * fields row => object: the product's ids in the API and in the events ({ blogId, postId })
144
+ * userAgent of the signed sends
145
+ * fetch for the tests; the global one by default
146
+ */
147
+ export function createFeedback({ product, prefix, error, requirePerson, locate = async () => ({}), fields = () => ({}), userAgent = `ciromaciel-${product}/1.0`, fetch: fetchImpl }) {
148
+ if (!/^[a-z][a-z0-9-]{0,30}$/.test(String(product))) throw new Error('createFeedback: product is a lowercase key, like "decks".')
149
+ if (!/^[A-Z]{2,4}$/.test(String(prefix))) throw new Error('createFeedback: prefix is 2 to 4 capital letters, like "DK".')
150
+ const fail = (code, message, details) => error(code, message, details)
151
+
152
+ const refOf = id => `${prefix}-${String(id).replace(/-/g, '').slice(0, 6).toUpperCase()}`
153
+ const REF = new RegExp(`^${prefix}-([0-9A-F]{6})\\b`)
154
+ const WORK_KEY = new RegExp(`^${product}:(${prefix}-[0-9A-F]{6})$`)
155
+ const workKeyOf = id => `${product}:${refOf(id)}`
156
+ const byRef = (db, hex) => db.prepare('SELECT * FROM feedback WHERE id LIKE ?').bind(`${hex.toLowerCase()}%`).first()
157
+
158
+ function serialize(row) {
159
+ return {
160
+ id: row.id,
161
+ number: row.number,
162
+ ref: refOf(row.id),
163
+ kind: row.kind,
164
+ happens: row.happens,
165
+ wish: row.wish,
166
+ ...fields(row),
167
+ context: parse(row.context, {}),
168
+ status: row.status,
169
+ statusLabel: FEEDBACK_STATUS_LABELS[row.status] ?? row.status,
170
+ statusNote: row.status_note,
171
+ createdAt: row.created_at,
172
+ updatedAt: row.updated_at,
173
+ }
174
+ }
175
+
176
+ /** The event the Tasks receives: everything the triage needs, in one piece. A resend is another event id. */
177
+ function eventOf(row, type = 'feedback.created', attempt = 0, extra = {}, eventId = null) {
178
+ return {
179
+ eventId: eventId ?? `${row.id}.${type}${attempt ? `.retry${attempt}` : ''}`,
180
+ body: envelope({
181
+ type,
182
+ at: row.created_at,
183
+ channel: 'panel',
184
+ person: { email: row.reporter_email || null, name: row.reporter_name || null },
185
+ properties: { product, id: row.id, ref: refOf(row.id), number: row.number, accountId: row.account_id, kind: row.kind, happens: row.happens, wish: row.wish, ...fields(row), ...parse(row.context, {}), ...extra },
186
+ }),
187
+ }
188
+ }
189
+
190
+ const isWired = env => Boolean(env.FEEDBACK_URL && env.FEEDBACK_SECRET)
191
+
192
+ /** One signed event to the Tasks' source; the HTTP status (0 when it did not answer). */
193
+ async function send(env, event) {
194
+ try {
195
+ const answer = await sendSigned({ url: env.FEEDBACK_URL, secret_ciphertext: env.FEEDBACK_SECRET }, event, { decryptSecret: async secret => secret, userAgent, ...(fetchImpl ? { fetch: fetchImpl } : {}) })
196
+ return answer?.status ?? 0
197
+ } catch {
198
+ return 0
199
+ }
200
+ }
201
+
202
+ async function forward(env, db, row, now) {
203
+ if (!isWired(env)) return false
204
+ const status = await send(env, eventOf(row))
205
+ await db
206
+ .prepare('UPDATE feedback SET forwarded_at = ?, forward_status = ? WHERE id = ?')
207
+ .bind(isOk(status) ? now : null, status, row.id)
208
+ .run()
209
+ return isOk(status)
210
+ }
211
+
212
+ /** Each status a feedback reaches, once (`feedback.status`): the Tasks' boards count the funnel from it. */
213
+ async function announceStatus(env, row, status, now) {
214
+ if (!isWired(env) || status === row.status) return
215
+ await send(env, eventOf({ ...row, created_at: now }, 'feedback.status', 0, { status, statusLabel: FEEDBACK_STATUS_LABELS[status] ?? status }, `${row.id}.feedback.status.${status}`))
216
+ }
217
+
218
+ async function resend(env, db, row, now) {
219
+ const attempt = (row.retries ?? 0) + 1
220
+ const status = await send(env, eventOf(row, 'feedback.created', attempt))
221
+ if (isOk(status)) await db.prepare('UPDATE feedback SET retries = ?, retried_at = ?, stuck_at = NULL WHERE id = ?').bind(attempt, now, row.id).run()
222
+ return isOk(status)
223
+ }
224
+
225
+ /**
226
+ * The cron's watch over the triage: taken by the Tasks and still "Recebido", after 15 minutes
227
+ * it goes to the agent once more; 15 minutes after that, `feedback.stuck`, which a rule in the
228
+ * Tasks turns into a request to the Ciro ("A sugestão travou").
229
+ */
230
+ async function watchTriage(env, db, now) {
231
+ if (!isWired(env)) return { resent: 0, stuck: 0 }
232
+ const { results } = await db
233
+ .prepare("SELECT * FROM feedback WHERE status = 'received' AND forwarded_at IS NOT NULL AND stuck_at IS NULL AND created_at > ? ORDER BY created_at LIMIT 20")
234
+ .bind(now - 3 * 86_400)
235
+ .all()
236
+ let resent = 0
237
+ let stuck = 0
238
+ for (const row of results) {
239
+ if (!row.retries && row.forwarded_at <= now - STUCK_AFTER) {
240
+ if (await resend(env, db, row, now)) resent++
241
+ } else if (row.retries && row.retried_at <= now - STUCK_AFTER) {
242
+ if (isOk(await send(env, eventOf(row, 'feedback.stuck', row.retries)))) {
243
+ await db.prepare('UPDATE feedback SET stuck_at = ? WHERE id = ?').bind(now, row.id).run()
244
+ stuck++
245
+ }
246
+ }
247
+ }
248
+ return { resent, stuck }
249
+ }
250
+
251
+ /** The cron's second chance: rows of the last 3 days the Tasks did not take yet. */
252
+ async function forwardPending(env, db, now) {
253
+ if (!isWired(env)) return 0
254
+ const { results } = await db
255
+ .prepare('SELECT * FROM feedback WHERE forwarded_at IS NULL AND created_at > ? ORDER BY created_at LIMIT 20')
256
+ .bind(now - 3 * 86_400)
257
+ .all()
258
+ let sent = 0
259
+ for (const row of results) if (await forward(env, db, row, now)) sent++
260
+ return sent
261
+ }
262
+
263
+ /** Both cron chores at once, for the product's scheduled(). */
264
+ async function runCron(env, db, now) {
265
+ return { sent: await forwardPending(env, db, now), ...(await watchTriage(env, db, now)) }
266
+ }
267
+
268
+ /**
269
+ * Starts the build job on the Tasks' machines: a signed `feedback.job` to the source, whose rule
270
+ * "Construir melhorias · <product>" runs the product's improvement job. `group` keeps two jobs of
271
+ * one group from running at once: the ships share one (two deploys never overlap), each
272
+ * improvement's build, revise and discard share another.
273
+ */
274
+ async function startJob(env, job, row, props) {
275
+ if (!isWired(env)) return 0
276
+ const feedback = { id: row.id, ref: refOf(row.id), number: row.number, kind: row.kind, happens: row.happens, wish: row.wish, context: parse(row.context, {}) }
277
+ const group = job === 'ship' ? `${product}-deploy` : `${product}-${feedback.ref.toLowerCase()}`
278
+ const payload = { feedback, workKey: workKeyOf(row.id), requestId: String(props.requestId ?? ''), note: String(props.note ?? '').slice(0, 2000), group }
279
+ const event = {
280
+ eventId: `${row.id}.feedback.job.${job}.${payload.requestId || Date.now()}`,
281
+ body: envelope({ type: 'feedback.job', at: Math.floor(Date.now() / 1000), channel: 'panel', person: { email: null, name: null }, properties: { product, ref: feedback.ref, job, ...payload } }),
282
+ }
283
+ return send(env, event)
284
+ }
285
+
286
+ async function readSigned(request, secret, maxBytes, now) {
287
+ const body = await request.text()
288
+ if (body.length > maxBytes) throw fail(CODES.PAYLOAD_TOO_LARGE, 'Evento grande demais.')
289
+ const check = await verifyWebhook([secret], { id: request.headers.get('x-ccm-id'), timestamp: request.headers.get('x-ccm-timestamp'), signature: request.headers.get('x-ccm-signature'), body }, now)
290
+ if (!check.ok) throw fail(CODES.UNAUTHORIZED, 'Assinatura inválida.')
291
+ return parse(body, {})
292
+ }
293
+
294
+ /**
295
+ * POST /feedback/tasks — public, signed with TASKS_WEBHOOK_SECRET. A request whose thread key
296
+ * (or, before the threads, whose title code) names a feedback moves it: created in the triage →
297
+ * Em análise; answered → the option the Ciro chose; refused → Não vamos fazer. Any other request
298
+ * of the Ciro's Tasks is acknowledged and ignored.
299
+ */
300
+ async function handleTasksHook({ request, env, db, pathname, method, now }) {
301
+ if (pathname !== '/feedback/tasks' || method !== 'POST') return null
302
+ if (!env.TASKS_WEBHOOK_SECRET) return null
303
+ const event = await readSigned(request, env.TASKS_WEBHOOK_SECRET, 64 * 1024, now)
304
+ const props = event?.data?.properties ?? {}
305
+ const fromWork = WORK_KEY.exec(String(props.workKey ?? ''))
306
+ const match = fromWork ? REF.exec(fromWork[1]) : REF.exec(String(props.title ?? ''))
307
+ if (!match || !['request.created', 'request.answered'].includes(event.type)) return ok({ accepted: 0 })
308
+ const row = await byRef(db, match[1])
309
+ if (!row) return ok({ accepted: 0 })
310
+ let status = row.status
311
+ let note = ''
312
+ let job = null
313
+ const isTriage = props.workKey ? props.phase === TRIAGE_PHASE : !FOLLOW_UP.test(String(props.title))
314
+ if (event.type === 'request.created' && row.status === 'received' && isTriage) status = 'triaged'
315
+ if (event.type === 'request.answered' && props.outcome === 'answered' && props.option === RESEND_OPTION) {
316
+ const sent = await resend(env, db, row, now)
317
+ return ok({ accepted: 1, status: row.status, resent: sent })
318
+ }
319
+ if (event.type === 'request.answered' && props.kind === 'review' && props.decision) {
320
+ // The review, answered natively: approve ships, changes rebuilds on the same branch with
321
+ // the note (meant for whoever redoes it, not for the person), refuse discards.
322
+ job = REVIEW_JOB[props.decision] ?? null
323
+ if (props.decision === 'refused') status = 'declined'
324
+ note = props.decision === 'changes' ? '' : String(props.note ?? '').slice(0, 500)
325
+ } else if (event.type === 'request.answered') {
326
+ if (props.outcome === 'refused') status = 'declined'
327
+ else if (props.outcome === 'answered') {
328
+ status = OPTION_STATUS[props.option] ?? status
329
+ job = OPTION_JOB[props.option] ?? null
330
+ }
331
+ note = String(props.note ?? '').slice(0, 500)
332
+ }
333
+ // "Pôr no ar" keeps "Em revisão" until the deploy is over: the job itself says "No ar".
334
+ const keepsNote = event.type !== 'request.answered' || props.decision === 'changes'
335
+ const isChanged = status !== row.status || (note && !keepsNote)
336
+ if (isChanged) {
337
+ await db
338
+ .prepare('UPDATE feedback SET status = ?, status_note = ?, updated_at = ? WHERE id = ?')
339
+ .bind(status, keepsNote ? row.status_note : note, now, row.id)
340
+ .run()
341
+ await announceStatus(env, row, status, now)
342
+ }
343
+ const dispatched = job ? await startJob(env, job, row, props) : null
344
+ if (!isChanged && !job) return ok({ accepted: 0 })
345
+ return ok({ accepted: 1, status, ...(job ? { job, dispatched } : {}) })
346
+ }
347
+
348
+ /** POST /feedback/build — public, signed with BUILD_SECRET: { ref, status, note? }, the job's news. */
349
+ async function handleBuildHook({ request, env, db, pathname, method, now }) {
350
+ if (pathname !== '/feedback/build' || method !== 'POST') return null
351
+ if (!env.BUILD_SECRET) return null
352
+ const input = await readSigned(request, env.BUILD_SECRET, 16 * 1024, now)
353
+ const match = REF.exec(String(input.ref ?? ''))
354
+ if (!match) throw fail(CODES.VALIDATION_ERROR, `Falta o código (${prefix}-…).`, { field: 'ref' })
355
+ if (!BUILD_STATUSES.includes(input.status)) throw fail(CODES.VALIDATION_ERROR, `O status é ${BUILD_STATUSES.join(', ')}.`, { field: 'status' })
356
+ const row = await byRef(db, match[1])
357
+ if (!row) return ok({ accepted: 0 })
358
+ await db
359
+ .prepare('UPDATE feedback SET status = ?, status_note = ?, updated_at = ? WHERE id = ?')
360
+ .bind(input.status, text(input.note, 500), now, row.id)
361
+ .run()
362
+ await announceStatus(env, row, input.status, now)
363
+ return ok({ accepted: 1, status: input.status })
364
+ }
365
+
366
+ /** The open hooks, in one call for the product's public chain. */
367
+ async function handleHooks(context) {
368
+ return (await handleTasksHook(context)) ?? (await handleBuildHook(context))
369
+ }
370
+
371
+ /** GET and POST /api/feedback, behind the product's login. */
372
+ async function handleRoutes(context) {
373
+ const { request, env, db, auth, pathname, method, now, ctx } = context
374
+ if (pathname !== '/api/feedback') return null
375
+
376
+ if (method === 'GET') {
377
+ const { results } = await db.prepare('SELECT * FROM feedback WHERE account_id = ? ORDER BY created_at DESC LIMIT 50').bind(auth.accountId).all()
378
+ return collection(results.map(serialize), page({ limit: 50, offset: 0, total: results.length }))
379
+ }
380
+
381
+ if (method === 'POST') {
382
+ requirePerson(auth)
383
+ const body = await readBody(request)
384
+ const kind = String(body.kind ?? '')
385
+ if (!FEEDBACK_KINDS.includes(kind)) throw fail(CODES.VALIDATION_ERROR, 'Escolha se é um problema ou uma melhoria.', { field: 'kind' })
386
+ const happens = text(body.happens, MAX_TEXT)
387
+ if (happens.length < 5) throw fail(CODES.VALIDATION_ERROR, kind === 'problem' ? 'Conte o que aconteceu.' : 'Conte a sua ideia.', { field: 'happens' })
388
+ const wish = text(body.wish, MAX_TEXT)
389
+ const located = (await locate({ db, auth, body })) ?? {}
390
+ const columns = Object.entries(located.columns ?? {}).filter(([name]) => COLUMN.test(name))
391
+ const where = { ...cleanContext(body.context), ...(located.where ?? {}) }
392
+ const id = crypto.randomUUID()
393
+ const number = ((await db.prepare('SELECT MAX(number) AS n FROM feedback WHERE account_id = ?').bind(auth.accountId).first())?.n ?? 0) + 1
394
+ const names = ['id', 'account_id', 'number', ...columns.map(([name]) => name), 'kind', 'happens', 'wish', 'context', 'reporter_name', 'reporter_email', 'created_at', 'updated_at']
395
+ const values = [id, auth.accountId, number, ...columns.map(([, value]) => value ?? null), kind, happens, wish, JSON.stringify(where), text(auth.name, 120), text(auth.email, 200), now, now]
396
+ await db
397
+ .prepare(`INSERT INTO feedback (${names.join(', ')}) VALUES (${names.map(() => '?').join(', ')})`)
398
+ .bind(...values)
399
+ .run()
400
+ const row = await db.prepare('SELECT * FROM feedback WHERE id = ?').bind(id).first()
401
+ // To the Tasks after the answer: the person never waits for the triage's door.
402
+ if (ctx?.waitUntil) ctx.waitUntil(forward(env, db, row, now).catch(() => {}))
403
+ return created(serialize(row))
404
+ }
405
+ return null
406
+ }
407
+
408
+ async function readBody(request) {
409
+ const raw = await request.text()
410
+ if (raw.length > 64 * 1024) throw fail(CODES.PAYLOAD_TOO_LARGE, 'Texto grande demais.')
411
+ const body = parse(raw, null)
412
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw fail(CODES.VALIDATION_ERROR, 'O corpo é um objeto JSON.')
413
+ return body
414
+ }
415
+
416
+ return { product, prefix, refOf, workKeyOf, serialize, handleRoutes, handleHooks, handleTasksHook, handleBuildHook, forwardPending, watchTriage, runCron }
417
+ }
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
+ }