@authowl/core 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,442 @@
1
+ type DecodedPublishableKey = {
2
+ prefix: 'pk_live' | 'pk_test';
3
+ env: 'live' | 'test';
4
+ projectId: string;
5
+ };
6
+ declare function decodePublishableKey(key: string): DecodedPublishableKey;
7
+
8
+ /**
9
+ * The exact session-cookie name the auth server sets for a given project.
10
+ *
11
+ * Single source of truth for the cookie name across the SDK. The server
12
+ * (the AuthOwl server project factory) configures
13
+ * `advanced.cookiePrefix = "p_" + <projectId without dashes>` and
14
+ * `useSecureCookies` in production; the auth engine then names the session
15
+ * cookie `${securePrefix}${cookiePrefix}.session_token`, where `securePrefix`
16
+ * is `__Secure-` for secure cookies and empty otherwise:
17
+ *
18
+ * dev (http): p_<idNoDashes>.session_token
19
+ * prod (https): __Secure-p_<idNoDashes>.session_token
20
+ *
21
+ * Verified 2026-07-06 against the auth engine's `getCookies()` with the server's
22
+ * real config (see CONTRACTS section 5 and `cookie.test.ts`). Note the engine
23
+ * joins the prefix and name with a dot (`.`), not an underscore, and uses the
24
+ * `__Secure-` (not `__Host-`) prefix - both are easy to get wrong by hand, and
25
+ * getting them wrong makes `auth()` forward a cookie the server never set. If an
26
+ * auth-engine upgrade changes cookie naming, re-verify and update this function,
27
+ * the `cookie.test.ts` fixtures, and CONTRACTS section 5 together.
28
+ *
29
+ * `secure` must reflect the SERVER's cookie mode: callers holding the auth API
30
+ * URL derive it from the protocol (`https:` => secure). Callers that cannot
31
+ * know it (the UX-only redirect middleware) should check both variants.
32
+ */
33
+ declare function sessionCookieName(projectId: string, opts?: {
34
+ secure?: boolean;
35
+ }): string;
36
+
37
+ /** How a session BEGINS, as the minting door knows it. */
38
+ type SessionStart = {
39
+ /**
40
+ * Whether the session is meant to outlive the tab.
41
+ *
42
+ * `dont_remember` is a SECOND cookie, and on the browsers this transport
43
+ * exists for it is dropped exactly like the session cookie is - so the engine
44
+ * never sees it and treats every bearer session as persistent. The token is
45
+ * the only copy of that session the SDK controls, so "don't remember me" has
46
+ * to be honoured by WHERE it is kept: `sessionStorage`, which dies with the
47
+ * tab, rather than `localStorage`, which does not.
48
+ *
49
+ * NOTE the behaviour change that buys: `sessionStorage` is per TAB, where
50
+ * `dont_remember` was per browser session. A "don't remember me" bearer
51
+ * session is therefore not shared with other tabs. That is deliberate and it
52
+ * is the safe direction (a session that ends too soon rather than one that
53
+ * outlives the machine's owner), but it is a real difference from the cookie
54
+ * transport and callers should not discover it by accident.
55
+ */
56
+ remember: boolean;
57
+ };
58
+ /**
59
+ * One session read, from the moment it is dispatched to the moment it answers.
60
+ *
61
+ * Cut by the store, so the rule about which answers may be acted on lives with
62
+ * the state it protects rather than being restated by every caller. The token
63
+ * the read is about is captured INSIDE it: `hasToken` explains why nothing hands
64
+ * the credential out, and a receipt that leaked it would be the same hole with a
65
+ * shorter lifetime.
66
+ */
67
+ type SessionRead = {
68
+ /**
69
+ * Whether the read this receipt was cut for actually presented a token.
70
+ *
71
+ * The cookie measurement turns on it: a read that carried NO token and still
72
+ * found a session IS the proof that our cross-site cookie survives here.
73
+ */
74
+ readonly carriedToken: boolean;
75
+ /**
76
+ * The read came back with no session. End the session it was reading - unless
77
+ * the token it presented is no longer the session in play.
78
+ *
79
+ * This is the path-independent catch for every ending that is not sign-out:
80
+ * expiry, a revoke from another device, `account.revokeSession` called with
81
+ * your own id, `account.delete`, an admin ban. None of those are visible in
82
+ * the response to the call that caused them, and a token we PRESENTED and got
83
+ * nothing back for is dead by definition.
84
+ */
85
+ endIfDead(): void;
86
+ /**
87
+ * Record what this read proved about the browser's cookies. `true` means the
88
+ * cookie came back and no token is needed here.
89
+ *
90
+ * On the receipt rather than on the store, because a detached read answers
91
+ * "there is no cookie session" for two completely different reasons, and only
92
+ * one of them is about the browser. A probe dispatched for one session and
93
+ * answered after that session ENDED - a sign-out, or a sign-out and a fresh
94
+ * sign-in, both of which fit inside one probe's round trip - reports the
95
+ * signed-out gap as a broken cookie. Acting on it would record `bearer` on a
96
+ * browser whose cookies are fine and then persist the NEXT session's token to
97
+ * disk under it, which is the exact defect this gate exists to remove, and it
98
+ * would stick until the sign-in after next.
99
+ *
100
+ * Two facts here, with different authorities, and reading them as one is what
101
+ * this comment used to license. RECORDING the verdict needs no token-value
102
+ * comparison of the kind `endIfDead` does: it is a statement about the
103
+ * BROWSER, every tab on this origin is the same browser, and a verdict
104
+ * wrongly skipped only costs another measurement. The WRITE it unlocks is the
105
+ * opposite - the token this receipt closed over is at least a probe round trip
106
+ * old, and across tabs arbitrarily old, so flushing it unguarded puts a
107
+ * superseded credential into the slot every tab shares. See `flushHeldToken`,
108
+ * which is where that write goes and why.
109
+ */
110
+ recordCookieVerdict(cookiesWork: boolean): void;
111
+ };
112
+ type SessionTokenStore = {
113
+ /**
114
+ * Whether a token is held. Deliberately not a getter for the token itself:
115
+ * `declareOn` is the only thing that puts it on the wire, so nothing else
116
+ * needs the credential in hand.
117
+ */
118
+ hasToken(): boolean;
119
+ /**
120
+ * Put this request on the transport - EVERY header, or none of them.
121
+ *
122
+ * The declaration, the bearer when one is held, and the challenge when one is
123
+ * held. Returns whether it declared, which is also the only condition under
124
+ * which the response can carry either cargo back.
125
+ *
126
+ * The challenge rides here rather than at whichever call site knows it is
127
+ * doing 2FA, and that is the point of the shape: `dont_remember` outlives the
128
+ * challenge it arrived with - `get-session` reads it for the life of the
129
+ * session to decide expiry refresh - so a store presented only when answering
130
+ * a code leaves a "don't remember me" session quietly resuming refresh. A
131
+ * request that is on this transport at all is on it for both cargoes.
132
+ */
133
+ declareOn(headers: Headers): boolean;
134
+ /**
135
+ * Whether this transport is still in play at all - for EITHER cargo, since one
136
+ * declaration governs both.
137
+ *
138
+ * False only once cookies are PROVEN to work here, after which `declareOn`
139
+ * changes nothing and no response can carry a token or a challenge back - so a
140
+ * caller may skip the decoration entirely rather than copy headers it will not
141
+ * touch. A browser that keeps our cookies keeps the ticket in its jar, where it
142
+ * is HttpOnly and strictly safer than anything a header can carry.
143
+ */
144
+ wantsToken(): boolean;
145
+ /** Whether this browser's cookie behaviour is still unmeasured. */
146
+ needsProbe(): boolean;
147
+ /**
148
+ * Capture a token the server handed back, and put it where it belongs.
149
+ *
150
+ * "Where it belongs" is MEMORY until the cookie has demonstrably failed. The
151
+ * verdict is not knowable at the moment of capture and the token is not needed
152
+ * on most browsers, so the safe order is to hold it, ask, and write only if
153
+ * the answer says this browser has no other way to keep a session. Capturing
154
+ * one is also what ASKS the question - see `measureWith` - because a gate on a
155
+ * measurement nobody runs is just a session that dies at the next reload.
156
+ */
157
+ observe(headers: Headers): void;
158
+ /**
159
+ * The server rejected the challenge this store presented: the ticket is spent,
160
+ * expired, or was never there, and the user has to start at the first factor
161
+ * again.
162
+ *
163
+ * Separate from `endSession` because no session ended - the challenge one was
164
+ * pending on did. Keeping the store instead would present a dead ticket on
165
+ * every later request, and keep a `dont_remember` that no longer describes
166
+ * anything, for as long as the tab lives.
167
+ */
168
+ dropChallenge(): void;
169
+ /**
170
+ * Register the one thing that can actually settle the verdict: a session read
171
+ * with the token deliberately detached. The store holds the question and every
172
+ * fact bearing on it, and none of the network.
173
+ *
174
+ * Called the moment a token is captured with the verdict still unmeasured, and
175
+ * at registration if that already happened. Registration is what makes this
176
+ * work at all: a controller registers when a CLIENT is built, so the
177
+ * measurement no longer waits on a host app subscribing to the session store -
178
+ * which is a thing plenty of integrations never do, and every one of them used
179
+ * to keep a durable token for it.
180
+ *
181
+ * At most one measurer. A second registration replaces the first rather than
182
+ * joining it, because StrictMode double-invokes the memo that builds a client
183
+ * and two probes answer one question.
184
+ */
185
+ measureWith(measure: () => void): void;
186
+ /**
187
+ * Cut a receipt for a session read that is about to go out.
188
+ *
189
+ * A session read is answered asynchronously, and the session can be replaced
190
+ * while one is in flight - by a sign-in in THIS tab (`observe` runs inside the
191
+ * fetch decorator, while the store's post-mutation refresh only fires once the
192
+ * action resolves), or by one in ANOTHER tab, which lands in shared storage
193
+ * that this store holds no copy of. A read that started before either and
194
+ * comes back "no session" then describes a session that no longer exists, and
195
+ * acting on it wipes the token that just replaced it: a silent sign-out
196
+ * immediately after a successful sign-in, in whichever tab reloads first.
197
+ *
198
+ * The caller states no rule of its own. It cuts the receipt before dispatch
199
+ * and hands the answer back; deciding whether that answer still describes the
200
+ * session in play needs both the token and the lifecycle, and this is the only
201
+ * thing that has them.
202
+ */
203
+ beginRead(): SessionRead;
204
+ /**
205
+ * A session BEGINS here, before the request that mints it goes out.
206
+ *
207
+ * Called pre-dispatch at every minting door, because both facts it sets have
208
+ * to be true by the time the RESPONSE arrives: the verdict decides whether the
209
+ * request declares the transport at all (and an un-re-armed "cookies work"
210
+ * means it does not, so no token is ever minted), and `remember` decides where
211
+ * the token that comes back is written.
212
+ *
213
+ * It deliberately does NOT drop the token already held. Several minting doors
214
+ * run ON an existing session - the 2FA verifies upgrade a pending one, phone
215
+ * and email OTP verification can run signed in - so dropping the credential
216
+ * before dispatch would send the very request that needs it out anonymous. A
217
+ * failed attempt (a mistyped password at a re-auth prompt, a wrong OTP) would
218
+ * likewise sign the user out locally on exactly the browsers this exists for.
219
+ * The old token is replaced when the new one ARRIVES, which is the only moment
220
+ * the old session is actually over.
221
+ */
222
+ beginSession(start: SessionStart): void;
223
+ /**
224
+ * A session ENDS here, unconditionally: this is sign-out, the one ending a
225
+ * response states plainly, and it means "end whatever is in play".
226
+ *
227
+ * Every other ending - expiry, a revoke from another device, the account
228
+ * deleted - is invisible in the response to the call that caused it and is
229
+ * caught instead by `SessionRead.endIfDead`, which has to establish that the
230
+ * ending is even about the session it is holding.
231
+ */
232
+ endSession(): void;
233
+ };
234
+
235
+ type TransportErrorKind = 'aborted' | 'timeout' | 'network' | 'response_too_large' | 'invalid_response';
236
+ /**
237
+ * Stable, secret-safe failure from the shared HTTP boundary.
238
+ *
239
+ * Deliberately does not retain the request URL, headers, body, or underlying
240
+ * error. Server clients may carry secret authorization headers and hostile
241
+ * fetch implementations may echo those values in their error messages.
242
+ */
243
+ declare class TransportError extends Error {
244
+ readonly kind: TransportErrorKind;
245
+ readonly requestId?: string;
246
+ constructor(kind: TransportErrorKind, requestId?: string);
247
+ }
248
+ declare const TRANSPORT_FETCH: unique symbol;
249
+ /**
250
+ * A `fetch` that has been through the SDK's transport wiring - the ONLY thing
251
+ * this boundary will execute.
252
+ *
253
+ * The brand is a phantom: it exists purely so that `fetchImpl: fetch` and
254
+ * `fetchImpl: config.fetch ?? fetch` stop compiling. That is not pedantry, it is
255
+ * the bug this file has already shipped. The session transport lives in a
256
+ * decorator around `fetch` (see `session-transport.ts`), and the SDK had TWO
257
+ * sibling lines - `http.ts` and `http-client.ts` - each independently writing
258
+ * `config.fetch ?? fetch`. One got the decorator and the other did not, so the
259
+ * JWT issuer and the consent gate stayed broken on exactly the browsers the
260
+ * work existed to fix, with nothing failing anywhere to say so.
261
+ *
262
+ * A brand cannot stop a deliberate cast, and is not meant to. What it stops is
263
+ * the ACCIDENT: reaching this boundary now forces the author to name where the
264
+ * fetch came from, and there are only two answers - `config.fetch`, which
265
+ * carries the session, or `withoutSessionTransport(...)`, which says in one
266
+ * greppable word that this request has no session to carry.
267
+ *
268
+ * The brand is why `requestBoundedJson` can stay what it says it is - network
269
+ * mechanics, stateless, no idea what a session is - while still being the place
270
+ * a missing transport is caught.
271
+ */
272
+ type TransportFetch = typeof fetch & {
273
+ readonly [TRANSPORT_FETCH]: true;
274
+ };
275
+
276
+ /**
277
+ * The session, attached to a request and harvested off a response, in ONE place.
278
+ *
279
+ * BOTH CARGOES, NO SECOND WIRE. The sign-in challenge - the `two_factor` ticket
280
+ * and the `dont_remember` flag - travels on this same transport, and it needed
281
+ * not one line here: `declareOn` and `observe` below each carry both, so a
282
+ * request that gets the session gets the challenge by construction. That is
283
+ * deliberate rather than incidental. Wiring the challenge at the call sites that
284
+ * know about 2FA is precisely the bug class this file exists to make
285
+ * unrepresentable - a door that gets one cargo and not the other - and the
286
+ * server's own ingress is built the same way for the same reason
287
+ * (`bearerTransportHeaders` does both translations in one function, and the
288
+ * headers it returns are obtainable nowhere else, so no door can opt into the
289
+ * session half and forget the challenge half). See `session-challenge.ts`.
290
+ *
291
+ * WHY THIS IS A `fetch` DECORATOR AND NOT A STEP IN A CLIENT
292
+ *
293
+ * Because this SDK used to reach the network through two independent doors and
294
+ * the first version of this transport wired one and missed the other. See
295
+ * `TransportFetch` in `transport.ts`, which owns that incident and the guard it
296
+ * produced.
297
+ *
298
+ * The doors have since been collapsed into one (`requestPublishableJson`), but
299
+ * the session does not ride THAT, because a door is a thing somebody can add.
300
+ * It rides the `fetch`, which no door can make a request without, and the brand
301
+ * on `TransportFetch` makes an undecorated one refuse to typecheck at the one
302
+ * boundary every request passes through. A deliberate opt-out still compiles and
303
+ * is meant to; what the brand removes is the SILENT version, where a client
304
+ * somebody added simply never carries the session and nothing anywhere says so.
305
+ *
306
+ * `resolveConfig` is the single producer: `config.fetch` is ALWAYS the decorated
307
+ * fetch, wrapped around the host's if one was supplied. There is no undecorated
308
+ * fetch left on a resolved config to reach for by mistake.
309
+ */
310
+
311
+ /**
312
+ * Everything about this project's session that is NOT the ordinary fetch.
313
+ *
314
+ * Handed to the session controller directly rather than looked up from a project
315
+ * id. The controller used to take that id and use it as BOTH the token store's
316
+ * key and a BroadcastChannel name, which is only correct while the two strings
317
+ * are the same string: pass a channel name and the controller silently measures
318
+ * a private, permanently empty store. Two facts, two parameters.
319
+ */
320
+ type SessionBinding = {
321
+ /**
322
+ * The same transport with the session deliberately DETACHED - no token, no
323
+ * challenge, no declaration, cookies only.
324
+ *
325
+ * Exactly one caller: the probe that measures whether this browser keeps our
326
+ * cross-site cookie. Every other request attaches the token when we hold one,
327
+ * which would make a session read succeed whether or not the cookie survived,
328
+ * and the SDK would never learn the difference.
329
+ */
330
+ readonly probe: TransportFetch;
331
+ /** The store the fetch above attaches from, and the lifecycle the doors drive. */
332
+ readonly tokens: SessionTokenStore;
333
+ };
334
+
335
+ type AuthConfig = {
336
+ publishableKey: string;
337
+ apiUrl: string;
338
+ /** Optional fetch override (e.g. for testing). */
339
+ fetch?: typeof fetch;
340
+ };
341
+ type ResolvedAuthConfig = Omit<AuthConfig, 'fetch'> & {
342
+ decoded: DecodedPublishableKey;
343
+ /** Fully-resolved base URL pointing at the per-project auth endpoint. */
344
+ projectBaseURL: string;
345
+ /**
346
+ * THE fetch for this project - the caller's, wrapped in the session transport.
347
+ *
348
+ * Required rather than optional, and branded, so that `config.fetch ?? fetch`
349
+ * cannot be written at all. See `TransportFetch` in `transport.ts` for what
350
+ * the brand buys and which bug it closes.
351
+ */
352
+ fetch: TransportFetch;
353
+ /**
354
+ * The session itself, resolved here because this function is the only producer
355
+ * of the fetch that carries it. Nothing else has to find the store by id, and
356
+ * no request boundary has to grow a "but not this one" flag to reach the
357
+ * detached fetch.
358
+ *
359
+ * A BROWSER SIGN-IN FLOW MUST GO THROUGH A CONSTRUCTED CLIENT, not through
360
+ * `config.fetch` directly. Resolving a config builds the token store, but the
361
+ * thing that SETTLES its cookie verdict is registered by the session
362
+ * controller a client builds - so a hand-rolled integration that drives sign-in
363
+ * off this fetch captures a token nothing will ever measure. The token is then
364
+ * held in memory and never written, and the session dies at the next reload on
365
+ * exactly the browsers this transport exists for. Every shipped surface
366
+ * (`createAuthOwlClient`, the native client, the React provider) builds one;
367
+ * this note is for anyone reaching below them. Moving the measurement onto
368
+ * this binding is the queued fix that removes the hazard rather than
369
+ * documenting it.
370
+ */
371
+ session: SessionBinding;
372
+ };
373
+ declare function resolveConfig(input: AuthConfig): ResolvedAuthConfig;
374
+
375
+ /**
376
+ * Pure, dependency-free evaluators for an organization membership's advisory
377
+ * permission claim. Shared by the CLIENT `has()` (organization-client.ts, over
378
+ * the browser session) and the SERVER `has()` (server.ts, over a verified JWT),
379
+ * so the two paths can never disagree on what a membership grants.
380
+ *
381
+ * The membership carries the SAME `permissions` array AuthOwl emits into the
382
+ * session and the JWT claim (plan §4/§5): the relabelled `org:sys_*` system ids
383
+ * (plus their legacy bare forms during the dual-emit window) AND the operator's
384
+ * custom `org:<feature>:<action>` ids. Evaluation is a pure array/string check
385
+ * over that local claim - it NEVER calls a statement-only `/organization/has-
386
+ * permission` route, which only knows the 14 static statements and would wrongly
387
+ * report `false` for any custom permission.
388
+ */
389
+ /** The active-membership shape carried on the session / decoded from a token. */
390
+ interface OrganizationMembership {
391
+ /** The member's canonical role key (built-in `owner`/`admin`/`member` or a project role). */
392
+ role: string;
393
+ /**
394
+ * The member's effective permission ids: `org:sys_*` system claims (with
395
+ * their legacy bare forms during dual-emit) plus custom `org:<feature>:<action>`
396
+ * ids. Advisory only - the real boundary is server-side over the verified token.
397
+ */
398
+ permissions: string[];
399
+ /**
400
+ * Team ids the member holds inside the ACTIVE organization, as emitted by
401
+ * AuthOwl into both the session and the JWT claim. Teams are pure grouping:
402
+ * belonging to one grants nothing on its own, so this is for the application's
403
+ * own gating, never an authority check.
404
+ *
405
+ * Optional because a token minted before teams shipped carries no `teams` claim.
406
+ * `has({ teamId })` then returns false rather than guessing - it can only ever
407
+ * confirm a team the claim actually proves.
408
+ */
409
+ teams?: string[];
410
+ }
411
+ /** Clerk-style `has()` query: match the role, the permission, the team, or a combination (AND). */
412
+ interface HasParams {
413
+ role?: string;
414
+ permission?: string;
415
+ /** Require membership of this team within the active organization. */
416
+ teamId?: string;
417
+ }
418
+ /** True when the membership's permission claim includes `permission`. Pure. */
419
+ declare function membershipHasPermission(membership: OrganizationMembership | null | undefined, permission: string): boolean;
420
+ /**
421
+ * True when the membership's team claim includes `teamId`. Pure.
422
+ *
423
+ * False when the claim carries no `teams` at all, which is what a token minted
424
+ * before teams shipped looks like - an absent claim is never read as "any team".
425
+ */
426
+ declare function membershipHasTeam(membership: OrganizationMembership | null | undefined, teamId: string): boolean;
427
+ /**
428
+ * Clerk-style `has()`: true when the membership satisfies EVERY provided
429
+ * criterion - the role matches AND the permission is included AND the team is
430
+ * held. Returns false when there is no membership, or when no criterion at all is
431
+ * given. Pure: no I/O, evaluated entirely against the local claim.
432
+ */
433
+ declare function membershipHas(membership: OrganizationMembership | null | undefined, params: HasParams): boolean;
434
+ /** Bind the pure evaluators to one membership (drives the client / hook `has`). */
435
+ declare function createMembershipHas(membership: OrganizationMembership | null | undefined): {
436
+ has: (params: HasParams) => boolean;
437
+ hasPermission: (params: {
438
+ permission: string;
439
+ }) => boolean;
440
+ };
441
+
442
+ export { type AuthConfig as A, type DecodedPublishableKey as D, type HasParams as H, type OrganizationMembership as O, type ResolvedAuthConfig as R, TransportError as T, type TransportErrorKind as a, membershipHasPermission as b, createMembershipHas as c, decodePublishableKey as d, membershipHasTeam as e, membershipHas as m, resolveConfig as r, sessionCookieName as s };