@pithy-sh/ui-react-native 0.0.0-stage → 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/LICENSE +21 -0
- package/dist/generatedConfig.d.ts +195 -0
- package/dist/generatedConfig.d.ts.map +1 -0
- package/dist/generatedConfig.js +323 -0
- package/dist/templates.d.ts +48 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +85 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +10 -0
- package/package.json +83 -4
- package/src/generatedConfig.ts +419 -0
- package/src/templates.ts +92 -0
- package/src/version.ts +13 -0
- package/templates/app/[...pithy].tsx +7 -0
- package/templates/app/_layout.tsx +31 -0
- package/templates/app/index.tsx +4 -0
- package/templates/app.config.ts +56 -0
- package/templates/metro-proxy.cjs +289 -0
- package/templates/metro.config.js +6 -0
- package/templates/src/payments-store.native.ts +20 -0
- package/templates/src/payments-store.ts +16 -0
- package/templates/src/payments.tsx +769 -0
- package/templates/src/pithy-config.generated.ts +99 -0
- package/templates/src/pithy-config.ts +169 -0
- package/templates/src/pithy-locale.tsx +90 -0
- package/templates/src/platform.ts +111 -0
- package/templates/src/route-modules.ts +76 -0
- package/templates/src/router.tsx +387 -0
- package/templates/src/routes/app/home.bare.tsx +75 -0
- package/templates/src/routes/app/home.tsx +55 -0
- package/templates/src/routes/pithy/account-deleted.tsx +69 -0
- package/templates/src/routes/pithy/account-suspended.tsx +70 -0
- package/templates/src/routes/pithy/agent-consent.tsx +260 -0
- package/templates/src/routes/pithy/callback.tsx +74 -0
- package/templates/src/routes/pithy/choose-organization.tsx +230 -0
- package/templates/src/routes/pithy/connected-applications.tsx +153 -0
- package/templates/src/routes/pithy/consent.tsx +453 -0
- package/templates/src/routes/pithy/invitation.tsx +239 -0
- package/templates/src/routes/pithy/organization-suspended.tsx +51 -0
- package/templates/src/routes/pithy/otp.tsx +196 -0
- package/templates/src/routes/pithy/paywall.tsx +253 -0
- package/templates/src/routes/pithy/pricing.tsx +317 -0
- package/templates/src/routes/pithy/sign-in.tsx +673 -0
- package/templates/src/routes/pithy/subscription.tsx +240 -0
- package/templates/src/session.tsx +290 -0
- package/templates/src/theme.ts +100 -0
- package/templates/src/transport.ts +290 -0
- package/templates/src/turnstile.tsx +117 -0
- package/templates/src/useAppFocus.ts +20 -0
- package/templates/src/useStoreTransactions.ts +16 -0
- package/templates/src/useTurnstileWeb.ts +104 -0
- package/templates/tsconfig.json +10 -0
- package/README.md +0 -3
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { type AuthClientOptions, type AuthFetch, resolveFetch, withoutCookies } from "@pithy-sh/auth/src/client/api";
|
|
2
|
+
import {
|
|
3
|
+
type BearerSession,
|
|
4
|
+
createBearerSession,
|
|
5
|
+
type DeviceInfo,
|
|
6
|
+
type SecureStore,
|
|
7
|
+
} from "@pithy-sh/auth/src/client/bearer";
|
|
8
|
+
import { CONSENT_VISITOR_HEADER } from "@pithy-sh/consent/src/client/api";
|
|
9
|
+
import type { OrganizationClientOptions } from "@pithy-sh/organization/src/client/api";
|
|
10
|
+
import { randomUUID } from "expo-crypto";
|
|
11
|
+
import * as ExpoSecureStore from "expo-secure-store";
|
|
12
|
+
import { Platform } from "react-native";
|
|
13
|
+
import { authConfig, consentConfig, origin } from "./pithy-config";
|
|
14
|
+
import { devicePlatform } from "./platform";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* How this app reaches its Worker: one decision, made once, from the platform it runs on.
|
|
18
|
+
*
|
|
19
|
+
* **On a phone the session is a bearer credential.** There is no page origin and no cookie jar, so every call goes
|
|
20
|
+
* to the Worker's absolute origin, sends no cookie, and carries `Authorization: Bearer`. The credential is the raw
|
|
21
|
+
* session token a sign-in answered with, held in memory and kept in the Keychain or the Keystore through
|
|
22
|
+
* `expo-secure-store`, never in AsyncStorage. `@pithy-sh/auth/src/client/bearer` rotates it before it expires.
|
|
23
|
+
*
|
|
24
|
+
* **On the web the session is the page's httpOnly cookie**, exactly as the React screens hold it: calls are
|
|
25
|
+
* relative, and nothing here stores anything. The web build is served from the Worker's own origin.
|
|
26
|
+
*
|
|
27
|
+
* Every client takes its options from here, so a screen never decides the transport itself: auth calls take
|
|
28
|
+
* {@link Transport.auth}, organization calls {@link Transport.organization}, and payments and any other
|
|
29
|
+
* capability {@link Transport.capability}.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** Where the device's own id is kept. */
|
|
33
|
+
export const DEVICE_ID_KEY = "pithy.device.id";
|
|
34
|
+
|
|
35
|
+
/** Where the visitor id consent knows a signed-out phone by is kept. */
|
|
36
|
+
export const CONSENT_VISITOR_KEY = "pithy.consent.visitor";
|
|
37
|
+
|
|
38
|
+
/** `expo-secure-store`, in the shape the bearer session persists through. */
|
|
39
|
+
export const secureStore: SecureStore = {
|
|
40
|
+
getItem: (key) => ExpoSecureStore.getItemAsync(key),
|
|
41
|
+
setItem: (key, value) => ExpoSecureStore.setItemAsync(key, value),
|
|
42
|
+
deleteItem: (key) => ExpoSecureStore.deleteItemAsync(key),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** The hardware React Native reports, as a label and a model. iOS reports only what kind of device it is. */
|
|
46
|
+
function hardware(): { name?: string; model?: string } {
|
|
47
|
+
if (Platform.OS === "android") {
|
|
48
|
+
const { Manufacturer, Model } = Platform.constants ?? {};
|
|
49
|
+
if (!Model) return {};
|
|
50
|
+
return { name: `${Manufacturer ?? ""} ${Model}`.trim(), model: Model };
|
|
51
|
+
}
|
|
52
|
+
if (Platform.OS === "ios") {
|
|
53
|
+
const idiom = Platform.constants?.interfaceIdiom;
|
|
54
|
+
if (idiom === "phone") return { name: "iPhone" };
|
|
55
|
+
if (idiom === "pad") return { name: "iPad" };
|
|
56
|
+
}
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The device a sign-in registers: an id minted once per install and kept in the secure store, the platform, and
|
|
62
|
+
* what React Native says about the hardware. A store that refuses costs the id's permanence, never the sign-in.
|
|
63
|
+
*/
|
|
64
|
+
export async function deviceInfo(store: SecureStore, platform: DeviceInfo["platform"]): Promise<DeviceInfo> {
|
|
65
|
+
let id: string | null = null;
|
|
66
|
+
try {
|
|
67
|
+
id = await store.getItem(DEVICE_ID_KEY);
|
|
68
|
+
} catch {
|
|
69
|
+
id = null;
|
|
70
|
+
}
|
|
71
|
+
if (id === null || id === "") {
|
|
72
|
+
id = randomUUID();
|
|
73
|
+
try {
|
|
74
|
+
await store.setItem(DEVICE_ID_KEY, id);
|
|
75
|
+
} catch {
|
|
76
|
+
// Kept for this launch only. The next launch registers as a new device.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { id, platform, ...hardware() };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The id consent holds a signed-out choice against, minted once per install and kept in the secure store. It is
|
|
84
|
+
* its own random UUID, never the device id: that one is registered at sign-in and listed to operators, and consent
|
|
85
|
+
* takes whoever sends its visitor id to be that visitor. A store that refuses costs the id's permanence.
|
|
86
|
+
*/
|
|
87
|
+
export async function consentVisitor(store: SecureStore): Promise<string> {
|
|
88
|
+
let id: string | null = null;
|
|
89
|
+
try {
|
|
90
|
+
id = await store.getItem(CONSENT_VISITOR_KEY);
|
|
91
|
+
} catch {
|
|
92
|
+
id = null;
|
|
93
|
+
}
|
|
94
|
+
if (id !== null && id !== "") return id;
|
|
95
|
+
const minted = randomUUID();
|
|
96
|
+
try {
|
|
97
|
+
await store.setItem(CONSENT_VISITOR_KEY, minted);
|
|
98
|
+
} catch {
|
|
99
|
+
// Kept for this launch only. A choice made now is not found on the next launch.
|
|
100
|
+
}
|
|
101
|
+
return minted;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** An image on the Worker, as React Native's `<Image source>` takes it. */
|
|
105
|
+
export interface WorkerImage {
|
|
106
|
+
/** Where it is: an absolute URL on a phone, the path itself on the web. */
|
|
107
|
+
readonly uri: string;
|
|
108
|
+
/** The credential a phone's request for it carries. Absent on the web, where the page's cookie goes with it. */
|
|
109
|
+
readonly headers?: Record<string, string>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `path` when it is a rooted path on the Worker, or `null`: one leading slash, nothing that reads as a second one,
|
|
114
|
+
* and no control character, so prefixing the origin cannot name another host.
|
|
115
|
+
*/
|
|
116
|
+
function workerPath(path: string): string | null {
|
|
117
|
+
if (!path.startsWith("/") || path[1] === "/" || path[1] === "\\") return null;
|
|
118
|
+
for (const char of path) {
|
|
119
|
+
const code = char.charCodeAt(0);
|
|
120
|
+
if (code < 0x20 || code === 0x7f || char === "\\") return null;
|
|
121
|
+
}
|
|
122
|
+
return path;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Client options that present one credential, the one held when they were made. */
|
|
126
|
+
export interface PinnedCredential {
|
|
127
|
+
/** Pass to an `@pithy-sh/auth` client call. */
|
|
128
|
+
readonly auth: AuthClientOptions;
|
|
129
|
+
/** Spread into any other capability's client options. */
|
|
130
|
+
readonly capability: { readonly origin: string; readonly fetch: AuthFetch };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** What every client call takes, and the credential behind it. */
|
|
134
|
+
export interface Transport {
|
|
135
|
+
/** True on a phone, where the credential is a bearer token. */
|
|
136
|
+
readonly bearer: boolean;
|
|
137
|
+
/** Pass to every `@pithy-sh/auth` client call. */
|
|
138
|
+
readonly auth: AuthClientOptions;
|
|
139
|
+
/** Pass to every `@pithy-sh/organization` client call. */
|
|
140
|
+
readonly organization: OrganizationClientOptions;
|
|
141
|
+
/** Spread into any other capability's client options, such as payments'. */
|
|
142
|
+
readonly capability: { readonly origin?: string; readonly fetch?: AuthFetch };
|
|
143
|
+
/** The bearer session, restored from the store on first use; `null` on the web. */
|
|
144
|
+
session(): Promise<BearerSession | null>;
|
|
145
|
+
/** Hold the raw session token a sign-in answered with. Stored before it resolves. Nothing on the web. */
|
|
146
|
+
adopt(token: string): Promise<void>;
|
|
147
|
+
/** Forget the credential here and in the store, after the server's sign-out. Nothing on the web. */
|
|
148
|
+
forget(): Promise<void>;
|
|
149
|
+
/**
|
|
150
|
+
* Run `listener` each time the credential this device holds is adopted or forgotten: someone signed in or out.
|
|
151
|
+
* Answers a function that stops it. Never runs on the web, where the cookie changes out of sight.
|
|
152
|
+
*/
|
|
153
|
+
onChange(listener: () => void): () => void;
|
|
154
|
+
/**
|
|
155
|
+
* The credential held now, fixed: every call made through what this answers presents exactly it, whatever is
|
|
156
|
+
* adopted or forgotten meanwhile, so what one call learns about whose credential it is stays true of the next
|
|
157
|
+
* call sent with it. A rotation since leaves it spent, and the Worker refuses it. `null` when nothing is held,
|
|
158
|
+
* and on the web, where the cookie cannot be held still.
|
|
159
|
+
*/
|
|
160
|
+
pinned(): Promise<PinnedCredential | null>;
|
|
161
|
+
/**
|
|
162
|
+
* How to draw an image the Worker serves at `path`, such as an organization's mark: `<Image>` makes its own
|
|
163
|
+
* request, so on a phone it is handed the origin and the credential. `null` for anything but a path on the Worker.
|
|
164
|
+
*/
|
|
165
|
+
image(path: string): Promise<WorkerImage | null>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** What {@link createTransport} is built from. The app's own is {@link transport}. */
|
|
169
|
+
export interface TransportInput {
|
|
170
|
+
readonly platform: "ios" | "android" | "web";
|
|
171
|
+
readonly origin: string;
|
|
172
|
+
readonly authBasePath: string;
|
|
173
|
+
/** Where consent's routes mount. A phone's calls under it carry the visitor id consent knows it by. */
|
|
174
|
+
readonly consentBasePath: string;
|
|
175
|
+
readonly store: SecureStore;
|
|
176
|
+
/** The fetch under the session. The platform's own by default. */
|
|
177
|
+
readonly fetch?: AuthFetch;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A transport for `platform`: bearer to `origin` on a phone, the page's cookie on the web. */
|
|
181
|
+
export function createTransport(input: TransportInput): Transport {
|
|
182
|
+
const { platform, store } = input;
|
|
183
|
+
if (platform === "web") {
|
|
184
|
+
return {
|
|
185
|
+
bearer: false,
|
|
186
|
+
auth: { basePath: input.authBasePath },
|
|
187
|
+
organization: {},
|
|
188
|
+
capability: {},
|
|
189
|
+
session: async () => null,
|
|
190
|
+
adopt: async () => undefined,
|
|
191
|
+
forget: async () => undefined,
|
|
192
|
+
onChange: () => () => undefined,
|
|
193
|
+
pinned: async () => null,
|
|
194
|
+
image: async (path) => {
|
|
195
|
+
const own = workerPath(path);
|
|
196
|
+
return own === null ? null : { uri: own };
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let opened: Promise<BearerSession> | null = null;
|
|
202
|
+
const open = (): Promise<BearerSession> => {
|
|
203
|
+
opened ??= (async () => {
|
|
204
|
+
const session = createBearerSession({
|
|
205
|
+
origin: input.origin,
|
|
206
|
+
basePath: input.authBasePath,
|
|
207
|
+
store,
|
|
208
|
+
device: await deviceInfo(store, platform),
|
|
209
|
+
...(input.fetch ? { fetch: input.fetch } : {}),
|
|
210
|
+
});
|
|
211
|
+
await session.restore();
|
|
212
|
+
return session;
|
|
213
|
+
})();
|
|
214
|
+
return opened;
|
|
215
|
+
};
|
|
216
|
+
// The session's own fetch, once it is open: it adds the credential, rotates it when due, and refuses any
|
|
217
|
+
// address off the Worker's origin.
|
|
218
|
+
const fetch: AuthFetch = async (url, init) => (await open()).fetch(url, init);
|
|
219
|
+
// Signed out, a phone has no cookie for consent to know its visitor by, so consent's requests carry its own id.
|
|
220
|
+
let visitor: Promise<string> | null = null;
|
|
221
|
+
const consentPrefix = `${input.origin}${input.consentBasePath}/`;
|
|
222
|
+
const capabilityFetch: AuthFetch = async (url, init) => {
|
|
223
|
+
if (!url.startsWith(consentPrefix)) return fetch(url, init);
|
|
224
|
+
visitor ??= consentVisitor(store);
|
|
225
|
+
return fetch(url, { ...init, headers: { ...init?.headers, [CONSENT_VISITOR_HEADER]: await visitor } });
|
|
226
|
+
};
|
|
227
|
+
const listeners = new Set<() => void>();
|
|
228
|
+
const changed = (): void => {
|
|
229
|
+
for (const listener of [...listeners]) listener();
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
bearer: true,
|
|
234
|
+
auth: { origin: input.origin, basePath: input.authBasePath, fetch },
|
|
235
|
+
organization: { origin: input.origin, fetch },
|
|
236
|
+
capability: { origin: input.origin, fetch: capabilityFetch },
|
|
237
|
+
session: open,
|
|
238
|
+
adopt: async (token) => {
|
|
239
|
+
await (await open()).adopt(token);
|
|
240
|
+
changed();
|
|
241
|
+
},
|
|
242
|
+
forget: async () => {
|
|
243
|
+
await (await open()).clear();
|
|
244
|
+
changed();
|
|
245
|
+
},
|
|
246
|
+
onChange: (listener) => {
|
|
247
|
+
listeners.add(listener);
|
|
248
|
+
return () => {
|
|
249
|
+
listeners.delete(listener);
|
|
250
|
+
};
|
|
251
|
+
},
|
|
252
|
+
pinned: async () => {
|
|
253
|
+
const authorization = await (await open()).authorization(`${input.origin}/`);
|
|
254
|
+
if (authorization === null) return null;
|
|
255
|
+
const held: AuthFetch = (url, init) => {
|
|
256
|
+
if (!url.startsWith(`${input.origin}/`)) {
|
|
257
|
+
return Promise.reject(new TypeError("A pinned credential only goes to the Worker's own origin."));
|
|
258
|
+
}
|
|
259
|
+
const underlying = resolveFetch({ fetch: input.fetch });
|
|
260
|
+
if (!underlying) return Promise.reject(new TypeError("No fetch is available to send this request."));
|
|
261
|
+
const headers: Record<string, string> = {};
|
|
262
|
+
for (const [name, value] of Object.entries(init?.headers ?? {})) {
|
|
263
|
+
if (name.toLowerCase() !== "authorization") headers[name] = value;
|
|
264
|
+
}
|
|
265
|
+
headers.authorization = authorization;
|
|
266
|
+
return underlying(url, withoutCookies({ ...init, headers }));
|
|
267
|
+
};
|
|
268
|
+
return {
|
|
269
|
+
auth: { origin: input.origin, basePath: input.authBasePath, fetch: held },
|
|
270
|
+
capability: { origin: input.origin, fetch: held },
|
|
271
|
+
};
|
|
272
|
+
},
|
|
273
|
+
image: async (path) => {
|
|
274
|
+
const own = workerPath(path);
|
|
275
|
+
if (own === null) return null;
|
|
276
|
+
const uri = `${input.origin}${own}`;
|
|
277
|
+
const authorization = await (await open()).authorization(uri);
|
|
278
|
+
return authorization === null ? { uri } : { uri, headers: { authorization } };
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** This app's transport. */
|
|
284
|
+
export const transport: Transport = createTransport({
|
|
285
|
+
platform: devicePlatform(),
|
|
286
|
+
origin,
|
|
287
|
+
authBasePath: authConfig.basePath,
|
|
288
|
+
consentBasePath: consentConfig.basePath,
|
|
289
|
+
store: secureStore,
|
|
290
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { TURNSTILE_SCRIPT_ORIGIN, TURNSTILE_WIDGET_PATH } from "@pithy-sh/turnstile/src/http/widgetPage";
|
|
2
|
+
import { type ReactNode, useRef } from "react";
|
|
3
|
+
import { View } from "react-native";
|
|
4
|
+
import { WebView } from "react-native-webview";
|
|
5
|
+
import { origin, turnstileConfig } from "./pithy-config";
|
|
6
|
+
import { onDevice, queryString } from "./platform";
|
|
7
|
+
import { useTheme } from "./theme";
|
|
8
|
+
import { useTurnstileWeb } from "./useTurnstileWeb";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The humanity check in front of sign-in, when the Worker composes Turnstile.
|
|
12
|
+
*
|
|
13
|
+
* On a phone, Cloudflare's widget runs in a WebView on a page the Worker serves from its own origin, and the page
|
|
14
|
+
* posts the token back. The WebView loads nothing but that page and Cloudflare's challenge, and a message from any
|
|
15
|
+
* other page is dropped, so nothing else can hand the app a token. On the web the widget renders into the page.
|
|
16
|
+
*
|
|
17
|
+
* Either way the token goes where the Worker reads it: {@link turnstileRequest} puts it in the body field or the
|
|
18
|
+
* header the projection names.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Where the token goes on a gated request: `token.header` when one is configured, `token.field` in the body
|
|
23
|
+
* otherwise. Returns both halves already decided.
|
|
24
|
+
*/
|
|
25
|
+
export function turnstileRequest(
|
|
26
|
+
body: Record<string, unknown>,
|
|
27
|
+
value: string | null,
|
|
28
|
+
): { body: Record<string, unknown>; headers: Record<string, string> } {
|
|
29
|
+
if (!turnstileConfig.enabled || !value) return { body, headers: {} };
|
|
30
|
+
if (turnstileConfig.token.header) return { body, headers: { [turnstileConfig.token.header]: value } };
|
|
31
|
+
return { body: { ...body, [turnstileConfig.token.field]: value }, headers: {} };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** True when a gated form still needs a token before it can submit. */
|
|
35
|
+
export function turnstilePending(value: string | null): boolean {
|
|
36
|
+
return turnstileConfig.enabled && !value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The widget page's address on the Worker, drawn in the app's color scheme. */
|
|
40
|
+
export function widgetUrl(scheme: "light" | "dark"): string {
|
|
41
|
+
return `${origin}${TURNSTILE_WIDGET_PATH}${queryString({ theme: scheme })}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Whether `url` is the Worker's widget page, query aside. */
|
|
45
|
+
function isWidgetPage(url: string): boolean {
|
|
46
|
+
const page = `${origin}${TURNSTILE_WIDGET_PATH}`;
|
|
47
|
+
return url === page || url.startsWith(`${page}?`) || url.startsWith(`${page}#`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether the WebView may load `url`: the widget page itself, and Cloudflare's challenge in its frames. Everything
|
|
52
|
+
* else is refused, so a link inside a challenge can never take the view somewhere else.
|
|
53
|
+
*/
|
|
54
|
+
export function allowWidgetNavigation(url: string, isTopFrame = true): boolean {
|
|
55
|
+
if (isWidgetPage(url)) return true;
|
|
56
|
+
if (isTopFrame) return false;
|
|
57
|
+
return url.startsWith(`${TURNSTILE_SCRIPT_ORIGIN}/`) || url === "about:blank" || url === "about:srcdoc";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What the widget page said. */
|
|
61
|
+
export type TurnstileMessage = { kind: "token"; token: string } | { kind: "expired" } | { kind: "error" };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The message a WebView event carries, or `null` when it is not one the widget page sent: posted from any other
|
|
65
|
+
* page, not JSON, or not one of the page's three shapes.
|
|
66
|
+
*/
|
|
67
|
+
export function turnstileMessage(event: { url: string; data: string }): TurnstileMessage | null {
|
|
68
|
+
if (!isWidgetPage(event.url)) return null;
|
|
69
|
+
let parsed: unknown;
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(event.data);
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
76
|
+
const { type, token } = parsed as { type?: unknown; token?: unknown };
|
|
77
|
+
if (type === "turnstile" && typeof token === "string" && token !== "") return { kind: "token", token };
|
|
78
|
+
if (type === "turnstile-expired") return { kind: "expired" };
|
|
79
|
+
if (type === "turnstile-error") return { kind: "error" };
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The widget in a WebView on the Worker's origin. */
|
|
84
|
+
function DeviceTurnstile(props: { onToken: (value: string | null) => void }): ReactNode {
|
|
85
|
+
const theme = useTheme();
|
|
86
|
+
const invisible = turnstileConfig.mode === "invisible";
|
|
87
|
+
return (
|
|
88
|
+
<WebView
|
|
89
|
+
testID="turnstile"
|
|
90
|
+
source={{ uri: widgetUrl(theme.scheme) }}
|
|
91
|
+
originWhitelist={[origin, TURNSTILE_SCRIPT_ORIGIN]}
|
|
92
|
+
onShouldStartLoadWithRequest={(request) => allowWidgetNavigation(request.url, request.isTopFrame ?? true)}
|
|
93
|
+
onMessage={(event) => {
|
|
94
|
+
const message = turnstileMessage(event.nativeEvent);
|
|
95
|
+
if (message) props.onToken(message.kind === "token" ? message.token : null);
|
|
96
|
+
}}
|
|
97
|
+
onError={() => props.onToken(null)}
|
|
98
|
+
onHttpError={() => props.onToken(null)}
|
|
99
|
+
javaScriptEnabled
|
|
100
|
+
scrollEnabled={false}
|
|
101
|
+
style={{ height: invisible ? 1 : 72, opacity: invisible ? 0 : 1, backgroundColor: "transparent" }}
|
|
102
|
+
/>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The widget rendered into the page, on the web. */
|
|
107
|
+
function WebTurnstile(props: { onToken: (value: string | null) => void }): ReactNode {
|
|
108
|
+
const host = useRef<View>(null);
|
|
109
|
+
useTurnstileWeb(host, turnstileConfig.enabled, props.onToken);
|
|
110
|
+
return <View ref={host} testID="turnstile" style={{ width: "100%" }} />;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The widget. Renders nothing when the capability is not composed. Keep `onToken` stable across renders. */
|
|
114
|
+
export function Turnstile(props: { onToken: (value: string | null) => void }): ReactNode {
|
|
115
|
+
if (!turnstileConfig.enabled) return null;
|
|
116
|
+
return onDevice() ? <DeviceTurnstile onToken={props.onToken} /> : <WebTurnstile onToken={props.onToken} />;
|
|
117
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { AppState, type AppStateStatus } from "react-native";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Call `onActive` each time the app comes back to the foreground: after a checkout in the system browser, or a
|
|
6
|
+
* trip to the store's subscription settings, the screen reads the subscription again rather than show what it
|
|
7
|
+
* read before the reader left. Not on the first render; the screen's own read covers that.
|
|
8
|
+
*/
|
|
9
|
+
export function useAppFocus(onActive: () => void): void {
|
|
10
|
+
const latest = useRef(onActive);
|
|
11
|
+
latest.current = onActive;
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
let previous: AppStateStatus = AppState.currentState;
|
|
14
|
+
const subscription = AppState.addEventListener("change", (next) => {
|
|
15
|
+
if (previous !== "active" && next === "active") latest.current();
|
|
16
|
+
previous = next;
|
|
17
|
+
});
|
|
18
|
+
return () => subscription.remove();
|
|
19
|
+
}, []);
|
|
20
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { storeRailHere, watchStoreTransactions } from "./payments";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Listen to the platform's store for as long as the app runs, and submit what it delivers with no screen waiting:
|
|
6
|
+
* a purchase interrupted before it was finished, an approved Ask to Buy request, a renewal. Each is finished only
|
|
7
|
+
* once the Worker has accepted it. Nothing on the web, or where this platform sells through no store.
|
|
8
|
+
*
|
|
9
|
+
* The root layout mounts it, so a purchase that lands at launch is on the account before any screen asks.
|
|
10
|
+
*/
|
|
11
|
+
export function useStoreTransactions(): void {
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const rail = storeRailHere();
|
|
14
|
+
return rail === null ? undefined : watchStoreTransactions(rail);
|
|
15
|
+
}, []);
|
|
16
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useRead } from "@pithy-sh/ui-core/src/read";
|
|
2
|
+
import { type RefObject, useEffect } from "react";
|
|
3
|
+
import type { View } from "react-native";
|
|
4
|
+
import { turnstileConfig } from "./pithy-config";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Cloudflare's Turnstile widget in the browser, for the web build. React Native Web renders a `View` as a DOM
|
|
8
|
+
* element, so the widget renders into it the way it does on any page. Only the web build calls this; on a phone the
|
|
9
|
+
* widget runs in a WebView on the Worker's own origin instead.
|
|
10
|
+
*
|
|
11
|
+
* This program has no DOM types, so the few calls it makes are declared here.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
|
|
15
|
+
|
|
16
|
+
interface TurnstileApi {
|
|
17
|
+
render(
|
|
18
|
+
element: unknown,
|
|
19
|
+
options: {
|
|
20
|
+
sitekey: string;
|
|
21
|
+
action: string;
|
|
22
|
+
appearance: "always" | "execute";
|
|
23
|
+
theme: "light" | "dark" | "auto";
|
|
24
|
+
size: "normal" | "flexible" | "compact";
|
|
25
|
+
callback: (value: string) => void;
|
|
26
|
+
"expired-callback": () => void;
|
|
27
|
+
"error-callback": () => void;
|
|
28
|
+
},
|
|
29
|
+
): string;
|
|
30
|
+
remove?(widget: string): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ScriptElement {
|
|
34
|
+
src: string;
|
|
35
|
+
async: boolean;
|
|
36
|
+
onload: (() => void) | null;
|
|
37
|
+
onerror: (() => void) | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Browser {
|
|
41
|
+
turnstile?: TurnstileApi;
|
|
42
|
+
document?: { createElement(tag: "script"): ScriptElement; head: { appendChild(node: ScriptElement): unknown } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const browser = globalThis as unknown as Browser;
|
|
46
|
+
|
|
47
|
+
let script: Promise<void> | undefined;
|
|
48
|
+
|
|
49
|
+
/** Turnstile's script, appended once per page. A load that failed is forgotten, so the next widget asks again. */
|
|
50
|
+
function loadScript(): Promise<void> {
|
|
51
|
+
if (browser.turnstile) return Promise.resolve();
|
|
52
|
+
script ??= new Promise<void>((resolve, reject) => {
|
|
53
|
+
const document = browser.document;
|
|
54
|
+
if (!document) {
|
|
55
|
+
reject(new Error("Turnstile needs a browser."));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const element = document.createElement("script");
|
|
59
|
+
element.src = SCRIPT_SRC;
|
|
60
|
+
element.async = true;
|
|
61
|
+
element.onload = () => resolve();
|
|
62
|
+
element.onerror = () => reject(new Error("Turnstile failed to load."));
|
|
63
|
+
document.head.appendChild(element);
|
|
64
|
+
}).catch((failure: unknown) => {
|
|
65
|
+
script = undefined;
|
|
66
|
+
throw failure;
|
|
67
|
+
});
|
|
68
|
+
return script;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Render the widget into `host` once its script has loaded, handing each token, and each expiry or failure as
|
|
73
|
+
* `null`, to `onToken`, and take it off the page on unmount. A script that will not load answers `null`, so a form
|
|
74
|
+
* waiting on a token says so.
|
|
75
|
+
*/
|
|
76
|
+
export function useTurnstileWeb(
|
|
77
|
+
host: RefObject<View | null>,
|
|
78
|
+
enabled: boolean,
|
|
79
|
+
onToken: (value: string | null) => void,
|
|
80
|
+
): void {
|
|
81
|
+
const loaded = useRead("turnstile-script", loadScript, { skip: !enabled });
|
|
82
|
+
const state = loaded.failure ? "failed" : loaded.loading ? "loading" : "loaded";
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!enabled) return;
|
|
85
|
+
if (state === "failed") {
|
|
86
|
+
onToken(null);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const turnstile = browser.turnstile;
|
|
90
|
+
if (state === "loading" || !host.current || !turnstile) return;
|
|
91
|
+
const widget = turnstile.render(host.current, {
|
|
92
|
+
sitekey: turnstileConfig.sitekey,
|
|
93
|
+
// The action is the projection's, never retyped: the Worker asserts this exact string against the token.
|
|
94
|
+
action: turnstileConfig.action,
|
|
95
|
+
appearance: turnstileConfig.mode === "invisible" ? "execute" : "always",
|
|
96
|
+
theme: "auto",
|
|
97
|
+
size: "flexible",
|
|
98
|
+
callback: (value) => onToken(value),
|
|
99
|
+
"expired-callback": () => onToken(null),
|
|
100
|
+
"error-callback": () => onToken(null),
|
|
101
|
+
});
|
|
102
|
+
return () => turnstile.remove?.(widget);
|
|
103
|
+
}, [host, enabled, state, onToken]);
|
|
104
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
// The app's TypeScript: Expo's base, strict. Expo writes `expo-env.d.ts` on its first start, which declares
|
|
3
|
+
// Metro's `require` and the app's assets.
|
|
4
|
+
"extends": "expo/tsconfig.base",
|
|
5
|
+
"compilerOptions": {
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noUncheckedIndexedAccess": true
|
|
8
|
+
},
|
|
9
|
+
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
|
10
|
+
}
|
package/README.md
DELETED