@colixsystems/widget-sdk 0.17.0 → 0.19.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
@@ -1,23 +1,87 @@
1
1
  # @colixsystems/widget-sdk
2
2
 
3
- Common widget interface for [AppStudio](https://github.com/appstudio). This package implements the contract that every widget built-in or third-party, web or native speaks: a `WidgetManifest`, a `WidgetContext`, a property schema, the helper hooks, and the static linter that gates submissions.
3
+ Common widget interface for [AppStudio](https://github.com/appstudio). This package is **core only** — it implements the contract that every widget (built-in or third-party, web or native) speaks: a `WidgetManifest`, a `WidgetContext`, a property schema, the primitives + rendering surface, the helper hooks, events, theme/i18n, and the static linter that gates submissions. **It owns no HTTP and depends on none of the data SDK packages.**
4
+
5
+ The data layer lives in **four separate domain-client packages**, each instantiated by the host and **injected into `WidgetContext`**. Widgets never import those packages — they reach the data surface only through this SDK's hooks, which read the injected client instances:
6
+
7
+ | Injected at | Package | Surface (snake_case verbatim, `list` → `{ data, meta }`) |
8
+ | ----------- | ------- | -------- |
9
+ | `ctx.datastore` | `@colixsystems/datastore-client` | `tables.{list,get}`, `schema(tableId)`, `records(tableId).{ list(query), get(id), create(values), update(id,values) [PATCH], delete(id), aggregate(spec), permissions(recordId).{ list, grant, update, revoke } }` |
10
+ | `ctx.directory` | `@colixsystems/directory-client` | `me()`, `users.{list,get,invite,deactivate,reactivate}`, `groups.{list,create,remove,addMember,removeMember,listMine}`, `invites.{list,revoke,resend}` |
11
+ | `ctx.files` | `@colixsystems/files-client` | the Asset Manager: `get(id)`, `list(query)`, `upload(formData)` over `/files` — what `useFile()` resolves |
12
+ | `ctx.payments` | `@colixsystems/payments-client` | `requestPayment(body)`, `getPayment(id)` |
13
+
14
+ **Wire / casing: snake_case end to end.** The clients send and return snake_case **verbatim** (`created_at`, `group_ids`, `can_read`, `amount_cents`, `data_type`, `is_active`, …). There is **no client-side case transform anywhere** — the only transform left in the system is the backend's wire ↔ Prisma middleware. Author-defined record column values pass through verbatim. Every `list(...)` returns the `{ data, meta }` envelope; the read hooks unwrap `res.data` for you.
15
+
16
+ **Hooks read the injected clients** — they do not hold their own HTTP. This is the **complete** hook surface (18 hooks), grouped by the domain client each one reads. **CORE** hooks read host state directly off `WidgetContext` (no data client); the rest delegate to one of the four injected clients. The grouping mirrors the banner sections in [`src/hooks.js`](src/hooks.js).
17
+
18
+ | Group | Hook (signature) | Returns | Reads / scope |
19
+ | ----- | ---------------- | ------- | ------------- |
20
+ | **CORE** | `useTheme()` | `{ colors, spacing, radii, typography }` | `ctx.workspace.theme` — no scope |
21
+ | **CORE** | `useUser()` | `{ id, email, display_name, roles, group_ids }` | `ctx.user` (snake_case verbatim; `id` null when anonymous) — no scope |
22
+ | **CORE** | `useNavigation()` | `{ goTo, goBack, push, replace, back, currentRoute }` | `ctx.navigation` — no scope (external URLs use the `Linking` primitive) |
23
+ | **CORE** | `useWidgetEvent(name)` | `(payload?) => void` | `ctx.events.emit` — no scope |
24
+ | **CORE** | `useChildRenderer()` | `{ renderNode(node) }` | `ctx.renderer` — no scope (prefer the `WidgetTree` component) |
25
+ | **CORE** | `useClipboard()` | `{ copy, paste, hasContent }` | platform clipboard (web `navigator.clipboard` / native `expo-clipboard`); rejects with `ClipboardError` — no scope |
26
+ | **CORE** | `useToast()` | `{ showToast }` | `ctx.toast.showToast` (falls back to a CustomEvent / console) — no scope |
27
+ | **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope |
28
+ | **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options?)` | `{ data, loading, error, refetch }` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
29
+ | **DATASTORE** | `useDatastoreRecord(table, id)` | `{ data, loading, error, refetch }` | `records(table).get` — `datastore.read:<table>` |
30
+ | **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
31
+ | **DATASTORE** | `useDatastoreMutation(table)` | `{ create, update, delete }` | `records(table).{ create, update (PATCH), delete }` — `datastore.write:*` |
32
+ | **DATASTORE** | `useRecordPermissions(tableId, recordId)` | `{ permissions, loading, error, grant, revoke, update, refetch }` | `records(table).permissions(record).{ list, grant, update, revoke }` — `acl.write:records` (+ `can_grant` on the record) |
33
+ | **FILES** (`ctx.files`) | `useFile(id)` | `{ url, file, loading, error, refetch }` | `ctx.files.get` — no scope |
34
+ | **DIRECTORY** (`ctx.directory`) | `useDirectory(query?)` | `{ users, loading, error, refetch }` | `directory.users.list` — `directory.read:users` |
35
+ | **DIRECTORY** | `useUsers(query?)` | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` | `directory.users.*` — `users.read:*` (mutations also `users.write:*`) |
36
+ | **DIRECTORY** | `useGroups(query?)` | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
37
+ | **PAYMENTS** (`ctx.payments`) | `usePayments()` | `{ requestPayment, getPayment }` | `ctx.payments.*` — `payments.charge:appUser` |
38
+
39
+ All list calls return the `{ data, meta }` envelope; the read hooks unwrap `res.data` for you. There is no `useWorkspace()` or `useLogger()` hook — read the theme via `useTheme()` and the locale via `useI18n()`; the host logger lives on `ctx.logger` (`{ debug, info, warn, error }`).
40
+
41
+ `ctx.recordPermissions`, `ctx.users`, and `ctx.groups` **no longer exist** — they were folded into `ctx.datastore.records(t).permissions` and `ctx.directory.{users,groups}` respectively.
4
42
 
5
43
  See the design reference for the full architecture: [`docs/architecture/widget-marketplace.md`](../../docs/architecture/widget-marketplace.md), specifically section 3.1.
6
44
 
7
45
  ## Status
8
46
 
9
- `v0.17.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**.
47
+ `v0.19.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**.
10
48
 
11
- ### What's new in 0.17.0
49
+ ### What's new in 0.19.0
12
50
 
13
- REQ-WBLT-FORMBUILDER a runtime schema resolver so widgets can render by column type.
51
+ **The data layer splits into four injected domain clients; the SDK becomes core-only.**
14
52
 
15
- - **`useDatastoreSchema(tableId)` is wired** + the new `WidgetContext.datastore.schema` slice. Returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, dataType, required, relationType, targetTableId, isIdentification }] }` (`null` until loaded). It reads the **existing** ACL-gated `GET /api/v1/tables/:id` — structure only, never row data — so a public-grant table resolves for anonymous visitors exactly like a record read. Use it to resolve a stored `columnId` to its column name / dataType / relation target at runtime (the built-in Form Builder uses it to render an input per column type). Reads need the `datastore.read:<table>` scope. Backed by a new `datastore.schema(tableId)` host facade (web: `widgetHostDatastore`; native: `hostDatastore` in the export's `widgetHost.js`). Additive.
53
+ - **The SDK no longer owns any data facade.** The bespoke per-hook facades that used to live on `WidgetContext` (`ctx.datastore` as an opaque host object, `ctx.directory.listUsers`, `ctx.users`, `ctx.groups`, `ctx.recordPermissions`, `ctx.files.get`) are replaced by four host-instantiated, host-injected domain clients: `ctx.datastore` (`@colixsystems/datastore-client`), `ctx.directory` (`@colixsystems/directory-client`), `ctx.files` (`@colixsystems/files-client`, flattened), `ctx.payments` (`@colixsystems/payments-client`). The SDK imports none of them and ships no HTTP.
54
+ - **`ctx.recordPermissions`, `ctx.users`, `ctx.groups` are removed.** Per-record permission management moved under `ctx.datastore.records(tableId).permissions(recordId)`; user / group administration moved under `ctx.directory.users` / `ctx.directory.groups`. The hooks (`useRecordPermissions`, `useUsers`, `useGroups`) keep the same names and signatures — only the client slice they read changed.
55
+ - **snake_case end to end, no client-side transform.** Clients send and return snake_case verbatim (`group_ids`, `can_read`, `is_active`, `amount_cents`, `data_type`, `created_at`, …). The SDK passes bodies straight through and unwraps the `{ data, meta }` list envelope without renaming a single field. Hook return rows and `useUser()` fields are therefore snake_case (`display_name`, `group_ids`, `is_active`, `member_count`, and `useRecordPermissions` rows carry `user_id` / `group_id` / `can_read` / `can_write` / `can_delete` / `can_grant`).
56
+ - **Companion package versions:** `datastore-client 0.5.0`, `files-client 0.3.0`, `directory-client 0.1.0`, `payments-client 0.1.0`.
57
+ - **`CONTRACT.version` → `1.9.0`.** Breaking for `WidgetContext` consumers (removed slices, renamed wire fields); the hook export surface is unchanged.
58
+
59
+ ### What was in 0.18.0
60
+
61
+ Two additive features land in this version.
62
+
63
+ **A widget may declare server-side actions in its manifest.**
64
+
65
+ - **`WidgetManifest.actions` is now part of the public contract.** An optional array; each entry is `{ key, name, description?, triggerType, scheduleCron?, timeoutMs?, scriptSource }`. `triggerType` is one of `schedule`, `record_created`, `record_updated`, `record_deleted`; `scheduleCron` is required when `triggerType === "schedule"`. The `scriptSource` (≤ 200 KiB) runs in the **shared isolated-vm action runner** — against `datastore` / `fetch` / `console` / `record` / `tenantId` (the runner surface, **not** the React/SDK widget surface, so SDK imports and hooks are unavailable there and the component linter does not scan it). Actions never run in the rendered app, so they have **no effect on Player ↔ export parity**.
66
+ - **Operators enable actions per tenant** from the Properties Panel. An enabled action materialises a tenant `Action` row **DISABLED** until the operator binds an integration API key (and, for `record_*` triggers, a target table) in the Actions admin page — those bindings are tenant-local, so `triggerTableId` / `apiKeyId` must **not** appear in the manifest (the validator and linter reject them).
67
+ - **New contract fields** `CONTRACT.actionTriggerTypes`, `CONTRACT.actionScriptGlobals`, `CONTRACT.actionScriptMaxBytes` expose the grammar so the Developer page, the AI agent prompt, and `validateManifest` derive it from one source. `validateManifest` now structurally validates `actions`; the marketplace linter rejects malformed / oversized declarations.
68
+ - **New manifest category `ADMINISTRATION`** for app-administration widgets such as User Management. Added to `CONTRACT.manifestCategories`, `validateManifest`, the `WidgetCategory` type, the marketplace category list, and the master-DB `WidgetCategory` enum.
69
+
70
+ **Per-record permission management from inside a widget.**
71
+
72
+ - **`useRecordPermissions(tableId, recordId)` is wired** + the new `WidgetContext.recordPermissions` slice + the new `acl.write:records` scope. Returns `{ permissions, loading, error, grant, revoke, update, refetch }` where `permissions` is `Array<{ id, principalType: "USER" | "GROUP" | "PUBLIC", principalId, canRead, canWrite, canDelete, canGrant }>`. Rejections from `grant` / `revoke` / `update` surface as a structured `PermissionError` (named export) with `code` ∈ `FORBIDDEN | VALIDATION | NOT_FOUND | CONFLICT | INTERNAL`. When `tableId` or `recordId` is null/empty the hook collapses to a stable empty no-op result. Mutating requires `acl.write:records` AND `canGrant` on the target record — Studio owners pass automatically; an APP_USER holds `canGrant` as the record's creator or via a delegated grant. Backs the built-in Chat widget's "+ New channel" + DM-create flows. Additive.
73
+ - **`CONTRACT.version` → `1.8.0`** (additive: a new optional `actions` manifest field, three new contract fields, the `ADMINISTRATION` category, the `useRecordPermissions` hook, the `recordPermissions` context slice, the `acl.write:records` scope, and the `PermissionError` class). No existing export changed signature.
74
+
75
+ ### What was in 0.17.0
76
+
77
+ A runtime schema resolver so widgets can render by column type.
78
+
79
+ - **`useDatastoreSchema(tableId)` is wired** + the new `WidgetContext.datastore.schema` slice. Returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, dataType, required, relationType, targetTableId, isIdentification }] }` (`null` until loaded). Reads structure only, never row data, so a public-grant table resolves for anonymous visitors like a record read. Use it to resolve a stored `columnId` to its name / dataType / relation target at runtime. Reads need the `datastore.read:<table>` scope. Additive.
16
80
  - **`CONTRACT.version` → `1.7.0`** (additive: one new hook, one new `datastore.schema` context field). No existing export changed signature.
