diff --git a/.changeset/tidy-moons-yawn.md b/.changeset/tidy-moons-yawn.md new file mode 100644 index 0000000000..24cd1e54f9 --- /dev/null +++ b/.changeset/tidy-moons-yawn.md @@ -0,0 +1,33 @@ +--- +'@objectstack/plugin-sharing': patch +'@objectstack/spec': patch +--- + +fix(sharing): enforce `publicSharing.eligibility` when a share link is created + +`publicSharing.eligibility` was declared with a TSDoc promising the predicate +would be evaluated against the candidate record at link creation and refuse with +422 — and nothing read it. `ShareLinkService.getPolicy()` built its policy from +the five sibling keys (`enabled`, `allowedAudiences`, `allowedPermissions`, +`maxExpiryDays`, `redactFields`), all of which were genuinely enforced, and +skipped this one, so `createLink` ran no predicate at all. + +The consequence was not confined to link creation: `resolveToken` has no auth +check and reads the record under a system context, so any staff user who could +read a record could publish it to the open internet past a policy the object +author wrote specifically to prevent that — with no error raised. + +`createLink` now evaluates the predicate against the candidate record before the +insert. An ineligible record is refused with `422 RECORD_NOT_ELIGIBLE`, and a +predicate that cannot be compiled or that faults on the record is refused with +`422 ELIGIBILITY_UNEVALUABLE` rather than issued past an unanswered policy — a +restrictive policy must never fail open. Objects that declare no `eligibility` +key are unaffected, down to the record read's field projection. + +The predicate is evaluated by `@objectstack/formula`'s record-level CEL engine — +the same evaluator the server-side validation and hook-condition gates use, and +the same canonical CEL front end sharing rules parse through. Sharing rules +additionally lower their condition to a pushdown filter because they select a +set of records inside a query; eligibility judges one record already in hand, so +record-level constructs such as `has(record.x)` — which the pushdown compiler +rejects — evaluate normally here. diff --git a/content/docs/references/api/analytics.mdx b/content/docs/references/api/analytics.mdx index 879f553c57..9180f81f1c 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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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 1c2eb781ff..2c2009660d 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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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 146ff68fb8..5a9735be46 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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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 62f2f0e713..7b24fda07b 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' \| … +262 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' \| … +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). | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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 591dd05f8c..35e9ccfd40 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' \| … +258 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' \| … +260 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 | @@ -129,6 +129,7 @@ const result = ApiErrorSchema.parse(data); * `DOMAIN_VERIFICATION_FAILED` * `DRIVER_UNAVAILABLE` * `DUPLICATE_REQUEST` +* `ELIGIBILITY_UNEVALUABLE` * `EMAIL_SEND_FAILED` * `EMAIL_SERVICE_REQUIRED` * `ENQUEUE_FAILED` @@ -240,6 +241,7 @@ const result = ApiErrorSchema.parse(data); * `READ_SCOPE_COMPILE_FAILED` * `RECORD_GONE` * `RECORD_LOCKED` +* `RECORD_NOT_ELIGIBLE` * `REPORTS_LIST_FAILED` * `REPORT_DELETE_FAILED` * `REPORT_GET_FAILED` @@ -312,7 +314,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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | @@ -351,7 +353,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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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 | @@ -393,7 +395,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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **id** | `string` | ✅ | ID of the deleted record | @@ -445,7 +447,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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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 | @@ -461,7 +463,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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | | +| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +264 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | | | **index** | `number` | optional | Index in original request | | **data** | `any` | optional | Result data (e.g. created record) | @@ -500,7 +502,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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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 aa06b53c5a..3e233ebee2 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -201,6 +201,7 @@ const result = ErrorCode.parse(data); * `DOMAIN_VERIFICATION_FAILED` * `DRIVER_UNAVAILABLE` * `DUPLICATE_REQUEST` +* `ELIGIBILITY_UNEVALUABLE` * `EMAIL_SEND_FAILED` * `EMAIL_SERVICE_REQUIRED` * `ENQUEUE_FAILED` @@ -312,6 +313,7 @@ const result = ErrorCode.parse(data); * `READ_SCOPE_COMPILE_FAILED` * `RECORD_GONE` * `RECORD_LOCKED` +* `RECORD_NOT_ELIGIBLE` * `REPORTS_LIST_FAILED` * `REPORT_DELETE_FAILED` * `REPORT_GET_FAILED` diff --git a/content/docs/references/api/export.mdx b/content/docs/references/api/export.mdx index f287cac90d..184c910581 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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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 d65d50934f..0d22a7363c 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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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 bc51928b9c..f286874750 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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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 0bc5fe9404..46ad75b26b 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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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 bfec403889..0b6b35d111 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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **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' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **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 | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ uploadId: string; fileId: string; filename: string; totalSize: integer; … }` | ✅ | | diff --git a/packages/plugins/plugin-sharing/package.json b/packages/plugins/plugin-sharing/package.json index 1bd2de3e9a..2f877f32d1 100644 --- a/packages/plugins/plugin-sharing/package.json +++ b/packages/plugins/plugin-sharing/package.json @@ -26,7 +26,9 @@ "@objectstack/types": "workspace:*" }, "devDependencies": { + "@objectstack/driver-sql": "workspace:*", "@types/node": "^26.1.2", + "better-sqlite3": "^13.0.2", "typescript": "^6.0.3", "vitest": "^4.1.10" }, diff --git a/packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts b/packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts new file mode 100644 index 0000000000..d751051107 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts @@ -0,0 +1,373 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7861] `publicSharing.eligibility` — declared, and now enforced. + * + * ## The defect this pins + * + * The key was declared in `object.zod.ts` with a TSDoc promising *"evaluated + * against the candidate record when a link is created … the create call fails + * with 422"*, and **nothing read it**. `getPolicy()` built its policy out of the + * five sibling keys and skipped this one, so `createLink` ran no predicate at + * all. Its five siblings were each genuinely enforced, which is exactly what + * made the hole survive review. + * + * ## Why the evidence here is end-to-end, and why it is about the INSERT + * + * The harm does not land at `createLink`. It lands at `resolveToken`: + * `GET /api/v1/share-links/:token/resolve` has no auth check and reads the + * record under `SYSTEM_CTX`, so a link that gets minted is served to a caller + * with **no principal at all**. A test proving "the service throws" would + * therefore prove the wrong thing — a token that exists is already the harm. + * So every rejection case below additionally asserts that `sys_share_link` took + * **no row**, and the headline case walks the whole path: mint → resolve, on + * both an eligible and an ineligible record. + * + * For the same reason the backend is real rather than a fake: a live + * `SqlDriver` on better-sqlite3 `:memory:`, DDL through the driver's own + * `initObjects` from the REAL `SysShareLink` object definition — the harness + * `plugin-auth`'s suite settled on (#5893/#5830/#5704). A hand-written double + * would decide for itself what `fields`-projection and insert mean, and the + * fix widens exactly that projection. + * + * ## The sibling-key regression surface + * + * The card's own table is the control group, so all five enforced siblings are + * pinned here beside the new gate — two of them (`allowedAudiences`, + * `maxExpiryDays`) had no pin in this package before. That set is what makes + * the ablation meaningful: with the new predicate removed, the eligibility + * cases must flip red and these five must stay green. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { SqlDriver } from '@objectstack/driver-sql'; +import type { DriverQuery } from '@objectstack/spec/contracts'; +// The producer's OWN dispatch predicate: a double that opens its write verbs +// with this cannot accept a call the real engine refuses +// (`check:engine-double-contract`). +import { assertEngineUpdateDispatch } from '@objectstack/objectql'; +import { ShareLinkService } from './share-link-service.js'; +import { SysShareLink } from './objects/sys-share-link.object.js'; + +/** + * The object the card measured against: a mix of public and internal records, + * which is the case an eligibility gate exists for. The predicate is the + * card's, verbatim. + */ +const ELIGIBILITY = "record.status == 'published' && record.audience == 'public'"; + +const ARTICLE = { + name: 'article', + label: 'Article', + fields: { + id: { type: 'text', name: 'id', label: 'Id', primary: true }, + title: { type: 'text', name: 'title', label: 'Title' }, + status: { type: 'text', name: 'status', label: 'Status' }, + audience: { type: 'text', name: 'audience', label: 'Audience' }, + owner_id: { type: 'text', name: 'owner_id', label: 'Owner' }, + }, + publicSharing: { + enabled: true, + allowedAudiences: ['public', 'link_only'], + allowedPermissions: ['view'], + maxExpiryDays: 30, + redactFields: ['owner_id'], + eligibility: ELIGIBILITY, + }, +} as any; + +const ROWS = [ + { id: 'a_ok', title: 'Published + public', status: 'published', audience: 'public', owner_id: 'u1' }, + { id: 'a_internal', title: 'Published + INTERNAL', status: 'published', audience: 'internal', owner_id: 'u1' }, + { id: 'a_draft', title: 'DRAFT + public', status: 'draft', audience: 'public', owner_id: 'u1' }, +]; + +const CALLER = { userId: 'u1' } as any; + +/** Live `:memory:` databases, closed after each test. */ +const openDrivers: SqlDriver[] = []; + +afterEach(async () => { + while (openDrivers.length) { + const driver = openDrivers.pop(); + try { await driver?.disconnect(); } catch { /* noop */ } + } +}); + +/** + * A real backend behind the real service. + * + * Only the verbs `ShareLinkService` actually calls are exposed; `getSchema` is + * the in-memory registry read the engine performs, so the policy the service + * reads is the object's declared one. + */ +async function boot(article: any = ARTICLE) { + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + openDrivers.push(driver); + await driver.initObjects([SysShareLink as any, article]); + for (const row of ROWS) await driver.create('article', row); + + const schemas: Record = { article, sys_share_link: SysShareLink }; + const engine = { + getSchema: (name: string) => schemas[name], + find: (object: string, query: any) => driver.find(object, query), + findOne: (object: string, query: any) => driver.findOne(object, query), + insert: (object: string, data: any) => driver.create(object, data), + // Opened with ObjectQL's OWN dispatch predicate rather than a hand-mirrored + // id check, so this facade cannot accept an update the real engine refuses + // (`check:engine-double-contract`). `by-id` is the only branch reachable + // here — `resolveToken`'s usage stamp is the sole update the service makes. + update: (object: string, data: any, options?: any) => { + const dispatch = assertEngineUpdateDispatch(data, options); + if (dispatch.kind !== 'by-id') throw new Error(`unexpected dispatch: ${dispatch.kind}`); + return driver.update(object, dispatch.id as string, data); + }, + }; + const service = new ShareLinkService({ engine: engine as any }); + return { driver, service }; +} + +/** Every row currently in the share-link table — the thing that must stay empty. */ +async function mintedLinks(driver: SqlDriver): Promise { + // The unfiltered read keeps its declared driver-side type rather than an + // `as any` erasure — `query-options/no-any-erasure` (#4674/#4918) counts + // test-side calls too. + const everyRow: DriverQuery = {}; + return driver.find('sys_share_link', everyRow); +} + +/** + * The rejection assertion, per the ADR-0112 envelope: `code` AND `status`. + * A bare `.toThrow()` would pass against a refusal for entirely the wrong + * reason — including the pre-fix 403/404 visibility refusal. + */ +async function expectRefusal( + run: () => Promise, + expected: { status: number; code: string }, +): Promise { + let caught: any; + try { + await run(); + } catch (err) { + caught = err; + } + expect(caught, 'expected a refusal, but the call resolved').toBeDefined(); + expect(caught.status).toBe(expected.status); + expect(caught.code).toBe(expected.code); + return caught; +} + +describe('[#7861] publicSharing.eligibility is enforced at createLink', () => { + it('THE REPRO — an eligible record mints and resolves; an ineligible one does neither', async () => { + const { driver, service } = await boot(); + + // The record the policy admits: minted, and served through the real + // anonymous resolve path. + const link = await service.createLink( + { object: 'article', recordId: 'a_ok', audience: 'public', permission: 'view' }, + CALLER, + ); + expect(link.token).toBeTruthy(); + const resolved = await service.resolveToken(link.token, {}); + expect(resolved).not.toBeNull(); + expect(resolved!.link.record_id).toBe('a_ok'); + + // The record the policy excludes. Pre-fix this returned OK and the token + // resolved anonymously — the card's measured `← should be 422`. + await expectRefusal( + () => service.createLink( + { object: 'article', recordId: 'a_internal', audience: 'public', permission: 'view' }, + CALLER, + ), + { status: 422, code: 'RECORD_NOT_ELIGIBLE' }, + ); + + // The harm is the ROW, so that is what is asserted: the only link in the + // table is the eligible one. Nothing exists for `resolveToken` to serve. + const rows = await mintedLinks(driver); + expect(rows.map((r) => r.record_id)).toEqual(['a_ok']); + }); + + it('a DRAFT record is refused with 422 and mints nothing', async () => { + const { driver, service } = await boot(); + await expectRefusal( + () => service.createLink( + { object: 'article', recordId: 'a_draft', audience: 'public', permission: 'view' }, + CALLER, + ), + { status: 422, code: 'RECORD_NOT_ELIGIBLE' }, + ); + expect(await mintedLinks(driver)).toHaveLength(0); + }); + + it('the refusal names the predicate the author wrote', async () => { + const { service } = await boot(); + const err = await expectRefusal( + () => service.createLink( + { object: 'article', recordId: 'a_internal', audience: 'public' }, + CALLER, + ), + { status: 422, code: 'RECORD_NOT_ELIGIBLE' }, + ); + expect(err.message).toContain(ELIGIBILITY); + }); + + it('a record-level construct the pushdown compiler rejects still evaluates', async () => { + // `has(record.x)` is REJECTED by `compileCelToFilter` (measured: + // `unsupported`) — the reason the filter compiler is the wrong instrument + // for a record-level verdict. Through the record-level evaluator it is an + // ordinary predicate, so this shape must mint rather than 422. + const { service } = await boot({ + ...ARTICLE, + publicSharing: { ...ARTICLE.publicSharing, eligibility: 'has(record.owner_id)' }, + }); + const link = await service.createLink( + { object: 'article', recordId: 'a_internal', audience: 'public', permission: 'view' }, + CALLER, + ); + expect(link.token).toBeTruthy(); + }); + + describe('an unanswered policy refuses — it never mints', () => { + it('a predicate that does not compile is 422, not a link', async () => { + const { driver, service } = await boot({ + ...ARTICLE, + publicSharing: { ...ARTICLE.publicSharing, eligibility: 'record.status ===== ' }, + }); + await expectRefusal( + () => service.createLink( + { object: 'article', recordId: 'a_ok', audience: 'public', permission: 'view' }, + CALLER, + ), + { status: 422, code: 'ELIGIBILITY_UNEVALUABLE' }, + ); + expect(await mintedLinks(driver)).toHaveLength(0); + }); + + it('a predicate naming an UNDECLARED key is 422, not a link', async () => { + const { driver, service } = await boot({ + ...ARTICLE, + publicSharing: { ...ARTICLE.publicSharing, eligibility: "record.nope == 'x'" }, + }); + await expectRefusal( + () => service.createLink( + { object: 'article', recordId: 'a_ok', audience: 'public', permission: 'view' }, + CALLER, + ), + { status: 422, code: 'ELIGIBILITY_UNEVALUABLE' }, + ); + expect(await mintedLinks(driver)).toHaveLength(0); + }); + + it('a predicate resolving to a non-boolean has not consented', async () => { + const { driver, service } = await boot({ + ...ARTICLE, + publicSharing: { ...ARTICLE.publicSharing, eligibility: 'record.title' }, + }); + await expectRefusal( + () => service.createLink( + { object: 'article', recordId: 'a_ok', audience: 'public', permission: 'view' }, + CALLER, + ), + { status: 422, code: 'RECORD_NOT_ELIGIBLE' }, + ); + expect(await mintedLinks(driver)).toHaveLength(0); + }); + + it('a DECLARED field the row left empty is judged, not faulted', async () => { + // Several drivers omit NULL columns. Binding declared-and-absent to + // `null` is what keeps such a row a `false` verdict (refused on the + // merits) rather than an `ELIGIBILITY_UNEVALUABLE` fault. + const { service } = await boot({ + ...ARTICLE, + publicSharing: { ...ARTICLE.publicSharing, eligibility: 'record.owner_id == null' }, + }); + await expectRefusal( + () => service.createLink( + { object: 'article', recordId: 'a_ok', audience: 'public', permission: 'view' }, + CALLER, + ), + { status: 422, code: 'RECORD_NOT_ELIGIBLE' }, + ); + }); + }); + + /** + * The card's sibling table, pinned. These are the ablation's control group: + * removing the eligibility predicate must leave every one of them green. + */ + describe('the five enforced sibling keys stay enforced', () => { + const noEligibility = () => ({ + ...ARTICLE, + publicSharing: { ...ARTICLE.publicSharing, eligibility: undefined }, + }); + + it('`enabled` — an object that did not opt in is refused', async () => { + const { service } = await boot({ ...noEligibility(), publicSharing: undefined }); + await expectRefusal( + () => service.createLink({ object: 'article', recordId: 'a_ok' }, CALLER), + { status: 422, code: 'SHARING_NOT_ENABLED' }, + ); + }); + + it('`allowedAudiences` — an audience outside the list is refused', async () => { + const { service } = await boot(noEligibility()); + await expectRefusal( + () => service.createLink( + { object: 'article', recordId: 'a_ok', audience: 'signed_in' }, + CALLER, + ), + { status: 422, code: 'AUDIENCE_NOT_ALLOWED' }, + ); + }); + + it('`allowedPermissions` — a grant outside the list is refused', async () => { + const { service } = await boot(noEligibility()); + await expectRefusal( + () => service.createLink( + { object: 'article', recordId: 'a_ok', audience: 'public', permission: 'edit' }, + CALLER, + ), + { status: 422, code: 'PERMISSION_NOT_ALLOWED' }, + ); + }); + + it('`maxExpiryDays` — an expiry beyond the cap is refused', async () => { + const { service } = await boot(noEligibility()); + await expectRefusal( + () => service.createLink( + { object: 'article', recordId: 'a_ok', audience: 'public', expiresAt: '90d' }, + CALLER, + ), + { status: 422, code: 'EXPIRY_TOO_LONG' }, + ); + }); + + it('`redactFields` — the object default reaches the resolved link', async () => { + const { service } = await boot(noEligibility()); + const link = await service.createLink( + { object: 'article', recordId: 'a_ok', audience: 'public' }, + CALLER, + ); + const resolved = await service.resolveToken(link.token, {}); + expect(resolved!.redactFields).toContain('owner_id'); + }); + + it('an object with no eligibility key behaves exactly as before', async () => { + // The other half of the same guarantee: the gate is inert when the key + // is absent, so every one of the three records still mints. + const { service } = await boot(noEligibility()); + for (const id of ['a_ok', 'a_internal', 'a_draft']) { + const link = await service.createLink( + { object: 'article', recordId: id, audience: 'public' }, + CALLER, + ); + expect(link.record_id).toBe(id); + } + }); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/share-link-service.ts b/packages/plugins/plugin-sharing/src/share-link-service.ts index 34a81a04ff..6c29f633b9 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.ts @@ -18,6 +18,11 @@ import type { * `systemPermissions` and `tabPermissions` are all read. */ import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { Expression } from '@objectstack/spec'; +// [#7861] The record-level CEL seam — the same `@objectstack/formula` engine the +// server-side validation and hook-condition gates evaluate predicates through. +// See `assertEligible` for why this and not `compileCelToFilter`. +import { ExpressionEngine } from '@objectstack/formula'; import type { SharingEngine } from './sharing-service.js'; import { deleteRowsForDeletedRecords, @@ -78,6 +83,7 @@ function getPolicy(schema: any): { allowedPermissions: ShareLinkPermission[]; maxExpiryDays?: number; redactFields: string[]; + eligibility?: string; } { const raw = schema?.publicSharing; if (!raw || raw.enabled !== true) { @@ -94,9 +100,35 @@ function getPolicy(schema: any): { allowedPermissions: (raw.allowedPermissions as ShareLinkPermission[] | undefined) ?? ['view'], maxExpiryDays: typeof raw.maxExpiryDays === 'number' ? raw.maxExpiryDays : undefined, redactFields: Array.isArray(raw.redactFields) ? (raw.redactFields as string[]) : [], + eligibility: + typeof raw.eligibility === 'string' && raw.eligibility.trim() ? raw.eligibility : undefined, }; } +/** + * [#7861] Bind the candidate record's DECLARED fields before evaluating, so a + * predicate over a field the driver simply did not return is not a fault. + * + * The same materialisation the two server-side CEL gates in `@objectstack/objectql` + * do (`materializeDeclaredFields`, behind the validation and hook-condition + * evaluators). It matters more here than there: several drivers omit NULL + * columns entirely, so `record.status == 'published'` on a row whose `status` + * is null would fault — and this gate FAILS CLOSED, so a fault is a refusal. + * Without this, an eligibility policy would refuse links for exactly the rows + * whose field is empty rather than judging them. Declared-and-absent therefore + * binds to `null`, which is what the row means; an UNdeclared key still faults, + * because that one really is an author typo. + */ +function bindDeclaredFields(record: Record, schema: any): Record { + const declared = schema?.fields; + if (!declared || typeof declared !== 'object') return record; + const bound: Record = { ...record }; + for (const name of Object.keys(declared)) { + if (!(name in bound)) bound[name] = null; + } + return bound; +} + /** Parse `expiresAt` as either an ISO string or a relative duration like "7d", "24h", "30m". */ function normaliseExpiresAt(input: string | null | undefined, maxDays: number): string | null { if (!input) return null; @@ -177,6 +209,101 @@ async function defaultVerifyPassword(password: string, hash: string): Promise, + schema: any, + object: string, +): void { + const expr: Expression = { dialect: 'cel', source }; + + const compiled = ExpressionEngine.compile(expr); + if (!compiled.ok) { + throw makeError( + 422, + 'ELIGIBILITY_UNEVALUABLE', + `Object '${object}' declares a publicSharing.eligibility predicate that does not compile ` + + `(${celFault(compiled.error)}) — no link can be issued until it is fixed: ${source}`, + ); + } + + const verdict = ExpressionEngine.evaluate(expr, { + record: bindDeclaredFields(record, schema), + }); + if (!verdict.ok) { + throw makeError( + 422, + 'ELIGIBILITY_UNEVALUABLE', + `Object '${object}' could not evaluate its publicSharing.eligibility predicate ` + + `(${celFault(verdict.error)}) — refusing the link rather than issuing one past an ` + + `unanswered policy: ${source}`, + ); + } + + // Strictly `true`. A predicate resolving to a truthy non-boolean (a string, a + // number) has not answered the question it was asked, so it is refused with + // the other codes' direction rather than coerced into consent. + if (verdict.value !== true) { + throw makeError( + 422, + 'RECORD_NOT_ELIGIBLE', + `Record is not eligible for link sharing under '${object}'s publicSharing.eligibility policy: ${source}`, + ); + } +} + +/** + * One line out of a CEL fault. The engine appends a source excerpt and a caret + * line, which is right for a log and wrong for an API message. + */ +function celFault(error: { kind: string; message: string }): string { + const first = String(error.message ?? '').split('\n')[0]!.trim(); + return `${error.kind}: ${first || 'unknown error'}`; +} + function makeError(status: number, code: string, message: string): Error { const err: any = new Error(message); err.status = status; @@ -305,9 +432,15 @@ export class ShareLinkService implements IShareLinkService { // `org_user_ids`, and `posture` travels with the context rather than being // re-derived here (ADR-0095 D2). Rebuilding a subset at this seam is what // made this check answer 403 for every `group`-posture caller. + // + // [#7861] When an eligibility predicate is declared, this same read is the + // one that fetches the record it judges — the projection widens instead of + // a second query being issued, and it widens ONLY then, so an object + // without the key keeps the exact `id`-only read it always had. + const eligibility = policy.enabled ? policy.eligibility : undefined; const exists = await this.engine.find(input.object, { where: { id: input.recordId }, - fields: ['id'], + ...(eligibility ? {} : { fields: ['id'] }), limit: 1, context: context.isSystem ? SYSTEM_CTX : context, } as any); @@ -318,6 +451,21 @@ export class ShareLinkService implements IShareLinkService { : makeError(403, 'FORBIDDEN', `Not permitted to share ${input.object}/${input.recordId}`); } + // [#7861] The declared eligibility gate. Placed AFTER the visibility read + // (it needs the record, and a caller who cannot see the row must not learn + // anything about its contents from the refusal) and BEFORE the insert, so + // an ineligible link is never MINTED — which is the only placement that + // helps, since `resolveToken` serves an existing row anonymously under + // `SYSTEM_CTX` with no auth check to fall back on. + if (eligibility) { + assertEligible( + eligibility, + (exists[0] ?? {}) as Record, + schema, + input.object, + ); + } + const maxDays = policy.maxExpiryDays ?? DEFAULT_MAX_EXPIRY_DAYS; const expires_at = normaliseExpiresAt(input.expiresAt, maxDays); diff --git a/packages/plugins/plugin-sharing/vitest.config.ts b/packages/plugins/plugin-sharing/vitest.config.ts new file mode 100644 index 0000000000..db150b06bb --- /dev/null +++ b/packages/plugins/plugin-sharing/vitest.config.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + test: { + environment: 'node', + }, + resolve: { + // `share-link-eligibility.test.ts` (#7861) drives the real `SqlDriver` on + // better-sqlite3 `:memory:` so the eligibility gate's acceptance evidence + // is end-to-end rather than a verdict about a hand-written double. That + // import is a VALUE import, and `@objectstack/driver-sql` resolves through + // its `exports` to `dist/` — so without this alias the suite would be + // judging driver-sql's BUILD ARTIFACT rather than the source in the + // checkout. + // + // The dangerous half is not a loud error. A dist merely BEHIND rather than + // missing a symbol lets these pins run GREEN against driver-sql's old + // behaviour, with nothing in the output saying so — which for a test whose + // whole job is to prove an ineligible share link is never MINTED would be + // a security pin quietly reporting on the wrong tree. + // + // Turbo already orders `test` after `^build`, so `turbo run test` was never + // the exposed path. The exposed ones are `pnpm test` inside this package, + // `vitest run `, an editor runner, or an agent in a tree built at an + // older commit — exactly how these pins get re-run while someone is + // changing the driver. + // + // Array form with an anchored pattern, deliberately: the OBJECT form + // matches by prefix, so a bare `@objectstack/driver-sql` key would also + // swallow any subpath and resolve it to `…/src/index.ts/` + // (ENOTDIR) — a config that looks right and fails at run time. Same shape + // as `service-storage`'s and `service-knowledge`'s. + alias: [ + { find: /^@objectstack\/driver-sql$/, replacement: path.resolve(__dirname, '../../drivers/driver-sql/src/index.ts') }, + ], + }, +}); diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 2a18fed02e..e5112adfe3 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -249,6 +249,7 @@ export const ERROR_CODE_LEDGER = { ], '@objectstack/plugin-sharing': [ 'AUDIENCE_NOT_ALLOWED', + 'ELIGIBILITY_UNEVALUABLE', // [#7861] publicSharing.eligibility would not compile / faulted on the record — refused, never issued past an unanswered policy 'EXPIRED_OR_REVOKED', 'EXPIRY_IN_PAST', 'EXPIRY_TOO_LONG', @@ -260,6 +261,7 @@ export const ERROR_CODE_LEDGER = { 'NOT_FOUND', 'PERMISSION_NOT_ALLOWED', // share level would grant a verb the sharer lacks 'RECORD_GONE', + 'RECORD_NOT_ELIGIBLE', // [#7861] publicSharing.eligibility returned false for this record 'SHARING_NOT_ENABLED', 'SIGN_IN_REQUIRED', 'UNSUPPORTED', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a66e8677ea..06c048eec0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1762,9 +1762,15 @@ importers: specifier: workspace:* version: link:../../types devDependencies: + '@objectstack/driver-sql': + specifier: workspace:* + version: link:../../drivers/driver-sql '@types/node': specifier: ^26.1.2 version: 26.1.2 + better-sqlite3: + specifier: ^13.0.2 + version: 13.0.2 typescript: specifier: ^6.0.3 version: 6.0.3