@colixsystems/widget-sdk 0.287.0 → 0.289.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -90,7 +90,15 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
90
90
 
91
91
  ## Status
92
92
 
93
- `v0.287.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
93
+ `v0.289.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
94
+
95
+ ### What's new in 0.289.0 (contract 1.237.0)
96
+
97
+ **`manifest.translations` may declare up to 500 keys (was 200).** A widget with many screens, or one that ships every label of a large form, no longer runs out of keys. Widening: every manifest that validated before still does.
98
+
99
+ ### What's new in 0.288.0 (contract 1.236.0)
100
+
101
+ **A payment can pay for an order, and only the payment marks it paid.** Until now a shop widget charged the buyer and then wrote "Paid" to the order itself, so a buyer who could edit their own order could mark it paid without paying. `usePayments().requestPayment` now takes an optional `order: { table_id, record_id }`: a row the buyer may edit, in a table whose payment-status column the workspace has set (a STRING or BOOL column, one per table). The server checks it before the charge starts and refuses a missing or unreachable order (`ORDER_NOT_FOUND`), a table with no payment-status column (`ORDER_NOT_PAYABLE`) and an order that is already paid (`ORDER_ALREADY_PAID`); all three are non-retryable. Only one checkout per order can be open at a time: a second one while the first is still open is refused with `ORDER_PAYMENT_IN_PROGRESS`, which is retryable and clears when that checkout ends. When the payment settles, the server writes the column (`"PAID"`, or `true` on a BOOL; `"REFUNDED"` / `false` after a full refund); a failed or cancelled payment leaves it alone. Nothing else can write that column: every client value for it is ignored on create and update, so drop any code that sets "paid" yourself. The payment returned by `requestPayment` and `getPayment` carries `order` too, so a buyer coming back from Checkout lands on the right order. A `datastoreTemplate` column may declare `paymentStatus: true` to ship the column with the widget. Additive: a charge without `order` behaves as before.
94
102
 
95
103
  ### What's new in 0.287.0 (contract 1.235.0)
96
104
 
