@usethrottle/auth 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +52 -0
- package/README.md +230 -0
- package/dist/chunk-NQU7HM23.js +378 -0
- package/dist/chunk-NQU7HM23.js.map +1 -0
- package/dist/chunk-YHIGTY64.js +49 -0
- package/dist/chunk-YHIGTY64.js.map +1 -0
- package/dist/chunk-ZMNCURDE.js +1 -0
- package/dist/chunk-ZMNCURDE.js.map +1 -0
- package/dist/index.cjs +408 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +245 -0
- package/dist/index.d.ts +245 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/react/forms/index.cjs +402 -0
- package/dist/react/forms/index.cjs.map +1 -0
- package/dist/react/forms/index.d.cts +77 -0
- package/dist/react/forms/index.d.ts +77 -0
- package/dist/react/forms/index.js +354 -0
- package/dist/react/forms/index.js.map +1 -0
- package/dist/react/index.cjs +581 -0
- package/dist/react/index.cjs.map +1 -0
- package/dist/react/index.d.cts +54 -0
- package/dist/react/index.d.ts +54 -0
- package/dist/react/index.js +145 -0
- package/dist/react/index.js.map +1 -0
- package/package.json +36 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @usethrottle/auth
|
|
2
|
+
|
|
3
|
+
Buyer authentication SDK: framework-free core (`src/client.ts`, `src/account.ts`,
|
|
4
|
+
`src/session.ts`, `src/storage.ts`, `src/request.ts`), a React provider + hooks
|
|
5
|
+
(`src/react/`), and five prebuilt forms (`src/react/forms/`). Three entry
|
|
6
|
+
points: `.` (core), `./react`, `./forms` — see `exports` in `package.json`.
|
|
7
|
+
|
|
8
|
+
## Invariants an agent must not break
|
|
9
|
+
|
|
10
|
+
- **The access token never goes to storage.** It lives only in `SessionState`
|
|
11
|
+
(`src/session.ts`), in memory. Only the refresh token is persisted, via the
|
|
12
|
+
`AuthStorage` adapter. Do not add a code path that writes `accessToken`
|
|
13
|
+
anywhere durable.
|
|
14
|
+
- **Refresh is single-flight and cross-tab.** `client.ts`'s `refresh()` shares
|
|
15
|
+
one in-flight promise per client instance so N concurrent 401s produce one
|
|
16
|
+
`/auth/refresh` call, and `withLock()` additionally serialises refreshes
|
|
17
|
+
across browser tabs via the Web Locks API (falls back to no-op where
|
|
18
|
+
unsupported). The refresh token **rotates on every use**, and the server
|
|
19
|
+
revokes the whole session family on replay of a spent one — changing this
|
|
20
|
+
logic without preserving single-flight risks logging every buyer out the
|
|
21
|
+
next time two requests race.
|
|
22
|
+
- **`useSavedCards` is named that way on purpose**, not `usePaymentMethods`.
|
|
23
|
+
`@usethrottle/payment-methods` exports a hook with that name, and merchants
|
|
24
|
+
commonly import both packages in one file. `src/exports.test.ts` asserts
|
|
25
|
+
`usePaymentMethods` is absent from this package's `./react` entry — do not
|
|
26
|
+
add it back, and do not rename `useSavedCards` without checking that test.
|
|
27
|
+
- **The server API lives in `packages/platform-storefront`.** This package is
|
|
28
|
+
a client for `/v1/storefront/*` and must never outrun it — do not add a
|
|
29
|
+
method here for a route that package doesn't implement yet, and do not
|
|
30
|
+
change a request/response shape here without confirming the server already
|
|
31
|
+
matches it. Docs for the wire contract:
|
|
32
|
+
`apps/web/src/pages/docs/developers/storefront-auth/index.astro`.
|
|
33
|
+
- **The claim cutoff is a server behavior, not a client one.** This package
|
|
34
|
+
does not filter anything client-side for it — an unverified buyer simply
|
|
35
|
+
gets empty lists and 403s back from the server. Do not "fix" apparently
|
|
36
|
+
empty `useOrders`/`useAddresses` results by adding client-side workarounds;
|
|
37
|
+
see the README's "Email verification and the claim cutoff" section.
|
|
38
|
+
|
|
39
|
+
## Conventions
|
|
40
|
+
|
|
41
|
+
- Every exported function's public surface is asserted in `src/exports.test.ts`
|
|
42
|
+
— a passing build with a broken barrel is the failure mode that test exists
|
|
43
|
+
to catch. Update it when you intentionally add/remove/rename a public export.
|
|
44
|
+
- React stays a peer dependency (`peerDependenciesMeta.react.optional: true`).
|
|
45
|
+
Do not add a runtime `import` of `react` to any file reachable from the `.`
|
|
46
|
+
(core) entry point.
|
|
47
|
+
- Test with `pnpm --filter @usethrottle/auth test`, not `pnpm vitest run
|
|
48
|
+
packages/auth-sdk/...` from the repo root — the latter resolves the root
|
|
49
|
+
vitest config, which lacks the jsdom environment this package's React tests
|
|
50
|
+
need. Tests are colocated (`src/*.test.ts(x)`); `vitest.config.ts`'s
|
|
51
|
+
`include` is scoped to `src/**` on purpose — a new test file outside `src/`
|
|
52
|
+
will not run.
|
package/README.md
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# @usethrottle/auth
|
|
2
|
+
|
|
3
|
+
Buyer authentication for Throttle storefronts — a framework-free core, React
|
|
4
|
+
hooks, and five drop-in forms. Wraps the `/v1/storefront/*` buyer-auth API
|
|
5
|
+
(silent refresh, single-flight and cross-tab safe, retry-once-on-401) so a
|
|
6
|
+
storefront never hand-rolls that logic.
|
|
7
|
+
|
|
8
|
+
This is a different plane from your merchant `sk_*`/`pk_*` API key. See the
|
|
9
|
+
[Storefront Customer Auth docs](https://usethrottle.dev/docs/developers/storefront-auth)
|
|
10
|
+
for the full HTTP contract this package wraps.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install @usethrottle/auth
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
React is a **peer dependency** and optional: the core client
|
|
19
|
+
(`createThrottleAuth`, `createAccount`) works with no React installed at all.
|
|
20
|
+
Only importing from `@usethrottle/auth/react` or `@usethrottle/auth/forms`
|
|
21
|
+
requires `react` + `react-dom` (`>=18`) to be present.
|
|
22
|
+
|
|
23
|
+
## Prerequisites
|
|
24
|
+
|
|
25
|
+
Before any of this works, in the Throttle dashboard:
|
|
26
|
+
|
|
27
|
+
1. Turn on customer accounts for the application (`auth.enabled: true`), per
|
|
28
|
+
environment.
|
|
29
|
+
2. Add the storefront's origin to the application's allowed-origins list (the
|
|
30
|
+
same list `PUT /api/v1/embed-config` manages).
|
|
31
|
+
3. Use a **publishable** (`pk_`) key — this package only ever talks to the
|
|
32
|
+
buyer plane, never the merchant one.
|
|
33
|
+
|
|
34
|
+
The two failures merchants hit first:
|
|
35
|
+
|
|
36
|
+
| Error | Cause |
|
|
37
|
+
| ----- | ----- |
|
|
38
|
+
| `404 auth_not_enabled` | Customer accounts are off for this application/environment. |
|
|
39
|
+
| `403 origin_not_allowed` | The storefront's `Origin` is not on the allow-list (a production environment with an empty list denies everything; sandbox allows everything). |
|
|
40
|
+
|
|
41
|
+
## React quick start
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
import { ThrottleAuthProvider, useAuth } from '@usethrottle/auth/react';
|
|
45
|
+
import { SignInForm } from '@usethrottle/auth/forms';
|
|
46
|
+
|
|
47
|
+
function App() {
|
|
48
|
+
return (
|
|
49
|
+
<ThrottleAuthProvider publishableKey="pk_...">
|
|
50
|
+
<Account />
|
|
51
|
+
</ThrottleAuthProvider>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function Account() {
|
|
56
|
+
const { isAuthenticated, isLoading, customer } = useAuth();
|
|
57
|
+
if (isLoading) return null;
|
|
58
|
+
if (!isAuthenticated) return <SignInForm />;
|
|
59
|
+
return <p>Signed in as {customer!.email}</p>;
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`<ThrottleAuthProvider>` goes once, near the root — it owns the auth client
|
|
64
|
+
and calls `restore()` on mount to pick up an existing session from storage.
|
|
65
|
+
|
|
66
|
+
## Headless quick start
|
|
67
|
+
|
|
68
|
+
No React required:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { createThrottleAuth, createAccount } from '@usethrottle/auth';
|
|
72
|
+
|
|
73
|
+
const auth = createThrottleAuth({ publishableKey: 'pk_...' });
|
|
74
|
+
const account = createAccount(auth);
|
|
75
|
+
|
|
76
|
+
await auth.signIn('buyer@example.com', 'hunter2000');
|
|
77
|
+
const profile = await account.getProfile();
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`auth.subscribe(fn)` + `auth.getSnapshot()` give you the same state a
|
|
81
|
+
framework adapter would use to re-render.
|
|
82
|
+
|
|
83
|
+
## Account pages
|
|
84
|
+
|
|
85
|
+
Six hooks, each a `{ data, isLoading, error, refetch }` resource plus the
|
|
86
|
+
mutations that resource supports:
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
import { useCustomer, useAddresses, useSavedCards, useOrders, useInvoices, useSubscriptions } from '@usethrottle/auth/react';
|
|
90
|
+
|
|
91
|
+
function Orders() {
|
|
92
|
+
const { data: orders, isLoading, error } = useOrders();
|
|
93
|
+
if (isLoading) return <p>Loading…</p>;
|
|
94
|
+
if (error) return <p>{error.message}</p>;
|
|
95
|
+
return <ul>{orders.map((o) => <li key={o.id}>{o.orderNumber}</li>)}</ul>;
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- `useCustomer()` — `update(patch)` calls `PATCH /me`.
|
|
100
|
+
- `useAddresses()` — `create`, `update`, `remove`.
|
|
101
|
+
- `useSavedCards()` — `setDefault`, `remove`. **Not** `usePaymentMethods`:
|
|
102
|
+
that name belongs to `@usethrottle/payment-methods`, which merchants often
|
|
103
|
+
import alongside this package in the same file.
|
|
104
|
+
- `useOrders(page?)`, `useInvoices(page?)` — read-only, cursor-paginated
|
|
105
|
+
(`{ cursor, limit }`).
|
|
106
|
+
- `useSubscriptions(page?)` — `cancel(id, { atPeriodEnd })`, `pause(id)`,
|
|
107
|
+
`resume(id)`.
|
|
108
|
+
|
|
109
|
+
## Email verification and the claim cutoff
|
|
110
|
+
|
|
111
|
+
**This is the single most surprising thing about this API — read it before
|
|
112
|
+
you file a bug.**
|
|
113
|
+
|
|
114
|
+
Registration issues a session immediately; a buyer never waits on an email
|
|
115
|
+
round trip to browse or check out. But until they click the verification
|
|
116
|
+
link, `emailVerified` is `false` on the session, and that has real
|
|
117
|
+
consequences:
|
|
118
|
+
|
|
119
|
+
- `PATCH /me` (`account.updateProfile` / `useCustomer().update`) returns
|
|
120
|
+
`403 verification_required`.
|
|
121
|
+
- Saved payment methods are refused outright (`useSavedCards`), not just
|
|
122
|
+
filtered.
|
|
123
|
+
- If the buyer's email already had **guest** orders, addresses, or a
|
|
124
|
+
subscription on this application, that pre-existing history stays hidden —
|
|
125
|
+
`useOrders`, `useInvoices`, `useSubscriptions`, and `useAddresses` all
|
|
126
|
+
return **empty** for anything created before registration — until
|
|
127
|
+
verification clears the cutoff.
|
|
128
|
+
|
|
129
|
+
None of this is a bug. It looks exactly like one (an account with real order
|
|
130
|
+
history rendering as empty), so show a persistent banner rather than treating
|
|
131
|
+
it as broken:
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
import { useAuth } from '@usethrottle/auth/react';
|
|
135
|
+
import { VerifyEmailPanel } from '@usethrottle/auth/forms';
|
|
136
|
+
|
|
137
|
+
function AccountShell({ children }: { children: React.ReactNode }) {
|
|
138
|
+
const { isAuthenticated, emailVerified } = useAuth();
|
|
139
|
+
return (
|
|
140
|
+
<>
|
|
141
|
+
{isAuthenticated && !emailVerified && <VerifyEmailPanel />}
|
|
142
|
+
{children}
|
|
143
|
+
</>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`<VerifyEmailPanel token={token} />` (token from the emailed link's query
|
|
149
|
+
string) verifies on mount; call it with no `token` prop to render a
|
|
150
|
+
"resend verification email" prompt instead.
|
|
151
|
+
|
|
152
|
+
## Step-up
|
|
153
|
+
|
|
154
|
+
A handful of destructive actions require the buyer to re-prove their current
|
|
155
|
+
password: changing the password itself, removing a saved card, and
|
|
156
|
+
cancelling/pausing/resuming a subscription. `ThrottleAuth` handles this for
|
|
157
|
+
you — on a `403 step_up_required`, it calls `onStepUpRequired()`, expects the
|
|
158
|
+
buyer's password back (or `null` to cancel), and retries the call once:
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
<ThrottleAuthProvider
|
|
162
|
+
publishableKey={pk}
|
|
163
|
+
onStepUpRequired={async () => window.prompt('Confirm your password to continue')}
|
|
164
|
+
>
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Prefer a real password dialog over `window.prompt` in production — this is
|
|
168
|
+
the minimum that satisfies the contract.
|
|
169
|
+
|
|
170
|
+
## Errors
|
|
171
|
+
|
|
172
|
+
Every rejected call throws a `ThrottleAuthError`:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
class ThrottleAuthError extends Error {
|
|
176
|
+
code: string;
|
|
177
|
+
statusCode: number;
|
|
178
|
+
retryAfter?: number;
|
|
179
|
+
details?: unknown;
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`messageForError(err)` (from `@usethrottle/auth/forms`) maps every code to
|
|
184
|
+
buyer-safe copy — use it instead of showing `err.message` directly, since two
|
|
185
|
+
codes (`invalid_credentials`, and anything unrecognized) are deliberately
|
|
186
|
+
vague by design.
|
|
187
|
+
|
|
188
|
+
| Code | Meaning |
|
|
189
|
+
| ---- | ------- |
|
|
190
|
+
| `auth_not_enabled` | Customer accounts are off for this application/environment. |
|
|
191
|
+
| `origin_not_allowed` | Storefront origin isn't on the allow-list. |
|
|
192
|
+
| `invalid_api_key` | The publishable key is invalid, revoked, or expired. |
|
|
193
|
+
| `invalid_credentials` | Sign-in or step-up failed — any cause, deliberately vague. |
|
|
194
|
+
| `weak_password` | Fails the password policy; `message` carries the specific reason. |
|
|
195
|
+
| `too_many_attempts` | Rate limited; `retryAfter` (seconds) is set when the server sent one. |
|
|
196
|
+
| `verification_required` | The route needs a verified session (see claim cutoff above). |
|
|
197
|
+
| `step_up_required` | The route needs a fresh step-up proof. |
|
|
198
|
+
| `session_revoked` | The session was revoked, or its epoch is stale. |
|
|
199
|
+
| `token_expired` / `token_invalid` / `token_already_used` | An email-verification or reset link is dead. |
|
|
200
|
+
| `environment_mismatch` | Session and API key belong to different environments (sandbox vs. production). |
|
|
201
|
+
| `auth_unavailable` | Credential routes (login/register/etc.) fail closed when the rate limiter is unreachable. |
|
|
202
|
+
| `cart_already_claimed` | `claimCart` targeted a cart already bound to a different customer. |
|
|
203
|
+
| `not_found` | The address/card/session/order/invoice/subscription id doesn't belong to this buyer. |
|
|
204
|
+
|
|
205
|
+
## Security notes
|
|
206
|
+
|
|
207
|
+
- The **access token lives in memory only** — this package never writes it to
|
|
208
|
+
storage, and there is no way to configure it to.
|
|
209
|
+
- The **refresh token** is the only thing persisted, through the storage
|
|
210
|
+
adapter you provide (`localStorageAdapter()` by default).
|
|
211
|
+
- Pass `storage: memoryStorage()` to `createThrottleAuth` /
|
|
212
|
+
`<ThrottleAuthProvider>` to opt out of persistence entirely — the buyer is
|
|
213
|
+
signed out on every page reload, useful for kiosk/shared-device contexts.
|
|
214
|
+
- Refresh is single-flight (concurrent 401s share one refresh call) and
|
|
215
|
+
cross-tab safe via the Web Locks API where available. Do not build your own
|
|
216
|
+
refresh loop around the same publishable key; use `auth.call()` /
|
|
217
|
+
`account.*` / the hooks, all of which already go through it.
|
|
218
|
+
|
|
219
|
+
## What this package does NOT do
|
|
220
|
+
|
|
221
|
+
- **It does not add a saved card.** There is no buyer-plane "add payment
|
|
222
|
+
method" route — cards are added through
|
|
223
|
+
[`@usethrottle/payment-methods`](https://usethrottle.dev/docs/developers/payment-methods),
|
|
224
|
+
which needs a merchant-minted client token. `useSavedCards()` here only
|
|
225
|
+
lists, sets a default, and removes an already-saved card.
|
|
226
|
+
- It does not let a buyer change which card a subscription renews on directly
|
|
227
|
+
— that's done by setting a different card as the account default
|
|
228
|
+
(`useSavedCards().setDefault(id)`).
|
|
229
|
+
- It does not change a buyer's email address (`PATCH /me` covers name, phone,
|
|
230
|
+
company, marketing consent — not email).
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var ThrottleAuthError = class extends Error {
|
|
3
|
+
code;
|
|
4
|
+
statusCode;
|
|
5
|
+
retryAfter;
|
|
6
|
+
details;
|
|
7
|
+
constructor(args) {
|
|
8
|
+
super(args.message);
|
|
9
|
+
this.name = "ThrottleAuthError";
|
|
10
|
+
this.code = args.code;
|
|
11
|
+
this.statusCode = args.statusCode;
|
|
12
|
+
this.retryAfter = args.retryAfter;
|
|
13
|
+
this.details = args.details;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// src/storage.ts
|
|
18
|
+
function memoryStorage() {
|
|
19
|
+
const map = /* @__PURE__ */ new Map();
|
|
20
|
+
return {
|
|
21
|
+
get: (k) => map.get(k) ?? null,
|
|
22
|
+
set: (k, v) => void map.set(k, v),
|
|
23
|
+
remove: (k) => void map.delete(k)
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function localStorageAdapter() {
|
|
27
|
+
const fallback = memoryStorage();
|
|
28
|
+
const ls = () => {
|
|
29
|
+
try {
|
|
30
|
+
return typeof localStorage !== "undefined" ? localStorage : null;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
get(k) {
|
|
37
|
+
try {
|
|
38
|
+
const v = ls()?.getItem(k);
|
|
39
|
+
if (v != null) return v;
|
|
40
|
+
} catch {
|
|
41
|
+
}
|
|
42
|
+
return fallback.get(k);
|
|
43
|
+
},
|
|
44
|
+
set(k, v) {
|
|
45
|
+
fallback.set(k, v);
|
|
46
|
+
try {
|
|
47
|
+
ls()?.setItem(k, v);
|
|
48
|
+
} catch {
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
remove(k) {
|
|
52
|
+
fallback.remove(k);
|
|
53
|
+
try {
|
|
54
|
+
ls()?.removeItem(k);
|
|
55
|
+
} catch {
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/request.ts
|
|
62
|
+
async function request(input) {
|
|
63
|
+
const headers = { "X-API-Key": input.publishableKey };
|
|
64
|
+
if (input.body !== void 0) headers["Content-Type"] = "application/json";
|
|
65
|
+
if (input.accessToken) headers.Authorization = `Bearer ${input.accessToken}`;
|
|
66
|
+
if (input.stepUpToken) headers["X-Step-Up"] = input.stepUpToken;
|
|
67
|
+
const res = await fetch(`${input.baseUrl}/v1/storefront${input.path}`, {
|
|
68
|
+
method: input.method,
|
|
69
|
+
headers,
|
|
70
|
+
...input.body !== void 0 ? { body: JSON.stringify(input.body) } : {}
|
|
71
|
+
});
|
|
72
|
+
const text = await res.text();
|
|
73
|
+
let parsed = {};
|
|
74
|
+
try {
|
|
75
|
+
parsed = text ? JSON.parse(text) : {};
|
|
76
|
+
} catch {
|
|
77
|
+
}
|
|
78
|
+
if (!res.ok) {
|
|
79
|
+
const e = parsed?.error ?? {};
|
|
80
|
+
const ra = Number(res.headers.get("retry-after"));
|
|
81
|
+
throw new ThrottleAuthError({
|
|
82
|
+
code: e.code ?? "request_failed",
|
|
83
|
+
message: e.message ?? `Request failed with ${res.status}`,
|
|
84
|
+
statusCode: res.status,
|
|
85
|
+
retryAfter: Number.isFinite(ra) && ra >= 0 ? ra : void 0,
|
|
86
|
+
details: e.details
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return parsed && typeof parsed === "object" && "data" in parsed ? parsed.data : parsed ?? {};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/session.ts
|
|
93
|
+
var SessionState = class {
|
|
94
|
+
#snapshot = { status: "loading", customer: null, emailVerified: false };
|
|
95
|
+
#accessToken = null;
|
|
96
|
+
#expiresAt = null;
|
|
97
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
98
|
+
get() {
|
|
99
|
+
return this.#snapshot;
|
|
100
|
+
}
|
|
101
|
+
accessToken() {
|
|
102
|
+
return this.#accessToken;
|
|
103
|
+
}
|
|
104
|
+
expiresAt() {
|
|
105
|
+
return this.#expiresAt;
|
|
106
|
+
}
|
|
107
|
+
setSession(session) {
|
|
108
|
+
this.#accessToken = session.accessToken;
|
|
109
|
+
this.#expiresAt = Date.parse(session.expiresAt);
|
|
110
|
+
this.#publish({
|
|
111
|
+
status: "authenticated",
|
|
112
|
+
customer: session.customer,
|
|
113
|
+
emailVerified: Boolean(session.customer?.emailVerified)
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
setStatus(status) {
|
|
117
|
+
this.#publish({ ...this.#snapshot, status });
|
|
118
|
+
}
|
|
119
|
+
clear() {
|
|
120
|
+
this.#accessToken = null;
|
|
121
|
+
this.#expiresAt = null;
|
|
122
|
+
this.#publish({ status: "unauthenticated", customer: null, emailVerified: false });
|
|
123
|
+
}
|
|
124
|
+
subscribe(fn) {
|
|
125
|
+
this.#listeners.add(fn);
|
|
126
|
+
return () => {
|
|
127
|
+
this.#listeners.delete(fn);
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
#publish(next) {
|
|
131
|
+
const same = next.status === this.#snapshot.status && next.emailVerified === this.#snapshot.emailVerified && customerEquals(next.customer, this.#snapshot.customer);
|
|
132
|
+
if (same) return;
|
|
133
|
+
this.#snapshot = next;
|
|
134
|
+
for (const fn of [...this.#listeners]) {
|
|
135
|
+
try {
|
|
136
|
+
fn(next);
|
|
137
|
+
} catch {
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
function customerEquals(a, b) {
|
|
143
|
+
if (a === b) return true;
|
|
144
|
+
if (!a || !b) return false;
|
|
145
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
146
|
+
for (const k of keys) if (a[k] !== b[k]) return false;
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/client.ts
|
|
151
|
+
var DEFAULT_BASE_URL = "https://api.usethrottle.dev";
|
|
152
|
+
var REFRESH_SKEW_MS = 3e4;
|
|
153
|
+
function createThrottleAuth(config) {
|
|
154
|
+
const baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
155
|
+
const storage = config.storage ?? localStorageAdapter();
|
|
156
|
+
const storageKey = `throttle_auth_rt:${config.publishableKey}`;
|
|
157
|
+
const lockName = `throttle_auth_refresh:${config.publishableKey}`;
|
|
158
|
+
const state = new SessionState();
|
|
159
|
+
let refreshInFlight = null;
|
|
160
|
+
let stepUpToken = null;
|
|
161
|
+
let stepUpExpiresAt = 0;
|
|
162
|
+
const base = { baseUrl, publishableKey: config.publishableKey };
|
|
163
|
+
async function persist(session) {
|
|
164
|
+
state.setSession(session);
|
|
165
|
+
await storage.set(storageKey, session.refreshToken);
|
|
166
|
+
}
|
|
167
|
+
async function forget() {
|
|
168
|
+
stepUpToken = null;
|
|
169
|
+
state.clear();
|
|
170
|
+
await storage.remove(storageKey);
|
|
171
|
+
}
|
|
172
|
+
function withLock(fn) {
|
|
173
|
+
const locks = globalThis.navigator?.locks;
|
|
174
|
+
return locks?.request ? locks.request(lockName, fn) : fn();
|
|
175
|
+
}
|
|
176
|
+
async function doRefresh() {
|
|
177
|
+
await withLock(async () => {
|
|
178
|
+
const refreshToken = await storage.get(storageKey);
|
|
179
|
+
if (!refreshToken) {
|
|
180
|
+
await forget();
|
|
181
|
+
throw new ThrottleAuthError({ code: "session_revoked", message: "No session", statusCode: 401 });
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const data = await request({ ...base, method: "POST", path: "/auth/refresh", body: { refreshToken } });
|
|
185
|
+
await persist(data);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
await forget();
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
function refresh() {
|
|
193
|
+
if (!refreshInFlight) {
|
|
194
|
+
refreshInFlight = doRefresh().finally(() => {
|
|
195
|
+
refreshInFlight = null;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return refreshInFlight;
|
|
199
|
+
}
|
|
200
|
+
async function accessToken() {
|
|
201
|
+
const expires = state.expiresAt();
|
|
202
|
+
if (state.accessToken() && expires && expires - Date.now() > REFRESH_SKEW_MS) {
|
|
203
|
+
return state.accessToken();
|
|
204
|
+
}
|
|
205
|
+
if (await storage.get(storageKey)) {
|
|
206
|
+
await refresh();
|
|
207
|
+
return state.accessToken();
|
|
208
|
+
}
|
|
209
|
+
return state.accessToken();
|
|
210
|
+
}
|
|
211
|
+
async function ensureStepUp() {
|
|
212
|
+
if (stepUpToken && stepUpExpiresAt > Date.now()) return true;
|
|
213
|
+
if (!config.onStepUpRequired) return false;
|
|
214
|
+
const password = await config.onStepUpRequired();
|
|
215
|
+
if (!password) return false;
|
|
216
|
+
await stepUp(password);
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
async function stepUp(password) {
|
|
220
|
+
const data = await request({
|
|
221
|
+
...base,
|
|
222
|
+
method: "POST",
|
|
223
|
+
path: "/auth/step-up",
|
|
224
|
+
accessToken: await accessToken() ?? void 0,
|
|
225
|
+
body: { password }
|
|
226
|
+
});
|
|
227
|
+
stepUpToken = data.stepUpToken;
|
|
228
|
+
stepUpExpiresAt = Date.now() + (Number(data.expiresIn) || 300) * 1e3 - 5e3;
|
|
229
|
+
}
|
|
230
|
+
async function call(method, path, body) {
|
|
231
|
+
let retriedAuth = false;
|
|
232
|
+
let retriedStepUp = false;
|
|
233
|
+
for (; ; ) {
|
|
234
|
+
const token = await accessToken();
|
|
235
|
+
try {
|
|
236
|
+
return await request({
|
|
237
|
+
...base,
|
|
238
|
+
method,
|
|
239
|
+
path,
|
|
240
|
+
body,
|
|
241
|
+
accessToken: token ?? void 0,
|
|
242
|
+
stepUpToken: stepUpToken ?? void 0
|
|
243
|
+
});
|
|
244
|
+
} catch (err) {
|
|
245
|
+
const e = err;
|
|
246
|
+
const expired = e.statusCode === 401 && (e.code === "unauthorized" || e.code === "token_expired" || e.code === "session_revoked");
|
|
247
|
+
if (expired && !retriedAuth) {
|
|
248
|
+
retriedAuth = true;
|
|
249
|
+
await refresh();
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (e.code === "step_up_required" && !retriedStepUp) {
|
|
253
|
+
retriedStepUp = true;
|
|
254
|
+
stepUpToken = null;
|
|
255
|
+
if (await ensureStepUp()) continue;
|
|
256
|
+
}
|
|
257
|
+
throw err;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
async register(email, password) {
|
|
263
|
+
const data = await request({ ...base, method: "POST", path: "/auth/register", body: { email, password } });
|
|
264
|
+
if (data?.status === "pending") return { status: "pending" };
|
|
265
|
+
await persist(data);
|
|
266
|
+
return { status: "active" };
|
|
267
|
+
},
|
|
268
|
+
async signIn(email, password) {
|
|
269
|
+
const data = await request({ ...base, method: "POST", path: "/auth/login", body: { email, password } });
|
|
270
|
+
await persist(data);
|
|
271
|
+
},
|
|
272
|
+
async signOut() {
|
|
273
|
+
try {
|
|
274
|
+
await call("POST", "/auth/logout");
|
|
275
|
+
} catch {
|
|
276
|
+
}
|
|
277
|
+
await forget();
|
|
278
|
+
},
|
|
279
|
+
async signOutEverywhere() {
|
|
280
|
+
try {
|
|
281
|
+
await call("POST", "/auth/logout-all");
|
|
282
|
+
} catch {
|
|
283
|
+
}
|
|
284
|
+
await forget();
|
|
285
|
+
},
|
|
286
|
+
async restore() {
|
|
287
|
+
if (!await storage.get(storageKey)) {
|
|
288
|
+
state.setStatus("unauthenticated");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
await refresh();
|
|
293
|
+
} catch {
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
getSnapshot: () => state.get(),
|
|
297
|
+
subscribe: (fn) => state.subscribe(fn),
|
|
298
|
+
stepUp,
|
|
299
|
+
call,
|
|
300
|
+
async verifyEmail(token) {
|
|
301
|
+
await request({ ...base, method: "POST", path: "/auth/verify-email", body: { token } });
|
|
302
|
+
if (await storage.get(storageKey)) {
|
|
303
|
+
try {
|
|
304
|
+
await refresh();
|
|
305
|
+
} catch {
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
async resendVerification() {
|
|
310
|
+
await call("POST", "/auth/verify-email/resend");
|
|
311
|
+
},
|
|
312
|
+
async forgotPassword(email) {
|
|
313
|
+
await request({ ...base, method: "POST", path: "/auth/forgot-password", body: { email } });
|
|
314
|
+
},
|
|
315
|
+
async resetPassword(token, password) {
|
|
316
|
+
await request({ ...base, method: "POST", path: "/auth/reset-password", body: { token, password } });
|
|
317
|
+
await forget();
|
|
318
|
+
},
|
|
319
|
+
async changePassword(password) {
|
|
320
|
+
await call("POST", "/me/change-password", { password });
|
|
321
|
+
await refresh();
|
|
322
|
+
},
|
|
323
|
+
async listSessions() {
|
|
324
|
+
return call("GET", "/me/sessions");
|
|
325
|
+
},
|
|
326
|
+
async revokeSession(id) {
|
|
327
|
+
const mine = await call("GET", "/me/sessions");
|
|
328
|
+
const current = mine.find((s) => s.current);
|
|
329
|
+
await call("DELETE", `/me/sessions/${encodeURIComponent(id)}`);
|
|
330
|
+
if (current?.id === id) await forget();
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// src/account.ts
|
|
336
|
+
var seg = (v) => encodeURIComponent(v);
|
|
337
|
+
function query(page) {
|
|
338
|
+
const params = new URLSearchParams();
|
|
339
|
+
if (page?.cursor) params.set("cursor", page.cursor);
|
|
340
|
+
if (page?.limit != null) params.set("limit", String(page.limit));
|
|
341
|
+
const s = params.toString();
|
|
342
|
+
return s ? `?${s}` : "";
|
|
343
|
+
}
|
|
344
|
+
function createAccount(auth) {
|
|
345
|
+
const { call } = auth;
|
|
346
|
+
return {
|
|
347
|
+
getProfile: () => call("GET", "/me"),
|
|
348
|
+
updateProfile: (patch) => call("PATCH", "/me", patch),
|
|
349
|
+
listAddresses: () => call("GET", "/me/addresses"),
|
|
350
|
+
createAddress: (input) => call("POST", "/me/addresses", input),
|
|
351
|
+
updateAddress: (id, patch) => call("PATCH", `/me/addresses/${seg(id)}`, patch),
|
|
352
|
+
deleteAddress: (id) => call("DELETE", `/me/addresses/${seg(id)}`),
|
|
353
|
+
listPaymentMethods: () => call("GET", "/me/payment-methods"),
|
|
354
|
+
// The platform has no "change this subscription's card" capability, so
|
|
355
|
+
// this is how a buyer changes what they are billed on: move the default.
|
|
356
|
+
setDefaultPaymentMethod: (id) => call("PATCH", `/me/payment-methods/${seg(id)}`, { isDefault: true }),
|
|
357
|
+
deletePaymentMethod: (id) => call("DELETE", `/me/payment-methods/${seg(id)}`),
|
|
358
|
+
listOrders: (page) => call("GET", `/me/orders${query(page)}`),
|
|
359
|
+
getOrder: (id) => call("GET", `/me/orders/${seg(id)}`),
|
|
360
|
+
listInvoices: (page) => call("GET", `/me/invoices${query(page)}`),
|
|
361
|
+
getInvoice: (id) => call("GET", `/me/invoices/${seg(id)}`),
|
|
362
|
+
listSubscriptions: (page) => call("GET", `/me/subscriptions${query(page)}`),
|
|
363
|
+
getSubscription: (id) => call("GET", `/me/subscriptions/${seg(id)}`),
|
|
364
|
+
cancelSubscription: (id, opts) => call("POST", `/me/subscriptions/${seg(id)}/cancel`, opts),
|
|
365
|
+
pauseSubscription: (id) => call("POST", `/me/subscriptions/${seg(id)}/pause`),
|
|
366
|
+
resumeSubscription: (id) => call("POST", `/me/subscriptions/${seg(id)}/resume`),
|
|
367
|
+
claimCart: (cartId) => call("POST", `/me/carts/${seg(cartId)}/claim`)
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export {
|
|
372
|
+
ThrottleAuthError,
|
|
373
|
+
memoryStorage,
|
|
374
|
+
localStorageAdapter,
|
|
375
|
+
createThrottleAuth,
|
|
376
|
+
createAccount
|
|
377
|
+
};
|
|
378
|
+
//# sourceMappingURL=chunk-NQU7HM23.js.map
|