@colixsystems/widget-sdk 0.45.1 → 0.48.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
@@ -13,7 +13,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
13
13
 
14
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 case transform anywhere** — not on the client and not in the backend; the only casing boundary is Prisma `@map` (snake_case field → camelCase column). Author-defined record column values pass through verbatim. Every `list(...)` returns the `{ data, meta }` envelope; the read hooks unwrap `res.data` for you.
15
15
 
16
- **Hooks read the injected clients** — they do not hold their own HTTP. This is the **complete** hook surface (19 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).
16
+ **Hooks read the injected clients** — they do not hold their own HTTP. This is the **complete** hook surface (20 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
17
 
18
18
  | Group | Hook (signature) | Returns | Reads / scope |
19
19
  | ----- | ---------------- | ------- | ------------- |
@@ -40,6 +40,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
40
40
  | **DIRECTORY** | `useGroups(query?)` | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
41
41
  | **DIRECTORY** | `useBankIdLink()` | `{ linked, available, status, qr, message, startLink, refresh, cancel, unlink, refetchStatus, … }` | `directory.bankid.*` — no scope (JWT-gated self-service) |
42
42
  | **PAYMENTS** (`ctx.payments`) | `usePayments()` | `{ requestPayment, getPayment }` | `ctx.payments.*` — `payments.charge:appUser` |
43
+ | **NOTIFICATIONS** (`ctx.notifications`) | `useSendNotification()` | `{ send, sending, error }` | `ctx.notifications.send` — `notifications.send:appUser`. `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace; call from an event handler (never render); rejects with `NotificationError`. |
43
44
 
44
45
  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 }`).
45
46
 
@@ -49,7 +50,24 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
49
50
 
50
51
  ## Status
51
52
 
52
- `v0.45.1` — 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**.
53
+ `v0.48.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**.
54
+
55
+ ### What's new in 0.48.0
56
+
57
+ **New `useSendNotification()` hook — send an in-app notification to an app user (sc-890).** A new NOTIFICATIONS hook reading a newly-injected `ctx.notifications` slice (the `@colixsystems/notifications-client`, now constructed by both the web Player and the native Expo export). Returns `{ send, sending, error }`. `send({ recipient_user_id, title, body, link?, payload? })` posts a notification to one app user **in the same workspace** and resolves to the created row; `recipient_user_id` must be a member of the tenant or the call is rejected (cross-workspace targets never resolve). Call it from an **event handler** (a `Pressable.onPress`, a submit, a mutation callback) — never in render, where the abuse/rate-limit guard would fire on every paint. Gated by the new `notifications.send:appUser` scope, which the widget declares in its manifest `requestedScopes`. Rejections surface as a structured `NotificationError` (new named export) with a stable `.code` (`INVALID_TITLE` / `INVALID_BODY` / `INVALID_RECIPIENT` / `INVALID_PAYLOAD` / `VALIDATION` / `AUTH_REQUIRED` / `FORBIDDEN` / `RECIPIENT_NOT_FOUND` / `RATE_LIMITED` / `INTERNAL`). `CONTRACT.version` → `1.35.0`. Additive — one new hook, one new context slice, one new scope, one new error class; no existing export changed signature.
58
+
59
+ ### What's new in 0.47.0
60
+
61
+ **`<FilePicker>` native variant is real (sc-1378 follow-up).** The Expo-export shell of `<FilePicker>` now wraps the vetted `expo-document-picker` and reports `FilePicker.isSupported = true`. Result: the Files widget's `allowUpload` toggle works on **both** the web Player and the native Expo export — no more disabled stub on mobile. `expo-document-picker` is added to `CONTRACT.vettedImports` (`platforms: ["native"]`); the compiler's `generatePackageJson` was already pinning it for the export build, so no new export-pin work was needed. `useFilestoreUpload` accepts whatever the host's FormData reads as a binary part — a browser `File` on web, the React Native `{ uri, name, type }` shape from the picker on native — so the same hook code path feeds the multipart on both platforms. `CONTRACT.version` → `1.34.0`, additive.
62
+
63
+ ### What's new in 0.46.0
64
+
65
+ **End-user upload primitive + hook (sc-1378).** Widgets can now let an end user upload a file to the Filestore from the published app. Two pieces ship together:
66
+
67
+ - **New SDK hook `useFilestoreUpload({ spaceType, folderId? })`.** Resolves `owner_id` the same way the read filestore hooks do, builds a multipart `FormData` with the snake_case fields the backend reads verbatim (`space_type`, `owner_id`, `folder_id`, plus the binary `file`), and POSTs through `ctx.filestore.files.upload`. Returns `{ upload(file, { folderId? }), uploading, error, lastUploaded }`. A 404 from the backend means the destination folder denied a write (REQ-FSH `canWrite` gate).
68
+ - **New SDK primitive `<FilePicker accept onPick>`.** Web wraps a hidden DOM `<input type="file">` (children render as the visible click target inside a `<label>` so the click reaches the file dialog without ref plumbing). Native renders a disabled trigger and exposes `FilePicker.isSupported = false` until a vetted Expo picker pin lands.
69
+ - **Files widget `allowUpload`.** The built-in Files widget grew an `Allow uploading files` toggle (manifest v2.2.0). When enabled, the toolbar renders an Upload trigger that pipes the picked file through the new hook into the currently-open folder; the `typeFilter` setting narrows the `accept` MIME so an "Images" filter shows only images in the OS dialog.
70
+ - **`CONTRACT.version` → `1.33.0`.** Additive — new hook + new primitive; no existing hook, primitive, manifest field, or token changed shape.
53
71
 
54
72
  ### What's new in 0.45.1
55
73
 
@@ -151,6 +169,7 @@ Also: `useFileSignatures(fileIds)` is now **self-scoped** (the caller's own sign
151
169
  **Filestore browsing + BankID file signing for widgets (REQ-FS / REQ-SIGN).** Three new hooks read a newly-injected `ctx.filestore` (the `@colixsystems/filestore-client`, now constructed by both the web and native hosts):
152
170
  - `useFilestoreFiles({ spaceType, folderId?, q?, type? })` → `{ files, loading, error, refetch }` — browses the end-user's Filestore space. The hook resolves `owner_id` from the host context (tenant for a project space, the app user for a personal space), so the widget only picks the space.
153
171
  - `useFilestoreFolders({ spaceType, parentFolderId?, q?, enabled? })` → `{ folders, loading, error, refetch }` — the folder-navigation companion to `useFilestoreFiles`; pass `enabled:false` to suspend fetching.
172
+ - `useFilestoreUpload({ spaceType, folderId? })` → `{ upload, uploading, error, lastUploaded }` — POSTs a multipart upload to `ctx.filestore.files.upload`. Resolves `owner_id` from the host context (like the read hooks) so the widget only picks the space + destination folder. Pair with the `<FilePicker>` primitive for the visible trigger. Requires the `files.write:*` scope.
154
173
  - `useFileSignature(fileId)` → `{ status, qr, signerName, verdict, initiate, refresh, cancel, verify, … }` — drives a BankID signing flow for a file (the backend hashes the bytes server-side, binds the digest into the signature, and verifies the proof offline).
155
174
 
156
175
  `CONTRACT.version` → `1.20.0` (additive — no existing hook changed).
@@ -382,7 +401,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
382
401
 
383
402
  - `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
