@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.
@@ -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.
@@ -0,0 +1,236 @@
1
+ # Requesting Login from the Portal
2
+
3
+ This is the **recommended** way for a micro-app to get a logged-in user. The
4
+ sub-app does not render any auth UI of its own — it asks the portal to run
5
+ its standard AuthKit flow and awaits a result. The portal owns the modal,
6
+ WhatsApp / OAuth / magic-link redirects, session persistence, and the
7
+ header avatar swap.
8
+
9
+ Use `requestLogin` whenever the answer to "what do I want?" is simply
10
+ *"a logged-in user before I continue"*. Only fall back to
11
+ [publishing your own session](./portal-auth-broadcast.md) when the app runs
12
+ a genuinely custom auth flow (e.g. its own bidder API).
13
+
14
+ ---
15
+
16
+ ## Why this exists
17
+
18
+ Historically every sub-app imported `@proveanything/smartlinks-auth-ui` and
19
+ rendered its own login modal. That meant:
20
+
21
+ - Two React copies of `AuthContext` could exist at once → silent session
22
+ desync between the header and the sub-app.
23
+ - Each app re-implemented the same WhatsApp / OAuth / magic-link flow.
24
+ - Redirect bounces would land back on the portal with no memory of which
25
+ sub-app had triggered the login.
26
+
27
+ The portal now owns the entire flow. Sub-apps just declare intent.
28
+
29
+ ---
30
+
31
+ ## Container / Widget Apps (Same React Tree)
32
+
33
+ These share the portal's React context. Use `useSafeAuth()` from the
34
+ portal framework:
35
+
36
+ ```tsx
37
+ import { useSafeAuth } from '@proveanything/portal-framework';
38
+
39
+ function PlaceBidButton() {
40
+ const auth = useSafeAuth();
41
+
42
+ async function onClick() {
43
+ const result = await auth?.requestLogin?.({ reason: 'bid' });
44
+ if (result?.status === 'success' || result?.status === 'already-authenticated') {
45
+ // auth.user / auth.token are now populated.
46
+ await api.placeBid(...);
47
+ }
48
+ // 'cancelled' → user dismissed the modal.
49
+ // 'unavailable' → portal has no AuthKit configured.
50
+ }
51
+
52
+ return <button onClick={onClick}>Place bid</button>;
53
+ }
54
+ ```
55
+
56
+ `requestLogin` is also exposed as `useAuthRequest().requestLogin` if you
57
+ prefer a dedicated hook, but the folded `useSafeAuth()` shape is preferred
58
+ so callers have one entry point for both session reads and login intent.
59
+
60
+ ### Options
61
+
62
+ ```ts
63
+ auth.requestLogin({
64
+ reason?: string, // analytics / copy hint
65
+ mode?: 'login' | 'signup' | 'either', // default 'either'
66
+ signupProminence?: SignupProminence, // overrides modal default
67
+ resolveIfAuthenticated?: boolean, // default true
68
+ });
69
+ ```
70
+
71
+ | Option | Default | Effect |
72
+ |---|---|---|
73
+ | `reason` | — | Free-form tag. Surfaced to analytics; some templates use it for copy. |
74
+ | `mode` | `'either'` | `'signup'` biases the modal to the registration tab. |
75
+ | `signupProminence` | portal default | One of `'minimal' | 'balanced' | 'emphasized'`. |
76
+ | `resolveIfAuthenticated` | `true` | When already logged in, resolve immediately without showing the modal. Set `false` to force a fresh prompt (e.g. re-auth before a sensitive action). |
77
+
78
+ ### Result
79
+
80
+ ```ts
81
+ type LoginRequestResult = {
82
+ status: 'success' | 'already-authenticated' | 'cancelled' | 'unavailable';
83
+ user?: PortalAuthBridgeUser | null;
84
+ token?: string | null;
85
+ };
86
+ ```
87
+
88
+ - `success` — fresh login completed inside the modal.
89
+ - `already-authenticated` — there was already a session; nothing was shown.
90
+ - `cancelled` — user dismissed the modal without logging in.
91
+ - `unavailable` — no AuthKit is configured on this portal. Treat as
92
+ not-logged-in and surface an appropriate message; the user cannot log
93
+ in from this portal at all.
94
+
95
+ ---
96
+
97
+ ## Iframe Apps (Cross-Origin)
98
+
99
+ Iframe apps don't share React context with the portal. They post a request
100
+ message and listen for a single correlated reply:
101
+
102
+ ```ts
103
+ const requestId = crypto.randomUUID();
104
+
105
+ window.addEventListener('message', function handler(ev) {
106
+ const d = ev.data;
107
+ if (!d || d._smartlinksIframeMessage !== true) return;
108
+ if (d.type !== 'smartlinks:authkit:login-result') return;
109
+ if (d.payload?.requestId !== requestId) return;
110
+ window.removeEventListener('message', handler);
111
+
112
+ const { status, user, token } = d.payload;
113
+ if (status === 'success' || status === 'already-authenticated') {
114
+ // user + token populated — resume the action that needed auth
115
+ placeBid();
116
+ }
117
+ });
118
+
119
+ window.parent.postMessage({
120
+ _smartlinksIframeMessage: true,
121
+ type: 'smartlinks:authkit:request-login',
122
+ payload: {
123
+ requestId,
124
+ reason: 'bid',
125
+ mode: 'either',
126
+ resolveIfAuthenticated: true,
127
+ },
128
+ }, '*');
129
+ ```
130
+
131
+ The portal opens its modal, runs the standard AuthKit flow (including any
132
+ WhatsApp / OAuth / magic-link redirect bounces), then posts exactly one
133
+ `login-result` back with the same `requestId`.
134
+
135
+ ### Message contract
136
+
137
+ **Request — child → parent**
138
+
139
+ ```ts
140
+ {
141
+ _smartlinksIframeMessage: true,
142
+ type: 'smartlinks:authkit:request-login',
143
+ payload: {
144
+ requestId: string, // generate per request; echoed in the reply
145
+ reason?: string,
146
+ mode?: 'login' | 'signup' | 'either',
147
+ signupProminence?: 'minimal' | 'balanced' | 'emphasized',
148
+ resolveIfAuthenticated?: boolean,
149
+ }
150
+ }
151
+ ```
152
+
153
+ **Reply — parent → child**
154
+
155
+ ```ts
156
+ {
157
+ _smartlinksIframeMessage: true,
158
+ type: 'smartlinks:authkit:login-result',
159
+ payload: {
160
+ requestId: string, // matches the request
161
+ status: 'success' | 'already-authenticated' | 'cancelled' | 'unavailable',
162
+ user?: { uid, email?, displayName?, photoURL?, phoneNumber?, accountData? } | null,
163
+ token?: string | null,
164
+ }
165
+ }
166
+ ```
167
+
168
+ Only one reply is sent per `requestId`. Stacking multiple request-logins
169
+ cancels prior pending requests — they receive `status: 'cancelled'`.
170
+
171
+ ---
172
+
173
+ ## Redirect-Bounce Flows (WhatsApp, OAuth, Magic Link)
174
+
175
+ These are handled **inside the portal's AuthModal by AuthKit itself** —
176
+ your iframe stays mounted at the same URL throughout. There is no
177
+ sub-app state restoration to wire up; the modal closes when the redirect
178
+ resolves and the portal posts back your `login-result`.
179
+
180
+ If you have a sub-app that lives on a route the user navigates away from
181
+ during the redirect, snapshot whatever you need into your own
182
+ `sessionStorage` before calling `requestLogin`, the same as for any other
183
+ async UX. The portal does not (yet) snapshot sub-app state on your behalf.
184
+
185
+ ---
186
+
187
+ ## Reading the Current Session
188
+
189
+ You don't need to call `requestLogin` just to read the current session —
190
+ that comes for free:
191
+
192
+ ```tsx
193
+ // Container / widget
194
+ import { useSafeAuth } from '@proveanything/portal-framework';
195
+ const { user, token, isAuthenticated } = useSafeAuth() ?? {};
196
+ ```
197
+
198
+ ```ts
199
+ // Iframe — the IframeResponder caches the parent's user under `cache.user`.
200
+ // Read via the SDK's standard hooks/helpers.
201
+ ```
202
+
203
+ ---
204
+
205
+ ## Anti-Patterns
206
+
207
+ ❌ Sub-apps importing `@proveanything/smartlinks-auth-ui` and rendering
208
+ their own `<SmartlinksAuthUI />` modal. This is what `requestLogin`
209
+ replaces — duplicate React contexts cause silent header desync.
210
+
211
+ ❌ Posting `request-login` and then also opening a local fallback modal.
212
+ Pick one; the portal modal is canonical.
213
+
214
+ ❌ Reusing a single `requestId` across multiple calls. Generate a new ID
215
+ per request so replies can be correlated unambiguously.
216
+
217
+ ❌ Treating `unavailable` as an error to retry. It means the portal has no
218
+ AuthKit configured — surface a "login isn't available here" message.
219
+
220
+ ❌ Setting `resolveIfAuthenticated: false` for normal "gate this action"
221
+ use cases — you'll re-prompt logged-in users for no reason. Only use
222
+ it for genuine step-up auth.
223
+
224
+ ---
225
+
226
+ ## Relationship to `portal-auth-broadcast.md`
227
+
228
+ | Concern | Use |
229
+ |---|---|
230
+ | "Log the user in so I can call my API" | **`requestLogin`** (this doc) |
231
+ | "I ran my own custom auth flow, here's the resulting session" | [`portal-auth-broadcast.md`](./portal-auth-broadcast.md) |
232
+ | "Log the user out of the whole portal" | `useAuth().logout()` (container/widget) or `smartlinks:authkit:logout` postMessage (iframe) |
233
+
234
+ Both flows ultimately drive the same `PortalAuthProvider.login` /
235
+ `logout` calls and produce the same observable side effects (header
236
+ avatar, `useAuth()` re-render, `setBearerToken` on the SDK).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proveanything/smartlinks",
3
- "version": "1.14.11",
3
+ "version": "1.14.13",
4
4
  "description": "Official JavaScript/TypeScript SDK for the Smartlinks API",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",