@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.
@@ -0,0 +1,122 @@
1
+ // Wire contract for the Phosra Connect component. These types mirror the
2
+ // client-facing shapes of @phosra/link's server-side ceremony, but are defined
3
+ // LOCALLY so this package has zero server dependencies (no @phosra/link runtime,
4
+ // no pg). The PCA app implements a BFF that fulfils `ConnectTransport` by calling
5
+ // @phosra/link server-side (initPlatformOAuth / completePlatformOAuth / bindProfile
6
+ // / runConnectCeremony).
7
+
8
+ export type AgeHint = 'under_13' | '13_15' | '16_17';
9
+
10
+ /** A child account discovered on the platform after the parent authorizes. */
11
+ export interface ChildProfile {
12
+ id: string;
13
+ displayName: string;
14
+ ageHint?: number;
15
+ }
16
+
17
+ /** One safety rule shown in the pre-connect preview ("what Snaptr will apply & confirm"). */
18
+ export interface ConnectRule {
19
+ category: string;
20
+ label: string;
21
+ }
22
+
23
+ /** The enforcement platform being connected (e.g. Snaptr). */
24
+ export interface ConnectPlatform {
25
+ did: string;
26
+ name: string;
27
+ }
28
+
29
+ export interface InitResult {
30
+ authorizeUrl: string;
31
+ state: string;
32
+ sessionId: string;
33
+ }
34
+ export interface CompleteResult {
35
+ sessionId: string;
36
+ childProfiles: ChildProfile[];
37
+ }
38
+ export interface BindResult {
39
+ grant_id: string;
40
+ }
41
+
42
+ /**
43
+ * The three BFF calls the component drives. The PCA implements these against its
44
+ * own routes, which wrap @phosra/link server-side. The component NEVER talks to
45
+ * Phosra directly, and never sees the endpoint_id_label (the server delivers it
46
+ * to the platform).
47
+ */
48
+ export interface ConnectTransport {
49
+ init(req: {
50
+ platformDid: string;
51
+ childId?: string;
52
+ grantedScope: string[];
53
+ ageHint?: AgeHint;
54
+ }): Promise<InitResult>;
55
+ complete(req: { code: string; state: string; sessionId: string }): Promise<CompleteResult>;
56
+ bind(req: {
57
+ sessionId: string;
58
+ platformChildProfileId: string;
59
+ childId: string;
60
+ grantedScope: string[];
61
+ ageHint?: AgeHint;
62
+ }): Promise<BindResult>;
63
+ }
64
+
65
+ /**
66
+ * Injected per-platform. Opens the secure webview to `authorizeUrl` and resolves
67
+ * the redirect's `{code, state}` (or `{canceled:true}` if the parent backs out).
68
+ * Web supplies a popup/postMessage opener; React Native supplies an
69
+ * expo-web-browser opener. The core is platform-agnostic because this is injected.
70
+ */
71
+ export type AuthorizeOpener = (
72
+ authorizeUrl: string,
73
+ redirectUri: string,
74
+ ) => Promise<{ code: string; state: string } | { canceled: true }>;
75
+
76
+ export type ConnectStatus =
77
+ | 'idle'
78
+ | 'authorizing'
79
+ | 'exchanging'
80
+ | 'selecting'
81
+ | 'binding'
82
+ | 'success'
83
+ | 'error'
84
+ | 'canceled';
85
+
86
+ export interface ConnectError {
87
+ stage: ConnectStatus;
88
+ message: string;
89
+ }
90
+
91
+ export interface ConnectState {
92
+ status: ConnectStatus;
93
+ childProfiles?: ChildProfile[];
94
+ result?: BindResult;
95
+ error?: ConnectError;
96
+ }
97
+
98
+ export interface ConnectControllerOptions {
99
+ transport: ConnectTransport;
100
+ openAuthorizeUrl: AuthorizeOpener;
101
+ redirectUri: string;
102
+ platform: ConnectPlatform;
103
+ grantedScope: string[];
104
+ childId?: string;
105
+ ageHint?: AgeHint;
106
+ onSuccess?(r: BindResult): void;
107
+ onError?(e: ConnectError): void;
108
+ onExit?(): void;
109
+ }
110
+
111
+ export interface ConnectController {
112
+ getState(): ConnectState;
113
+ subscribe(fn: (s: ConnectState) => void): () => void;
114
+ /** Begin the ceremony (from idle / error / canceled). */
115
+ open(): Promise<void>;
116
+ /** Confirm the child to bind (valid only while status === 'selecting'). */
117
+ selectChild(profileId: string): Promise<void>;
118
+ /** Re-run from the failed stage. */
119
+ retry(): Promise<void>;
120
+ /** Abort → canceled, fires onExit. */
121
+ cancel(): void;
122
+ }
@@ -0,0 +1,68 @@
1
+ import { useRef, useSyncExternalStore } from 'react';
2
+ import { createConnectController } from './controller.js';
3
+ import type { ConnectController, ConnectControllerOptions, ConnectState } from './types.js';
4
+
5
+ export interface UseConnectResult {
6
+ state: ConnectState;
7
+ open: ConnectController['open'];
8
+ selectChild: ConnectController['selectChild'];
9
+ retry: ConnectController['retry'];
10
+ cancel: ConnectController['cancel'];
11
+ }
12
+
13
+ /**
14
+ * Universal React hook wrapping the framework-agnostic ConnectController. Works
15
+ * on web and React Native (it imports only `react`, never `react-dom` or
16
+ * `react-native`). The controller is created once; option changes (fresh
17
+ * callbacks, transport, scope) are picked up lazily via a ref, so the controller
18
+ * identity — and therefore any in-flight ceremony — is stable across renders.
19
+ */
20
+ export function useConnect(opts: ConnectControllerOptions): UseConnectResult {
21
+ const optsRef = useRef(opts);
22
+ optsRef.current = opts;
23
+
24
+ const controllerRef = useRef<ConnectController | null>(null);
25
+ if (controllerRef.current === null) {
26
+ controllerRef.current = createConnectController({
27
+ get transport() {
28
+ return optsRef.current.transport;
29
+ },
30
+ get openAuthorizeUrl() {
31
+ return optsRef.current.openAuthorizeUrl;
32
+ },
33
+ get redirectUri() {
34
+ return optsRef.current.redirectUri;
35
+ },
36
+ get platform() {
37
+ return optsRef.current.platform;
38
+ },
39
+ get grantedScope() {
40
+ return optsRef.current.grantedScope;
41
+ },
42
+ get childId() {
43
+ return optsRef.current.childId;
44
+ },
45
+ get ageHint() {
46
+ return optsRef.current.ageHint;
47
+ },
48
+ onSuccess: (r) => optsRef.current.onSuccess?.(r),
49
+ onError: (e) => optsRef.current.onError?.(e),
50
+ onExit: () => optsRef.current.onExit?.(),
51
+ });
52
+ }
53
+ const controller = controllerRef.current;
54
+
55
+ const state = useSyncExternalStore(
56
+ controller.subscribe,
57
+ controller.getState,
58
+ controller.getState,
59
+ );
60
+
61
+ return {
62
+ state,
63
+ open: controller.open,
64
+ selectChild: controller.selectChild,
65
+ retry: controller.retry,
66
+ cancel: controller.cancel,
67
+ };
68
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ // Default entry = core + web (the common case: a web PCA using the styled
2
+ // drop-in). React Native consumers import from '@phosra/connect/native'.
3
+ export * from './core/index.js';
4
+ export * from './web/index.js';
@@ -0,0 +1,205 @@
1
+ /**
2
+ * ConnectFlow (React Native) — unstyled reference implementation. Mirrors
3
+ * web/ConnectFlow.tsx: same states, same honest copy, minimal styling, semantic
4
+ * structure with stable `accessibilityHint` hooks so a PCA can style it entirely.
5
+ *
6
+ * This is the "Layer 2" starting point between the headless hook (useConnect)
7
+ * and the styled drop-in (PhosraConnect.native.tsx). The honesty contract copy
8
+ * ("never a fake green") is preserved; only visual treatment is left to the
9
+ * consumer.
10
+ */
11
+ import * as React from 'react';
12
+ import { View, Text, Pressable } from 'react-native';
13
+ import { useConnect } from '../core/useConnect.js';
14
+ import type {
15
+ AgeHint,
16
+ AuthorizeOpener,
17
+ BindResult,
18
+ ConnectError,
19
+ ConnectPlatform,
20
+ ConnectRule,
21
+ ConnectTransport,
22
+ } from '../core/types.js';
23
+ import { createNativeAuthorizeOpener } from './openAuthorizeUrl.native.js';
24
+
25
+ export interface ConnectFlowProps {
26
+ platform: ConnectPlatform;
27
+ rules: ConnectRule[];
28
+ grantedScope: string[];
29
+ transport: ConnectTransport;
30
+ redirectUri: string;
31
+ childId?: string;
32
+ ageHint?: AgeHint;
33
+ openAuthorizeUrl?: AuthorizeOpener;
34
+ onSuccess?(r: BindResult): void;
35
+ onError?(e: ConnectError): void;
36
+ onExit?(): void;
37
+ }
38
+
39
+ export function ConnectFlow(props: ConnectFlowProps): React.ReactElement {
40
+ const opener = React.useMemo(
41
+ () => props.openAuthorizeUrl ?? createNativeAuthorizeOpener(),
42
+ [props.openAuthorizeUrl],
43
+ );
44
+ const { state, open, selectChild, retry, cancel } = useConnect({
45
+ transport: props.transport,
46
+ openAuthorizeUrl: opener,
47
+ redirectUri: props.redirectUri,
48
+ platform: props.platform,
49
+ grantedScope: props.grantedScope,
50
+ childId: props.childId,
51
+ ageHint: props.ageHint,
52
+ onSuccess: props.onSuccess,
53
+ onError: props.onError,
54
+ onExit: props.onExit,
55
+ });
56
+ const [showRules, setShowRules] = React.useState(false);
57
+ const { status } = state;
58
+ const name = props.platform.name;
59
+
60
+ if (status === 'idle' && !showRules) {
61
+ return (
62
+ <View accessibilityHint="phosra-connect-intro">
63
+ <Text accessibilityHint="phosra-connect-title">Connect {name}</Text>
64
+ <Text accessibilityHint="phosra-connect-subtitle">
65
+ {'You\'ll go to '}{name}{' to securely approve these safety rules for your child\'s account.'}
66
+ </Text>
67
+ <Text accessibilityHint="phosra-connect-trust">Accredited on the OCSS Trust List.</Text>
68
+ <Text accessibilityHint="phosra-connect-footnote">
69
+ {'Phosra verifies enforcement with '}{name}{' and can\'t read messages.'}
70
+ </Text>
71
+ <Pressable
72
+ accessibilityHint="phosra-connect-continue"
73
+ accessibilityRole="button"
74
+ onPress={() => setShowRules(true)}
75
+ >
76
+ <Text>Continue</Text>
77
+ </Pressable>
78
+ </View>
79
+ );
80
+ }
81
+
82
+ if (status === 'idle' && showRules) {
83
+ return (
84
+ <View accessibilityHint="phosra-connect-rules">
85
+ <Text accessibilityHint="phosra-connect-title">Connect {name}</Text>
86
+ <Text accessibilityHint="phosra-connect-subtitle">
87
+ {'After you connect, '}{name}{' applies and confirms these rules:'}
88
+ </Text>
89
+ <View accessibilityHint="phosra-connect-rule-list">
90
+ {props.rules.map((rule) => (
91
+ <View key={rule.category} accessibilityHint={`phosra-connect-rule-${rule.category}`}>
92
+ <Text>{rule.label}</Text>
93
+ </View>
94
+ ))}
95
+ </View>
96
+ <Text accessibilityHint="phosra-connect-footnote">
97
+ {'Shown as "Enforced" only after '}{name}{' confirms — never a fake green.'}
98
+ </Text>
99
+ <Pressable
100
+ accessibilityHint="phosra-connect-back"
101
+ accessibilityRole="button"
102
+ onPress={() => setShowRules(false)}
103
+ >
104
+ <Text>Back</Text>
105
+ </Pressable>
106
+ <Pressable
107
+ accessibilityHint="phosra-connect-continue"
108
+ accessibilityRole="button"
109
+ onPress={() => void open()}
110
+ >
111
+ <Text>Continue</Text>
112
+ </Pressable>
113
+ </View>
114
+ );
115
+ }
116
+
117
+ if (status === 'authorizing' || status === 'exchanging') {
118
+ return (
119
+ <View accessibilityHint="phosra-connect-loading" accessibilityLabel="Loading">
120
+ <Text>
121
+ {status === 'authorizing' ? `Waiting for ${name}…` : 'Finishing up…'}
122
+ </Text>
123
+ </View>
124
+ );
125
+ }
126
+
127
+ if (status === 'selecting') {
128
+ return (
129
+ <View accessibilityHint="phosra-connect-picker">
130
+ <Text accessibilityHint="phosra-connect-title">Choose an account</Text>
131
+ <Text accessibilityHint="phosra-connect-subtitle">
132
+ {'Which '}{name}{' account should these rules protect?'}
133
+ </Text>
134
+ <View accessibilityHint="phosra-connect-profile-list">
135
+ {(state.childProfiles ?? []).map((profile) => (
136
+ <Pressable
137
+ key={profile.id}
138
+ accessibilityHint={`phosra-connect-profile-${profile.id}`}
139
+ accessibilityRole="button"
140
+ onPress={() => void selectChild(profile.id)}
141
+ >
142
+ <Text>{profile.displayName}</Text>
143
+ </Pressable>
144
+ ))}
145
+ </View>
146
+ </View>
147
+ );
148
+ }
149
+
150
+ if (status === 'binding') {
151
+ return (
152
+ <View accessibilityHint="phosra-connect-loading" accessibilityLabel="Applying rules">
153
+ <Text>Applying rules on {name}…</Text>
154
+ </View>
155
+ );
156
+ }
157
+
158
+ if (status === 'success') {
159
+ return (
160
+ <View accessibilityHint="phosra-connect-success">
161
+ <Text accessibilityHint="phosra-connect-title">{name} connected</Text>
162
+ <Text accessibilityHint="phosra-connect-subtitle">
163
+ {'These rules are now enforced on '}{name}{'.'}
164
+ </Text>
165
+ <Text accessibilityHint="phosra-connect-verified">Verified on the OCSS Trust List.</Text>
166
+ <Pressable
167
+ accessibilityHint="phosra-connect-done"
168
+ accessibilityRole="button"
169
+ onPress={() => props.onExit?.()}
170
+ >
171
+ <Text>Done</Text>
172
+ </Pressable>
173
+ </View>
174
+ );
175
+ }
176
+
177
+ if (status === 'error') {
178
+ return (
179
+ <View accessibilityHint="phosra-connect-error" accessibilityRole="alert" accessibilityLiveRegion="polite">
180
+ <Text accessibilityHint="phosra-connect-title">
181
+ {'Couldn\'t connect '}{name}
182
+ </Text>
183
+ <Text accessibilityHint="phosra-connect-error-message">
184
+ {state.error?.message ?? 'Something went wrong.'}{' No rules were changed — you can try again.'}
185
+ </Text>
186
+ <Pressable
187
+ accessibilityHint="phosra-connect-retry"
188
+ accessibilityRole="button"
189
+ onPress={() => void retry()}
190
+ >
191
+ <Text>Try again</Text>
192
+ </Pressable>
193
+ <Pressable
194
+ accessibilityHint="phosra-connect-cancel"
195
+ accessibilityRole="button"
196
+ onPress={() => cancel()}
197
+ >
198
+ <Text>Cancel</Text>
199
+ </Pressable>
200
+ </View>
201
+ );
202
+ }
203
+
204
+ return <View accessibilityHint="phosra-connect-canceled" />;
205
+ }