17
81
 
18
82
  ### What's new in 0.16.0
19
83
 
20
- REQ-THEME — the tenant's **Theme Settings** now flow all the way into `useTheme()`.
84
+ The tenant's **Theme Settings** now flow all the way into `useTheme()`.
21
85
 
22
86
  - **`themeTokens.colors` gains `secondary` + `onSecondary`.** `useTheme().colors.secondary` reflects the tenant's *Secondary Color* picker (with `onSecondary` as its readable contrast color), alongside the existing `primary` / `onPrimary`. Built-in widgets like Button use it for their secondary variant; third-party widgets can use it for a branded second accent. The full `colors` shape is now `{ primary, onPrimary, secondary, onSecondary, surface, onSurface, surfaceMuted, onSurfaceMuted, border, danger, success, warning, info }`.
23
87
  - **`colors.primary` / `colors.secondary` / `typography.fontFamily` are tenant-resolved.** The host maps the Studio Theme Settings blob (Primary Color, Secondary Color, Global Font) onto the default tokens before handing them to `useTheme()`, on both the live Player and the exported app — so a widget that reads tokens re-themes automatically. (Custom Google fonts render in the Player today; the exported app falls back to the system face for non-system fonts until font bundling lands.)
@@ -25,7 +89,7 @@ REQ-THEME — the tenant's **Theme Settings** now flow all the way into `useThem
25
89
 