384
403
  - `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
385
- - `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `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. `useAsset(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).
404
+ - `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `usePayments`, `useSendNotification`, `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`. `useSendNotification()` returns `{ send, sending, error }` and requires the `notifications.send:appUser` scope; `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace (cross-workspace `recipient_user_id` is rejected), must be called from an event handler rather than render, and rejects with a `NotificationError`. `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. `useAsset(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).
386
405
  - `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.
387
406
  - `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` on native and renders `<input type="date|time|datetime-local">` directly on web because the RN library has no react-native-web mapping). The web build aliases `react-native` to `react-native-web` so the RN-re-exported primitives 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.
388
407
  - `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
package/dist/contract.cjs CHANGED
@@ -214,6 +214,27 @@ const HOOKS = [
214
214
  requiredContextSlice: ["filestore.files"],
215
215
  scopes: ["files.read:*"],
216
216
  },
217
+ {
218
+ name: "useFilestoreUpload",
219
+ signature: "useFilestoreUpload({ spaceType, folderId? })",
220
+ description:
221
+ "Upload a file into the end-user's Filestore space. The widget passes " +
222
+ "the SPACE (`{ spaceType, folderId? }`); the hook resolves owner_id " +
223
+ "from the host context, builds a multipart FormData with the " +
224
+ "snake_case fields the backend reads verbatim (`space_type`, " +
225
+ "`owner_id`, `folder_id`, plus the binary `file`), and POSTs through " +
226
+ "ctx.filestore.files.upload. `upload(file, { folderId? })` resolves " +
227
+ "to the created file row or throws the wire error; a 404 means the " +
228
+ "destination folder denied a write (REQ-FSH canWrite gate).",
229
+ returnShape: {
230
+ upload: "(file, { folderId? }) => Promise<FilestoreFile>",
231
+ uploading: "boolean",
232
+ error: "Error | null",
233
+ lastUploaded: "FilestoreFile | null",
234
+ },
235
+ requiredContextSlice: ["filestore.files"],
236
+ scopes: ["files.write:*"],
237
+ },
217
238
  {
218
239
  name: "useFilestoreFolders",
219
240
  signature: "useFilestoreFolders({ spaceType, parentFolderId?, q?, enabled? })",
@@ -399,6 +420,22 @@ const HOOKS = [
399
420
  requiredContextSlice: ["payments.requestPayment"],
400
421
  scopes: ["payments.charge:appUser"],
401
422
  },
423
+ // sc-890 — send an in-app notification to one app user in the tenant.
424
+ // IMPERATIVE: send() never fires on mount; the widget calls it from an
425
+ // event handler. Reads ctx.notifications.send (the injected
426
+ // @colixsystems/notifications-client). Requires the
427
+ // notifications.send:appUser scope.
428
+ {
429
+ name: "useSendNotification",
430
+ signature: "useSendNotification()",
431
+ returnShape: {
432
+ send: "({ recipient_user_id, title, body, link?, payload? }) => Promise<notification> // rejects with NotificationError",
433
+ sending: "boolean",
434
+ error: "NotificationError | null",
435
+ },
436
+ requiredContextSlice: ["notifications.send"],
437
+ scopes: ["notifications.send:appUser"],
438
+ },
402
439
  // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
403
440
  // `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
404
441
  // Reads need `users.read:*` scope; edit-style mutations (invite /
@@ -710,6 +747,19 @@ const PRIMITIVES = [
710
747
  rnComponent: "@react-native-community/datetimepicker",
711
748
  docsUrl: "https://github.com/react-native-datetimepicker/datetimepicker",
712
749
  },
750
+ // sc-1378 — `<FilePicker accept onPick>` lets a widget surface a native
751
+ // file-picker on the web Player so end users can upload files to the
752
+ // backend (paired with useFilestoreUpload or useAssets.upload). Web wraps
753
+ // a hidden DOM `<input type="file">`; native renders a disabled trigger
754
+ // and exposes `FilePicker.isSupported = false` until a vetted Expo
755
+ // picker pin lands.
756
+ {
757
+ name: "FilePicker",
758
+ description:
759
+ 'End-user file picker primitive. `<FilePicker accept="image/*" multiple={false} disabled={false} onPick={(file) => …}>…trigger…</FilePicker>`. Children render as the visible click target (a Pressable + Icon + Text is the common shape) and are wrapped in a hidden-input <label> so the click reaches the file dialog without any ref plumbing. Pair with useFilestoreUpload to upload the picked File to the end-user filestore. `FilePicker.isSupported` is `true` on the web Player and `false` on the native Expo export — widgets should branch on it to hide the trigger when uploads are not yet wired natively.',
760
+ rnComponent: null,
761
+ docsUrl: null,
762
+ },
713
763
  ];
714
764
 
