diff --git a/.changeset/webhook-drop-durable-record.md b/.changeset/webhook-drop-durable-record.md new file mode 100644 index 0000000000..b9a6b9d9df --- /dev/null +++ b/.changeset/webhook-drop-durable-record.md @@ -0,0 +1,19 @@ +--- +'@objectstack/service-messaging': patch +'@objectstack/plugin-webhooks': patch +'@objectstack/spec': patch +--- + +A dropped webhook subscription now leaves a durable record, and no operator action can turn that record into an unsigned delivery (#8069). + +When the auto-enqueuer cannot decrypt a webhook's signing secret or its custom header map, it drops the subscription rather than delivering unsigned (#7799, #7986). Until now the drop left no `sys_http_delivery` row at all: every matching record change was discarded with nothing an operator reading the delivery table could find. `#8043` made that loud in the logs; it did not make it durable. + +Each discarded event is now recorded as a `sys_http_delivery` row with `status: dead`, `attempts: 0`, and the cause and remedy in the existing `error` column — so it appears in the object's existing "Failures" view with no new lifecycle state and no migration. + +The record is unsendable by construction, which is the half that matters: + +- `redeliver()` refuses any terminal row with `attempts: 0`. Such a row was never sent, so re-sending it would be a **first** delivery — and a parked row carries no HMAC signature, because the secret that would have produced one is exactly what went missing. New error code `DELIVERY_NEVER_SENT` (409 on `POST /api/v1/webhooks/redeliver`). +- `redeliver()` also consults a producer-registered guard, so a webhook row whose `sys_webhook` subscription was deleted, or whose stored signing secret can no longer be recovered, is refused rather than replayed. A guard whose own lookup fails refuses too. +- The parked row never carries the authored header map, so a credential is not copied onto a row that will sit out the retention window without ever being sent. + +Redelivery of a genuine dead-letter is unchanged: the same bytes, the same signature. diff --git a/content/docs/automation/webhooks.mdx b/content/docs/automation/webhooks.mdx index 41b3b2601f..c9bfad8606 100644 --- a/content/docs/automation/webhooks.mdx +++ b/content/docs/automation/webhooks.mdx @@ -504,13 +504,20 @@ The plugin mounts exactly **one** custom endpoint: | Method | Path | Body | Purpose | |--------|-----------------------------|-------------------|--------------------------------------------------| -| POST | `/api/v1/webhooks/redeliver` | `{ deliveryId }` | Re-queue a previously failed/dead delivery. | +| POST | `/api/v1/webhooks/redeliver` | `{ deliveryId }` | Re-queue a delivery that was **attempted** and failed. A row that was never attempted cannot be redelivered. | It delegates to `messaging.redeliverHttp(deliveryId)` and is guarded by the better-auth session (any authenticated user). Failures answer in the declared envelope — `{ success: false, error: { code, message } }` — with `401` `UNAUTHENTICATED`, `400` `INVALID_REQUEST` or `MISSING_REQUIRED_FIELD`, `404` -`RESOURCE_NOT_FOUND`, `409` `DELIVERY_NOT_ELIGIBLE` (ADR-0112). There are no +`RESOURCE_NOT_FOUND`, `409` `DELIVERY_NOT_ELIGIBLE`, or `409` +`DELIVERY_NEVER_SENT` (ADR-0112). The last one is the refusal behind the table +row above: a row with `attempts = 0` is a durable record of a delivery that +could never be prepared — its webhook's signing secret could not be decrypted — +so it carries **no HMAC signature**, and re-queuing it would not replay +anything, it would send the payload for the first time and send it unsigned. +Fix the signing configuration instead; the subscription re-arms on its own and +subsequent events are delivered signed. There are no `/webhooks/:id/test`, `/rotate`, `/deliveries`, or per-delivery redeliver routes. ## 9. Concurrency & rate-limiting diff --git a/content/docs/references/api/analytics.mdx b/content/docs/references/api/analytics.mdx index 2917e8cf66..17119a4bfd 100644 --- a/content/docs/references/api/analytics.mdx +++ b/content/docs/references/api/analytics.mdx @@ -44,7 +44,7 @@ const result = AnalyticsEndpoint.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; title?: string; measures: object[]; dimensions: object[] }[]` | ✅ | Available cubes, each as the `CubeMeta` discovery projection — the cube name, its title, and the measures/dimensions a client may name in a query. A bare array: there is no `cubes` wrapper object, and no cube `sql` is published. | @@ -79,7 +79,7 @@ const result = AnalyticsEndpoint.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ rows: Record[]; fields: object[]; sql?: string }` | ✅ | | @@ -93,7 +93,7 @@ const result = AnalyticsEndpoint.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ sql: string; params: any[] }` | ✅ | | diff --git a/content/docs/references/api/auth.mdx b/content/docs/references/api/auth.mdx index 604206b6e2..e751c063d9 100644 --- a/content/docs/references/api/auth.mdx +++ b/content/docs/references/api/auth.mdx @@ -117,7 +117,7 @@ const result = AuthProvider.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ session: object; user: object; token?: string }` | ✅ | | @@ -153,7 +153,7 @@ const result = AuthProvider.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id: string; email: string; emailVerified: boolean; name: string; … }` | ✅ | | diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 7b61d4bb75..29cb938927 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -119,7 +119,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | The created flow definition | @@ -144,7 +144,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; deleted: boolean }` | ✅ | | @@ -187,7 +187,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | Full flow definition | @@ -213,7 +213,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id: string; flowName: string; flowVersion?: integer; status: Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| … +2 more>; … }` | ✅ | Full execution log with step details | @@ -241,7 +241,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ flows: object[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | @@ -269,7 +269,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ runs: object[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | @@ -295,7 +295,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; enabled: boolean }` | ✅ | | @@ -325,7 +325,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; output?: any; error?: string; durationMs?: number }` | ✅ | | @@ -351,7 +351,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | The updated flow definition | diff --git a/content/docs/references/api/batch.mdx b/content/docs/references/api/batch.mdx index 6b65527425..a4cfe24ab5 100644 --- a/content/docs/references/api/batch.mdx +++ b/content/docs/references/api/batch.mdx @@ -55,7 +55,7 @@ const result = BatchConfigSchema.parse(data); | :--- | :--- | :--- | :--- | | **id** | `string` | optional | Record ID if operation succeeded | | **success** | `boolean` | ✅ | Whether this record was processed successfully | -| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error (#4793). A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back (#7539). | +| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error (#4793). A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back (#7539). | | **data** | `Record` | optional | Full record data (if returnRecords=true) | | **index** | `number` | optional | Index of the record in the request array | | **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability (#3407/#3431/#3455): caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` (#2948) / TRUE `readonlyWhen` (#3042) on update, or the #3043 create-ingress strip. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. | @@ -122,7 +122,7 @@ const result = BatchConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index bf99253d6e..bbf306f1fb 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +261 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +262 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **message** | `string` | ✅ | Readable error message | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | @@ -123,6 +123,7 @@ const result = ApiErrorSchema.parse(data); * `DATASET_INVALID` * `DATASOURCE_ADMIN_ERROR` * `DELEGABLE_SCOPE_FAILED` +* `DELIVERY_NEVER_SENT` * `DELIVERY_NOT_ELIGIBLE` * `DESTRUCTIVE_CHANGE` * `DEVICE_CODE_FAILED` @@ -315,7 +316,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | @@ -354,7 +355,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id?: string; success: boolean; errors?: object[]; index?: number; … }[]` | ✅ | Results for each item in the batch | @@ -396,7 +397,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **id** | `string` | ✅ | ID of the deleted record | @@ -448,7 +449,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `Record[]` | ✅ | Array of matching records | | **pagination** | `{ total?: number; limit?: number; offset?: number; cursor?: string; … }` | ✅ | Pagination info | @@ -464,7 +465,7 @@ const result = ApiErrorSchema.parse(data); | :--- | :--- | :--- | :--- | | **id** | `string` | optional | Record ID if processed | | **success** | `boolean` | ✅ | | -| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | | +| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | | | **index** | `number` | optional | Index in original request | | **data** | `any` | optional | Result data (e.g. created record) | @@ -503,7 +504,7 @@ Key-value map of record data | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `Record` | ✅ | The requested or modified record | diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index be8f0d1820..5092fa4385 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -225,6 +225,7 @@ const result = ErrorCode.parse(data); * `DATASET_INVALID` * `DATASOURCE_ADMIN_ERROR` * `DELEGABLE_SCOPE_FAILED` +* `DELIVERY_NEVER_SENT` * `DELIVERY_NOT_ELIGIBLE` * `DESTRUCTIVE_CHANGE` * `DEVICE_CODE_FAILED` diff --git a/content/docs/references/api/export.mdx b/content/docs/references/api/export.mdx index 9b491b688f..bf2f78dc3f 100644 --- a/content/docs/references/api/export.mdx +++ b/content/docs/references/api/export.mdx @@ -57,7 +57,7 @@ const result = CreateExportJobRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ jobId: string; status: Enum<'pending' \| 'processing' \| 'completed' \| 'failed' \| 'cancelled' \| 'expired'>; estimatedRecords?: integer; createdAt: string }` | ✅ | | @@ -157,7 +157,7 @@ const result = CreateExportJobRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ jobId: string; status: Enum<'pending' \| 'processing' \| 'completed' \| 'failed' \| 'cancelled' \| 'expired'>; format: Enum<'csv' \| 'json' \| 'jsonl' \| 'xlsx' \| 'parquet'>; totalRecords?: integer; … }` | ✅ | | @@ -231,7 +231,7 @@ const result = CreateExportJobRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ jobId: string; downloadUrl: string; fileName: string; fileSize: integer; … }` | ✅ | | @@ -449,7 +449,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ totalRecords: integer; validRecords: integer; invalidRecords: integer; duplicateRecords: integer; … }` | ✅ | | @@ -488,7 +488,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ jobs: object[]; nextCursor?: string; hasMore: boolean }` | ✅ | | @@ -546,7 +546,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id: string; name: string; enabled: boolean; nextRunAt?: string; … }` | ✅ | | diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 2f7000e6b8..86ed440646 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -51,7 +51,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string \| Record; description?: string \| Record; icon?: string; … }` | ✅ | Full App Configuration | @@ -65,7 +65,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; icon?: string; description?: string }[]` | ✅ | List of available concepts (Objects, Apps, Flows) | @@ -92,7 +92,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ total: integer; succeeded: integer; failed: integer; errors?: object[] }` | ✅ | Bulk operation result | @@ -117,7 +117,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ type: string; name: string }` | ✅ | | @@ -131,7 +131,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ sourceType: string; sourceName: string; targetType: string; targetName: string; … }[]` | ✅ | Items this item depends on | @@ -145,7 +145,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ sourceType: string; sourceName: string; targetType: string; targetName: string; … }[]` | ✅ | Items that depend on this item | @@ -159,7 +159,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `Record` | optional | Effective metadata with all overlays applied | @@ -173,7 +173,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ exists: boolean }` | ✅ | | @@ -200,7 +200,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `any` | ✅ | Exported metadata bundle | @@ -228,7 +228,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ total: integer; imported: integer; skipped: integer; failed: integer; … }` | ✅ | Import result | @@ -242,7 +242,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ type: string; name: string; definition: Record }` | ✅ | Metadata item | @@ -256,7 +256,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `Record[]` | ✅ | Array of metadata definitions | @@ -270,7 +270,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `string[]` | ✅ | Array of metadata item names | @@ -284,7 +284,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id: string; baseType: string; baseName: string; packageId?: string; … }` | optional | Overlay definition, undefined if none | @@ -348,7 +348,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ items: object[]; total: integer; page: integer; pageSize: integer }` | ✅ | Paginated query result | @@ -406,7 +406,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ type: string; label: string; description?: string; filePatterns: string[]; … }` | optional | Type info | @@ -420,7 +420,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `string[]` | ✅ | Registered metadata type identifiers | @@ -446,7 +446,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ valid: boolean; errors?: object[]; warnings?: object[] }` | ✅ | Validation result | @@ -460,7 +460,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label?: string; pluralLabel?: string; description?: string; … }` | ✅ | Full Object Schema | diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index eca93192ca..d727d02ce1 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -57,7 +57,7 @@ Get installed package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ manifest: object; status?: Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>; enabled?: boolean; installedAt?: string; … }` | ✅ | Installed package details | @@ -89,7 +89,7 @@ List installed packages response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ packages: object[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | @@ -143,7 +143,7 @@ Install package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ package: object; dependencyResolution?: object; namespaceConflicts?: object[]; message?: string }` | ✅ | | @@ -185,7 +185,7 @@ Rollback package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; restoredVersion?: string; message?: string }` | ✅ | | @@ -220,7 +220,7 @@ Upgrade package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; phase: string; plan?: object; snapshotId?: string; … }` | ✅ | | @@ -250,7 +250,7 @@ Resolve dependencies response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ dependencies: object[]; canProceed: boolean; requiredActions: object[]; installOrder: string[]; … }` | ✅ | Dependency resolution result with topological sort | @@ -277,7 +277,7 @@ Uninstall package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ packageId: string; success: boolean; message?: string }` | ✅ | | @@ -309,7 +309,7 @@ Upload artifact response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; artifactRef?: object; submissionId?: string; message?: string }` | ✅ | | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index e475128b5a..f7421cc141 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -280,7 +280,7 @@ const result = AiAgentCapabilitiesSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | @@ -428,7 +428,7 @@ const result = AiAgentCapabilitiesSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | @@ -1528,7 +1528,7 @@ Uninstall package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | diff --git a/content/docs/references/api/storage.mdx b/content/docs/references/api/storage.mdx index 1f605cccfc..4682356ca0 100644 --- a/content/docs/references/api/storage.mdx +++ b/content/docs/references/api/storage.mdx @@ -46,7 +46,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ fileId: string; key: string; size: integer; mimeType: string; … }` | ✅ | | @@ -72,7 +72,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ url: string }` | ✅ | | @@ -101,7 +101,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ path: string; name: string; size: integer; mimeType: string; … }` | ✅ | Uploaded file metadata | @@ -147,7 +147,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ uploadId: string; resumeToken: string; fileId: string; totalChunks: integer; … }` | ✅ | | @@ -161,7 +161,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ uploadUrl: string; downloadUrl?: string; fileId: string; method: Enum<'PUT' \| 'POST'>; … }` | ✅ | | @@ -175,7 +175,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ key: string }` | ✅ | | @@ -202,7 +202,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ chunkIndex: integer; eTag: string; bytesReceived: integer }` | ✅ | | @@ -216,7 +216,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +266 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ uploadId: string; fileId: string; filename: string; totalSize: integer; … }` | ✅ | | diff --git a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts index fc519ad8eb..2b7ecbb177 100644 --- a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts +++ b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts @@ -29,6 +29,15 @@ type WebhookTrigger = WebhookTriggerType; * Enqueue callback into the shared `service-messaging` HTTP outbox (ADR-0018 M3). * The plugin supplies one bound to `messaging.enqueueHttp(...)`; webhooks no * longer own a delivery outbox/dispatcher — they share the generic substrate. + * + * [#8069] It MUST be `MessagingService.enqueueHttp`, not `IHttpOutbox.enqueue`. + * The enqueuer now emits two kinds of input through this one door — an ordinary + * delivery, and a PARKED event whose subscription lost its credentials — and + * only the messaging seam routes the second to `recordUndeliverable()`. Wired + * to the raw outbox instead, the parked input is refused at the delivery door + * (correctly — the alternative is a `pending` unsigned row) and the durable + * record is lost; {@link AutoEnqueuer} reports that at `error` rather than + * letting it pass as an ordinary enqueue failure. */ export type HttpEnqueueFn = (input: EnqueueHttpInput) => Promise; @@ -98,6 +107,20 @@ interface CachedSubscription { headers?: Record; secret?: string; timeoutMs?: number; + /** + * [#8069] Set when a credential this subscription needs could not be + * recovered. The subscription stays CACHED — that is the change — but every + * event it matches is written to `sys_http_delivery` as a parked `dead` row + * carrying this text, instead of being discarded with nothing to find. + * + * Before this, `attachCredentials` returning false removed the row from the + * cache entirely, so matching events found no subscription and vanished: + * fail-closed and correct, but leaving an operator with a log line (#8043) + * and no durable trace. A parked subscription is still fail-closed — + * {@link secret} and {@link headers} stay unset, so nothing can be sent — + * it is merely no longer silent. + */ + parkedReason?: string; } export interface AutoEnqueuerOptions { @@ -315,9 +338,11 @@ export class AutoEnqueuer { // them here, on the 60s refresh, rather than per event: the cache // already holds the plaintext in memory (it always did), so this // changes where the values come FROM, not how long they are held. A - // row whose credentials cannot be recovered is DROPPED — see + // row whose credentials cannot be recovered is PARKED — cached with + // `parkedReason` set and no credentials, so its events are recorded + // as undeliverable instead of silently discarded (#8069). See // `attachCredentials`. - if (!(await this.attachCredentials(sub, row))) continue; + await this.attachCredentials(sub, row); // Empty objectName == "any object" → indexed under '*'. const key = sub.objectName ?? '*'; const arr = next.get(key) ?? []; @@ -379,6 +404,80 @@ export class AutoEnqueuer { return true; } + /** + * [#8069] Mark a subscription parked and strip anything sendable off it. + * + * Called from the two `attachX` failure paths, which each already reported + * the drop at `error` (say-once, #8022). The credentials are cleared rather + * than merely "not set": `attachSecret` can succeed and `attachHeaders` + * fail, and a parked row must not carry the header map — that map is the + * ordinary place an `Authorization: Bearer …` goes (#7986), and copying it + * onto a row that will sit in `sys_http_delivery` for the full 30d + * retention window without ever being sent is a credential copy bought for + * nothing. + */ + /** + * [#8069] Report a failed outbox write off the hot path, at the level the + * loss actually deserves. + * + * AGENTS.md decides that with one question — *does the system still look + * normal from the outside while something it claims is persisted has not + * landed?* For a PARKED subscription the answer is unambiguously yes, and + * worse than for an ordinary enqueue failure: the durable record is the + * only trace this event ever existed, so losing the write puts us back + * exactly where this issue started, silently. So `error` there, and the + * pre-existing `warn` for an ordinary enqueue, where the delivery itself is + * the thing that did not happen and the subscription is otherwise healthy. + * + * The realistic cause of the parked branch is a host that wired + * {@link HttpEnqueueFn} straight to `IHttpOutbox.enqueue` instead of + * `MessagingService.enqueueHttp`: only the messaging seam routes a parked + * input to `recordUndeliverable()`, and the raw delivery door refuses the + * discriminator rather than minting a `pending` unsigned row from it. The + * message names that, because it is not guessable from "enqueue failed". + */ + private reportWriteFailure( + sub: CachedSubscription, + eventId: string, + err: unknown, + verb: string, + ): void { + const meta = { webhook: sub.name, eventId, err: (err as Error)?.message ?? err }; + if (!sub.parkedReason) { + this.logger.warn?.(`[webhook-auto-enqueuer] ${verb} failed`, meta); + return; + } + const message = + `[webhook-auto-enqueuer] could not record the undeliverable event for webhook ` + + `'${sub.name}' — the subscription is parked for an unresolvable credential, and this ` + + `event is now DISCARDED WITH NO TRACE in sys_http_delivery, which is the durability ` + + `gap #8069 closes. Most likely cause: the enqueue callback was wired directly to ` + + `IHttpOutbox.enqueue instead of MessagingService.enqueueHttp — only the messaging seam ` + + `routes a parked event to recordUndeliverable(), and the delivery door refuses it ` + + `rather than minting a pending row that would be sent UNSIGNED.`; + if (typeof this.logger.error === 'function') { + this.logger.error(message, err, meta); + } else { + this.logger.warn?.(message, meta); + } + } + + private park(sub: CachedSubscription, err: unknown, credential: DropReason): void { + sub.secret = undefined; + sub.headers = undefined; + sub.parkedReason = + `[${WEBHOOK_SECRET_REFUSAL_CODE}/${WEBHOOK_SECRET_REFUSAL_STATUS}] webhook '${sub.name}' ` + + `holds ${credential.article} that could not be decrypted, so this event was NOT ` + + `delivered — recording it here rather than ${credential.ratherThan} (${credential.issue}, ` + + `#8069). This row was never sent and cannot be redelivered: it carries no HMAC signature, ` + + `because the ${credential.noun} that would have produced one is exactly what is missing. ` + + `Fix: register a CryptoProvider (engine.setCryptoProvider — LocalCryptoProvider in dev, ` + + `KMS/Vault in production) with the same key the ${credential.noun} was written under, and ` + + `make sure the sys_secret row is reachable; the subscription re-arms on registration ` + + `(#8022) and at the next periodic refresh, and later events are delivered normally. ` + + `Cause: ${(err as Error)?.message ?? String(err)}`; + } + /** * [#7799] Resolve `sub.secret`. Returns `false` when the subscription must * be dropped. @@ -409,6 +508,7 @@ export class AutoEnqueuer { } } catch (err) { this.reportDrop(sub, err, SIGNING_SECRET_CREDENTIAL); + this.park(sub, err, SIGNING_SECRET_CREDENTIAL); return false; } @@ -451,6 +551,7 @@ export class AutoEnqueuer { } } catch (err) { this.reportDrop(sub, err, CUSTOM_HEADERS_CREDENTIAL); + this.park(sub, err, CUSTOM_HEADERS_CREDENTIAL); return false; } @@ -516,11 +617,20 @@ export class AutoEnqueuer { return; } this.droppedForSecret.add(sub.id); + // [#8069] The consequence clause used to end "…with NO delivery and NO + // sys_http_delivery row". The second half is no longer true — that is + // precisely what this card changed — and an `error` that misdescribes + // the consequence sends an operator looking in the wrong place, which + // is worse than the old accurate-but-bleaker line. It now names where + // the evidence IS. const message = `[webhook-auto-enqueuer] webhook '${sub.name}' holds ${credential.article} that ` + - `could not be decrypted — the subscription is DROPPED rather than ${credential.ratherThan} ` + - `(${credential.issue}), so every matching record change is discarded with NO delivery and NO ` + - 'sys_http_delivery row, while the row keeps reading active:true in Setup. Fix: register a ' + + `could not be decrypted — the subscription is PARKED rather than ${credential.ratherThan} ` + + `(${credential.issue}), so every matching record change is discarded with NO delivery, ` + + 'while the row keeps reading active:true in Setup. Each discarded event IS recorded in ' + + 'sys_http_delivery as a dead row with 0 attempts carrying this cause (#8069) — look there ' + + 'for the backlog; those rows can never be sent or redelivered, because a parked row has no ' + + 'HMAC signature. Fix: register a ' + 'CryptoProvider (engine.setCryptoProvider — LocalCryptoProvider in dev, KMS/Vault in ' + `production) with the same key the ${credential.noun} was written under, and make sure the ` + 'sys_secret row is reachable; the subscription re-arms on registration (#8022) and at the ' + @@ -706,6 +816,12 @@ export class AutoEnqueuer { method: sub.method, headers: sub.headers, signingSecret: sub.secret, + // [#8069] Set only for a PARKED subscription, and then this is + // not an enqueue at all: the messaging seam routes it to + // `recordUndeliverable()`, which writes a terminal `dead` row + // with this reason and no signature. Undefined for every healthy + // subscription, so the delivery path is byte-identical to before. + undeliverableReason: sub.parkedReason, timeoutMs: sub.timeoutMs, // [#3946] Envelope keys are written LAST so the event payload // cannot rewrite them. Behaviour-neutral for the engine's own @@ -724,13 +840,7 @@ export class AutoEnqueuer { action, timestamp: event.timestamp, }, - }).catch((err) => - this.logger.warn?.('[webhook-auto-enqueuer] enqueue failed', { - webhook: sub.name, - eventId, - err: (err as Error)?.message ?? err, - }), - ); + }).catch((err) => this.reportWriteFailure(sub, eventId, err, 'enqueue')); } } @@ -802,6 +912,9 @@ export class AutoEnqueuer { method: sub.method, headers: sub.headers, signingSecret: sub.secret, + // [#8069] See the per-record path — parked subscriptions record + // an undeliverable row instead of enqueuing a delivery. + undeliverableReason: sub.parkedReason, timeoutMs: sub.timeoutMs, // [#3946] Envelope keys last so the payload cannot rewrite them. payload: { @@ -811,13 +924,7 @@ export class AutoEnqueuer { action, timestamp: event.timestamp, }, - }).catch((err) => - this.logger.warn?.('[webhook-auto-enqueuer] bulk enqueue failed', { - webhook: sub.name, - eventId, - err: (err as Error)?.message ?? err, - }), - ); + }).catch((err) => this.reportWriteFailure(sub, eventId, err, 'bulk enqueue')); } } diff --git a/packages/plugins/plugin-webhooks/src/redeliver-guard.ts b/packages/plugins/plugin-webhooks/src/redeliver-guard.ts new file mode 100644 index 0000000000..a0f02fa4d9 --- /dev/null +++ b/packages/plugins/plugin-webhooks/src/redeliver-guard.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8069] The webhook lane's veto over redelivering one of its own + * `sys_http_delivery` rows. + * + * ## Why a guard exists at all + * `service-messaging` owns the replay mechanics and deliberately knows nothing + * about `sys_webhook`. So the row-local refusal it can make on its own — "this + * row was never sent, so there is nothing to re-send" — cannot answer the + * question the maintainer's ruling of 2026-08-12 actually asks: *is the signing + * configuration for this delivery still available?* That question is only + * answerable here, where the subscription and its encrypted secret live. + * + * ## What it refuses, and why each case + * A redelivery replays the row's bytes together with the signature computed at + * enqueue. That is safe exactly while the configuration those bytes were + * authorised under still stands. It refuses when: + * + * 1. **The subscription is gone.** Nothing is left to say whether this URL + * should still receive this payload, or under which key — and an operator + * deleting a webhook has expressed that it should stop. The maintainer + * named this case specifically. + * 2. **A secret is stored but does not come back.** The subscription is signed + * and the key cannot be recovered — a rotated KMS key, an unregistered + * CryptoProvider, a deleted `sys_secret` row. Deliveries for it are being + * dropped right now; replaying an old one is the same fail-open by another + * route. + * 3. **The lookup itself failed.** Handled by the caller + * (`assertRedeliverAllowed` turns a throwing guard into a refusal), because + * "we could not check" must never read as "allowed". + * + * It ALLOWS a subscription that is legitimately unsigned (`secret` is optional + * on the authoring envelope) and any row from another producer (`source !== + * 'webhook'`) — this guard speaks only for webhook rows. + * + * ## The narrow fail-open this closes deliberately + * Case 2 is checked as *"a value is stored but nothing came back"*, not as + * *"the resolver threw"*. `resolveWebhookSecret` returns `undefined` — the same + * value it uses for "authored unsigned" — when the column holds something that + * is not a resolvable ref, so a `try/catch` alone would treat an unrecoverable + * key as a legitimately unsigned webhook and allow the replay. Presence is + * decidable from the masked read even though the value is not, so the guard + * asks the question it can actually answer. + */ + +import type { IDataEngine } from '@objectstack/spec/contracts'; +import { + WEBHOOK_OBJECT, + WEBHOOK_SECRET_FIELD, + resolveWebhookSecret, +} from './webhook-secret.js'; + +/** The delivery-row fields this guard reads. Structural — no messaging import. */ +export interface RedeliverGuardRow { + /** Producer domain; only `'webhook'` rows are this guard's business. */ + source: string; + /** Partition/ordering anchor — the `sys_webhook` row id for webhook rows. */ + refId: string; +} + +/** + * Build the guard `MessagingService.registerRedeliverGuard('webhook', …)` takes. + * + * Returns a refusal reason, or `undefined` to allow. + */ +export function createWebhookRedeliverGuard( + engine: IDataEngine, + subscriptionsObject: string = WEBHOOK_OBJECT, +): (row: RedeliverGuardRow) => Promise { + return async (row) => { + if (row.source !== 'webhook') return undefined; + + const subscription = (await engine.findOne(subscriptionsObject, { + where: { id: row.refId }, + })) as Record | null; + + if (!subscription) { + return ( + `the ${subscriptionsObject} subscription '${row.refId}' this delivery belongs to no ` + + 'longer exists, so there is nothing left to say whether it may still be signed and ' + + 'sent (#8069). Recreate the webhook if the endpoint should keep receiving events; ' + + 'new events are then delivered signed.' + ); + } + + // Presence is decidable on the masked read — a set secret comes back as + // the engine's mask, an unset one as null — even though the value is not. + const storesSecret = + subscription[WEBHOOK_SECRET_FIELD] != null + && subscription[WEBHOOK_SECRET_FIELD] !== ''; + if (!storesSecret) return undefined; + + const plaintext = await resolveWebhookSecret(engine, subscription as { id: string }, subscriptionsObject); + if (plaintext) return undefined; + + return ( + `webhook '${String(subscription.name ?? row.refId)}' stores a signing secret that cannot ` + + 'be recovered, so this delivery cannot be authenticated as coming from us — refusing ' + + 'rather than sending (#7799, #8069). Fix: register a CryptoProvider with the same key ' + + 'the secret was written under and make sure the sys_secret row is reachable.' + ); + }; +} diff --git a/packages/plugins/plugin-webhooks/src/webhook-drop-durable-record.test.ts b/packages/plugins/plugin-webhooks/src/webhook-drop-durable-record.test.ts new file mode 100644 index 0000000000..b4d78c77fd --- /dev/null +++ b/packages/plugins/plugin-webhooks/src/webhook-drop-durable-record.test.ts @@ -0,0 +1,383 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #8069 — the webhook half of "a dropped subscription leaves a durable record, + * and that record is not a button that sends unsigned". + * + * ## What was wrong + * `AutoEnqueuer` drops a subscription whose encrypted signing secret it cannot + * resolve — correct and fail-closed (#7799) — by omitting it from the cache + * entirely. Every matching record change then found no subscription and + * vanished: no delivery, no `sys_http_delivery` row, nothing for an operator + * reading the delivery table to find. #8043 made the drop LOUD (say-once + * `error` with the remedy); it did not make it DURABLE, and the card says so + * explicitly. + * + * ## Why the whole chain, not the enqueuer alone + * The drop record only means something if it is unsendable, and "unsendable" is + * a property of the outbox and the redeliver path, not of the enqueuer. So + * these tests drive the real `AutoEnqueuer`, the real `MessagingService` + * routing seam, the real `MemoryHttpOutbox` (same write path shape as the SQL + * one) and the real refusal — because the failure mode being excluded lives + * precisely in the hand-offs between them. + * + * ## The vacuity trap closed here + * A test asserting "a row exists after a drop" is satisfied by the PROHIBITED + * naive shape — the bare `dead` row plus a live redeliver button. So the + * end-to-end test asserts the record AND that `redeliverHttp()` refuses it AND + * that a dispatcher tick puts nothing on the wire. Conversely, a subscription + * that is legitimately unsigned must keep delivering, so that is pinned too: + * otherwise "refuse everything" would pass. + */ + +import { randomUUID } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { DataEventSchema } from '@objectstack/spec/api'; +import type { + IDataEngine, + IRealtimeService, + RealtimeEventHandler, + RealtimeEventPayload, +} from '@objectstack/spec/contracts'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { + HttpDispatcher, + MemoryHttpOutbox, + MessagingService, + type EnqueueHttpInput, + type FetchImpl, +} from '@objectstack/service-messaging'; +import { AutoEnqueuer } from './auto-enqueuer.js'; +import { createWebhookRedeliverGuard } from './redeliver-guard.js'; + +/** objectql's opaque stored form — present, but not the key. */ +const SECRET_REF = 'secret:sec_8069'; + +class FakeRealtime implements IRealtimeService { + private subs = new Map(); + private n = 0; + async publish(event: RealtimeEventPayload): Promise { + for (const sub of this.subs.values()) { + const o = sub.opts ?? {}; + if (o.object && event.object !== o.object) continue; + await sub.handler(event); + } + } + async subscribe(_channel: string, handler: any, opts?: any): Promise { + const id = `s-${++this.n}`; + this.subs.set(id, { handler, opts }); + return id; + } + async unsubscribe(id: string): Promise { + this.subs.delete(id); + } +} + +/** + * Just enough engine to serve the subscription cache and the guard's lookup. + * + * The write verbs open with the PRODUCER's own dispatch predicates, per + * `check:engine-double-contract` — a double looser than the real engine turns a + * green suite into no suite on exactly the call shapes ObjectQL refuses. + * + * `resolveSecretField` is the privileged dereference the enqueuer uses; a + * `null` return models the engine's own "there is a stored value but nothing + * came back" outcome, and a throw models the fail-closed refusals (no + * CryptoProvider registered, `sys_secret` row gone). + */ +function makeEngine( + rows: any[], + resolveSecretField?: (object: string, id: string, field: string) => Promise, +): IDataEngine { + const engine: any = { + async find() { return rows; }, + async findOne(_n: string, opts?: any) { + const id = opts?.where?.id; + if (id === undefined) return rows[0] ?? null; + return rows.find((r) => r.id === id) ?? null; + }, + async insert(_n: string, d: any) { return d; }, + async update(_n: string, data: any, options?: any) { + assertEngineUpdateDispatch(data, options); + return { affected: 0 }; + }, + async delete(_n: string, options?: any) { + assertEngineDeleteDispatch(options); + return { affected: 0 }; + }, + async count() { return rows.length; }, + async aggregate() { return []; }, + }; + if (resolveSecretField) engine.resolveSecretField = resolveSecretField; + return engine as IDataEngine; +} + +function recordEvent(object: string, record: any): RealtimeEventPayload { + const payload = DataEventSchema.parse({ + id: randomUUID(), + type: 'data.record.created', + object, + recordId: String(record.id), + after: record, + timestamp: '2026-08-13T00:00:00.000Z', + }); + return { type: payload.type, object, payload: { ...payload }, timestamp: payload.timestamp }; +} + +function subscriptionRow(over: Record = {}) { + return { + id: 'wh-1', + name: 'orders_hook', + active: true, + object_name: 'crm_order', + triggers: ['create'], + url: 'https://receiver.example/hook', + method: 'POST', + definition_json: '{}', + ...over, + }; +} + +/** The production wiring shape: one injected door, the service routes it. */ +function wire(engine: IDataEngine) { + const outbox = new MemoryHttpOutbox(); + const messaging = new MessagingService({ logger: { info() {}, warn() {}, error() {}, debug() {} } as any }); + messaging.setHttpOutbox(outbox); + messaging.registerRedeliverGuard('webhook', createWebhookRedeliverGuard(engine)); + const realtime = new FakeRealtime(); + const enqueuer = new AutoEnqueuer( + engine, + realtime, + (input: EnqueueHttpInput) => messaging.enqueueHttp(input), + { logger: { info() {}, warn() {}, error() {}, debug() {} } }, + ); + return { outbox, messaging, realtime, enqueuer }; +} + +function makeFetch() { + const calls: Array<{ headers: Record; body: string }> = []; + const impl: FetchImpl = async (_url, init) => { + calls.push({ headers: init.headers, body: init.body }); + return { ok: true, status: 200, async text() { return 'ok'; } }; + }; + return { impl, calls }; +} + +describe('dropped webhook subscription leaves a durable, unsendable record (#8069)', () => { + it('records the discarded event instead of losing it, and refuses to redeliver it', async () => { + // A webhook that HAS a stored key the engine cannot decrypt — the + // rotated-key / missing-sys_secret shape, the residue #8043 left. + const engine = makeEngine( + [subscriptionRow({ signing_secret: SECRET_REF })], + async () => { throw new Error('Cannot resolve secret: sys_secret row "sec_8069" not found (fail-closed).'); }, + ); + const { outbox, messaging, realtime, enqueuer } = wire(engine); + await enqueuer.start(); + + await realtime.publish(recordEvent('crm_order', { id: 'o1', total: 42 })); + await new Promise((r) => setTimeout(r, 0)); // enqueue is fire-and-forget + await enqueuer.stop(); + + // ── DURABLE: the discarded event is in the delivery table, with cause ── + const rows = await outbox.list(); + expect(rows).toHaveLength(1); + const row = rows[0]; + expect(row.source).toBe('webhook'); + expect(row.refId).toBe('wh-1'); + expect(row.status).toBe('dead'); + expect(row.attempts).toBe(0); + // The reason column carries the cause AND the remedy — this is the + // measured answer to the ruling's step 3: no new lifecycle state needed. + expect(row.error).toContain('could not be decrypted'); + expect(row.error).toContain('setCryptoProvider'); + expect(row.error).toContain('sec_8069'); // the underlying engine cause, not a generic string + // …and the event itself is recorded, not just an empty marker. + expect(row.payload).toMatchObject({ object: 'crm_order', recordId: 'o1', action: 'created' }); + + // ── UNSENDABLE: no signature, never claimed, never redeliverable ── + expect(row.signature).toBeUndefined(); + const { impl, calls } = makeFetch(); + await new HttpDispatcher({ nodeId: 'n1', outbox, fetchImpl: impl, partitionCount: 1 }).tick(); + expect(calls).toHaveLength(0); + + await expect(messaging.redeliverHttp(row.id)).rejects.toMatchObject({ + code: 'DELIVERY_NEVER_SENT', + }); + // The refusal did not mutate the row on its way out. + expect((await outbox.list())[0]).toMatchObject({ status: 'dead', attempts: 0 }); + }); + + it('parks without copying the header map onto a row that will never be sent', async () => { + // Secret resolves; the HEADER map does not. Same drop rule (#7986), and + // the parked row must not carry the authored headers — that map is the + // ordinary place an Authorization credential goes, and a 30d-retained + // row is a credential copy bought for nothing. + const engine = makeEngine( + [subscriptionRow({ signing_secret: SECRET_REF, headers_secret: SECRET_REF })], + async (_o, _id, field) => + field === 'headers_secret' + ? Promise.reject(new Error('no CryptoProvider')) + : 'whsec_live', + ); + const { outbox, realtime, enqueuer } = wire(engine); + await enqueuer.start(); + await realtime.publish(recordEvent('crm_order', { id: 'o2' })); + await new Promise((r) => setTimeout(r, 0)); + await enqueuer.stop(); + + const [row] = await outbox.list(); + expect(row.status).toBe('dead'); + expect(row.headers).toBeUndefined(); + // And the resolved signing secret did not ride along either. + expect(JSON.stringify(row)).not.toContain('whsec_live'); + }); + + /** + * The one wiring that can still lose a drop record, and the register it is + * reported in. Bound straight to `IHttpOutbox.enqueue`, the parked input is + * refused at the delivery door — correct, since the alternative is minting + * a `pending` unsigned row — but the record is then lost, which is the very + * gap this card closes. That is a durability degradation, so it owes an + * `error`, not the ordinary "enqueue failed" `warn`. + */ + it('reports a lost drop record at error level, naming the mis-wiring', async () => { + const engine = makeEngine( + [subscriptionRow({ signing_secret: SECRET_REF })], + async () => { throw new Error('no CryptoProvider'); }, + ); + const outbox = new MemoryHttpOutbox(); + const realtime = new FakeRealtime(); + const errors: string[] = []; + const warns: string[] = []; + const enqueuer = new AutoEnqueuer( + engine, + realtime, + // ⛔ the unsupported wiring: the raw delivery door, not the seam. + (input: EnqueueHttpInput) => outbox.enqueue(input), + { + logger: { + info() {}, debug() {}, + warn(m: string) { warns.push(m); }, + error(m: string) { errors.push(m); }, + }, + }, + ); + await enqueuer.start(); + await realtime.publish(recordEvent('crm_order', { id: 'o6' })); + await new Promise((r) => setTimeout(r, 0)); + await enqueuer.stop(); + + // Nothing was minted — in particular, no `pending` row that would have + // gone out unsigned. That is the half that must never regress. + expect(await outbox.list()).toHaveLength(0); + // …and the loss is loud, in the register AGENTS.md assigns it. + const lost = errors.find((m) => m.includes('could not record the undeliverable event')); + expect(lost).toBeDefined(); + expect(lost).toContain('MessagingService.enqueueHttp'); + expect(warns.some((m) => m.includes('enqueue failed'))).toBe(false); + }); + + it('still delivers a legitimately unsigned webhook — the refusal is not a blanket', async () => { + // `secret` is optional on the authoring envelope. A webhook that stores + // no key is not parked, and its rows stay ordinary deliveries; without + // this, "park everything" would pass every test above. + const engine = makeEngine([subscriptionRow()]); + const { outbox, realtime, enqueuer } = wire(engine); + await enqueuer.start(); + await realtime.publish(recordEvent('crm_order', { id: 'o3' })); + await new Promise((r) => setTimeout(r, 0)); + await enqueuer.stop(); + + const [row] = await outbox.list(); + expect(row.status).toBe('pending'); + expect(row.error).toBeUndefined(); + + const { impl, calls } = makeFetch(); + await new HttpDispatcher({ nodeId: 'n1', outbox, fetchImpl: impl, partitionCount: 1 }).tick(); + expect(calls).toHaveLength(1); + }); + + it('re-arms cleanly: once the secret resolves, deliveries are signed again (#8022)', async () => { + // The parked state must not be sticky. This is the #8022 regression + // guard: a refresh that can resolve the credential clears the park. + let broken = true; + const engine = makeEngine( + [subscriptionRow({ signing_secret: SECRET_REF })], + async () => { + if (broken) throw new Error('no CryptoProvider'); + return 'whsec_recovered'; + }, + ); + const { outbox, realtime, enqueuer } = wire(engine); + await enqueuer.start(); + await realtime.publish(recordEvent('crm_order', { id: 'o4' })); + await new Promise((r) => setTimeout(r, 0)); + + broken = false; + await enqueuer.refresh(); + await realtime.publish(recordEvent('crm_order', { id: 'o5' })); + await new Promise((r) => setTimeout(r, 0)); + await enqueuer.stop(); + + const rows = await outbox.list(); + expect(rows).toHaveLength(2); + const parked = rows.find((r) => (r.payload as any).recordId === 'o4')!; + const delivered = rows.find((r) => (r.payload as any).recordId === 'o5')!; + expect(parked.status).toBe('dead'); + expect(delivered.status).toBe('pending'); + expect(delivered.signature).toMatch(/^sha256=/); + }); +}); + +describe('webhook redeliver guard — signing configuration availability (#8069)', () => { + const row = { source: 'webhook', refId: 'wh-1' }; + + it('refuses when the subscription was deleted — the case the ruling names', async () => { + const guard = createWebhookRedeliverGuard(makeEngine([])); + await expect(guard(row)).resolves.toContain('no longer exists'); + }); + + it('refuses when a secret is stored but cannot be recovered', async () => { + const guard = createWebhookRedeliverGuard( + makeEngine([subscriptionRow({ signing_secret: SECRET_REF })], async () => { + throw new Error('no CryptoProvider'); + }), + ); + // A throwing resolver surfaces as a throw; `assertRedeliverAllowed` + // turns that into a refusal — "could not check" is never "allowed". + await expect(guard(row)).rejects.toThrow(/CryptoProvider/); + }); + + /** + * The narrow fail-open this guard closes deliberately. + * + * `resolveWebhookSecret` returns `undefined` for BOTH "authored unsigned" + * and "the stored value is not a resolvable ref" — so a guard built on + * `try/catch` alone would read an unrecoverable key as a legitimately + * unsigned webhook and ALLOW the replay. Presence is decidable from the + * masked read even when the value is not, so the guard asks that instead. + */ + it('refuses a stored secret that resolves to nothing, not just one that throws', async () => { + const guard = createWebhookRedeliverGuard( + makeEngine([subscriptionRow({ signing_secret: SECRET_REF })], async () => null), + ); + await expect(guard(row)).resolves.toContain('cannot be recovered'); + }); + + it('allows a healthy signed subscription and a legitimately unsigned one', async () => { + const signed = createWebhookRedeliverGuard( + makeEngine([subscriptionRow({ signing_secret: SECRET_REF })], async () => 'whsec_live'), + ); + await expect(signed(row)).resolves.toBeUndefined(); + + const unsigned = createWebhookRedeliverGuard(makeEngine([subscriptionRow()])); + await expect(unsigned(row)).resolves.toBeUndefined(); + }); + + it('speaks only for webhook rows', async () => { + // A flow row must not be judged against `sys_webhook` — this guard has + // no opinion on another producer's configuration. + const guard = createWebhookRedeliverGuard(makeEngine([])); + await expect(guard({ source: 'flow', refId: 'node_1' })).resolves.toBeUndefined(); + }); +}); diff --git a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts index 2ed3f25015..50a3af2e6f 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts @@ -12,6 +12,7 @@ import { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js'; import { SysWebhook } from './sys-webhook.object.js'; import { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js'; import { migrateLegacyWebhookSecrets } from './migrate-webhook-secrets.js'; +import { createWebhookRedeliverGuard } from './redeliver-guard.js'; import { bindWebhookProvenanceStamp, unbindWebhookProvenanceStamp } from './webhook-provenance.js'; /** @@ -24,6 +25,17 @@ interface MessagingHttpSurface { isHttpDeliveryReady(): boolean; enqueueHttp(input: EnqueueHttpInput): Promise; redeliverHttp(id: string): Promise<{ id: string; status: string }>; + /** + * [#8069] Where this plugin's veto over redelivering `source: 'webhook'` + * rows is installed. Declared REQUIRED on this structural view even though + * the import is type-only: a messaging build without it cannot enforce the + * refusal, and {@link WebhookOutboxPlugin.installRedeliverGuard} says so at + * `error` rather than arming the endpoint with a guarantee nothing keeps. + */ + registerRedeliverGuard( + source: string, + guard: (row: { source: string; refId: string }) => Promise, + ): void; } export interface WebhookOutboxPluginOptions { @@ -233,6 +245,10 @@ export class WebhookOutboxPlugin implements Plugin { } const enqOpts = (typeof opt === 'object' ? opt : {}) as AutoEnqueuerOptions; + // [#8069] Install the redelivery veto BEFORE the enqueuer starts + // writing rows, so no delivery row can ever exist while the refusal + // that protects it does not. + this.installRedeliverGuard(ctx, messaging, engine, enqOpts.subscriptionsObject); this.autoEnqueuer = new AutoEnqueuer( engine, realtime, @@ -244,6 +260,42 @@ export class WebhookOutboxPlugin implements Plugin { ctx.logger.info?.('[webhook-auto-enqueuer] started (enqueues source=webhook onto sys_http_delivery)'); } + /** + * [#8069] Register {@link createWebhookRedeliverGuard} with messaging, so + * `redeliver()` refuses a webhook row whose signing configuration is no + * longer available — for EVERY caller, not just the + * `POST /api/v1/webhooks/redeliver` route. + * + * Absence is loud, and `error` is the right level by AGENTS.md's one + * question: with no guard installed the endpoint still answers 200 and the + * dispatcher still reports a delivery, while the fail-closed signing + * guarantee the system claims (#7799) is not actually being kept. That is a + * durability/consistency degradation wearing a functional one's clothes. + */ + private installRedeliverGuard( + ctx: PluginContext, + messaging: MessagingHttpSurface, + engine: IDataEngine, + subscriptionsObject?: string, + ): void { + if (typeof messaging.registerRedeliverGuard !== 'function') { + ctx.logger.error?.( + '[webhook-outbox] messaging service exposes no registerRedeliverGuard() — redelivery ' + + 'of a webhook whose signing configuration is gone CANNOT be refused, so an operator ' + + 'pressing redeliver may send a delivery that can no longer be authenticated ' + + '(#7799, #8069). The POST /api/v1/webhooks/redeliver endpoint is reachable by any ' + + 'authenticated user. Fix: upgrade @objectstack/service-messaging to a build that ' + + 'implements registerRedeliverGuard.', + ); + return; + } + messaging.registerRedeliverGuard( + 'webhook', + createWebhookRedeliverGuard(engine, subscriptionsObject), + ); + ctx.logger.debug?.('[webhook-outbox] redeliver guard installed for source=webhook'); + } + private tryGetService(ctx: PluginContext, names: string[]): T | undefined { for (const n of names) { try { @@ -301,7 +353,15 @@ export class WebhookOutboxPlugin implements Plugin { if (code === 'RESOURCE_NOT_FOUND') { return c.json({ success: false, error: { code, message: err.message } }, 404); } - if (code === 'DELIVERY_NOT_ELIGIBLE') { + // [#8069] `DELIVERY_NEVER_SENT` is a refusal, not a server + // fault: the row is a parked record of a delivery that was + // never prepared, and re-sending it would be a FIRST delivery — + // unsigned, because the secret that would have signed it is + // what went missing. 409 alongside the eligibility refusal, so + // an operator tool can present both the same way; without this + // arm it would surface as a 500 and read as a transient glitch + // worth retrying. + if (code === 'DELIVERY_NOT_ELIGIBLE' || code === 'DELIVERY_NEVER_SENT') { return c.json({ success: false, error: { code, message: err.message } }, 409); } ctx.logger.error?.('[webhook-outbox] redeliver failed', err as Error); diff --git a/packages/plugins/plugin-webhooks/src/webhook-secret-at-rest.test.ts b/packages/plugins/plugin-webhooks/src/webhook-secret-at-rest.test.ts index c99a505789..674876aeed 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-secret-at-rest.test.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-secret-at-rest.test.ts @@ -30,7 +30,13 @@ import type { CryptoHandle, CryptoContext, } from '@objectstack/spec/contracts'; -import { MemoryHttpOutbox, HttpDispatcher, type FetchImpl } from '@objectstack/service-messaging'; +import { + MemoryHttpOutbox, + HttpDispatcher, + MessagingService, + type EnqueueHttpInput, + type FetchImpl, +} from '@objectstack/service-messaging'; import { AutoEnqueuer } from './auto-enqueuer.js'; import { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js'; import { migrateLegacyWebhookSecrets } from './migrate-webhook-secrets.js'; @@ -38,6 +44,25 @@ import { SysWebhook } from './sys-webhook.object.js'; import { WEBHOOK_SECRET_FIELD, __objectqlSecretWireForms } from './webhook-secret.js'; import { WEBHOOK_HEADERS_FIELD } from './webhook-headers.js'; +/** + * [#8069] The PRODUCTION enqueue wiring, as one helper. + * + * These tests used to bind the enqueuer straight to `outbox.enqueue`. That was + * indistinguishable from the real thing while a dropped subscription produced + * nothing at all; it no longer is. The enqueuer now emits two kinds of input — + * a delivery, and a parked event whose subscription lost its credentials — and + * only `MessagingService.enqueueHttp` routes the second to + * `recordUndeliverable()`. Bound to the raw outbox, these tests would assert + * fail-closed behaviour against a wiring the plugin never uses. + */ +function enqueueVia(outbox: MemoryHttpOutbox): (input: EnqueueHttpInput) => Promise { + const messaging = new MessagingService({ + logger: { info() {}, warn() {}, error() {}, debug() {} } as any, + }); + messaging.setHttpOutbox(outbox); + return (input) => messaging.enqueueHttp(input); +} + const SECRET = 'whsec_7799_subscriber_key'; const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; @@ -593,7 +618,7 @@ describe('boot ordering: the cache is built before the CryptoProvider (#8022)', const outbox = new MemoryHttpOutbox(); const errors: Array<{ msg: string; meta: any }> = []; const debugs: string[] = []; - const enqueuer = new AutoEnqueuer(engine, realtime, (i) => outbox.enqueue(i), { + const enqueuer = new AutoEnqueuer(engine, realtime, enqueueVia(outbox), { refreshIntervalMs: 0, logger: { error: (msg: string, _err?: unknown, meta?: unknown) => { errors.push({ msg, meta: meta as any }); }, @@ -613,7 +638,17 @@ describe('boot ordering: the cache is built before the CryptoProvider (#8022)', // The #7799 boundary: nothing is delivered, and nothing is delivered // UNSIGNED, which is the outcome this whole card must not buy. expect(calls).toHaveLength(0); - expect(await outbox.list()).toHaveLength(0); + + // [#8069] What used to be `expect(list()).toHaveLength(0)` — the absence + // of durability that this card exists to remove. The fail-closed half is + // unchanged and asserted above; what changed is that the discarded event + // now leaves a PARKED record. Checking the row rather than dropping the + // assertion matters: an assertion that passes because nothing was + // produced stops testing anything the moment something is. + const [parked] = await outbox.list(); + expect(parked).toMatchObject({ status: 'dead', attempts: 0 }); + expect(parked.signature).toBeUndefined(); + expect(parked.error).toMatch(/could not be decrypted/); // ADR-0112 — a consumer branches on the pair, not on message text. expect(errors).toHaveLength(1); @@ -621,7 +656,10 @@ describe('boot ordering: the cache is built before the CryptoProvider (#8022)', // An `error` owes the consequence and the fix (AGENTS.md), and owes // them ONCE: the second refresh repeats at debug, not at error, or an // unfixed deployment prints this every 60s until nobody reads `error`. - expect(errors[0].msg).toMatch(/NO delivery and NO sys_http_delivery row/); + // [#8069] The consequence clause changed with the behaviour: there IS a + // sys_http_delivery row now, and the message has to say where to look. + expect(errors[0].msg).toMatch(/NO delivery/); + expect(errors[0].msg).toMatch(/recorded in sys_http_delivery/); expect(errors[0].msg).toMatch(/setCryptoProvider/); expect(debugs.join('\n')).toMatch(/still dropped for an unresolvable signing secret/); }); @@ -847,7 +885,7 @@ describe('fail-closed and re-arm, extended to headers (#7986 × #7799/#8022)', ( const realtime = new FakeRealtime(); const outbox = new MemoryHttpOutbox(); const errors: Array<{ msg: string; meta: any }> = []; - const enqueuer = new AutoEnqueuer(engine, realtime, (i) => outbox.enqueue(i), { + const enqueuer = new AutoEnqueuer(engine, realtime, enqueueVia(outbox), { refreshIntervalMs: 0, logger: { error: (msg: string, _e?: unknown, meta?: unknown) => { errors.push({ msg, meta: meta as any }); }, @@ -867,7 +905,15 @@ describe('fail-closed and re-arm, extended to headers (#7986 × #7799/#8022)', ( // and against an endpoint that does not require auth it even succeeds. // A subscription that stops is visible and gets investigated. expect(calls).toHaveLength(0); - expect(await outbox.list()).toHaveLength(0); + // [#8069] …and since this card, it is investigable from the delivery + // table too: the discarded event is parked, unsigned and unsendable. + // The parked row must NOT carry the header map — that map is the + // ordinary place an Authorization credential goes, and a row that will + // never be sent has no business holding one for the 30d retention. + const [parked] = await outbox.list(); + expect(parked).toMatchObject({ status: 'dead', attempts: 0 }); + expect(parked.headers).toBeUndefined(); + expect(parked.signature).toBeUndefined(); // ADR-0112 — one report, carrying the pair a consumer branches on. expect(errors).toHaveLength(1); expect(errors[0].meta).toMatchObject({ code: 'INTERNAL_ERROR', status: 500 }); diff --git a/packages/services/service-messaging/src/http-outbox-parked-record.integration.test.ts b/packages/services/service-messaging/src/http-outbox-parked-record.integration.test.ts new file mode 100644 index 0000000000..6a09f9e17a --- /dev/null +++ b/packages/services/service-messaging/src/http-outbox-parked-record.integration.test.ts @@ -0,0 +1,328 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #8069 — a dropped webhook subscription must leave a durable record, and that + * record must NOT become a button that sends the delivery unsigned. + * + * ## The defect these tests pin + * When `plugin-webhooks` cannot resolve a webhook's encrypted signing secret it + * drops the subscription — correctly, fail-closed (#7799) — but the drop left + * no `sys_http_delivery` row, so an operator reading the delivery table found + * nothing. The obvious fix (write the discarded event as a `dead` row) is + * *actively unsafe on its own*: `redeliver()` reset ANY terminal row to + * `pending` with no signature check, behind `POST /api/v1/webhooks/redeliver` + * which any authenticated user can reach. A parked row has no signature — + * the signature is computed at enqueue from the very secret that could not be + * resolved — so redelivering one would put the payload on the wire UNSIGNED, + * reopening #7799 through a door nobody would think to audit. + * + * **An unsigned redelivery is strictly worse than an unrecorded drop**, which + * is why the refusal is tested first and tested hardest. + * + * ## Why the real storage path + * Same harness as `http-signature-at-rest.integration.test.ts`: ObjectQL + + * `@objectstack/driver-sql` on better-sqlite3 `:memory:`, the production + * `SqlHttpOutbox` and `HttpDispatcher`. The refusal is a claim about what a + * real row in a real table does when a real dispatcher runs, and both halves of + * that — "the row is still `dead` afterwards" and "nothing went on the wire" — + * are only observable end to end. + * + * ## The vacuity traps closed here, explicitly + * 1. **A row that was never signable anyway.** A test asserting the refusal on + * a `pending` row would pass against a completely unfixed tree, because + * `redeliver()` already refused non-terminal rows. Every refusal test below + * uses a **`dead`** row — the state the old code happily reset — and the + * first one builds it with `engine.insert` alone, so it is a behavioural + * RED against unmodified `redeliver()` rather than a compile error. + * 2. **A durable record that is merely present.** Asserting "a row exists" is + * what the PROHIBITED naive shape also satisfies. So the record tests + * assert the row is unsendable: not claimed by a real dispatcher, and + * refused by `redeliver()`. + * 3. **An over-broad refusal.** A `redeliver()` that refused everything would + * pass every test above while breaking the feature, so the last test + * replays a genuinely failed, genuinely signed row and checks it still goes + * out with the same bytes and the same signature. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { createHmac } from 'node:crypto'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SqlHttpOutbox } from './sql-http-outbox.js'; +import { MemoryHttpOutbox } from './memory-http-outbox.js'; +import { HttpDispatcher } from './http-dispatcher.js'; +import { HttpRedeliverError, type IHttpOutbox } from './http-outbox.js'; +import { HttpDelivery, SYS_HTTP_DELIVERY } from './objects/http-delivery.object.js'; +import type { FetchImpl } from './http-sender.js'; + +const SECRET = 'whsec_8069_signing_key'; + +const DROP_REASON = + "[INTERNAL_ERROR/500] webhook 'orders' holds an encrypted signing secret that could not be " + + 'decrypted, so this event was NOT delivered. Fix: register a CryptoProvider with the same key ' + + 'the signing secret was written under.'; + +function makeSqliteDriver() { + return new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); +} + +function makeFetch(): { + impl: FetchImpl; + calls: Array<{ url: string; headers: Record; body: string }>; +} { + const calls: Array<{ url: string; headers: Record; body: string }> = []; + const impl: FetchImpl = async (url, init) => { + calls.push({ url, headers: init.headers, body: init.body }); + return { ok: true, status: 204, async text() { return ''; } }; + }; + return { impl, calls }; +} + +describe('sys_http_delivery — parked drop records are not redeliverable (#8069)', () => { + let engine: ObjectQL | undefined; + + afterEach(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + engine = undefined; + }); + + async function boot() { + const driver = makeSqliteDriver(); + engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(HttpDelivery as any, '@objectstack/service-messaging'); + await engine.syncSchemas(); + return { driver, engine: engine! }; + } + + /** + * The refusal, against a row built WITHOUT any API this issue added. + * + * This is the behavioural RED: on `origin/main` `redeliver()` sees a `dead` + * row, resets it to `pending`, and returns — no throw, and the dispatcher + * then puts an unsigned body on the wire. Both consequences are asserted, + * because "it threw" alone would not distinguish a refusal from a refusal + * that still mutated the row first. + */ + it('refuses to redeliver a terminal row that was never attempted', async () => { + const { engine: eng } = await boot(); + const outbox = new SqlHttpOutbox(eng as any, { partitionCount: 1 }); + + const now = new Date(); + await eng.insert(SYS_HTTP_DELIVERY, { + id: 'parked_1', + source: 'webhook', + ref_id: 'wh_1', + dedup_key: 'crm_order:o1:created:1760000000000', + label: 'data.record.created', + url: 'https://receiver.example/hook', + method: 'POST', + payload_json: JSON.stringify({ recordId: 'o1' }), + partition_key: 0, + status: 'dead', + attempts: 0, + error: DROP_REASON, + created_at: now, + updated_at: now, + }); + + await expect(outbox.redeliver('parked_1')).rejects.toMatchObject({ + name: 'HttpRedeliverError', + code: 'DELIVERY_NEVER_SENT', + }); + + // The refusal ran before any write: the row is untouched, reason intact. + const [row] = await outbox.list({ status: 'dead' }); + expect(row.id).toBe('parked_1'); + expect(row.attempts).toBe(0); + expect(row.error).toBe(DROP_REASON); + + // …and nothing reaches the wire, which is the consequence that matters. + const { impl, calls } = makeFetch(); + await new HttpDispatcher({ nodeId: 'n1', outbox, fetchImpl: impl, partitionCount: 1 }).tick(); + expect(calls).toHaveLength(0); + }); + + it('records a drop as a durable dead row carrying the cause, with no signature', async () => { + const { engine: eng } = await boot(); + const outbox = new SqlHttpOutbox(eng as any, { partitionCount: 1 }); + + const id = await outbox.recordUndeliverable({ + source: 'webhook', + refId: 'wh_1', + dedupKey: 'crm_order:o1:created:1760000000000', + label: 'data.record.created', + url: 'https://receiver.example/hook', + payload: { recordId: 'o1', object: 'crm_order' }, + reason: DROP_REASON, + }); + + // Durable: an operator reading the table finds the discarded event AND + // why it was discarded — the existing reason column, no new state. + const [row] = await outbox.list({ source: 'webhook' }); + expect(row.id).toBe(id); + expect(row.status).toBe('dead'); + expect(row.attempts).toBe(0); + expect(row.error).toBe(DROP_REASON); + expect(row.signature).toBeUndefined(); + // The payload really is on the row — a record of nothing is not a record. + expect(row.payload).toEqual({ recordId: 'o1', object: 'crm_order' }); + + // Unsendable by both routes: the dispatcher never claims it… + const { impl, calls } = makeFetch(); + await new HttpDispatcher({ nodeId: 'n1', outbox, fetchImpl: impl, partitionCount: 1 }).tick(); + expect(calls).toHaveLength(0); + // …and an operator cannot conjure a first delivery out of it. + await expect(outbox.redeliver(id)).rejects.toMatchObject({ code: 'DELIVERY_NEVER_SENT' }); + }); + + it('converges duplicates like enqueue does — one discarded event, one record', async () => { + const { engine: eng } = await boot(); + const outbox = new SqlHttpOutbox(eng as any, { partitionCount: 1 }); + const input = { + source: 'webhook', + refId: 'wh_1', + dedupKey: 'same-event', + url: 'https://receiver.example/hook', + payload: {}, + reason: DROP_REASON, + }; + const a = await outbox.recordUndeliverable(input); + const b = await outbox.recordUndeliverable(input); + expect(a).toBe(b); + expect(await outbox.list()).toHaveLength(1); + }); + + /** + * The producer's veto, for the case the maintainer named: the row's webhook + * config was DELETED, so nothing can say this delivery may still be signed + * and sent. The outbox cannot know that on its own — `service-messaging` + * knows nothing about `sys_webhook` — so it asks the guard. + */ + it('refuses a previously-attempted row when the producer guard vetoes it', async () => { + const { engine: eng } = await boot(); + const outbox = new SqlHttpOutbox(eng as any, { partitionCount: 1 }); + + const id = await outbox.enqueue({ + source: 'webhook', + refId: 'wh_deleted', + dedupKey: 'e1', + url: 'https://receiver.example/hook', + signingSecret: SECRET, + payload: { recordId: 'o1' }, + }); + // Make it a genuine dead-letter: attempted, failed, terminal. + await outbox.ack(id, { success: false, dead: true, error: 'boom', durationMs: 1 }); + + const seen: string[] = []; + await expect( + outbox.redeliver(id, (row) => { + seen.push(row.refId); + return 'the sys_webhook subscription no longer exists'; + }), + ).rejects.toMatchObject({ code: 'DELIVERY_NOT_ELIGIBLE' }); + + // The guard was asked about THIS row, not some default. + expect(seen).toEqual(['wh_deleted']); + const [row] = await outbox.list(); + expect(row.status).toBe('dead'); + expect(row.attempts).toBe(1); + }); + + it('treats a guard that throws as a refusal — "could not check" is never "allowed"', async () => { + const { engine: eng } = await boot(); + const outbox = new SqlHttpOutbox(eng as any, { partitionCount: 1 }); + const id = await outbox.enqueue({ + source: 'webhook', refId: 'wh_1', dedupKey: 'e1', + url: 'https://receiver.example/hook', signingSecret: SECRET, payload: {}, + }); + await outbox.ack(id, { success: false, dead: true, error: 'boom', durationMs: 1 }); + + await expect( + outbox.redeliver(id, () => { throw new Error('sys_webhook read failed'); }), + ).rejects.toMatchObject({ code: 'DELIVERY_NOT_ELIGIBLE' }); + const [row] = await outbox.list(); + expect(row.status).toBe('dead'); + }); + + /** + * The anti-over-refusal test. Everything above would also pass if + * `redeliver()` simply refused everything, which would break the feature + * #8022's source retest verified working — so a genuinely failed, genuinely + * signed row must still replay, with the SAME bytes and the SAME signature. + */ + it('still replays a real dead-letter byte-for-byte, signature intact', async () => { + const { engine: eng } = await boot(); + const outbox = new SqlHttpOutbox(eng as any, { partitionCount: 1 }); + const payload = { object: 'crm_order', recordId: 'o1', action: 'created' }; + + const id = await outbox.enqueue({ + source: 'webhook', + refId: 'wh_live', + dedupKey: 'e1', + url: 'https://receiver.example/hook', + signingSecret: SECRET, + payload, + }); + await outbox.ack(id, { success: false, dead: true, error: 'receiver down', durationMs: 1 }); + + const replayed = await outbox.redeliver(id); + expect(replayed.status).toBe('pending'); + + const { impl, calls } = makeFetch(); + await new HttpDispatcher({ nodeId: 'n1', outbox, fetchImpl: impl, partitionCount: 1 }).tick(); + + expect(calls).toHaveLength(1); + expect(JSON.parse(calls[0].body)).toEqual(payload); + const expected = createHmac('sha256', SECRET).update(calls[0].body).digest('hex'); + expect(calls[0].headers['X-Objectstack-Signature']).toBe(`sha256=${expected}`); + }); +}); + +/** + * The two implementations must not drift: `MemoryHttpOutbox` is what most tests + * in this repo assert against, so a refusal present only in the SQL one would + * be invisible exactly where it is most often exercised. + */ +describe.each<[string, () => IHttpOutbox]>([ + ['MemoryHttpOutbox', () => new MemoryHttpOutbox()], +])('%s — parked-record contract (#8069)', (_name, make) => { + it('parks with the reason on the row and refuses redelivery', async () => { + const outbox = make(); + const id = await outbox.recordUndeliverable({ + source: 'webhook', refId: 'wh_1', dedupKey: 'e1', + url: 'https://x', payload: { a: 1 }, reason: DROP_REASON, + }); + const [row] = await outbox.list(); + expect(row.status).toBe('dead'); + expect(row.attempts).toBe(0); + expect(row.error).toBe(DROP_REASON); + expect(row.signature).toBeUndefined(); + + // Never claimed… + expect(await outbox.claim({ nodeId: 'n1', limit: 10, claimTtlMs: 1000 })).toHaveLength(0); + // …and never redeliverable. + await expect(outbox.redeliver(id)).rejects.toBeInstanceOf(HttpRedeliverError); + await expect(outbox.redeliver(id)).rejects.toMatchObject({ code: 'DELIVERY_NEVER_SENT' }); + }); + + it('refuses the parked discriminator at the delivery door', async () => { + const outbox = make(); + // The routing in `MessagingService.enqueueHttp` is what normally keeps + // this input away from `enqueue()`. If it is ever bypassed, minting a + // `pending` unsigned row is the catastrophe — so the door refuses + // rather than ignoring the flag it does not act on. + await expect( + outbox.enqueue({ + source: 'webhook', refId: 'wh_1', dedupKey: 'e1', url: 'https://x', + payload: {}, undeliverableReason: DROP_REASON, + }), + ).rejects.toThrow(/recordUndeliverable/); + expect(await outbox.list()).toHaveLength(0); + }); +}); diff --git a/packages/services/service-messaging/src/http-outbox.ts b/packages/services/service-messaging/src/http-outbox.ts index 199d2b5978..7e3c5d8df4 100644 --- a/packages/services/service-messaging/src/http-outbox.ts +++ b/packages/services/service-messaging/src/http-outbox.ts @@ -23,6 +23,20 @@ * `secret`→`signingSecret`. */ +/** + * Lifecycle state of a delivery row. + * + * [#8069] Deliberately UNCHANGED by the drop-record work. A subscription + * dropped for an unresolvable signing secret is recorded as `dead` carrying + * its cause in {@link HttpDelivery.error}, not as a new never-sendable member + * of this union — the maintainer's ruling of 2026-08-12 admits a new lifecycle + * state only if the reason column cannot carry the cause, and it can (`error` + * is an unbounded textarea already surfaced by `sys_http_delivery`'s + * "Failures" list view, which filters `status in (failed, dead)` and shows + * `error` as a column). What distinguishes a parked row from a row the + * dispatcher killed is {@link HttpDelivery.attempts} — see + * {@link assertHttpRedeliverable}. + */ export type HttpDeliveryStatus = | 'pending' | 'in_flight' @@ -93,7 +107,19 @@ export interface HttpDelivery { /** Lifecycle state. */ status: HttpDeliveryStatus; - /** Number of attempts made so far (0 before first attempt). */ + /** + * Number of attempts made so far (0 before first attempt). + * + * [#8069] **Load-bearing beyond diagnostics.** `ack()` — the only writer of + * a terminal status — increments this unconditionally, so a row that is + * terminal (`success` / `failed` / `dead`) with `attempts === 0` never came + * from the dispatch path: it was **parked** by + * {@link IHttpOutbox.recordUndeliverable} and has never existed on the + * wire. {@link assertHttpRedeliverable} reads exactly that pair, which is + * why this counter is part of the contract and not an implementation + * detail. See {@link HttpDeliveryStatus} for why the pair is used instead + * of a new lifecycle state. + */ attempts: number; /** Node id currently working on this row, when `status = in_flight`. */ claimedBy?: string; @@ -132,8 +158,64 @@ export interface EnqueueHttpInput { signingSecret?: string; timeoutMs?: number; payload: unknown; + /** + * [#8069] Transport-only discriminator for the ONE seam that carries both + * kinds of write — `MessagingService.enqueueHttp()`, the single function a + * producer plugin is handed. When set, this is **not a delivery**: the + * producer could not obtain the credentials this delivery needed, and the + * seam routes the input to {@link IHttpOutbox.recordUndeliverable} instead + * of {@link IHttpOutbox.enqueue}. + * + * ⛔ It never reaches `enqueue()`. Both implementations reject it there + * (see {@link assertEnqueueDeliverable}) rather than ignoring it, because + * an ignored discriminator is exactly how a row that must never be sent + * gets minted `pending` — the shape this issue exists to prevent. + */ + undeliverableReason?: string; +} + +/** + * [#8069] Input for a PARKED row — a delivery that was never sendable, written + * so the drop leaves a durable record instead of only a log line. + * + * `signingSecret` is structurally absent, not merely unused: a parked row is + * parked *because* the secret could not be resolved, so there is nothing to + * sign with, and a signature on such a row would be a lie. `undeliverableReason` + * is absent too — the cause travels as the required {@link reason} here. + */ +export interface UndeliverableHttpInput + extends Omit { + /** + * Why this delivery can never be sent. Lands verbatim in + * {@link HttpDelivery.error} — the existing reason column, which the + * `sys_http_delivery` "Failures" list view already renders. + * + * Owes what an AGENTS.md `error` owes: the consequence, concretely, and the + * fix. An operator reading this row is the person who has to repair the + * misconfiguration. + */ + reason: string; } +/** + * Verdict function consulted by {@link IHttpOutbox.redeliver} BEFORE it resets + * a row — the seam through which a producer that owns configuration the outbox + * cannot see (a webhook's `sys_webhook` row and its encrypted signing secret) + * gets to refuse a replay. + * + * Returns a human-readable refusal reason, or `undefined` to allow. + * + * [#8069] It exists because "is the signing configuration still available?" is + * not answerable from the delivery row: `service-messaging` deliberately knows + * nothing about `sys_webhook`. Putting the question here — rather than in the + * `POST /api/v1/webhooks/redeliver` route — means every caller of + * `redeliver()` is covered, not just the audited HTTP door. A guard that throws + * refuses the redelivery: the row is left untouched. + */ +export type RedeliverGuard = ( + row: HttpDelivery, +) => Promise | string | undefined; + export interface HttpClaimOptions { /** Identifier of the node doing the claim (for `claimedBy`). */ nodeId: string; @@ -179,13 +261,122 @@ export type HttpAckResult = HttpAckSuccess | HttpAckFailure; export class HttpRedeliverError extends Error { constructor( message: string, - readonly code: 'RESOURCE_NOT_FOUND' | 'DELIVERY_NOT_ELIGIBLE', + readonly code: 'RESOURCE_NOT_FOUND' | 'DELIVERY_NOT_ELIGIBLE' | 'DELIVERY_NEVER_SENT', ) { super(message); this.name = 'HttpRedeliverError'; } } +/** + * [#8069] The `redeliver()` refusal, in ONE place both implementations call. + * + * ## What it refuses, and why that predicate + * `redeliver` means *send this again*. A terminal row with `attempts === 0` was + * never sent a first time — {@link IHttpOutbox.ack}, the only writer of a + * terminal status, increments `attempts` unconditionally, so the pair + * (terminal, 0 attempts) is reachable only through + * {@link IHttpOutbox.recordUndeliverable}. Such a row is a **record of a + * delivery that was refused before it ever existed on the wire**, and resetting + * it to `pending` is not a replay: it is a FIRST delivery conjured by an + * operator button, bypassing every check the enqueue path applies. + * + * ## Why this is a security refusal and not a tidiness one + * A parked row has no {@link HttpDelivery.signature}, because the signature is + * computed at enqueue from the very secret that could not be resolved (#7722, + * #7799). `POST /api/v1/webhooks/redeliver` is reachable by any authenticated + * user. Without this refusal, making the drop durable would hand that user a + * button that delivers the webhook UNSIGNED — reopening #7799 through a door + * nobody would think to audit. **An unsigned redelivery is strictly worse than + * an unrecorded drop**, which is why this guard lands with — and logically + * before — the durable record itself. + * + * ## Why `attempts`, not "row has no signature" + * `signature === undefined` is AMBIGUOUS: it also means "authored unsigned", + * which is a legitimate configuration (`secret` is optional on the webhook + * envelope), and refusing those would break a working feature. `attempts === 0` + * on a terminal row is unambiguous and needs no new column and no new lifecycle + * state. It is also fail-closed under the ambiguity that remains: a parked row + * that somehow carried a signature would still be refused. + */ +export function assertHttpRedeliverable(row: HttpDelivery): void { + if (row.status !== 'success' && row.status !== 'failed' && row.status !== 'dead') { + throw new HttpRedeliverError( + `Delivery row '${row.id}' is '${row.status}', expected one of: success, failed, dead`, + 'DELIVERY_NOT_ELIGIBLE', + ); + } + if (row.attempts === 0) { + throw new HttpRedeliverError( + `Delivery row '${row.id}' was never sent — it is a PARKED record of a delivery that ` + + 'could not be prepared (0 attempts), not a delivery that failed. Reason recorded on ' + + `the row: ${row.error ?? '(none recorded)'}. Redelivering it would send the payload ` + + 'for the first time, and a parked row carries no HMAC signature because the signing ' + + 'secret it needed could not be resolved — so the delivery would go out UNSIGNED ' + + '(#7799, #8069). Fix the underlying configuration instead; the subscription re-arms ' + + 'on its own and future events are delivered signed.', + 'DELIVERY_NEVER_SENT', + ); + } +} + +/** + * [#8069] The full pre-write refusal for `redeliver()`: the row-local check + * above, then the producer's {@link RedeliverGuard}. + * + * Ordering is deliberate — the row-local refusal never needs I/O and never + * needs a guard to be wired, so a deployment whose producer registered no guard + * still refuses parked rows. The guard adds what the row cannot answer: whether + * the configuration this delivery was signed against still exists. + * + * A guard that THROWS refuses the redelivery. That is fail-closed on purpose: + * a guard whose own lookup failed does not know the configuration is available, + * and "we could not check" must never read as "allowed". + */ +export async function assertRedeliverAllowed( + row: HttpDelivery, + guard?: RedeliverGuard, +): Promise { + assertHttpRedeliverable(row); + if (!guard) return; + let refusal: string | undefined; + try { + refusal = await guard(row); + } catch (err) { + throw new HttpRedeliverError( + `Delivery row '${row.id}' cannot be redelivered: its producer's configuration check ` + + `failed, so it is unknown whether this delivery can still be signed — refusing ` + + `rather than sending (#8069). Cause: ${(err as Error)?.message ?? String(err)}`, + 'DELIVERY_NOT_ELIGIBLE', + ); + } + if (refusal !== undefined) { + throw new HttpRedeliverError( + `Delivery row '${row.id}' cannot be redelivered: ${refusal}`, + 'DELIVERY_NOT_ELIGIBLE', + ); + } +} + +/** + * [#8069] Reject a parked-row discriminator that reached the delivery door. + * + * {@link EnqueueHttpInput.undeliverableReason} is routed away by + * `MessagingService.enqueueHttp()`. If it arrives here anyway, the routing was + * bypassed or broken, and the row about to be minted is a `pending` unsigned + * delivery for a subscription whose credentials are missing. Refuse loudly + * rather than silently dropping the flag. + */ +export function assertEnqueueDeliverable(input: EnqueueHttpInput): void { + if (input.undeliverableReason !== undefined) { + throw new Error( + 'IHttpOutbox.enqueue: input carries `undeliverableReason` — a row that must NEVER be sent ' + + 'cannot be minted `pending`. Route it to recordUndeliverable() instead (#8069). ' + + `Reason was: ${input.undeliverableReason}`, + ); + } +} + /** * Pluggable storage backend for outbound-HTTP delivery rows. Implementations * MUST make `claim()` atomic across concurrent callers — that property is the @@ -199,6 +390,26 @@ export interface IHttpOutbox { */ enqueue(input: EnqueueHttpInput): Promise; + /** + * [#8069] Record a delivery that can **never** be sent, so a dropped + * subscription leaves a durable trace instead of only a log line. + * + * Writes the row terminal on arrival — `status: 'dead'`, `attempts: 0`, + * `error: input.reason`, no signature — and it is therefore never claimed + * by a dispatcher and never redeliverable (see + * {@link assertHttpRedeliverable}). Same `(source, dedupKey)` uniqueness as + * {@link enqueue}, so the same event cannot produce two records. + * + * ## Why a second door rather than a flag on `enqueue()` + * `enqueue()`'s contract is "insert a delivery row that the dispatcher will + * send". A mode flag that sometimes makes it mint a terminal non-delivery + * would make the name lie, and the failure mode of a *missed* flag is the + * exact catastrophe this issue is about — a `pending`, unsigned row for a + * subscription whose secret could not be resolved. Two named doors make the + * dangerous outcome unreachable by omission rather than merely discouraged. + */ + recordUndeliverable(input: UndeliverableHttpInput): Promise; + /** * Atomically claim up to `limit` rows whose `nextRetryAt <= now` (or null) * and matching the partition predicate. Claimed rows MUST be marked @@ -228,6 +439,14 @@ export interface IHttpOutbox { * the dispatcher re-sends it. Resets `attempts=0`; URL / payload / signature * are NOT touched (byte-for-byte replay — the same body carries the same * signature). Throws {@link HttpRedeliverError}. + * + * [#8069] Implementations MUST call {@link assertHttpRedeliverable} before + * writing anything, and MUST consult `guard` — the producer's verdict on + * whether the configuration this row depends on is still available — with + * the same "refuse before you write" ordering. A refused redelivery leaves + * the row exactly as it was. + * + * @param guard Optional producer verdict; see {@link RedeliverGuard}. */ - redeliver(id: string): Promise; + redeliver(id: string, guard?: RedeliverGuard): Promise; } diff --git a/packages/services/service-messaging/src/index.ts b/packages/services/service-messaging/src/index.ts index b2b09ae05b..1a49daf1ac 100644 --- a/packages/services/service-messaging/src/index.ts +++ b/packages/services/service-messaging/src/index.ts @@ -119,8 +119,15 @@ export type { HttpAckResult, HttpAckSuccess, HttpAckFailure, + UndeliverableHttpInput, + RedeliverGuard, +} from './http-outbox.js'; +export { + HttpRedeliverError, + assertHttpRedeliverable, + assertRedeliverAllowed, + assertEnqueueDeliverable, } from './http-outbox.js'; -export { HttpRedeliverError } from './http-outbox.js'; export { SqlHttpOutbox, type SqlHttpOutboxOptions } from './sql-http-outbox.js'; export { MemoryHttpOutbox } from './memory-http-outbox.js'; export { HttpDispatcher } from './http-dispatcher.js'; diff --git a/packages/services/service-messaging/src/memory-http-outbox.ts b/packages/services/service-messaging/src/memory-http-outbox.ts index 5342eb8f3b..68a2ba904e 100644 --- a/packages/services/service-messaging/src/memory-http-outbox.ts +++ b/packages/services/service-messaging/src/memory-http-outbox.ts @@ -5,12 +5,16 @@ import { hashPartition } from './backoff.js'; import { deliveryBody, signBody } from './http-sender.js'; import { HttpRedeliverError, + assertEnqueueDeliverable, + assertRedeliverAllowed, type EnqueueHttpInput, type HttpAckResult, type HttpClaimOptions, type HttpDelivery, type HttpDeliveryStatus, type IHttpOutbox, + type RedeliverGuard, + type UndeliverableHttpInput, } from './http-outbox.js'; /** @@ -30,6 +34,29 @@ export class MemoryHttpOutbox implements IHttpOutbox { private readonly dedup = new Map(); async enqueue(input: EnqueueHttpInput): Promise { + assertEnqueueDeliverable(input); + return this.insert(input, { + signature: input.signingSecret + ? signBody(deliveryBody(input.payload), input.signingSecret) + : undefined, + status: 'pending', + error: undefined, + }); + } + + /** + * [#8069] Park a delivery that can never be sent — same row shape the SQL + * outbox writes, so a test that inspects a parked row here is looking at + * what production persists. + */ + async recordUndeliverable(input: UndeliverableHttpInput): Promise { + return this.insert(input, { signature: undefined, status: 'dead', error: input.reason }); + } + + private insert( + input: Omit, + terminal: { signature: string | undefined; status: HttpDeliveryStatus; error?: string }, + ): string { const dedupKey = `${input.source}::${input.dedupKey}`; const existing = this.dedup.get(dedupKey); if (existing) return existing; @@ -45,13 +72,12 @@ export class MemoryHttpOutbox implements IHttpOutbox { url: input.url, method: input.method ?? 'POST', headers: input.headers, - signature: input.signingSecret - ? signBody(deliveryBody(input.payload), input.signingSecret) - : undefined, + signature: terminal.signature, timeoutMs: input.timeoutMs, payload: input.payload, - status: 'pending', + status: terminal.status, attempts: 0, + error: terminal.error, createdAt: now, updatedAt: now, }; @@ -130,17 +156,14 @@ export class MemoryHttpOutbox implements IHttpOutbox { return all; } - async redeliver(id: string): Promise { + async redeliver(id: string, guard?: RedeliverGuard): Promise { const row = this.rows.get(id); if (!row) { throw new HttpRedeliverError(`Delivery row '${id}' not found`, 'RESOURCE_NOT_FOUND'); } - if (row.status !== 'success' && row.status !== 'failed' && row.status !== 'dead') { - throw new HttpRedeliverError( - `Delivery row '${id}' is '${row.status}', expected one of: success, failed, dead`, - 'DELIVERY_NOT_ELIGIBLE', - ); - } + // [#8069] Refuse BEFORE any mutation — a refused redelivery must leave + // the row byte-identical, including its `dead` status and its reason. + await assertRedeliverAllowed({ ...row }, guard); const now = Date.now(); row.status = 'pending'; row.attempts = 0; diff --git a/packages/services/service-messaging/src/messaging-service.ts b/packages/services/service-messaging/src/messaging-service.ts index 24112ca477..a4d76ff31b 100644 --- a/packages/services/service-messaging/src/messaging-service.ts +++ b/packages/services/service-messaging/src/messaging-service.ts @@ -10,7 +10,13 @@ import type { import { RecipientResolver } from './recipient-resolver.js'; import { PreferenceResolver, type PreferenceTarget } from './preference-resolver.js'; import type { INotificationOutbox } from './outbox.js'; -import type { EnqueueHttpInput, HttpDelivery, HttpDeliveryStatus, IHttpOutbox } from './http-outbox.js'; +import type { + EnqueueHttpInput, + HttpDelivery, + HttpDeliveryStatus, + IHttpOutbox, + RedeliverGuard, +} from './http-outbox.js'; import { INBOX_OBJECT, RECEIPT_OBJECT } from './inbox-channel.js'; /** The L2 event object every `emit()` writes one row to (ADR-0030). */ @@ -174,6 +180,8 @@ export class MessagingService { private readonly preferences: PreferenceResolver; private outbox?: INotificationOutbox; private httpOutbox?: IHttpOutbox; + /** [#8069] Producer vetoes over redelivery, keyed by `HttpDelivery.source`. */ + private readonly redeliverGuards = new Map(); constructor(private readonly ctx: MessagingServiceContext) { this.now = ctx.now ?? (() => new Date().toISOString()); @@ -220,25 +228,64 @@ export class MessagingService { /** * Enqueue a durable outbound-HTTP delivery (ADR-0018 M3). Returns the row id. * Throws if no HTTP outbox is wired — guard with {@link isHttpDeliveryReady}. + * + * [#8069] This is the ONE function a producer plugin is handed, so it is + * also where the two kinds of write part company. An input carrying + * {@link EnqueueHttpInput.undeliverableReason} is not a delivery — the + * producer could not obtain the credentials it needed — and is routed to + * `recordUndeliverable()`, which parks it `dead` with the reason on the row. + * It never reaches `enqueue()`, which refuses the discriminator outright. */ async enqueueHttp(input: EnqueueHttpInput): Promise { if (!this.httpOutbox) { throw new Error('messaging: HTTP delivery outbox not configured (no data engine / reliableDelivery off)'); } + const { undeliverableReason, signingSecret, ...rest } = input; + if (undeliverableReason !== undefined) { + return this.httpOutbox.recordUndeliverable({ ...rest, reason: undeliverableReason }); + } return this.httpOutbox.enqueue(input); } + /** + * [#8069] Register a producer's veto over redelivery of ITS rows, keyed by + * `HttpDelivery.source` (`'webhook'`, `'flow'`, …). + * + * The outbox cannot answer "is the signing configuration for this row still + * available?" — `sys_webhook` and its encrypted secret are the webhook + * plugin's, and this package deliberately knows nothing about them. This is + * how that knowledge reaches `redeliver()` without inverting the dependency. + * + * Registering here rather than in the `POST /api/v1/webhooks/redeliver` + * route is deliberate: the route is one door, and the failure this guards + * against is precisely a delivery going out through a door nobody audited. + * Every caller of {@link redeliverHttp} is covered. + */ + registerRedeliverGuard(source: string, guard: RedeliverGuard): void { + if (this.redeliverGuards.has(source)) { + this.ctx.logger.warn( + `[messaging] redeliver guard for source '${source}' already registered; replacing`, + ); + } + this.redeliverGuards.set(source, guard); + } + /** * Reset a terminal HTTP delivery row back to `pending` so the dispatcher * re-sends it (ADR-0018 M3). Backs the webhook redeliver admin endpoint. * Throws if no HTTP outbox is wired, or `HttpRedeliverError` for a missing / * non-terminal row. + * + * [#8069] Also throws `HttpRedeliverError` for a row that was never sent + * (a parked drop record — sending it would be a FIRST delivery, unsigned), + * and for a row whose producer's {@link registerRedeliverGuard} verdict + * refuses. Both refusals happen before anything is written. */ async redeliverHttp(id: string): Promise { if (!this.httpOutbox) { throw new Error('messaging: HTTP delivery outbox not configured'); } - return this.httpOutbox.redeliver(id); + return this.httpOutbox.redeliver(id, (row) => this.redeliverGuards.get(row.source)?.(row)); } /** List HTTP delivery rows (admin/tests). Empty when no outbox is wired. */ diff --git a/packages/services/service-messaging/src/objects/http-delivery.object.ts b/packages/services/service-messaging/src/objects/http-delivery.object.ts index 7cd0bd0ae8..940c990bdd 100644 --- a/packages/services/service-messaging/src/objects/http-delivery.object.ts +++ b/packages/services/service-messaging/src/objects/http-delivery.object.ts @@ -176,6 +176,14 @@ export const HttpDelivery = ObjectSchema.create({ description: 'hash(ref_id) mod partitionCount — precomputed for cheap WHERE', }), + // [#8069] The vocabulary is UNCHANGED by the drop-record work, and that + // is a decision rather than an omission. The maintainer's ruling + // (2026-08-12) admits a new never-sendable state only if the reason + // column cannot carry the drop cause — and `error` below carries it + // fine, which is why no migration-shaped state landed here. A dropped + // subscription's discarded event is written `dead` with its cause in + // `error`, so it appears in the existing "Failures" view with no new + // vocabulary for an operator (or a driver, or a saved filter) to learn. status: Field.text({ label: 'Status', required: true, @@ -184,11 +192,25 @@ export const HttpDelivery = ObjectSchema.create({ description: 'pending | in_flight | success | failed | dead', }), + // [#8069] Part of the redelivery CONTRACT, not just a diagnostic count. + // `ack()` — the only writer of a terminal status — always increments + // it, so `status in (success, failed, dead) AND attempts = 0` is + // reachable only through `SqlHttpOutbox.recordUndeliverable()`: a + // PARKED row, recording an event that was discarded because its + // webhook's signing secret could not be resolved. `redeliver()` refuses + // exactly that pair. It must, because a parked row carries no + // `signature` (the secret that would have produced one is what went + // missing) and `POST /api/v1/webhooks/redeliver` is reachable by any + // authenticated user — so without the refusal, recording the drop would + // hand that user a button that delivers the payload UNSIGNED, reopening + // #7799 through a door nobody would think to audit. attempts: Field.number({ label: 'Attempts', required: true, defaultValue: 0, - description: 'Number of attempts made so far', + description: + 'Number of attempts made so far. 0 on a terminal row means the delivery was PARKED — ' + + 'never sent, and not redeliverable (#8069); see `error` for the cause.', }), claimed_by: Field.text({ label: 'Claimed By', required: false, maxLength: 128 }), @@ -197,7 +219,22 @@ export const HttpDelivery = ObjectSchema.create({ last_attempted_at: Field.number({ label: 'Last Attempted At (ms)', required: false }), response_code: Field.number({ label: 'HTTP Status', required: false }), response_body: Field.textarea({ label: 'Response Body (capped)', required: false }), - error: Field.textarea({ label: 'Error', required: false }), + // [#8069] Two authors, one column — and it holds both without a new + // field. The dispatcher writes the last transport/timeout error here; + // `recordUndeliverable()` writes why a delivery was never attempted at + // all (an unresolvable signing secret, an unrecoverable header map). + // Both are "why this row is not a delivered webhook", both are read by + // the same operator in the same "Failures" view, and the column is an + // unbounded textarea, so it carries the remedy text an AGENTS.md + // `error` owes. That is the measured answer to the ruling's step 3: the + // reason column CAN carry the drop cause, so no new lifecycle state. + error: Field.textarea({ + label: 'Error', + required: false, + description: + 'Why this row is not a delivered callout: the last transport error, or — on a row with ' + + '0 attempts — why the delivery could never be prepared (#8069).', + }), // Builtin audit columns are native TIMESTAMP columns (Postgres/MySQL), // so declare them `datetime` and write `Date`s (not epoch-ms numbers, diff --git a/packages/services/service-messaging/src/sql-http-outbox.ts b/packages/services/service-messaging/src/sql-http-outbox.ts index 909f88ee15..e9c70137fc 100644 --- a/packages/services/service-messaging/src/sql-http-outbox.ts +++ b/packages/services/service-messaging/src/sql-http-outbox.ts @@ -7,12 +7,16 @@ import { toEpochMs } from './audit-timestamp.js'; import { deliveryBody, signBody } from './http-sender.js'; import { HttpRedeliverError, + assertEnqueueDeliverable, + assertRedeliverAllowed, type EnqueueHttpInput, type HttpAckResult, type HttpClaimOptions, type HttpDelivery, type HttpDeliveryStatus, type IHttpOutbox, + type RedeliverGuard, + type UndeliverableHttpInput, } from './http-outbox.js'; import { SYS_HTTP_DELIVERY } from './objects/http-delivery.object.js'; @@ -104,6 +108,42 @@ export class SqlHttpOutbox implements IHttpOutbox { } async enqueue(input: EnqueueHttpInput): Promise { + assertEnqueueDeliverable(input); + return this.insert(input, { + // Sign here, store only the signature (#7722). The body is decided + // at enqueue and replayed byte-for-byte by every retry, so one HMAC + // covers every attempt and the secret has no reason to be persisted. + signature: input.signingSecret + ? signBody(deliveryBody(input.payload), input.signingSecret) + : undefined, + status: 'pending', + error: undefined, + }); + } + + /** + * [#8069] Park a delivery that can never be sent. + * + * Written terminal on arrival — `status: 'dead'`, `attempts: 0`, the cause + * in the existing `error` column, and **no signature**, because the secret + * that would have produced one is precisely what could not be resolved. + * The dispatcher's claim query filters `status = 'pending'`, so a parked row + * is never picked up; `redeliver()` refuses it (see + * `assertHttpRedeliverable`), so no operator can conjure a first delivery + * out of it either. + * + * Reuses the same INSERT + `(source, dedup_key)` dedup convergence as + * `enqueue()`: one discarded event yields at most one record, and a webhook + * that re-arms cannot double-write a record for an event it already parked. + */ + async recordUndeliverable(input: UndeliverableHttpInput): Promise { + return this.insert(input, { signature: undefined, status: 'dead', error: input.reason }); + } + + private async insert( + input: Omit, + terminal: { signature: string | undefined; status: HttpDeliveryStatus; error?: string }, + ): Promise { const existing = await this.engine.findOne(this.objectName, { where: { source: input.source, dedup_key: input.dedupKey }, fields: ['id'], @@ -123,17 +163,13 @@ export class SqlHttpOutbox implements IHttpOutbox { url: input.url, method: input.method ?? 'POST', headers_json: input.headers ? JSON.stringify(input.headers) : undefined, - // Sign here, store only the signature (#7722). The body is decided - // at enqueue and replayed byte-for-byte by every retry, so one HMAC - // covers every attempt and the secret has no reason to be persisted. - signature: input.signingSecret - ? signBody(deliveryBody(input.payload), input.signingSecret) - : undefined, + signature: terminal.signature, timeout_ms: input.timeoutMs, payload_json: JSON.stringify(input.payload ?? null), partition_key: hashPartition(input.refId, this.partitionCount), - status: 'pending', + status: terminal.status, attempts: 0, + error: terminal.error, created_at: now, updated_at: now, }; @@ -303,17 +339,14 @@ export class SqlHttpOutbox implements IHttpOutbox { return rows.map((r) => this.toDelivery(r)); } - async redeliver(id: string): Promise { + async redeliver(id: string, guard?: RedeliverGuard): Promise { const current = (await this.engine.findOne(this.objectName, { where: { id } })) as DeliveryRow | null; if (!current) { throw new HttpRedeliverError(`Delivery row '${id}' not found`, 'RESOURCE_NOT_FOUND'); } - if (current.status !== 'success' && current.status !== 'failed' && current.status !== 'dead') { - throw new HttpRedeliverError( - `Delivery row '${id}' is '${current.status}', expected one of: success, failed, dead`, - 'DELIVERY_NOT_ELIGIBLE', - ); - } + // [#8069] Every refusal runs BEFORE the reset UPDATE — a refused + // redelivery leaves the row exactly as it was, `dead` reason included. + await assertRedeliverAllowed(this.toDelivery(current), guard); await this.engine.update( this.objectName, { diff --git a/packages/services/service-messaging/src/translations/en.objects.generated.ts b/packages/services/service-messaging/src/translations/en.objects.generated.ts index 53f78ee43c..e4a3bd5e72 100644 --- a/packages/services/service-messaging/src/translations/en.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/en.objects.generated.ts @@ -371,7 +371,8 @@ export const enObjects: NonNullable = { label: "Response Body (capped)" }, error: { - label: "Error" + label: "Error", + help: "Why this row is not a delivered callout: the last transport error, or — on a row with 0 attempts — why the delivery could never be prepared (#8069)." }, created_at: { label: "Created At" diff --git a/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts b/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts index e5e3b4faf1..c0c0f2e3d8 100644 --- a/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts @@ -371,7 +371,8 @@ export const esESObjects: NonNullable = { label: "Response Body (capped)" }, error: { - label: "Error" + label: "Error", + help: "Why this row is not a delivered callout: the last transport error, or — on a row with 0 attempts — why the delivery could never be prepared (#8069)." }, created_at: { label: "Created At" diff --git a/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts b/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts index 62d9f42e80..b7c9a8bb3a 100644 --- a/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts @@ -371,7 +371,8 @@ export const jaJPObjects: NonNullable = { label: "Response Body (capped)" }, error: { - label: "Error" + label: "Error", + help: "Why this row is not a delivered callout: the last transport error, or — on a row with 0 attempts — why the delivery could never be prepared (#8069)." }, created_at: { label: "Created At" diff --git a/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts b/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts index 82922a5251..67cc72cf2f 100644 --- a/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts @@ -371,7 +371,8 @@ export const zhCNObjects: NonNullable = { label: "响应体(截断)" }, error: { - label: "错误" + label: "错误", + help: "Why this row is not a delivered callout: the last transport error, or — on a row with 0 attempts — why the delivery could never be prepared (#8069)." }, created_at: { label: "创建时间" diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 6baca79e1f..c91aa1baea 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -357,6 +357,7 @@ export const ERROR_CODE_LEDGER = { 'AUTH_CONFIG_ERROR', // auth service threw while the adapter mounted it ], '@objectstack/service-messaging': [ + 'DELIVERY_NEVER_SENT', // [#8069] terminal delivery row with 0 attempts — a PARKED record of a delivery that could never be prepared, not one that failed. Redelivering it would be a FIRST send, and the row carries no HMAC signature because the secret that would have produced one is exactly what went missing, so it would go out unsigned (#7799). Distinct from DELIVERY_NOT_ELIGIBLE: that one says "wrong state, try when it settles"; this one says "never, fix the configuration instead" 'DELIVERY_NOT_ELIGIBLE', // delivery row is in a non-terminal state ], '@objectstack/trigger-api': [