@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 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';