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