@colixsystems/widget-sdk 0.77.0 → 0.78.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
@@ -22,6 +22,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
22
22
  | **CORE** | `useUser()` | `{ id, email, displayName, roles, groupIds }` | `ctx.user` (host-built context, **camelCase** — not a wire payload; `id` null when anonymous) — no scope |
23
23
  | **CORE** | `useNavigation()` | `{ goTo, goBack, push, replace, back, currentRoute }` | `ctx.navigation` — no scope (external URLs use the `Linking` primitive) |
24
24
  | **CORE** | `useRouteParams()` | `{ [paramKey]: value }` | `ctx.navigation.currentRoute.params` — no scope. The nav params the previous page passed via `goTo(pageId, params)`; the flat accessor for master→detail (read `recordId` on a detail page). Empty object when none. |
25
+ | **CORE** | `usePageContext()` | `{ params, records }` | `ctx.pageContext` — no scope. The page's DECLARED parameters, resolved once by the host: `params` are coerced to their declared types, `records` holds the row already fetched for each `record` param (read it instead of fetching again). Both empty when the page declares none. |
25
26
  | **CORE** | `useWidgetEvent(name)` | `(payload?) => void` | `ctx.events.emit` — no scope |
26
27
  | **CORE** | `useChildRenderer()` | `{ renderNode(node) }` | `ctx.renderer` — no scope (prefer the `WidgetTree` component) |
27
28
  | **CORE** | `useFill()` | `boolean` | `ctx.fill` — no scope. `true` when the host sized this widget to fill its page-grid tile's reserved height (containers + media fill by default; the author can override per tile). Media-style widgets switch to a `flex: 1` / `height: "100%"` layout; others ignore it. Defaults `false`. |
@@ -45,6 +46,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
45
46
  | **FILESTORE** (`ctx.filestore`) | `usePdfExport({ spaceType, folderId? })` | `{ exportToPdf, exporting, error, lastExported }` | `ctx.filestore.files.exportPdf` — `files.write:*`. `exportToPdf(html, { fileName?, folderId? })` renders the HTML to a PDF server-side and saves it as a file (`application/pdf`); same server-side renderer on web + native. |
46
47
  | **PAYMENTS** (`ctx.payments`) | `usePayments()` | `{ requestPayment, getPayment }` | `ctx.payments.*` — `payments.charge:appUser` |
47
48
  | **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`. |
49
+ | **IDENTIFICATION** (`ctx.identification`) | `useIdentification({ provider?, purpose?, pollIntervalMs? })` | `{ available, status, qr, autoStartToken, message, identity, identificationId, start, refresh, cancel, reset, … }` | `ctx.identification.*` — no scope (the visitor is deliberately NOT signed in). Gate the UI on `available`; `start()` opens the order and the hook polls to completion. `identity` carries `personal_number_masked` + a stable `subject_hash` — never a raw personal number. |
48
50
 
49
51
  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 }`).
50
52
 
@@ -56,6 +58,20 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
56
58
 
57
59
  `v0.77.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**.
58
60
 
61
+ ### What's new in 0.78.0
62
+
63
+ **New `useIdentification()` hook — identify a visitor who is NOT signed in (REQ-IDENT, sc-4313).** A new IDENTIFICATION hook reading a newly-injected `ctx.identification` slice (the new `@colixsystems/identification-client`, constructed by both the web Player and the native Expo export). Returns `{ available, availabilityLoading, status, qr, autoStartToken, message, identity, identificationId, loading, error, start, refresh, cancel, reset }`.
64
+
65
+ It exists to **prove presence and keep the result** — an attestation on a record, a consent line, an identity check before a submit. It creates **no account and no session**: to sign someone *in* use the app's login, to attach BankID to an existing account use `useBankIdLink()`, and to e-sign a file's bytes use `useFileSignature()`.
66
+
67
+ **BankID is the first provider**, and the API is provider-abstracted — a future provider becomes available without a widget change (`options.provider` defaults to `"bankid"`).
68
+
69
+ Gate the UI on `available`: when it is `false` the provider is not configured on the deployment and no QR can ever complete, so render nothing rather than a dead button. `start()` opens an order and **the hook polls it to completion for you** (`pollIntervalMs`, default `1000`; pass `0` to drive `refresh()` yourself), clearing its timer on unmount — a widget renders state instead of owning a loop. Render `qr` with the `Image` primitive and show `message` (a display-ready instruction); `autoStartToken` opens the provider app on the same device.
70
+
71
+ **No raw personal number is reachable from a widget.** On completion `identity` is `{ provider, name, given_name, surname, personal_number_masked, subject_hash, identified_at }` — `personal_number_masked` is `"19900101-****"` and `subject_hash` is stable for the same person, so a returning visitor is recognisable without the number. The full value stays server-side behind a studio-admin endpoint, so it can never end up in page JSON or a datastore column by accident. Write the masked string (and `identificationId`, to trace the proof) into your column.
72
+
73
+ `options.purpose` is a short audit label ("attest", "age_check"), capped at 120 characters. Orders expire five minutes after `start()`. It needs **no manifest scope** and **no `requestedScopes` entry** — requiring one would defeat a flow whose whole point is an anonymous visitor. Rejections surface as a structured `IdentificationError` (new named export) with a stable `.code` (`NOT_CONFIGURED` / `UNKNOWN_PROVIDER` / `NOT_FOUND` / `RATE_LIMITED` / `UNAVAILABLE` / `INTERNAL`). `CONTRACT.version` → `1.52.0`. Additive — one new hook, one new context slice, one new error class, one new client package; no existing export changed signature.
74
+
59
75
  ### What's new in 0.77.0
60
76
 
61
77
  **`ui.group` is a layout hint, not a visibility rule (sc-4176).** 0.75.0 gave `"Basics"` a reserved meaning: Agent Mode's in-preview edit panel rendered only that group and pointed the author at the Builder for the rest. That withheld styling from an author already editing the widget in front of them, so the reserved behaviour is **retired**.
@@ -522,6 +538,7 @@ The "split-implementation + vetted package list" pivot.
522
538
  ### What's new in 0.11.0
523
539
 
524
540
  - **`useNavigation()` is wired.** Returns the host-provided navigation surface `{ goTo, goBack, push, replace, back, currentRoute }` for internal page-to-page navigation. Missing methods degrade to no-ops on the Studio canvas preview. Additive.
541
+ - **`usePageContext()` reads the page's DECLARED parameters.** When a page declares parameters (Page Settings → Parameters), the host resolves them ONCE before any widget renders and fetches each `record` parameter's row for the whole page. `params` holds the values coerced to their declared types (a `number` parameter is a number, not the string `useRouteParams()` returns); `records` maps each `record` parameter to its already-loaded row — read it rather than issuing the same request from every widget. A required parameter that is absent, malformed, or whose record does not resolve never reaches the widget: the host renders one page-level state instead. Both bags are empty on a page that declares nothing, so fall back to `useRouteParams()` for a page you do not control. Additive (v0.77.0).
525
542
  - **`useRouteParams()` reads the nav params.** Returns `currentRoute.params` — the bag a `goTo(pageId, params)` carried to this page. The flat accessor for master→detail: navigate with `goTo(detailPageId, { recordId: row.id })`, then read `const { recordId } = useRouteParams()`. It is an OBJECT — read a param off it, never call it. Empty object when the page was opened without params. Additive (v0.61.0).
526
543
  - **`Linking` primitive re-exported.** `Linking.openURL(url)` opens an external URL with the OS handler — web (`react-native-web`) maps to `window.open` / `location.href`; native hands off to the system. Use this for external URLs; use `useNavigation().goTo(pageId)` for internal pages.
527
544
 
@@ -595,7 +612,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
595
612
 
596
613
  - `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