26
90
  ### What's new in 0.15.0
27
91
 
28
- REQ-WSDK-PLATFORM — the "split-implementation + vetted package list" pivot. See [`docs/design/req-widget-sdk-cross-platform-primitives.md`](../../docs/design/req-widget-sdk-cross-platform-primitives.md) for the full design and rationale.
92
+ The "split-implementation + vetted package list" pivot.
29
93
 
30
94
  - **`CONTRACT.vettedImports` (new).** A curated allowlist of bare specifiers a widget may import — `react`, `@colixsystems/widget-sdk`, `react-native`, `axios`, `date-fns`, `react-native-svg`, `lucide-react-native`, `react-native-maps`, `leaflet`, `react-leaflet`, `expo-av`, `@react-native-community/datetimepicker`, `expo-clipboard`, `expo-haptics`. Each entry carries `platforms` (one or both of `"web"` / `"native"`) and a `category` so the linter and the marketplace listing can render honest platform badges. `CONTRACT.allowedBareImports` (the existing field) is now derived from `vettedImports` and stays a plain `string[]` for back-compat.
31
95
  - **`fetch` and `XMLHttpRequest` come off `CONTRACT.bannedApis`.** Widgets may call third-party APIs directly. Calls to the host's own `/api/*` surface will 401 because the JWT token is never shared with widget code; the linter emits a soft `no-host-api-url` warning when it sees host-URL substrings so authors learn the rule statically. Use SDK hooks (`useDatastoreQuery`, `useUsers`, `useFile`, …) for workspace data; use `axios` / `fetch` for third-party APIs.
@@ -41,12 +105,12 @@ REQ-WSDK-PLATFORM — the "split-implementation + vetted package list" pivot. Se
41
105
 
42
106
  ### What was in 0.14.1
43
107
 
44
- - **`groupRef` property type (REQ-USERMGMT M4 / §4.8).** Authors can now declare `{ type: 'groupRef', label: 'Group' }` in their `propertySchema` to render a Group picker in the Studio Properties Panel. The widget receives a bare `AppUserGroup` UUID — REQ-GEN-07 compliant, so tenant-copy walks the value transparently. Used by the built-in `appstudio.user-management` widget for its `defaultGroupId` prop and available to third-party widgets that need to anchor behaviour on a specific group.
108
+ - **`groupRef` property type.** Authors can declare `{ type: 'groupRef', label: 'Group' }` in their `propertySchema` to render a Group picker in the Studio Properties Panel. The widget receives a bare `AppUserGroup` UUID, so tenant-copy walks the value transparently. Used by the built-in `appstudio.user-management` widget for its `defaultGroupId` prop and available to third-party widgets that need to anchor behaviour on a specific group.
45
109
  - **Patch bump** — additive enumeration entry, no exported function signature changed. `CONTRACT.version` stays `1.4.0`.
46
110
 
47
111
  ### What's new in 0.14.0
48
112
 
49
- - **`useUsers()` + `useGroups()` — AppUser administration hooks (REQ-USERMGMT / REQ-ACL-SYS M3).** A widget can now invite, deactivate, reactivate, and remove members, and create / delete groups + add / remove members, from a published-app surface. Returns `{ users | groups, loading, error, refetch, ... }` plus imperative mutation methods. Reads gated by `users.read:*` / `groups.read:*`; mutations by `users.write:*` / `groups.write:*`. Rejections surface as a structured `DirectoryError` (new named export) with `code` ∈ `FORBIDDEN | VALIDATION | NOT_FOUND | INVITE_ONLY`. The host signs an `X-Widget-Scopes` header against `JWT_SECRET` so an APP_USER cannot forge a scope set, and the backend additionally gates the request behind a SystemAcl `users.*` / `groups.*` capability grant (REQ-ACL-SYS M1 + M3).
113
+ - **`useUsers()` + `useGroups()` — AppUser administration hooks.** A widget can invite, deactivate, reactivate, and remove members, and create / delete groups + add / remove members, from a published-app surface. Returns `{ users | groups, loading, error, refetch, ... }` plus imperative mutation methods. Reads gated by `users.read:*` / `groups.read:*`; mutations by `users.write:*` / `groups.write:*`. Rejections surface as a structured `DirectoryError` (new named export) with `code` ∈ `FORBIDDEN | VALIDATION | NOT_FOUND | INVITE_ONLY`. The host signs an `X-Widget-Scopes` header against `JWT_SECRET` so an APP_USER cannot forge a scope set, and the backend additionally gates the request behind a SystemAcl `users.*` / `groups.*` capability grant.
50
114
  - **Managing app users from a widget — see the section below.**
51
115
  - **New linter rule `no-scope-mismatch-useUsers` / `no-scope-mismatch-useGroups`.** Calling `useUsers().invite()` / `.deactivate()` / `.reactivate()` / `.remove()` without `users.write:*` in the manifest's `requestedScopes` fails the lint; calling `useGroups()` mutation methods without `groups.write:*` fails the lint. Keeps the manifest honest at submission time.
52
116
  - **`CONTRACT.version` → `1.4.0`** (additive: two new hooks, two new context slices, six new scope verbs, one new error class). No existing export changed signature.
@@ -71,12 +135,12 @@ REQ-WSDK-PLATFORM — the "split-implementation + vetted package list" pivot. Se
71
135
 
72
136
  ### What's new in 0.9.0
73
137
 
