diff --git a/.changeset/sharing-envelope-convergence.md b/.changeset/sharing-envelope-convergence.md new file mode 100644 index 0000000000..64021f1686 --- /dev/null +++ b/.changeset/sharing-envelope-convergence.md @@ -0,0 +1,39 @@ +--- +"@objectstack/rest": patch +"@objectstack/spec": patch +--- + +fix(rest): one error envelope across the record-sharing family (#8111) + +`registerSharingEndpoints` — `GET/POST /data/:object/:id/shares` and +`DELETE /data/:object/:id/shares/:shareId` — answered two retired dialects across +its nine refusal arms: the 501 was flat `{ code, message }`, and the five mapped +verdicts (400 / 403 / 404 / 409 / 422) plus the three verb-specific 500s were +`{ code, error: 'a bare string' }`. So `body.error.code`, the one position +ADR-0112 D5 declares, read `undefined` on all nine — while the adjacent +`/security` registrars, converged in #7981 and #8073, already answered the +declared shape. + +Every arm now emits `{ success: false, error: { code, message } }` through the +shared `sendError` from `@objectstack/types` — the same builder every conformant +route module writes through — so the family agrees by construction rather than by +nine literals happening to match. + +No status code moves and no code VALUE changes. One code did change status in the +REGISTRY rather than on the wire: the 409 arm's `CONFLICT` was registered in +neither `StandardErrorCode` nor `ERROR_CODE_LEDGER`, so `ApiErrorSchema` — whose +`code` is a closed enum — would have rejected that body. It is now registered +under `@objectstack/rest`, keeping the emitted value byte-identical; consolidating +it onto the standard catalog's `RESOURCE_CONFLICT` would change what clients read +and is filed separately for the maintainer. + +The `CODE:` message prefix the service uses to signal its verdict is untouched: it +is a server-internal service→REST derivation, stripped before the response is +written and never present on the wire, so no consumer can read it (censused at +claim). `ObjectStackClient` reads both envelopes' declared spots +(`errorBody?.code ?? errorBody?.error?.code`, and a bare-string limb for the +message), so `client.shares.list()`, `.grant()` and `.revoke()` keep throwing +identical `err.code`, `err.message`, `err.httpStatus`, `err.category`, +`err.retryable` and `err.fields` — re-measured against these call paths rather +than inherited from #7981 or #8073. `err.details` does change on every refusal: +its last fallback is the whole response body, and the body is what moved. diff --git a/content/docs/references/api/analytics.mdx b/content/docs/references/api/analytics.mdx index 9180f81f1c..2917e8cf66 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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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 2c2009660d..604206b6e2 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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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 5a9735be46..7b61d4bb75 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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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 7b24fda07b..6b65527425 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' \| … +264 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' \| … +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). | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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 35e9ccfd40..4d1ae7b773 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' \| … +260 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' \| … +261 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 | @@ -115,6 +115,7 @@ const result = ApiErrorSchema.parse(data); * `CLOUD_UNCONFIGURED` * `COMMIT_NOT_FOUND` * `CONCURRENT_UPDATE` +* `CONFLICT` * `CONFLICTING_MAPPING` * `CONNECTOR_UPSTREAM_UNAVAILABLE` * `CREATE_FAILED` @@ -314,7 +315,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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | @@ -353,7 +354,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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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 | @@ -395,7 +396,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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **id** | `string` | ✅ | ID of the deleted record | @@ -447,7 +448,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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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 | @@ -463,7 +464,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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | | +| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +265 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | | | **index** | `number` | optional | Index in original request | | **data** | `any` | optional | Result data (e.g. created record) | @@ -502,7 +503,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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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 3e233ebee2..0fab9ffe8a 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -187,6 +187,7 @@ const result = ErrorCode.parse(data); * `CLOUD_UNCONFIGURED` * `COMMIT_NOT_FOUND` * `CONCURRENT_UPDATE` +* `CONFLICT` * `CONFLICTING_MAPPING` * `CONNECTOR_UPSTREAM_UNAVAILABLE` * `CREATE_FAILED` diff --git a/content/docs/references/api/export.mdx b/content/docs/references/api/export.mdx index 184c910581..9b491b688f 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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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 0d22a7363c..2f7000e6b8 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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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 f286874750..eca93192ca 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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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 46ad75b26b..e475128b5a 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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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 0b6b35d111..1f605cccfc 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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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/client/src/shares-envelope-compat.test.ts b/packages/client/src/shares-envelope-compat.test.ts new file mode 100644 index 0000000000..0ff1609f11 --- /dev/null +++ b/packages/client/src/shares-envelope-compat.test.ts @@ -0,0 +1,187 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8111] SDK compatibility across the record-sharing envelope convergence. + * + * `registerSharingEndpoints` moved its nine refusal arms from two retired + * dialects onto the ADR-0112 D5 envelope. This measures what a caller of + * `client.shares.*` actually observes across that move — re-run against THESE + * consumers rather than inherited from #7981 or #8073, because a compatibility + * claim taken on one call path is not evidence about another. + * + * The method: drive the REAL `ObjectStackClient` against a stubbed transport + * answering the OLD body and then the NEW body for each arm, and compare every + * property the client attaches to the thrown error. `client.shares.*` goes + * through `ObjectStackClient.fetch`, whose error path reads BOTH envelopes' + * declared spots on purpose (`errorBody?.code ?? errorBody?.error?.code`, plus + * a bare-string limb for the message), which is what makes the move safe. + * + * ## The measured result + * + * `code`, `message`, `httpStatus`, `category`, `retryable` and `fields` are + * IDENTICAL on every arm. `details` CHANGES on every arm — its last fallback + * is the whole response body (`errorBody?.details ?? errorBody?.error?.details + * ?? errorBody`), and the body is exactly what this card reshapes. Neither + * envelope carries a `details`, so both fall through to that last limb; the + * value differs because the body differs. + * + * That is discharged by census, not by assertion: no in-repo consumer reads + * `err.details` off a `shares.*` call, and `details` is documented as + * unstructured debugging context. It is pinned here rather than left implicit + * so the next reader sees the one property that moved and why it was accepted. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackClient } from './index'; + +/** A client whose transport answers exactly one refusal body. */ +function clientAnswering(status: number, body: any) { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status, + statusText: 'Error', + json: async () => body, + headers: new Headers(), + }); + return new ObjectStackClient({ baseUrl: 'http://localhost:3000', fetch: fetchMock }); +} + +/** Every property `ObjectStackClient.fetch` attaches to the error it throws. */ +function observed(err: any) { + return { + message: err.message, + code: err.code, + httpStatus: err.httpStatus, + category: err.category, + retryable: err.retryable, + fields: err.fields, + details: err.details, + }; +} + +async function refusalFrom(status: number, body: any, call: (c: ObjectStackClient) => Promise) { + const client = clientAnswering(status, body); + try { + await call(client); + throw new Error('expected the call to reject, but it resolved'); + } catch (err: any) { + if (err?.httpStatus === undefined) throw err; + return observed(err); + } +} + +const LIST = (c: ObjectStackClient) => c.shares.list('lead', 'rec1'); +const GRANT = (c: ObjectStackClient) => c.shares.grant('lead', 'rec1', { + recipientType: 'user', recipientId: 'u1', accessLevel: 'read', +}); +const REVOKE = (c: ObjectStackClient) => c.shares.revoke('lead', 'rec1', 'sh1'); + +/** + * Every arm, in both dialects. + * + * `old` is what the arm emitted before this card — the flat `{ code, message }` + * for the 501 and `{ code, error: '' }` for the rest. `next` is + * what the shared `sendError` writes now. + */ +const ARMS: Array<{ + label: string; + status: number; + code: string; + message: string; + old: any; + call: (c: ObjectStackClient) => Promise; +}> = [ + { + label: '501 NOT_IMPLEMENTED', + status: 501, code: 'NOT_IMPLEMENTED', + message: 'Sharing service is not configured on this deployment', + old: { code: 'NOT_IMPLEMENTED', message: 'Sharing service is not configured on this deployment' }, + call: LIST, + }, + { + label: '400 VALIDATION_FAILED', + status: 400, code: 'VALIDATION_FAILED', message: 'recipientId is required', + old: { code: 'VALIDATION_FAILED', error: 'recipientId is required' }, + call: GRANT, + }, + { + label: '403 PERMISSION_DENIED', + status: 403, code: 'PERMISSION_DENIED', message: 'you do not hold canManageShares on this record', + old: { code: 'PERMISSION_DENIED', error: 'you do not hold canManageShares on this record' }, + call: LIST, + }, + { + label: '404 NOT_FOUND', + status: 404, code: 'NOT_FOUND', message: 'record lead/rec1 does not exist', + old: { code: 'NOT_FOUND', error: 'record lead/rec1 does not exist' }, + call: LIST, + }, + { + label: '409 CONFLICT', + status: 409, code: 'CONFLICT', message: "share sh1 is materialised by source 'rule'", + old: { code: 'CONFLICT', error: "share sh1 is materialised by source 'rule'" }, + call: REVOKE, + }, + { + label: '422 SHARING_NOT_ENABLED', + status: 422, code: 'SHARING_NOT_ENABLED', message: "'lead' bypasses record sharing", + old: { code: 'SHARING_NOT_ENABLED', error: "'lead' bypasses record sharing" }, + call: GRANT, + }, + { + label: '500 SHARE_GRANT_FAILED', + status: 500, code: 'SHARE_GRANT_FAILED', message: 'boom', + old: { code: 'SHARE_GRANT_FAILED', error: 'boom' }, + call: GRANT, + }, +]; + +const nextBody = (code: string, message: string) => ({ success: false, error: { code, message } }); + +describe('[#8111] client.shares.* observes the same error across the envelope move', () => { + for (const arm of ARMS) { + it(`${arm.label} — code, message, httpStatus, category, retryable, fields unchanged`, async () => { + const before = await refusalFrom(arm.status, arm.old, arm.call); + const after = await refusalFrom(arm.status, nextBody(arm.code, arm.message), arm.call); + + // The properties a caller branches on are identical, arm by arm. + expect(after.code).toBe(before.code); + expect(after.code).toBe(arm.code); + expect(after.message).toBe(before.message); + expect(after.message).toBe(arm.message); + expect(after.httpStatus).toBe(before.httpStatus); + expect(after.category).toBe(before.category); + expect(after.retryable).toBe(before.retryable); + expect(after.fields).toBe(before.fields); + }); + } + + it('`details` is the ONE property that moves — measured, not assumed', async () => { + // Both envelopes fall through to the same last limb (`?? errorBody`), + // because neither carries a `details`. So `err.details` is the whole + // response body in both cases — and the body is what this card + // reshapes. Discharged by census: nothing in-repo reads `err.details` + // off a `shares.*` call. + const arm = ARMS[1]; + const before = await refusalFrom(arm.status, arm.old, arm.call); + const after = await refusalFrom(arm.status, nextBody(arm.code, arm.message), arm.call); + + expect(before.details).toEqual({ code: 'VALIDATION_FAILED', error: 'recipientId is required' }); + expect(after.details).toEqual({ + success: false, + error: { code: 'VALIDATION_FAILED', message: 'recipientId is required' }, + }); + expect(after.details).not.toEqual(before.details); + }); + + it('the NEW envelope alone satisfies the whole read surface — no dialect straddle', async () => { + // Read on its own terms rather than only relative to the old body: a + // caller on a converged server gets the pair, not a half-populated error. + for (const arm of ARMS) { + const after = await refusalFrom(arm.status, nextBody(arm.code, arm.message), arm.call); + expect(after.code, `${arm.label} lost its code`).toBe(arm.code); + expect(after.message, `${arm.label} lost its message`).toBe(arm.message); + expect(after.httpStatus, `${arm.label} lost its status`).toBe(arm.status); + } + }); +}); diff --git a/packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts b/packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts index e41e36379a..308b21dbee 100644 --- a/packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts +++ b/packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts @@ -307,11 +307,17 @@ describe('[#8119] federated phantom anchor: single-record gates + share posture' { recipientId: adminId, accessLevel: 'edit' }, ); expect(res.status).toBe(422); - const body = await res.json() as { code?: string; error?: string }; - expect(body.code).toBe('SHARING_NOT_ENABLED'); + // [#8111] MIGRATED, not loosened: this route family converged onto the + // ADR-0112 D5 envelope, so the pair lives at `body.error.{code,message}`. + // It was written against the retired dialect (`{ code, error: '' }`) + // because that is what the route emitted when #8119 landed. Asserting the + // D5 position ALONE is deliberate — accepting both shapes would re-admit + // the dialect this convergence retired. + const body = await res.json() as { error?: { code?: string; message?: string } }; + expect(body.error?.code).toBe('SHARING_NOT_ENABLED'); // The operator-facing half: "no owner_id field" would be false here and // would send them to add a column the platform already injected. - expect(body.error).toMatch(/federated/); + expect(body.error?.message).toMatch(/federated/); }); it('CONTROL: a LOCAL private record is NOT refused by the posture guard', async () => { @@ -339,7 +345,13 @@ describe('[#8119] federated phantom anchor: single-record gates + share posture' { recipientId: 'usr_grantee_8119', accessLevel: 'edit' }, ); expect(res.status).not.toBe(422); - expect((await res.json() as { code?: string }).code).not.toBe('SHARING_NOT_ENABLED'); + // [#8111] Migrated with its sibling above. This one did not FAIL after the + // envelope moved — a negative assertion on the vacated flat position + // passes for free (`undefined !== 'SHARING_NOT_ENABLED'`) — which is + // exactly why it had to move too: left alone it would have gone on + // "passing" while reading a key no response carries any more. + expect((await res.json() as { error?: { code?: string } }).error?.code) + .not.toBe('SHARING_NOT_ENABLED'); }); it('CONTROL: the grandfathered shipped object still refuses as PUBLIC, unchanged', async () => { diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 17fda035aa..6c39932666 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -9459,16 +9459,55 @@ export class RestServer { try { return await this.sharingServiceProvider(environmentId); } catch { return undefined; } }; - const respond501 = (res: any) => res.status(501).json({ - code: 'NOT_IMPLEMENTED', - message: 'Sharing service is not configured on this deployment', - }); + /** + * [#8111] The ONE refusal emitter for the record-sharing family — the + * 501, all five mapped verdicts and all three 500s go through it, so + * "list, grant and revoke answer the same shape" is a property of the + * code rather than of nine literals that happen to agree. + * + * Before this, the family carried BOTH dialects ADR-0112 D5 retires: + * `respond501` was flat `{ code, message }` and every other arm was + * `{ code, error: '' }`, so `body.error.code` — the one + * position D5 declares — read `undefined` on all nine. #7035 + * (PR #7293) had already removed both from this file's `/meta` + * refusals, #7981 (PR #8071) from `registerSecurityEndpoints` and + * #8073 (PR #8174) from the `/security/explain` pair. + * + * Emitted through the SHARED builder (`sendError` from + * `@objectstack/types`, imported as `sendEnvelopeError` because this + * module has a local `sendError` of its own — the sanitizing responder + * for THROWN errors, a different thing). That is what makes this the + * reference shape by construction rather than a tenth local literal + * agreeing with the nine it replaced, and it types `code` to the + * closed ADR-0112 vocabulary for free. + * + * ⛔ Status codes are untouched and no code VALUE moves: only the + * POSITION of `code` and `message` changes. + */ + const respondError = ( + res: any, + status: number, + code: ErrorCode, + message: string, + ): void => sendEnvelopeError(res, status, code, message); + + const respond501 = (res: any) => respondError( + res, 501, 'NOT_IMPLEMENTED', + 'Sharing service is not configured on this deployment', + ); // [ADR-0111] The service enforces authorization (D1/D4/D5/D7) and // signals the verdict via message prefixes, the plugin's established // error idiom — this maps them onto HTTP. Returns true when handled. + // + // [#8111] The prefix is a SERVER-INTERNAL service→REST derivation: it + // is stripped below and never reaches the wire, so no consumer can + // read it (censused at claim — the only in-repo `startsWith(CODE)` + // readers are this file's own route mappings plus one + // `plugin-approvals` check on an error it threw itself in-process). + // It therefore stays exactly as it is; only the response SHAPE moved. const respondSharingError = (res: any, error: any): boolean => { const msg = String(error?.message ?? error ?? ''); - const map: Array<[string, number]> = [ + const map: Array<[ErrorCode, number]> = [ ['VALIDATION_FAILED', 400], ['PERMISSION_DENIED', 403], ['NOT_FOUND', 404], @@ -9477,10 +9516,10 @@ export class RestServer { ]; for (const [code, status] of map) { if (msg.startsWith(code)) { - res.status(status).json({ - code, - error: msg.replace(new RegExp(`^${code}:\\s*`), ''), - }); + respondError( + res, status, code, + msg.replace(new RegExp(`^${code}:\\s*`), ''), + ); return true; } } @@ -9504,7 +9543,10 @@ export class RestServer { } catch (error: any) { if (respondSharingError(res, error)) return; logError('[REST] List shares error:', error); - res.status(500).json({ code: 'SHARES_LIST_FAILED', error: String(error?.message ?? error).slice(0, 500) }); + // The 500 arms keep their 500-char cap: an unexpected + // fault's message is not a contract, and truncating it + // stays a sanitization step — only the position moves. + respondError(res, 500, 'SHARES_LIST_FAILED', String(error?.message ?? error).slice(0, 500)); } }, metadata: { summary: 'List per-record sharing grants', tags: ['sharing'] }, @@ -9538,7 +9580,7 @@ export class RestServer { } catch (error: any) { if (respondSharingError(res, error)) return; logError('[REST] Grant share error:', error); - res.status(500).json({ code: 'SHARE_GRANT_FAILED', error: String(error?.message ?? error).slice(0, 500) }); + respondError(res, 500, 'SHARE_GRANT_FAILED', String(error?.message ?? error).slice(0, 500)); } }, metadata: { summary: 'Grant a per-record share to a principal', tags: ['sharing'] }, @@ -9567,7 +9609,7 @@ export class RestServer { } catch (error: any) { if (respondSharingError(res, error)) return; logError('[REST] Revoke share error:', error); - res.status(500).json({ code: 'SHARE_REVOKE_FAILED', error: String(error?.message ?? error).slice(0, 500) }); + respondError(res, 500, 'SHARE_REVOKE_FAILED', String(error?.message ?? error).slice(0, 500)); } }, metadata: { summary: 'Revoke a per-record share by id', tags: ['sharing'] }, diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 106955c032..272ab2e6fe 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -1506,7 +1506,18 @@ describe('RestServer', () => { const res = { json: vi.fn(), status: vi.fn().mockReturnThis() }; await route!.handler({ params: { object: 'account', id: 'a1' }, body: {} } as any, res as any); expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'VALIDATION_FAILED' })); + // [#8111] Migrated from the retired FLAT position to the ADR-0112 D5 one. + // Same code, same 400 — `body.error.code` is where it is now declared. + // This arm carried the OTHER retired dialect too (`{ code, error: '' }`), so its message now lives at `body.error.message` rather + // than being `body.error` itself; asserting the code position alone does + // not catch a bare string sitting where the object belongs. + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + error: expect.objectContaining({ + code: 'VALIDATION_FAILED', + message: 'recipientId is required', + }), + })); }); it('DELETE revokes and returns 204', async () => { diff --git a/packages/rest/src/sharing-envelope.test.ts b/packages/rest/src/sharing-envelope.test.ts new file mode 100644 index 0000000000..7b0ebbd79f --- /dev/null +++ b/packages/rest/src/sharing-envelope.test.ts @@ -0,0 +1,340 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8111] ONE error envelope across the record-sharing family (ADR-0112 D5). + * + * ## What was wrong + * + * `registerSharingEndpoints` — `GET/POST /data/:object/:id/shares` and + * `DELETE /data/:object/:id/shares/:shareId` — answered TWO retired dialects + * across its nine refusal arms, both of which #7035 (PR #7293) had already + * removed from this file's `/meta` refusals, #7981 (PR #8071) from + * `registerSecurityEndpoints` and #8073 (PR #8174) from the explain pair: + * + * | arms | shape | + * | :-------------------------------------- | :--------------------------------------- | + * | 501 `NOT_IMPLEMENTED` (`respond501`) | `{ code, message }` — flat | + * | 400/403/404/409/422 (`respondSharingError`) | `{ code, error: '' }` | + * | three 500s (list/grant/revoke) | `{ code, error: '' }` | + * + * So `body.error.code` — the one position ADR-0112 D5 declares — read + * `undefined` on all nine. + * + * ## The `CODE:` message prefix is NOT the wire contract + * + * `respondSharingError` recovers the verdict by parsing the service's message + * text (`msg.startsWith(CODE)`) and then STRIPS that prefix before answering. + * The prefix is a server-internal service→REST derivation that never reaches + * the wire, so no consumer can read it — censused at claim. It is therefore + * untouched here: this card moved the response SHAPE only. `stripsThePrefix` + * below pins that the stripping still happens, so a future reader does not + * "restore" a prefix the wire never carried. + * + * ## What these cases assert, and why not `toThrow` + * + * These handlers *send*; they never throw. A `rejects.toThrow()`-shaped + * assertion would report "the promise resolved" and could not separate + * "refused with the wrong envelope" from "did not refuse at all" — and the + * wrong envelope IS the defect. So every case asserts the ADR-0112 **pair**, + * HTTP `status` AND nested `body.error.code`, plus both retired dialects' + * absence: no top-level `code` sibling, and `error` an object rather than a + * bare string. + * + * ## The cross-arm pin is DERIVED + * + * Nine hand-written literal expectations that agree today is how this defect + * started — each arm was individually defensible and nobody compared them. So + * `shapeOf()` reduces a body to its structural skeleton (key paths + value + * types) and the family case asserts every arm reduces to the SAME skeleton + * without naming what that skeleton is. A third dialect added to any arm fails + * there even if someone also adds a matching literal case. + * + * ## What does NOT move + * + * No status code, and no code VALUE. `NOT_IMPLEMENTED` and `PERMISSION_DENIED` + * are `StandardErrorCode`; `VALIDATION_FAILED`, `NOT_FOUND`, `SHARES_LIST_FAILED`, + * `SHARE_GRANT_FAILED` and `SHARE_REVOKE_FAILED` are in `ERROR_CODE_LEDGER`'s + * `@objectstack/rest` block; `SHARING_NOT_ENABLED` in `@objectstack/plugin-sharing`'s + * (the union is flat). `CONFLICT` was registered by this card — it was the one + * code this emitter has always put on the wire while being declared NOWHERE, + * so `ApiErrorSchema` would have rejected the 409 body. Registering the + * existing value keeps the wire byte-identical; renaming it onto the standard + * catalog's `RESOURCE_CONFLICT` would change what clients read and is filed + * separately for the maintainer. + */ + +import { describe, it, expect, vi } from 'vitest'; +// `.js` on purpose — NodeNext resolution requires the extension, and this +// package's TEST_DEBT ceiling has no margin for another TS2835 (#7248). +import { RestServer } from './rest-server.js'; +import { ApiErrorSchema } from '@objectstack/spec/api'; + +const LIST = '/api/v1/data/:object/:id/shares'; +const REVOKE = '/api/v1/data/:object/:id/shares/:shareId'; + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +function mockRes() { + const res: any = { + statusCode: 200, + json: vi.fn(function (this: any, body: any) { this._body = body; return this; }), + send: vi.fn(function (this: any) { return this; }), + end: vi.fn(function (this: any) { return this; }), + setHeader: vi.fn(function (this: any) { return this; }), + status: vi.fn(function (this: any, code: number) { this.statusCode = code; return this; }), + header: vi.fn(function (this: any) { return this; }), + }; + return res; +} + +type Answer = { status: number; body: any }; + +/** + * @param service what `sharingServiceProvider` resolves to. `undefined` leaves + * the provider unset, which is the 501 arm. + */ +function boot(service?: any) { + const rest = new RestServer( + mockServer() as any, + { getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: {} }) } as any, + { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, + undefined, + service === undefined ? undefined : (async () => service) as any, + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'u_admin' }); + rest.registerRoutes(); + + const route = (method: string, path: string) => { + const found = (rest as any).getRoutes().find( + (r: any) => r.method === method && r.path === path, + ); + if (!found) throw new Error(`route not registered: ${method} ${path}`); + return found; + }; + + const drive = async ( + method: string, + path: string, + req: Record = {}, + ): Promise => { + const res = mockRes(); + await route(method, path).handler( + { + method, path, headers: {}, query: {}, body: {}, + params: { object: 'account', id: 'a1', shareId: 'shr_X' }, + ...req, + } as any, + res, + ); + return { status: res.statusCode, body: res.json.mock.calls.at(-1)?.[0] }; + }; + + return { + list: () => drive('GET', LIST), + grant: (body: Record = {}) => drive('POST', LIST, { body }), + revoke: () => drive('DELETE', REVOKE), + }; +} + +/** A service whose every verb rejects with `err`. */ +function throwingService(err: unknown) { + return { + listShares: vi.fn().mockRejectedValue(err), + grant: vi.fn().mockRejectedValue(err), + revoke: vi.fn().mockRejectedValue(err), + }; +} + +/** The prefixed messages the real `sharing-service` throws (verbatim idiom). */ +const prefixed = (code: string, rest: string) => new Error(`${code}: ${rest}`); + +/** + * The full ADR-0112 assertion for one refusal: the PAIR (status + code) at the + * nested position, and both retired dialects absent. + */ +function expectNestedEnvelope(answer: Answer, status: number, code: string) { + expect( + answer.status, + `expected ${status}, got ${answer.status} with body ${JSON.stringify(answer.body)}`, + ).toBe(status); + // The pair ADR-0112 D5 declares — nested, because the flat position is the defect. + expect(answer.body?.error?.code).toBe(code); + expect(typeof answer.body?.error?.message).toBe('string'); + // Dialect 1 retired: `code` as a sibling of `error` (all nine arms had it). + expect(answer.body).not.toHaveProperty('code'); + // Dialect 2 retired: `error` as a bare string, which is what made + // `error.code` and `error.message` both read `undefined`. + expect(typeof answer.body?.error).toBe('object'); +} + +/** + * A body reduced to its STRUCTURE: every leaf key path with the type of its + * value, sorted. Values are dropped on purpose — arms legitimately differ in + * code and message, and the claim under test is that they agree in shape. + */ +function shapeOf(body: unknown): string { + const walk = (node: unknown, prefix: string): string[] => { + if (node === null || typeof node !== 'object' || Array.isArray(node)) { + return [`${prefix}:${Array.isArray(node) ? 'array' : node === null ? 'null' : typeof node}`]; + } + return Object.entries(node as Record) + .flatMap(([k, v]) => walk(v, prefix ? `${prefix}.${k}` : k)); + }; + return walk(body, '').sort().join('|'); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Each arm, on its own terms — status AND nested code, per ADR-0112 +// ───────────────────────────────────────────────────────────────────────────── + +describe('[#8111] the record-sharing family answers the ADR-0112 D5 envelope', () => { + it('501 NOT_IMPLEMENTED — no sharing service provider is wired', async () => { + const api = boot(undefined); + for (const answer of [await api.list(), await api.grant(), await api.revoke()]) { + expectNestedEnvelope(answer, 501, 'NOT_IMPLEMENTED'); + } + }); + + it('400 VALIDATION_FAILED — the service refuses the grant input', async () => { + const api = boot(throwingService(prefixed('VALIDATION_FAILED', 'recipientId is required'))); + const answer = await api.grant(); + expectNestedEnvelope(answer, 400, 'VALIDATION_FAILED'); + expect(answer.body.error.message).toBe('recipientId is required'); + }); + + it('403 PERMISSION_DENIED — the caller does not manage the record (ADR-0111 D1)', async () => { + const api = boot(throwingService( + prefixed('PERMISSION_DENIED', 'you do not hold canManageShares on this record'), + )); + expectNestedEnvelope(await api.list(), 403, 'PERMISSION_DENIED'); + }); + + it('404 NOT_FOUND — the record is missing or invisible (indistinguishable by design)', async () => { + const api = boot(throwingService(prefixed('NOT_FOUND', 'record account/a1 does not exist'))); + expectNestedEnvelope(await api.list(), 404, 'NOT_FOUND'); + }); + + it('409 CONFLICT — revoke on a rule-materialised share (ADR-0111 D4)', async () => { + const api = boot(throwingService( + prefixed('CONFLICT', "share shr_X is materialised by source 'rule' and would be re-granted"), + )); + const answer = await api.revoke(); + expectNestedEnvelope(answer, 409, 'CONFLICT'); + expect(answer.body.error.message).toBe( + "share shr_X is materialised by source 'rule' and would be re-granted", + ); + }); + + it('422 SHARING_NOT_ENABLED — grant on an object the gates never consult', async () => { + const api = boot(throwingService( + prefixed('SHARING_NOT_ENABLED', "'account' bypasses record sharing"), + )); + expectNestedEnvelope(await api.grant(), 422, 'SHARING_NOT_ENABLED'); + }); + + it('500 — an unexpected fault on each verb keeps its own code', async () => { + const api = boot(throwingService(new Error('boom'))); + const cases: Array<[Answer, string]> = [ + [await api.list(), 'SHARES_LIST_FAILED'], + [await api.grant(), 'SHARE_GRANT_FAILED'], + [await api.revoke(), 'SHARE_REVOKE_FAILED'], + ]; + for (const [answer, code] of cases) { + expectNestedEnvelope(answer, 500, code); + // The bare-string dialect put this text at `body.error`; it is the + // declared `message` now, and the 500-char cap is kept. + expect(answer.body.error.message).toBe('boom'); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 2. The cross-arm claim, derived rather than restated +// ───────────────────────────────────────────────────────────────────────────── + +describe('[#8111] the whole sharing family reduces to ONE skeleton', () => { + it('every refusal arm of all three routes has the same structural shape', async () => { + const arms: Array<[string, Answer]> = [ + ['501 list', await boot(undefined).list()], + ['501 revoke', await boot(undefined).revoke()], + ['400 grant', await boot(throwingService(prefixed('VALIDATION_FAILED', 'x'))).grant()], + ['403 list', await boot(throwingService(prefixed('PERMISSION_DENIED', 'x'))).list()], + ['404 list', await boot(throwingService(prefixed('NOT_FOUND', 'x'))).list()], + ['409 revoke', await boot(throwingService(prefixed('CONFLICT', 'x'))).revoke()], + ['422 grant', await boot(throwingService(prefixed('SHARING_NOT_ENABLED', 'x'))).grant()], + ['500 list', await boot(throwingService(new Error('x'))).list()], + ['500 grant', await boot(throwingService(new Error('x'))).grant()], + ['500 revoke', await boot(throwingService(new Error('x'))).revoke()], + ]; + const [, reference] = arms[0]; + for (const [label, answer] of arms) { + expect(shapeOf(answer.body), `${label} drifted: ${JSON.stringify(answer.body)}`) + .toBe(shapeOf(reference.body)); + } + }); + + it('every arm parses as the DECLARED ApiErrorSchema, code vocabulary included', async () => { + const arms: Answer[] = [ + await boot(undefined).list(), + await boot(throwingService(prefixed('VALIDATION_FAILED', 'x'))).grant(), + await boot(throwingService(prefixed('PERMISSION_DENIED', 'x'))).list(), + await boot(throwingService(prefixed('NOT_FOUND', 'x'))).list(), + // The 409's `CONFLICT` was registered NOWHERE before this card, so + // this parse is what would have caught it: `ApiErrorSchema.code` is + // a closed enum and an unregistered code fails it. + await boot(throwingService(prefixed('CONFLICT', 'x'))).revoke(), + await boot(throwingService(prefixed('SHARING_NOT_ENABLED', 'x'))).grant(), + await boot(throwingService(new Error('x'))).grant(), + ]; + for (const answer of arms) { + const parsed = ApiErrorSchema.safeParse(answer.body.error); + expect( + parsed.success, + `body.error failed ApiErrorSchema: ${JSON.stringify(answer.body)} → ${ + parsed.success ? '' : JSON.stringify(parsed.error.issues)}`, + ).toBe(true); + } + }); + + it('stripsThePrefix — the `CODE:` derivation stays server-internal', async () => { + // The prefix is how the service signals the verdict to the route. It is + // stripped before answering and has never been on the wire, so nothing + // can read it. Pinned so a later reader neither "restores" it nor + // assumes the message is the raw throw. + const api = boot(throwingService(prefixed('NOT_FOUND', 'record account/a1 does not exist'))); + const answer = await api.list(); + expect(answer.body.error.message).toBe('record account/a1 does not exist'); + expect(answer.body.error.message.startsWith('NOT_FOUND')).toBe(false); + }); + + it('an unprefixed service error falls through to the 500 arm, not a verdict', async () => { + // `respondSharingError` returns false when no prefix matches — the + // fall-through that keeps an unexpected fault from being reported as a + // permission verdict. + const api = boot(throwingService(new Error('connection reset'))); + const answer = await api.list(); + expectNestedEnvelope(answer, 500, 'SHARES_LIST_FAILED'); + expect(answer.body.error.message).toBe('connection reset'); + }); + + it('the healthy paths are untouched — only refusals moved', async () => { + const api = boot({ + listShares: vi.fn().mockResolvedValue([{ id: 'shr_1', recipient_id: 'bob' }]), + grant: vi.fn(async (input: any) => ({ id: 'shr_2', ...input })), + revoke: vi.fn().mockResolvedValue(undefined), + }); + const listed = await api.list(); + expect(listed.body).toEqual({ data: [{ id: 'shr_1', recipient_id: 'bob' }] }); + const granted = await api.grant({ recipientId: 'bob', accessLevel: 'edit' }); + expect(granted.status).toBe(201); + expect(granted.body).toEqual(expect.objectContaining({ id: 'shr_2', recipientId: 'bob' })); + }); +}); diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index e5112adfe3..a28551e313 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -102,6 +102,16 @@ export const ERROR_CODE_LEDGER = { 'BATCH_UNRESOLVED_REF', 'BLANK_MATCH_KEY', 'CONCURRENT_UPDATE', + // [#8111] `respondSharingError`'s 409 arm — `revoke` on a rule-materialised + // share (`source != 'manual'`), thrown by plugin-sharing's `sharing-service` + // and documented at `content/docs/kernel/runtime-services/sharing-service.mdx`. + // REGISTERED, not renamed: this is the value the arm has always put on the + // wire, and #8111 converged its POSITION only. Consolidating it onto the + // standard catalog's `RESOURCE_CONFLICT` would change what clients read, so + // it is a deliberate wire change for the maintainer, filed separately — + // exactly the shape this block's existing generic synonyms (`NOT_FOUND`, + // `FORBIDDEN`, `INTERNAL`) already carry. + 'CONFLICT', 'CONFLICTING_MAPPING', 'DATASET_INVALID', 'DELEGABLE_SCOPE_FAILED', diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index 76dee8072f..6c59510da8 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -258,7 +258,22 @@ const MODULES = { // COMPUTED message (`msg.slice(0, 500)`), which that counter cannot see — // the six flat `{ code, message }` arms converted alongside them were never // counted by either dialect, having no `error` key at all. - siblingCode: 73, + // + // 73 → 69 (#8111): `registerSharingEndpoints` — the record-sharing family — + // followed. Its four `{ code, error }` sites (the shared `respondSharingError` + // literal feeding 400/403/404/409/422, plus the three verb-specific 500s + // `SHARES_LIST_FAILED` / `SHARE_GRANT_FAILED` / `SHARE_REVOKE_FAILED`) now + // emit through the shared `sendError` from `@objectstack/types`. + // Measured: merge-base (2473cd2d3) siblingCode=73, branch head=69; the four + // vanished sites are merge-base lines 9480, 9507, 9541, 9570, all inside + // that one function, and the head has NO site left in that range while all + // 69 survivors map 1:1 onto a head line by the edit's own line shift (0 + // before the emitter, +37 after it). `stringError` + // is unmoved at 44 by construction: all four arms carried a COMPUTED message + // (`msg.replace(…)`, `String(…).slice(0, 500)`), which that counter cannot + // see — and `respond501`, converted alongside them, was never counted by + // either dialect, having no `error` key at all. + siblingCode: 69, }, };