597
614
  - `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
598
- - `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `usePayments`, `useSendNotification`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useRouteParams`, `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, displayName, roles, groupIds }` (camelCase — the host-built context object, not a wire payload; `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)`). `useRouteParams()` returns the current route's params object (`currentRoute.params`) — the flat master→detail accessor; read a param off it (e.g. `recordId`), never call it. `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).
615
+ - `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `usePayments`, `useSendNotification`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useRouteParams`, `usePageContext`, `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, displayName, roles, groupIds }` (camelCase — the host-built context object, not a wire payload; `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)`). `useRouteParams()` returns the current route's params object (`currentRoute.params`) — the flat master→detail accessor; read a param off it (e.g. `recordId`), never call it. `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).
599
616
  - `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.
600
617
  - `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.
601
618
  - `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
package/dist/contract.cjs CHANGED
@@ -382,6 +382,18 @@ const HOOKS = [
382
382
  requiredContextSlice: ["navigation"],
383
383
  scopes: null,
384
384
  },
385
+ {
386
+ name: "usePageContext",
387
+ signature: "usePageContext()",
388
+ returnShape: {
389
+ params:
390
+ "the page's DECLARED parameters, coerced to their declared types (a number param is a number here, not the string useRouteParams() returns). Empty when the page declares none.",
391
+ records:
392
+ "<paramName> → the row the HOST already fetched for a `record` parameter. Read it instead of fetching the same record again; empty when the page declares none.",
393
+ },
394
+ requiredContextSlice: ["pageContext"],
395
+ scopes: null,
396
+ },
385
397
  {
386
398
  name: "useDatastoreRecord",
387
399
  signature: "useDatastoreRecord(tableId, recordId)",
@@ -834,6 +846,55 @@ const HOOKS = [
834
846
  requiredContextSlice: ["directory.bankid"],
835
847
  scopes: null,
836
848
  },
849
+ // REQ-IDENT (sc-4313) — identify a visitor who is NOT signed in and keep the
850
+ // result. Anonymous by design (no widget scope, no session). Mirror of contract.js.
851
+ {
852
+ name: "useIdentification",
853
+ signature: "useIdentification(options?)",
854
+ description:
855
+ "Identify a visitor who is NOT signed in via the injected " +
856
+ "identification-client at ctx.identification.{available,start,get,cancel}. " +
857
+ "Returns { available, availabilityLoading, status, qr, autoStartToken, " +
858
+ "message, identity, identificationId, loading, error, start, refresh, " +
859
+ "cancel, reset }. USE IT TO PROVE PRESENCE AND KEEP THE RESULT — an " +
860
+ "attestation on a record, a consent line, an identity check before a " +
861
+ "submit. It creates NO account and NO session: to sign someone IN use the " +
862
+ "app's login, to attach BankID to an existing account use useBankIdLink(), " +
863
+ "to e-sign a file use useFileSignature(). On mount it reads provider " +
864
+ "availability; `available` is false when identification can't be used here " +
865
+ "(provider not configured) — render nothing rather than a dead button. " +
866
+ "start() opens an order (status 'pending' + qr, a PNG data-URL to render " +
867
+ "with the Image primitive) and the hook then POLLS to completion itself " +
868
+ "(pollIntervalMs, default 1000; pass 0 to drive refresh() yourself); " +
869
+ "cancel() aborts; reset() clears the flow. autoStartToken opens the " +
870
+ "provider app on the SAME device (bankid:///?autostarttoken=<token>" +
871
+ "&redirect=null). On complete, `identity` is { provider, name, given_name, " +
872
+ "surname, personal_number_masked, subject_hash, identified_at } — there is " +
873
+ "NO raw personal number by design: write personal_number_masked " +
874
+ "(\"19900101-****\") and, if you need to trace the proof, identificationId. " +
875
+ "subject_hash is stable per person so a returning visitor is recognisable " +
876
+ "without the number. `options.purpose` is a short audit label capped at 120 " +
877
+ "chars. Orders expire 5 minutes after start(). No requestedScopes entry needed.",
878
+ returnShape: {
879
+ available: "boolean // false → don't offer the flow",
880
+ availabilityLoading: "boolean",
881
+ status: "'pending' | 'complete' | 'failed' | 'cancelled' | null",
882
+ qr: "string | null // PNG data-URL of the animated QR",
883
+ autoStartToken: "string | null // same-device deeplink token",
884
+ message: "string | null // display-ready instruction for this step",
885
+ identity:
886
+ "{ provider, name, given_name, surname, personal_number_masked, subject_hash, identified_at } | null",
887
+ identificationId: "string | null // store alongside an attestation",
888
+ loading: "boolean",
889
+ error: "IdentificationError | null",
890
+ start: "() => Promise<{ identification_id, status, qr, auto_start_token, expires_at }>",
891
+ refresh: "() => Promise<void>",
892
+ cancel: "() => Promise<void>",
893
+ reset: "() => void",
894
+ },
895
+ requiredContextSlice: ["identification"],
896
+ scopes: null,
897
+ },
837
898
  // REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — per-record VirtualPermission
838
899
  // management for a single record. Mirror of contract.js.
839
900
  {
@@ -1306,6 +1367,16 @@ const WIDGET_CONTEXT_SHAPE = {
1306
1367
  required: true,
1307
1368
  fields: { push: "function", replace: "function", back: "function" },
1308
1369
  },
1370
+ pageContext: {
1371
+ description:
1372
+ "REQ-NAV-05 — the PAGE's resolved parameters. " +
1373
+ "{ params: { <name>: value }, records: { <name>: Record|null } }. " +
1374
+ "`params` are the page's DECLARED parameters coerced to their declared types (a number param is a number, not the string useRouteParams() returns). " +
1375
+ "`records` holds the row the HOST already fetched for each `record` parameter — read it instead of fetching the same record again. " +
1376
+ "Both bags are empty on a page that declares no parameters. Backs usePageContext().",
1377
+ required: true,
1378
+ fields: { params: "object", records: "object" },
1379
+ },
1309
1380
  datastore: {
1310
1381
  description:
1311
1382
  "Injected @colixsystems/datastore-client instance. " +
@@ -1385,6 +1456,13 @@ const WIDGET_CONTEXT_SHAPE = {
1385
1456
  required: true,
1386
1457
  fields: { send: "function" },
1387
1458
  },
1459
+ // REQ-IDENT (sc-4313) — backs useIdentification(). Mirror of contract.js.
1460
+ identification: {
1461
+ description:
1462
+ "Injected @colixsystems/identification-client instance (REQ-IDENT). { available() -> Promise<{ available, providers }>, start(body) -> Promise<order>, get(id) -> Promise<state>, cancel(id) -> Promise<state> }. Backs useIdentification(); no widget scope and no signed-in user required — identifying a NOT-signed-in visitor is the point, so the endpoints are anonymous. Bodies and rows are snake_case verbatim. A completed identification exposes name + personal_number_masked + a stable subject_hash; the RAW personal number is never reachable from a widget (it lives behind a studio-admin endpoint).",
1463
+ required: true,
1464
+ fields: { available: "function", start: "function", get: "function", cancel: "function" },
1465
+ },
1388
1466
  // REQ-WSDK-DOMAIN-CLIENTS — the AppUser administration, AppUserGroup
1389
1467
  // administration, and per-record VirtualPermission facades that used to
1390
1468
  // live here (`users`, `groups`, `recordPermissions`) were folded into the
@@ -2290,7 +2368,7 @@ const CONTRACT = deepFreeze({
2290
2368
  // public endpoint instead, which skips the cache, the metering and the
2291
2369
  // workspace's provider. Publishing the host list here keeps the linter,
2292
2370
  // the Developer guide and the agent prompt reading one source.
2293
- version: "1.51.0",
2371
+ version: "1.52.0",
2294
2372
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2295
2373
  hooks: HOOKS,
2296
2374
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -382,6 +382,18 @@ const HOOKS = [
382
382
  requiredContextSlice: ["navigation"],
383
383
  scopes: null,
384
384
  },
385
+ {
386
+ name: "usePageContext",
387
+ signature: "usePageContext()",
388
+ returnShape: {
389
+ params:
390
+ "the page's DECLARED parameters, coerced to their declared types (a number param is a number here, not the string useRouteParams() returns). Empty when the page declares none.",
391
+ records:
392
+ "<paramName> → the row the HOST already fetched for a `record` parameter. Read it instead of fetching the same record again; empty when the page declares none.",
393
+ },
394
+ requiredContextSlice: ["pageContext"],
395
+ scopes: null,
396
+ },
385
397
  {
386
398
  name: "useDatastoreRecord",
387
399
  signature: "useDatastoreRecord(tableId, recordId)",
@@ -834,6 +846,55 @@ const HOOKS = [
834
846
  requiredContextSlice: ["directory.bankid"],
835
847
  scopes: null,
836
848
  },
849
+ // REQ-IDENT (sc-4313) — identify a visitor who is NOT signed in and keep the
850
+ // result. Anonymous by design (no widget scope, no session). Mirror of contract.cjs.
851
+ {
852
+ name: "useIdentification",
853
+ signature: "useIdentification(options?)",
854
+ description:
855
+ "Identify a visitor who is NOT signed in via the injected " +
856
+ "identification-client at ctx.identification.{available,start,get,cancel}. " +
857
+ "Returns { available, availabilityLoading, status, qr, autoStartToken, " +
858
+ "message, identity, identificationId, loading, error, start, refresh, " +
859
+ "cancel, reset }. USE IT TO PROVE PRESENCE AND KEEP THE RESULT — an " +
860
+ "attestation on a record, a consent line, an identity check before a " +
861
+ "submit. It creates NO account and NO session: to sign someone IN use the " +
862
+ "app's login, to attach BankID to an existing account use useBankIdLink(), " +
863
+ "to e-sign a file use useFileSignature(). On mount it reads provider " +
864
+ "availability; `available` is false when identification can't be used here " +
865
+ "(provider not configured) — render nothing rather than a dead button. " +
866
+ "start() opens an order (status 'pending' + qr, a PNG data-URL to render " +
867
+ "with the Image primitive) and the hook then POLLS to completion itself " +
868
+ "(pollIntervalMs, default 1000; pass 0 to drive refresh() yourself); " +
869
+ "cancel() aborts; reset() clears the flow. autoStartToken opens the " +
870
+ "provider app on the SAME device (bankid:///?autostarttoken=<token>" +
871
+ "&redirect=null). On complete, `identity` is { provider, name, given_name, " +
872
+ "surname, personal_number_masked, subject_hash, identified_at } — there is " +
873
+ "NO raw personal number by design: write personal_number_masked " +
874
+ "(\"19900101-****\") and, if you need to trace the proof, identificationId. " +
875
+ "subject_hash is stable per person so a returning visitor is recognisable " +
876
+ "without the number. `options.purpose` is a short audit label capped at 120 " +
877
+ "chars. Orders expire 5 minutes after start(). No requestedScopes entry needed.",
878
+ returnShape: {
879
+ available: "boolean // false → don't offer the flow",
880
+ availabilityLoading: "boolean",
881
+ status: "'pending' | 'complete' | 'failed' | 'cancelled' | null",
882
+ qr: "string | null // PNG data-URL of the animated QR",
883
+ autoStartToken: "string | null // same-device deeplink token",
884
+ message: "string | null // display-ready instruction for this step",
885
+ identity:
886
+ "{ provider, name, given_name, surname, personal_number_masked, subject_hash, identified_at } | null",
887
+ identificationId: "string | null // store alongside an attestation",
888
+ loading: "boolean",
889
+ error: "IdentificationError | null",
890
+ start: "() => Promise<{ identification_id, status, qr, auto_start_token, expires_at }>",
891
+ refresh: "() => Promise<void>",
892
+ cancel: "() => Promise<void>",
893
+ reset: "() => void",
894
+ },
895
+ requiredContextSlice: ["identification"],
896
+ scopes: null,
897
+ },
837
898
  // REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — per-record VirtualPermission
838
899
  // management for a single record. Mirror of contract.js.
839
900
  {
@@ -1306,6 +1367,16 @@ const WIDGET_CONTEXT_SHAPE = {
1306
1367
  required: true,
1307
1368
  fields: { push: "function", replace: "function", back: "function" },
1308
1369
  },
1370
+ pageContext: {
1371
+ description:
1372
+ "REQ-NAV-05 — the PAGE's resolved parameters. " +
1373
+ "{ params: { <name>: value }, records: { <name>: Record|null } }. " +
1374
+ "`params` are the page's DECLARED parameters coerced to their declared types (a number param is a number, not the string useRouteParams() returns). " +
1375
+ "`records` holds the row the HOST already fetched for each `record` parameter — read it instead of fetching the same record again. " +
1376
+ "Both bags are empty on a page that declares no parameters. Backs usePageContext().",
1377
+ required: true,
1378
+ fields: { params: "object", records: "object" },
1379
+ },
1309
1380
  datastore: {
1310
1381
  description:
1311
1382
  "Injected @colixsystems/datastore-client instance. " +
@@ -1385,6 +1456,13 @@ const WIDGET_CONTEXT_SHAPE = {
1385
1456
  required: true,
1386
1457
  fields: { send: "function" },
1387
1458
  },
1459
+ // REQ-IDENT (sc-4313) — backs useIdentification(). Mirror of contract.cjs.
1460
+ identification: {
1461
+ description:
1462
+ "Injected @colixsystems/identification-client instance (REQ-IDENT). { available() -> Promise<{ available, providers }>, start(body) -> Promise<order>, get(id) -> Promise<state>, cancel(id) -> Promise<state> }. Backs useIdentification(); no widget scope and no signed-in user required — identifying a NOT-signed-in visitor is the point, so the endpoints are anonymous. Bodies and rows are snake_case verbatim. A completed identification exposes name + personal_number_masked + a stable subject_hash; the RAW personal number is never reachable from a widget (it lives behind a studio-admin endpoint).",
1463
+ required: true,
1464
+ fields: { available: "function", start: "function", get: "function", cancel: "function" },
1465
+ },
1388
1466
  // REQ-WSDK-DOMAIN-CLIENTS — the AppUser administration, AppUserGroup
1389
1467
  // administration, and per-record VirtualPermission facades that used to
1390
1468
  // live here (`users`, `groups`, `recordPermissions`) were folded into the
@@ -2290,7 +2368,7 @@ const CONTRACT = deepFreeze({
2290
2368
  // public endpoint instead, which skips the cache, the metering and the
2291
2369
  // workspace's provider. Publishing the host list here keeps the linter,
2292
2370
  // the Developer guide and the agent prompt reading one source.
2293
- version: "1.51.0",
2371
+ version: "1.52.0",
2294
2372
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2295
2373
  hooks: HOOKS,
2296
2374
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -261,6 +261,13 @@ export function useNavigation() {
261
261
  }
262
262
 
263
263
  const EMPTY_PARAMS = Object.freeze({});
264
+ // Stable references so `useEffect([params])` / `[records]` don't re-fire every
265
+ // render on a page that declares no parameters.
266
+ const EMPTY_PAGE_RECORDS = Object.freeze({});
267
+ const EMPTY_PAGE_CONTEXT = Object.freeze({
268
+ params: EMPTY_PARAMS,
269
+ records: EMPTY_PAGE_RECORDS,
270
+ });
264
271
 
265
272
  /**
266
273
  * Returns the current route's navigation params — the bag a `goTo(pageId, params)`
@@ -281,6 +288,35 @@ export function useRouteParams() {
281
288
  return route.params || EMPTY_PARAMS;
282
289
  }
283
290
 
291
+ /**
292
+ * REQ-NAV-05 — the PAGE's resolved context: `{ params, records }`.
293
+ *
294
+ * When the page declares typed parameters (Page Settings → Parameters), the
295
+ * host resolves them ONCE before any widget renders: `params` holds the values
296
+ * coerced to their declared types (a `number` param is a number here, not the
297
+ * "42" string `useRouteParams()` returns), and `records` maps each `record`
298
+ * param to the row the host already fetched. Read the row from `records`
299
+ * instead of fetching it again — that duplicate fetch per widget is exactly
300
+ * what this hook exists to remove:
301
+ *
302
+ * const { records } = usePageContext();
303
+ * const policy = records.policy_id; // already loaded, no request
304
+ *
305
+ * A required parameter that is absent, malformed, or whose record does not
306
+ * resolve never reaches the widget — the host renders one page-level state
307
+ * instead. Both bags are empty on a page that declares nothing and on the
308
+ * Studio canvas, so a widget can always read them; fall back to
309
+ * `useRouteParams()` for a page you do not control.
310
+ */
311
+ export function usePageContext() {
312
+ const ctx = useWidgetContextOrThrow("usePageContext");
313
+ const page = ctx.pageContext || EMPTY_PAGE_CONTEXT;
314
+ return {
315
+ params: page.params || EMPTY_PARAMS,
316
+ records: page.records || EMPTY_PAGE_RECORDS,
317
+ };
318
+ }
319
+
284
320
  /**
285
321
  * Returns { t, locale }. `t(key, fallback)` resolves `{{t:key}}` against
286
322
  * the host's translation table and falls back to `fallback ?? key` when
@@ -3025,6 +3061,273 @@ export function useBankIdLink() {
3025
3061
  };
3026
3062
  }
3027
3063
 
3064
+ /* ============================================================================
3065
+ * IDENTIFICATION CLIENT — ctx.identification
3066
+ * (@colixsystems/identification-client)
3067
+ *
3068
+ * available, start, get, cancel. Covers: useIdentification.
3069
+ * ==========================================================================*/
3070
+
3071
+ /**
3072
+ * Structured error thrown by `useIdentification` callbacks. Carries a stable
3073
+ * `code` so widgets can branch without parsing message strings.
3074
+ *
3075
+ * `code` is one of:
3076
+ * - "NOT_CONFIGURED" — the provider is not set up on this deployment
3077
+ * - "UNKNOWN_PROVIDER"— the requested provider does not exist
3078
+ * - "NOT_FOUND" — no such identification in this workspace
3079
+ * - "RATE_LIMITED" — too many starts; back off and retry
3080
+ * - "UNAVAILABLE" — the provider itself failed. Retryable
3081
+ * - "INTERNAL" — anything else
3082
+ */
3083
+ export class IdentificationError extends Error {
3084
+ constructor(code, message, opts) {
3085
+ super(message);
3086
+ this.name = "IdentificationError";
3087
+ this.code = code;
3088
+ if (opts && opts.cause) this.cause = opts.cause;
3089
+ }
3090
+ }
3091
+
3092
+ function toIdentificationError(err) {
3093
+ if (err instanceof IdentificationError) return err;
3094
+ // The injected client already throws typed errors carrying .code / .status;
3095
+ // fall back to the axios-ish shape the other hooks normalise.
3096
+ const status =
3097
+ (err && typeof err.status === "number" ? err.status : null) ??
3098
+ (err && err.response && typeof err.response.status === "number"
3099
+ ? err.response.status
3100
+ : null);
3101
+ const bodyCode =
3102
+ (err && typeof err.code === "string" ? err.code : null) ||
3103
+ (err && err.response && err.response.data && err.response.data.code);
3104
+ let code = "INTERNAL";
3105
+ if (bodyCode === "IDENTIFICATION_NOT_CONFIGURED") code = "NOT_CONFIGURED";
3106
+ else if (bodyCode === "UNKNOWN_PROVIDER") code = "UNKNOWN_PROVIDER";
3107
+ else if (bodyCode === "IDENTIFICATION_UNAVAILABLE") code = "UNAVAILABLE";
3108
+ else if (bodyCode === "RATE_LIMITED" || status === 429) code = "RATE_LIMITED";
3109
+ else if (status === 404) code = "NOT_FOUND";
3110
+ else if (status === 409) code = "NOT_CONFIGURED";
3111
+ else if (status === 502) code = "UNAVAILABLE";
3112
+ const message =
3113
+ (err && typeof err.message === "string" && err.message) ||
3114
+ "Identification failed";
3115
+ return new IdentificationError(code, message, { cause: err });
3116
+ }
3117
+
3118
+ /**
3119
+ * Identify a visitor who is NOT signed in (REQ-IDENT). Returns
3120
+ * `{ available, availabilityLoading, status, qr, autoStartToken, message,
3121
+ * identity, identificationId, loading, error, start, refresh, cancel, reset }`.
3122
+ *
3123
+ * `start()` opens an order and the hook then POLLS it for you until it is
3124
+ * terminal, clearing the timer on unmount — so a widget renders state rather
3125
+ * than running a loop. `refresh()` polls once by hand (set `pollIntervalMs: 0`
3126
+ * to own the cadence yourself); `cancel()` aborts; `reset()` clears the flow so
3127
+ * the visitor can start over. Reads
3128
+ * `ctx.identification.{available,start,get,cancel}`.
3129
+ *
3130
+ * WHAT THIS IS FOR — proving a real person holding a credential was present, so
3131
+ * the app can KEEP that: an attestation on a record, a consent line, an identity
3132
+ * check before a submit. It creates NO account and NO session. To sign someone
3133
+ * IN with BankID use the app's login; to attach BankID to an existing account
3134
+ * use `useBankIdLink()`; to e-sign a file's bytes use `useFileSignature()`.
3135
+ *
3136
+ * RENDER THE FLOW LIKE THIS. Gate on `available` first — when it is false the
3137
+ * provider is not configured and no QR can ever complete, so show nothing (or an
3138
+ * explanatory line) rather than a dead button. While `status === "pending"`,
3139
+ * render `qr` with the `Image` primitive and show `message` (a display-ready,
3140
+ * localised instruction such as "Enter your security code in the BankID app").
3141
+ * On the SAME device, `autoStartToken` opens the provider app directly —
3142
+ * `bankid:///?autostarttoken=<token>&redirect=null`.
3143
+ *
3144
+ * ON COMPLETION you get `identity`:
3145
+ * `{ provider, name, given_name, surname, personal_number_masked,
3146
+ * subject_hash, identified_at }`
3147
+ * There is deliberately NO raw personal number: `personal_number_masked` is
3148
+ * `"19900101-****"` and `subject_hash` is stable for the same person, so you can
3149
+ * recognise a returning visitor without ever holding the number. Write the
3150
+ * masked string (and `identificationId`, if you want to trace the proof) into
3151
+ * your datastore column — never try to reconstruct the full number.
3152
+ *
3153
+ * `purpose` is a short audit label ("attest", "age_check"), capped at 120 chars
3154
+ * server-side. Orders expire five minutes after `start()`.
3155
+ */
3156
+ export function useIdentification(options) {
3157
+ const ctx = useWidgetContextOrThrow("useIdentification");
3158
+ if (
3159
+ !ctx.identification ||
3160
+ typeof ctx.identification.start !== "function"
3161
+ ) {
3162
+ throw new Error(
3163
+ "useIdentification: host did not inject an identification client (ctx.identification)",
3164
+ );
3165
+ }
3166
+ const apiRef = useRef(ctx.identification);
3167
+ apiRef.current = ctx.identification;
3168
+
3169
+ const opts = options || {};
3170
+ const provider = opts.provider || "bankid";
3171
+ const purpose = opts.purpose;
3172
+ const pollIntervalMs =
3173
+ typeof opts.pollIntervalMs === "number" ? opts.pollIntervalMs : 1000;
3174
+
3175
+ const [available, setAvailable] = useState(false);
3176
+ const [availabilityLoading, setAvailabilityLoading] = useState(true);
3177
+ const [status, setStatus] = useState(null); // null | pending | complete | failed | cancelled
3178
+ const [qr, setQr] = useState(null);
3179
+ const [autoStartToken, setAutoStartToken] = useState(null);
3180
+ const [message, setMessage] = useState(null);
3181
+ const [identity, setIdentity] = useState(null);
3182
+ const [identificationId, setIdentificationId] = useState(null);
3183
+ const [loading, setLoading] = useState(false);
3184
+ const [error, setError] = useState(null);
3185
+
3186
+ const idRef = useRef(null);
3187
+ // Guards every async setState: a flow that resolves after unmount (or after a
3188
+ // reset) must not write into a dead render.
3189
+ const aliveRef = useRef(true);
3190
+ useEffect(() => {
3191
+ aliveRef.current = true;
3192
+ return () => {
3193
+ aliveRef.current = false;
3194
+ };
3195
+ }, []);
3196
+
3197
+ useEffect(() => {
3198
+ let cancelled = false;
3199
+ (async () => {
3200
+ try {
3201
+ const res = await apiRef.current.available();
3202
+ if (cancelled) return;
3203
+ setAvailable(Boolean(res && res.available));
3204
+ } catch {
3205
+ if (cancelled) return;
3206
+ // Availability is a pre-flight nicety, not the flow — a failure here
3207
+ // means "don't offer it", not an error the widget must render.
3208
+ setAvailable(false);
3209
+ } finally {
3210
+ if (!cancelled) setAvailabilityLoading(false);
3211
+ }
3212
+ })();
3213
+ return () => {
3214
+ cancelled = true;
3215
+ };
3216
+ }, []);
3217
+
3218
+ const _applyState = useCallback((res) => {
3219
+ if (!res) return null;
3220
+ if (res.status != null) setStatus(res.status);
3221
+ if (res.qr !== undefined) setQr(res.qr || null);
3222
+ if (res.message !== undefined) setMessage(res.message || null);
3223
+ if (res.identity !== undefined) setIdentity(res.identity || null);
3224
+ return res;
3225
+ }, []);
3226
+
3227
+ const refresh = useCallback(async () => {
3228
+ const id = idRef.current;
3229
+ if (!id) return null;
3230
+ try {
3231
+ const res = await apiRef.current.get(id);
3232
+ if (!aliveRef.current || idRef.current !== id) return res;
3233
+ return _applyState(res);
3234
+ } catch (err) {
3235
+ if (!aliveRef.current || idRef.current !== id) return null;
3236
+ const e = toIdentificationError(err);
3237
+ // A transient provider blip is already retried inside the client, so an
3238
+ // error surfacing here ends the flow rather than spinning forever.
3239
+ setError(e);
3240
+ setStatus("failed");
3241
+ setMessage(e.message);
3242
+ return null;
3243
+ }
3244
+ }, [_applyState]);
3245
+
3246
+ // Poll while the order is pending. Cleared on terminal state and on unmount.
3247
+ useEffect(() => {
3248
+ if (status !== "pending" || pollIntervalMs <= 0) return undefined;
3249
+ const timer = setInterval(() => {
3250
+ refresh();
3251
+ }, pollIntervalMs);
3252
+ return () => clearInterval(timer);
3253
+ }, [status, pollIntervalMs, refresh]);
3254
+
3255
+ const start = useCallback(async () => {
3256
+ setLoading(true);
3257
+ setError(null);
3258
+ setIdentity(null);
3259
+ try {
3260
+ const res = await apiRef.current.start({ provider, purpose });
3261
+ const newId = res && res.identification_id ? res.identification_id : null;
3262
+ idRef.current = newId;
3263
+ if (!aliveRef.current) return res;
3264
+ setIdentificationId(newId);
3265
+ setStatus(res && res.status ? res.status : "pending");
3266
+ setQr(res && res.qr ? res.qr : null);
3267
+ setAutoStartToken(res && res.auto_start_token ? res.auto_start_token : null);
3268
+ setMessage(null);
3269
+ setLoading(false);
3270
+ return res;
3271
+ } catch (err) {
3272
+ const e = toIdentificationError(err);
3273
+ if (aliveRef.current) {
3274
+ setError(e);
3275
+ setStatus("failed");
3276
+ setMessage(e.message);
3277
+ setLoading(false);
3278
+ }
3279
+ throw e;
3280
+ }
3281
+ }, [provider, purpose]);
3282
+
3283
+ const cancel = useCallback(async () => {
3284
+ const id = idRef.current;
3285
+ idRef.current = null;
3286
+ if (aliveRef.current) {
3287
+ setStatus(null);
3288
+ setQr(null);
3289
+ setAutoStartToken(null);
3290
+ setMessage(null);
3291
+ }
3292
+ if (id) {
3293
+ try {
3294
+ await apiRef.current.cancel(id);
3295
+ } catch {
3296
+ // Best-effort — the order may have already expired or completed.
3297
+ }
3298
+ }
3299
+ }, []);
3300
+
3301
+ const reset = useCallback(() => {
3302
+ idRef.current = null;
3303
+ setStatus(null);
3304
+ setQr(null);
3305
+ setAutoStartToken(null);
3306
+ setMessage(null);
3307
+ setIdentity(null);
3308
+ setIdentificationId(null);
3309
+ setError(null);
3310
+ setLoading(false);
3311
+ }, []);
3312
+
3313
+ return {
3314
+ available,
3315
+ availabilityLoading,
3316
+ status,
3317
+ qr,
3318
+ autoStartToken,
3319
+ message,
3320
+ identity,
3321
+ identificationId,
3322
+ loading,
3323
+ error,
3324
+ start,
3325
+ refresh,
3326
+ cancel,
3327
+ reset,
3328
+ };
3329
+ }
3330
+
3028
3331
  /* ============================================================================
3029
3332
  * PAYMENTS CLIENT — ctx.payments (@colixsystems/payments-client)
3030
3333
  *
package/dist/index.d.ts CHANGED
@@ -445,6 +445,59 @@ export interface PaymentsClient {
445
445
  getPayment(paymentId: string): Promise<PaymentResult>;
446
446
  }
447
447
 
448
+ /**
449
+ * A verified person a completed identification resolved to (REQ-IDENT).
450
+ *
451
+ * There is deliberately NO raw personal number: the full value stays
452
+ * server-side. `personal_number_masked` is e.g. `"19900101-****"`, and
453
+ * `subject_hash` is stable for the same person so a returning visitor can be
454
+ * recognised without it.
455
+ */
456
+ export interface Identity {
457
+ provider: string;
458
+ name: string | null;
459
+ given_name: string | null;
460
+ surname: string | null;
461
+ personal_number_masked: string | null;
462
+ subject_hash: string | null;
463
+ identified_at: string | null;
464
+ }
465
+
466
+ /**
467
+ * Structural shape of the injected `@colixsystems/identification-client`
468
+ * (`ctx.identification`, REQ-IDENT). Backs `useIdentification`. Identifies a
469
+ * visitor who is NOT signed in; creates no account and no session.
470
+ */
471
+ export interface IdentificationClient {
472
+ available(): Promise<{
473
+ available: boolean;
474
+ providers: Array<{ provider: string; available: boolean }>;
475
+ }>;
476
+ start(body?: { provider?: string; purpose?: string }): Promise<{
477
+ identification_id: string;
478
+ provider: string;
479
+ purpose: string | null;
480
+ status: "pending";
481
+ auto_start_token: string | null;
482
+ qr: string | null;
483
+ expires_at: string;
484
+ }>;
485
+ get(identificationId: string): Promise<{
486
+ identification_id: string;
487
+ provider: string;
488
+ purpose: string | null;
489
+ status: "pending" | "complete" | "failed" | "cancelled";
490
+ hint_code?: string | null;
491
+ message?: string | null;
492
+ qr?: string | null;
493
+ identity?: Identity;
494
+ }>;
495
+ cancel(identificationId: string): Promise<{
496
+ identification_id: string;
497
+ status: "pending" | "complete" | "failed" | "cancelled";
498
+ }>;
499
+ }
500
+
448
501
  /**
449
502
  * Structural shape of the injected `@colixsystems/notifications-client`
450
503
  * (`ctx.notifications`, sc-890). Backs `useSendNotification`. `send` POSTs the
@@ -497,6 +550,8 @@ export interface WidgetContext<TProps = unknown> {
497
550
  payments: PaymentsClient;
498
551
  /** Injected @colixsystems/notifications-client; backs useSendNotification. */