74
- - **`usePayments()` — incoming app-user payments (REQ-BILL-07-WIDGETPAY).** Returns `{ requestPayment, getPayment }`. `requestPayment({ amountCents, currency?, description, metadata?, returnPath? })` triggers a one-time charge from the signed-in app user and resolves to `{ id, status, checkoutUrl? }`: when `checkoutUrl` is present the widget opens it (web: navigate; native: `expo-web-browser`) — the user pays in **hosted Stripe Checkout**; when absent (the platform's built-in **mock** provider, the default until Stripe is configured) the charge auto-confirms (`status: "PAID"`). `getPayment(id)` polls the terminal status. Backed by a new `WidgetContext.payments` slice and gated by the new `payments.charge:appUser` scope. **No card data ever touches the widget** — never collect card fields yourself. The charge settles to the workspace owner; the amount is bounded by a platform per-charge cap. Rejections are a structured `PaymentError` (also a new named export) with a stable `.code`.
138
+ - **`usePayments()` — incoming app-user payments.** Returns `{ requestPayment, getPayment }`. `requestPayment({ amountCents, currency?, description, metadata?, returnPath? })` triggers a one-time charge from the signed-in app user and resolves to `{ id, status, checkoutUrl? }`: when `checkoutUrl` is present the widget opens it (web: navigate; native: `expo-web-browser`) — the user pays in **hosted Stripe Checkout**; when absent (the platform's built-in **mock** provider, the default until Stripe is configured) the charge auto-confirms (`status: "PAID"`). `getPayment(id)` polls the terminal status. Backed by a new `WidgetContext.payments` slice and gated by the new `payments.charge:appUser` scope. **No card data ever touches the widget** — never collect card fields yourself. The charge settles to the workspace owner; the amount is bounded by a platform per-charge cap. Rejections are a structured `PaymentError` (also a new named export) with a stable `.code`.
75
139
  - **`CONTRACT.version` → `1.2.0`** (additive: one new hook, one new context slice, one new scope, one new error class). No existing export changed signature.
76
140
 
77
141
  ### What was in 0.8.0
78
142
 
79
- - **`useDirectory()` — read-only user directory hook (REQ-DIR-01).** Returns `{ users, loading, error, refetch }` where each user is `{ id, name, role }`. Backed by a new `WidgetContext.directory.listUsers(query)` slice and gated by the new `directory.read:users` scope. Use it to build a chat people-list, an @-mention picker, or to resolve an author id to a display name. The host reads `GET /api/v1/app/users`, which hands non-Studio (Player) callers the reduced `{ id, name, role }` projection — email and other admin-only fields never leave the server for an app end-user. `query` is an optional `{ q, role, isActive, limit, offset }` (`q` substring-matches the display name; `role` is `"USER"` (default), `"INTEGRATION"`, or `"ALL"`). Mutating users is not part of the widget surface — the directory is read-only.
143
+ - **`useDirectory()` — read-only user directory hook.** Returns `{ users, loading, error, refetch }` where each user is `{ id, name, role }`. Gated by the new `directory.read:users` scope. Use it for a chat people-list, an @-mention picker, or to resolve an author id to a display name. Player callers get the reduced `{ id, name, role }` projection — email and other admin fields never leave the server for an app end-user. `query` is an optional `{ q, role, isActive, limit, offset }` (`q` substring-matches the display name; `role` is `"USER"` (default), `"INTEGRATION"`, or `"ALL"`). The directory is read-only.
80
144
  - **`CONTRACT.version` → `1.1.0`** (additive: one new hook, one new context slice, one new scope). No existing export changed signature.
81
145
 
82
146
  ### What was in 0.7.0
@@ -95,7 +159,7 @@ REQ-WSDK-PLATFORM — the "split-implementation + vetted package list" pivot. Se
95
159
  ### What was in 0.5.0
96
160
 
97
161
  - **`TextInput` primitive.** Cross-platform controlled text field — web maps to `<input type="text">` (or `<textarea>` when `multiline` is true), native maps to `react-native` `TextInput`. Props: `value`, `onChangeText`, `placeholder`, `multiline`, `rows`, `disabled`, `style`. Fixes the gap that forced widgets to fall back to raw `<textarea>` (web-only). The AI Widget Agent's system prompt now documents this primitive.
98
- - **`useDatastoreMutation(...).update(id, partial)` is wired.** Hits `PATCH /api/v1/tables/:tableId/records/:id`, which now exists on the backend (REQ-DML-PATCH). Partial-update semantics — only the supplied columns are mutated, the rest of the row is left intact. MANY_TO_MANY relations follow replace-set semantics when supplied; omitting the column leaves the existing links alone. Constraint enforcement (REQ-CONS-04) runs against the merged row, excluding self so a re-affirm doesn't trip its own UNIQUE.
162
+ - **`useDatastoreMutation(...).update(id, partial)` is wired.** Partial-update semantics — only the supplied columns are mutated, the rest of the row is left intact. MANY_TO_MANY relations follow replace-set semantics when supplied; omitting the column leaves the existing links alone. Constraint enforcement runs against the merged row, excluding self so a re-affirm doesn't trip its own UNIQUE.
99
163
 
100
164
  ### What was in 0.4.1
101
165
 
@@ -122,7 +186,7 @@ REQ-WSDK-PLATFORM — the "split-implementation + vetted package list" pivot. Se
122
186
 
123
187
  ### What was in 0.3.0
124
188
 
125
- Additive: `WidgetManifest` carries an optional `datastoreTemplate` field. When a tenant installs a widget that declares one, the table set is seeded into their workspace alongside the install. Tables follow the existing built-in template semantics (auto-suffixed naming, REQ-ACL-05 creator-grants, REQ-TEMPLATES-ACL public grants, REQ-ACL-RELINHERIT cross-relation inheritance) and persist when the widget is later uninstalled. See `WidgetDatastoreTemplate` in `src/index.d.ts` for the structural constraints — at most 8 tables per widget, 24 columns per table, RELATION columns address siblings by `suffix`.
189
+ Additive: `WidgetManifest` carries an optional `datastoreTemplate` field. When a tenant installs a widget that declares one, the table set is seeded into their workspace alongside the install. Tables follow the built-in template semantics (auto-suffixed naming, creator-grants, public grants, cross-relation inheritance) and persist when the widget is later uninstalled. See `WidgetDatastoreTemplate` in `src/index.d.ts` for the constraints — at most 8 tables per widget, 24 columns per table, RELATION columns address siblings by `suffix`.
126
190
 
127
191
  ## Public API
128
192
 
@@ -132,14 +196,14 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
132
196
 
133
197
  - `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
134
198
  - `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
135
- - `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useFile`, `useWidgetEvent`, `usePayments`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (`id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute }` for internal page navigation — for external URLs use the `Linking` primitive (`Linking.openURL(url)`). `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useDatastoreSchema(tableId)` returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, dataType, required, relationType, targetTableId, isIdentification }] }` (structure only, no row data) — use it to resolve a stored `columnId` to its column type at runtime; requires the `datastore.read:<table>` scope. `useFile(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base. `useChildRenderer()` returns `{ renderNode(node) }` — container widgets call it to render arbitrary child page-tree nodes (prefer the `WidgetTree` component for the common case).
199
+ - `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useFile`, `useWidgetEvent`, `usePayments`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError`. `useUser()` returns the active end-user identity `{ id, email, display_name, roles, group_ids }` (snake_case verbatim; `id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute }` for internal page navigation — for external URLs use the `Linking` primitive (`Linking.openURL(url)`). `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useDatastoreSchema(tableId)` returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, data_type, required, relation_type, target_table_id, is_identification }] }` (structure only, no row data; snake_case verbatim) — use it to resolve a stored `columnId` to its column type at runtime; requires the `datastore.read:<table>` scope. `useFile(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base. `useChildRenderer()` returns `{ renderNode(node) }` — container widgets call it to render arbitrary child page-tree nodes (prefer the `WidgetTree` component for the common case).
136
200
  - `WidgetTree({ node })` — component that renders an author-authored child node through the host's renderer; used by Tabs / Card / custom containers to host arbitrary child widgets.
137
201
  - `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet`, `Linking`, `Icon`, `DateTimePicker` — re-exported from `react-native` (the RN primitives) or implemented in the SDK (`Icon` wraps `lucide-react-native`, `DateTimePicker` wraps `@react-native-community/datetimepicker`). The web build aliases `react-native` to `react-native-web` so widgets render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the real `react-native` library. `Linking` is a static API (`Linking.openURL(url)`) — use it for external URLs, and use `useNavigation().goTo(pageId)` for internal page navigation. See https://reactnative.dev/docs/ for per-component props.
138
202
  - `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