715
765
  const CATEGORIES = [
@@ -992,6 +1042,13 @@ const WIDGET_CONTEXT_SHAPE = {
992
1042
  required: true,
993
1043
  fields: { requestPayment: "function", getPayment: "function" },
994
1044
  },
1045
+ // sc-890 — backs useSendNotification(). Mirror of contract.js.
1046
+ notifications: {
1047
+ description:
1048
+ "Injected @colixsystems/notifications-client instance (sc-890). { send(body) -> Promise<notification> }. Backs useSendNotification(); requires the notifications.send:appUser scope. The host POSTs the body snake_case verbatim ({ recipient_user_id, title, body, link?, payload? }) to /notifications/send and returns the created notification row; the call is imperative (a send never fires on mount).",
1049
+ required: true,
1050
+ fields: { send: "function" },
1051
+ },
995
1052
  // REQ-WSDK-DOMAIN-CLIENTS — the AppUser administration, AppUserGroup
996
1053
  // administration, and per-record VirtualPermission facades that used to
997
1054
  // live here (`users`, `groups`, `recordPermissions`) were folded into the
@@ -1169,6 +1226,13 @@ const VETTED_IMPORTS = [
1169
1226
  description:
1170
1227
  "Canvas-style 2D/GPU drawing & animation (games, custom graphics) on native. Native-only — author it in widget.native.jsx and pair it with a web variant in widget.web.jsx (a <canvas> or react-native-svg). There is no Skia web build wired into the Player.",
1171
1228
  },
1229
+ {
1230
+ specifier: "expo-document-picker",
1231
+ platforms: ["native"],
1232
+ category: "files",
1233
+ description:
1234
+ "Native file picker used by the SDK's <FilePicker> primitive on the Expo export (web uses a hidden DOM <input type=\"file\">, mapped to the same { accept, onPick } contract). Most widgets reach <FilePicker> through the SDK rather than importing this directly; a widget that does import it goes in widget.native.jsx and is pinned by the compiler's generatePackageJson — Expo SDK 56 ships 56.0.x.",
1235
+ },
1172
1236
  {
1173
1237
  specifier: "lucide-react-native",
1174
1238
  platforms: ["web", "native"],
@@ -1681,7 +1745,40 @@ const CONTRACT = deepFreeze({
1681
1745
  // instance — one StyleSheet/context, no double-instance conflicts. The
1682
1746
  // vetted-import SET is unchanged (react-native was already vetted); only
1683
1747
  // its web resolution + description changed — minor bump.
1684
- version: "1.32.0",
1748
+ //
1749
+ // 1.33.0: additive (sc-1378) — `useFilestoreUpload({ spaceType, folderId? })`
1750
+ // posts a multipart upload to `ctx.filestore.files.upload` after the
1751
+ // same owner_id resolution the read filestore hooks use (tenant for
1752
+ // PROJECT, app user for PERSONAL). Paired with the new `<FilePicker>`
1753
+ // primitive — web wraps a hidden DOM `<input type="file">`, native
1754
+ // renders a disabled trigger and exposes `FilePicker.isSupported =
1755
+ // false` until a vetted Expo picker pin lands. Backs the Files widget's
1756
+ // new `allowUpload` toggle. No existing hook, primitive, manifest
1757
+ // field, or token changed shape — minor bump.
1758
+ //
1759
+ // 1.34.0: additive (sc-1378 follow-up) — `<FilePicker>` native variant
1760
+ // now wraps the vetted `expo-document-picker` and reports
1761
+ // `FilePicker.isSupported = true` on the Expo export (the build was
1762
+ // already pinning the package in `generatePackageJson`). Closes the
1763
+ // REQ-FW-03 native gap; the Files widget's Upload button now works on
1764
+ // both the web Player and the Expo export. New vetted entry
1765
+ // `expo-document-picker` (`platforms: ["native"]`). `useFilestoreUpload`
1766
+ // accepts both a browser `File` and the React Native `{ uri, name,
1767
+ // type }` shape the native picker returns, so the same hook code path
1768
+ // feeds the multipart in both hosts. No existing hook, primitive, or
1769
+ // manifest field changed shape — minor bump.
1770
+ //
1771
+ // 1.35.0: additive (sc-890) — new `useSendNotification()` hook reading the
1772
+ // newly-injected `ctx.notifications` (@colixsystems/notifications-client).
1773
+ // Returns `{ send, sending, error }`; `send({ recipient_user_id, title,
1774
+ // body, link?, payload? })` POSTs to `/notifications/send` and resolves to
1775
+ // the created notification row, rejecting with a structured
1776
+ // NotificationError. Imperative — never fires on mount. New required field
1777
+ // on the new `notifications` context slice (`send: "function"`) + the new
1778
+ // `notifications.send:appUser` scope it gates on. No existing hook,
1779
+ // primitive, manifest field, or token changed shape — minor bump on the
1780
+ // pre-1.0 channel.
1781
+ version: "1.35.0",
1685
1782
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
1686
1783
  hooks: HOOKS,
1687
1784
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -214,6 +214,27 @@ const HOOKS = [
214
214
  requiredContextSlice: ["filestore.files"],
215
215
  scopes: ["files.read:*"],
216
216
  },
217
+ {
218
+ name: "useFilestoreUpload",
219
+ signature: "useFilestoreUpload({ spaceType, folderId? })",
220
+ description:
221
+ "Upload a file into the end-user's Filestore space. The widget passes " +
222
+ "the SPACE (`{ spaceType, folderId? }`); the hook resolves owner_id " +
223
+ "from the host context, builds a multipart FormData with the " +
224
+ "snake_case fields the backend reads verbatim (`space_type`, " +
225
+ "`owner_id`, `folder_id`, plus the binary `file`), and POSTs through " +
226
+ "ctx.filestore.files.upload. `upload(file, { folderId? })` resolves " +
227
+ "to the created file row or throws the wire error; a 404 means the " +
228
+ "destination folder denied a write (REQ-FSH canWrite gate).",
229
+ returnShape: {
230
+ upload: "(file, { folderId? }) => Promise<FilestoreFile>",
231
+ uploading: "boolean",
232
+ error: "Error | null",
233
+ lastUploaded: "FilestoreFile | null",
234
+ },
235
+ requiredContextSlice: ["filestore.files"],
236
+ scopes: ["files.write:*"],
237
+ },
217
238
  {
218
239
  name: "useFilestoreFolders",
219
240
  signature: "useFilestoreFolders({ spaceType, parentFolderId?, q?, enabled? })",
@@ -399,6 +420,22 @@ const HOOKS = [
399
420
  requiredContextSlice: ["payments.requestPayment"],
400
421
  scopes: ["payments.charge:appUser"],
401
422
  },
423
+ // sc-890 — send an in-app notification to one app user in the tenant.
424
+ // IMPERATIVE: send() never fires on mount; the widget calls it from an
425
+ // event handler. Reads ctx.notifications.send (the injected
426
+ // @colixsystems/notifications-client). Requires the
427
+ // notifications.send:appUser scope.
428
+ {
429
+ name: "useSendNotification",
430
+ signature: "useSendNotification()",
431
+ returnShape: {
432
+ send: "({ recipient_user_id, title, body, link?, payload? }) => Promise<notification> // rejects with NotificationError",
433
+ sending: "boolean",
434
+ error: "NotificationError | null",
435
+ },
436
+ requiredContextSlice: ["notifications.send"],
437
+ scopes: ["notifications.send:appUser"],
438
+ },
402
439
  // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
403
440
  // `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
404
441
  // Reads need `users.read:*` scope; edit-style mutations (invite /
@@ -710,6 +747,19 @@ const PRIMITIVES = [
710
747
  rnComponent: "@react-native-community/datetimepicker",
711
748
  docsUrl: "https://github.com/react-native-datetimepicker/datetimepicker",
712
749
  },
750
+ // sc-1378 — `<FilePicker accept onPick>` lets a widget surface a native
751
+ // file-picker on the web Player so end users can upload files to the
752
+ // backend (paired with useFilestoreUpload or useAssets.upload). Web wraps
753
+ // a hidden DOM `<input type="file">`; native renders a disabled trigger
754
+ // and exposes `FilePicker.isSupported = false` until a vetted Expo
755
+ // picker pin lands.
756
+ {
757
+ name: "FilePicker",
758
+ description:
759
+ 'End-user file picker primitive. `<FilePicker accept="image/*" multiple={false} disabled={false} onPick={(file) => …}>…trigger…</FilePicker>`. Children render as the visible click target (a Pressable + Icon + Text is the common shape) and are wrapped in a hidden-input <label> so the click reaches the file dialog without any ref plumbing. Pair with useFilestoreUpload to upload the picked File to the end-user filestore. `FilePicker.isSupported` is `true` on the web Player and `false` on the native Expo export — widgets should branch on it to hide the trigger when uploads are not yet wired natively.',
760
+ rnComponent: null,
761
+ docsUrl: null,
762
+ },
713
763
  ];
714
764
 
715
765
  const CATEGORIES = [
@@ -992,6 +1042,13 @@ const WIDGET_CONTEXT_SHAPE = {
992
1042
  required: true,
993
1043
  fields: { requestPayment: "function", getPayment: "function" },
994
1044
  },
1045
+ // sc-890 — backs useSendNotification(). Mirror of contract.cjs.
1046
+ notifications: {
1047
+ description:
1048
+ "Injected @colixsystems/notifications-client instance (sc-890). { send(body) -> Promise<notification> }. Backs useSendNotification(); requires the notifications.send:appUser scope. The host POSTs the body snake_case verbatim ({ recipient_user_id, title, body, link?, payload? }) to /notifications/send and returns the created notification row; the call is imperative (a send never fires on mount).",
1049
+ required: true,
1050
+ fields: { send: "function" },
1051
+ },
995
1052
  // REQ-WSDK-DOMAIN-CLIENTS — the AppUser administration, AppUserGroup
996
1053
  // administration, and per-record VirtualPermission facades that used to
997
1054
  // live here (`users`, `groups`, `recordPermissions`) were folded into the
@@ -1169,6 +1226,13 @@ const VETTED_IMPORTS = [
1169
1226
  description:
1170
1227
  "Canvas-style 2D/GPU drawing & animation (games, custom graphics) on native. Native-only — author it in widget.native.jsx and pair it with a web variant in widget.web.jsx (a <canvas> or react-native-svg). There is no Skia web build wired into the Player.",
1171
1228
  },
1229
+ {
1230
+ specifier: "expo-document-picker",
1231
+ platforms: ["native"],
1232
+ category: "files",
1233
+ description:
1234
+ "Native file picker used by the SDK's <FilePicker> primitive on the Expo export (web uses a hidden DOM <input type=\"file\">, mapped to the same { accept, onPick } contract). Most widgets reach <FilePicker> through the SDK rather than importing this directly; a widget that does import it goes in widget.native.jsx and is pinned by the compiler's generatePackageJson — Expo SDK 56 ships 56.0.x.",
1235
+ },
1172
1236
  {
1173
1237
  specifier: "lucide-react-native",
1174
1238
  platforms: ["web", "native"],
@@ -1681,7 +1745,40 @@ const CONTRACT = deepFreeze({
1681
1745
  // instance — one StyleSheet/context, no double-instance conflicts. The
1682
1746
  // vetted-import SET is unchanged (react-native was already vetted); only
1683
1747
  // its web resolution + description changed — minor bump.
1684
- version: "1.32.0",
1748
+ //
1749
+ // 1.33.0: additive (sc-1378) — `useFilestoreUpload({ spaceType, folderId? })`
1750
+ // posts a multipart upload to `ctx.filestore.files.upload` after the
1751
+ // same owner_id resolution the read filestore hooks use (tenant for
1752
+ // PROJECT, app user for PERSONAL). Paired with the new `<FilePicker>`
1753
+ // primitive — web wraps a hidden DOM `<input type="file">`, native
1754
+ // renders a disabled trigger and exposes `FilePicker.isSupported =
1755
+ // false` until a vetted Expo picker pin lands. Backs the Files widget's
1756
+ // new `allowUpload` toggle. No existing hook, primitive, manifest
1757
+ // field, or token changed shape — minor bump.
1758
+ //
1759
+ // 1.34.0: additive (sc-1378 follow-up) — `<FilePicker>` native variant
1760
+ // now wraps the vetted `expo-document-picker` and reports
1761
+ // `FilePicker.isSupported = true` on the Expo export (the build was
1762
+ // already pinning the package in `generatePackageJson`). Closes the
1763
+ // REQ-FW-03 native gap; the Files widget's Upload button now works on
1764
+ // both the web Player and the Expo export. New vetted entry
1765
+ // `expo-document-picker` (`platforms: ["native"]`). `useFilestoreUpload`
1766
+ // accepts both a browser `File` and the React Native `{ uri, name,
1767
+ // type }` shape the native picker returns, so the same hook code path
1768
+ // feeds the multipart in both hosts. No existing hook, primitive, or
1769
+ // manifest field changed shape — minor bump.
1770
+ //
1771
+ // 1.35.0: additive (sc-890) — new `useSendNotification()` hook reading the
1772
+ // newly-injected `ctx.notifications` (@colixsystems/notifications-client).
1773
+ // Returns `{ send, sending, error }`; `send({ recipient_user_id, title,
1774
+ // body, link?, payload? })` POSTs to `/notifications/send` and resolves to
1775
+ // the created notification row, rejecting with a structured
1776
+ // NotificationError. Imperative — never fires on mount. New required field
1777
+ // on the new `notifications` context slice (`send: "function"`) + the new
1778
+ // `notifications.send:appUser` scope it gates on. No existing hook,
1779
+ // primitive, manifest field, or token changed shape — minor bump on the
1780
+ // pre-1.0 channel.
1781
+ version: "1.35.0",
1685
1782
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
1686
1783
  hooks: HOOKS,
1687
1784
  primitives: PRIMITIVES,
@@ -0,0 +1,93 @@
1
+ // sc-1378 — `<FilePicker>` SDK primitive (web implementation).
2
+ //
3
+ // React Native (and react-native-web) has no native equivalent of the
4
+ // browser's `<input type="file">`, so the web build renders the DOM input
5
+ // directly (the same trick the web DateTimePicker uses: `React.createElement
6
+ // ("input", ...)`). The input is visually hidden so authors style the
7
+ // trigger themselves — children are rendered inside a label so any
8
+ // Pressable / View / Text the widget passes in becomes the click target.
9
+ //
10
+ // Public contract (mirror in filepicker.native.js — keep them in lockstep):
11
+ // accept: string | undefined — same value as the DOM `accept`
12
+ // attribute. "image/*" / "image/png,image/jpeg" narrow the
13
+ // picker; omit / "" lets the OS picker show every file type.
14
+ // multiple: boolean — default false. When true, `onPick`
15
+ // fires once with an array of File objects.
16
+ // disabled: boolean — gates the click; renders a muted
17
+ // label.
18
+ // onPick: (file | File[]) => void
19
+ // children: ReactNode — the visible trigger (a Pressable +
20
+ // an icon + a label is the common shape). Wrapped in a
21
+ // `<label>` so the click reaches the hidden input
22
+ // everywhere — no manual ref / `.click()` plumbing needed.
23
+ // accessibilityLabel: string? — written to the wrapper label.
24
+
25
+ import React, { useRef, useCallback } from "react";
26
+
27
+ const HIDDEN_INPUT_STYLE = {
28
+ position: "absolute",
29
+ width: 1,
30
+ height: 1,
31
+ padding: 0,
32
+ margin: -1,
33
+ overflow: "hidden",
34
+ clip: "rect(0, 0, 0, 0)",
35
+ whiteSpace: "nowrap",
36
+ border: 0,
37
+ };
38
+
39
+ const LABEL_STYLE = {
40
+ display: "inline-flex",
41
+ cursor: "pointer",
42
+ };
43
+
44
+ const LABEL_STYLE_DISABLED = {
45
+ ...LABEL_STYLE,
46
+ cursor: "not-allowed",
47
+ opacity: 0.5,
48
+ };
49
+
50
+ export function FilePicker({
51
+ accept,
52
+ multiple = false,
53
+ disabled = false,
54
+ onPick,
55
+ children,
56
+ accessibilityLabel,
57
+ }) {
58
+ const inputRef = useRef(null);
59
+
60
+ const handleChange = useCallback(
61
+ (event) => {
62
+ const list = event && event.target && event.target.files;
63
+ if (!list || list.length === 0) return;
64
+ if (typeof onPick === "function") {
65
+ onPick(multiple ? Array.from(list) : list[0]);
66
+ }
67
+ // Reset the input so picking the SAME file twice in a row still fires
68
+ // onChange — DOM dedupes by value, the SDK contract does not.
69
+ event.target.value = "";
70
+ },
71
+ [onPick, multiple],
72
+ );
73
+
74
+ return React.createElement(
75
+ "label",
76
+ {
77
+ style: disabled ? LABEL_STYLE_DISABLED : LABEL_STYLE,
78
+ "aria-label": accessibilityLabel,
79
+ },
80
+ React.createElement("input", {
81
+ ref: inputRef,
82
+ type: "file",
83
+ accept: accept || undefined,
84
+ multiple,
85
+ disabled,
86
+ onChange: handleChange,
87
+ style: HIDDEN_INPUT_STYLE,
88
+ }),
89
+ children,
90
+ );
91
+ }
92
+
93
+ FilePicker.isSupported = true;
@@ -0,0 +1,100 @@
1
+ // sc-1378 — `<FilePicker>` SDK primitive (native — Expo export).
2
+ //
3
+ // Wraps `expo-document-picker` (a vetted import, native-only; pinned by
4
+ // the compiler's generatePackageJson at expo-document-picker ~56.0.x).
5
+ // The web variant in ./filepicker.js wraps a hidden DOM
6
+ // `<input type="file">` because react-native-web has no equivalent of the
7
+ // system file picker; this native variant calls the OS document picker
8
+ // directly. Both honour the same public contract — `accept` / `multiple`
9
+ // / `disabled` / `onPick` / `children` / `accessibilityLabel`.
10
+ //
11
+ // `onPick` receives the same shape on both platforms:
12
+ // - web → the browser's `File` object (a `Blob` with name + type).
13
+ // - native → `{ uri, name, type, size }` — React Native's
14
+ // FormData-friendly file part. `useFilestoreUpload` appends whichever
15
+ // verbatim into the multipart body; the React Native fetch
16
+ // implementation reads `uri` and streams the bytes.
17
+
18
+ import React, { useCallback, useMemo } from "react";
19
+ import { Pressable, View } from "react-native";
20
+ // eslint-disable-next-line no-restricted-syntax
21
+ import { getDocumentAsync } from "expo-document-picker";
22
+
23
+ // Map an HTML `accept` string (the web contract — "image/*", "audio/*",
24
+ // "image/png,image/jpeg", …) to expo-document-picker's `type` arg, which
25
+ // accepts either a single MIME string or an array of MIME strings. We
26
+ // pass the array form unchanged so a comma-separated list narrows the
27
+ // same way it does on web. An empty / falsy accept maps to "*/*"
28
+ // (DocumentPicker's wide-open default — every file type is offered).
29
+ function _mapAccept(accept) {
30
+ if (!accept || typeof accept !== "string") return "*/*";
31
+ const parts = accept
32
+ .split(",")
33
+ .map((s) => s.trim())
34
+ .filter(Boolean);
35
+ if (parts.length === 0) return "*/*";
36
+ if (parts.length === 1) return parts[0];
37
+ return parts;
38
+ }
39
+
40
+ // expo-document-picker returns `{ canceled, assets: [{ uri, name,
41
+ // mimeType, size }] }`. Normalise each asset to the FormData-friendly
42
+ // `{ uri, name, type, size }` shape useFilestoreUpload appends into the
43
+ // multipart body — `type` is the field React Native's FormData reads
44
+ // for the part's content-type, mirroring the browser File's `.type`.
45
+ function _asset(picked) {
46
+ return {
47
+ uri: picked.uri,
48
+ name: picked.name || "upload",
49
+ type: picked.mimeType || "application/octet-stream",
50
+ size: typeof picked.size === "number" ? picked.size : undefined,
51
+ };
52
+ }
53
+
54
+ export function FilePicker({
55
+ accept,
56
+ multiple = false,
57
+ disabled = false,
58
+ onPick,
59
+ children,
60
+ accessibilityLabel,
61
+ }) {
62
+ const type = useMemo(() => _mapAccept(accept), [accept]);
63
+
64
+ const handlePress = useCallback(async () => {
65
+ if (disabled) return;
66
+ let result;
67
+ try {
68
+ result = await getDocumentAsync({
69
+ type,
70
+ multiple,
71
+ copyToCacheDirectory: true,
72
+ });
73
+ } catch {
74
+ return;
75
+ }
76
+ if (!result || result.canceled) return;
77
+ const assets = Array.isArray(result.assets) ? result.assets : [];
78
+ if (assets.length === 0) return;
79
+ if (typeof onPick !== "function") return;
80
+ if (multiple) {
81
+ onPick(assets.map(_asset));
82
+ } else {
83
+ onPick(_asset(assets[0]));
84
+ }
85
+ }, [disabled, type, multiple, onPick]);
86
+
87
+ return React.createElement(
88
+ Pressable,
89
+ {
90
+ onPress: handlePress,
91
+ disabled,
92
+ accessibilityRole: "button",
93
+ accessibilityLabel,
94
+ accessibilityState: { disabled },
95
+ },
96
+ React.createElement(View, { style: disabled ? { opacity: 0.5 } : undefined }, children),
97
+ );
98
+ }
99
+
100
+ FilePicker.isSupported = true;
package/dist/hooks.js CHANGED
@@ -1364,6 +1364,82 @@ export function useFilestoreFiles(options) {
1364
1364
  return { files, loading, error, refetch };
1365
1365
  }
1366
1366
 
1367
+ /**
1368
+ * sc-1378 — upload a file into the end-user's Filestore space. Returns
1369
+ * `{ upload, uploading, error, lastUploaded }`. The widget passes the
1370
+ * SPACE (`{ spaceType, folderId? }`); the hook resolves `owner_id` the
1371
+ * same way the read hooks do (tenant for PROJECT, app user for PERSONAL)
1372
+ * and posts a multipart FormData to `ctx.filestore.files.upload`.
1373
+ *
1374
+ * `upload(file, { folderId? })` accepts whatever the host's FormData
1375
+ * implementation reads as a binary file part — on the web Player a
1376
+ * browser `File`, on the Expo export the React Native shape
1377
+ * `{ uri, name, type }` returned by `<FilePicker>` (which wraps
1378
+ * `expo-document-picker` natively and a hidden DOM `<input type="file">`
1379
+ * on web). FormData serialises both verbatim, so the same hook code path
1380
+ * feeds the multipart on both platforms.
1381
+ *
1382
+ * A 404 from the backend means the destination folder denied a write —
1383
+ * the widget should surface a friendly "you can't upload here" message
1384
+ * rather than the raw error.
1385
+ */
1386
+ export function useFilestoreUpload(options) {
1387
+ const ctx = useWidgetContextOrThrow("useFilestoreUpload");
1388
+ if (
1389
+ !ctx.filestore ||
1390
+ !ctx.filestore.files ||
1391
+ typeof ctx.filestore.files.upload !== "function"
1392
+ ) {
1393
+ throw new Error(
1394
+ "useFilestoreUpload: host did not inject a filestore client (ctx.filestore.files.upload)",
1395
+ );
1396
+ }
1397
+ const { spaceType = "project", folderId: defaultFolderId = null } = options || {};
1398
+ const ownerId = _filestoreOwnerId(ctx, spaceType);
1399
+
1400
+ const [uploading, setUploading] = useState(false);
1401
+ const [error, setError] = useState(null);
1402
+ const [lastUploaded, setLastUploaded] = useState(null);
1403
+
1404
+ const filesRef = useRef(ctx.filestore.files);
1405
+ filesRef.current = ctx.filestore.files;
1406
+
1407
+ const upload = useCallback(
1408
+ async (file, overrides) => {
1409
+ if (!file) throw new Error("useFilestoreUpload: file is required");
1410
+ if (!ownerId) {
1411
+ const err = new Error("Sign in to upload");
1412
+ setError(err);
1413
+ throw err;
1414
+ }
1415
+ const folderId =
1416
+ overrides && Object.prototype.hasOwnProperty.call(overrides, "folderId")
1417
+ ? overrides.folderId
1418
+ : defaultFolderId;
1419
+ const form = new FormData();
1420
+ form.append("space_type", String(spaceType || "project").toUpperCase());
1421
+ form.append("owner_id", ownerId);
1422
+ if (folderId) form.append("folder_id", folderId);
1423
+ form.append("file", file);
1424
+ setUploading(true);
1425
+ setError(null);
1426
+ try {
1427
+ const created = await filesRef.current.upload(form);
1428
+ setLastUploaded(created || null);
1429
+ setUploading(false);
1430
+ return created;
1431
+ } catch (err) {
1432
+ setError(err);
1433
+ setUploading(false);
1434
+ throw err;
1435
+ }
1436
+ },
1437
+ [ownerId, defaultFolderId, spaceType],
1438
+ );
1439
+
1440
+ return { upload, uploading, error, lastUploaded };
1441
+ }
1442
+
1367
1443
  /**
1368
1444
  * Browse the end-user's Filestore folders. Returns { folders, loading, error,
1369
1445
  * refetch }. Mirrors useFilestoreFiles for subfolder navigation: the widget
@@ -2477,3 +2553,124 @@ export function usePayments() {
2477
2553
  }, []);
2478
2554
  return { requestPayment, getPayment };
2479
2555
  }
2556
+
2557
+ /* ============================================================================
2558
+ * NOTIFICATIONS CLIENT — ctx.notifications (@colixsystems/notifications-client)
2559
+ *
2560
+ * send. Covers: useSendNotification.
2561
+ * ==========================================================================*/
2562
+
2563
+ /**
2564
+ * sc-890 — structured error thrown by `useSendNotification().send`. Carries a
2565
+ * stable `code` so widgets can branch without parsing message strings; mirrors
2566
+ * the shape of `PaymentError` / `DirectoryError`.
2567
+ *
2568
+ * `code` is one of:
2569
+ * - "INVALID_TITLE" / "INVALID_BODY" / "INVALID_RECIPIENT" /
2570
+ * "INVALID_PAYLOAD" / "VALIDATION" — 400 (bad request)
2571
+ * - "AUTH_REQUIRED" — 401 (no signed-in app user)
2572
+ * - "FORBIDDEN" — 403 (widget lacks notifications.send:appUser)
2573
+ * - "RECIPIENT_NOT_FOUND" — 404 (recipient not a member of the tenant)
2574
+ * - "RATE_LIMITED" — 429 (too many sends)
2575
+ * - "INTERNAL" — anything else (network, 5xx)
2576
+ *
2577
+ * The server's stable `code` (when volunteered in the error body) is preserved
2578
+ * over the status-derived one.
2579
+ */
2580
+ export class NotificationError extends Error {
2581
+ constructor(code, message, opts) {
2582
+ super(message);
2583
+ this.name = "NotificationError";
2584
+ this.code = code;
2585
+ if (opts && opts.cause) this.cause = opts.cause;
2586
+ }
2587
+ }
2588
+
2589
+ function toNotificationError(err) {
2590
+ if (err instanceof NotificationError) return err;
2591
+ // The injected notifications-client rejects with its own NotificationError
2592
+ // subclass carrying { code, status }; the host facade may instead throw an
2593
+ // axios-shaped error ({ response: { status, data: { code, error } } }). Read
2594
+ // both shapes so the hook surfaces a stable code either way.
2595
+ const status =
2596
+ err && err.response && typeof err.response.status === "number"
2597
+ ? err.response.status
2598
+ : err && typeof err.status === "number"
2599
+ ? err.status
2600
+ : null;
2601
+ const bodyCode =
2602
+ (err && err.response && err.response.data && err.response.data.code) ||
2603
+ (err && typeof err.code === "string" ? err.code : null);
2604
+ const bodyMessage =
2605
+ err && err.response && err.response.data && err.response.data.error;
2606
+ let code = "INTERNAL";
2607
+ if (typeof bodyCode === "string" && bodyCode) code = bodyCode;
2608
+ else if (status === 401) code = "AUTH_REQUIRED";
2609
+ else if (status === 403) code = "FORBIDDEN";
2610
+ else if (status === 404) code = "RECIPIENT_NOT_FOUND";
2611
+ else if (status === 429) code = "RATE_LIMITED";
2612
+ else if (status === 400) code = "VALIDATION";
2613
+ const message =
2614
+ (typeof bodyMessage === "string" && bodyMessage) ||
2615
+ (err && typeof err.message === "string"
2616
+ ? err.message
2617
+ : "Send notification failed");
2618
+ return new NotificationError(code, message, { cause: err });
2619
+ }
2620
+
2621
+ /**
2622
+ * sc-890 — send an in-app notification to one app user in the tenant. Returns
2623
+ * `{ send, sending, error }`.
2624
+ *
2625
+ * send({ recipient_user_id, title, body, link?, payload? })
2626
+ * → Promise<notification>. Body is snake_case VERBATIM (the wire contract,
2627
+ * REQ-GEN-09) — the SDK does NOT transform it. Resolves to the created
2628
+ * notification row (`{ id, tenant_id, user_id, title, body, link,
2629
+ * payload, read_at, created_at }`) and rejects with a `NotificationError`.
2630
+ *
2631
+ * The hook is IMPERATIVE — it NEVER fires on mount; the widget calls `send`
2632
+ * from an event handler (a button press, a form submit). `sending` tracks an
2633
+ * in-flight call and `error` holds the last `NotificationError` (cleared at the
2634
+ * start of each `send`). Reads the injected
2635
+ * `@colixsystems/notifications-client` at `ctx.notifications`.
2636
+ *
2637
+ * Requires the `notifications.send:appUser` scope in the manifest's
2638
+ * `requestedScopes`. A widget that declares the scope but whose caller is not
2639
+ * permitted receives a `NotificationError { code: "FORBIDDEN" }`.
2640
+ */
2641
+ export function useSendNotification() {
2642
+ const ctx = useWidgetContextOrThrow("useSendNotification");
2643
+ if (!ctx.notifications || typeof ctx.notifications.send !== "function") {
2644
+ throw new Error(
2645
+ "useSendNotification: host did not inject a notifications client. The " +
2646
+ "widget must declare the notifications.send:appUser scope and be " +
2647
+ "installed in a workspace whose host supports notifications.",
2648
+ );
2649
+ }
2650
+ // `ctx` is a fresh identity each host render — hold the send fn in a ref so
2651
+ // the callback stays stable.
2652
+ const sendRef = useRef(ctx.notifications.send);
2653
+ sendRef.current = ctx.notifications.send;
2654
+
2655
+ const [sending, setSending] = useState(false);
2656
+ const [error, setError] = useState(null);
2657
+
2658
+ const send = useCallback(async (body) => {
2659
+ setSending(true);
2660
+ setError(null);
2661
+ try {
2662
+ // body is snake_case verbatim ({ recipient_user_id, title, body, link?,
2663
+ // payload? }) — passed straight through.
2664
+ const row = await sendRef.current(body);
2665
+ setSending(false);
2666
+ return row;
2667
+ } catch (err) {
2668
+ const e = toNotificationError(err);
2669
+ setError(e);
2670
+ setSending(false);
2671
+ throw e;
2672
+ }
2673
+ }, []);
2674
+
2675
+ return { send, sending, error };
2676
+ }
package/dist/index.d.ts CHANGED
@@ -395,6 +395,16 @@ export interface PaymentsClient {
395
395
  getPayment(paymentId: string): Promise<PaymentResult>;
396
396
  }
397
397
 
398
+ /**
399
+ * Structural shape of the injected `@colixsystems/notifications-client`
400
+ * (`ctx.notifications`, sc-890). Backs `useSendNotification`. `send` POSTs the
401
+ * body snake_case verbatim to `/notifications/send` and resolves to the created
402
+ * notification row.
403
+ */
404
+ export interface NotificationsClient {
405
+ send(body: SendNotificationRequest): Promise<NotificationRow>;
406
+ }
407
+
398
408
  export interface WidgetContext<TProps = unknown> {
399
409
  props: TProps;
400
410
  widget: { id: string; instanceId: string; version: string };
@@ -435,6 +445,8 @@ export interface WidgetContext<TProps = unknown> {
435
445
  assets: AssetsClient;
436
446
  /** Injected @colixsystems/payments-client. */
437
447
  payments: PaymentsClient;
448
+ /** Injected @colixsystems/notifications-client; backs useSendNotification. */
449
+ notifications: NotificationsClient;
438
450
  /** Host child-node renderer; backs WidgetTree / useChildRenderer. */
439
451
  renderer: { renderNode(node: unknown): unknown };
440
452
  events: { emit(eventName: string, payload?: unknown): void };
@@ -612,6 +624,52 @@ export interface PaymentsApi {
612
624
  */
613
625
  export function usePayments(): PaymentsApi;
614
626
 
627
+ /**
628
+ * Arguments for `useSendNotification().send(...)`. snake_case VERBATIM — this
629
+ * is the wire contract (REQ-GEN-09). `recipient_user_id`, `title`, and `body`
630
+ * are required; `link` and `payload` are optional.
631
+ */
632
+ export interface SendNotificationRequest {
633
+ recipient_user_id: string;
634
+ title: string;
635
+ body: string;
636
+ link?: string | null;
637
+ payload?: Record<string, unknown> | null;
638
+ }
639
+
640
+ /**
641
+ * A notification row, snake_case verbatim as returned by the backend
642
+ * (`POST /notifications/send`, 201). The shape is open-ended; the well-known
643
+ * fields are listed.
644
+ */
645
+ export interface NotificationRow {
646
+ id: string;
647
+ tenant_id?: string;
648
+ user_id?: string;
649
+ title?: string;
650
+ body?: string;
651
+ link?: string | null;
652
+ payload?: Record<string, unknown> | null;
653
+ read_at?: string | null;
654
+ created_at?: string;
655
+ [key: string]: unknown;
656
+ }
657
+
658
+ export interface SendNotificationApi {
659
+ send(body: SendNotificationRequest): Promise<NotificationRow>;
660
+ sending: boolean;
661
+ error: NotificationError | null;
662
+ }
663
+
664
+ /**
665
+ * sc-890 — send an in-app notification to one app user in the tenant. Returns
666
+ * `{ send, sending, error }`. The hook is imperative — `send` never fires on
667
+ * mount; the widget calls it from an event handler. Rejects with a
668
+ * `NotificationError`. Requires the `notifications.send:appUser` scope in the
669
+ * widget manifest.
670
+ */
671
+ export function useSendNotification(): SendNotificationApi;
672
+
615
673
  export function useTheme(): ThemeTokens;
616
674
 
617
675
  /**
@@ -905,6 +963,30 @@ export class DirectoryError extends Error {
905
963
  );
906
964
  }
907
965
 
966
+ /**
967
+ * sc-890 — error class thrown by `useSendNotification().send`. The `code` is a
968
+ * stable categorisation widgets can branch on.
969
+ */
970
+ export class NotificationError extends Error {
971
+ code:
972
+ | "INVALID_TITLE"
973
+ | "INVALID_BODY"
974
+ | "INVALID_RECIPIENT"
975
+ | "INVALID_PAYLOAD"
976
+ | "VALIDATION"
977
+ | "AUTH_REQUIRED"
978
+ | "FORBIDDEN"
979
+ | "RECIPIENT_NOT_FOUND"
980
+ | "RATE_LIMITED"
981
+ | "INTERNAL"
982
+ | string;
983
+ constructor(
984
+ code: NotificationError["code"],
985
+ message: string,
986
+ opts?: { cause?: unknown },
987
+ );
988
+ }
989
+
908
990
  // --------------------------------------------------------------- useUsers
909
991
  //
910
992
  // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration hook.
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ export {
9
9
  WidgetContextProvider,
10
10
  DatastoreError,
11
11
  PaymentError,
12
+ NotificationError,
12
13
  DirectoryError,
13
14
  PermissionError,
14
15
  useDatastoreQuery,
@@ -17,6 +18,7 @@ export {
17
18
  useAsset,
18
19
  useAssetsByTag,
19
20
  useFilestoreFiles,
21
+ useFilestoreUpload,
20
22
  useFilestoreFolders,
21
23
  useFileSignature,
22
24
  useFileSignatures,
@@ -31,6 +33,7 @@ export {
31
33
  useDatastoreSubscription,
32
34
  useWidgetEvent,
33
35
  usePayments,
36
+ useSendNotification,
34
37
  useTheme,
35
38
  useWidgetStyle,
36
39
  useI18n,
@@ -61,6 +64,7 @@ export {
61
64
  Linking,
62
65
  Icon,
63
66
  DateTimePicker,
67
+ FilePicker,
64
68
  } from "./primitives.js";
65
69
  export { lintSource, bannedIdentifiers } from "./linter.js";
66
70
  export { CONTRACT, isHookAllowed, requiredContextKeys } from "./contract.js";
@@ -9,6 +9,7 @@ export {
9
9
  WidgetContextProvider,
10
10
  DatastoreError,
11
11
  PaymentError,
12
+ NotificationError,
12
13
  DirectoryError,
13
14
  PermissionError,
14
15
  useDatastoreQuery,
@@ -17,6 +18,7 @@ export {
17
18
  useAsset,
18
19
  useAssetsByTag,
19
20
  useFilestoreFiles,
21
+ useFilestoreUpload,
20
22
  useFilestoreFolders,
21
23
  useFileSignature,
22
24
  useFileSignatures,
@@ -29,6 +31,7 @@ export {
29
31
  useDatastoreSubscription,
30
32
  useWidgetEvent,
31
33
  usePayments,
34
+ useSendNotification,
32
35
  useTheme,
33
36
  useWidgetStyle,
34
37
  useI18n,
@@ -57,6 +60,7 @@ export {
57
60
  Linking,
58
61
  Icon,
59
62
  DateTimePicker,
63
+ FilePicker,
60
64
  } from "./primitives.native.js";
61
65
  export { lintSource, bannedIdentifiers } from "./linter.js";
62
66
  export { CONTRACT, isHookAllowed, requiredContextKeys } from "./contract.js";
package/dist/linter.cjs CHANGED
@@ -240,11 +240,31 @@ function _classifySpecifier(spec) {
240
240
  return "bare";
241
241
  }
242
242
 
243
+ // The package root of a bare specifier: `leaflet/dist/leaflet.css` → "leaflet",
244
+ // `@scope/pkg/sub` → "@scope/pkg". A subpath/asset import of a vetted package
245
+ // belongs to that package, so the root carries the vetting + platforms.
246
+ // Mirror of linter.js.
247
+ function _packageRoot(spec) {
248
+ const parts = spec.split("/");
249
+ // A traversal / empty segment is a non-canonical specifier — never resolve it
250
+ // to a vetted root, or "leaflet/../evil" would pass the gate as "leaflet".
251
+ if (parts.some((p) => p === "" || p === "." || p === "..")) return null;
252
+ return spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
253
+ }
254
+
243
255
  function _importRules(source, manifest) {
244
256
  const findings = [];
245
257
  const allowed = new Map(
246
258
  CONTRACT.vettedImports.map((v) => [v.specifier, v]),
247
259
  );
260
+ // Core-infra specifiers (react-dom, react-dom/client) are host-shimmed on
261
+ // both runtimes but kept off the author-facing vetted list — accept them so
262
+ // a bundle's transitive react-dom import isn't flagged import-not-vetted.
263
+ for (const spec of CONTRACT.allowedBareImports) {
264
+ if (!allowed.has(spec)) {
265
+ allowed.set(spec, { specifier: spec, platforms: ["web", "native"] });
266
+ }
267
+ }
248
268
  const declaredPlatforms =
249
269
  manifest &&
250
270
  Array.isArray(manifest.supportedPlatforms) &&
@@ -279,7 +299,7 @@ function _importRules(source, manifest) {
279
299
  });
280
300
  continue;
281
301
  }
282
- const entry = allowed.get(spec);
302
+ const entry = allowed.get(spec) || allowed.get(_packageRoot(spec));
283
303
  if (!entry) {
284
304
  findings.push({
285
305
  rule: "import-not-vetted",
@@ -574,6 +594,20 @@ function _lucideIconRules(source) {
574
594
  return findings;
575
595
  }
576
596
 
597
+ // Narrow a split-impl widget's manifest to the platform a single bundle file
598
+ // ships to, so `import-platform-mismatch` lints each file against what it
599
+ // actually targets. Mirror of linter.js.
600
+ function narrowManifestForFile(manifest, filename) {
601
+ if (!manifest || typeof manifest !== "object") return manifest;
602
+ if (filename === "widget.web.jsx") {
603
+ return { ...manifest, supportedPlatforms: ["web"] };
604
+ }
605
+ if (filename === "widget.native.jsx") {
606
+ return { ...manifest, supportedPlatforms: ["native"] };
607
+ }
608
+ return manifest;
609
+ }
610
+
577
611
  function lintSource(source, options) {
578
612
  if (typeof source !== "string") {
579
613
  return {
@@ -630,4 +664,4 @@ function lintSource(source, options) {
630
664
  return { ok: !hasErrors, findings };
631
665
  }
632
666
 
633
- module.exports = { lintSource, bannedIdentifiers };
667
+ module.exports = { lintSource, bannedIdentifiers, narrowManifestForFile };
package/dist/linter.js CHANGED
@@ -266,9 +266,29 @@ function _classifySpecifier(spec) {
266
266
  return "bare";
267
267
  }
268
268
 
269
+ // The package root of a bare specifier: `leaflet/dist/leaflet.css` → "leaflet",
270
+ // `@scope/pkg/sub` → "@scope/pkg". A subpath/asset import of a vetted package
271
+ // (its CSS, a marker PNG, react-dom/client) belongs to that package, so the
272
+ // root carries the vetting + platform metadata.
273
+ function _packageRoot(spec) {
274
+ const parts = spec.split("/");
275
+ // A traversal / empty segment is a non-canonical specifier — never resolve it
276
+ // to a vetted root, or "leaflet/../evil" would pass the gate as "leaflet".
277
+ if (parts.some((p) => p === "" || p === "." || p === "..")) return null;
278
+ return spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
279
+ }
280
+
269
281
  function _importRules(source, manifest) {
270
282
  const findings = [];
271
283
  const allowed = new Map(CONTRACT.vettedImports.map((v) => [v.specifier, v]));
284
+ // Core-infra specifiers (react-dom, react-dom/client) are host-shimmed on
285
+ // both runtimes but kept off the author-facing vetted list — accept them so
286
+ // a bundle's transitive react-dom import isn't flagged import-not-vetted.
287
+ for (const spec of CONTRACT.allowedBareImports) {
288
+ if (!allowed.has(spec)) {
289
+ allowed.set(spec, { specifier: spec, platforms: ["web", "native"] });
290
+ }
291
+ }
272
292
  // Track declared `supportedPlatforms` so a widget that claims "web only"
273
293
  // doesn't import a native-only package (and vice versa) without the
274
294
  // marketplace listing being honest about which platforms ship.
@@ -307,8 +327,9 @@ function _importRules(source, manifest) {
307
327
  });
308
328
  continue;
309
329
  }
310
- // Bare specifier — validate against the vetted list.
311
- const entry = allowed.get(spec);
330
+ // Bare specifier — validate against the vetted list. A subpath/asset
331
+ // import (leaflet/dist/leaflet.css) resolves to its vetted package root.
332
+ const entry = allowed.get(spec) || allowed.get(_packageRoot(spec));
312
333
  if (!entry) {
313
334
  findings.push({
314
335
  rule: "import-not-vetted",
@@ -662,6 +683,24 @@ function _lucideIconRules(source) {
662
683
  return findings;
663
684
  }
664
685
 
686
+ /**
687
+ * Narrow a split-impl widget's manifest to the platform a single bundle file
688
+ * ships to, so `import-platform-mismatch` lints each file against what it
689
+ * actually targets. `widget.web.jsx` → ["web"], `widget.native.jsx` →
690
+ * ["native"], cross-platform `widget.jsx` keeps the manifest's union claim.
691
+ * Returns a shallow clone; the original is untouched.
692
+ */
693
+ export function narrowManifestForFile(manifest, filename) {
694
+ if (!manifest || typeof manifest !== "object") return manifest;
695
+ if (filename === "widget.web.jsx") {
696
+ return { ...manifest, supportedPlatforms: ["web"] };
697
+ }
698
+ if (filename === "widget.native.jsx") {
699
+ return { ...manifest, supportedPlatforms: ["native"] };
700
+ }
701
+ return manifest;
702
+ }
703
+
665
704
  export function lintSource(source, options) {
666
705
  if (typeof source !== "string") {
667
706
  return {
@@ -70,3 +70,9 @@ export { Icon } from "./icon.js";
70
70
  // react-native-web mapping. Both implementations honour the same
71
71
  // ISO 8601 value / onChange contract.
72
72
  export { DateTimePicker } from "./datetimepicker.js";
73
+ // sc-1378 — `<FilePicker>` (web). Wraps a hidden DOM `<input type="file">`
74
+ // because react-native-web has no native equivalent. The native build's
75
+ // counterpart (./filepicker.native.js) degrades to a disabled trigger until
76
+ // a vetted expo-document-picker pin lands. `isSupported` is a static
77
+ // boolean widgets branch on to hide the trigger on native.
78
+ export { FilePicker } from "./filepicker.js";
@@ -31,3 +31,8 @@ export { Icon } from "./icon.js";
31
31
  // `./datetimepicker.js`. Both files honour the same ISO 8601 value /
32
32
  // onChange contract.
33
33
  export { DateTimePicker } from "./datetimepicker.native.js";
34
+ // sc-1378 — `<FilePicker>` (native). Web counterpart (./filepicker.js)
35
+ // wraps a hidden DOM `<input type="file">`; native has no portable
36
+ // equivalent yet, so this build renders a disabled trigger and exposes
37
+ // `FilePicker.isSupported = false` for widgets to branch on.
38
+ export { FilePicker } from "./filepicker.native.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.45.1",
3
+ "version": "0.48.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -42,7 +42,7 @@
42
42
  ],
43
43
  "scripts": {
44
44
  "build": "node scripts/build.js",
45
- "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-subscription.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js"
45
+ "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-subscription.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js"
46
46
  },
47
47
  "engines": {
48
48
  "node": ">=18"