@phosra/connect 0.1.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 +313 -0
- package/package.json +56 -0
- package/src/core/controller.ts +186 -0
- package/src/core/index.ts +19 -0
- package/src/core/types.ts +122 -0
- package/src/core/useConnect.ts +68 -0
- package/src/index.ts +4 -0
- package/src/native/ConnectFlow.native.tsx +205 -0
- package/src/native/PhosraConnect.native.tsx +719 -0
- package/src/native/assets.native.tsx +98 -0
- package/src/native/index.ts +24 -0
- package/src/native/openAuthorizeUrl.native.ts +56 -0
- package/src/web/ConnectFlow.tsx +156 -0
- package/src/web/PhosraConnect.tsx +286 -0
- package/src/web/assets.tsx +84 -0
- package/src/web/connect.css +358 -0
- package/src/web/index.ts +10 -0
- package/src/web/openAuthorizeUrl.ts +81 -0
package/README.md
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# @phosra/connect
|
|
2
|
+
|
|
3
|
+
Phosra Connect is a Plaid Link-style embeddable flow that lets a parent authorize safety rules on an enforcement platform (e.g. Snaptr) directly inside a parental-controls app (PCA). It has three layers: a headless `useConnect` hook (in `@phosra/connect/core`) that drives the full ceremony state machine; an unstyled `ConnectFlow` reference component with stable `data-phosra-connect` hooks for custom styling; and a styled `PhosraConnect` drop-in that ships the approved Phosra design out of the box. Both web (React DOM) and React Native are supported via separate entry points. The package ships as TypeScript source and runs entirely inside the PCA app's own trust boundary — there is no Phosra-hosted page or iframe. The component calls only the PCA's own BFF routes, which wrap `@phosra/link` server-side; the browser never talks to Phosra directly and never sees the `endpoint_id_label` (the enforcement handle). The ceremony is router-blind: Phosra verifies signed enforcement receipts but cannot read message content. The safety path is never metered, rate-limited, or blocked.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
npm install @phosra/connect
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Peer dependencies for web: `react ^18 || ^19`, `react-dom ^18 || ^19`.
|
|
14
|
+
|
|
15
|
+
Peer dependencies for React Native (additional): `react-native`, `react-native-svg`, `expo-web-browser`.
|
|
16
|
+
|
|
17
|
+
**This package ships as TypeScript source** (so it compiles into your bundle and runs on your own origin — never a Phosra-hosted script). Your bundler transpiles it:
|
|
18
|
+
|
|
19
|
+
- **React Native / Metro** and **Vite** — works out of the box.
|
|
20
|
+
- **Next.js** — add the package to `transpilePackages`:
|
|
21
|
+
```js
|
|
22
|
+
// next.config.js
|
|
23
|
+
module.exports = { transpilePackages: ['@phosra/connect'] };
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Entry Points
|
|
29
|
+
|
|
30
|
+
| Import | Contents |
|
|
31
|
+
|--------|----------|
|
|
32
|
+
| `@phosra/connect` | `PhosraConnect` + `ConnectFlow` + all core types (web default) |
|
|
33
|
+
| `@phosra/connect/web` | Same, explicit web entry |
|
|
34
|
+
| `@phosra/connect/core` | `useConnect` hook, `createConnectController`, all shared types — no UI |
|
|
35
|
+
| `@phosra/connect/native` | `PhosraConnect` for React Native (needs `react-native-svg`) |
|
|
36
|
+
| `@phosra/connect/connect.css` | Default styles for the web drop-in; import once in your app root |
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Web Usage
|
|
41
|
+
|
|
42
|
+
```tsx
|
|
43
|
+
import '@phosra/connect/connect.css';
|
|
44
|
+
import { PhosraConnect } from '@phosra/connect';
|
|
45
|
+
import type { ConnectTransport } from '@phosra/connect/core';
|
|
46
|
+
|
|
47
|
+
const transport: ConnectTransport = {
|
|
48
|
+
async init(req) {
|
|
49
|
+
const res = await fetch('/api/phosra/connect/init', {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: { 'Content-Type': 'application/json' },
|
|
52
|
+
body: JSON.stringify(req),
|
|
53
|
+
});
|
|
54
|
+
if (!res.ok) throw new Error(await res.text());
|
|
55
|
+
return res.json();
|
|
56
|
+
},
|
|
57
|
+
async complete(req) {
|
|
58
|
+
const res = await fetch('/api/phosra/connect/complete', {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: { 'Content-Type': 'application/json' },
|
|
61
|
+
body: JSON.stringify(req),
|
|
62
|
+
});
|
|
63
|
+
if (!res.ok) throw new Error(await res.text());
|
|
64
|
+
return res.json();
|
|
65
|
+
},
|
|
66
|
+
async bind(req) {
|
|
67
|
+
const res = await fetch('/api/phosra/connect/bind', {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'Content-Type': 'application/json' },
|
|
70
|
+
body: JSON.stringify(req),
|
|
71
|
+
});
|
|
72
|
+
if (!res.ok) throw new Error(await res.text());
|
|
73
|
+
return res.json(); // { grant_id }
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export default function ConnectPage() {
|
|
78
|
+
return (
|
|
79
|
+
<PhosraConnect
|
|
80
|
+
platform={{ did: 'did:ocss:snaptr', name: 'Snaptr' }}
|
|
81
|
+
rules={[
|
|
82
|
+
{ category: 'addictive_pattern_block', label: 'Block addictive feed patterns' },
|
|
83
|
+
{ category: 'dm_restriction', label: 'Restrict direct messages' },
|
|
84
|
+
]}
|
|
85
|
+
grantedScope={['addictive_pattern_block', 'dm_restriction']}
|
|
86
|
+
redirectUri="https://yourapp.com/phosra/callback"
|
|
87
|
+
transport={transport}
|
|
88
|
+
onSuccess={(result) => console.log('connected, grant_id:', result.grant_id)}
|
|
89
|
+
onExit={() => router.back()}
|
|
90
|
+
/>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Optional props
|
|
96
|
+
|
|
97
|
+
| Prop | Type | Default |
|
|
98
|
+
|------|------|---------|
|
|
99
|
+
| `childId` | `string` | — (parent picks from list) |
|
|
100
|
+
| `ageHint` | `'under_13' \| '13_15' \| '16_17'` | — |
|
|
101
|
+
| `openAuthorizeUrl` | `AuthorizeOpener` | Same-origin popup + postMessage |
|
|
102
|
+
| `platformGlyph` | `React.ReactNode` | Generic platform glyph |
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## React Native Usage
|
|
107
|
+
|
|
108
|
+
```tsx
|
|
109
|
+
import { PhosraConnect } from '@phosra/connect/native';
|
|
110
|
+
// transport shape is identical to the web example above.
|
|
111
|
+
|
|
112
|
+
export default function ConnectScreen({ navigation }) {
|
|
113
|
+
return (
|
|
114
|
+
<PhosraConnect
|
|
115
|
+
platform={{ did: 'did:ocss:snaptr', name: 'Snaptr' }}
|
|
116
|
+
rules={[
|
|
117
|
+
{ category: 'addictive_pattern_block', label: 'Block addictive feed patterns' },
|
|
118
|
+
]}
|
|
119
|
+
grantedScope={['addictive_pattern_block']}
|
|
120
|
+
redirectUri="yourapp://phosra/callback"
|
|
121
|
+
transport={transport}
|
|
122
|
+
onSuccess={(result) => navigation.navigate('Success', { grantId: result.grant_id })}
|
|
123
|
+
onExit={() => navigation.goBack()}
|
|
124
|
+
/>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Additional peer dependencies for React Native: `react-native-svg` (for icons) and `expo-web-browser` (for the in-app browser opener). The component injects a native `AuthorizeOpener` automatically when running on React Native; pass `openAuthorizeUrl` to override.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## The BFF Contract
|
|
134
|
+
|
|
135
|
+
**This is the load-bearing section.** The `ConnectTransport` interface maps directly onto three route handlers that your BFF implements. Your BFF wraps `@phosra/link` server-side. The component never talks to Phosra or to the platform directly.
|
|
136
|
+
|
|
137
|
+
The three `@phosra/link` server functions in play:
|
|
138
|
+
|
|
139
|
+
| `@phosra/link` function | Transport call | Role |
|
|
140
|
+
|-------------------------|----------------|------|
|
|
141
|
+
| `initPlatformOAuth` | `transport.init` | PKCE S256 authorize URL + state |
|
|
142
|
+
| `completePlatformOAuth` | `transport.complete` | Code exchange + child-profile list |
|
|
143
|
+
| `bindProfile` + `runConnectCeremony` | `transport.bind` | Bind child, mint consent, provision enforcement endpoint, deliver label to platform |
|
|
144
|
+
|
|
145
|
+
### Shared config (once at startup)
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
// lib/phosra-link.ts
|
|
149
|
+
import type { LinkConfig } from '@phosra/link';
|
|
150
|
+
import { loadSenderKey } from '@openchildsafety/ocss';
|
|
151
|
+
import { pool } from './db'; // your pg Pool
|
|
152
|
+
|
|
153
|
+
export const linkCfg: LinkConfig = {
|
|
154
|
+
censusBaseUrl: process.env.PHOSRA_CENSUS_URL!,
|
|
155
|
+
trustRootXB64Url: process.env.PHOSRA_TRUST_ROOT_X!,
|
|
156
|
+
parentKey: loadSenderKey(process.env.PARENT_SIGNING_KEY_PEM!),
|
|
157
|
+
writerKey: loadSenderKey(process.env.WRITER_SIGNING_KEY_PEM!),
|
|
158
|
+
writerDid: process.env.WRITER_DID!,
|
|
159
|
+
routerDid: process.env.ROUTER_DID!,
|
|
160
|
+
householdSecret: process.env.HOUSEHOLD_SECRET!,
|
|
161
|
+
pool,
|
|
162
|
+
developerOrgId: process.env.PHOSRA_DEVELOPER_ORG_ID, // from your Phosra dashboard
|
|
163
|
+
};
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### `POST /api/phosra/connect/init`
|
|
167
|
+
|
|
168
|
+
Generates a PKCE S256 pair, stores a `pending` platform session, and returns the authorize URL.
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
// app/api/phosra/connect/init/route.ts
|
|
172
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
173
|
+
import { initPlatformOAuth } from '@phosra/link';
|
|
174
|
+
import { linkCfg } from '@/lib/phosra-link';
|
|
175
|
+
import { getServerSession } from '@/lib/auth'; // your parent auth
|
|
176
|
+
|
|
177
|
+
export async function POST(req: NextRequest) {
|
|
178
|
+
const { platformDid, redirectUri, grantedScope, ageHint, childId } = await req.json();
|
|
179
|
+
const session = await getServerSession(req); // authenticated parent session
|
|
180
|
+
|
|
181
|
+
const result = await initPlatformOAuth(linkCfg, {
|
|
182
|
+
platformDid,
|
|
183
|
+
redirectUri,
|
|
184
|
+
parentSessionRef: session.id, // binds state to the authenticated parent (CSRF defense)
|
|
185
|
+
childHint: childId, // optional login_hint for the platform
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Returns: { authorizeUrl: string, state: string, sessionId: string }
|
|
189
|
+
return NextResponse.json(result);
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### `POST /api/phosra/connect/complete`
|
|
194
|
+
|
|
195
|
+
Entirely server-side: verifies `state` (CSRF + session-fixation defense), performs the PKCE code exchange with the platform (code_verifier stays server-side, never reaches the client — GC5), fetches and stores the child-profile list, then discards the access token.
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
// app/api/phosra/connect/complete/route.ts
|
|
199
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
200
|
+
import { completePlatformOAuth } from '@phosra/link';
|
|
201
|
+
import { linkCfg } from '@/lib/phosra-link';
|
|
202
|
+
import { getServerSession } from '@/lib/auth';
|
|
203
|
+
|
|
204
|
+
export async function POST(req: NextRequest) {
|
|
205
|
+
const { code, state, sessionId } = await req.json();
|
|
206
|
+
const session = await getServerSession(req);
|
|
207
|
+
|
|
208
|
+
const result = await completePlatformOAuth(linkCfg, {
|
|
209
|
+
code,
|
|
210
|
+
state,
|
|
211
|
+
parentSessionRef: session.id,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Returns: { sessionId: string, childProfiles: ChildProfile[] }
|
|
215
|
+
// ChildProfile: { id: string, displayName: string, ageHint?: number }
|
|
216
|
+
return NextResponse.json(result);
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### `POST /api/phosra/connect/bind`
|
|
221
|
+
|
|
222
|
+
The ceremony's final leg. `bindProfile` records the parent's confirmed child pick and returns a `LinkSession`. `runConnectCeremony` then calls `completeLink` (mints the consent attestation and provisions the §9.3(b) enforcement endpoint signed by the writer key), then calls your `deliver` closure to POST the `endpoint_id_label` server-to-server to the platform's callback. **The `endpoint_id_label` never reaches the browser. The route returns only `{ grant_id }`.**
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
// app/api/phosra/connect/bind/route.ts
|
|
226
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
227
|
+
import { bindProfile, runConnectCeremony, deliverLabelToPlatform } from '@phosra/link';
|
|
228
|
+
import { linkCfg } from '@/lib/phosra-link';
|
|
229
|
+
|
|
230
|
+
// Your registry mapping a platform DID to its server-side callback URL.
|
|
231
|
+
function resolvePlatformCallbackUrl(platformDid: string): string {
|
|
232
|
+
const registry: Record<string, string> = {
|
|
233
|
+
'did:ocss:snaptr': 'https://api.snaptr.example/ocss/connect-callback',
|
|
234
|
+
};
|
|
235
|
+
const url = registry[platformDid];
|
|
236
|
+
if (!url) throw new Error(`unknown platform: ${platformDid}`);
|
|
237
|
+
return url;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function POST(req: NextRequest) {
|
|
241
|
+
const { sessionId, platformChildProfileId, childId, grantedScope, ageHint } = await req.json();
|
|
242
|
+
|
|
243
|
+
// Leg 1: record the parent's child pick, advance platform session → bound,
|
|
244
|
+
// produce a LinkSession ready for completeLink.
|
|
245
|
+
const linkSession = await bindProfile(linkCfg, {
|
|
246
|
+
sessionId,
|
|
247
|
+
platformChildProfileId,
|
|
248
|
+
childId,
|
|
249
|
+
granted_scope: grantedScope, // note: snake_case in @phosra/link
|
|
250
|
+
ageHint,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const platformCallbackUrl = resolvePlatformCallbackUrl(linkSession.audience_did);
|
|
254
|
+
|
|
255
|
+
// Leg 2: completeLink (consent + endpoint) then deliver the label server-to-server.
|
|
256
|
+
// runConnectCeremony calls completeLink internally; your deliver closure is called
|
|
257
|
+
// with the cleartext endpoint_id_label exactly once.
|
|
258
|
+
const { grant_id } = await runConnectCeremony(
|
|
259
|
+
linkCfg,
|
|
260
|
+
linkSession,
|
|
261
|
+
async (endpoint_id_label) => {
|
|
262
|
+
const r = await deliverLabelToPlatform(platformCallbackUrl, {
|
|
263
|
+
endpoint_id_label,
|
|
264
|
+
state: sessionId, // correlates to the platform's active session
|
|
265
|
+
});
|
|
266
|
+
if (!r.ok) throw new Error(`platform callback failed: ${r.status}`);
|
|
267
|
+
},
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
// endpoint_id_label is consumed by the deliver closure and never returned.
|
|
271
|
+
return NextResponse.json({ grant_id });
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
`grant_id` is the value surfaced to your `onSuccess` callback as `result.grant_id`.
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## Redirect Page (Web)
|
|
280
|
+
|
|
281
|
+
The authorize popup (or tab) lands on the `redirectUri` on **your own origin**. That page reads `code` and `state` from the query string, postMessages them back to the opener, and closes. Nothing Phosra-specific is required here — it is a standard OAuth redirect receiver.
|
|
282
|
+
|
|
283
|
+
```html
|
|
284
|
+
<!-- public/phosra/callback/index.html (or a minimal Next.js page at /phosra/callback) -->
|
|
285
|
+
<script>
|
|
286
|
+
const params = new URLSearchParams(location.search);
|
|
287
|
+
const code = params.get('code');
|
|
288
|
+
const state = params.get('state');
|
|
289
|
+
if (window.opener && code && state) {
|
|
290
|
+
window.opener.postMessage(
|
|
291
|
+
{ type: 'phosra-connect', code, state },
|
|
292
|
+
window.location.origin, // same-origin only
|
|
293
|
+
);
|
|
294
|
+
window.close();
|
|
295
|
+
}
|
|
296
|
+
</script>
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
The component's built-in `createWebAuthorizeOpener` opens the popup and listens for `{ type: 'phosra-connect' }` from the same origin. If you use a mobile deep-link scheme or a custom web-view, pass a custom `openAuthorizeUrl` prop (an `AuthorizeOpener`) to `PhosraConnect` — it receives `(authorizeUrl, redirectUri)` and must resolve to `{ code, state }` or `{ canceled: true }`.
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
## Honesty and Trust Boundary
|
|
304
|
+
|
|
305
|
+
**Never a fake green.** The success screen ("Verified on the OCSS Trust List") is shown only after the census returns a signed write receipt that verifies to the OCSS trust root. The component displays "Enforced" only on a confirmed `success` status from the ceremony — never on a client-side flag or an optimistic assumption.
|
|
306
|
+
|
|
307
|
+
**Router-blind.** Phosra verifies enforcement receipts (what rule applied, to which child, on which platform) but is structurally prevented from reading message content. The §3A.3 harm-context lane is sealed end-to-end; Phosra is on the signal-and-receipt path, not the content path.
|
|
308
|
+
|
|
309
|
+
**Ships as source, runs on your domain.** There is no Phosra-hosted page, iframe, or remotely loaded script. You review the source, you bundle it, it runs inside your application's trust boundary. The component's `transport` calls go to your own BFF routes; no browser request is ever made to Phosra infrastructure directly.
|
|
310
|
+
|
|
311
|
+
**The safety path is never metered or blocked.** `@phosra/link` enforces rule writes unconditionally once a valid grant exists. Billing applies to the grant lifecycle (connecting and managing integrations), not to individual safety-rule evaluations. An expired or suspended billing state cannot prevent a rule from being applied to a child's account.
|
|
312
|
+
|
|
313
|
+
**No hidden failures.** The controller never leaves the parent on a spinner that hides an error: a blocked popup, a rejected webview, or a failed BFF call all surface as an honest `error` state with a retry. The web opener rejects if the browser blocks the popup. Because the `transport` fetches are yours, **give each one an `AbortSignal`/timeout** so a stalled network also fails into the error state rather than spinning forever — the component honors whatever your transport throws.
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@phosra/connect",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Embeddable Phosra Connect component — the Plaid Link for parental-controls apps. Headless hook + unstyled reference + styled drop-in, web and React Native. Ships as source; runs in the PCA app's own trust boundary.",
|
|
7
|
+
"sideEffects": [
|
|
8
|
+
"*.css"
|
|
9
|
+
],
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts",
|
|
12
|
+
"./core": "./src/core/index.ts",
|
|
13
|
+
"./web": "./src/web/index.ts",
|
|
14
|
+
"./native": "./src/native/index.ts",
|
|
15
|
+
"./connect.css": "./src/web/connect.css"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"README.md",
|
|
20
|
+
"!**/*.test.ts",
|
|
21
|
+
"!**/*.test.tsx",
|
|
22
|
+
"!**/tsconfig.json",
|
|
23
|
+
"!**/vitest.config.ts",
|
|
24
|
+
"!src/native/react-native.d.ts"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"test:watch": "vitest",
|
|
32
|
+
"typecheck": "tsc --noEmit && tsc -p src/native/tsconfig.json --noEmit"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"react": "^18 || ^19",
|
|
36
|
+
"react-dom": "^18 || ^19",
|
|
37
|
+
"react-native": "*",
|
|
38
|
+
"react-native-svg": "*"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"react-dom": {
|
|
42
|
+
"optional": true
|
|
43
|
+
},
|
|
44
|
+
"react-native": {
|
|
45
|
+
"optional": true
|
|
46
|
+
},
|
|
47
|
+
"react-native-svg": {
|
|
48
|
+
"optional": true
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"typescript": "^5.7.0",
|
|
53
|
+
"@types/react": "^19",
|
|
54
|
+
"vitest": "^1.6.1"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConnectController,
|
|
3
|
+
ConnectControllerOptions,
|
|
4
|
+
ConnectState,
|
|
5
|
+
ConnectStatus,
|
|
6
|
+
} from './types.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Framework-agnostic state machine for the Phosra Connect ceremony. No React,
|
|
10
|
+
* no DOM, no React Native — the transport (3 BFF calls) and the "open secure
|
|
11
|
+
* webview" primitive are injected, so this single controller drives both the web
|
|
12
|
+
* and the native rendering layers. `useConnect` is a thin React adapter over it.
|
|
13
|
+
*
|
|
14
|
+
* Flow: idle → authorizing (init + webview) → exchanging (PKCE complete) →
|
|
15
|
+
* selecting (child picker) → binding (mint consent + provision endpoint) → success.
|
|
16
|
+
* Any failure lands in `error{stage}`; `retry()` re-enters from that stage.
|
|
17
|
+
*
|
|
18
|
+
* Cancellation is epoch-guarded: every run captures the current `epoch`, and
|
|
19
|
+
* `cancel()`/`retry()`/`open()` bump it. After each await, a leg bails if its
|
|
20
|
+
* epoch is stale, so a ceremony the parent canceled can never resume, bind a
|
|
21
|
+
* child, or fire onSuccess after onExit.
|
|
22
|
+
*/
|
|
23
|
+
export function createConnectController(opts: ConnectControllerOptions): ConnectController {
|
|
24
|
+
let state: ConnectState = { status: 'idle' };
|
|
25
|
+
const listeners = new Set<(s: ConnectState) => void>();
|
|
26
|
+
|
|
27
|
+
// Ceremony context threaded across legs.
|
|
28
|
+
let sessionId: string | undefined;
|
|
29
|
+
let lastProfileId: string | undefined;
|
|
30
|
+
// Monotonic run epoch; bumped by open/retry/cancel. A leg with a stale epoch
|
|
31
|
+
// aborts on its next await boundary and never mutates state.
|
|
32
|
+
let epoch = 0;
|
|
33
|
+
|
|
34
|
+
function set(next: Partial<ConnectState> & { status: ConnectStatus }): void {
|
|
35
|
+
// Replace wholesale so stale fields (childProfiles, error, result) never leak
|
|
36
|
+
// across unrelated transitions; callers pass exactly what the new state carries.
|
|
37
|
+
state = { ...next };
|
|
38
|
+
for (const fn of listeners) fn(state);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const stale = (gen: number): boolean => gen !== epoch;
|
|
42
|
+
|
|
43
|
+
function fail(gen: number, stage: ConnectStatus, err: unknown): void {
|
|
44
|
+
if (stale(gen)) return; // a canceled/superseded run must not clobber state
|
|
45
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
46
|
+
const error = { stage, message };
|
|
47
|
+
set({ status: 'error', error });
|
|
48
|
+
opts.onError?.(error);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function runAuthorize(gen: number): Promise<void> {
|
|
52
|
+
set({ status: 'authorizing' });
|
|
53
|
+
let init;
|
|
54
|
+
try {
|
|
55
|
+
init = await opts.transport.init({
|
|
56
|
+
platformDid: opts.platform.did,
|
|
57
|
+
childId: opts.childId,
|
|
58
|
+
grantedScope: opts.grantedScope,
|
|
59
|
+
ageHint: opts.ageHint,
|
|
60
|
+
});
|
|
61
|
+
} catch (err) {
|
|
62
|
+
fail(gen, 'authorizing', err);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (stale(gen)) return;
|
|
66
|
+
sessionId = init.sessionId;
|
|
67
|
+
|
|
68
|
+
let opened;
|
|
69
|
+
try {
|
|
70
|
+
opened = await opts.openAuthorizeUrl(init.authorizeUrl, opts.redirectUri);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
fail(gen, 'authorizing', err);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (stale(gen)) return;
|
|
76
|
+
if ('canceled' in opened) {
|
|
77
|
+
set({ status: 'canceled' });
|
|
78
|
+
opts.onExit?.();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// Defense-in-depth CSRF check (the server's complete() is authoritative).
|
|
82
|
+
if (opened.state !== init.state) {
|
|
83
|
+
fail(gen, 'authorizing', new Error('Security check failed. Please try again.'));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
await runExchange(gen, opened.code, opened.state);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function runExchange(gen: number, code: string, oauthState: string): Promise<void> {
|
|
90
|
+
if (!sessionId) {
|
|
91
|
+
fail(gen, 'exchanging', new Error('missing session'));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
set({ status: 'exchanging' });
|
|
95
|
+
let done;
|
|
96
|
+
try {
|
|
97
|
+
done = await opts.transport.complete({ code, state: oauthState, sessionId });
|
|
98
|
+
} catch (err) {
|
|
99
|
+
fail(gen, 'exchanging', err);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (stale(gen)) return;
|
|
103
|
+
sessionId = done.sessionId;
|
|
104
|
+
set({ status: 'selecting', childProfiles: done.childProfiles });
|
|
105
|
+
|
|
106
|
+
// Auto-advance only when there is exactly one profile AND the caller already
|
|
107
|
+
// named the child — otherwise the parent must confirm which account.
|
|
108
|
+
if (done.childProfiles.length === 1 && opts.childId) {
|
|
109
|
+
await runBind(gen, done.childProfiles[0].id);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function runBind(gen: number, profileId: string): Promise<void> {
|
|
114
|
+
if (!sessionId) {
|
|
115
|
+
fail(gen, 'binding', new Error('missing session'));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
lastProfileId = profileId;
|
|
119
|
+
set({ status: 'binding' });
|
|
120
|
+
let bound;
|
|
121
|
+
try {
|
|
122
|
+
bound = await opts.transport.bind({
|
|
123
|
+
sessionId,
|
|
124
|
+
platformChildProfileId: profileId,
|
|
125
|
+
childId: opts.childId ?? profileId,
|
|
126
|
+
grantedScope: opts.grantedScope,
|
|
127
|
+
ageHint: opts.ageHint,
|
|
128
|
+
});
|
|
129
|
+
} catch (err) {
|
|
130
|
+
fail(gen, 'binding', err);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (stale(gen)) return;
|
|
134
|
+
set({ status: 'success', result: bound });
|
|
135
|
+
opts.onSuccess?.(bound);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Defined as locals so both `open` and `retry` can invoke them without relying
|
|
139
|
+
// on `this` (the hook destructures these methods).
|
|
140
|
+
async function openImpl(): Promise<void> {
|
|
141
|
+
if (state.status === 'authorizing' || state.status === 'exchanging' || state.status === 'binding') {
|
|
142
|
+
return; // already in flight
|
|
143
|
+
}
|
|
144
|
+
sessionId = undefined;
|
|
145
|
+
lastProfileId = undefined;
|
|
146
|
+
await runAuthorize(++epoch);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function selectChildImpl(profileId: string): Promise<void> {
|
|
150
|
+
if (state.status !== 'selecting') return;
|
|
151
|
+
await runBind(epoch, profileId); // continuation of the current run
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function retryImpl(): Promise<void> {
|
|
155
|
+
const stage = state.error?.stage;
|
|
156
|
+
const gen = ++epoch;
|
|
157
|
+
if (stage === 'binding' && lastProfileId) {
|
|
158
|
+
await runBind(gen, lastProfileId);
|
|
159
|
+
} else {
|
|
160
|
+
sessionId = undefined;
|
|
161
|
+
lastProfileId = undefined;
|
|
162
|
+
await runAuthorize(gen);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function cancelImpl(): void {
|
|
167
|
+
if (state.status === 'success') return;
|
|
168
|
+
epoch++; // invalidate any in-flight leg so it can't resume post-cancel
|
|
169
|
+
set({ status: 'canceled' });
|
|
170
|
+
opts.onExit?.();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
getState: () => state,
|
|
175
|
+
subscribe(fn) {
|
|
176
|
+
listeners.add(fn);
|
|
177
|
+
return () => {
|
|
178
|
+
listeners.delete(fn);
|
|
179
|
+
};
|
|
180
|
+
},
|
|
181
|
+
open: openImpl,
|
|
182
|
+
selectChild: selectChildImpl,
|
|
183
|
+
retry: retryImpl,
|
|
184
|
+
cancel: cancelImpl,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { createConnectController } from './controller.js';
|
|
2
|
+
export { useConnect } from './useConnect.js';
|
|
3
|
+
export type { UseConnectResult } from './useConnect.js';
|
|
4
|
+
export type {
|
|
5
|
+
AgeHint,
|
|
6
|
+
ChildProfile,
|
|
7
|
+
ConnectRule,
|
|
8
|
+
ConnectPlatform,
|
|
9
|
+
InitResult,
|
|
10
|
+
CompleteResult,
|
|
11
|
+
BindResult,
|
|
12
|
+
ConnectTransport,
|
|
13
|
+
AuthorizeOpener,
|
|
14
|
+
ConnectStatus,
|
|
15
|
+
ConnectError,
|
|
16
|
+
ConnectState,
|
|
17
|
+
ConnectControllerOptions,
|
|
18
|
+
ConnectController,
|
|
19
|
+
} from './types.js';
|