@proveanything/smartlinks 1.14.11 → 1.14.13

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.
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 1.14.11 | Generated: 2026-05-19T10:35:51.071Z
3
+ Version: 1.14.13 | Generated: 2026-05-19T11:49:34.090Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -36,6 +36,11 @@ For detailed guides on specific features:
36
36
  - **[Product Facets SDK](PRODUCT_FACETS_SDK.md)** - Admin and public product facet endpoints and TypeScript interfaces
37
37
  - **[Attestations](attestations.md)** - Append-only fact log with cryptographic chain integrity, time-series analytics, and public/owner/admin visibility
38
38
  - **[Auth Kit](auth-kit.md)** - End-user authentication flows (email/password, magic link, OTP, OAuth) for microapps
39
+ - **[Portal Request Login](portal-request-login.md)** - Sub-apps delegate user authentication to the portal; `useSafeAuth().requestLogin` hook and iframe postMessage contract, redirect-bounce handling
40
+ - **[Portal Auth Broadcast](portal-auth-broadcast.md)** - Publishing a custom-flow session to the portal so the header, sibling apps, and SDK all stay in sync
41
+ - **[Portal Request Action](portal-request-action.md)** - Invoking portal built-in actions (`__qrScanner`, `__share`, `__logout`, etc.) from containers, widgets, and iframes
42
+ - **[Portal Back Button](portal-back-button.md)** - `parentPath` contract for hierarchy-aware "up" navigation; `useDeepLinkSync` integration
43
+ - **[Contact Search](contact-search.md)** - Admin contact search: free-text, typeahead, identity/tag/JSONB filters, and pagination
39
44
  - **[App Data Storage](app-data-storage.md)** - User-specific and collection-scoped app data storage
40
45
  - **[Forms](forms.md)** - Platform-managed form definitions, submissions, and schema-driven React form UI
41
46
  - **[App Objects: Cases, Threads & Records](app-objects.md)** - Generic app-scoped building blocks for support cases, discussions, bookings, registrations, and more
@@ -89,7 +94,7 @@ The Smartlinks SDK is organized into the following namespaces:
89
94
  — Identity & Access —
90
95
  - **auth** - Admin authentication and account ops: login/logout, tokens, account info.
91
96
  - **authKit** - End‑user auth flows (email/password, OAuth, phone); profiles and verification.
92
- - **contact** - Manage customer contacts; CRUD, lookup, upsert, erase.
97
+ - **contact** - Manage customer contacts; CRUD, lookup, upsert, erase, and admin search. → [Guide](contact-search.md)
93
98
 
94
99
  — Messaging & Audience —
95
100
  - **comms** - Send notifications (push, email, wallet); templating, severity, delivery status. → [Guide](comms.md)
@@ -65,6 +65,7 @@ The SmartLinks SDK (`@proveanything/smartlinks`) includes comprehensive document
65
65
  | **Executors** | `docs/executor.md` | Building executor bundles for SEO, LLM content, programmatic config |
66
66
  | **Deep Linking** | `docs/deep-link-discovery.md` | URL state management, navigable states, portal menus, AI nav |
67
67
  | **Portal Back Button** | `docs/portal-back-button.md` | Hierarchy-aware "up" navigation inside embedded apps |
68
+ | **Portal Request Action** | `docs/portal-request-action.md` | Triggering portal built-in actions (__qrScanner, __share, __logout, etc.) from sub-apps |
68
69
  | **Interactions** | `docs/interactions.md` | Business events, outcomes, voting, competitions, and journey triggers |
69
70
  | **AI-Native Manifests** | `docs/manifests.md` | `app.manifest.json`, `app.admin.json`, `ai-guide.md` structure |
70
71
  | **App Config Files** | `docs/app-manifest.md` | Full field-by-field reference for both JSON config files |
@@ -74,7 +75,9 @@ The SmartLinks SDK (`@proveanything/smartlinks`) includes comprehensive document
74
75
  | **AI Guide Template** | `docs/ai-guide-template.md` | Template for creating `public/ai-guide.md` — customise per app |
75
76
  | **Forms** | `docs/forms.md` | Form definitions, schema-driven rendering, submission patterns |
76
77
  | **Auth Kit** | `docs/auth-kit.md` | End-user sign-in: email/password, magic links, phone OTP, Google OAuth |
