@marktiderman/genesis-supabase 0.1.1 → 0.2.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 +178 -0
- package/dist/index.d.ts +26 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/native.d.ts +148 -4
- package/dist/native.js +128 -1
- package/dist/native.js.map +1 -1
- package/migrations/0001_genesis_account_deletion.sql +133 -0
- package/migrations/0002_genesis_app_release_policy.sql +82 -0
- package/migrations/tests/0001_genesis_account_deletion.test.sql +138 -0
- package/migrations/tests/0002_genesis_app_release_policy.test.sql +148 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -23,9 +23,187 @@ const rows = must(await supabase.from("tasks").select("*"));
|
|
|
23
23
|
- **`refreshWhileActive(client, AppState)`**: refreshes the session only while the app is in front.
|
|
24
24
|
- **`wipeSessionOnReinstall`**: the iOS Keychain outlives an uninstall; this removes a leftover session (and its PKCE verifier) on the first launch after an install.
|
|
25
25
|
- **`must(result)`**: the data, or a thrown `SupabaseQueryError` carrying the database's `code`, `details` and `hint`.
|
|
26
|
+
- **`signInWithApple`, `completeOAuthRedirect`, `deleteMyAccount`**: see [Account](#account-sign-in-and-deletion) below.
|
|
27
|
+
- **`app_release_policy`**: the table the native updates block reads; see [Release policy](#release-policy-the-updates-block).
|
|
26
28
|
|
|
27
29
|
Nothing here imports React Native or Expo: you pass those modules in.
|
|
28
30
|
|
|
31
|
+
## Account: sign-in and deletion
|
|
32
|
+
|
|
33
|
+
What the App Store asks of an app with accounts: an equivalent privacy-preserving login beside any
|
|
34
|
+
other third-party sign-in (Guideline 4.8; Sign in with Apple satisfies it), Apple's own button, and
|
|
35
|
+
deleting the account from inside the app (5.1.1(v)), including revoking the person's Apple tokens.
|
|
36
|
+
Everything below is covered except that last server step; see [Not covered yet](#not-covered-yet).
|
|
37
|
+
|
|
38
|
+
### Sign in with Apple
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import * as AppleAuthentication from "expo-apple-authentication";
|
|
42
|
+
import * as Crypto from "expo-crypto";
|
|
43
|
+
import { signInWithApple } from "@marktiderman/genesis-supabase/native";
|
|
44
|
+
|
|
45
|
+
const digest = (s: string) => Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, s);
|
|
46
|
+
|
|
47
|
+
async function onApple() {
|
|
48
|
+
const result = await signInWithApple(supabase, AppleAuthentication, { digest, randomNonce: Crypto.randomUUID });
|
|
49
|
+
if (result.canceled) return; // they closed Apple's sheet: not an error
|
|
50
|
+
if (result.error) return showError(result.error.message); // result.error.code says why
|
|
51
|
+
// Apple shares the name on the first sign-in only, so save it now.
|
|
52
|
+
if (result.fullName) await supabase.auth.updateUser({ data: { full_name: result.fullName } });
|
|
53
|
+
// For revoking on deletion: hand the single-use code to your server now (see below).
|
|
54
|
+
if (result.authorizationCode) await supabase.functions.invoke("apple-store-token", { body: { code: result.authorizationCode } });
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- **`signInWithApple(client, AppleAuthentication, { digest, randomNonce? })`**: checks
|
|
59
|
+
`isAvailableAsync()`, asks for name and email with the SHA-256 (lowercase hex; anything else is
|
|
60
|
+
`bad_digest`) of a fresh nonce, and signs in with `signInWithIdToken({ provider: "apple", token, nonce })`
|
|
61
|
+
(Supabase gets the raw nonce and checks the token against it). Returns
|
|
62
|
+
`{ session, user, fullName, email, authorizationCode }`, `{ canceled: true }` or `{ error }` (a
|
|
63
|
+
`GenesisAuthError` with a `code`, plus `fullName` when Apple shared one, so a retry can still
|
|
64
|
+
save it); it never throws.
|
|
65
|
+
- Draw it with `appleProvider({ apple: AppleAuthentication, onPress: onApple })` from
|
|
66
|
+
`@marktiderman/genesis-ui-native` in `AuthScreen`'s `providers`: Apple's own button on iOS,
|
|
67
|
+
nothing elsewhere.
|
|
68
|
+
- Setup is yours: enable the Apple provider in Supabase Auth with your bundle id as a client id,
|
|
69
|
+
turn on the Sign in with Apple capability (`ios.usesAppleSignIn: true` in `app.json`), and add
|
|
70
|
+
`expo-apple-authentication` and `expo-crypto`.
|
|
71
|
+
|
|
72
|
+
### Other OAuth providers (Google…): the redirect
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import * as WebBrowser from "expo-web-browser";
|
|
76
|
+
import { completeOAuthRedirect } from "@marktiderman/genesis-supabase/native";
|
|
77
|
+
|
|
78
|
+
const redirectTo = "myapp://auth/callback";
|
|
79
|
+
const { data } = await supabase.auth.signInWithOAuth({ provider: "google", options: { redirectTo, skipBrowserRedirect: true } });
|
|
80
|
+
const res = await WebBrowser.openAuthSessionAsync(data.url!, redirectTo);
|
|
81
|
+
if (res.type === "success") {
|
|
82
|
+
const result = await completeOAuthRedirect(supabase, res.url);
|
|
83
|
+
if (result.error) showError(result.error.message); // never result.error.providerMessage
|
|
84
|
+
else if (result.redirectType === "recovery") router.push("/set-password");
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
- **`completeOAuthRedirect(client, url)`**: reads the redirect's query and fragment. A `code` is
|
|
89
|
+
exchanged with `exchangeCodeForSession`, with the redirect's `sb_flow_id` when it has one so the
|
|
90
|
+
right flow's verifier is used. An `error` comes back as `{ error }` with no request. Returns
|
|
91
|
+
`{ session, redirectType }` (`"recovery"` after a password-reset link) or `{ error }`; it never throws.
|
|
92
|
+
- **Pass only the URL `openAuthSessionAsync` resolved with**, for the sign-in the app just started.
|
|
93
|
+
A code is single-use and the exchange spends the stored verifier whatever happens, so feeding it a
|
|
94
|
+
stray `?code=` from some other link burns the sign-in in progress.
|
|
95
|
+
- **Handle each redirect once.** If the app also listens with `Linking.addEventListener("url", …)`,
|
|
96
|
+
the same redirect arrives twice and the second exchange fails just after the first signed them
|
|
97
|
+
in. Pick one path (the auth-session result), or remember the last code you handled and skip it.
|
|
98
|
+
- **The error text is fixed copy.** `error.message` is chosen from the `code`; the redirect's own
|
|
99
|
+
`error_description` is only on `error.providerMessage`, which is **untrusted**: any web page or
|
|
100
|
+
app can open your callback link with any text in it. Log it, don't show it.
|
|
101
|
+
- **Native needs `flowType: "pkce"`**, which `createGenesisSupabase` sets. With the implicit flow
|
|
102
|
+
the redirect carries tokens instead of a code, and this returns `missing_code`.
|
|
103
|
+
- **Redirect URLs:** add the exact deep link (`myapp://auth/callback`) to Supabase Auth's Redirect
|
|
104
|
+
URLs, not a wildcard such as `myapp://**`.
|
|
105
|
+
- **Why the deep link is safe:** another app can claim your custom scheme, and any page can open
|
|
106
|
+
it. That's harmless because of PKCE: a code is useless without the verifier, which never leaves
|
|
107
|
+
this device. The implicit flow would hand tokens to whoever owns the scheme.
|
|
108
|
+
|
|
109
|
+
### Deleting the account
|
|
110
|
+
|
|
111
|
+
The database half is a migration this package ships:
|
|
112
|
+
[`migrations/0001_genesis_account_deletion.sql`](./migrations/0001_genesis_account_deletion.sql)
|
|
113
|
+
(also at `node_modules/@marktiderman/genesis-supabase/migrations/`). Copy it into your app's
|
|
114
|
+
`supabase/migrations/` under a new timestamped name. It defines `public.delete_my_account()`, a
|
|
115
|
+
`security definer` function with `search_path = ''` that deletes the caller's `auth.users` row,
|
|
116
|
+
executable by `authenticated` only (granted and revoked by name; a later blanket
|
|
117
|
+
`grant … on all functions` would hand it back to `anon`, so such a grant must exclude it). Its pgTAP
|
|
118
|
+
test is `migrations/tests/0001_genesis_account_deletion.test.sql`: copy it to
|
|
119
|
+
`supabase/tests/database/` and run `supabase test db`.
|
|
120
|
+
|
|
121
|
+
It refuses, by error code (`SupabaseQueryError.code` on the client):
|
|
122
|
+
|
|
123
|
+
| Code | When |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `42501` | Nobody is signed in, or the person has a verified MFA factor and the session isn't `aal2`: verify the second factor, then retry. |
|
|
126
|
+
| `GA001` | The app's hook refused (below). |
|
|
127
|
+
| `GA002` | The app requires a recent sign-in and this one isn't: sign in again, then retry. |
|
|
128
|
+
| `23503` | A row still references the login: a foreign key without `on delete cascade`, or files in Storage. |
|
|
129
|
+
|
|
130
|
+
- **Your data:** deleting the login removes the rows whose foreign keys to `auth.users(id)` are
|
|
131
|
+
`on delete cascade`, and is refused (`23503`) while any other row still points at it, so the
|
|
132
|
+
person never ends up half-deleted. Give every user-owned table such a foreign key: a table keyed
|
|
133
|
+
on `auth.uid()` without one keeps orphans.
|
|
134
|
+
- **Storage:** Supabase documents that a user who owns objects in Storage can't be deleted, so the
|
|
135
|
+
call fails (`23503`) while they have files. Remove their files through the Storage API first.
|
|
136
|
+
(Documented by Supabase; not verified against a full stack in this package's tests.)
|
|
137
|
+
- **The app hook:** create `private.genesis_before_account_delete(uuid)` and it runs first, in the
|
|
138
|
+
same transaction, with the person's id, to scrub data, set flags, or refuse (e.g. the sole owner
|
|
139
|
+
of a team). Any error it raises cancels the delete and reaches the client as `GA001` (or as the
|
|
140
|
+
hook's own code when that starts with `GA`). The migration's header has an example.
|
|
141
|
+
- **Recent sign-in (optional):** call `perform private.genesis_require_recent_sign_in(interval '10 minutes')`
|
|
142
|
+
from the hook to require that the person authenticated within that time (`GA002` otherwise). It
|
|
143
|
+
reads the newest `amr` timestamp in the JWT, which records the actual sign-in and survives a token
|
|
144
|
+
refresh, and falls back to `iat`. On `GA002`, have them sign in again (or `reauthenticate()`),
|
|
145
|
+
then retry.
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import { Alert } from "react-native";
|
|
149
|
+
import { deleteMyAccount } from "@marktiderman/genesis-supabase";
|
|
150
|
+
|
|
151
|
+
<SettingsPage
|
|
152
|
+
sections={sections}
|
|
153
|
+
dangerousActions={[
|
|
154
|
+
{
|
|
155
|
+
label: "Delete account",
|
|
156
|
+
testID: "delete-account",
|
|
157
|
+
onPress: () =>
|
|
158
|
+
Alert.alert("Delete your account?", "This removes your account and its data for good. It can't be undone.", [
|
|
159
|
+
{ text: "Cancel", style: "cancel" },
|
|
160
|
+
{ text: "Delete", style: "destructive", onPress: () => void deleteMyAccount(supabase).catch(showError) },
|
|
161
|
+
]),
|
|
162
|
+
},
|
|
163
|
+
]}
|
|
164
|
+
/>;
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
- **`deleteMyAccount(client)`**: calls the `delete_my_account` RPC through `must`, then signs this
|
|
168
|
+
device out (`scope: "local"`; the login's refresh tokens went with it). A refusal throws a
|
|
169
|
+
`SupabaseQueryError` and keeps the session. An access token already issued stays valid until it
|
|
170
|
+
expires.
|
|
171
|
+
|
|
172
|
+
### Not covered yet
|
|
173
|
+
|
|
174
|
+
- **Revoking Sign in with Apple tokens on deletion.** Apple requires an app that offers Sign in
|
|
175
|
+
with Apple to revoke the person's tokens through its REST API when they delete their account, and
|
|
176
|
+
App Review rejects apps that don't. Supabase doesn't do it, and the native id-token sign-in never
|
|
177
|
+
gives Supabase an Apple refresh token, so it takes a server step this package doesn't ship yet:
|
|
178
|
+
1. At sign-in, the app sends `result.authorizationCode` to an Edge Function. It is single-use and
|
|
179
|
+
valid for about five minutes.
|
|
180
|
+
2. The function exchanges it at `https://appleid.apple.com/auth/token`
|
|
181
|
+
(`grant_type=authorization_code`, with your client id and a client secret JWT signed with your
|
|
182
|
+
Sign in with Apple key) and stores the returned `refresh_token` against the user, somewhere
|
|
183
|
+
only the service role reads.
|
|
184
|
+
3. Before deletion, a function calls `https://appleid.apple.com/auth/revoke` with that refresh
|
|
185
|
+
token (`token_type_hint=refresh_token`), then calls `delete_my_account` (or the app does).
|
|
186
|
+
|
|
187
|
+
## Release policy (the updates block)
|
|
188
|
+
|
|
189
|
+
[`migrations/0002_genesis_app_release_policy.sql`](./migrations/0002_genesis_app_release_policy.sql)
|
|
190
|
+
defines `public.app_release_policy`, one row per app: the oldest store version each platform may
|
|
191
|
+
run (`min_version_ios`, `min_version_android`), store links, a maintenance switch with a message,
|
|
192
|
+
and release notes (`notes`, an array of `{ version, title?, body }`). Copy it into your app's
|
|
193
|
+
`supabase/migrations/` under a new timestamped name, and its pgTAP test
|
|
194
|
+
(`migrations/tests/0002_genesis_app_release_policy.test.sql`) into `supabase/tests/database/`.
|
|
195
|
+
|
|
196
|
+
- **Who can do what:** row level security is on; anon and authenticated may only `select` (the app
|
|
197
|
+
reads it before sign-in), and only the service role writes. Supabase's default privileges give
|
|
198
|
+
anon and authenticated everything on a new public table, so the migration revokes them by name
|
|
199
|
+
and grants back `select` alone.
|
|
200
|
+
- **Malformed values stay out:** check constraints require plain dotted versions (`1.4.0`), https
|
|
201
|
+
(or `itms-apps://` / `market://`) store links, a store link beside each minimum version (the
|
|
202
|
+
force-upgrade screen's only way out), and notes that are objects with a string `version` and
|
|
203
|
+
`body` (and a string `title`, if any).
|
|
204
|
+
- **Read it** with `supabasePolicySource(supabase)` from `@marktiderman/genesis-native/updates`,
|
|
205
|
+
which fails open: no row, a failed read or a malformed value never blocks anyone.
|
|
206
|
+
|
|
29
207
|
## Choices, and why
|
|
30
208
|
|
|
31
209
|
- **Keychain chunks rather than encrypted AsyncStorage.** Supabase's Expo guide shows two options: an AES-encrypted value in AsyncStorage with the key in SecureStore ("LargeSecureStore"), or `expo-sqlite/localStorage`. `keychainStorage` keeps the whole session in the Keychain/Keystore instead, split into chunks under the item size limit, with an atomic switch between generations. That means one native dependency (`expo-secure-store`) rather than three, no key to manage, and a session that can't be read off the device's file system. If you prefer LargeSecureStore, pass it as `storage`; everything else here works the same.
|
package/dist/index.d.ts
CHANGED
|
@@ -74,5 +74,30 @@ declare function must<T>(result: {
|
|
|
74
74
|
data: null;
|
|
75
75
|
error: PostgrestErrorLike;
|
|
76
76
|
}): T;
|
|
77
|
+
/** The parts of a Supabase client {@link deleteMyAccount} uses. A `SupabaseClient` fits. */
|
|
78
|
+
interface DeleteAccountClient {
|
|
79
|
+
rpc: (fn: any, ...rest: any[]) => PromiseLike<{
|
|
80
|
+
data: unknown;
|
|
81
|
+
error: PostgrestErrorLike | null;
|
|
82
|
+
}>;
|
|
83
|
+
auth: {
|
|
84
|
+
signOut(options?: {
|
|
85
|
+
scope?: "global" | "local" | "others";
|
|
86
|
+
}): Promise<unknown>;
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* In-app account deletion (App Store Review Guideline 5.1.1(v)), on web and
|
|
91
|
+
* native: calls the `delete_my_account` RPC (this package's
|
|
92
|
+
* `migrations/0001_genesis_account_deletion.sql`), which deletes the login
|
|
93
|
+
* and, through the app's `on delete cascade` foreign keys, its data; then
|
|
94
|
+
* clears the session on this device. Throws a {@link SupabaseQueryError} when
|
|
95
|
+
* the database refuses (`42501`: not signed in) and keeps the session then.
|
|
96
|
+
*
|
|
97
|
+
* Put it behind a confirmation; see the README.
|
|
98
|
+
*
|
|
99
|
+
* @stability Beta
|
|
100
|
+
*/
|
|
101
|
+
declare function deleteMyAccount(client: DeleteAccountClient): Promise<void>;
|
|
77
102
|
|
|
78
|
-
export { type GenesisSupabaseOptions, type PostgrestErrorLike, type SessionStorage, SupabaseQueryError, createGenesisSupabase, must };
|
|
103
|
+
export { type DeleteAccountClient, type GenesisSupabaseOptions, type PostgrestErrorLike, type SessionStorage, SupabaseQueryError, createGenesisSupabase, deleteMyAccount, must };
|
package/dist/index.js
CHANGED
|
@@ -38,7 +38,11 @@ function must(result) {
|
|
|
38
38
|
if (result.error) throw new SupabaseQueryError(result.error);
|
|
39
39
|
return result.data;
|
|
40
40
|
}
|
|
41
|
+
async function deleteMyAccount(client) {
|
|
42
|
+
must(await client.rpc("delete_my_account"));
|
|
43
|
+
await client.auth.signOut({ scope: "local" });
|
|
44
|
+
}
|
|
41
45
|
|
|
42
|
-
export { SupabaseQueryError, createGenesisSupabase, must };
|
|
46
|
+
export { SupabaseQueryError, createGenesisSupabase, deleteMyAccount, must };
|
|
43
47
|
//# sourceMappingURL=index.js.map
|
|
44
48
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";;;AAwCO,SAAS,qBAAA,CAA0C;AAAA,EACxD,GAAA;AAAA,EACA,cAAA;AAAA,EACA,OAAA;AAAA,EACA,QAAA,GAAW,KAAA;AAAA,EACX,qBAAqB,QAAA,KAAa,KAAA;AAAA,EAClC,UAAU;AACZ,CAAA,EAAqD;AAGnD,EAAA,MAAM,MAAA,GAAS,YAAA,CAAa,GAAA,EAAK,cAAA,EAAgB;AAAA,IAC/C,GAAG,OAAA;AAAA,IACH,IAAA,EAAM;AAAA,MACJ,QAAA,EAAU,MAAA;AAAA,MACV,cAAA,EAAgB,IAAA;AAAA,MAChB,gBAAA,EAAkB,IAAA;AAAA,MAClB,kBAAA;AAAA,MACA,GAAI,OAAA,GAAU,EAAE,OAAA,KAAY,EAAC;AAAA,MAC7B,GAAG,OAAA,CAAQ;AAAA;AACb,GACD,CAAA;AACD,EAAA,OAAO,MAAA;AACT;AAkBO,IAAM,kBAAA,GAAN,cAAiC,KAAA,CAAM;AAAA,EACnC,IAAA,GAAO,oBAAA;AAAA,EACP,IAAA;AAAA,EACA,OAAA;AAAA,EACA,IAAA;AAAA,EACT,YAAY,KAAA,EAA2B;AACrC,IAAA,KAAA,CAAM,KAAA,CAAM,OAAA,EAAS,EAAE,KAAA,EAAO,OAAO,CAAA;AACrC,IAAA,IAAA,CAAK,OAAO,KAAA,CAAM,IAAA;AAClB,IAAA,IAAA,CAAK,UAAU,KAAA,CAAM,OAAA;AACrB,IAAA,IAAA,CAAK,OAAO,KAAA,CAAM,IAAA;AAAA,EACpB;AACF;AASO,SAAS,KAAQ,MAAA,EAAiF;AACvG,EAAA,IAAI,OAAO,KAAA,EAAO,MAAM,IAAI,kBAAA,CAAmB,OAAO,KAAK,CAAA;AAC3D,EAAA,OAAO,MAAA,CAAO,IAAA;AAChB","file":"index.js","sourcesContent":["/**\n * Supabase for Genesis apps. Platform-neutral: web, React Native and the\n * server import it. Native-only helpers (Keychain storage, foreground\n * refresh, reinstall wipe) are on `@marktiderman/genesis-supabase/native`.\n */\nimport { createClient, type SupabaseClient, type SupabaseClientOptions } from \"@supabase/supabase-js\";\n\n/** The async key-value store Supabase auth keeps its session in. */\nexport interface SessionStorage {\n getItem(key: string): Promise<string | null>;\n setItem(key: string, value: string): Promise<void>;\n removeItem(key: string): Promise<void>;\n}\n\n/** @stability Beta */\nexport interface GenesisSupabaseOptions {\n url: string;\n /** The publishable (anon) key. Never the secret key in an app. */\n publishableKey: string;\n /** Where the session lives. Native: `keychainStorage(SecureStore)`. Web: leave out for supabase-js's own default. */\n storage?: SessionStorage;\n /** `\"native\"` sets the React Native defaults (no session from the URL). Default `\"web\"`. */\n platform?: \"web\" | \"native\";\n /**\n * Read a session from the URL after an OAuth redirect. Default: `true` on\n * web, `false` on native, where the app hands the redirect URL to\n * `exchangeCodeForSession` itself.\n */\n detectSessionInUrl?: boolean;\n /** Anything else for supabase-js, merged last. */\n options?: SupabaseClientOptions<\"public\">;\n}\n\n/**\n * A Supabase client with Genesis's defaults: PKCE sign-in (the code flow, not\n * tokens in a redirect URL), a persisted session that refreshes itself, and\n * the session storage you pass.\n *\n * @stability Beta\n */\nexport function createGenesisSupabase<Database = unknown>({\n url,\n publishableKey,\n storage,\n platform = \"web\",\n detectSessionInUrl = platform === \"web\",\n options = {},\n}: GenesisSupabaseOptions): SupabaseClient<Database> {\n // supabase-js's generics over the schema don't survive being forwarded, so the\n // options are built untyped and the client is typed by the caller's Database.\n const client = createClient(url, publishableKey, {\n ...options,\n auth: {\n flowType: \"pkce\",\n persistSession: true,\n autoRefreshToken: true,\n detectSessionInUrl,\n ...(storage ? { storage } : {}),\n ...options.auth,\n },\n });\n return client as unknown as SupabaseClient<Database>;\n}\n\n/** What a Supabase or PostgREST error carries. */\nexport interface PostgrestErrorLike {\n message: string;\n code?: string;\n details?: string | null;\n hint?: string | null;\n}\n\n/**\n * A failed query, with the database's `code` (`23505` unique violation,\n * `42501` permission denied, `PGRST116` no rows…), `details` and `hint`, so a\n * caller can tell an RLS refusal from a conflict. The original error is the\n * `cause`.\n *\n * @stability Beta\n */\nexport class SupabaseQueryError extends Error {\n override name = \"SupabaseQueryError\";\n readonly code?: string;\n readonly details?: string | null;\n readonly hint?: string | null;\n constructor(error: PostgrestErrorLike) {\n super(error.message, { cause: error });\n this.code = error.code;\n this.details = error.details;\n this.hint = error.hint;\n }\n}\n\n/**\n * The data, or a thrown {@link SupabaseQueryError}: for query results where an\n * error should fail the caller (a TanStack Query `queryFn`). The return type\n * keeps `null` when the query can return no row (`.maybeSingle()`).\n *\n * @stability Beta\n */\nexport function must<T>(result: { data: T; error: null } | { data: null; error: PostgrestErrorLike }): T {\n if (result.error) throw new SupabaseQueryError(result.error);\n return result.data as T;\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";;;AAwCO,SAAS,qBAAA,CAA0C;AAAA,EACxD,GAAA;AAAA,EACA,cAAA;AAAA,EACA,OAAA;AAAA,EACA,QAAA,GAAW,KAAA;AAAA,EACX,qBAAqB,QAAA,KAAa,KAAA;AAAA,EAClC,UAAU;AACZ,CAAA,EAAqD;AAGnD,EAAA,MAAM,MAAA,GAAS,YAAA,CAAa,GAAA,EAAK,cAAA,EAAgB;AAAA,IAC/C,GAAG,OAAA;AAAA,IACH,IAAA,EAAM;AAAA,MACJ,QAAA,EAAU,MAAA;AAAA,MACV,cAAA,EAAgB,IAAA;AAAA,MAChB,gBAAA,EAAkB,IAAA;AAAA,MAClB,kBAAA;AAAA,MACA,GAAI,OAAA,GAAU,EAAE,OAAA,KAAY,EAAC;AAAA,MAC7B,GAAG,OAAA,CAAQ;AAAA;AACb,GACD,CAAA;AACD,EAAA,OAAO,MAAA;AACT;AAkBO,IAAM,kBAAA,GAAN,cAAiC,KAAA,CAAM;AAAA,EACnC,IAAA,GAAO,oBAAA;AAAA,EACP,IAAA;AAAA,EACA,OAAA;AAAA,EACA,IAAA;AAAA,EACT,YAAY,KAAA,EAA2B;AACrC,IAAA,KAAA,CAAM,KAAA,CAAM,OAAA,EAAS,EAAE,KAAA,EAAO,OAAO,CAAA;AACrC,IAAA,IAAA,CAAK,OAAO,KAAA,CAAM,IAAA;AAClB,IAAA,IAAA,CAAK,UAAU,KAAA,CAAM,OAAA;AACrB,IAAA,IAAA,CAAK,OAAO,KAAA,CAAM,IAAA;AAAA,EACpB;AACF;AASO,SAAS,KAAQ,MAAA,EAAiF;AACvG,EAAA,IAAI,OAAO,KAAA,EAAO,MAAM,IAAI,kBAAA,CAAmB,OAAO,KAAK,CAAA;AAC3D,EAAA,OAAO,MAAA,CAAO,IAAA;AAChB;AAuBA,eAAsB,gBAAgB,MAAA,EAA4C;AAChF,EAAA,IAAA,CAAM,MAAM,MAAA,CAAO,GAAA,CAAI,mBAAmB,CAAgF,CAAA;AAE1H,EAAA,MAAM,OAAO,IAAA,CAAK,OAAA,CAAQ,EAAE,KAAA,EAAO,SAAS,CAAA;AAC9C","file":"index.js","sourcesContent":["/**\n * Supabase for Genesis apps. Platform-neutral: web, React Native and the\n * server import it. Native-only helpers (Keychain storage, foreground\n * refresh, reinstall wipe) are on `@marktiderman/genesis-supabase/native`.\n */\nimport { createClient, type SupabaseClient, type SupabaseClientOptions } from \"@supabase/supabase-js\";\n\n/** The async key-value store Supabase auth keeps its session in. */\nexport interface SessionStorage {\n getItem(key: string): Promise<string | null>;\n setItem(key: string, value: string): Promise<void>;\n removeItem(key: string): Promise<void>;\n}\n\n/** @stability Beta */\nexport interface GenesisSupabaseOptions {\n url: string;\n /** The publishable (anon) key. Never the secret key in an app. */\n publishableKey: string;\n /** Where the session lives. Native: `keychainStorage(SecureStore)`. Web: leave out for supabase-js's own default. */\n storage?: SessionStorage;\n /** `\"native\"` sets the React Native defaults (no session from the URL). Default `\"web\"`. */\n platform?: \"web\" | \"native\";\n /**\n * Read a session from the URL after an OAuth redirect. Default: `true` on\n * web, `false` on native, where the app hands the redirect URL to\n * `exchangeCodeForSession` itself.\n */\n detectSessionInUrl?: boolean;\n /** Anything else for supabase-js, merged last. */\n options?: SupabaseClientOptions<\"public\">;\n}\n\n/**\n * A Supabase client with Genesis's defaults: PKCE sign-in (the code flow, not\n * tokens in a redirect URL), a persisted session that refreshes itself, and\n * the session storage you pass.\n *\n * @stability Beta\n */\nexport function createGenesisSupabase<Database = unknown>({\n url,\n publishableKey,\n storage,\n platform = \"web\",\n detectSessionInUrl = platform === \"web\",\n options = {},\n}: GenesisSupabaseOptions): SupabaseClient<Database> {\n // supabase-js's generics over the schema don't survive being forwarded, so the\n // options are built untyped and the client is typed by the caller's Database.\n const client = createClient(url, publishableKey, {\n ...options,\n auth: {\n flowType: \"pkce\",\n persistSession: true,\n autoRefreshToken: true,\n detectSessionInUrl,\n ...(storage ? { storage } : {}),\n ...options.auth,\n },\n });\n return client as unknown as SupabaseClient<Database>;\n}\n\n/** What a Supabase or PostgREST error carries. */\nexport interface PostgrestErrorLike {\n message: string;\n code?: string;\n details?: string | null;\n hint?: string | null;\n}\n\n/**\n * A failed query, with the database's `code` (`23505` unique violation,\n * `42501` permission denied, `PGRST116` no rows…), `details` and `hint`, so a\n * caller can tell an RLS refusal from a conflict. The original error is the\n * `cause`.\n *\n * @stability Beta\n */\nexport class SupabaseQueryError extends Error {\n override name = \"SupabaseQueryError\";\n readonly code?: string;\n readonly details?: string | null;\n readonly hint?: string | null;\n constructor(error: PostgrestErrorLike) {\n super(error.message, { cause: error });\n this.code = error.code;\n this.details = error.details;\n this.hint = error.hint;\n }\n}\n\n/**\n * The data, or a thrown {@link SupabaseQueryError}: for query results where an\n * error should fail the caller (a TanStack Query `queryFn`). The return type\n * keeps `null` when the query can return no row (`.maybeSingle()`).\n *\n * @stability Beta\n */\nexport function must<T>(result: { data: T; error: null } | { data: null; error: PostgrestErrorLike }): T {\n if (result.error) throw new SupabaseQueryError(result.error);\n return result.data as T;\n}\n\n/** The parts of a Supabase client {@link deleteMyAccount} uses. A `SupabaseClient` fits. */\nexport interface DeleteAccountClient {\n // Loose on purpose: a client typed by an app's Database narrows `rpc`'s\n // function names, and the app's generated types may not list this one yet.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n rpc: (fn: any, ...rest: any[]) => PromiseLike<{ data: unknown; error: PostgrestErrorLike | null }>;\n auth: { signOut(options?: { scope?: \"global\" | \"local\" | \"others\" }): Promise<unknown> };\n}\n\n/**\n * In-app account deletion (App Store Review Guideline 5.1.1(v)), on web and\n * native: calls the `delete_my_account` RPC (this package's\n * `migrations/0001_genesis_account_deletion.sql`), which deletes the login\n * and, through the app's `on delete cascade` foreign keys, its data; then\n * clears the session on this device. Throws a {@link SupabaseQueryError} when\n * the database refuses (`42501`: not signed in) and keeps the session then.\n *\n * Put it behind a confirmation; see the README.\n *\n * @stability Beta\n */\nexport async function deleteMyAccount(client: DeleteAccountClient): Promise<void> {\n must((await client.rpc(\"delete_my_account\")) as { data: unknown; error: null } | { data: null; error: PostgrestErrorLike });\n // The login is gone and its refresh tokens with it: only this device's copy is left to clear.\n await client.auth.signOut({ scope: \"local\" });\n}\n"]}
|
package/dist/native.d.ts
CHANGED
|
@@ -1,10 +1,154 @@
|
|
|
1
1
|
import { SessionStorage } from './index.js';
|
|
2
|
-
import '@supabase/supabase-js';
|
|
2
|
+
import { Session, SupabaseClient, User } from '@supabase/supabase-js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Native sign-in onto a Supabase session: finishing an OAuth redirect (PKCE)
|
|
6
|
+
* and Sign in with Apple. Nothing here imports Expo: pass the modules in.
|
|
7
|
+
* Exported from `@marktiderman/genesis-supabase/native`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Why a sign-in didn't produce a session. `code` is the provider's error
|
|
12
|
+
* (`access_denied`…), Supabase Auth's error code, or one of Genesis's own:
|
|
13
|
+
* `missing_code` (the redirect carried neither a code nor an error),
|
|
14
|
+
* `oauth_error`, `exchange_failed`, `apple_unavailable`, `apple_failed`,
|
|
15
|
+
* `apple_no_token`, `bad_digest`, `sign_in_failed`, `no_session`.
|
|
16
|
+
*
|
|
17
|
+
* @stability Beta
|
|
18
|
+
*/
|
|
19
|
+
declare class GenesisAuthError extends Error {
|
|
20
|
+
name: string;
|
|
21
|
+
readonly code: string;
|
|
22
|
+
/**
|
|
23
|
+
* The provider's own `error_description`, as it came in the redirect.
|
|
24
|
+
* UNTRUSTED: any app or web page can open your callback link with any text
|
|
25
|
+
* here, so never show it as an official message. Log it; show `message`.
|
|
26
|
+
*/
|
|
27
|
+
readonly providerMessage?: string;
|
|
28
|
+
constructor(code: string, message: string, options?: {
|
|
29
|
+
cause?: unknown;
|
|
30
|
+
providerMessage?: string;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/** The parts of a Supabase client the sign-in helpers use. A `SupabaseClient` fits. */
|
|
34
|
+
interface SignInClient {
|
|
35
|
+
auth: Pick<SupabaseClient["auth"], "exchangeCodeForSession" | "signInWithIdToken">;
|
|
36
|
+
}
|
|
37
|
+
/** @stability Beta */
|
|
38
|
+
type OAuthRedirectResult = {
|
|
39
|
+
session: Session;
|
|
40
|
+
/** `"recovery"` after a password-reset link (send them to set a new password), otherwise `null`. */
|
|
41
|
+
redirectType: string | null;
|
|
42
|
+
error?: undefined;
|
|
43
|
+
} | {
|
|
44
|
+
session?: undefined;
|
|
45
|
+
redirectType?: undefined;
|
|
46
|
+
error: GenesisAuthError;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* The query and fragment parameters of a URL (the query wins for a key in
|
|
50
|
+
* both). Hand-parsed: React Native's `URL` has no reliable `searchParams`.
|
|
51
|
+
*/
|
|
52
|
+
declare function redirectParams(url: string): Record<string, string>;
|
|
53
|
+
/**
|
|
54
|
+
* Finish an OAuth sign-in from the URL the browser came back to. With a `code`
|
|
55
|
+
* it exchanges it for a session (PKCE: the verifier is already in the client's
|
|
56
|
+
* storage), passing the redirect's `sb_flow_id` so the right flow's verifier
|
|
57
|
+
* is used; with an `error` it returns that error and makes no request.
|
|
58
|
+
* Nothing is thrown; every failure is `{ error }`.
|
|
59
|
+
*
|
|
60
|
+
* Pass only the URL `WebBrowser.openAuthSessionAsync` resolved with, for the
|
|
61
|
+
* sign-in this app just started. A code is single-use and the exchange uses
|
|
62
|
+
* up the stored verifier whatever happens, so a stray `?code=` from another
|
|
63
|
+
* link spends the sign-in in progress, and handling the same redirect twice
|
|
64
|
+
* (the auth-session result and a `Linking` listener) fails the second time.
|
|
65
|
+
* Handle each redirect once.
|
|
66
|
+
*
|
|
67
|
+
* The error `message` is fixed copy chosen by `code`; the redirect's own text
|
|
68
|
+
* is only in `providerMessage`, which is untrusted.
|
|
69
|
+
*
|
|
70
|
+
* @stability Beta
|
|
71
|
+
*/
|
|
72
|
+
declare function completeOAuthRedirect(client: SignInClient, url: string): Promise<OAuthRedirectResult>;
|
|
73
|
+
/** A person's name as `expo-apple-authentication` returns it. */
|
|
74
|
+
interface AppleFullNameLike {
|
|
75
|
+
givenName?: string | null;
|
|
76
|
+
middleName?: string | null;
|
|
77
|
+
familyName?: string | null;
|
|
78
|
+
}
|
|
79
|
+
/** The parts of `expo-apple-authentication` that {@link signInWithApple} uses. Pass the module itself. */
|
|
80
|
+
interface AppleAuthenticationLike {
|
|
81
|
+
isAvailableAsync(): Promise<boolean>;
|
|
82
|
+
signInAsync(options: {
|
|
83
|
+
requestedScopes?: number[];
|
|
84
|
+
nonce?: string;
|
|
85
|
+
}): Promise<{
|
|
86
|
+
identityToken: string | null;
|
|
87
|
+
/** Single-use, valid about five minutes: what a server exchanges for the Apple refresh token it revokes on deletion. */
|
|
88
|
+
authorizationCode: string | null;
|
|
89
|
+
fullName: AppleFullNameLike | null;
|
|
90
|
+
email: string | null;
|
|
91
|
+
}>;
|
|
92
|
+
AppleAuthenticationScope: {
|
|
93
|
+
FULL_NAME: number;
|
|
94
|
+
EMAIL: number;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** @stability Beta */
|
|
98
|
+
interface SignInWithAppleOptions {
|
|
99
|
+
/**
|
|
100
|
+
* SHA-256 of a string as lowercase hex, e.g.
|
|
101
|
+
* `(s) => Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, s)` from `expo-crypto`.
|
|
102
|
+
* Apple gets the hash; Supabase gets the raw nonce and checks the token against it.
|
|
103
|
+
*/
|
|
104
|
+
digest(value: string): Promise<string>;
|
|
105
|
+
/** A fresh random nonce. Default: 32 bytes from `crypto.getRandomValues`, as hex (pass one, e.g. `Crypto.randomUUID`, where that's missing). */
|
|
106
|
+
randomNonce?: () => string;
|
|
107
|
+
}
|
|
108
|
+
/** @stability Beta */
|
|
109
|
+
type SignInWithAppleResult = {
|
|
110
|
+
session: Session;
|
|
111
|
+
user: User;
|
|
112
|
+
/** The name Apple shared. Apple sends it on the first sign-in only: save it then. */
|
|
113
|
+
fullName: string | null;
|
|
114
|
+
/** The email Apple shared (possibly a private relay address), on the first sign-in only. */
|
|
115
|
+
email: string | null;
|
|
116
|
+
/**
|
|
117
|
+
* Apple's single-use authorization code (valid about five minutes). Send it
|
|
118
|
+
* to your server straight away: it exchanges it for the Apple refresh token
|
|
119
|
+
* that account deletion must revoke. See the README.
|
|
120
|
+
*/
|
|
121
|
+
authorizationCode: string | null;
|
|
122
|
+
canceled?: undefined;
|
|
123
|
+
error?: undefined;
|
|
124
|
+
} | {
|
|
125
|
+
canceled: true;
|
|
126
|
+
session?: undefined;
|
|
127
|
+
error?: undefined;
|
|
128
|
+
} | {
|
|
129
|
+
error: GenesisAuthError;
|
|
130
|
+
/** When Apple shared a name but Supabase then refused: Apple won't send it again, so keep it for the retry. */
|
|
131
|
+
fullName?: string | null;
|
|
132
|
+
session?: undefined;
|
|
133
|
+
canceled?: undefined;
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* Native Sign in with Apple straight into a Supabase session: checks the
|
|
137
|
+
* device supports it, asks for name and email with a SHA-256 hashed nonce, and
|
|
138
|
+
* signs in with the identity token. Closing Apple's sheet is `{ canceled: true }`,
|
|
139
|
+
* not an error. Nothing is thrown; every failure is `{ error }`.
|
|
140
|
+
*
|
|
141
|
+
* @stability Beta
|
|
142
|
+
*/
|
|
143
|
+
declare function signInWithApple(client: SignInClient, apple: AppleAuthenticationLike, { digest, randomNonce }: SignInWithAppleOptions): Promise<SignInWithAppleResult>;
|
|
144
|
+
/** "Given Middle Family", or null when Apple shared no name. */
|
|
145
|
+
declare function formatFullName(name: AppleFullNameLike | null | undefined): string | null;
|
|
3
146
|
|
|
4
147
|
/**
|
|
5
148
|
* React Native helpers for a Supabase session. Nothing here imports React
|
|
6
|
-
* Native or Expo: pass in `expo-secure-store`, `AppState
|
|
7
|
-
*
|
|
149
|
+
* Native or Expo: pass in `expo-secure-store`, `AppState`,
|
|
150
|
+
* `expo-apple-authentication` and a plain store, so the package stays light
|
|
151
|
+
* and the helpers are easy to test.
|
|
8
152
|
*/
|
|
9
153
|
|
|
10
154
|
/** The parts of `expo-secure-store` the Keychain storage uses. */
|
|
@@ -81,4 +225,4 @@ declare function wipeSessionOnReinstall({ session, sessionKey, installMarker, ma
|
|
|
81
225
|
/** Split text into pieces of at most `maxBytes` UTF-8 bytes, never inside a character. */
|
|
82
226
|
declare function splitUtf8(text: string, maxBytes: number): string[];
|
|
83
227
|
|
|
84
|
-
export { type AppStateLike, type AutoRefreshAuth, type KeychainStorageOptions, type SecureStoreLike, type WipeOnReinstallOptions, keychainStorage, refreshWhileActive, splitUtf8, wipeSessionOnReinstall };
|
|
228
|
+
export { type AppStateLike, type AppleAuthenticationLike, type AppleFullNameLike, type AutoRefreshAuth, GenesisAuthError, type KeychainStorageOptions, type OAuthRedirectResult, type SecureStoreLike, type SignInClient, type SignInWithAppleOptions, type SignInWithAppleResult, type WipeOnReinstallOptions, completeOAuthRedirect, formatFullName, keychainStorage, redirectParams, refreshWhileActive, signInWithApple, splitUtf8, wipeSessionOnReinstall };
|
package/dist/native.js
CHANGED
|
@@ -1,3 +1,130 @@
|
|
|
1
|
+
// src/sign-in.ts
|
|
2
|
+
var GenesisAuthError = class extends Error {
|
|
3
|
+
name = "GenesisAuthError";
|
|
4
|
+
code;
|
|
5
|
+
/**
|
|
6
|
+
* The provider's own `error_description`, as it came in the redirect.
|
|
7
|
+
* UNTRUSTED: any app or web page can open your callback link with any text
|
|
8
|
+
* here, so never show it as an official message. Log it; show `message`.
|
|
9
|
+
*/
|
|
10
|
+
providerMessage;
|
|
11
|
+
constructor(code, message, options = {}) {
|
|
12
|
+
super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
|
|
13
|
+
this.code = code;
|
|
14
|
+
if (options.providerMessage !== void 0) this.providerMessage = options.providerMessage;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
var REDIRECT_MESSAGES = {
|
|
18
|
+
access_denied: "Sign-in was canceled or not allowed.",
|
|
19
|
+
otp_expired: "That sign-in link has expired. Ask for a new one.",
|
|
20
|
+
flow_state_expired: "That sign-in took too long. Try again.",
|
|
21
|
+
flow_state_not_found: "That sign-in has already been used or has expired. Try again.",
|
|
22
|
+
bad_oauth_state: "That sign-in couldn't be checked. Try again.",
|
|
23
|
+
bad_oauth_callback: "That sign-in couldn't be checked. Try again.",
|
|
24
|
+
provider_email_needs_verification: "Verify your email with the provider, then try again.",
|
|
25
|
+
server_error: "The sign-in service had a problem. Try again.",
|
|
26
|
+
temporarily_unavailable: "The sign-in service is busy. Try again in a moment."
|
|
27
|
+
};
|
|
28
|
+
var REDIRECT_FALLBACK = "Sign-in didn't complete. Try again.";
|
|
29
|
+
var hasOwn = (o, key) => Object.prototype.hasOwnProperty.call(o, key);
|
|
30
|
+
function redirectParams(url) {
|
|
31
|
+
const params = /* @__PURE__ */ Object.create(null);
|
|
32
|
+
const hash = url.indexOf("#");
|
|
33
|
+
const query = url.indexOf("?");
|
|
34
|
+
const parts = [
|
|
35
|
+
query >= 0 && (hash < 0 || query < hash) ? url.slice(query + 1, hash >= 0 ? hash : void 0) : "",
|
|
36
|
+
hash >= 0 ? url.slice(hash + 1) : ""
|
|
37
|
+
];
|
|
38
|
+
for (const part of parts) {
|
|
39
|
+
for (const pair of part.split("&")) {
|
|
40
|
+
if (!pair) continue;
|
|
41
|
+
const eq = pair.indexOf("=");
|
|
42
|
+
const key = decode(eq >= 0 ? pair.slice(0, eq) : pair);
|
|
43
|
+
if (!hasOwn(params, key)) params[key] = decode(eq >= 0 ? pair.slice(eq + 1) : "");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return params;
|
|
47
|
+
}
|
|
48
|
+
function decode(s) {
|
|
49
|
+
try {
|
|
50
|
+
return decodeURIComponent(s.replace(/\+/g, " "));
|
|
51
|
+
} catch {
|
|
52
|
+
return s;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function completeOAuthRedirect(client, url) {
|
|
56
|
+
const params = redirectParams(url);
|
|
57
|
+
if (params.error || params.error_description || params.error_code) {
|
|
58
|
+
const code = params.error_code || params.error || "oauth_error";
|
|
59
|
+
const message = REDIRECT_MESSAGES[code] ?? (params.error ? REDIRECT_MESSAGES[params.error] : void 0) ?? REDIRECT_FALLBACK;
|
|
60
|
+
return { error: new GenesisAuthError(code, message, { providerMessage: params.error_description || void 0 }) };
|
|
61
|
+
}
|
|
62
|
+
if (!params.code) return { error: new GenesisAuthError("missing_code", "The sign-in redirect carried no code") };
|
|
63
|
+
try {
|
|
64
|
+
const { data, error } = await client.auth.exchangeCodeForSession(
|
|
65
|
+
params.code,
|
|
66
|
+
params.sb_flow_id ? { flowId: params.sb_flow_id } : void 0
|
|
67
|
+
);
|
|
68
|
+
if (error) return { error: new GenesisAuthError(error.code ?? "exchange_failed", error.message, { cause: error }) };
|
|
69
|
+
if (!data.session) return { error: new GenesisAuthError("no_session", "The code exchange returned no session") };
|
|
70
|
+
const redirectType = data.redirectType ?? null;
|
|
71
|
+
return { session: data.session, redirectType };
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return { error: new GenesisAuthError("exchange_failed", e instanceof Error ? e.message : "The code exchange failed", { cause: e }) };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function signInWithApple(client, apple, { digest, randomNonce = defaultNonce }) {
|
|
77
|
+
const unavailable = (cause) => ({
|
|
78
|
+
error: new GenesisAuthError("apple_unavailable", "Sign in with Apple isn't available on this device", { cause })
|
|
79
|
+
});
|
|
80
|
+
try {
|
|
81
|
+
if (!await apple.isAvailableAsync()) return unavailable();
|
|
82
|
+
} catch (e) {
|
|
83
|
+
return unavailable(e);
|
|
84
|
+
}
|
|
85
|
+
let credential;
|
|
86
|
+
let nonce;
|
|
87
|
+
try {
|
|
88
|
+
nonce = randomNonce();
|
|
89
|
+
const hashed = await digest(nonce);
|
|
90
|
+
if (!/^[0-9a-f]{64}$/.test(hashed)) {
|
|
91
|
+
return { error: new GenesisAuthError("bad_digest", "signInWithApple: digest must return SHA-256 as 64 lowercase hex characters") };
|
|
92
|
+
}
|
|
93
|
+
credential = await apple.signInAsync({
|
|
94
|
+
requestedScopes: [apple.AppleAuthenticationScope.FULL_NAME, apple.AppleAuthenticationScope.EMAIL],
|
|
95
|
+
nonce: hashed
|
|
96
|
+
});
|
|
97
|
+
} catch (e) {
|
|
98
|
+
if (e?.code === "ERR_REQUEST_CANCELED") return { canceled: true };
|
|
99
|
+
return { error: new GenesisAuthError("apple_failed", e instanceof Error ? e.message : "Sign in with Apple failed", { cause: e }) };
|
|
100
|
+
}
|
|
101
|
+
const fullName = formatFullName(credential.fullName);
|
|
102
|
+
if (!credential.identityToken) return { error: new GenesisAuthError("apple_no_token", "Apple returned no identity token"), fullName };
|
|
103
|
+
try {
|
|
104
|
+
const { data, error } = await client.auth.signInWithIdToken({ provider: "apple", token: credential.identityToken, nonce });
|
|
105
|
+
if (error) return { error: new GenesisAuthError(error.code ?? "sign_in_failed", error.message, { cause: error }), fullName };
|
|
106
|
+
if (!data.session || !data.user) return { error: new GenesisAuthError("no_session", "Sign-in returned no session"), fullName };
|
|
107
|
+
return {
|
|
108
|
+
session: data.session,
|
|
109
|
+
user: data.user,
|
|
110
|
+
fullName,
|
|
111
|
+
email: credential.email ?? null,
|
|
112
|
+
authorizationCode: credential.authorizationCode ?? null
|
|
113
|
+
};
|
|
114
|
+
} catch (e) {
|
|
115
|
+
return { error: new GenesisAuthError("sign_in_failed", e instanceof Error ? e.message : "Sign-in failed", { cause: e }), fullName };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function formatFullName(name) {
|
|
119
|
+
if (!name) return null;
|
|
120
|
+
return [name.givenName, name.middleName, name.familyName].map((p) => p?.trim()).filter(Boolean).join(" ") || null;
|
|
121
|
+
}
|
|
122
|
+
function defaultNonce() {
|
|
123
|
+
const c = globalThis.crypto;
|
|
124
|
+
if (!c?.getRandomValues) throw new Error("[genesis] signInWithApple: no crypto.getRandomValues here; pass randomNonce (e.g. expo-crypto's randomUUID)");
|
|
125
|
+
return Array.from(c.getRandomValues(new Uint8Array(32)), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
126
|
+
}
|
|
127
|
+
|
|
1
128
|
// src/native.ts
|
|
2
129
|
function keychainStorage(secureStore, options = {}) {
|
|
3
130
|
const size = options.chunkSize ?? 1800;
|
|
@@ -106,6 +233,6 @@ function parseFlowIndex(raw) {
|
|
|
106
233
|
}
|
|
107
234
|
}
|
|
108
235
|
|
|
109
|
-
export { keychainStorage, refreshWhileActive, splitUtf8, wipeSessionOnReinstall };
|
|
236
|
+
export { GenesisAuthError, completeOAuthRedirect, formatFullName, keychainStorage, redirectParams, refreshWhileActive, signInWithApple, splitUtf8, wipeSessionOnReinstall };
|
|
110
237
|
//# sourceMappingURL=native.js.map
|
|
111
238
|
//# sourceMappingURL=native.js.map
|
package/dist/native.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/native.ts"],"names":[],"mappings":";AAsCO,SAAS,eAAA,CAAgB,WAAA,EAA8B,OAAA,GAAkC,EAAC,EAAmB;AAClH,EAAA,MAAM,IAAA,GAAO,QAAQ,SAAA,IAAa,IAAA;AAClC,EAAA,MAAM,IAAA,GAAO,OAAA,CAAQ,kBAAA,KAAuB,WAAA,CAAY,kBAAA,KAAuB,MAAA,GAAY,EAAE,kBAAA,EAAoB,WAAA,CAAY,kBAAA,EAAmB,GAAI,EAAC,CAAA;AACrJ,EAAA,MAAM,QAAA,GAAW,CAAC,GAAA,EAAa,GAAA,EAAa,CAAA,KAAc,GAAG,GAAG,CAAA,CAAA,EAAI,GAAG,CAAA,CAAA,EAAI,CAAC,CAAA,CAAA;AAC5E,EAAA,IAAI,OAAA,GAAU,CAAA;AACd,EAAA,MAAM,MAAA,uBAAa,GAAA,EAA8B;AAGjD,EAAA,MAAM,MAAA,GAAS,CAAI,GAAA,EAAa,IAAA,KAAuC;AACrE,IAAA,MAAM,GAAA,GAAA,CAAO,MAAA,CAAO,GAAA,CAAI,GAAG,CAAA,IAAK,OAAA,CAAQ,OAAA,EAAQ,EAAG,KAAA,CAAM,MAAM,MAAS,CAAA,CAAE,KAAK,IAAI,CAAA;AACnF,IAAA,MAAA,CAAO,GAAA,CAAI,KAAK,GAAG,CAAA;AACnB,IAAA,OAAO,GAAA;AAAA,EACT,CAAA;AAEA,EAAA,MAAM,KAAA,GAAQ,CAAC,GAAA,KAAuB;AACpC,IAAA,MAAM,CAAC,GAAA,EAAK,KAAK,KAAK,GAAA,IAAO,EAAA,EAAI,MAAM,GAAG,CAAA;AAC1C,IAAA,OAAO,GAAA,IAAO,MAAA,CAAO,KAAK,CAAA,GAAI,CAAA,GAAI,EAAE,GAAA,EAAK,KAAA,EAAO,MAAA,CAAO,KAAK,CAAA,EAAE,GAAI,IAAA;AAAA,EACpE,CAAA;AACA,EAAA,MAAM,QAAA,GAAW,OAAO,GAAA,KAAgB,KAAA,CAAM,MAAM,WAAA,CAAY,YAAA,CAAa,CAAA,EAAG,GAAG,CAAA,EAAA,CAAA,EAAM,IAAI,CAAC,CAAA;AAC9F,EAAA,MAAM,OAAA,GAAU,OAAO,GAAA,KAAgB,KAAA,CAAM,MAAM,WAAA,CAAY,YAAA,CAAa,CAAA,EAAG,GAAG,CAAA,EAAA,CAAA,EAAM,IAAI,CAAC,CAAA;AAC7F,EAAA,MAAM,IAAA,GAAO,CAAC,GAAA,EAAa,GAAA,EAAa,KAAA,KACtC,QAAQ,GAAA,CAAI,KAAA,CAAM,IAAA,CAAK,EAAE,MAAA,EAAQ,KAAA,IAAS,CAAC,CAAA,EAAG,CAAA,KAAM,WAAA,CAAY,eAAA,CAAgB,QAAA,CAAS,GAAA,EAAK,GAAA,EAAK,CAAC,CAAA,EAAG,IAAI,CAAC,CAAC,CAAA;AAG/G,EAAA,MAAM,YAAA,GAAe,OAAO,GAAA,EAAa,OAAA,KAAgC;AACvE,IAAA,MAAM,CAAA,GAAI,MAAM,OAAA,CAAQ,GAAG,CAAA;AAC3B,IAAA,IAAI,CAAC,CAAA,EAAG;AACR,IAAA,IAAI,CAAA,CAAE,QAAQ,OAAA,EAAS,MAAM,KAAK,GAAA,EAAK,CAAA,CAAE,GAAA,EAAK,CAAA,CAAE,KAAK,CAAA;AACrD,IAAA,MAAM,WAAA,CAAY,eAAA,CAAgB,CAAA,EAAG,GAAG,MAAM,IAAI,CAAA;AAAA,EACpD,CAAA;AAEA,EAAA,OAAO;AAAA,IACL,QAAQ,GAAA,EAAK;AAGX,MAAA,OAAO,MAAA,CAAO,KAAK,YAAY;AAC7B,QAAA,MAAM,CAAA,GAAI,MAAM,QAAA,CAAS,GAAG,CAAA;AAC5B,QAAA,IAAI,CAAC,GAAG,OAAO,IAAA;AACf,QAAA,MAAM,KAAA,GAAQ,MAAM,OAAA,CAAQ,GAAA,CAAI,KAAA,CAAM,KAAK,EAAE,MAAA,EAAQ,CAAA,CAAE,KAAA,EAAM,EAAG,CAAC,GAAG,CAAA,KAAM,WAAA,CAAY,YAAA,CAAa,QAAA,CAAS,GAAA,EAAK,CAAA,CAAE,KAAK,CAAC,CAAA,EAAG,IAAI,CAAC,CAAC,CAAA;AAClI,QAAA,OAAO,KAAA,CAAM,IAAA,CAAK,CAAC,CAAA,KAAM,CAAA,KAAM,IAAI,CAAA,GAAI,IAAA,GAAO,KAAA,CAAM,IAAA,CAAK,EAAE,CAAA;AAAA,MAC7D,CAAC,CAAA;AAAA,IACH,CAAA;AAAA,IACA,OAAA,CAAQ,KAAK,KAAA,EAAO;AAClB,MAAA,OAAO,MAAA,CAAO,KAAK,YAAY;AAC7B,QAAA,MAAM,MAAA,GAAS,MAAM,QAAA,CAAS,GAAG,CAAA;AACjC,QAAA,MAAM,YAAA,CAAa,GAAA,EAAK,MAAA,EAAQ,GAAG,CAAA;AACnC,QAAA,MAAM,GAAA,GAAM,CAAA,EAAG,IAAA,CAAK,GAAA,EAAI,CAAE,QAAA,CAAS,EAAE,CAAC,CAAA,EAAA,CAAI,OAAA,EAAA,EAAW,QAAA,CAAS,EAAE,CAAC,CAAA,CAAA;AACjE,QAAA,MAAM,KAAA,GAAQ,SAAA,CAAU,KAAA,EAAO,IAAI,CAAA;AACnC,QAAA,MAAM,WAAA,CAAY,YAAA,CAAa,CAAA,EAAG,GAAG,CAAA,EAAA,CAAA,EAAM,CAAA,EAAG,GAAG,CAAA,CAAA,EAAI,KAAA,CAAM,MAAM,CAAA,CAAA,EAAI,IAAI,CAAA;AAGzE,QAAA,MAAM,UAAU,MAAM,OAAA,CAAQ,WAAW,KAAA,CAAM,GAAA,CAAI,CAAC,CAAA,EAAG,CAAA,KAAM,YAAY,YAAA,CAAa,QAAA,CAAS,KAAK,GAAA,EAAK,CAAC,GAAG,CAAA,EAAG,IAAI,CAAC,CAAC,CAAA;AACtH,QAAA,MAAM,SAAS,OAAA,CAAQ,IAAA,CAAK,CAAC,CAAA,KAAkC,CAAA,CAAE,WAAW,UAAU,CAAA;AACtF,QAAA,IAAI,MAAA,QAAc,MAAA,CAAO,MAAA;AACzB,QAAA,MAAM,WAAA,CAAY,YAAA,CAAa,CAAA,EAAG,GAAG,CAAA,EAAA,CAAA,EAAM,CAAA,EAAG,GAAG,CAAA,CAAA,EAAI,KAAA,CAAM,MAAM,CAAA,CAAA,EAAI,IAAI,CAAA;AACzE,QAAA,MAAM,WAAA,CAAY,eAAA,CAAgB,CAAA,EAAG,GAAG,MAAM,IAAI,CAAA;AAClD,QAAA,IAAI,QAAQ,MAAM,IAAA,CAAK,KAAK,MAAA,CAAO,GAAA,EAAK,OAAO,KAAK,CAAA;AAAA,MACtD,CAAC,CAAA;AAAA,IACH,CAAA;AAAA,IACA,WAAW,GAAA,EAAK;AACd,MAAA,OAAO,MAAA,CAAO,KAAK,YAAY;AAC7B,QAAA,MAAM,CAAA,GAAI,MAAM,QAAA,CAAS,GAAG,CAAA;AAC5B,QAAA,MAAM,YAAA,CAAa,GAAA,EAAK,CAAA,EAAG,GAAG,CAAA;AAE9B,QAAA,IAAI,GAAG,MAAM,IAAA,CAAK,KAAK,CAAA,CAAE,GAAA,EAAK,EAAE,KAAK,CAAA;AACrC,QAAA,MAAM,WAAA,CAAY,eAAA,CAAgB,CAAA,EAAG,GAAG,MAAM,IAAI,CAAA;AAAA,MACpD,CAAC,CAAA;AAAA,IACH;AAAA,GACF;AACF;AAmBO,SAAS,kBAAA,CAAmB,QAAyB,QAAA,EAAoC;AAC9F,EAAA,MAAM,KAAA,GAAQ,CAAC,KAAA,KAAqC;AAClD,IAAA,IAAI,KAAA,KAAU,QAAA,EAAU,KAAK,MAAA,CAAO,KAAK,gBAAA,EAAiB;AAAA,SACrD,KAAK,MAAA,CAAO,IAAA,CAAK,eAAA,EAAgB;AAAA,EACxC,CAAA;AACA,EAAA,KAAA,CAAM,QAAA,CAAS,gBAAgB,QAAQ,CAAA;AACvC,EAAA,MAAM,GAAA,GAAM,QAAA,CAAS,gBAAA,CAAiB,QAAA,EAAU,KAAK,CAAA;AACrD,EAAA,OAAO,MAAM;AACX,IAAA,GAAA,CAAI,MAAA,EAAO;AACX,IAAA,KAAK,MAAA,CAAO,KAAK,eAAA,EAAgB;AAAA,EACnC,CAAA;AACF;AAsBA,eAAsB,uBAAuB,EAAE,OAAA,EAAS,YAAY,aAAA,EAAe,SAAA,GAAY,qBAAoB,EAA6C;AAC9J,EAAA,IAAK,MAAM,aAAA,CAAc,OAAA,CAAQ,SAAS,CAAA,KAAO,MAAM,OAAO,KAAA;AAI9D,EAAA,MAAM,QAAA,GAAW,GAAG,UAAU,CAAA,oBAAA,CAAA;AAC9B,EAAA,KAAA,MAAW,MAAA,IAAU,cAAA,CAAe,MAAM,OAAA,CAAQ,QAAQ,QAAQ,CAAC,CAAA,EAAG,MAAM,QAAQ,UAAA,CAAW,CAAA,EAAG,UAAU,CAAA,MAAA,EAAS,MAAM,CAAA,cAAA,CAAgB,CAAA;AAC3I,EAAA,KAAA,MAAW,GAAA,IAAO,CAAC,UAAA,EAAY,CAAA,EAAG,UAAU,CAAA,cAAA,CAAA,EAAkB,QAAA,EAAU,CAAA,EAAG,UAAU,CAAA,KAAA,CAAO,CAAA,EAAG,MAAM,OAAA,CAAQ,WAAW,GAAG,CAAA;AAC3H,EAAA,MAAM,aAAA,CAAc,OAAA,CAAQ,SAAA,EAAW,GAAG,CAAA;AAC1C,EAAA,OAAO,IAAA;AACT;AAGA,IAAM,UAAA,GAAa,CAAC,SAAA,KAAuB,SAAA,GAAY,GAAA,GAAO,CAAA,GAAI,SAAA,GAAY,IAAA,GAAQ,CAAA,GAAI,SAAA,GAAY,KAAA,GAAU,CAAA,GAAI,CAAA;AAG7G,SAAS,SAAA,CAAU,MAAc,QAAA,EAA4B;AAClE,EAAA,IAAI,IAAA,KAAS,EAAA,EAAI,OAAO,CAAC,EAAE,CAAA;AAC3B,EAAA,MAAM,QAAkB,EAAC;AACzB,EAAA,IAAI,OAAA,GAAU,EAAA;AACd,EAAA,IAAI,KAAA,GAAQ,CAAA;AACZ,EAAA,KAAA,MAAW,MAAM,IAAA,EAAM;AACrB,IAAA,MAAM,CAAA,GAAI,UAAA,CAAW,EAAA,CAAG,WAAA,CAAY,CAAC,CAAE,CAAA;AACvC,IAAA,IAAI,KAAA,GAAQ,CAAA,GAAI,QAAA,IAAY,OAAA,EAAS;AACnC,MAAA,KAAA,CAAM,KAAK,OAAO,CAAA;AAClB,MAAA,OAAA,GAAU,EAAA;AACV,MAAA,KAAA,GAAQ,CAAA;AAAA,IACV;AACA,IAAA,OAAA,IAAW,EAAA;AACX,IAAA,KAAA,IAAS,CAAA;AAAA,EACX;AACA,EAAA,KAAA,CAAM,KAAK,OAAO,CAAA;AAClB,EAAA,OAAO,KAAA;AACT;AAGA,SAAS,eAAe,GAAA,EAA8B;AACpD,EAAA,IAAI,CAAC,GAAA,EAAK,OAAO,EAAC;AAClB,EAAA,IAAI;AACF,IAAA,MAAM,GAAA,GAAe,IAAA,CAAK,KAAA,CAAM,GAAG,CAAA;AACnC,IAAA,OAAO,MAAM,OAAA,CAAQ,GAAG,CAAA,GAAI,GAAA,CAAI,OAAO,CAAC,EAAA,KAAqB,OAAO,EAAA,KAAO,YAAY,gBAAA,CAAiB,IAAA,CAAK,EAAE,CAAC,IAAI,EAAC;AAAA,EACvH,CAAA,CAAA,MAAQ;AACN,IAAA,OAAO,EAAC;AAAA,EACV;AACF","file":"native.js","sourcesContent":["/**\n * React Native helpers for a Supabase session. Nothing here imports React\n * Native or Expo: pass in `expo-secure-store`, `AppState` and a plain store,\n * so the package stays light and the helpers are easy to test.\n */\nimport type { SessionStorage } from \"./index\";\n\n/** The parts of `expo-secure-store` the Keychain storage uses. */\nexport interface SecureStoreLike {\n getItemAsync(key: string, options?: Record<string, unknown>): Promise<string | null>;\n setItemAsync(key: string, value: string, options?: Record<string, unknown>): Promise<void>;\n deleteItemAsync(key: string, options?: Record<string, unknown>): Promise<void>;\n /** `SecureStore.AFTER_FIRST_UNLOCK`, when available. */\n AFTER_FIRST_UNLOCK?: unknown;\n}\n\n/** @stability Beta */\nexport interface KeychainStorageOptions {\n /** UTF-8 bytes per Keychain item. A Keychain item should stay under about 2 KB; a Supabase session is larger. Default 1800. */\n chunkSize?: number;\n /** Extra options for every SecureStore call. Default: readable after first unlock, so a background refresh works. */\n secureStoreOptions?: Record<string, unknown>;\n}\n\n/**\n * Session storage in the Keychain (iOS) or Keystore (Android).\n *\n * A value is split into numbered chunks. Each write goes to a new generation of\n * chunks, and one manifest item (`<key>.n` = `generation:count`) switches to it\n * only once every chunk is written, so a write cut short leaves the previous\n * session whole. Chunks split between characters (never inside one) and\n * stay under `chunkSize` UTF-8 bytes. A pending marker (`<key>.p`) names a\n * generation while it is being written, so chunks left by a write that was\n * cut short or crashed are swept on the next write or remove. Writes to the\n * same key run one at a time, so two token refreshes can't interleave.\n *\n * @stability Beta\n */\nexport function keychainStorage(secureStore: SecureStoreLike, options: KeychainStorageOptions = {}): SessionStorage {\n const size = options.chunkSize ?? 1800;\n const opts = options.secureStoreOptions ?? (secureStore.AFTER_FIRST_UNLOCK !== undefined ? { keychainAccessible: secureStore.AFTER_FIRST_UNLOCK } : {});\n const chunkKey = (key: string, gen: string, i: number) => `${key}.${gen}.${i}`;\n let counter = 0;\n const queues = new Map<string, Promise<unknown>>();\n\n /** Run `task` after every earlier write to `key` has settled. */\n const serial = <T>(key: string, task: () => Promise<T>): Promise<T> => {\n const run = (queues.get(key) ?? Promise.resolve()).catch(() => undefined).then(task);\n queues.set(key, run);\n return run;\n };\n\n const parse = (raw: string | null) => {\n const [gen, count] = (raw ?? \"\").split(\":\");\n return gen && Number(count) > 0 ? { gen, count: Number(count) } : null;\n };\n const manifest = async (key: string) => parse(await secureStore.getItemAsync(`${key}.n`, opts));\n const pending = async (key: string) => parse(await secureStore.getItemAsync(`${key}.p`, opts));\n const drop = (key: string, gen: string, count: number) =>\n Promise.all(Array.from({ length: count }, (_, i) => secureStore.deleteItemAsync(chunkKey(key, gen, i), opts)));\n\n /** Remove chunks of a write that never completed (unless they are the current generation). */\n const sweepPending = async (key: string, current: string | undefined) => {\n const p = await pending(key);\n if (!p) return;\n if (p.gen !== current) await drop(key, p.gen, p.count);\n await secureStore.deleteItemAsync(`${key}.p`, opts);\n };\n\n return {\n getItem(key) {\n // Reads queue with writes, so a write can't swap the manifest and drop\n // the old chunks halfway through a read.\n return serial(key, async () => {\n const m = await manifest(key);\n if (!m) return null;\n const parts = await Promise.all(Array.from({ length: m.count }, (_, i) => secureStore.getItemAsync(chunkKey(key, m.gen, i), opts)));\n return parts.some((p) => p === null) ? null : parts.join(\"\");\n });\n },\n setItem(key, value) {\n return serial(key, async () => {\n const before = await manifest(key);\n await sweepPending(key, before?.gen);\n const gen = `${Date.now().toString(36)}${(counter++).toString(36)}`;\n const parts = splitUtf8(value, size);\n await secureStore.setItemAsync(`${key}.p`, `${gen}:${parts.length}`, opts);\n // Let every chunk write settle before failing, so a late write can't land\n // after the next operation has swept this generation.\n const written = await Promise.allSettled(parts.map((p, i) => secureStore.setItemAsync(chunkKey(key, gen, i), p, opts)));\n const failed = written.find((r): r is PromiseRejectedResult => r.status === \"rejected\");\n if (failed) throw failed.reason;\n await secureStore.setItemAsync(`${key}.n`, `${gen}:${parts.length}`, opts);\n await secureStore.deleteItemAsync(`${key}.p`, opts);\n if (before) await drop(key, before.gen, before.count);\n });\n },\n removeItem(key) {\n return serial(key, async () => {\n const m = await manifest(key);\n await sweepPending(key, m?.gen);\n // Chunks first: the manifest is what finds them, so it goes last.\n if (m) await drop(key, m.gen, m.count);\n await secureStore.deleteItemAsync(`${key}.n`, opts);\n });\n },\n };\n}\n\n/** The parts of a Supabase client's auth the refresh helper uses. */\nexport interface AutoRefreshAuth {\n auth: { startAutoRefresh(): Promise<void> | void; stopAutoRefresh(): Promise<void> | void };\n}\n\n/** The parts of React Native's `AppState` the refresh helper uses. */\nexport interface AppStateLike {\n currentState?: string | null;\n addEventListener(type: \"change\", listener: (state: string) => void): { remove(): void };\n}\n\n/**\n * Refresh the session only while the app is in front, as Supabase recommends\n * for React Native. Returns the unsubscribe.\n *\n * @stability Beta\n */\nexport function refreshWhileActive(client: AutoRefreshAuth, appState: AppStateLike): () => void {\n const apply = (state: string | null | undefined) => {\n if (state === \"active\") void client.auth.startAutoRefresh();\n else void client.auth.stopAutoRefresh();\n };\n apply(appState.currentState ?? \"active\");\n const sub = appState.addEventListener(\"change\", apply);\n return () => {\n sub.remove();\n void client.auth.stopAutoRefresh();\n };\n}\n\n/** @stability Beta */\nexport interface WipeOnReinstallOptions {\n /** The session storage (usually `keychainStorage`). */\n session: SessionStorage;\n /** The session's storage key. Supabase's default is `sb-<project ref>-auth-token`. */\n sessionKey: string;\n /** A store that an uninstall deletes, e.g. AsyncStorage. The Keychain survives an uninstall; this is how we notice one. */\n installMarker: SessionStorage;\n /** The marker's key. Default `genesis.installed`. */\n markerKey?: string;\n}\n\n/**\n * On iOS the Keychain outlives an uninstall, so a reinstalled app would sign\n * the last person back in. Call this before creating the client: on the first\n * launch after an install it removes any session left in the Keychain, then\n * sets a marker so later launches keep the session. Returns whether it wiped.\n *\n * @stability Beta\n */\nexport async function wipeSessionOnReinstall({ session, sessionKey, installMarker, markerKey = \"genesis.installed\" }: WipeOnReinstallOptions): Promise<boolean> {\n if ((await installMarker.getItem(markerKey)) !== null) return false;\n // The session, plus what supabase-js keeps beside it: the PKCE verifier (the\n // single legacy slot, each indexed flow's slot and the index) and, with\n // userStorage, the user.\n const indexKey = `${sessionKey}-flows-code-verifier`;\n for (const flowId of parseFlowIndex(await session.getItem(indexKey))) await session.removeItem(`${sessionKey}-flow-${flowId}-code-verifier`);\n for (const key of [sessionKey, `${sessionKey}-code-verifier`, indexKey, `${sessionKey}-user`]) await session.removeItem(key);\n await installMarker.setItem(markerKey, \"1\");\n return true;\n}\n\n/** UTF-8 byte length of one code point. */\nconst utf8Length = (codePoint: number) => (codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : codePoint < 0x10000 ? 3 : 4);\n\n/** Split text into pieces of at most `maxBytes` UTF-8 bytes, never inside a character. */\nexport function splitUtf8(text: string, maxBytes: number): string[] {\n if (text === \"\") return [\"\"];\n const parts: string[] = [];\n let current = \"\";\n let bytes = 0;\n for (const ch of text) {\n const n = utf8Length(ch.codePointAt(0)!);\n if (bytes + n > maxBytes && current) {\n parts.push(current);\n current = \"\";\n bytes = 0;\n }\n current += ch;\n bytes += n;\n }\n parts.push(current);\n return parts;\n}\n\n/** supabase-js's PKCE flow index: a JSON array of flow ids. Anything else is ignored. */\nfunction parseFlowIndex(raw: string | null): string[] {\n if (!raw) return [];\n try {\n const ids: unknown = JSON.parse(raw);\n return Array.isArray(ids) ? ids.filter((id): id is string => typeof id === \"string\" && /^[\\w-]{1,128}$/.test(id)) : [];\n } catch {\n return [];\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/sign-in.ts","../src/native.ts"],"names":[],"mappings":"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","file":"native.js","sourcesContent":["/**\n * Native sign-in onto a Supabase session: finishing an OAuth redirect (PKCE)\n * and Sign in with Apple. Nothing here imports Expo: pass the modules in.\n * Exported from `@marktiderman/genesis-supabase/native`.\n */\nimport type { Session, SupabaseClient, User } from \"@supabase/supabase-js\";\n\n/**\n * Why a sign-in didn't produce a session. `code` is the provider's error\n * (`access_denied`…), Supabase Auth's error code, or one of Genesis's own:\n * `missing_code` (the redirect carried neither a code nor an error),\n * `oauth_error`, `exchange_failed`, `apple_unavailable`, `apple_failed`,\n * `apple_no_token`, `bad_digest`, `sign_in_failed`, `no_session`.\n *\n * @stability Beta\n */\nexport class GenesisAuthError extends Error {\n override name = \"GenesisAuthError\";\n readonly code: string;\n /**\n * The provider's own `error_description`, as it came in the redirect.\n * UNTRUSTED: any app or web page can open your callback link with any text\n * here, so never show it as an official message. Log it; show `message`.\n */\n readonly providerMessage?: string;\n constructor(code: string, message: string, options: { cause?: unknown; providerMessage?: string } = {}) {\n super(message, options.cause === undefined ? undefined : { cause: options.cause });\n this.code = code;\n if (options.providerMessage !== undefined) this.providerMessage = options.providerMessage;\n }\n}\n\n/** Fixed, safe copy for the redirect errors an app is likely to see; anything else gets the fallback. */\nconst REDIRECT_MESSAGES: Record<string, string> = {\n access_denied: \"Sign-in was canceled or not allowed.\",\n otp_expired: \"That sign-in link has expired. Ask for a new one.\",\n flow_state_expired: \"That sign-in took too long. Try again.\",\n flow_state_not_found: \"That sign-in has already been used or has expired. Try again.\",\n bad_oauth_state: \"That sign-in couldn't be checked. Try again.\",\n bad_oauth_callback: \"That sign-in couldn't be checked. Try again.\",\n provider_email_needs_verification: \"Verify your email with the provider, then try again.\",\n server_error: \"The sign-in service had a problem. Try again.\",\n temporarily_unavailable: \"The sign-in service is busy. Try again in a moment.\",\n};\nconst REDIRECT_FALLBACK = \"Sign-in didn't complete. Try again.\";\n\n/** The parts of a Supabase client the sign-in helpers use. A `SupabaseClient` fits. */\nexport interface SignInClient {\n auth: Pick<SupabaseClient[\"auth\"], \"exchangeCodeForSession\" | \"signInWithIdToken\">;\n}\n\n/** @stability Beta */\nexport type OAuthRedirectResult =\n | {\n session: Session;\n /** `\"recovery\"` after a password-reset link (send them to set a new password), otherwise `null`. */\n redirectType: string | null;\n error?: undefined;\n }\n | { session?: undefined; redirectType?: undefined; error: GenesisAuthError };\n\nconst hasOwn = (o: object, key: string) => Object.prototype.hasOwnProperty.call(o, key);\n\n/**\n * The query and fragment parameters of a URL (the query wins for a key in\n * both). Hand-parsed: React Native's `URL` has no reliable `searchParams`.\n */\nexport function redirectParams(url: string): Record<string, string> {\n const params: Record<string, string> = Object.create(null);\n const hash = url.indexOf(\"#\");\n const query = url.indexOf(\"?\");\n const parts = [\n query >= 0 && (hash < 0 || query < hash) ? url.slice(query + 1, hash >= 0 ? hash : undefined) : \"\",\n hash >= 0 ? url.slice(hash + 1) : \"\",\n ];\n for (const part of parts) {\n for (const pair of part.split(\"&\")) {\n if (!pair) continue;\n const eq = pair.indexOf(\"=\");\n const key = decode(eq >= 0 ? pair.slice(0, eq) : pair);\n if (!hasOwn(params, key)) params[key] =decode(eq >= 0 ? pair.slice(eq + 1) : \"\");\n }\n }\n return params;\n}\n\nfunction decode(s: string): string {\n try {\n return decodeURIComponent(s.replace(/\\+/g, \" \"));\n } catch {\n return s;\n }\n}\n\n/**\n * Finish an OAuth sign-in from the URL the browser came back to. With a `code`\n * it exchanges it for a session (PKCE: the verifier is already in the client's\n * storage), passing the redirect's `sb_flow_id` so the right flow's verifier\n * is used; with an `error` it returns that error and makes no request.\n * Nothing is thrown; every failure is `{ error }`.\n *\n * Pass only the URL `WebBrowser.openAuthSessionAsync` resolved with, for the\n * sign-in this app just started. A code is single-use and the exchange uses\n * up the stored verifier whatever happens, so a stray `?code=` from another\n * link spends the sign-in in progress, and handling the same redirect twice\n * (the auth-session result and a `Linking` listener) fails the second time.\n * Handle each redirect once.\n *\n * The error `message` is fixed copy chosen by `code`; the redirect's own text\n * is only in `providerMessage`, which is untrusted.\n *\n * @stability Beta\n */\nexport async function completeOAuthRedirect(client: SignInClient, url: string): Promise<OAuthRedirectResult> {\n const params = redirectParams(url);\n if (params.error || params.error_description || params.error_code) {\n const code = params.error_code || params.error || \"oauth_error\";\n const message = REDIRECT_MESSAGES[code] ?? (params.error ? REDIRECT_MESSAGES[params.error] : undefined) ?? REDIRECT_FALLBACK;\n return { error: new GenesisAuthError(code, message, { providerMessage: params.error_description || undefined }) };\n }\n if (!params.code) return { error: new GenesisAuthError(\"missing_code\", \"The sign-in redirect carried no code\") };\n try {\n const { data, error } = await client.auth.exchangeCodeForSession(\n params.code,\n params.sb_flow_id ? { flowId: params.sb_flow_id } : undefined,\n );\n if (error) return { error: new GenesisAuthError(error.code ?? \"exchange_failed\", error.message, { cause: error }) };\n if (!data.session) return { error: new GenesisAuthError(\"no_session\", \"The code exchange returned no session\") };\n // supabase-js returns redirectType at run time without typing it.\n const redirectType = (data as { redirectType?: string | null }).redirectType ?? null;\n return { session: data.session, redirectType };\n } catch (e) {\n return { error: new GenesisAuthError(\"exchange_failed\", e instanceof Error ? e.message : \"The code exchange failed\", { cause: e }) };\n }\n}\n\n/** A person's name as `expo-apple-authentication` returns it. */\nexport interface AppleFullNameLike {\n givenName?: string | null;\n middleName?: string | null;\n familyName?: string | null;\n}\n\n/** The parts of `expo-apple-authentication` that {@link signInWithApple} uses. Pass the module itself. */\nexport interface AppleAuthenticationLike {\n isAvailableAsync(): Promise<boolean>;\n signInAsync(options: { requestedScopes?: number[]; nonce?: string }): Promise<{\n identityToken: string | null;\n /** Single-use, valid about five minutes: what a server exchanges for the Apple refresh token it revokes on deletion. */\n authorizationCode: string | null;\n fullName: AppleFullNameLike | null;\n email: string | null;\n }>;\n AppleAuthenticationScope: { FULL_NAME: number; EMAIL: number };\n}\n\n/** @stability Beta */\nexport interface SignInWithAppleOptions {\n /**\n * SHA-256 of a string as lowercase hex, e.g.\n * `(s) => Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, s)` from `expo-crypto`.\n * Apple gets the hash; Supabase gets the raw nonce and checks the token against it.\n */\n digest(value: string): Promise<string>;\n /** A fresh random nonce. Default: 32 bytes from `crypto.getRandomValues`, as hex (pass one, e.g. `Crypto.randomUUID`, where that's missing). */\n randomNonce?: () => string;\n}\n\n/** @stability Beta */\nexport type SignInWithAppleResult =\n | {\n session: Session;\n user: User;\n /** The name Apple shared. Apple sends it on the first sign-in only: save it then. */\n fullName: string | null;\n /** The email Apple shared (possibly a private relay address), on the first sign-in only. */\n email: string | null;\n /**\n * Apple's single-use authorization code (valid about five minutes). Send it\n * to your server straight away: it exchanges it for the Apple refresh token\n * that account deletion must revoke. See the README.\n */\n authorizationCode: string | null;\n canceled?: undefined;\n error?: undefined;\n }\n | { canceled: true; session?: undefined; error?: undefined }\n | {\n error: GenesisAuthError;\n /** When Apple shared a name but Supabase then refused: Apple won't send it again, so keep it for the retry. */\n fullName?: string | null;\n session?: undefined;\n canceled?: undefined;\n };\n\n/**\n * Native Sign in with Apple straight into a Supabase session: checks the\n * device supports it, asks for name and email with a SHA-256 hashed nonce, and\n * signs in with the identity token. Closing Apple's sheet is `{ canceled: true }`,\n * not an error. Nothing is thrown; every failure is `{ error }`.\n *\n * @stability Beta\n */\nexport async function signInWithApple(\n client: SignInClient,\n apple: AppleAuthenticationLike,\n { digest, randomNonce = defaultNonce }: SignInWithAppleOptions,\n): Promise<SignInWithAppleResult> {\n const unavailable = (cause?: unknown) => ({\n error: new GenesisAuthError(\"apple_unavailable\", \"Sign in with Apple isn't available on this device\", { cause }),\n });\n try {\n if (!(await apple.isAvailableAsync())) return unavailable();\n } catch (e) {\n return unavailable(e);\n }\n let credential: Awaited<ReturnType<AppleAuthenticationLike[\"signInAsync\"]>>;\n let nonce: string;\n try {\n nonce = randomNonce();\n const hashed = await digest(nonce);\n if (!/^[0-9a-f]{64}$/.test(hashed)) {\n return { error: new GenesisAuthError(\"bad_digest\", \"signInWithApple: digest must return SHA-256 as 64 lowercase hex characters\") };\n }\n credential = await apple.signInAsync({\n requestedScopes: [apple.AppleAuthenticationScope.FULL_NAME, apple.AppleAuthenticationScope.EMAIL],\n nonce: hashed,\n });\n } catch (e) {\n if ((e as { code?: unknown } | null)?.code === \"ERR_REQUEST_CANCELED\") return { canceled: true };\n return { error: new GenesisAuthError(\"apple_failed\", e instanceof Error ? e.message : \"Sign in with Apple failed\", { cause: e }) };\n }\n const fullName = formatFullName(credential.fullName);\n if (!credential.identityToken) return { error: new GenesisAuthError(\"apple_no_token\", \"Apple returned no identity token\"), fullName };\n try {\n const { data, error } = await client.auth.signInWithIdToken({ provider: \"apple\", token: credential.identityToken, nonce });\n if (error) return { error: new GenesisAuthError(error.code ?? \"sign_in_failed\", error.message, { cause: error }), fullName };\n if (!data.session || !data.user) return { error: new GenesisAuthError(\"no_session\", \"Sign-in returned no session\"), fullName };\n return {\n session: data.session,\n user: data.user,\n fullName,\n email: credential.email ?? null,\n authorizationCode: credential.authorizationCode ?? null,\n };\n } catch (e) {\n return { error: new GenesisAuthError(\"sign_in_failed\", e instanceof Error ? e.message : \"Sign-in failed\", { cause: e }), fullName };\n }\n}\n\n/** \"Given Middle Family\", or null when Apple shared no name. */\nexport function formatFullName(name: AppleFullNameLike | null | undefined): string | null {\n if (!name) return null;\n return [name.givenName, name.middleName, name.familyName].map((p) => p?.trim()).filter(Boolean).join(\" \") || null;\n}\n\nfunction defaultNonce(): string {\n const c = (globalThis as { crypto?: { getRandomValues?: (a: Uint8Array) => Uint8Array } }).crypto;\n if (!c?.getRandomValues) throw new Error(\"[genesis] signInWithApple: no crypto.getRandomValues here; pass randomNonce (e.g. expo-crypto's randomUUID)\");\n return Array.from(c.getRandomValues(new Uint8Array(32)), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n","/**\n * React Native helpers for a Supabase session. Nothing here imports React\n * Native or Expo: pass in `expo-secure-store`, `AppState`,\n * `expo-apple-authentication` and a plain store, so the package stays light\n * and the helpers are easy to test.\n */\nimport type { SessionStorage } from \"./index\";\n\nexport {\n GenesisAuthError,\n completeOAuthRedirect,\n formatFullName,\n redirectParams,\n signInWithApple,\n type AppleAuthenticationLike,\n type AppleFullNameLike,\n type OAuthRedirectResult,\n type SignInClient,\n type SignInWithAppleOptions,\n type SignInWithAppleResult,\n} from \"./sign-in\";\n\n/** The parts of `expo-secure-store` the Keychain storage uses. */\nexport interface SecureStoreLike {\n getItemAsync(key: string, options?: Record<string, unknown>): Promise<string | null>;\n setItemAsync(key: string, value: string, options?: Record<string, unknown>): Promise<void>;\n deleteItemAsync(key: string, options?: Record<string, unknown>): Promise<void>;\n /** `SecureStore.AFTER_FIRST_UNLOCK`, when available. */\n AFTER_FIRST_UNLOCK?: unknown;\n}\n\n/** @stability Beta */\nexport interface KeychainStorageOptions {\n /** UTF-8 bytes per Keychain item. A Keychain item should stay under about 2 KB; a Supabase session is larger. Default 1800. */\n chunkSize?: number;\n /** Extra options for every SecureStore call. Default: readable after first unlock, so a background refresh works. */\n secureStoreOptions?: Record<string, unknown>;\n}\n\n/**\n * Session storage in the Keychain (iOS) or Keystore (Android).\n *\n * A value is split into numbered chunks. Each write goes to a new generation of\n * chunks, and one manifest item (`<key>.n` = `generation:count`) switches to it\n * only once every chunk is written, so a write cut short leaves the previous\n * session whole. Chunks split between characters (never inside one) and\n * stay under `chunkSize` UTF-8 bytes. A pending marker (`<key>.p`) names a\n * generation while it is being written, so chunks left by a write that was\n * cut short or crashed are swept on the next write or remove. Writes to the\n * same key run one at a time, so two token refreshes can't interleave.\n *\n * @stability Beta\n */\nexport function keychainStorage(secureStore: SecureStoreLike, options: KeychainStorageOptions = {}): SessionStorage {\n const size = options.chunkSize ?? 1800;\n const opts = options.secureStoreOptions ?? (secureStore.AFTER_FIRST_UNLOCK !== undefined ? { keychainAccessible: secureStore.AFTER_FIRST_UNLOCK } : {});\n const chunkKey = (key: string, gen: string, i: number) => `${key}.${gen}.${i}`;\n let counter = 0;\n const queues = new Map<string, Promise<unknown>>();\n\n /** Run `task` after every earlier write to `key` has settled. */\n const serial = <T>(key: string, task: () => Promise<T>): Promise<T> => {\n const run = (queues.get(key) ?? Promise.resolve()).catch(() => undefined).then(task);\n queues.set(key, run);\n return run;\n };\n\n const parse = (raw: string | null) => {\n const [gen, count] = (raw ?? \"\").split(\":\");\n return gen && Number(count) > 0 ? { gen, count: Number(count) } : null;\n };\n const manifest = async (key: string) => parse(await secureStore.getItemAsync(`${key}.n`, opts));\n const pending = async (key: string) => parse(await secureStore.getItemAsync(`${key}.p`, opts));\n const drop = (key: string, gen: string, count: number) =>\n Promise.all(Array.from({ length: count }, (_, i) => secureStore.deleteItemAsync(chunkKey(key, gen, i), opts)));\n\n /** Remove chunks of a write that never completed (unless they are the current generation). */\n const sweepPending = async (key: string, current: string | undefined) => {\n const p = await pending(key);\n if (!p) return;\n if (p.gen !== current) await drop(key, p.gen, p.count);\n await secureStore.deleteItemAsync(`${key}.p`, opts);\n };\n\n return {\n getItem(key) {\n // Reads queue with writes, so a write can't swap the manifest and drop\n // the old chunks halfway through a read.\n return serial(key, async () => {\n const m = await manifest(key);\n if (!m) return null;\n const parts = await Promise.all(Array.from({ length: m.count }, (_, i) => secureStore.getItemAsync(chunkKey(key, m.gen, i), opts)));\n return parts.some((p) => p === null) ? null : parts.join(\"\");\n });\n },\n setItem(key, value) {\n return serial(key, async () => {\n const before = await manifest(key);\n await sweepPending(key, before?.gen);\n const gen = `${Date.now().toString(36)}${(counter++).toString(36)}`;\n const parts = splitUtf8(value, size);\n await secureStore.setItemAsync(`${key}.p`, `${gen}:${parts.length}`, opts);\n // Let every chunk write settle before failing, so a late write can't land\n // after the next operation has swept this generation.\n const written = await Promise.allSettled(parts.map((p, i) => secureStore.setItemAsync(chunkKey(key, gen, i), p, opts)));\n const failed = written.find((r): r is PromiseRejectedResult => r.status === \"rejected\");\n if (failed) throw failed.reason;\n await secureStore.setItemAsync(`${key}.n`, `${gen}:${parts.length}`, opts);\n await secureStore.deleteItemAsync(`${key}.p`, opts);\n if (before) await drop(key, before.gen, before.count);\n });\n },\n removeItem(key) {\n return serial(key, async () => {\n const m = await manifest(key);\n await sweepPending(key, m?.gen);\n // Chunks first: the manifest is what finds them, so it goes last.\n if (m) await drop(key, m.gen, m.count);\n await secureStore.deleteItemAsync(`${key}.n`, opts);\n });\n },\n };\n}\n\n/** The parts of a Supabase client's auth the refresh helper uses. */\nexport interface AutoRefreshAuth {\n auth: { startAutoRefresh(): Promise<void> | void; stopAutoRefresh(): Promise<void> | void };\n}\n\n/** The parts of React Native's `AppState` the refresh helper uses. */\nexport interface AppStateLike {\n currentState?: string | null;\n addEventListener(type: \"change\", listener: (state: string) => void): { remove(): void };\n}\n\n/**\n * Refresh the session only while the app is in front, as Supabase recommends\n * for React Native. Returns the unsubscribe.\n *\n * @stability Beta\n */\nexport function refreshWhileActive(client: AutoRefreshAuth, appState: AppStateLike): () => void {\n const apply = (state: string | null | undefined) => {\n if (state === \"active\") void client.auth.startAutoRefresh();\n else void client.auth.stopAutoRefresh();\n };\n apply(appState.currentState ?? \"active\");\n const sub = appState.addEventListener(\"change\", apply);\n return () => {\n sub.remove();\n void client.auth.stopAutoRefresh();\n };\n}\n\n/** @stability Beta */\nexport interface WipeOnReinstallOptions {\n /** The session storage (usually `keychainStorage`). */\n session: SessionStorage;\n /** The session's storage key. Supabase's default is `sb-<project ref>-auth-token`. */\n sessionKey: string;\n /** A store that an uninstall deletes, e.g. AsyncStorage. The Keychain survives an uninstall; this is how we notice one. */\n installMarker: SessionStorage;\n /** The marker's key. Default `genesis.installed`. */\n markerKey?: string;\n}\n\n/**\n * On iOS the Keychain outlives an uninstall, so a reinstalled app would sign\n * the last person back in. Call this before creating the client: on the first\n * launch after an install it removes any session left in the Keychain, then\n * sets a marker so later launches keep the session. Returns whether it wiped.\n *\n * @stability Beta\n */\nexport async function wipeSessionOnReinstall({ session, sessionKey, installMarker, markerKey = \"genesis.installed\" }: WipeOnReinstallOptions): Promise<boolean> {\n if ((await installMarker.getItem(markerKey)) !== null) return false;\n // The session, plus what supabase-js keeps beside it: the PKCE verifier (the\n // single legacy slot, each indexed flow's slot and the index) and, with\n // userStorage, the user.\n const indexKey = `${sessionKey}-flows-code-verifier`;\n for (const flowId of parseFlowIndex(await session.getItem(indexKey))) await session.removeItem(`${sessionKey}-flow-${flowId}-code-verifier`);\n for (const key of [sessionKey, `${sessionKey}-code-verifier`, indexKey, `${sessionKey}-user`]) await session.removeItem(key);\n await installMarker.setItem(markerKey, \"1\");\n return true;\n}\n\n/** UTF-8 byte length of one code point. */\nconst utf8Length = (codePoint: number) => (codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : codePoint < 0x10000 ? 3 : 4);\n\n/** Split text into pieces of at most `maxBytes` UTF-8 bytes, never inside a character. */\nexport function splitUtf8(text: string, maxBytes: number): string[] {\n if (text === \"\") return [\"\"];\n const parts: string[] = [];\n let current = \"\";\n let bytes = 0;\n for (const ch of text) {\n const n = utf8Length(ch.codePointAt(0)!);\n if (bytes + n > maxBytes && current) {\n parts.push(current);\n current = \"\";\n bytes = 0;\n }\n current += ch;\n bytes += n;\n }\n parts.push(current);\n return parts;\n}\n\n/** supabase-js's PKCE flow index: a JSON array of flow ids. Anything else is ignored. */\nfunction parseFlowIndex(raw: string | null): string[] {\n if (!raw) return [];\n try {\n const ids: unknown = JSON.parse(raw);\n return Array.isArray(ids) ? ids.filter((id): id is string => typeof id === \"string\" && /^[\\w-]{1,128}$/.test(id)) : [];\n } catch {\n return [];\n }\n}\n"]}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
-- Genesis account deletion: public.delete_my_account()
|
|
2
|
+
--
|
|
3
|
+
-- App Store Review Guideline 5.1.1(v) requires an app that lets people make
|
|
4
|
+
-- an account to let them delete it from inside the app. This function deletes
|
|
5
|
+
-- the signed-in person's login; the client half is `deleteMyAccount(client)`
|
|
6
|
+
-- in @marktiderman/genesis-supabase.
|
|
7
|
+
--
|
|
8
|
+
-- Use: copy this file into your app's `supabase/migrations/` under a new
|
|
9
|
+
-- timestamped name (e.g. `20261024000000_genesis_account_deletion.sql`).
|
|
10
|
+
-- Never edit it once applied; change it with a new migration.
|
|
11
|
+
--
|
|
12
|
+
-- What it refuses, by SQLSTATE (the client sees it as `SupabaseQueryError.code`):
|
|
13
|
+
-- 42501 nobody is signed in, or the person has a verified MFA factor and
|
|
14
|
+
-- this session isn't aal2 (verify the second factor first)
|
|
15
|
+
-- GA001 refused by the app's hook (below), e.g. the sole owner of something
|
|
16
|
+
-- GA002 the app requires a recent sign-in and this one isn't (below)
|
|
17
|
+
-- 23503 a row still references the login (see "Your data")
|
|
18
|
+
--
|
|
19
|
+
-- Your data: deleting from auth.users removes rows whose foreign keys to
|
|
20
|
+
-- auth.users(id) say `on delete cascade`, and fails (23503) on any that
|
|
21
|
+
-- don't, so the person keeps their whole account rather than half of it. Give
|
|
22
|
+
-- every user-owned table a foreign key to auth.users with `on delete cascade`
|
|
23
|
+
-- (or `set null` for rows that must outlive the person): a table keyed on
|
|
24
|
+
-- auth.uid() with no foreign key keeps orphans, and accepts new ones while an
|
|
25
|
+
-- old access token is still valid (below).
|
|
26
|
+
--
|
|
27
|
+
-- Storage: Supabase documents that a user who owns objects in Storage can't
|
|
28
|
+
-- be deleted, so this fails (23503) while they have files. Remove a person's
|
|
29
|
+
-- files through the Storage API (not SQL) before calling it. (Documented by
|
|
30
|
+
-- Supabase; not verified against a full stack in Genesis's own tests.)
|
|
31
|
+
--
|
|
32
|
+
-- Sign in with Apple: Apple requires apps to revoke the person's Apple tokens
|
|
33
|
+
-- (https://appleid.apple.com/auth/revoke) when the account is deleted. That
|
|
34
|
+
-- needs your Apple client secret, so it is a server step this file can't do;
|
|
35
|
+
-- see the package README ("Not covered yet").
|
|
36
|
+
--
|
|
37
|
+
-- Sessions: the person's refresh tokens go with the login. An access token
|
|
38
|
+
-- already issued stays valid until it expires (the JWT expiry, one hour by
|
|
39
|
+
-- default); `deleteMyAccount` signs this device out straight after.
|
|
40
|
+
--
|
|
41
|
+
-- The app hook: if `private.genesis_before_account_delete(uuid)` exists, it
|
|
42
|
+
-- runs first, in the same transaction, with this function's (the owner's)
|
|
43
|
+
-- rights and the person's id. Use it to scrub data, set flags, or refuse. Any
|
|
44
|
+
-- error it raises refuses the delete and nothing is deleted; it reaches the
|
|
45
|
+
-- client as GA001 (or as the hook's own code when that starts with GA), e.g.
|
|
46
|
+
--
|
|
47
|
+
-- create function private.genesis_before_account_delete(person uuid) returns void
|
|
48
|
+
-- language plpgsql set search_path = '' as $$
|
|
49
|
+
-- begin
|
|
50
|
+
-- if exists (select 1 from public.teams t where t.sole_owner = person) then
|
|
51
|
+
-- raise exception 'Hand over your teams before deleting your account';
|
|
52
|
+
-- end if;
|
|
53
|
+
-- -- Optional: require a sign-in in the last 10 minutes (GA002 otherwise).
|
|
54
|
+
-- perform private.genesis_require_recent_sign_in(interval '10 minutes');
|
|
55
|
+
-- end $$;
|
|
56
|
+
-- revoke execute on function private.genesis_before_account_delete(uuid) from public, anon, authenticated;
|
|
57
|
+
--
|
|
58
|
+
-- Verify: the pgTAP test beside this file (`tests/0001_genesis_account_deletion.test.sql`).
|
|
59
|
+
|
|
60
|
+
create schema if not exists private;
|
|
61
|
+
|
|
62
|
+
-- Raises GA002 unless the person authenticated (password, OTP, OAuth, MFA…)
|
|
63
|
+
-- within `max_age`. Reads the newest `amr` timestamp in the JWT, which records
|
|
64
|
+
-- when they actually signed in and survives token refreshes; falls back to
|
|
65
|
+
-- `iat` (when this token was issued, which a refresh renews) without one.
|
|
66
|
+
-- Not called by default: an app turns it on from its hook.
|
|
67
|
+
create or replace function private.genesis_require_recent_sign_in(max_age interval)
|
|
68
|
+
returns void
|
|
69
|
+
language plpgsql
|
|
70
|
+
stable
|
|
71
|
+
set search_path = ''
|
|
72
|
+
as $$
|
|
73
|
+
declare
|
|
74
|
+
claims jsonb := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb, '{}'::jsonb);
|
|
75
|
+
signed_in bigint;
|
|
76
|
+
begin
|
|
77
|
+
select max((m ->> 'timestamp')::bigint) into signed_in
|
|
78
|
+
from jsonb_array_elements(case when jsonb_typeof(claims -> 'amr') = 'array' then claims -> 'amr' else '[]'::jsonb end) m
|
|
79
|
+
where m ->> 'timestamp' ~ '^\d+$';
|
|
80
|
+
signed_in := coalesce(signed_in, nullif(claims ->> 'iat', '')::bigint);
|
|
81
|
+
if signed_in is null or to_timestamp(signed_in) < now() - max_age then
|
|
82
|
+
raise exception 'delete_my_account: sign in again to delete your account'
|
|
83
|
+
using errcode = 'GA002', hint = 'Ask the person to sign in again, then retry.';
|
|
84
|
+
end if;
|
|
85
|
+
end;
|
|
86
|
+
$$;
|
|
87
|
+
|
|
88
|
+
create or replace function public.delete_my_account()
|
|
89
|
+
returns void
|
|
90
|
+
language plpgsql
|
|
91
|
+
security definer
|
|
92
|
+
set search_path = ''
|
|
93
|
+
as $$
|
|
94
|
+
declare
|
|
95
|
+
me uuid := auth.uid();
|
|
96
|
+
claims jsonb := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb, '{}'::jsonb);
|
|
97
|
+
begin
|
|
98
|
+
if me is null then
|
|
99
|
+
raise exception 'delete_my_account: not signed in' using errcode = '42501';
|
|
100
|
+
end if;
|
|
101
|
+
|
|
102
|
+
-- MFA step-up: with a verified second factor, only an aal2 session may delete.
|
|
103
|
+
if exists (select 1 from auth.mfa_factors f where f.user_id = me and f.status = 'verified')
|
|
104
|
+
and coalesce(claims ->> 'aal', '') <> 'aal2' then
|
|
105
|
+
raise exception 'delete_my_account: verify your second factor first'
|
|
106
|
+
using errcode = '42501', hint = 'Complete MFA (aal2), then retry.';
|
|
107
|
+
end if;
|
|
108
|
+
|
|
109
|
+
if to_regprocedure('private.genesis_before_account_delete(uuid)') is not null then
|
|
110
|
+
begin
|
|
111
|
+
perform private.genesis_before_account_delete(me);
|
|
112
|
+
exception when others then
|
|
113
|
+
if left(sqlstate, 2) = 'GA' then
|
|
114
|
+
raise;
|
|
115
|
+
end if;
|
|
116
|
+
raise exception '%', sqlerrm
|
|
117
|
+
using errcode = 'GA001', detail = format('Refused by private.genesis_before_account_delete (SQLSTATE %s).', sqlstate);
|
|
118
|
+
end;
|
|
119
|
+
end if;
|
|
120
|
+
|
|
121
|
+
delete from auth.users where id = me;
|
|
122
|
+
end;
|
|
123
|
+
$$;
|
|
124
|
+
|
|
125
|
+
comment on function public.delete_my_account() is
|
|
126
|
+
'Deletes the signed-in person''s login (auth.users) and, through on delete cascade, their data. Genesis account block.';
|
|
127
|
+
|
|
128
|
+
-- Privileges by name. Revoking here doesn't survive a later blanket grant
|
|
129
|
+
-- (`grant execute on all functions in schema public to anon` hands this back
|
|
130
|
+
-- to anon), so a blanket grant must exclude these functions.
|
|
131
|
+
revoke execute on function public.delete_my_account() from public, anon;
|
|
132
|
+
grant execute on function public.delete_my_account() to authenticated;
|
|
133
|
+
revoke execute on function private.genesis_require_recent_sign_in(interval) from public, anon, authenticated;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
-- Genesis app release policy: public.app_release_policy
|
|
2
|
+
--
|
|
3
|
+
-- What a native app reads at launch to decide whether this install must
|
|
4
|
+
-- update from the store, whether the app is down for maintenance, and which
|
|
5
|
+
-- release notes to show. The client half is `supabasePolicySource(client)` in
|
|
6
|
+
-- @marktiderman/genesis-native/updates.
|
|
7
|
+
--
|
|
8
|
+
-- Use: copy this file into your app's `supabase/migrations/` under a new
|
|
9
|
+
-- timestamped name (e.g. `20261024000000_genesis_app_release_policy.sql`).
|
|
10
|
+
-- Never edit it once applied; change it with a new migration.
|
|
11
|
+
--
|
|
12
|
+
-- Access: anyone may read (anon included: the app reads it before sign-in);
|
|
13
|
+
-- nobody but the service role (or a migration) may write. Edit the row from
|
|
14
|
+
-- the SQL editor, a migration, or a server job with the secret key.
|
|
15
|
+
--
|
|
16
|
+
-- Failing open: the client never blocks on a missing row, a malformed value
|
|
17
|
+
-- or a failed read. The checks below keep malformed values out in the first
|
|
18
|
+
-- place: a minimum version is plain dotted numbers (`1`, `1.4`, `1.4.0`),
|
|
19
|
+
-- store links are https (or the stores' own schemes), a minimum version
|
|
20
|
+
-- comes with its platform's store link, and notes are an array of
|
|
21
|
+
-- `{ "version": "1.4.0", "title"?: "...", "body": "..." }`.
|
|
22
|
+
--
|
|
23
|
+
-- One row per app (`app_id`, default 'default'). For example:
|
|
24
|
+
--
|
|
25
|
+
-- insert into public.app_release_policy (app_id, min_version_ios, store_url_ios)
|
|
26
|
+
-- values ('default', '1.4.0', 'https://apps.apple.com/app/id0000000000')
|
|
27
|
+
-- on conflict (app_id) do update set min_version_ios = excluded.min_version_ios,
|
|
28
|
+
-- store_url_ios = excluded.store_url_ios, updated_at = now();
|
|
29
|
+
--
|
|
30
|
+
-- Maintenance: set `maintenance = true` (and a `maintenance_message`). The
|
|
31
|
+
-- client honours it only from a live read, never from its cache, so a phone
|
|
32
|
+
-- offline when maintenance ends is never stuck behind it.
|
|
33
|
+
--
|
|
34
|
+
-- Verify: the pgTAP test beside this file (`tests/0002_genesis_app_release_policy.test.sql`).
|
|
35
|
+
|
|
36
|
+
create table if not exists public.app_release_policy (
|
|
37
|
+
app_id text primary key default 'default'
|
|
38
|
+
constraint app_release_policy_app_id_format check (app_id ~ '^[a-z0-9][a-z0-9._-]{0,63}$'),
|
|
39
|
+
min_version_ios text
|
|
40
|
+
constraint app_release_policy_min_version_ios_format check (min_version_ios ~ '^[0-9]{1,9}(\.[0-9]{1,9}){0,3}$'),
|
|
41
|
+
min_version_android text
|
|
42
|
+
constraint app_release_policy_min_version_android_format check (min_version_android ~ '^[0-9]{1,9}(\.[0-9]{1,9}){0,3}$'),
|
|
43
|
+
store_url_ios text
|
|
44
|
+
constraint app_release_policy_store_url_ios_format check (store_url_ios ~ '^(https|itms-apps)://'),
|
|
45
|
+
store_url_android text
|
|
46
|
+
constraint app_release_policy_store_url_android_format check (store_url_android ~ '^(https|market)://'),
|
|
47
|
+
maintenance boolean not null default false,
|
|
48
|
+
maintenance_message text
|
|
49
|
+
constraint app_release_policy_maintenance_message_length check (char_length(maintenance_message) <= 500),
|
|
50
|
+
-- An array of objects, each with a string version and body (and a string title, if any).
|
|
51
|
+
-- `strict` stops lax mode unwrapping nested arrays; `.type()` rejects arrays where strings belong.
|
|
52
|
+
notes jsonb not null default '[]'::jsonb
|
|
53
|
+
constraint app_release_policy_notes_shape check (
|
|
54
|
+
jsonb_typeof(notes) = 'array'
|
|
55
|
+
and not jsonb_path_exists(notes, 'strict $[*] ? (@.type() != "object")')
|
|
56
|
+
and not jsonb_path_exists(notes, '$[*] ? (!(@.version.type() == "string") || !(@.body.type() == "string") || (exists(@.title) && !(@.title.type() == "string")))')
|
|
57
|
+
),
|
|
58
|
+
updated_at timestamptz not null default now(),
|
|
59
|
+
-- A minimum version needs a store link: the force-upgrade screen's only way out is the store.
|
|
60
|
+
-- (The client also forces nothing without one.)
|
|
61
|
+
constraint app_release_policy_ios_store_link check (min_version_ios is null or store_url_ios is not null),
|
|
62
|
+
constraint app_release_policy_android_store_link check (min_version_android is null or store_url_android is not null)
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
comment on table public.app_release_policy is
|
|
66
|
+
'Minimum store versions, maintenance switch and release notes for native apps. Readable by everyone, written only by the service role. Genesis updates block.';
|
|
67
|
+
|
|
68
|
+
alter table public.app_release_policy enable row level security;
|
|
69
|
+
|
|
70
|
+
-- Reading: everyone, every row. No insert, update or delete policy exists, so
|
|
71
|
+
-- under RLS anon and authenticated can't write even if a later grant lets them try.
|
|
72
|
+
drop policy if exists app_release_policy_read on public.app_release_policy;
|
|
73
|
+
create policy app_release_policy_read on public.app_release_policy
|
|
74
|
+
for select to anon, authenticated
|
|
75
|
+
using (true);
|
|
76
|
+
|
|
77
|
+
-- Privileges by name. Supabase's default privileges give anon and
|
|
78
|
+
-- authenticated every privilege on a new public table; take them back and
|
|
79
|
+
-- grant only select. The service role bypasses RLS and keeps write access.
|
|
80
|
+
revoke all on table public.app_release_policy from public, anon, authenticated;
|
|
81
|
+
grant select on table public.app_release_policy to anon, authenticated;
|
|
82
|
+
grant select, insert, update, delete on table public.app_release_policy to service_role;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
-- pgTAP test for 0001_genesis_account_deletion.sql.
|
|
2
|
+
-- Copy into your app's `supabase/tests/database/` and run `supabase test db`.
|
|
3
|
+
-- It reads only its own users and tables and rolls everything back. If your
|
|
4
|
+
-- app already has `private.genesis_before_account_delete`, the hook cases
|
|
5
|
+
-- replace it inside this transaction only.
|
|
6
|
+
--
|
|
7
|
+
-- Signing in: auth.uid() reads request.jwt.claims (and the legacy
|
|
8
|
+
-- request.jwt.claim.sub on older images), so each case sets both, then
|
|
9
|
+
-- switches to the authenticated role.
|
|
10
|
+
begin;
|
|
11
|
+
create extension if not exists pgtap with schema extensions;
|
|
12
|
+
select plan(20);
|
|
13
|
+
|
|
14
|
+
insert into auth.users (id, aud, role, email, instance_id)
|
|
15
|
+
select ('00000000-0000-4000-8000-0000000000' || n)::uuid, 'authenticated', 'authenticated', 'genesis-delete-' || n || '@example.test', '00000000-0000-0000-0000-000000000000'
|
|
16
|
+
from unnest(array['a1', 'b2', 'c3', 'd4', 'e5', 'f6', '07']) n;
|
|
17
|
+
|
|
18
|
+
create function pg_temp.exists_user(n text) returns boolean language sql as $$
|
|
19
|
+
select exists (select 1 from auth.users where id = ('00000000-0000-4000-8000-0000000000' || n)::uuid)
|
|
20
|
+
$$;
|
|
21
|
+
|
|
22
|
+
-- Shape and privileges -------------------------------------------------------
|
|
23
|
+
select ok(
|
|
24
|
+
(select prosecdef from pg_proc where oid = 'public.delete_my_account()'::regprocedure),
|
|
25
|
+
'delete_my_account is security definer'
|
|
26
|
+
);
|
|
27
|
+
select ok(
|
|
28
|
+
(select proconfig @> array['search_path=""'] from pg_proc where oid = 'public.delete_my_account()'::regprocedure),
|
|
29
|
+
'delete_my_account pins an empty search_path'
|
|
30
|
+
);
|
|
31
|
+
select is(
|
|
32
|
+
(select proowner::regrole::text from pg_proc where oid = 'public.delete_my_account()'::regprocedure),
|
|
33
|
+
'postgres',
|
|
34
|
+
'delete_my_account is owned by postgres, as hosted migrations run'
|
|
35
|
+
);
|
|
36
|
+
select ok(not has_function_privilege('anon', 'public.delete_my_account()', 'execute'), 'anon cannot execute it');
|
|
37
|
+
select ok(
|
|
38
|
+
not exists (
|
|
39
|
+
select 1 from pg_proc p, aclexplode(coalesce(p.proacl, acldefault('f', p.proowner))) a
|
|
40
|
+
where p.oid = 'public.delete_my_account()'::regprocedure and a.grantee = 0 and a.privilege_type = 'EXECUTE'
|
|
41
|
+
),
|
|
42
|
+
'public has no execute grant'
|
|
43
|
+
);
|
|
44
|
+
select ok(has_function_privilege('authenticated', 'public.delete_my_account()', 'execute'), 'authenticated can execute it');
|
|
45
|
+
select ok(
|
|
46
|
+
not has_function_privilege('authenticated', 'private.genesis_require_recent_sign_in(interval)', 'execute'),
|
|
47
|
+
'authenticated cannot call the recent-sign-in helper directly'
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
-- Deletes only the caller ------------------------------------------------------
|
|
51
|
+
select set_config('request.jwt.claims', '{"sub":"00000000-0000-4000-8000-0000000000a1","role":"authenticated"}', true);
|
|
52
|
+
select set_config('request.jwt.claim.sub', '00000000-0000-4000-8000-0000000000a1', true);
|
|
53
|
+
set local role authenticated;
|
|
54
|
+
select lives_ok($$ select public.delete_my_account() $$, 'a signed-in person deletes their account');
|
|
55
|
+
reset role;
|
|
56
|
+
select ok(not pg_temp.exists_user('a1') and pg_temp.exists_user('b2'), 'only the caller''s login is gone');
|
|
57
|
+
|
|
58
|
+
select set_config('request.jwt.claims', '{"role":"authenticated"}', true);
|
|
59
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
60
|
+
set local role authenticated;
|
|
61
|
+
select throws_ok($$ select public.delete_my_account() $$, '42501', null, 'a call with no signed-in person is refused');
|
|
62
|
+
reset role;
|
|
63
|
+
|
|
64
|
+
-- Foreign keys -------------------------------------------------------------------
|
|
65
|
+
create table public.genesis_test_kept (person uuid references auth.users (id));
|
|
66
|
+
create table public.genesis_test_cascaded (person uuid references auth.users (id) on delete cascade);
|
|
67
|
+
insert into public.genesis_test_kept values ('00000000-0000-4000-8000-0000000000c3');
|
|
68
|
+
insert into public.genesis_test_cascaded values ('00000000-0000-4000-8000-0000000000d4');
|
|
69
|
+
|
|
70
|
+
select set_config('request.jwt.claims', '{"sub":"00000000-0000-4000-8000-0000000000c3","role":"authenticated"}', true);
|
|
71
|
+
select set_config('request.jwt.claim.sub', '00000000-0000-4000-8000-0000000000c3', true);
|
|
72
|
+
set local role authenticated;
|
|
73
|
+
select throws_ok($$ select public.delete_my_account() $$, '23503', null, 'a row without on delete cascade refuses the delete');
|
|
74
|
+
reset role;
|
|
75
|
+
select ok(pg_temp.exists_user('c3'), '... and the login is kept');
|
|
76
|
+
|
|
77
|
+
select set_config('request.jwt.claims', '{"sub":"00000000-0000-4000-8000-0000000000d4","role":"authenticated"}', true);
|
|
78
|
+
select set_config('request.jwt.claim.sub', '00000000-0000-4000-8000-0000000000d4', true);
|
|
79
|
+
set local role authenticated;
|
|
80
|
+
select lives_ok($$ select public.delete_my_account() $$, 'a person with cascading rows deletes their account');
|
|
81
|
+
reset role;
|
|
82
|
+
select is((select count(*) from public.genesis_test_cascaded)::int, 0, '... and their cascading rows go with it');
|
|
83
|
+
|
|
84
|
+
-- MFA step-up -------------------------------------------------------------------
|
|
85
|
+
insert into auth.mfa_factors (id, user_id, friendly_name, factor_type, status, created_at, updated_at)
|
|
86
|
+
values (gen_random_uuid(), '00000000-0000-4000-8000-0000000000e5', 'genesis-test', 'totp', 'verified', now(), now());
|
|
87
|
+
|
|
88
|
+
select set_config('request.jwt.claims', '{"sub":"00000000-0000-4000-8000-0000000000e5","role":"authenticated","aal":"aal1"}', true);
|
|
89
|
+
select set_config('request.jwt.claim.sub', '00000000-0000-4000-8000-0000000000e5', true);
|
|
90
|
+
set local role authenticated;
|
|
91
|
+
select throws_ok($$ select public.delete_my_account() $$, '42501', null, 'an aal1 session cannot delete an account with a verified factor');
|
|
92
|
+
reset role;
|
|
93
|
+
|
|
94
|
+
select set_config('request.jwt.claims', '{"sub":"00000000-0000-4000-8000-0000000000e5","role":"authenticated","aal":"aal2"}', true);
|
|
95
|
+
set local role authenticated;
|
|
96
|
+
select lives_ok($$ select public.delete_my_account() $$, 'an aal2 session can');
|
|
97
|
+
reset role;
|
|
98
|
+
|
|
99
|
+
-- The app hook -------------------------------------------------------------------
|
|
100
|
+
create or replace function private.genesis_before_account_delete(person uuid) returns void
|
|
101
|
+
language plpgsql set search_path = '' as $$
|
|
102
|
+
begin
|
|
103
|
+
if person = '00000000-0000-4000-8000-0000000000f6' then
|
|
104
|
+
raise exception 'sole owner';
|
|
105
|
+
end if;
|
|
106
|
+
if person = '00000000-0000-4000-8000-000000000007' then
|
|
107
|
+
perform private.genesis_require_recent_sign_in(interval '10 minutes');
|
|
108
|
+
end if;
|
|
109
|
+
end $$;
|
|
110
|
+
|
|
111
|
+
select set_config('request.jwt.claims', '{"sub":"00000000-0000-4000-8000-0000000000f6","role":"authenticated"}', true);
|
|
112
|
+
select set_config('request.jwt.claim.sub', '00000000-0000-4000-8000-0000000000f6', true);
|
|
113
|
+
set local role authenticated;
|
|
114
|
+
select throws_ok($$ select public.delete_my_account() $$, 'GA001', 'sole owner', 'a hook that raises refuses the delete, as GA001');
|
|
115
|
+
reset role;
|
|
116
|
+
select ok(pg_temp.exists_user('f6'), '... and the login is kept');
|
|
117
|
+
|
|
118
|
+
-- Recent sign-in, turned on from the hook: an hour-old sign-in is refused, a fresh one isn't.
|
|
119
|
+
select set_config('request.jwt.claims', json_build_object(
|
|
120
|
+
'sub', '00000000-0000-4000-8000-000000000007', 'role', 'authenticated',
|
|
121
|
+
'iat', extract(epoch from now())::bigint,
|
|
122
|
+
'amr', json_build_array(json_build_object('method', 'password', 'timestamp', extract(epoch from now() - interval '1 hour')::bigint))
|
|
123
|
+
)::text, true);
|
|
124
|
+
select set_config('request.jwt.claim.sub', '00000000-0000-4000-8000-000000000007', true);
|
|
125
|
+
set local role authenticated;
|
|
126
|
+
select throws_ok($$ select public.delete_my_account() $$, 'GA002', null, 'a refreshed token from an old sign-in is refused when the app requires a recent one');
|
|
127
|
+
reset role;
|
|
128
|
+
|
|
129
|
+
select set_config('request.jwt.claims', json_build_object(
|
|
130
|
+
'sub', '00000000-0000-4000-8000-000000000007', 'role', 'authenticated',
|
|
131
|
+
'amr', json_build_array(json_build_object('method', 'password', 'timestamp', extract(epoch from now() - interval '1 minute')::bigint))
|
|
132
|
+
)::text, true);
|
|
133
|
+
set local role authenticated;
|
|
134
|
+
select lives_ok($$ select public.delete_my_account() $$, 'a recent sign-in passes');
|
|
135
|
+
reset role;
|
|
136
|
+
|
|
137
|
+
select * from finish();
|
|
138
|
+
rollback;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
-- @feat:app-updates
|
|
2
|
+
-- pgTAP test for 0002_genesis_app_release_policy.sql.
|
|
3
|
+
-- Copy into your app's `supabase/tests/database/` and run `supabase test db`.
|
|
4
|
+
-- It writes only its own `genesis-test-*` rows and rolls everything back.
|
|
5
|
+
begin;
|
|
6
|
+
create extension if not exists pgtap with schema extensions;
|
|
7
|
+
select plan(29);
|
|
8
|
+
|
|
9
|
+
-- Shape -------------------------------------------------------------------------
|
|
10
|
+
select has_table('public', 'app_release_policy', 'app_release_policy exists');
|
|
11
|
+
select col_is_pk('public', 'app_release_policy', 'app_id', 'one row per app');
|
|
12
|
+
select ok(
|
|
13
|
+
(select relrowsecurity from pg_class where oid = 'public.app_release_policy'::regclass),
|
|
14
|
+
'row level security is on'
|
|
15
|
+
);
|
|
16
|
+
select is(
|
|
17
|
+
(select array_agg(polname::text order by polname) from pg_policy where polrelid = 'public.app_release_policy'::regclass),
|
|
18
|
+
array['app_release_policy_read'],
|
|
19
|
+
'the only policy is the read policy'
|
|
20
|
+
);
|
|
21
|
+
select is(
|
|
22
|
+
(select polcmd::text from pg_policy where polrelid = 'public.app_release_policy'::regclass),
|
|
23
|
+
'r',
|
|
24
|
+
'... and it is for select'
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
-- Privileges ------------------------------------------------------------------------
|
|
28
|
+
select ok(has_table_privilege('anon', 'public.app_release_policy', 'select'), 'anon can read');
|
|
29
|
+
select ok(has_table_privilege('authenticated', 'public.app_release_policy', 'select'), 'authenticated can read');
|
|
30
|
+
select ok(
|
|
31
|
+
not has_table_privilege('anon', 'public.app_release_policy', 'insert')
|
|
32
|
+
and not has_table_privilege('anon', 'public.app_release_policy', 'update')
|
|
33
|
+
and not has_table_privilege('anon', 'public.app_release_policy', 'delete')
|
|
34
|
+
and not has_table_privilege('anon', 'public.app_release_policy', 'truncate'),
|
|
35
|
+
'anon cannot write'
|
|
36
|
+
);
|
|
37
|
+
select ok(
|
|
38
|
+
not has_table_privilege('authenticated', 'public.app_release_policy', 'insert')
|
|
39
|
+
and not has_table_privilege('authenticated', 'public.app_release_policy', 'update')
|
|
40
|
+
and not has_table_privilege('authenticated', 'public.app_release_policy', 'delete')
|
|
41
|
+
and not has_table_privilege('authenticated', 'public.app_release_policy', 'truncate'),
|
|
42
|
+
'authenticated cannot write'
|
|
43
|
+
);
|
|
44
|
+
select ok(
|
|
45
|
+
not exists (
|
|
46
|
+
select 1 from pg_class c, aclexplode(coalesce(c.relacl, acldefault('r', c.relowner))) a
|
|
47
|
+
where c.oid = 'public.app_release_policy'::regclass and a.grantee = 0
|
|
48
|
+
),
|
|
49
|
+
'public has no grant'
|
|
50
|
+
);
|
|
51
|
+
select ok(
|
|
52
|
+
has_table_privilege('service_role', 'public.app_release_policy', 'insert')
|
|
53
|
+
and has_table_privilege('service_role', 'public.app_release_policy', 'update')
|
|
54
|
+
and has_table_privilege('service_role', 'public.app_release_policy', 'delete'),
|
|
55
|
+
'the service role can write'
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
-- Writing and reading as each role ----------------------------------------------------------
|
|
59
|
+
set local role service_role;
|
|
60
|
+
select lives_ok(
|
|
61
|
+
$$ insert into public.app_release_policy (app_id, min_version_ios, min_version_android, store_url_ios, store_url_android, maintenance, maintenance_message, notes)
|
|
62
|
+
values ('genesis-test-a', '1.4.0', '1.4', 'https://apps.apple.com/app/id1', 'market://details?id=x', true, 'Back soon',
|
|
63
|
+
'[{"version": "1.4.0", "title": "Faster", "body": "Everything loads quicker."}]') $$,
|
|
64
|
+
'the service role writes a policy'
|
|
65
|
+
);
|
|
66
|
+
reset role;
|
|
67
|
+
|
|
68
|
+
set local role anon;
|
|
69
|
+
select is(
|
|
70
|
+
(select min_version_ios from public.app_release_policy where app_id = 'genesis-test-a'),
|
|
71
|
+
'1.4.0',
|
|
72
|
+
'anon reads it'
|
|
73
|
+
);
|
|
74
|
+
select throws_ok(
|
|
75
|
+
$$ insert into public.app_release_policy (app_id) values ('genesis-test-b') $$,
|
|
76
|
+
'42501', null, 'anon cannot insert'
|
|
77
|
+
);
|
|
78
|
+
select throws_ok(
|
|
79
|
+
$$ update public.app_release_policy set maintenance = false where app_id = 'genesis-test-a' $$,
|
|
80
|
+
'42501', null, 'anon cannot update'
|
|
81
|
+
);
|
|
82
|
+
reset role;
|
|
83
|
+
|
|
84
|
+
select set_config('request.jwt.claims', '{"sub":"00000000-0000-4000-8000-0000000000a1","role":"authenticated"}', true);
|
|
85
|
+
set local role authenticated;
|
|
86
|
+
select is(
|
|
87
|
+
(select maintenance from public.app_release_policy where app_id = 'genesis-test-a'),
|
|
88
|
+
true,
|
|
89
|
+
'authenticated reads it'
|
|
90
|
+
);
|
|
91
|
+
select throws_ok(
|
|
92
|
+
$$ delete from public.app_release_policy where app_id = 'genesis-test-a' $$,
|
|
93
|
+
'42501', null, 'authenticated cannot delete'
|
|
94
|
+
);
|
|
95
|
+
select throws_ok(
|
|
96
|
+
$$ update public.app_release_policy set min_version_ios = '0' where app_id = 'genesis-test-a' $$,
|
|
97
|
+
'42501', null, 'authenticated cannot update'
|
|
98
|
+
);
|
|
99
|
+
reset role;
|
|
100
|
+
|
|
101
|
+
-- Checks keep malformed values out (the client fails open on them anyway) -------------------
|
|
102
|
+
select throws_ok(
|
|
103
|
+
$$ insert into public.app_release_policy (app_id, min_version_ios, store_url_ios) values ('genesis-test-c', '1.4-beta', 'https://apps.apple.com/app/id1') $$,
|
|
104
|
+
'23514', null, 'a minimum version must be plain dotted numbers'
|
|
105
|
+
);
|
|
106
|
+
select throws_ok(
|
|
107
|
+
$$ insert into public.app_release_policy (app_id, min_version_android, store_url_android) values ('genesis-test-c', '1.2.3.4.5', 'market://details?id=x') $$,
|
|
108
|
+
'23514', null, '... of at most four parts'
|
|
109
|
+
);
|
|
110
|
+
select throws_ok(
|
|
111
|
+
$$ insert into public.app_release_policy (app_id, store_url_ios) values ('genesis-test-c', 'javascript:alert(1)') $$,
|
|
112
|
+
'23514', null, 'a store link must be https or the store''s scheme'
|
|
113
|
+
);
|
|
114
|
+
select throws_ok(
|
|
115
|
+
$$ insert into public.app_release_policy (app_id, notes) values ('genesis-test-c', '{"version": "1.0"}') $$,
|
|
116
|
+
'23514', null, 'notes must be an array'
|
|
117
|
+
);
|
|
118
|
+
select throws_ok(
|
|
119
|
+
$$ insert into public.app_release_policy (app_id, notes) values ('genesis-test-c', '[{"version": 1, "body": "x"}]') $$,
|
|
120
|
+
'23514', null, 'each note needs a string version and body'
|
|
121
|
+
);
|
|
122
|
+
select throws_ok(
|
|
123
|
+
$$ insert into public.app_release_policy (app_id, notes) values ('genesis-test-c', '[{"version": "1.0"}]') $$,
|
|
124
|
+
'23514', null, '... a note without a body is refused'
|
|
125
|
+
);
|
|
126
|
+
select throws_ok(
|
|
127
|
+
$$ insert into public.app_release_policy (app_id, notes) values ('genesis-test-c', '[{"version": ["1.0"], "body": ["x", "y"]}]') $$,
|
|
128
|
+
'23514', null, '... arrays where strings belong are refused'
|
|
129
|
+
);
|
|
130
|
+
select throws_ok(
|
|
131
|
+
$$ insert into public.app_release_policy (app_id, notes) values ('genesis-test-c', '[[{"version": "1.0", "body": "x"}]]') $$,
|
|
132
|
+
'23514', null, '... a nested array is refused'
|
|
133
|
+
);
|
|
134
|
+
select throws_ok(
|
|
135
|
+
$$ insert into public.app_release_policy (app_id, notes) values ('genesis-test-c', '[{"version": "1.0", "body": "x", "title": 3}]') $$,
|
|
136
|
+
'23514', null, '... a title must be a string'
|
|
137
|
+
);
|
|
138
|
+
select throws_ok(
|
|
139
|
+
$$ insert into public.app_release_policy (app_id, min_version_android) values ('genesis-test-c', '2.0') $$,
|
|
140
|
+
'23514', null, 'a minimum version without its store link is refused (no way out of the screen)'
|
|
141
|
+
);
|
|
142
|
+
select lives_ok(
|
|
143
|
+
$$ insert into public.app_release_policy (app_id) values ('genesis-test-d') $$,
|
|
144
|
+
'a row with nothing set is valid: no minimum, no maintenance, no notes'
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
select * from finish();
|
|
148
|
+
rollback;
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marktiderman/genesis-supabase",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
7
|
-
"description": "Supabase for Genesis apps on web and React Native: a client with PKCE and safe defaults, Keychain session storage that survives interrupted writes, refresh only while the app is in front, and a result helper. Part of the Genesis framework.",
|
|
7
|
+
"description": "Supabase for Genesis apps on web and React Native: a client with PKCE and safe defaults, Keychain session storage that survives interrupted writes, refresh only while the app is in front, native Sign in with Apple and OAuth redirects, in-app account deletion, and a result helper. Part of the Genesis framework.",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "git+https://github.com/marktiderman/genesis.git",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"files": [
|
|
33
33
|
"dist",
|
|
34
|
+
"migrations",
|
|
34
35
|
"README.md"
|
|
35
36
|
],
|
|
36
37
|
"peerDependencies": {
|