499
552
  notifications: NotificationsClient;
553
+ /** Injected @colixsystems/identification-client; backs useIdentification. */
554
+ identification: IdentificationClient;
500
555
  /** Host child-node renderer; backs WidgetTree / useChildRenderer. */
501
556
  renderer: { renderNode(node: unknown): unknown };
502
557
  events: { emit(eventName: string, payload?: unknown): void };
@@ -1001,6 +1056,20 @@ export function useNavigation(): {
1001
1056
  */
1002
1057
  export function useRouteParams(): Record<string, unknown>;
1003
1058
 
1059
+ /**
1060
+ * REQ-NAV-05 — the PAGE's resolved context. When the page declares typed
1061
+ * parameters, the host resolves them ONCE before any widget renders: `params`
1062
+ * holds the values coerced to their declared types (a `number` parameter is a
1063
+ * number here, not the string `useRouteParams()` returns), and `records` maps
1064
+ * each `record` parameter to the row the host already fetched — read it instead
1065
+ * of fetching the same record again. Both bags are empty on a page that
1066
+ * declares nothing.
1067
+ */
1068
+ export function usePageContext(): {
1069
+ params: Record<string, unknown>;
1070
+ records: Record<string, Record<string, unknown> | null>;
1071
+ };
1072
+
1004
1073
  /**
1005
1074
  * Static API for external URLs. `openURL(url)` opens a URL with the OS
1006
1075
  * handler (web: react-native-web maps to `window.open` / `location.href`;
@@ -1299,6 +1368,76 @@ export interface BankIdLinkApi {
1299
1368
  */
1300
1369
  export function useBankIdLink(): BankIdLinkApi;
1301
1370
 
1371
+ // ----------------------------------------------------- useIdentification
1372
+ //
1373
+ // REQ-IDENT — identify a visitor who is NOT signed in and keep the result.
1374
+ // Reads the injected identification-client at `ctx.identification`. Anonymous by
1375
+ // design — no requestedScopes entry and no session needed.
1376
+
1377
+ /** Stable machine codes on an IdentificationError. */
1378
+ export type IdentificationErrorCode =
1379
+ | "NOT_CONFIGURED"
1380
+ | "UNKNOWN_PROVIDER"
1381
+ | "NOT_FOUND"
1382
+ | "RATE_LIMITED"
1383
+ | "UNAVAILABLE"
1384
+ | "INTERNAL";
1385
+
1386
+ export class IdentificationError extends Error {
1387
+ code: IdentificationErrorCode;
1388
+ cause?: unknown;
1389
+ }
1390
+
1391
+ export interface UseIdentificationOptions {
1392
+ /** Provider to identify with. Defaults to "bankid". */
1393
+ provider?: string;
1394
+ /** Short audit label ("attest", "age_check"). Capped at 120 chars server-side. */
1395
+ purpose?: string;
1396
+ /** Poll cadence while pending, in ms. Defaults to 1000; 0 disables auto-polling. */
1397
+ pollIntervalMs?: number;
1398
+ }
1399
+
1400
+ export interface IdentificationApi {
1401
+ /** Whether identification can be used here (provider configured + enabled). Gate the UI on this. */
1402
+ available: boolean;
1403
+ availabilityLoading: boolean;
1404
+ /** The active order's state, or null when no order is in flight. */
1405
+ status: "pending" | "complete" | "failed" | "cancelled" | null;
1406
+ /** PNG data-URL of the animated QR while pending (render with the Image primitive). */
1407
+ qr: string | null;
1408
+ /** Same-device deeplink token: `bankid:///?autostarttoken=<token>&redirect=null`. */
1409
+ autoStartToken: string | null;
1410
+ /** Display-ready instruction for the current step. */
1411
+ message: string | null;
1412
+ /** The verified person, set once status is "complete". */
1413
+ identity: Identity | null;
1414
+ /** The order id — store it alongside an attestation to trace back to the proof. */
1415
+ identificationId: string | null;
1416
+ loading: boolean;
1417
+ error: IdentificationError | null;
1418
+ /** Open an order -> sets status "pending" + qr, then polls to completion. */
1419
+ start(): Promise<unknown>;
1420
+ /** Poll the open order once by hand (for pollIntervalMs: 0). */
1421
+ refresh(): Promise<unknown>;
1422
+ /** Abort the in-flight order. */
1423
+ cancel(): Promise<void>;
1424
+ /** Clear the flow so the visitor can start over. */
1425
+ reset(): void;
1426
+ }
1427
+
1428
+ /**
1429
+ * Identify a visitor who is NOT signed in, so the app can keep the result (an
1430
+ * attestation on a record, a consent line, a pre-submit identity check). Polls
1431
+ * the order for you while pending and clears the timer on unmount.
1432
+ *
1433
+ * Creates no account and no session — to sign someone IN use the app's login, to
1434
+ * attach BankID to an existing account use `useBankIdLink()`, and to e-sign a
1435
+ * file use `useFileSignature()`. Hide the affordance when `available` is false.
1436
+ */
1437
+ export function useIdentification(
1438
+ options?: UseIdentificationOptions,
1439
+ ): IdentificationApi;
1440
+
1302
1441
  // ----------------------------------------------------- useRecordPermissions
1303
1442
  //
1304
1443
  // REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — per-record VirtualPermission
package/dist/index.js CHANGED
@@ -31,6 +31,8 @@ export {
31
31
  useUsers,
32
32
  useGroups,
33
33
  useBankIdLink,
34
+ useIdentification,
35
+ IdentificationError,
34
36
  useRecordPermissions,
35
37
  useDatastoreSubscription,
36
38
  useWidgetEvent,
@@ -45,6 +47,7 @@ export {
45
47
  useFill,
46
48
  useNavigation,
47
49
  useRouteParams,
50
+ usePageContext,
48
51
  useChildRenderer,
49
52
  useRefresh,
50
53
  useGeolocation,
@@ -31,6 +31,8 @@ export {
31
31
  useUsers,
32
32
  useGroups,
33
33
  useBankIdLink,
34
+ useIdentification,
35
+ IdentificationError,
34
36
  useRecordPermissions,
35
37
  useDatastoreSubscription,
36
38
  useWidgetEvent,
@@ -45,6 +47,7 @@ export {
45
47
  useFill,
46
48
  useNavigation,
47
49
  useRouteParams,
50
+ usePageContext,
48
51
  useChildRenderer,
49
52
  useRefresh,
50
53
  useGeolocation,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.77.0",
3
+ "version": "0.78.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",
@@ -48,7 +48,7 @@
48
48
  ],
49
49
  "scripts": {
50
50
  "build": "node scripts/build.js",
51
- "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-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js"
51
+ "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-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=18"