78
+ | **Portal Request Login** | `docs/portal-request-login.md` | How sub-apps ask the portal to authenticate the user; hook and iframe postMessage contracts |
77
79
  | **Portal Auth Broadcast** | `docs/portal-auth-broadcast.md` | Publishing custom auth flows to the portal; syncing sessions across containers and iframes |
80
+ | **Contact Search** | `docs/contact-search.md` | Admin contact search: free-text, typeahead, identity/tag/JSONB filters, and pagination |
78
81
  | **App Records Pattern** | `docs/app-records-pattern.md` | Standard pattern for per-product/facet/variant/batch admin + public widget UIs |
79
82
  | **UI Utils** | `docs/ui-utils.md` | `@proveanything/smartlinks-utils-ui` — React shells, hooks, and primitives for records-based apps |
80
83
 
@@ -1,6 +1,12 @@
1
1
  # Publishing Auth State to the Portal
2
2
 
3
- > **For sub-app authors.** This guide explains how to broadcast authentication state changes to the portal so the header, account UI, and sibling apps stay in sync.
3
+ > **⚠️ Read [`portal-request-login.md`](./portal-request-login.md) first.**
4
+ > The recommended pattern for "I need a logged-in user before I continue"
5
+ > is `requestLogin` — the sub-app asks the portal to run its standard
6
+ > AuthKit flow and awaits a result. This doc covers the *other* case:
7
+ > a sub-app that runs its **own** authentication flow and needs to
8
+ > publish the resulting session up to the portal so the header, account
9
+ > UI, and sibling apps stay in sync.
4
10
 
5
11
  A SmartLinks micro-app may need to run its own custom authentication flow —
6
12
  typical cases: an auction app that calls a bidder API, a competition app
@@ -11,6 +17,7 @@ app pick up the new session.
11
17
 
12
18
  This doc describes the contract the portal framework already implements.
13
19
 
20
+
14
21
  ---
15
22
 
16
23
  ## Container / Widget Apps (Same React Tree)
