@phosra/connect
v0.2.0
Published
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.
Readme
@phosra/connect
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.
Install
npm install @phosra/connectPeer dependencies for web: react ^18 || ^19, react-dom ^18 || ^19.
Peer dependencies for React Native (additional): react-native, react-native-svg, expo-web-browser.
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:
- React Native / Metro and Vite — works out of the box.
- Next.js — add the package to
transpilePackages:// next.config.js module.exports = { transpilePackages: ['@phosra/connect'] };
Entry Points
| Import | Contents |
|--------|----------|
| @phosra/connect | PhosraConnect + ConnectFlow + all core types (web default) |
| @phosra/connect/web | Same, explicit web entry |
| @phosra/connect/core | useConnect hook, createConnectController, all shared types — no UI |
| @phosra/connect/native | PhosraConnect for React Native (needs react-native-svg) |
| @phosra/connect/connect.css | Default styles for the web drop-in; import once in your app root |
Web Usage
import '@phosra/connect/connect.css';
import { PhosraConnect } from '@phosra/connect';
import type { ConnectTransport } from '@phosra/connect/core';
const transport: ConnectTransport = {
async init(req) {
const res = await fetch('/api/phosra/connect/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
},
async complete(req) {
const res = await fetch('/api/phosra/connect/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
},
async bind(req) {
const res = await fetch('/api/phosra/connect/bind', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!res.ok) throw new Error(await res.text());
return res.json(); // { grant_id }
},
};
export default function ConnectPage() {
return (
<PhosraConnect
platform={{ did: 'did:ocss:snaptr', name: 'Snaptr' }}
rules={[
{ category: 'addictive_pattern_block', label: 'Block addictive feed patterns' },
{ category: 'dm_restriction', label: 'Restrict direct messages' },
]}
grantedScope={['addictive_pattern_block', 'dm_restriction']}
redirectUri="https://yourapp.com/phosra/callback"
transport={transport}
onSuccess={(result) => console.log('connected, grant_id:', result.grant_id)}
onExit={() => router.back()}
/>
);
}Optional props
| Prop | Type | Default |
|------|------|---------|
| childId | string | — (parent picks from list) |
| ageHint | 'under_13' \| '13_15' \| '16_17' | — |
| childName | string | — (grant preview says "your child" instead of the name) |
| provisioningForm | 'batch' \| 'single' | — (no create-and-link step; a server provisioningForm overrides) |
| provisionChildren | ProvisionChild[] | — (one child derived from ageHint + childName) |
| openAuthorizeUrl | AuthorizeOpener | Same-origin popup + postMessage |
| platformGlyph | React.ReactNode | Generic platform glyph |
childName names the child in the grant/rules preview ("…applies and confirms these rules for Ava") so consent is legible. provisioningForm + a transport.provision implementation together enable the in-modal create-and-link step when the parent has no existing profile on the platform (see below).
React Native Usage
import { PhosraConnect } from '@phosra/connect/native';
// transport shape is identical to the web example above.
export default function ConnectScreen({ navigation }) {
return (
<PhosraConnect
platform={{ did: 'did:ocss:snaptr', name: 'Snaptr' }}
rules={[
{ category: 'addictive_pattern_block', label: 'Block addictive feed patterns' },
]}
grantedScope={['addictive_pattern_block']}
redirectUri="yourapp://phosra/callback"
transport={transport}
onSuccess={(result) => navigation.navigate('Success', { grantId: result.grant_id })}
onExit={() => navigation.goBack()}
/>
);
}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.
The BFF Contract
This is the load-bearing section. The ConnectTransport interface maps directly onto route handlers that your BFF implements. Your BFF wraps @phosra/link server-side. The component never talks to Phosra or to the platform directly.
The @phosra/link server functions in play:
| @phosra/link function | Transport call | Role |
|-------------------------|----------------|------|
| initPlatformOAuth | transport.init | PKCE S256 authorize URL + state |
| completePlatformOAuth | transport.complete | Code exchange + child-profile list (+ optional provisioningForm) |
| bindProfile + runConnectCeremony | transport.bind | Bind child, mint consent, provision enforcement endpoint, deliver label to platform → { grant_id, verified? } |
| provisionProfiles + runConnectCeremony | transport.provision (optional) | Create-and-link: stand up new profile(s) on the platform, then bind → { grant_id, verified?, provisioned } |
transport.provision is optional — implement it only to enable the in-modal create-and-link step (see "Create-and-link" below). The component works with just the three core calls.
The verified flag (never a fake green). transport.bind and transport.provision return { grant_id, verified?, receiptFingerprint? }. Set verified: true only when the census returned a signed write-receipt that verified to the pinned OCSS trust root (@phosra/link's ceremony surfaces this). When verified is true, the success screen shows the earned "Verified on the OCSS Trust List" cue; when it is false/absent, the screen shows an honest "{platform} connected — confirming enforcement…" pending state instead. Never hard-code verified: true — that is the fake green the component is designed to prevent.
Shared config (once at startup)
// lib/phosra-link.ts
import type { LinkConfig } from '@phosra/link';
import { loadSenderKey } from '@openchildsafety/ocss';
import { pool } from './db'; // your pg Pool
export const linkCfg: LinkConfig = {
censusBaseUrl: process.env.PHOSRA_CENSUS_URL!,
trustRootXB64Url: process.env.PHOSRA_TRUST_ROOT_X!,
parentKey: loadSenderKey(process.env.PARENT_SIGNING_KEY_PEM!),
writerKey: loadSenderKey(process.env.WRITER_SIGNING_KEY_PEM!),
writerDid: process.env.WRITER_DID!,
routerDid: process.env.ROUTER_DID!,
householdSecret: process.env.HOUSEHOLD_SECRET!,
pool,
developerOrgId: process.env.PHOSRA_DEVELOPER_ORG_ID, // from your Phosra dashboard
};POST /api/phosra/connect/init
Generates a PKCE S256 pair, stores a pending platform session, and returns the authorize URL.
// app/api/phosra/connect/init/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { initPlatformOAuth } from '@phosra/link';
import { linkCfg } from '@/lib/phosra-link';
import { getServerSession } from '@/lib/auth'; // your parent auth
export async function POST(req: NextRequest) {
const { platformDid, redirectUri, grantedScope, ageHint, childId } = await req.json();
const session = await getServerSession(req); // authenticated parent session
const result = await initPlatformOAuth(linkCfg, {
platformDid,
redirectUri,
parentSessionRef: session.id, // binds state to the authenticated parent (CSRF defense)
childHint: childId, // optional login_hint for the platform
});
// Returns: { authorizeUrl: string, state: string, sessionId: string }
return NextResponse.json(result);
}POST /api/phosra/connect/complete
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.
// app/api/phosra/connect/complete/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { completePlatformOAuth } from '@phosra/link';
import { linkCfg } from '@/lib/phosra-link';
import { getServerSession } from '@/lib/auth';
export async function POST(req: NextRequest) {
const { code, state, sessionId } = await req.json();
const session = await getServerSession(req);
const result = await completePlatformOAuth(linkCfg, {
code,
state,
parentSessionRef: session.id,
});
// Returns: { sessionId: string, childProfiles: ChildProfile[] }
// ChildProfile: { id: string, displayName: string, ageHint?: number }
return NextResponse.json(result);
}POST /api/phosra/connect/bind
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 }.
// app/api/phosra/connect/bind/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { bindProfile, runConnectCeremony, deliverLabelToPlatform } from '@phosra/link';
import { linkCfg } from '@/lib/phosra-link';
// Your registry mapping a platform DID to its server-side callback URL.
function resolvePlatformCallbackUrl(platformDid: string): string {
const registry: Record<string, string> = {
'did:ocss:snaptr': 'https://api.snaptr.example/ocss/connect-callback',
};
const url = registry[platformDid];
if (!url) throw new Error(`unknown platform: ${platformDid}`);
return url;
}
export async function POST(req: NextRequest) {
const { sessionId, platformChildProfileId, childId, grantedScope, ageHint } = await req.json();
// Leg 1: record the parent's child pick, advance platform session → bound,
// produce a LinkSession ready for completeLink.
const linkSession = await bindProfile(linkCfg, {
sessionId,
platformChildProfileId,
childId,
granted_scope: grantedScope, // note: snake_case in @phosra/link
ageHint,
});
// The platform's registered connect_url BASE; deliverLabelToPlatform appends the
// well-known /api/ocss/connect path (the same convention the census test-connect uses).
const platformConnectUrl = resolvePlatformConnectUrl(linkSession.audience_did);
// Leg 2: completeLink (consent + endpoint) then deliver the label server-to-server.
// runConnectCeremony calls completeLink internally; your deliver closure is called
// with the cleartext endpoint_id_label exactly once.
const { grant_id } = await runConnectCeremony(
linkCfg,
linkSession,
async (endpoint_id_label) => {
const r = await deliverLabelToPlatform(platformConnectUrl, {
endpoint_id_label,
state: sessionId, // correlates to the platform's active session
});
if (!r.ok) throw new Error(`platform callback failed: ${r.status}`);
},
);
// endpoint_id_label is consumed by the deliver closure and never returned.
// `verified` reflects whether the census write-receipt verified to the pinned
// root; pass it through so the modal shows the earned "Verified" cue only when true.
return NextResponse.json({ grant_id, verified, receiptFingerprint });
}grant_id is the value surfaced to your onSuccess callback as result.grant_id. verified drives the success screen's trust cue (see "The verified flag" above).
Create-and-link (parent has no profile yet)
When the parent has no existing profile on the platform, the old flow dead-ended (or bounced them to a new tab to make one). Instead, when the platform advertises a provisioning_form (batch or single) and you implement transport.provision, the modal offers an in-modal Create & connect step that stands up the profile and binds the grant in one action — never leaving your app.
Wire it two ways (both optional):
- Declare the form on the component: pass
provisioningForm="batch"(or"single") toPhosraConnect. The BFF can also returnprovisioningFormfromtransport.complete(resolved from the census discovery entry) — a server value overrides the prop. - Implement
transport.provision— the 4th route, wrapping@phosra/link'sprovisionProfiles:
// app/api/phosra/connect/provision/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { provisionProfiles, bindProfile, runConnectCeremony, deliverLabelToPlatform } from '@phosra/link';
import { linkCfg } from '@/lib/phosra-link';
export async function POST(req: NextRequest) {
const { sessionId, children, childId, grantedScope } = await req.json();
// children: { ageHint: 'under_13'|'13_15'|'16_17', displayName?: string }[]
// Leg 1: create the profile(s) on the platform (signed EXT-01 delivery).
const { provisioned } = await provisionProfiles(linkCfg, /* platformDid */ resolvePlatformDid(sessionId), {
state: sessionId,
children: children.map((c) => ({ endpoint_id_label: newLabel(), age_band: c.ageHint, display_hint: c.displayName })),
});
// Leg 2: bind the grant + deliver the enforcement label (same ceremony as /bind).
const linkSession = await bindProfile(linkCfg, { sessionId, childId, granted_scope: grantedScope /* … */ });
const { grant_id, verified } = await runConnectCeremony(linkCfg, linkSession, deliverClosure);
return NextResponse.json({ grant_id, verified, provisioned });
}The component drives it automatically: on an empty profile list it moves to a no_profiles state (a rules preview + a Create & connect button); pressing it calls transport.provision and lands on the same success screen. If you don't implement provision, an empty list falls back to the existing (empty) picker — fully back-compatible.
Redirect Page (Web)
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.
<!-- public/phosra/callback/index.html (or a minimal Next.js page at /phosra/callback) -->
<script>
const params = new URLSearchParams(location.search);
const code = params.get('code');
const state = params.get('state');
if (window.opener && code && state) {
window.opener.postMessage(
{ type: 'phosra-connect', code, state },
window.location.origin, // same-origin only
);
window.close();
}
</script>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 }.
Honesty and Trust Boundary
Never a fake green. The success screen's "Verified on the OCSS Trust List" cue renders only when the ceremony returned verified: true — i.e. the census signed a write-receipt that verified to the pinned OCSS trust root. If the connection landed but the signed receipt hasn't verified yet, the screen shows an honest "{platform} connected — confirming enforcement…" pending state instead. The verified badge is gated on the server's verified flag, never a client-side optimistic assumption; hard-coding verified: true in your BFF is the one thing that defeats this guarantee.
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.
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.
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.
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.