package/dist/contract.cjs CHANGED
@@ -2376,9 +2376,9 @@ const HOOKS = [
2376
2376
  signature: "usePayments()",
2377
2377
  returnShape: {
2378
2378
  requestPayment:
2379
- "({ amount_cents, currency?, description, kind, return_path? }) => Promise<{ id, status }> // kind is REQUIRED: \"physical\" | \"service\" | \"digital\" — what is sold; a digital charge is refused in an iOS/Android build (DIGITAL_SALE_NEEDS_STORE_BILLING); host opens hosted Checkout; rejects with PaymentError { code, message, retryable } — render the message when retryable is false, never \"try again\"",
2379
+ "({ amount_cents, currency?, description, kind, return_path?, order? }) => Promise<{ id, status, order }> // kind is REQUIRED: \"physical\" | \"service\" | \"digital\" — what is sold; a digital charge is refused in an iOS/Android build (DIGITAL_SALE_NEEDS_STORE_BILLING); order: { table_id, record_id } — the order row this pays for, in a table with a payment-status column; the SERVER marks it paid when the payment settles, so never write \"paid\" yourself (refused: ORDER_NOT_FOUND, ORDER_NOT_PAYABLE, ORDER_ALREADY_PAID; ORDER_PAYMENT_IN_PROGRESS is retryable while another checkout for the order is open); host opens hosted Checkout; rejects with PaymentError { code, message, retryable } — render the message when retryable is false, never \"try again\"",
2380
2380
  getPayment:
2381
- "(paymentId) => Promise<{ id, status, amount_cents, currency, description }>",
2381
+ "(paymentId) => Promise<{ id, status, amount_cents, currency, description, order }> // order: { table_id, record_id } | null — read it after returning from Checkout",
2382
2382
  },
2383
2383
  requiredContextSlice: ["payments.requestPayment"],
2384
2384
  scopes: ["payments.charge:appUser"],
@@ -4170,13 +4170,13 @@ const MANIFEST_SCHEMA = {
4170
4170
  type: "object",
4171
4171
  required: false,
4172
4172
  description:
4173
- "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 100 tables, 200 columns per table, 100 sample rows per table and 20000 sample cells across the whole template (each row counts the keys it sets, and at least one, summed over every table). RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 32 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, `@uuid` on UUID (a new random id per row), or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` / `@uuid` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation). A table may declare `presence: true` so the people who share a row see which of each other are online (useOnlineMembers); it takes effect only while the workspace records user activity, and an upgrade that declares it turns it on for the existing table. A table whose rows people join and leave through per-record grants (conversations) may declare `deleteWhenLastMemberLeaves: true`: when a signed-in member giving up their own grant leaves a row with no grant and no table-wide grant lets anyone read it, the row and every row a CASCADE relation ties to it (including rows deleted earlier) are deleted for good. A row that never had a grant (a seeded sample row) is never deleted this way, and a revoke made in the Studio never deletes. An upgrade that declares it turns it on for the existing table. A ONE_TO_ONE / ONE_TO_MANY RELATION may declare `latestVia: \"<relation column on its target that points back>\"` (onDelete left at SET_NULL; that relation must have inheritAcl and be immutable): the server keeps it at the newest row of the target table that points back at this row. A column may declare `copyOf: { via, column }`: the server keeps it equal to `column` on the row the latest-row column `via` points at (same type, or text into text, cut to 140 characters in a STRING column; a copy of an encrypted column must be encrypted, and a copy cannot inheritAcl). Both are written only by the server, in the same write as the related row; a client value is ignored. A table may declare `constraints`, checked on every write for every client and refused as CONSTRAINT_VIOLATION: `unique` ({ columns, partitionColumns?, unlessRelated?: { relation, column } } — waived for rows whose related row has that yes/no column set), `no-overlap` ({ startColumn, endColumn, partitionColumns? }), `related-open` ({ relation, closedColumn } — no row is added, changed or removed while the related row's column is filled) and `related-index` ({ column, relation, listColumn } — the number must be a position in the related row's list). An upgrade adds a declared constraint to an existing table without judging the rows already in it.",
4173
+ "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 100 tables, 200 columns per table, 100 sample rows per table and 20000 sample cells across the whole template (each row counts the keys it sets, and at least one, summed over every table). RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 32 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, `@uuid` on UUID (a new random id per row), or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` / `@uuid` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation). `paymentStatus: true` makes a STRING or BOOL column the table's payment status (at most one per table; not required, encrypted, immutable or defaulting to paid): a settled `requestPayment({ order: { table_id, record_id } })` for one of its rows writes it (`PAID`, or `true` on a BOOL; `REFUNDED` / `false` after a full refund), and every client value for it is ignored, so a buyer cannot mark their own order paid. A table may declare `presence: true` so the people who share a row see which of each other are online (useOnlineMembers); it takes effect only while the workspace records user activity, and an upgrade that declares it turns it on for the existing table. A table whose rows people join and leave through per-record grants (conversations) may declare `deleteWhenLastMemberLeaves: true`: when a signed-in member giving up their own grant leaves a row with no grant and no table-wide grant lets anyone read it, the row and every row a CASCADE relation ties to it (including rows deleted earlier) are deleted for good. A row that never had a grant (a seeded sample row) is never deleted this way, and a revoke made in the Studio never deletes. An upgrade that declares it turns it on for the existing table. A ONE_TO_ONE / ONE_TO_MANY RELATION may declare `latestVia: \"<relation column on its target that points back>\"` (onDelete left at SET_NULL; that relation must have inheritAcl and be immutable): the server keeps it at the newest row of the target table that points back at this row. A column may declare `copyOf: { via, column }`: the server keeps it equal to `column` on the row the latest-row column `via` points at (same type, or text into text, cut to 140 characters in a STRING column; a copy of an encrypted column must be encrypted, and a copy cannot inheritAcl). Both are written only by the server, in the same write as the related row; a client value is ignored. A table may declare `constraints`, checked on every write for every client and refused as CONSTRAINT_VIOLATION: `unique` ({ columns, partitionColumns?, unlessRelated?: { relation, column } } — waived for rows whose related row has that yes/no column set), `no-overlap` ({ startColumn, endColumn, partitionColumns? }), `related-open` ({ relation, closedColumn } — no row is added, changed or removed while the related row's column is filled) and `related-index` ({ column, relation, listColumn } — the number must be a position in the related row's list). An upgrade adds a declared constraint to an existing table without judging the rows already in it.",
4174
4174
  },
4175
4175
  translations: {
4176
4176
  type: "object",
4177
4177
  required: false,
4178
4178
  description:
4179
- "Optional (REQ-L10N-WIDGET). Translation strings the widget ships. Shape { <key>: { en: string, <locale>?: string, ... } } — `en` is REQUIRED per key. At install the host merges these into the tenant's localization dictionary under a per-widget namespace (`widget.<manifest.id>.<key>`); `useI18n().t(\"<key>\")` resolves the namespaced key automatically, so authors never type the prefix. Non-destructive: an existing admin-edited value is never overwritten, and a language the tenant has not added is never created (values for absent locales are dropped; the widget's `en` seeds the tenant's base language so every key renders). Keys persist across uninstalls; admins prune them from the Translations admin (bulk delete by `widget.<id>.` prefix). Limits: ≤200 keys; each key matches /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/; each value ≤1 KB; the namespaced key must fit the dictionary key cap (128 chars).",
4179
+ "Optional (REQ-L10N-WIDGET). Translation strings the widget ships. Shape { <key>: { en: string, <locale>?: string, ... } } — `en` is REQUIRED per key. At install the host merges these into the tenant's localization dictionary under a per-widget namespace (`widget.<manifest.id>.<key>`); `useI18n().t(\"<key>\")` resolves the namespaced key automatically, so authors never type the prefix. Non-destructive: an existing admin-edited value is never overwritten, and a language the tenant has not added is never created (values for absent locales are dropped; the widget's `en` seeds the tenant's base language so every key renders). Keys persist across uninstalls; admins prune them from the Translations admin (bulk delete by `widget.<id>.` prefix). Limits: ≤500 keys; each key matches /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/; each value ≤1 KB; the namespaced key must fit the dictionary key cap (128 chars).",
4180
4180
  default: {},
4181
4181
  },
4182
4182
  };
@@ -4471,7 +4471,7 @@ const WIDGET_CONTEXT_SHAPE = {
4471
4471
  },