@@ -52,18 +59,28 @@ function BidButton() {
52
59
 
53
60
  ## Iframe Apps (Cross-Origin)
54
61
 
55
- Iframe apps don't share React context. Post messages directly from the
56
- iframe to its parent the portal's `IframeResponder` listens for these:
62
+ Iframe apps don't share React context with the portal. They publish their
63
+ session by posting framework-recognised messages on `window.parent`. The
64
+ portal's `IframeResponder` listens for these and routes them into the same
65
+ `login` / `logout` calls the built-in `AuthModal` makes.
66
+
67
+ > **Note:** There is no `authKit.publishLogin` / `publishLogout` helper in
68
+ > the SmartLinks SDK today. Use the raw `postMessage` calls below. If a
69
+ > helper ships later it will wrap exactly these payloads.
57
70
 
58
71
  ```ts
59
- // LOGIN
72
+ // LOGIN — after your custom auth flow returns a token + user
60
73
  window.parent.postMessage({
61
74
  _smartlinksIframeMessage: true,
62
75
  type: 'smartlinks:authkit:login',
63
76
  payload: {
64
77
  token: '<bearer>',
65
- user: { uid: 'usr_123', email: 'bidder@example.com', displayName: 'Jane' },
66
- accountData: { /* optional */ },
78
+ user: {
79
+ uid: 'usr_123',
80
+ email: 'bidder@example.com',
81
+ displayName: 'Jane Bidder',
82
+ },
83
+ accountData: { tier: 'gold' }, // optional, free-form
67
84
  },
68
85
  }, '*');
69
86
 
@@ -114,8 +131,9 @@ through the standard portal UI.
114
131
  wiped.
115
132
 
116
133
  ❌ Implementing logout by just clearing your own state. Always call
117
- `useAuth().logout()` (container/widget) or post `smartlinks:authkit:logout`
118
- (iframe) so the whole portal session ends cleanly.
134
+ `useAuth().logout()` (container/widget) or post the
135
+ `smartlinks:authkit:logout` message (iframe) so the whole portal
136
+ session ends cleanly.
119
137
 
120
138
  ---
121
139
 
@@ -1,20 +1,36 @@
1
- # Portal Back Button
1
+ # Portal Back Button — "Up" Navigation Inside Sub-Apps
2
2
 
3
- > **For sub-app authors.** This guide explains how to make the portal's top back button behave like hierarchy-aware "up" navigation inside an embedded app.
3
+ > **For sub-app authors.** Drop this file into the SmartLinks SDK docs (e.g.
4
+ > `docs/portal-back-button.md`) and link it from `routing.md` / `mpa.md` so
5
+ > microapp authors discover it.
4
6
 
5
- When a SmartLinks app is embedded inside the portal shell, the shell can render a top-level back button. By default that button exits the app entirely. That is correct for flat screens, but wrong for apps with internal hierarchy such as lists, detail pages, wizards, or nested checkout flows.
7
+ When a sub-app is embedded by a portal shell, the shell renders a top-level
8
+ back button. By default that button **exits the app entirely** when tapped —
9
+ which is wrong for any app with internal hierarchy (lists → detail, wizards,
10
+ etc.).
6
11
 
7
- This document describes the contract for telling the portal where "up" goes from the current screen so the shell can keep the app mounted and route to the correct parent screen instead of leaving the embed.
12
+ This document describes the contract for telling the portal where "up" goes
13
+ from the current screen, so the top back button performs **hierarchy-aware
14
+ "up" navigation** inside your app instead.
8
15
 
9
- ## Why "up", not browser back
16
+ ---
10
17
 
11
- Browser back replays history. If a user navigates `list -> item A -> list -> item B`, browser back can yo-yo between detail and list screens in a way that feels wrong for an app chrome back button.
18
+ ## Why "up", not "back"
12
19
 
13
- "Up" navigates the content hierarchy instead: `item B -> list -> exit app`. It is single-direction and idempotent. Only your app's router knows that hierarchy, so the portal cannot infer it on its own.
20
+ Browser back replays history. If a user navigates **list item A list
21
+ item B**, browser back yo-yos: `B → list → A → list → ...`. That is not what
22
+ users expect from an "up arrow" in app chrome.
14
23
 
15
- ## The contract
24
+ "Up" navigates the **content hierarchy**: `B → list → exit app`. It is
25
+ single-direction and idempotent. Only your app's router knows the hierarchy,
26
+ so the portal can't infer it — your app must declare it.
16
27
 
17
- When your app posts `smartlinks-route-change` messages, include a `parentPath` field inside `state` for the current screen:
28
+ ---
29
+
30
+ ## The Contract
31
+
32
+ With every `smartlinks-route-change` your app already posts (via
33
+ `useDeepLinkSync` or directly), include a `parentPath` field inside `state`:
18
34
 
19
35
  ```ts
20
36
  window.parent.postMessage({
@@ -22,7 +38,8 @@ window.parent.postMessage({
22
38
  path: '/items/123',
23
39
  context: { collectionId: 'auction-2026', appId: 'auction' },
24
40
  state: {
25
- parentPath: '/items',
41
+ parentPath: '/items', // ← where "up" goes from /items/123
42
+ // ... your other app state
26
43
  },
27
44
  appId: 'auction',
28
45
  }, '*');
@@ -30,43 +47,68 @@ window.parent.postMessage({
30
47
 
31
48
  | `state.parentPath` value | Portal back-button behaviour |
32
49
  | --- | --- |
33
- | `'/items'` (or any string) | The portal posts `smartlinks-navigate` to the iframe with that path and keeps the app mounted. |
34
- | `''`, missing, or omitted | The portal falls back to the default behaviour and exits the app. |
50
+ | `'/items'` (or any string) | Posts `smartlinks-navigate` to the iframe with that path. App stays mounted. |
51
+ | `''` / missing / omitted | Default: clears the active app and returns to the portal homepage. |
52
+
53
+ When the portal sends the up-navigation message, your app must respond by
54
+ navigating to that path. If you use `useDeepLinkSync` with an SDK release
55
+ that includes the inbound `smartlinks-navigate` listener, this is automatic —
56
+ otherwise add a listener (see "Receiving the up-message" below).
35
57
 
36
- The portal only uses `parentPath` when it is present on the current route. Query-string changes, tab switches, and modal state should keep the same parent path.
58
+ ---
37
59
 
38
- ## Recommended pattern
60
+ ## Recommended sub-app pattern (React Router)
39
61
 
40
- Keep the hierarchy mapping close to your route definitions:
62
+ Keep "what's above this route" co-located with your route definitions:
41
63
 
42
64
  ```ts
65
+ // routes.ts
43
66
  const PARENTS: Record<string, (params: any) => string | null> = {
44
- '/': () => null,
45
- '/items': () => null,
46
- '/items/:id': () => '/items',
47
- '/items/:id/bid': ({ id }) => `/items/${id}`,
48
- '/checkout': () => '/items',
67
+ '/': () => null, // home — back exits the app
68
+ '/items': () => null, // list — back exits the app
69
+ '/items/:id': () => '/items', // detail — back goes to list
70
+ '/items/:id/bid': ({ id }) => `/items/${id}`,
71
+ '/checkout': () => '/items',
49
72
  };
50
73
 
51
74
  export function getParentPath(pathname: string): string | null {
52
- // Use your router's path matching here and return the parent route.
53
- return null;
75
+ // matchPath against PARENTS keys return parent or null
76
+ // (use react-router's matchPath in your real implementation)
54
77
  }
55
78
  ```
56
79
 
57
- Then emit `parentPath` whenever the route changes:
80
+ ```tsx
81
+ // App.tsx
82
+ import { useDeepLinkSync } from '@/hooks/useDeepLinkSync';
83
+ import { getParentPath } from './routes';
84
+
85
+ function App() {
86
+ useDeepLinkSync({
87
+ getParentPath: ({ pathname }) => getParentPath(pathname),
88
+ });
89
+ // ...
90
+ }
91
+ ```
92
+
93
+ If your app does not use `useDeepLinkSync`, post the message yourself:
58
94
 
59
95
  ```ts
60
- useDeepLinkSync({
61
- getParentPath: ({ pathname }) => getParentPath(pathname),
62
- });
96
+ useEffect(() => {
97
+ if (window.parent === window) return;
98
+ window.parent.postMessage({
99
+ type: 'smartlinks-route-change',
100
+ path: location.pathname,
101
+ context: { /* ... */ },
102
+ state: { parentPath: getParentPath(location.pathname) ?? '' },
103
+ }, '*');
104
+ }, [location.pathname]);
63
105
  ```
64
106
 
65
- If you do not use `useDeepLinkSync`, post the message yourself and include `parentPath` in `state`.
107
+ ---
66
108
 
67
- ## Receiving the up-message
109
+ ## Receiving the up-message (`smartlinks-navigate`)
68
110
 
69
- When the portal consumes `parentPath`, it posts `smartlinks-navigate` back to the iframe:
111
+ When the portal back button consumes a `parentPath`, it posts:
70
112
 
71
113
  ```ts
72
114
  {
@@ -78,18 +120,68 @@ When the portal consumes `parentPath`, it posts `smartlinks-navigate` back to th
78
120
  }
79
121
  ```
80
122
 
81
- Your app should listen for that message and route accordingly. If your SDK release already includes the inbound `smartlinks-navigate` listener, this can be automatic; otherwise add a small `message` listener and call your router manually.
123
+ Your app must listen for this and route accordingly. A minimal hook:
124
+
125
+ ```ts
126
+ import { useEffect } from 'react';
127
+ import { useNavigate } from 'react-router-dom';
128
+
129
+ export function useParentNavigation() {
130
+ const navigate = useNavigate();
131
+ useEffect(() => {
132
+ const onMessage = (e: MessageEvent) => {
133
+ if (e.data?.type !== 'smartlinks-navigate') return;
134
+ if (typeof e.data.path !== 'string') return;
135
+ navigate(e.data.path);
136
+ };
137
+ window.addEventListener('message', onMessage);
138
+ return () => window.removeEventListener('message', onMessage);
139
+ }, [navigate]);
140
+ }
141
+ ```
142
+
143
+ ---
144
+
145
+ ## Reference: Updated `RouteChangeMessage`
146
+
147
+ `state` is an open `Record<string, string>` — `parentPath` is a recognised
148
+ optional convention; everything else flows through unchanged.
149
+
150
+ ```ts
151
+ interface RouteChangeMessage {
152
+ type: 'smartlinks-route-change';
153
+ path: string;
154
+ context: Record<string, string>;
155
+ state: Record<string, string> & {
156
+ /**
157
+ * Optional. The "up" target from the current screen. When present, the
158
+ * portal's top back button posts smartlinks-navigate with this path
159
+ * instead of exiting the app. Empty / missing → exits the app.
160
+ */
161
+ parentPath?: string;
162
+ };
163
+ appId?: string;
164
+ }
165
+ ```
82
166
 
83
- ## Reference
167
+ No breaking change: existing apps that don't emit `parentPath` keep today's
168
+ behaviour (one tap of the portal back exits the app).
84
169
 
85
- `state` remains a general `Record<string, string>`. `parentPath` is a recognised optional convention, not a breaking protocol change.
170
+ ---
86
171
 
87
172
  ## FAQ
88
173
 
89
- **Do I need to push every query-param change as a navigation step?** No. `parentPath` is hierarchy, not history.
174
+ **Q: Do I need to push every query-param change as a navigation step?**
175
+ No. `parentPath` is hierarchy, not history. A filter toggle, a tab switch,
176
+ or a modal open on the same screen should keep the same `parentPath`.
90
177
 
91
- **What if my up target leaves the app?** Set `parentPath` to `''` or omit it.
178
+ **Q: What if my "up" leaves the app?**
179
+ Set `parentPath` to `''` (or omit it). The portal will exit the app on back.
92
180
 
93
- **Can I disable the portal back button on certain screens?** Not via this protocol. Use the existing `homeScope: 'entry'` portal behaviour if you need the shell back button suppressed.
181
+ **Q: Can I disable the portal back button on certain screens?**
182
+ Not directly via this protocol. Use the existing portal `homeScope: 'entry'`
183
+ mechanism if you need the entire portal back button suppressed.
94
184
 
95
- **What about an in-app back button?** Keep it if you want. The portal back is shell chrome; an in-content back is part of the app UI.
185
+ **Q: What about the in-app back button I already render?**
186
+ Keep it if you want — they're not mutually exclusive. The portal back is
187
+ chrome; an in-content back is content. Many apps will only need one.
@@ -0,0 +1,289 @@
1
+ # Requesting Built-in Portal Actions
2
+
3
+ `requestAction` is the general-purpose sibling of
4
+ [`requestLogin`](./portal-request-login.md). It lets a sub-app
5
+ (container, widget, or iframe) trigger any of the portal's built-in
6
+ capabilities — open the QR scanner, log out, copy the current link,
7
+ navigate to another app — and await a result.
8
+
9
+ Use it whenever the answer to *"what do I want?"* is one of the
10
+ portal's standardized capabilities. The portal owns the implementation;
11
+ your sub-app declares intent.
12
+
13
+ ---
14
+
15
+ ## Why this exists
16
+
17
+ Linkpage-style apps, action grids and CTAs across the platform all need
18
+ the same primitives: scan a QR, take a photo, copy a link, jump to
19
+ another app, log the user out. Without a shared contract every app
20
+ re-implemented these against direct browser APIs, fragmenting UX and
21
+ breaking cross-iframe boundaries. `requestAction` is the contract.
22
+
23
+ ---
24
+
25
+ ## Container / Widget Apps (Same React Tree)
26
+
27
+ ```tsx
28
+ import { usePortalActions } from '@proveanything/portal-framework';
29
+
30
+ function ShareButton() {
31
+ const portal = usePortalActions();
32
+ async function onClick() {
33
+ const result = await portal?.requestAction('__share', {
34
+ title: 'Check this out',
35
+ url: window.location.href,
36
+ });
37
+ // result.status === 'success' | 'cancelled' | 'unavailable' | 'error' | 'triggered'
38
+ }
39
+ return <button onClick={onClick}>Share</button>;
40
+ }
41
+ ```
42
+
43
+ `usePortalActions()` returns `null` when no `ActionRequestProvider` is
44
+ mounted upstream — treat that the same as `status: 'unavailable'`.
45
+
46
+ ### Discovering available actions
47
+
48
+ ```ts
49
+ const ids = portal?.getAvailableActions() ?? [];
50
+ // → ['__refresh','__share','__copyLink','__navigate','__login','__logout',
51
+ // '__account','__qrScanner','__barcodeScanner','__nfcScanner', ...]
52
+ ```
53
+
54
+ Combine with `BUILTIN_ACTION_META` (also exported) for labels/icons in
55
+ your action pickers.
56
+
57
+ ---
58
+
59
+ ## Iframe Apps (Cross-Origin)
60
+
61
+ ```ts
62
+ const requestId = crypto.randomUUID();
63
+
64
+ window.addEventListener('message', function handler(ev) {
65
+ const d = ev.data;
66
+ if (!d || d._smartlinksIframeMessage !== true) return;
67
+ if (d.type !== 'smartlinks:action:result') return;
68
+ if (d.payload?.requestId !== requestId) return;
69
+ window.removeEventListener('message', handler);
70
+
71
+ const { status, value, error } = d.payload;
72
+ // …handle result
73
+ });
74
+
75
+ window.parent.postMessage({
76
+ _smartlinksIframeMessage: true,
77
+ type: 'smartlinks:action:request',
78
+ payload: {
79
+ requestId,
80
+ actionId: '__copyLink',
81
+ params: { url: window.location.href },
82
+ },
83
+ }, '*');
84
+ ```
85
+
86
+ ### Capabilities probe
87
+
88
+ ```ts
89
+ window.parent.postMessage({
90
+ _smartlinksIframeMessage: true,
91
+ type: 'smartlinks:action:capabilities',
92
+ payload: { requestId },
93
+ }, '*');
94
+ // reply: type 'smartlinks:action:capabilities:result'
95
+ // payload: { requestId, actions: string[], meta: Record<id, {label, icon, description, available}> }
96
+ ```
97
+
98
+ Use this to hide actions the host doesn't support (e.g. `__nfcScanner`
99
+ on iOS web).
100
+
101
+ ---
102
+
103
+ ## Built-in actions
104
+
105
+ | Action ID | Kind | Params | Success value |
106
+ |---|---|---|---|
107
+ | `__refresh` | sync | — | — |
108
+ | `__share` | sync | `{ title?, text?, url? }` | `{ method: 'native' \| 'clipboard' }` |
109
+ | `__copyLink` | sync | `{ text?, url? }` | `{ url }` |
110
+ | `__navigate` | sync | `{ appId, deepLink?, deepLinkParams? }` | — |
111
+ | `__login` | sync | `{ reason?, mode?, resolveIfAuthenticated? }` | `{ user, token }` |
112
+ | `__logout` | sync | — | — |
113
+ | `__account` | sync | login params | login → `{ user, token }`; logout → — |
114
+ | `__qrScanner` | overlay | `{ suppressNavigation?: boolean }` | `{ text, format, resolved?: { collectionId, productId?, proofId? } }` |
115
+ | `__barcodeScanner` | overlay | `{ suppressNavigation?: boolean }` | `{ text, format }` |
116
+ | `__nfcScanner` | overlay | `{ suppressNavigation?: boolean }` | `{ serial, payload? }` (Android only) |
117
+ | `__language` | overlay | — | — |
118
+ | `__profile` | page | — | — |
119
+ | `__settings` | page | — | — |
120
+
121
+ > **`__login` vs `requestLogin`:** For the common case of "I just need a
122
+ > logged-in user before continuing", prefer
123
+ > [`requestLogin`](./portal-request-login.md) — it is a typed convenience
124
+ > wrapper around `requestAction('__login', ...)` with a richer result
125
+ > contract. Use `__login` / `__account` via `requestAction` only when you
126
+ > are composing with other built-in actions in the same flow.
127
+
128
+ ---
129
+
130
+ ## App-registered actions
131
+
132
+ Sub-apps may register their own action IDs via `siteInjections.actions`
133
+ on `OrchestratedPortal`. Those IDs become callable through the same
134
+ `requestAction` / postMessage contract as built-ins. Unlike built-ins they
135
+ are not guaranteed to be present on every portal — always probe
136
+ `getAvailableActions()` before using them.
137
+
138
+ ---
139
+
140
+ ## Result shape
141
+
142
+ ```ts
143
+ type ActionRequestResult = {
144
+ status:
145
+ | 'success' // action completed (sync actions may carry a value; capture overlays always do)
146
+ | 'cancelled' // user dismissed the overlay without completing
147
+ | 'unavailable' // unknown action or capability not present on this host
148
+ | 'error'; // action threw
149
+ value?: unknown;
150
+ error?: string;
151
+ requestId?: string; // echoed on postMessage replies
152
+ };
153
+ ```
154
+
155
+ All actions now resolve to one of these four terminal statuses. There is no
156
+ `triggered` intermediate status — capture-mode overlays (QR, barcode, NFC)
157
+ keep the caller's promise pending until the user completes the scan or
158
+ dismisses the overlay.
159
+
160
+ ---
161
+
162
+ ## Scanner actions in detail
163
+
164
+ QR, barcode, and NFC scanner actions open the portal's scanner overlay and
165
+ **await user input** — the promise (or postMessage reply) is held open until
166
+ the user either completes a scan or dismisses the overlay.
167
+
168
+ By default the portal also performs its standard navigation after a
169
+ successful scan (e.g. resolving a SmartLinks proof URL). Pass
170
+ `suppressNavigation: true` when you want the raw value and no portal routing.
171
+
172
+ ```ts
173
+ // Container / widget — get the scanned value, skip portal navigation
174
+ const result = await portal.requestAction('__qrScanner', {
175
+ suppressNavigation: true,
176
+ })
177
+ if (result.status === 'success') {
178
+ const { text, format, resolved } = result.value as QrScanResult
179
+ // text — raw scanned string
180
+ // format — e.g. 'QR_CODE', 'EAN_13'
181
+ // resolved — if the text decoded to a SmartLinks URL:
182
+ // { collectionId, productId?, proofId? }
183
+ }
184
+ if (result.status === 'cancelled') {
185
+ // user closed the scanner without scanning
186
+ }
187
+ ```
188
+
189
+ ```ts
190
+ // Container / widget — let the portal navigate AND get the value
191
+ const result = await portal.requestAction('__qrScanner')
192
+ // portal navigates to the scanned link; sub-app also gets the value
193
+ ```
194
+
195
+ ```ts
196
+ // Iframe — same contract via postMessage
197
+ const requestId = crypto.randomUUID()
198
+
199
+ window.addEventListener('message', function handler(ev) {
200
+ const d = ev.data
201
+ if (!d?._smartlinksIframeMessage) return
202
+ if (d.type !== 'smartlinks:action:result') return
203
+ if (d.payload?.requestId !== requestId) return
204
+ window.removeEventListener('message', handler)
205
+
206
+ if (d.payload.status === 'success') {
207
+ const { text, format, resolved } = d.payload.value
208
+ // use the scan result
209
+ }
210
+ })
211
+
212
+ window.parent.postMessage({
213
+ _smartlinksIframeMessage: true,
214
+ type: 'smartlinks:action:request',
215
+ payload: {
216
+ requestId,
217
+ actionId: '__qrScanner',
218
+ params: { suppressNavigation: true },
219
+ },
220
+ }, '*')
221
+ ```
222
+
223
+ ### Scan value types
224
+
225
+ ```ts
226
+ type QrScanResult = {
227
+ text: string
228
+ format: string // 'QR_CODE', 'EAN_13', etc.
229
+ resolved?: {
230
+ collectionId: string
231
+ productId?: string
232
+ proofId?: string
233
+ }
234
+ }
235
+
236
+ type BarcodeScanResult = {
237
+ text: string
238
+ format: string
239
+ }
240
+
241
+ type NfcScanResult = {
242
+ serial: string
243
+ payload?: string
244
+ }
245
+ ```
246
+
247
+ ---
248
+
249
+ ## Relationship to `LinkTarget`
250
+
251
+ Linkpage-style UIs persist a `LinkTarget` of kind `'action'`:
252
+
253
+ ```ts
254
+ type ActionLinkTarget = {
255
+ kind: 'action';
256
+ actionId: string;
257
+ params?: Record<string, unknown>;
258
+ };
259
+ ```
260
+
261
+ At runtime, resolve it through the portal:
262
+
263
+ ```ts
264
+ await portal?.requestAction(target.actionId, target.params);
265
+ ```
266
+
267
+ Never persist a resolved URL when an action is intended — the action
268
+ contract may change platform-side, the persisted ID won't.
269
+
270
+ ---
271
+
272
+ ## Anti-patterns
273
+
274
+ ❌ Calling `navigator.share` / `clipboard.writeText` directly from a
275
+ sub-app when `__share` / `__copyLink` exist — you lose host-level
276
+ error handling and capability negotiation.
277
+
278
+ ❌ Re-implementing logout by clearing your own state. Always go through
279
+ `__logout` (or `useAuth().logout()` in-tree) so the whole portal
280
+ session ends cleanly.
281
+
282
+ ❌ Hard-coding action IDs the host might not support. Probe
283
+ `getAvailableActions()` or the capabilities postMessage first.
284
+
285
+ ❌ Reusing a single `requestId` across postMessage calls.
286
+
287
+ ❌ Not awaiting the result of capture-mode scanner actions. The promise
288
+ stays pending until the user scans or cancels — fire and forget will
289
+ leak the listener.