139
203
 
140
204
  ## Managing app users from a widget
141
205
 
142
- REQ-USERMGMT / REQ-ACL-SYS M3 added two hooks that let a widget invite, deactivate, reactivate, and remove members, plus create / delete groups and add / remove their members. The hooks are gated by two layers: the widget's manifest must declare the scope (so the static analyzer + the host's signed `X-Widget-Scopes` header agree), and the calling APP_USER must hold the matching `users.*` / `groups.*` capability in the tenant (a SystemAcl grant the Studio admin issues via the Roles UI). A widget that declares `users.write:*` but whose caller does not hold the grant gets a `DirectoryError` with `code: 'FORBIDDEN'` — surface that to the end-user as a "you do not have permission to do that" message.
206
+ `useUsers()` and `useGroups()` let a widget invite, deactivate, reactivate, and remove members, plus create / delete groups and add / remove their members. Two gates apply: the manifest must declare the scope (the static analyzer + the host's signed `X-Widget-Scopes` header agree), and the calling APP_USER must hold the matching `users.*` / `groups.*` capability (a SystemAcl grant the Studio admin issues via the Roles UI). A widget that declares `users.write:*` but whose caller lacks the grant gets a `DirectoryError` with `code: 'FORBIDDEN'` — surface that as a "you do not have permission" message.
143
207
 
144
208
  ```js
145
209
  import { Text, View, Pressable, useUsers, useGroups } from "@colixsystems/widget-sdk";
@@ -152,7 +216,7 @@ export default function MemberManager() {
152
216
  <View>
153
217
  {users.map((u) => (
154
218
  <View key={u.id}>
155
- <Text>{u.name} — {u.isActive ? "active" : "inactive"}</Text>
219
+ <Text>{u.name} — {u.is_active ? "active" : "inactive"}</Text>
156
220
  <Pressable onPress={() => deactivate(u.id)}><Text>Deactivate</Text></Pressable>
157
221
  <Pressable onPress={() => remove(u.id)}><Text>Remove</Text></Pressable>
158
222
  </View>
@@ -160,7 +224,7 @@ export default function MemberManager() {
160
224
  <Pressable
161
225
  onPress={async () => {
162
226
  try {
163
- await invite({ email: "a@b.com", name: "New User", groupIds: [groups[0]?.id].filter(Boolean) });
227
+ await invite({ email: "a@b.com", name: "New User", group_ids: [groups[0]?.id].filter(Boolean) });
164
228
  } catch (err) {
165
229
  // err.code is one of FORBIDDEN | VALIDATION | NOT_FOUND | INVITE_ONLY
166
230
  }
@@ -184,6 +248,60 @@ The matching manifest declares the scopes:
184
248
 
185
249
  The host rejects calls whose scope is not declared in the manifest (the SDK linter catches this statically too). Declaring a write scope is also a consent prompt the Studio admin sees at install time — the wider the scope set, the more careful the admin is about granting the install.
186
250
 
251
+ ## Managing per-record permissions from a widget
252
+
253
+ `useRecordPermissions(tableId, recordId)` is the in-app surface for sharing a single record with another user or group. The chat widget uses it to invite members into a channel — the channel record's per-record grants ARE the membership list (messages inherit those grants). The hook also covers project-workspace, document-sharing, and team-roster widgets that grant access record-by-record.
254
+
255
+ The rows and bodies are snake_case verbatim. A row carries `user_id` OR `group_id` (both null = a public grant) plus the `can_read` / `can_write` / `can_delete` / `can_grant` flags; the hook reads `ctx.datastore.records(tableId).permissions(recordId)`.
256
+
257
+ ```js
258
+ import { Text, View, Pressable, useRecordPermissions, PermissionError } from "@colixsystems/widget-sdk";
259
+
260
+ export default function ShareRecord({ tableId, recordId, partnerUserId }) {
261
+ const { permissions, loading, grant, revoke } = useRecordPermissions(tableId, recordId);
262
+ if (loading) return <Text>Loading members…</Text>;
263
+ return (
264
+ <View>
265
+ {permissions.map((p) => (
266
+ <View key={p.id}>
267
+ <Text>{p.user_id || p.group_id || "public"} — {p.can_write ? "writer" : "reader"}</Text>
268
+ <Pressable onPress={() => revoke(p.id)}><Text>Remove</Text></Pressable>
269
+ </View>
270
+ ))}
271
+ <Pressable
272
+ onPress={async () => {
273
+ try {
274
+ await grant({
275
+ user_id: partnerUserId,
276
+ can_read: true,
277
+ can_write: true,
278
+ can_grant: true,
279
+ });
280
+ } catch (err) {
281
+ if (err instanceof PermissionError && err.code === "FORBIDDEN") {
282
+ // The signed-in user lacks can_grant on this record.
283
+ }
284
+ }
285
+ }}
286
+ >
287
+ <Text>Invite partner</Text>
288
+ </Pressable>
289
+ </View>
290
+ );
291
+ }
292
+ ```
293
+
294
+ The manifest declares the matching scope:
295
+
296
+ ```js
297
+ {
298
+ // ...
299
+ requestedScopes: ["acl.write:records"],
300
+ }
301
+ ```
302
+
303
+ The server-side gate is `canGrant` on the target record — Studio owners pass automatically; an APP_USER holds `canGrant` as the record's creator or via a delegated grant. A caller without `canGrant` receives `PermissionError { code: "FORBIDDEN" }`. The hook collapses to a stable no-op when `tableId` or `recordId` is null/empty — so a widget can render its picker first, then bind to the picked record without conditional hook tricks.
304
+
187
305
  ## Linter
188
306
 
189
307
  ```sh
package/dist/contract.cjs CHANGED
@@ -167,12 +167,12 @@ const HOOKS = [
167
167
  name: "useDirectory",
168
168
  signature: "useDirectory(query?)",
169
169
  returnShape: {
170
- users: "Array<{ id, name, role }>",
170
+ users: "Array<{ id, name, role }> // snake_case rows; unwrapped from { data, meta }",
171
171
  loading: "boolean",
172
172
  error: "DatastoreError | null",
173
173
  refetch: "() => Promise<void>",
174
174
  },
175
- requiredContextSlice: ["directory.listUsers"],
175
+ requiredContextSlice: ["directory.users"],
176
176
  scopes: ["directory.read:users"],
177
177
  },
178
178
  {
@@ -206,28 +206,26 @@ const HOOKS = [
206
206
  name: "useUsers",
207
207
  signature: "useUsers(query?)",
208
208
  description:
209
- "AppUser administration. Returns { users, loading, error, refetch, invite, " +
210
- "deactivate, reactivate, remove }. Reads need users.read:* scope; mutations " +
211
- "need users.write:*. The `invite` call accepts { email, name, groupIds? } " +
212
- "and returns the resulting AppUserInvite row (the email is sent by the host).",
209
+ "AppUser administration via the injected directory-client at " +
210
+ "ctx.directory.users.{list,get,invite,deactivate,reactivate}. Returns " +
211
+ "{ users, loading, error, refetch, invite, deactivate, reactivate, remove }. " +
212
+ "list returns the { data, meta } envelope verbatim the hook unwraps " +
213
+ "res.data; rows are snake_case (is_active, …). Reads need users.read:* " +
214
+ "scope; mutations need users.write:*. The `invite` call accepts " +
215
+ "{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
216
+ "(the email is sent by the host).",
213
217
  returnShape: {
214
- users: "Array<{ id, name, email?, role, isActive }>",
218
+ users: "Array<{ id, name, email?, role, is_active }> // snake_case rows; unwrapped from { data, meta }",
215
219
  loading: "boolean",
216
220
  error: "DirectoryError | null",
217
221
  refetch: "() => Promise<void>",
218
222
  invite:
219
- "({ email, name, groupIds? }) => Promise<Invite> // rejects with DirectoryError",
223
+ "({ email, name, group_ids? }) => Promise<Invite> // rejects with DirectoryError",
220
224
  deactivate: "(userId) => Promise<User> // rejects with DirectoryError",
221
225
  reactivate: "(userId) => Promise<User> // rejects with DirectoryError",
222
226
  remove: "(userId) => Promise<void> // rejects with DirectoryError",
223
227
  },
224
- requiredContextSlice: [
225
- "users.listUsers",
226
- "users.invite",
227
- "users.deactivate",
228
- "users.reactivate",
229
- "users.remove",
230
- ],
228
+ requiredContextSlice: ["directory.users"],
231
229
  scopes: ["users.read:*"],
232
230
  },
233
231
  // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration. Returns
@@ -239,11 +237,14 @@ const HOOKS = [
239
237
  name: "useGroups",
240
238
  signature: "useGroups(query?)",
241
239
  description:
242
- "AppUserGroup administration. Returns { groups, loading, error, refetch, " +
243
- "create, remove, addMember, removeMember }. Reads need groups.read:*; " +
240
+ "AppUserGroup administration via the injected directory-client at " +
241
+ "ctx.directory.groups.{list,create,remove,addMember,removeMember,listMine}. " +
242
+ "Returns { groups, loading, error, refetch, create, remove, addMember, " +
243
+ "removeMember }. list returns the { data, meta } envelope verbatim — the " +
244
+ "hook unwraps res.data; rows are snake_case. Reads need groups.read:*; " +
244
245
  "mutations need groups.write:*.",
245
246
  returnShape: {
246
- groups: "Array<{ id, name, memberCount }>",
247
+ groups: "Array<{ id, name, member_count }> // snake_case rows; unwrapped from { data, meta }",
247
248
  loading: "boolean",
248
249
  error: "DirectoryError | null",
249
250
  refetch: "() => Promise<void>",
@@ -255,15 +256,44 @@ const HOOKS = [
255
256
  removeMember:
256
257
  "(groupId, userId) => Promise<void> // rejects with DirectoryError",
257
258
  },
258
- requiredContextSlice: [
259
- "groups.listGroups",
260
- "groups.create",
261
- "groups.remove",
262
- "groups.addMember",
263
- "groups.removeMember",
264
- ],
259
+ requiredContextSlice: ["directory.groups"],
265
260
  scopes: ["groups.read:*"],
266
261
  },
262
+ // REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — per-record VirtualPermission
263
+ // management for a single record. Mirror of contract.js.
264
+ {
265
+ name: "useRecordPermissions",
266
+ signature: "useRecordPermissions(tableId, recordId)",
267
+ description:
268
+ "Manage per-record VirtualPermission grants on a single record via the " +
269
+ "injected datastore-client at " +
270
+ "ctx.datastore.records(tableId).permissions(recordId).{list,grant,update,revoke}. " +
271
+ "Returns { permissions, loading, error, grant, revoke, update, refetch } " +
272
+ "where permissions is Array<{ id, user_id, group_id, can_read, can_write, " +
273
+ "can_delete, can_grant }> (snake_case rows verbatim; list() returns the " +
274
+ "{ data, meta } envelope and the hook unwraps res.data). grant/update " +
275
+ "bodies are snake_case verbatim ({ user_id | group_id, can_read, " +
276
+ "can_write, can_delete, can_grant }). Mutating requires acl.write:records " +
277
+ "scope AND can_grant on the target record (REQ-ACL-RELINHERIT-05: " +
278
+ "APP_USER actors with can_grant are accepted, not only Studio owners). " +
279
+ "When tableId or recordId is null/empty the hook collapses to an empty " +
280
+ "no-op result without a network round-trip.",
281
+ returnShape: {
282
+ permissions:
283
+ "Array<{ id, user_id, group_id, can_read, can_write, can_delete, can_grant }> // snake_case rows; unwrapped from { data, meta }",
284
+ loading: "boolean",
285
+ error: "PermissionError | null",
286
+ grant:
287
+ "({ user_id?, group_id?, can_read?, can_write?, can_delete?, can_grant? }) => Promise<RecordPermission> // rejects with PermissionError",
288
+ revoke:
289
+ "(permissionId) => Promise<void> // rejects with PermissionError",
290
+ update:
291
+ "(permissionId, { can_read?, can_write?, can_delete?, can_grant? }) => Promise<RecordPermission> // rejects with PermissionError",
292
+ refetch: "() => Promise<void>",
293
+ },
294
+ requiredContextSlice: ["datastore.records"],
295
+ scopes: ["acl.write:records"],
296
+ },
267
297
  // REQ-WSDK-PLATFORM §6 — Tier A SDK hooks. Mirror of contract.js.
268
298
  {
269
299
  name: "useClipboard",
@@ -419,10 +449,40 @@ const CATEGORIES = [
419
449
  "DATA",
420
450
  "MEDIA",
421
451
  "COMMUNICATION",
452
+ // REQ-USERMGMT-06: app-administration widgets (User Management, …) the
453
+ // published app embeds for its own member management — its own palette
454
+ // section, distinct from COMMUNICATION.
455
+ "ADMINISTRATION",
422
456
  "CUSTOM",
423
457
  ];
424
458
  const PLATFORMS = ["web", "native"];
425
459
 
460
+ // REQ-WIDGET-ACTION — server-side actions a widget may declare in its
461
+ // manifest. Each runs in the shared isolated-vm action runner (see backend
462
+ // action-runner.service.js) on a cron schedule or in response to a record
463
+ // CRUD event — NEVER in the rendered app, so they never affect Player ↔
464
+ // export parity. The trigger vocabulary mirrors the backend Action model.
465
+ const ACTION_TRIGGER_TYPES = [
466
+ "schedule",
467
+ "record_created",
468
+ "record_updated",
469
+ "record_deleted",
470
+ ];
471
+ // Globals the action script runs against (the runner's surface) — distinct
472
+ // from the React/SDK widget surface, so the component import/banned-API
473
+ // linter does NOT scan action scripts.
474
+ const ACTION_SCRIPT_GLOBALS = [
475
+ "datastore",
476
+ "fetch",
477
+ "console",
478
+ "record",
479
+ "tenantId",
480
+ "triggerType",
481
+ "triggerTableId",
482
+ ];
483
+ // Mirrors action.service.js SCRIPT_MAX_BYTES.
484
+ const ACTION_SCRIPT_MAX_BYTES = 200 * 1024;
485
+
426
486
  // Reverse-DNS-ish manifest id, e.g. "com.acme.charts.barchart". Two or
427
487
  // more labels, lowercase alnum + hyphen, label starts with a letter. The
428
488
  // analyzer + the SDK validator both read this from the contract so a
@@ -508,6 +568,17 @@ const MANIFEST_SCHEMA = {
508
568
  description:
509
569
  "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: 8 tables, 24 columns per table. RELATION columns address siblings by `targetSuffix` (must be declared earlier in the array). Tables persist across uninstalls.",
510
570
  },
571
+ actions: {
572
+ type: "object[]",
573
+ required: false,
574
+ description:
575
+ "Optional. Server-side actions the widget declares. Each runs in the shared isolated-vm action runner (cron- or record-triggered) — NEVER in the rendered app. Operators enable them per tenant from the Properties Panel; the action materialises DISABLED until they bind an integration API key (and, for record_* triggers, a target table) in the Actions admin page. Each entry: { key (stable, unique within the manifest), name, description?, triggerType (one of " +
576
+ ACTION_TRIGGER_TYPES.join(", ") +
577
+ "), scheduleCron? (required iff triggerType=='schedule'; node-cron syntax), timeoutMs? (100–300000), scriptSource (≤200 KiB; runs against " +
578
+ ACTION_SCRIPT_GLOBALS.join(", ") +
579
+ " — NOT the React surface, so SDK imports/hooks are unavailable) }. Do NOT include triggerTableId or apiKeyId — those are tenant-local and bound after install.",
580
+ default: [],
581
+ },
511
582
  };
512
583
 
513
584
  const WIDGET_CONTEXT_SHAPE = {
@@ -524,9 +595,10 @@ const WIDGET_CONTEXT_SHAPE = {
524
595
  fields: { id: "manifest.id", version: "manifest.version" },
525
596
  },
526
597
  user: {
527
- description: "Signed-in user ({ id, email, displayName }).",
598
+ description:
599
+ "Signed-in user, host-provided VERBATIM (snake_case: { id, email, display_name, roles, group_ids }). Not a data-client.",
528
600
  required: true,
529
- fields: { id: "string", email: "string", displayName: "string" },
601
+ fields: { id: "string", email: "string", display_name: "string" },
530
602
  },
531
603
  workspace: {
532
604
  description:
@@ -546,19 +618,30 @@ const WIDGET_CONTEXT_SHAPE = {
546
618
  },
547
619
  datastore: {
548
620
  description:
549
- "{ records(table) -> { list(query?), get(id), create(values), update(id, values), delete(id) }, schema(table) -> Promise<{ id, name, columns: [...] }> }. `records` backs the query/record/mutation hooks; `schema` backs useDatastoreSchema() and resolves a table's column structure (no row data).",
621
+ "Injected @colixsystems/datastore-client instance. " +
622
+ "{ tables: { list(), get(idOrName) }, schema(tableId) -> Promise<{ id, name, columns: [...] }>, " +
623
+ "records(tableId) -> { list(query) -> Promise<{ data, meta }>, get(id), create(values), update(id, values), delete(id), aggregate(spec), " +
624
+ "permissions(recordId) -> { list() -> Promise<{ data, meta }>, grant(body), update(permId, patch), revoke(permId) } } }. " +
625
+ "`records` backs the query/record/mutation hooks; `records(t).permissions(r)` backs useRecordPermissions(); `schema` backs useDatastoreSchema(). " +
626
+ "List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case (author column values keep their author-given names).",
550
627
  required: true,
551
- fields: { records: "function", schema: "function" },
628
+ fields: { records: "function", schema: "function", tables: "object" },
552
629
  },
553
630
  directory: {
554
631
  description:
555
- "Read-only user directory. { listUsers(query?) -> Promise<Array<{ id, name, role }>> }. Backs useDirectory(); for chat people-lists / @-mention pickers / author-id resolution. Requires the directory.read:users scope.",
632
+ "Injected @colixsystems/directory-client instance. " +
633
+ "{ me(), users: { list(query?) -> Promise<{ data, meta }>, get(id), invite(body), deactivate(id), reactivate(id) }, " +
634
+ "groups: { list(query?) -> Promise<{ data, meta }>, create(body), remove(id), addMember(groupId, userId), removeMember(groupId, userId), listMine() }, " +
635
+ "invites: { list(), revoke(id), resend(id) } }. " +
636
+ "users backs useDirectory() + useUsers(); groups backs useGroups(). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:*.",
556
637
  required: true,
557
- fields: { listUsers: "function" },
638
+ fields: { users: "object", groups: "object" },
558
639
  },
559
640
  files: {
560
641
  description:
561
- "Read-only asset resolver. { get(fileId) -> Promise<{ id, url, storedFilename, mimeType, sizeBytes, ... }> }. Backs useFile(); resolves an asset id to an absolute URL the widget can drop into an <Image source>. The url field is always an absolute URL composed against the host's API base.",
642
+ "Injected @colixsystems/files-client instance, FLATTENED so file ops are top-level. " +
643
+ "{ get(id) -> Promise<{ id, url, ... }>, list(query) -> Promise<{ data, meta }>, upload(formData), folders: { list, create }, shares: { list, create, remove } }. " +
644
+ "Backs useFile(); the returned file already carries an absolute url the widget can drop into an <Image source>.",
562
645
  required: true,
563
646
  fields: { get: "function" },
564
647
  },
@@ -575,42 +658,17 @@ const WIDGET_CONTEXT_SHAPE = {
575
658
  },
576
659
  payments: {
577
660
  description:
578
- "Incoming app-user payments (REQ-BILL-07-WIDGETPAY). { requestPayment({ amountCents, currency?, description, metadata? }) -> Promise<{ id, status, checkoutUrl? }>, getPayment(id) -> Promise<payment> }. Backs usePayments(); requires the payments.charge:appUser scope. The host opens hosted Checkout (or auto-confirms under the mock provider); the charge settles to the workspace owner.",
661
+ "Injected @colixsystems/payments-client instance (REQ-BILL-07-WIDGETPAY). { requestPayment(body) -> Promise<{ id, status, checkoutUrl? }>, getPayment(id) -> Promise<payment> }. Backs usePayments(); requires the payments.charge:appUser scope. The host opens hosted Checkout (or auto-confirms under the mock provider); the charge settles to the workspace owner.",
579
662
  required: true,
580
663
  fields: { requestPayment: "function", getPayment: "function" },
581
664
  },
582
- // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration facade backing
583
- // useUsers(). Reads gated by `users.read:*`; mutations by `users.write:*`.
584
- // The host signs an `X-Widget-Scopes` header against JWT_SECRET so an
585
- // APP_USER cannot forge scope claims, and the request is additionally
586
- // gated by a SystemAcl `users.read` / `users.write` capability grant.
587
- users: {
588
- description:
589
- "AppUser administration. { listUsers(query?) -> Promise<User[]>, invite({ email, name, groupIds? }) -> Promise<Invite>, deactivate(userId) -> Promise<User>, reactivate(userId) -> Promise<User>, remove(userId) -> Promise<void> }. Backs useUsers(); reads require users.read:*, mutations require users.write:*.",
590
- required: true,
591
- fields: {
592
- listUsers: "function",
593
- invite: "function",
594
- deactivate: "function",
595
- reactivate: "function",
596
- remove: "function",
597
- },
598
- },
599
- // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration facade
600
- // backing useGroups(). Reads gated by `groups.read:*`; mutations by
601
- // `groups.write:*`. Same X-Widget-Scopes + SystemAcl gating as users.
602
- groups: {
603
- description:
604
- "AppUserGroup administration. { listGroups(query?) -> Promise<Group[]>, create({ name }) -> Promise<Group>, remove(groupId) -> Promise<void>, addMember(groupId, userId) -> Promise<void>, removeMember(groupId, userId) -> Promise<void> }. Backs useGroups(); reads require groups.read:*, mutations require groups.write:*.",
605
- required: true,
606
- fields: {
607
- listGroups: "function",
608
- create: "function",
609
- remove: "function",
610
- addMember: "function",
611
- removeMember: "function",
612
- },
613
- },
665
+ // REQ-WSDK-DOMAIN-CLIENTSthe AppUser administration, AppUserGroup
666
+ // administration, and per-record VirtualPermission facades that used to
667
+ // live here (`users`, `groups`, `recordPermissions`) were folded into the
668
+ // injected domain-client instances: useUsers()/useGroups() read
669
+ // ctx.directory.{users,groups}; useRecordPermissions() reads
670
+ // ctx.datastore.records(table).permissions(record). See the `directory`
671
+ // and `datastore` slices above.
614
672
  i18n: {
615
673
  description: "{ t(key, fallback?), locale }.",
616
674
  required: true,
@@ -865,12 +923,44 @@ const CONTRACT = deepFreeze({
865
923
  // stored columnId to its column name / dataType / relation target at
866
924
  // runtime (Form Builder renders inputs by column type). Reads the existing
867
925
  // ACL-gated `GET /tables/:id` — structure only, no row data.
868
- version: "1.7.0",
926
+ //
927
+ // 1.8.0: two additive features.
928
+ // - REQ-WIDGET-ACTION — manifests may declare an optional `actions` array.
929
+ // Each entry is a server-side action (cron- or record-triggered JS) the
930
+ // operator enables per tenant; it runs in the existing isolated-vm action
931
+ // runner, never in the rendered app. New contract fields
932
+ // `actionTriggerTypes`, `actionScriptGlobals`, and `actionScriptMaxBytes`
933
+ // let the docs + agent prompt + validator derive the grammar from one
934
+ // source. New `ADMINISTRATION` manifest category (REQ-USERMGMT-06).
935
+ // - REQ-ACL-06 — new `useRecordPermissions(tableId, recordId)` hook + the
936
+ // `recordPermissions` host-context slice it reads + the `acl.write:records`
937
+ // scope it gates on. Hits the existing REQ-ACL-06
938
+ // /api/v1/tables/:tableId/records/:recordId/permissions REST surface; the
939
+ // host normalises the wire shape into a single principalType+principalId
940
+ // pair widgets branch on. Caller still needs canGrant on the target
941
+ // record (REQ-ACL-RELINHERIT-05).
942
+ //
943
+ // 1.9.0: host-contract change (REQ-WSDK-DOMAIN-CLIENTS) — the host now
944
+ // injects domain-client INSTANCES on the WidgetContext rather than
945
+ // bespoke per-hook facades. ctx.datastore is a @colixsystems/datastore-client
946
+ // (records(t).list now returns the { data, meta } envelope verbatim;
947
+ // records(t).permissions(r) replaces the deleted ctx.recordPermissions),
948
+ // ctx.directory is a @colixsystems/directory-client (users/groups
949
+ // namespaces replace the deleted ctx.users / ctx.groups; list returns
950
+ // envelopes), ctx.files is a flattened @colixsystems/files-client,
951
+ // ctx.payments is a @colixsystems/payments-client. All rows/bodies are
952
+ // snake_case. Hooks now unwrap the list envelope (res.data ?? []). The
953
+ // SDK stays duck-typed — it imports none of the four data SDKs. Minor
954
+ // bump on the contract's pre-1.0 versioning (the breaking channel).
955
+ version: "1.9.0",
869
956
  hooks: HOOKS,
870
957
  primitives: PRIMITIVES,
871
958
  manifestSchema: MANIFEST_SCHEMA,
872
959
  manifestCategories: CATEGORIES,
873
960
  manifestPlatforms: PLATFORMS,
961
+ actionTriggerTypes: ACTION_TRIGGER_TYPES,
962
+ actionScriptGlobals: ACTION_SCRIPT_GLOBALS,
963
+ actionScriptMaxBytes: ACTION_SCRIPT_MAX_BYTES,
874
964
  themeTokens: DEFAULT_THEME_TOKENS,
875
965
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
876
966
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,