4472
4472
  payments: {
4473
4473
  description:
4474
- "Injected @colixsystems/payments-client instance (REQ-BILL-07-WIDGETPAY). { requestPayment(body) -> Promise<{ id, status }>, getPayment(id) -> Promise<payment>, listAccessPlans() -> Promise<{ data, meta }>, purchaseAccessPlan({ plan_id, return_path? }) -> Promise<{ plan_id, status }>, cancelAccessPlan(planId) -> Promise<{ plan_id, renews, access_ends_at }>, getAppSubscription() -> Promise<{ active, renews, access_ends_at }>, cancelAppSubscription() -> Promise<{ active, renews, access_ends_at }> }; wire is snake_case (amount_cents, return_path). Backs usePayments(), useAccessPlans() and useAppSubscription(); requires the payments.charge:appUser scope. The host opens hosted Checkout itself — same-tab on web, in-app browser on native (or auto-confirms under the mock provider); the charge settles to the workspace owner.",
4474
+ "Injected @colixsystems/payments-client instance (REQ-BILL-07-WIDGETPAY). { requestPayment(body) -> Promise<{ id, status }>, getPayment(id) -> Promise<payment>, listAccessPlans() -> Promise<{ data, meta }>, purchaseAccessPlan({ plan_id, return_path? }) -> Promise<{ plan_id, status }>, cancelAccessPlan(planId) -> Promise<{ plan_id, renews, access_ends_at }>, getAppSubscription() -> Promise<{ active, renews, access_ends_at }>, cancelAppSubscription() -> Promise<{ active, renews, access_ends_at }> }; wire is snake_case (amount_cents, return_path, order). Backs usePayments(), useAccessPlans() and useAppSubscription(); requires the payments.charge:appUser scope. The host opens hosted Checkout itself — same-tab on web, in-app browser on native (or auto-confirms under the mock provider); the charge settles to the workspace owner.",
4475
4475
  required: true,
4476
4476
  fields: {
4477
4477
  requestPayment: "function",
@@ -6745,7 +6745,13 @@ const CONTRACT = deepFreeze({
6745
6745
  // 1.235.0: additive (sc-15342) — `useAppSubscription()`, and the payments
6746
6746
  // slice gains `getAppSubscription` / `cancelAppSubscription`: a paid
6747
6747
  // app's subscriber cancels their own subscription.
6748
- version: "1.235.0",
6748
+ // 1.236.0: additive (sc-15358) — `requestPayment` takes an optional
6749
+ // `order: { table_id, record_id }` the server marks paid on settlement,
6750
+ // a template column may declare `paymentStatus: true`, and four new
6751
+ // PaymentError codes.
6752
+ // 1.237.0: widening — `manifest.translations` may declare up to 500 keys
6753
+ // (was 200).
6754
+ version: "1.237.0",
6749
6755
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6750
6756
  hooks: HOOKS,
6751
6757
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -2372,9 +2372,9 @@ const HOOKS = [
2372
2372
  signature: "usePayments()",
2373
2373
  returnShape: {
2374
2374
  requestPayment:
2375
- "({ amount_cents, currency?, description, kind, return_path? }) => Promise<{ id, status }> // kind is REQUIRED: \"physical\" | \"service\" | \"digital\" — what is sold; a digital charge is refused in an iOS/Android build (DIGITAL_SALE_NEEDS_STORE_BILLING); host opens hosted Checkout; rejects with PaymentError { code, message, retryable } — render the message when retryable is false, never \"try again\"",
2375
+ "({ amount_cents, currency?, description, kind, return_path?, order? }) => Promise<{ id, status, order }> // kind is REQUIRED: \"physical\" | \"service\" | \"digital\" — what is sold; a digital charge is refused in an iOS/Android build (DIGITAL_SALE_NEEDS_STORE_BILLING); order: { table_id, record_id } — the order row this pays for, in a table with a payment-status column; the SERVER marks it paid when the payment settles, so never write \"paid\" yourself (refused: ORDER_NOT_FOUND, ORDER_NOT_PAYABLE, ORDER_ALREADY_PAID; ORDER_PAYMENT_IN_PROGRESS is retryable while another checkout for the order is open); host opens hosted Checkout; rejects with PaymentError { code, message, retryable } — render the message when retryable is false, never \"try again\"",
2376
2376
  getPayment:
2377
- "(paymentId) => Promise<{ id, status, amount_cents, currency, description }>",
2377
+ "(paymentId) => Promise<{ id, status, amount_cents, currency, description, order }> // order: { table_id, record_id } | null — read it after returning from Checkout",
2378
2378
  },
2379
2379
  requiredContextSlice: ["payments.requestPayment"],
2380
2380
  scopes: ["payments.charge:appUser"],
@@ -4166,13 +4166,13 @@ const MANIFEST_SCHEMA = {
4166
4166
  type: "object",
4167
4167
  required: false,
4168
4168
  description:
4169
- "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 100 tables, 200 columns per table, 100 sample rows per table and 20000 sample cells across the whole template (each row counts the keys it sets, and at least one, summed over every table). RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 32 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, `@uuid` on UUID (a new random id per row), or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` / `@uuid` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation). A table may declare `presence: true` so the people who share a row see which of each other are online (useOnlineMembers); it takes effect only while the workspace records user activity, and an upgrade that declares it turns it on for the existing table. A table whose rows people join and leave through per-record grants (conversations) may declare `deleteWhenLastMemberLeaves: true`: when a signed-in member giving up their own grant leaves a row with no grant and no table-wide grant lets anyone read it, the row and every row a CASCADE relation ties to it (including rows deleted earlier) are deleted for good. A row that never had a grant (a seeded sample row) is never deleted this way, and a revoke made in the Studio never deletes. An upgrade that declares it turns it on for the existing table. A ONE_TO_ONE / ONE_TO_MANY RELATION may declare `latestVia: \"<relation column on its target that points back>\"` (onDelete left at SET_NULL; that relation must have inheritAcl and be immutable): the server keeps it at the newest row of the target table that points back at this row. A column may declare `copyOf: { via, column }`: the server keeps it equal to `column` on the row the latest-row column `via` points at (same type, or text into text, cut to 140 characters in a STRING column; a copy of an encrypted column must be encrypted, and a copy cannot inheritAcl). Both are written only by the server, in the same write as the related row; a client value is ignored. A table may declare `constraints`, checked on every write for every client and refused as CONSTRAINT_VIOLATION: `unique` ({ columns, partitionColumns?, unlessRelated?: { relation, column } } — waived for rows whose related row has that yes/no column set), `no-overlap` ({ startColumn, endColumn, partitionColumns? }), `related-open` ({ relation, closedColumn } — no row is added, changed or removed while the related row's column is filled) and `related-index` ({ column, relation, listColumn } — the number must be a position in the related row's list). An upgrade adds a declared constraint to an existing table without judging the rows already in it.",
4169
+ "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 100 tables, 200 columns per table, 100 sample rows per table and 20000 sample cells across the whole template (each row counts the keys it sets, and at least one, summed over every table). RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 32 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, `@uuid` on UUID (a new random id per row), or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` / `@uuid` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation). `paymentStatus: true` makes a STRING or BOOL column the table's payment status (at most one per table; not required, encrypted, immutable or defaulting to paid): a settled `requestPayment({ order: { table_id, record_id } })` for one of its rows writes it (`PAID`, or `true` on a BOOL; `REFUNDED` / `false` after a full refund), and every client value for it is ignored, so a buyer cannot mark their own order paid. A table may declare `presence: true` so the people who share a row see which of each other are online (useOnlineMembers); it takes effect only while the workspace records user activity, and an upgrade that declares it turns it on for the existing table. A table whose rows people join and leave through per-record grants (conversations) may declare `deleteWhenLastMemberLeaves: true`: when a signed-in member giving up their own grant leaves a row with no grant and no table-wide grant lets anyone read it, the row and every row a CASCADE relation ties to it (including rows deleted earlier) are deleted for good. A row that never had a grant (a seeded sample row) is never deleted this way, and a revoke made in the Studio never deletes. An upgrade that declares it turns it on for the existing table. A ONE_TO_ONE / ONE_TO_MANY RELATION may declare `latestVia: \"<relation column on its target that points back>\"` (onDelete left at SET_NULL; that relation must have inheritAcl and be immutable): the server keeps it at the newest row of the target table that points back at this row. A column may declare `copyOf: { via, column }`: the server keeps it equal to `column` on the row the latest-row column `via` points at (same type, or text into text, cut to 140 characters in a STRING column; a copy of an encrypted column must be encrypted, and a copy cannot inheritAcl). Both are written only by the server, in the same write as the related row; a client value is ignored. A table may declare `constraints`, checked on every write for every client and refused as CONSTRAINT_VIOLATION: `unique` ({ columns, partitionColumns?, unlessRelated?: { relation, column } } — waived for rows whose related row has that yes/no column set), `no-overlap` ({ startColumn, endColumn, partitionColumns? }), `related-open` ({ relation, closedColumn } — no row is added, changed or removed while the related row's column is filled) and `related-index` ({ column, relation, listColumn } — the number must be a position in the related row's list). An upgrade adds a declared constraint to an existing table without judging the rows already in it.",
4170
4170
  },
4171
4171
  translations: {
4172
4172
  type: "object",
4173
4173
  required: false,
4174
4174
  description:
4175
- "Optional (REQ-L10N-WIDGET). Translation strings the widget ships. Shape { <key>: { en: string, <locale>?: string, ... } } — `en` is REQUIRED per key. At install the host merges these into the tenant's localization dictionary under a per-widget namespace (`widget.<manifest.id>.<key>`); `useI18n().t(\"<key>\")` resolves the namespaced key automatically, so authors never type the prefix. Non-destructive: an existing admin-edited value is never overwritten, and a language the tenant has not added is never created (values for absent locales are dropped; the widget's `en` seeds the tenant's base language so every key renders). Keys persist across uninstalls; admins prune them from the Translations admin (bulk delete by `widget.<id>.` prefix). Limits: ≤200 keys; each key matches /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/; each value ≤1 KB; the namespaced key must fit the dictionary key cap (128 chars).",
4175
+ "Optional (REQ-L10N-WIDGET). Translation strings the widget ships. Shape { <key>: { en: string, <locale>?: string, ... } } — `en` is REQUIRED per key. At install the host merges these into the tenant's localization dictionary under a per-widget namespace (`widget.<manifest.id>.<key>`); `useI18n().t(\"<key>\")` resolves the namespaced key automatically, so authors never type the prefix. Non-destructive: an existing admin-edited value is never overwritten, and a language the tenant has not added is never created (values for absent locales are dropped; the widget's `en` seeds the tenant's base language so every key renders). Keys persist across uninstalls; admins prune them from the Translations admin (bulk delete by `widget.<id>.` prefix). Limits: ≤500 keys; each key matches /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/; each value ≤1 KB; the namespaced key must fit the dictionary key cap (128 chars).",
4176
4176
  default: {},
4177
4177
  },
4178
4178
  };
@@ -4467,7 +4467,7 @@ const WIDGET_CONTEXT_SHAPE = {
4467
4467
  },
4468
4468
  payments: {
4469
4469
  description:
4470
- "Injected @colixsystems/payments-client instance (REQ-BILL-07-WIDGETPAY). { requestPayment(body) -> Promise<{ id, status }>, getPayment(id) -> Promise<payment>, listAccessPlans() -> Promise<{ data, meta }>, purchaseAccessPlan({ plan_id, return_path? }) -> Promise<{ plan_id, status }>, cancelAccessPlan(planId) -> Promise<{ plan_id, renews, access_ends_at }>, getAppSubscription() -> Promise<{ active, renews, access_ends_at }>, cancelAppSubscription() -> Promise<{ active, renews, access_ends_at }> }; wire is snake_case (amount_cents, return_path). Backs usePayments(), useAccessPlans() and useAppSubscription(); requires the payments.charge:appUser scope. The host opens hosted Checkout itself — same-tab on web, in-app browser on native (or auto-confirms under the mock provider); the charge settles to the workspace owner.",
4470
+ "Injected @colixsystems/payments-client instance (REQ-BILL-07-WIDGETPAY). { requestPayment(body) -> Promise<{ id, status }>, getPayment(id) -> Promise<payment>, listAccessPlans() -> Promise<{ data, meta }>, purchaseAccessPlan({ plan_id, return_path? }) -> Promise<{ plan_id, status }>, cancelAccessPlan(planId) -> Promise<{ plan_id, renews, access_ends_at }>, getAppSubscription() -> Promise<{ active, renews, access_ends_at }>, cancelAppSubscription() -> Promise<{ active, renews, access_ends_at }> }; wire is snake_case (amount_cents, return_path, order). Backs usePayments(), useAccessPlans() and useAppSubscription(); requires the payments.charge:appUser scope. The host opens hosted Checkout itself — same-tab on web, in-app browser on native (or auto-confirms under the mock provider); the charge settles to the workspace owner.",
4471
4471
  required: true,
4472
4472
  fields: {
4473
4473
  requestPayment: "function",
@@ -6741,7 +6741,13 @@ const CONTRACT = deepFreeze({
6741
6741
  // 1.235.0: additive (sc-15342) — `useAppSubscription()`, and the payments
6742
6742
  // slice gains `getAppSubscription` / `cancelAppSubscription`: a paid
6743
6743
  // app's subscriber cancels their own subscription.
6744
- version: "1.235.0",
6744
+ // 1.236.0: additive (sc-15358) — `requestPayment` takes an optional
6745
+ // `order: { table_id, record_id }` the server marks paid on settlement,
6746
+ // a template column may declare `paymentStatus: true`, and four new
6747
+ // PaymentError codes.
6748
+ // 1.237.0: widening — `manifest.translations` may declare up to 500 keys
6749
+ // (was 200).
6750
+ version: "1.237.0",
6745
6751
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6746
6752
  hooks: HOOKS,
6747
6753
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -6893,6 +6893,9 @@ const NON_RETRYABLE_PAYMENT_CODES = new Set([
6893
6893
  "FORBIDDEN",
6894
6894
  "INVALID_AMOUNT",
6895
6895
  "NOT_FOUND",
6896
+ "ORDER_ALREADY_PAID",
6897
+ "ORDER_NOT_FOUND",
6898
+ "ORDER_NOT_PAYABLE",
6896
6899
  "PAYMENTS_DISABLED",
6897
6900
  "PAYMENTS_SCOPE_NOT_GRANTED",
6898
6901
  "PAYMENTS_UNAVAILABLE",
@@ -6920,6 +6923,10 @@ const NON_RETRYABLE_PAYMENT_CODES = new Set([
6920
6923
  * - "CHARGE_KIND_NOT_ENABLED" — this workspace cannot sell that kind yet
6921
6924
  * - "DIGITAL_SALE_NEEDS_STORE_BILLING" — a digital sale from an iOS / Android
6922
6925
  * build: store policy requires store billing
6926
+ * - "ORDER_NOT_FOUND" / "ORDER_NOT_PAYABLE" / "ORDER_ALREADY_PAID" — the
6927
+ * `order` is not one this user can pay for
6928
+ * - "ORDER_PAYMENT_IN_PROGRESS" — another checkout for the order is open
6929
+ * (retryable: it clears when that one ends)
6923
6930
  * - "CONNECT_NOT_READY" / "PAYMENTS_DISABLED" — provider not ready
6924
6931
  * - "DECLINED" — the charge was declined
6925
6932
  * - "INTERNAL" — anything else
package/dist/index.d.ts CHANGED
@@ -474,7 +474,7 @@ export interface WidgetManifest {
474
474
  * under a per-widget namespace (`widget.<id>.<key>`), so `useI18n().t(key)`
475
475
  * resolves the namespaced key automatically — the author never types the
476
476
  * prefix. Non-destructive (admin edits win; absent languages are not
477
- * created) and persists across uninstalls. Caps: ≤200 keys, key matches
477
+ * created) and persists across uninstalls. Caps: ≤500 keys, key matches
478
478
  * /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/, value ≤1 KB.
479
479
  */
480
480
  translations?: Record<string, { en: string } & Record<string, string>>;
@@ -1538,6 +1538,20 @@ export interface PaymentRequest {
1538
1538
  * to the current page, so the user lands back where they started.
1539
1539
  */
1540
1540
  return_path?: string;
1541
+ /**
1542
+ * The order this charge pays for: a row the user may edit, in a table with
1543
+ * a payment-status column. When the payment settles the server sets that
1544
+ * column, so never write "paid" yourself; the platform ignores it anyway.
1545
+ * Refused with ORDER_NOT_FOUND, ORDER_NOT_PAYABLE or ORDER_ALREADY_PAID, and
1546
+ * with the retryable ORDER_PAYMENT_IN_PROGRESS while another checkout is open.
1547
+ */
1548
+ order?: PaymentOrder;
1549
+ }
1550
+
1551
+ /** A row of a table with a payment-status column, by the ids a widget holds. */
1552
+ export interface PaymentOrder {
1553
+ table_id: string;
1554
+ record_id: string;
1541
1555
  }
1542
1556
 
1543
1557
  export interface PaymentResult {
@@ -1547,6 +1561,8 @@ export interface PaymentResult {
1547
1561
  currency?: string;
1548
1562
  description?: string;
1549
1563
  kind?: "physical" | "service" | "digital" | null;
1564
+ /** The order the charge pays for, or null. Read it after returning from Checkout. */
1565
+ order?: PaymentOrder | null;
1550
1566
  }
1551
1567
 
1552
1568
  export interface PaymentsApi {
@@ -1658,6 +1674,10 @@ export class PaymentError extends Error {
1658
1674
  | "CHARGE_KIND_REQUIRED"
1659
1675
  | "CHARGE_KIND_NOT_ENABLED"
1660
1676
  | "DIGITAL_SALE_NEEDS_STORE_BILLING"
1677
+ | "ORDER_NOT_FOUND"
1678
+ | "ORDER_NOT_PAYABLE"
1679
+ | "ORDER_ALREADY_PAID"
1680
+ | "ORDER_PAYMENT_IN_PROGRESS"
1661
1681
  | "PAYMENTS_DISABLED"
1662
1682
  | "DECLINED"
1663
1683
  | "FORBIDDEN"
package/dist/manifest.cjs CHANGED
@@ -47,8 +47,8 @@ const VALID_PLATFORMS = new Set(["web", "native"]);
47
47
  // backend/src/core/services/l10n.service.js). The locale pattern mirrors that
48
48
  // service's LANG_CODE_RE so a manifest can't declare a locale the dictionary
49
49
  // would reject.
50
- // One page of the dictionary's widget namespace (l10n MAX_KEYS_PER_PAGE).
51
- const TRANSLATIONS_MAX_KEYS = 200;
50
+ // Bounds one package's rows in a workspace's translation dictionary.
51
+ const TRANSLATIONS_MAX_KEYS = 500;
52
52
  const TRANSLATION_VALUE_MAX_BYTES = 1024;
53
53
  const TRANSLATION_KEY_RE = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
54
54
  const TRANSLATION_LOCALE_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$/;
package/dist/manifest.js CHANGED
@@ -42,8 +42,8 @@ const VALID_PLATFORMS = new Set(["web", "native"]);
42
42
  // backend/src/core/services/l10n.service.js). The locale pattern mirrors that
43
43
  // service's LANG_CODE_RE so a manifest can't declare a locale the dictionary
44
44
  // would reject.
45
- // One page of the dictionary's widget namespace (l10n MAX_KEYS_PER_PAGE).
46
- const TRANSLATIONS_MAX_KEYS = 200;
45
+ // Bounds one package's rows in a workspace's translation dictionary.
46
+ const TRANSLATIONS_MAX_KEYS = 500;
47
47
  const TRANSLATION_VALUE_MAX_BYTES = 1024;
48
48
  const TRANSLATION_KEY_RE = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
49
49
  const TRANSLATION_LOCALE_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$/;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.287.0",
3
+ "version": "0.289.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",
package/src/contract.js CHANGED
@@ -2372,9 +2372,9 @@ const HOOKS = [
2372
2372
  signature: "usePayments()",
2373
2373
  returnShape: {
2374
2374
  requestPayment:
2375
- "({ amount_cents, currency?, description, kind, return_path? }) => Promise<{ id, status }> // kind is REQUIRED: \"physical\" | \"service\" | \"digital\" — what is sold; a digital charge is refused in an iOS/Android build (DIGITAL_SALE_NEEDS_STORE_BILLING); host opens hosted Checkout; rejects with PaymentError { code, message, retryable } — render the message when retryable is false, never \"try again\"",
2375
+ "({ amount_cents, currency?, description, kind, return_path?, order? }) => Promise<{ id, status, order }> // kind is REQUIRED: \"physical\" | \"service\" | \"digital\" — what is sold; a digital charge is refused in an iOS/Android build (DIGITAL_SALE_NEEDS_STORE_BILLING); order: { table_id, record_id } — the order row this pays for, in a table with a payment-status column; the SERVER marks it paid when the payment settles, so never write \"paid\" yourself (refused: ORDER_NOT_FOUND, ORDER_NOT_PAYABLE, ORDER_ALREADY_PAID; ORDER_PAYMENT_IN_PROGRESS is retryable while another checkout for the order is open); host opens hosted Checkout; rejects with PaymentError { code, message, retryable } — render the message when retryable is false, never \"try again\"",
2376
2376
  getPayment:
2377
- "(paymentId) => Promise<{ id, status, amount_cents, currency, description }>",
2377
+ "(paymentId) => Promise<{ id, status, amount_cents, currency, description, order }> // order: { table_id, record_id } | null — read it after returning from Checkout",
2378
2378
  },
2379
2379
  requiredContextSlice: ["payments.requestPayment"],
2380
2380
  scopes: ["payments.charge:appUser"],
@@ -4166,13 +4166,13 @@ const MANIFEST_SCHEMA = {
4166
4166
  type: "object",
4167
4167
  required: false,
4168
4168
  description:
4169
- "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 100 tables, 200 columns per table, 100 sample rows per table and 20000 sample cells across the whole template (each row counts the keys it sets, and at least one, summed over every table). RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 32 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, `@uuid` on UUID (a new random id per row), or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` / `@uuid` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation). A table may declare `presence: true` so the people who share a row see which of each other are online (useOnlineMembers); it takes effect only while the workspace records user activity, and an upgrade that declares it turns it on for the existing table. A table whose rows people join and leave through per-record grants (conversations) may declare `deleteWhenLastMemberLeaves: true`: when a signed-in member giving up their own grant leaves a row with no grant and no table-wide grant lets anyone read it, the row and every row a CASCADE relation ties to it (including rows deleted earlier) are deleted for good. A row that never had a grant (a seeded sample row) is never deleted this way, and a revoke made in the Studio never deletes. An upgrade that declares it turns it on for the existing table. A ONE_TO_ONE / ONE_TO_MANY RELATION may declare `latestVia: \"<relation column on its target that points back>\"` (onDelete left at SET_NULL; that relation must have inheritAcl and be immutable): the server keeps it at the newest row of the target table that points back at this row. A column may declare `copyOf: { via, column }`: the server keeps it equal to `column` on the row the latest-row column `via` points at (same type, or text into text, cut to 140 characters in a STRING column; a copy of an encrypted column must be encrypted, and a copy cannot inheritAcl). Both are written only by the server, in the same write as the related row; a client value is ignored. A table may declare `constraints`, checked on every write for every client and refused as CONSTRAINT_VIOLATION: `unique` ({ columns, partitionColumns?, unlessRelated?: { relation, column } } — waived for rows whose related row has that yes/no column set), `no-overlap` ({ startColumn, endColumn, partitionColumns? }), `related-open` ({ relation, closedColumn } — no row is added, changed or removed while the related row's column is filled) and `related-index` ({ column, relation, listColumn } — the number must be a position in the related row's list). An upgrade adds a declared constraint to an existing table without judging the rows already in it.",
4169
+ "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 100 tables, 200 columns per table, 100 sample rows per table and 20000 sample cells across the whole template (each row counts the keys it sets, and at least one, summed over every table). RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 32 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, `@uuid` on UUID (a new random id per row), or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` / `@uuid` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation). `paymentStatus: true` makes a STRING or BOOL column the table's payment status (at most one per table; not required, encrypted, immutable or defaulting to paid): a settled `requestPayment({ order: { table_id, record_id } })` for one of its rows writes it (`PAID`, or `true` on a BOOL; `REFUNDED` / `false` after a full refund), and every client value for it is ignored, so a buyer cannot mark their own order paid. A table may declare `presence: true` so the people who share a row see which of each other are online (useOnlineMembers); it takes effect only while the workspace records user activity, and an upgrade that declares it turns it on for the existing table. A table whose rows people join and leave through per-record grants (conversations) may declare `deleteWhenLastMemberLeaves: true`: when a signed-in member giving up their own grant leaves a row with no grant and no table-wide grant lets anyone read it, the row and every row a CASCADE relation ties to it (including rows deleted earlier) are deleted for good. A row that never had a grant (a seeded sample row) is never deleted this way, and a revoke made in the Studio never deletes. An upgrade that declares it turns it on for the existing table. A ONE_TO_ONE / ONE_TO_MANY RELATION may declare `latestVia: \"<relation column on its target that points back>\"` (onDelete left at SET_NULL; that relation must have inheritAcl and be immutable): the server keeps it at the newest row of the target table that points back at this row. A column may declare `copyOf: { via, column }`: the server keeps it equal to `column` on the row the latest-row column `via` points at (same type, or text into text, cut to 140 characters in a STRING column; a copy of an encrypted column must be encrypted, and a copy cannot inheritAcl). Both are written only by the server, in the same write as the related row; a client value is ignored. A table may declare `constraints`, checked on every write for every client and refused as CONSTRAINT_VIOLATION: `unique` ({ columns, partitionColumns?, unlessRelated?: { relation, column } } — waived for rows whose related row has that yes/no column set), `no-overlap` ({ startColumn, endColumn, partitionColumns? }), `related-open` ({ relation, closedColumn } — no row is added, changed or removed while the related row's column is filled) and `related-index` ({ column, relation, listColumn } — the number must be a position in the related row's list). An upgrade adds a declared constraint to an existing table without judging the rows already in it.",
4170
4170
  },
4171
4171
  translations: {
4172
4172
  type: "object",
4173
4173
  required: false,
4174
4174
  description:
4175
- "Optional (REQ-L10N-WIDGET). Translation strings the widget ships. Shape { <key>: { en: string, <locale>?: string, ... } } — `en` is REQUIRED per key. At install the host merges these into the tenant's localization dictionary under a per-widget namespace (`widget.<manifest.id>.<key>`); `useI18n().t(\"<key>\")` resolves the namespaced key automatically, so authors never type the prefix. Non-destructive: an existing admin-edited value is never overwritten, and a language the tenant has not added is never created (values for absent locales are dropped; the widget's `en` seeds the tenant's base language so every key renders). Keys persist across uninstalls; admins prune them from the Translations admin (bulk delete by `widget.<id>.` prefix). Limits: ≤200 keys; each key matches /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/; each value ≤1 KB; the namespaced key must fit the dictionary key cap (128 chars).",
4175
+ "Optional (REQ-L10N-WIDGET). Translation strings the widget ships. Shape { <key>: { en: string, <locale>?: string, ... } } — `en` is REQUIRED per key. At install the host merges these into the tenant's localization dictionary under a per-widget namespace (`widget.<manifest.id>.<key>`); `useI18n().t(\"<key>\")` resolves the namespaced key automatically, so authors never type the prefix. Non-destructive: an existing admin-edited value is never overwritten, and a language the tenant has not added is never created (values for absent locales are dropped; the widget's `en` seeds the tenant's base language so every key renders). Keys persist across uninstalls; admins prune them from the Translations admin (bulk delete by `widget.<id>.` prefix). Limits: ≤500 keys; each key matches /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/; each value ≤1 KB; the namespaced key must fit the dictionary key cap (128 chars).",
4176
4176
  default: {},
4177
4177
  },
4178
4178
  };
@@ -4467,7 +4467,7 @@ const WIDGET_CONTEXT_SHAPE = {
4467
4467
  },
4468
4468
  payments: {
4469
4469
  description:
4470
- "Injected @colixsystems/payments-client instance (REQ-BILL-07-WIDGETPAY). { requestPayment(body) -> Promise<{ id, status }>, getPayment(id) -> Promise<payment>, listAccessPlans() -> Promise<{ data, meta }>, purchaseAccessPlan({ plan_id, return_path? }) -> Promise<{ plan_id, status }>, cancelAccessPlan(planId) -> Promise<{ plan_id, renews, access_ends_at }>, getAppSubscription() -> Promise<{ active, renews, access_ends_at }>, cancelAppSubscription() -> Promise<{ active, renews, access_ends_at }> }; wire is snake_case (amount_cents, return_path). Backs usePayments(), useAccessPlans() and useAppSubscription(); requires the payments.charge:appUser scope. The host opens hosted Checkout itself — same-tab on web, in-app browser on native (or auto-confirms under the mock provider); the charge settles to the workspace owner.",
4470
+ "Injected @colixsystems/payments-client instance (REQ-BILL-07-WIDGETPAY). { requestPayment(body) -> Promise<{ id, status }>, getPayment(id) -> Promise<payment>, listAccessPlans() -> Promise<{ data, meta }>, purchaseAccessPlan({ plan_id, return_path? }) -> Promise<{ plan_id, status }>, cancelAccessPlan(planId) -> Promise<{ plan_id, renews, access_ends_at }>, getAppSubscription() -> Promise<{ active, renews, access_ends_at }>, cancelAppSubscription() -> Promise<{ active, renews, access_ends_at }> }; wire is snake_case (amount_cents, return_path, order). Backs usePayments(), useAccessPlans() and useAppSubscription(); requires the payments.charge:appUser scope. The host opens hosted Checkout itself — same-tab on web, in-app browser on native (or auto-confirms under the mock provider); the charge settles to the workspace owner.",
4471
4471
  required: true,
4472
4472
  fields: {
4473
4473
  requestPayment: "function",
@@ -6741,7 +6741,13 @@ const CONTRACT = deepFreeze({
6741
6741
  // 1.235.0: additive (sc-15342) — `useAppSubscription()`, and the payments
6742
6742
  // slice gains `getAppSubscription` / `cancelAppSubscription`: a paid
6743
6743
  // app's subscriber cancels their own subscription.
6744
- version: "1.235.0",
6744
+ // 1.236.0: additive (sc-15358) — `requestPayment` takes an optional
6745
+ // `order: { table_id, record_id }` the server marks paid on settlement,
6746
+ // a template column may declare `paymentStatus: true`, and four new
6747
+ // PaymentError codes.
6748
+ // 1.237.0: widening — `manifest.translations` may declare up to 500 keys
6749
+ // (was 200).
6750
+ version: "1.237.0",
6745
6751
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6746
6752
  hooks: HOOKS,
6747
6753
  primitives: PRIMITIVES,