@ai-matrx/auth 0.2.0 → 0.2.2

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,594 @@
1
+ import { SupabaseClient, User, Session, AuthChangeEvent, Provider, UserAttributes } from '@supabase/supabase-js';
2
+
3
+ /**
4
+ * @ai-matrx/auth — sign-in and session doors (moved from @ai-matrx/data/auth, 2026-10-09).
5
+ *
6
+ * auth owns Supabase sign-in; @ai-matrx/data reads the same client and session for its doors. A package or app asks these doors instead of reaching into
7
+ * `client.auth.*`; the session leaves for `@ai-matrx/api` only as the `user` credential
8
+ * (`sessionCredentials(client)`, the shape api's `CredentialsPort` takes, and the same port
9
+ * `@ai-matrx/data`'s `createDb(...).credentials` returns), so api reads the token and never
10
+ * signs anyone in.
11
+ *
12
+ * const session = await getSession(supabase); // Session | null
13
+ * const person = await getPerson(supabase); // User | null (verified by the server)
14
+ * const stop = onSessionChange(supabase, (event, session) => …);
15
+ * await signInWithPassword(supabase, { email, password }); // throws SignInError
16
+ * const { url } = await signInWithOAuth(supabase, { provider: "google", redirectTo });
17
+ * await signOut(supabase);
18
+ * await updatePerson(supabase, { data: { full_name } });
19
+ *
20
+ * Every refusal throws `SignInError` carrying the auth server's own message, status and code.
21
+ */
22
+
23
+ /** Any supabase-js client (browser, server, route, extension) — only its `auth` is used here. */
24
+ type AuthClient = Pick<SupabaseClient<any, any, any>, "auth">;
25
+ /** A refusal from the auth server — its own message, plus status/code when it sent them. */
26
+ declare class SignInError extends Error {
27
+ readonly door: string;
28
+ readonly status?: number;
29
+ readonly code?: string;
30
+ constructor(door: string, message: string, status?: number, code?: string);
31
+ }
32
+ /** The stored session (no network unless a refresh is due), or null when signed out. */
33
+ declare function getSession(client: AuthClient): Promise<Session | null>;
34
+ /**
35
+ * Forget the cached person for this client, so the next {@link getPerson} asks the auth server.
36
+ * Sign-in, sign-out, a profile edit and an organization switch already call this; call it yourself
37
+ * only after changing the person outside this package.
38
+ */
39
+ declare function invalidatePerson(client: AuthClient): void;
40
+ /**
41
+ * The signed-in person, verified by the auth server, or null when signed out. The verified read is
42
+ * kept per client until the session changes (sign-in, sign-out, profile edit, organization switch),
43
+ * so repeated access checks cost zero extra auth-server calls; concurrent callers share one read.
44
+ */
45
+ declare function getPerson(client: AuthClient): Promise<User | null>;
46
+ /** Hear every session change (sign-in, refresh, sign-out). Returns the unsubscribe. */
47
+ declare function onSessionChange(client: AuthClient, cb: (event: AuthChangeEvent, session: Session | null) => void): () => void;
48
+ declare function signInWithPassword(client: AuthClient, credentials: {
49
+ email: string;
50
+ password: string;
51
+ } | {
52
+ phone: string;
53
+ password: string;
54
+ }): Promise<{
55
+ session: Session;
56
+ person: User;
57
+ }>;
58
+ declare function signInWithOAuth(client: AuthClient, options: {
59
+ provider: Provider;
60
+ redirectTo?: string;
61
+ scopes?: string;
62
+ queryParams?: Record<string, string>;
63
+ skipBrowserRedirect?: boolean;
64
+ }): Promise<{
65
+ provider: Provider;
66
+ url: string | null;
67
+ }>;
68
+ declare function signOut(client: AuthClient, scope?: "global" | "local" | "others"): Promise<void>;
69
+ /** Change the signed-in person's email, phone, password or metadata. */
70
+ declare function updatePerson(client: AuthClient, attributes: UserAttributes): Promise<User>;
71
+ /** The slice of a client the session credential reads: its `auth`, when it has one. */
72
+ interface SessionSourceLike {
73
+ auth?: {
74
+ getSession(): PromiseLike<{
75
+ data: {
76
+ session: {
77
+ access_token: string;
78
+ } | null;
79
+ };
80
+ }>;
81
+ onAuthStateChange(cb: (event: string, session: unknown) => void): {
82
+ data: {
83
+ subscription: {
84
+ unsubscribe(): void;
85
+ };
86
+ };
87
+ };
88
+ } | undefined;
89
+ }
90
+ /** The `user` credential port (structural; the shape `@ai-matrx/api`'s `CredentialsPort` takes). */
91
+ interface UserCredentialsPort {
92
+ get(): Promise<{
93
+ kind: "user";
94
+ accessToken: string;
95
+ } | null>;
96
+ onChange?(cb: () => void): () => void;
97
+ }
98
+ /**
99
+ * THE ONE session → credential mapping: the current access token as api's `user` credential,
100
+ * re-read on every call (a refresh rotates it), with `onChange` on every session change.
101
+ * A client with no `auth` is anonymous (null), never an error.
102
+ */
103
+ declare function sessionCredentials(client: SessionSourceLike): UserCredentialsPort;
104
+ /** The session as `@ai-matrx/api`'s `user` credential — the name `@ai-matrx/data/auth` shipped. */
105
+ declare const userCredentials: typeof sessionCredentials;
106
+ /**
107
+ * Refresh the session now (a long-lived script, a desktop app waking from sleep, a tab that was
108
+ * frozen). Returns the new session; throws `SignInError` when the refresh token is refused.
109
+ */
110
+ declare function refreshSession(client: AuthClient): Promise<Session>;
111
+
112
+ /**
113
+ * Organization-question plumbing that is LOGIC, not store binding (moved from the website's
114
+ * `orgBootstrapGate`, `organizationRefusal`, `childOrganization`, `adminOrganization`).
115
+ * Pure: every host input is injected; no Redux, no Next, no window.
116
+ */
117
+ /** How long a write may wait for the first answer before it is refused anyway. */
118
+ declare const ORGANIZATION_RESOLUTION_TIMEOUT_MS = 10000;
119
+ interface OrganizationBootstrapGate {
120
+ isResolved(): boolean;
121
+ /** The organization question has been answered (with an organization or without). Idempotent. */
122
+ markResolved(): void;
123
+ /** Wait for the answer or the timeout, whichever first. Always resolves; never rejects. */
124
+ whenResolved(timeoutMs?: number): Promise<void>;
125
+ /** Tests and in-place identity swaps only. */
126
+ reset(): void;
127
+ }
128
+ /** "Nobody has looked yet" is a race, not a fact: a held write waits on this, bounded. */
129
+ declare function createOrganizationBootstrapGate(defaultTimeoutMs?: number): OrganizationBootstrapGate;
130
+ /** True when an error body is the server's "you are not a member of the organization you carried". */
131
+ declare function isNotAMemberRefusal(body: unknown): boolean;
132
+ declare const NOT_A_MEMBER_RECHECK_GAP_MS = 30000;
133
+ interface NotAMemberRefusalNotice {
134
+ /** True when a recheck was started (at most one per gap, so a burst reads memberships once). */
135
+ notice(body: unknown, now?: number): boolean;
136
+ reset(): void;
137
+ }
138
+ declare function createNotAMemberRefusalNotice(recheck: () => void, gapMs?: number): NotAMemberRefusalNotice;
139
+ type ParentOrganization = string | {
140
+ organization_id?: string | null;
141
+ } | null | undefined;
142
+ /**
143
+ * A record created as part of an existing parent lives in the PARENT's organization; the explicit
144
+ * one applies only when the parent names none. A disagreement is announced (`onDisagree`,
145
+ * default console.warn), never obeyed.
146
+ */
147
+ declare function resolveChildOrganizationId(parent: ParentOrganization, explicit?: string | null, label?: string, onDisagree?: (message: string) => void): string | null;
148
+ /** What the host knows about the admin lane. */
149
+ interface AdminSeatInput {
150
+ readonly laneOpen: boolean;
151
+ readonly organizationId?: string | null;
152
+ readonly organizationName?: string | null;
153
+ }
154
+ interface AdminSeat {
155
+ readonly id: string;
156
+ readonly name: string | null;
157
+ }
158
+ /** The admin organization in force right now, or null (off the lane, or none set). */
159
+ declare function resolveAdminSeat(input: AdminSeatInput | null | undefined): AdminSeat | null;
160
+ /** True when the admin seat differs from the person's own active organization (scope/project/task never ride across). */
161
+ declare function adminSeatReplacesUserContext(input: AdminSeatInput | null | undefined, personOrganizationId: string | null | undefined): boolean;
162
+
163
+ /**
164
+ * @ai-matrx/auth/organizations (moved from @ai-matrx/data/organizations, 2026-10-09) — THE active-organization ladder and the account's organization
165
+ * facts, for every Matrx client (the web, Matrx 2, the extension, Studio).
166
+ *
167
+ * 🚨 ONE ACTIVE ORGANIZATION, SET ONCE WHEN THE APP LOADS, NEVER NONE (Arman, 2026-10-07;
168
+ * organizations STATE rules 10–15). The ladder picks what the UI OPENS to; from then on every
169
+ * request carries the one active organization the person can see. Reads never filter by it.
170
+ *
171
+ * THE RUNGS, each kept only if it is a CURRENT membership:
172
+ * 1. HELD — what this tab (web) or this running app (desktop, in memory) already works in;
173
+ * 2. LAST ACTIVE — `users.user_preferences.last_active_organization_id` (follows the person
174
+ * across devices: STATE rule 12);
175
+ * 3. START-UP — `users.user_preferences.startup_organization_id` (the "Start-up organization"
176
+ * setting);
177
+ * 4. FIRST — the oldest active membership.
178
+ * Zero memberships → null. A failed read THROWS (the host shows "could not check" and a retry,
179
+ * never a guess). The ladder's own answer never writes anything (rule 14): only a person's click
180
+ * writes last active.
181
+ *
182
+ * Hosts with extra rungs keep them (the web's `?org=` link rung) and call the pure core here.
183
+ */
184
+
185
+ /** One organization the person is an active member of, as every picker draws it. */
186
+ interface OrganizationMembership {
187
+ readonly id: string;
188
+ readonly name: string;
189
+ readonly slug: string | null;
190
+ /** The database-validated 2–3 letters, when the organization has them. */
191
+ readonly abbreviation: string | null;
192
+ readonly logoUrl: string | null;
193
+ /** owner | admin | member (the membership's role). */
194
+ readonly role: string;
195
+ /** The person created it (drawn under the favourites, before the rest). */
196
+ readonly isOwn: boolean;
197
+ /** Classified in `iam.organizations.settings.test_fixture` — never guessed from a name. */
198
+ readonly isTestFixture: boolean;
199
+ /** When the membership began (ISO): the oldest is rung 4. */
200
+ readonly joinedAt: string;
201
+ /** When the organization was archived (ISO), or null. Present on every row `readMemberships` returns. */
202
+ readonly archivedAt?: string | null;
203
+ }
204
+ interface AccountOrganizationChoices {
205
+ readonly lastActiveOrganizationId: string | null;
206
+ readonly startupOrganizationId: string | null;
207
+ }
208
+ type OrganizationRung = "held" | "last-active" | "startup" | "first";
209
+ interface ResolvedOrganization<T extends {
210
+ id: string;
211
+ }> {
212
+ readonly organization: T;
213
+ /** Which rung answered (for logs and tests; never shown as a choice). */
214
+ readonly rung: OrganizationRung;
215
+ }
216
+ interface LadderInput<T extends {
217
+ id: string;
218
+ }> {
219
+ readonly memberships: readonly T[];
220
+ readonly held?: string | null;
221
+ readonly account?: AccountOrganizationChoices | null;
222
+ /** Rung 4: the oldest active membership. Omitted: the oldest by `joinedAt`, else the first given. */
223
+ readonly first?: T | null;
224
+ }
225
+ /**
226
+ * THE LADDER. Pure: no read, no write. Null only when there are no memberships.
227
+ */
228
+ declare function resolveActiveOrganization<T extends {
229
+ id: string;
230
+ joinedAt?: string;
231
+ }>(input: LadderInput<T>): ResolvedOrganization<T> | null;
232
+ /**
233
+ * The ladder with its reads deferred: the account's choices are read only when no held
234
+ * organization answers, and the first membership only when nothing else does. A failed read
235
+ * throws through.
236
+ */
237
+ declare function resolveActiveOrganizationLazily<T extends {
238
+ id: string;
239
+ joinedAt?: string;
240
+ }>(input: {
241
+ readonly memberships: readonly T[];
242
+ readonly held?: string | null;
243
+ readonly readAccount: () => Promise<AccountOrganizationChoices>;
244
+ readonly first?: () => Promise<T | null>;
245
+ }): Promise<ResolvedOrganization<T> | null>;
246
+ /** The oldest membership by `joinedAt` (ties and missing dates keep the given order). */
247
+ declare function oldestMembership<T extends {
248
+ joinedAt?: string;
249
+ }>(memberships: readonly T[]): T | null;
250
+ /**
251
+ * THE ORDER every organization list uses (the shared OrganizationPicker's): favourites, then the
252
+ * person's own organizations, then the rest; inside each band the given order is kept.
253
+ */
254
+ declare function orderOrganizations<T extends {
255
+ id: string;
256
+ isOwn?: boolean;
257
+ }>(organizations: readonly T[], favouriteIds: readonly string[]): T[];
258
+ /** Search over the two things a person types: the name and the abbreviation. */
259
+ declare function organizationMatches(org: {
260
+ name: string;
261
+ abbreviation?: string | null;
262
+ }, query: string): boolean;
263
+ /** Any supabase-js client (typed or not). */
264
+ type OrganizationsDb = SupabaseClient<any, any, any>;
265
+ /** Which organizations a membership read keeps: the live ones, only the archived ones, or both. */
266
+ type ArchiveFilter = "active" | "archived" | "all";
267
+ interface ReadMembershipsOptions {
268
+ /** Default `"active"` (archived organizations left out): the ladder and every picker read this. */
269
+ readonly archived?: ArchiveFilter | undefined;
270
+ }
271
+ /**
272
+ * Every organization the signed-in person is an ACTIVE member of, through the canonical
273
+ * membership door (`mbr_for_user`, paged) and the organizations' own rows. By default archived
274
+ * organizations are left out; `{ archived: "archived" | "all" }` returns the archived ones (view
275
+ * only: they never answer the ladder) too. Throws when either read fails; [] when there are
276
+ * none. Ordered by name.
277
+ */
278
+ declare function readMemberships(db: OrganizationsDb, userId: string, options?: ReadMembershipsOptions): Promise<OrganizationMembership[]>;
279
+ interface ChoicesRow {
280
+ last_active_organization_id: string | null;
281
+ startup_organization_id: string | null;
282
+ }
283
+ /** The two columns as the ladder's input. A person with no preferences row has neither. */
284
+ declare function toAccountOrganizationChoices(row: Partial<ChoicesRow> | null | undefined): AccountOrganizationChoices;
285
+ /**
286
+ * The account's last active and start-up organizations. 🚨 ONLY THE LADDER READS THESE: they
287
+ * choose what the shell opens to and nothing else. Throws when the read fails.
288
+ */
289
+ declare function readAccountOrganizationChoices(db: OrganizationsDb, userId: string): Promise<AccountOrganizationChoices>;
290
+ /** Save where the person now works, for their next load on any device. Only a click calls this. Throws on refusal. */
291
+ declare function writeLastActiveOrganization(db: OrganizationsDb, organizationId: string): Promise<void>;
292
+ /** Set (or clear, with null) the Start-up organization setting. Throws on refusal. */
293
+ declare function writeStartupOrganization(db: OrganizationsDb, organizationId: string | null): Promise<void>;
294
+ /** The favourites store's entity type (`platform.user_entity_state`). */
295
+ declare const ORGANIZATION_ENTITY_TYPE = "organization";
296
+ /**
297
+ * The person's favourite organizations: the platform's per-person flag store
298
+ * (`public.ues_list`, kind favorite), the same store agent and model favourites use. Throws on
299
+ * failure.
300
+ */
301
+ declare function readOrganizationFavourites(db: OrganizationsDb): Promise<string[]>;
302
+ /** Star (true) or unstar (false) one organization. `ues_set` refuses one the person cannot open. Throws on refusal. */
303
+ declare function setOrganizationFavourite(db: OrganizationsDb, organizationId: string, favourite: boolean): Promise<void>;
304
+ /**
305
+ * What the AI Matrx server's refusal of a request's organization means for the ladder
306
+ * (`matrx_connect` AuthMiddleware._organization_admission_words via `organization_free.py`):
307
+ * - 400 with `detail.organization_admission` "denied" (not a member), "unknown" (no such
308
+ * organization) or "archived" → the organization is gone for this person: re-run the ladder;
309
+ * - 503 with "unverifiable" (the lookup failed; Retry-After 5) → keep it and retry;
310
+ * - anything else → not an organization refusal.
311
+ */
312
+ type OrganizationRefusal = "lapsed" | "retry" | null;
313
+ declare function classifyOrganizationRefusal(status: number, body: unknown): OrganizationRefusal;
314
+
315
+ /**
316
+ * The query key, platform-wide. The server stamps this on every deep link it
317
+ * emits; `features/admin/users/components/OrganizationsAdminClient.tsx` has
318
+ * read it since before this module existed. One spelling, in one place.
319
+ */
320
+ declare const LINK_ORGANIZATION_QUERY_KEY = "org";
321
+ type LinkOrganizationParam =
322
+ /** No link said anything about an organization. The ladder is untouched. */
323
+ {
324
+ kind: "absent";
325
+ }
326
+ /** Something was said and it is neither a uuid nor an address. Refused in words, never used. */
327
+ | {
328
+ kind: "malformed";
329
+ raw: string;
330
+ }
331
+ /** A well-formed organization id. Still has to survive the membership check. */
332
+ | {
333
+ kind: "named";
334
+ organizationId: string;
335
+ }
336
+ /** An organization's address (slug). Survives only by matching a membership's slug. */
337
+ | {
338
+ kind: "addressed";
339
+ slug: string;
340
+ };
341
+ /**
342
+ * Classify one bare `org=` value. The ONE rule for what a value may be, shared
343
+ * by the URL reader below and the boot resolver's bare-value path.
344
+ */
345
+ declare function classifyLinkOrganizationValue(raw: string): LinkOrganizationParam;
346
+ /**
347
+ * Read the key out of a query string, a `URLSearchParams`, or a full URL.
348
+ * Never throws: a URL this cannot parse is `absent`, because a crash on
349
+ * arrival is a worse answer than the ladder's own.
350
+ */
351
+ declare function readLinkOrganizationParam(source: string | URLSearchParams | null | undefined): LinkOrganizationParam;
352
+ interface LinkOrganizationMembership {
353
+ id: string;
354
+ name: string;
355
+ /** The organization's address, so an `addressed` link can be matched. */
356
+ slug?: string | null;
357
+ }
358
+ interface LinkOrganizationDecisionInput {
359
+ param: LinkOrganizationParam;
360
+ /** The person's LIVE memberships. The only authority on what they may open. */
361
+ memberships: ReadonlyArray<LinkOrganizationMembership>;
362
+ /** The organization they are working in right now, or null. */
363
+ currentOrganizationId: string | null;
364
+ currentOrganizationName?: string | null;
365
+ /**
366
+ * The knob `userPreferences.organization.switchOnLinkPrompt` — "switch
367
+ * organization when a link asks". DEFAULT ON, so an omitted value is `true`.
368
+ */
369
+ switchWhenALinkAsks?: boolean;
370
+ /** The account they are signed in as, for the refusal sentence. */
371
+ signedInAs?: string | null;
372
+ /**
373
+ * The link's organization is one this person is not a member of but IS let into
374
+ * by a share they accepted (`lib/organizations/linkOrganizationAdmission.ts`).
375
+ * A share admits and never makes a member, so the answer is `admitted`: nobody
376
+ * is moved, nothing is refused, and the page opens it as that organization's
377
+ * guest. Omitted, the answer is exactly what it was before (VERIFIER-15 H4).
378
+ */
379
+ admittedByAShare?: boolean;
380
+ /** Replace any of the default English sentences (a localising host). */
381
+ messages?: Partial<LinkOrganizationMessages>;
382
+ }
383
+ interface LinkOrganizationMessages {
384
+ notAMember(signedInAs?: string | null): string;
385
+ malformed(raw: string): string;
386
+ switched(toName: string, fromName: string | null): string;
387
+ offer(toName: string, fromName: string | null): string;
388
+ offerAction(toName: string): string;
389
+ }
390
+ type LinkOrganizationDecision =
391
+ /** Nothing to do. No toast, no change. */
392
+ {
393
+ kind: "no-link";
394
+ }
395
+ /** The link names the organization they are already in. Silent no-op. */
396
+ | {
397
+ kind: "already-current";
398
+ organizationId: string;
399
+ }
400
+ /** Use this organization for this session. `announcement` non-null = they moved. */
401
+ | {
402
+ kind: "honoured";
403
+ organizationId: string;
404
+ organizationName: string;
405
+ announcement: string | null;
406
+ }
407
+ /** The knob is off and this would be a real switch. Offer it as a click. */
408
+ | {
409
+ kind: "offered";
410
+ organizationId: string;
411
+ organizationName: string;
412
+ message: string;
413
+ actionLabel: string;
414
+ }
415
+ /**
416
+ * Not a member, but let in by a share they accepted. Silent: nobody is moved
417
+ * (a share never makes a member) and nothing was refused (the page opens it).
418
+ */
419
+ | {
420
+ kind: "admitted";
421
+ organizationId: string;
422
+ }
423
+ /** Malformed, or not theirs. Changes nothing; says so. */
424
+ | {
425
+ kind: "refused";
426
+ reason: "malformed" | "not-a-member";
427
+ message: string;
428
+ };
429
+ /** What a person reads when the link's organization is not theirs to open. */
430
+ declare function linkOrganizationNotAMemberMessage(signedInAs?: string | null): string;
431
+ /** What a person reads when `org=` is not an organization id at all. */
432
+ declare function linkOrganizationMalformedMessage(raw: string): string;
433
+ /** What a person reads when a link actually moved them. Law 4: never silent. */
434
+ declare function linkOrganizationSwitchAnnouncement(toName: string, fromName: string | null): string;
435
+ /** What a person reads with the knob turned off and a link asking to move them. */
436
+ declare function linkOrganizationOfferMessage(toName: string, fromName: string | null): string;
437
+ /**
438
+ * The decision. One function, every branch, no side effects.
439
+ *
440
+ * 🚨 WHY THE KNOB GOVERNS ONLY A REAL SWITCH. The knob protects the
441
+ * organization a person is WORKING IN from being yanked out from under them by
442
+ * a link someone else composed. With `currentOrganizationId === null` there is
443
+ * nothing to protect and nothing to switch away from: the alternative to
444
+ * honouring the link is the picker, which is the very dead end this module was
445
+ * built to end. So the knob turns a SWITCH into an offer; it never turns an
446
+ * arrival into a refusal. This is deliberate and it is not a hidden default —
447
+ * with no link present, a null selection stays null exactly as before.
448
+ */
449
+ declare function decideLinkOrganization(input: LinkOrganizationDecisionInput): LinkOrganizationDecision;
450
+ interface ResolveWithLinkInput<T extends {
451
+ id: string;
452
+ name: string;
453
+ slug?: string | null;
454
+ joinedAt?: string;
455
+ }> {
456
+ readonly memberships: readonly T[];
457
+ /** This tab's held organization (rung 1; a refresh keeps it). */
458
+ readonly held?: string | null;
459
+ readonly readAccount: () => Promise<AccountOrganizationChoices>;
460
+ readonly first?: () => Promise<T | null>;
461
+ /** The raw `?org=` value, a query string, a URL or URLSearchParams. Omitted = no link. */
462
+ readonly link?: string | URLSearchParams | null;
463
+ readonly switchWhenALinkAsks?: boolean | undefined;
464
+ readonly signedInAs?: string | null | undefined;
465
+ readonly messages?: Partial<LinkOrganizationMessages> | undefined;
466
+ /** Asked only when the link already failed the membership check (a share admits, never joins). */
467
+ readonly admittedByAShare?: ((organizationId: string) => Promise<boolean>) | undefined;
468
+ }
469
+ interface ResolvedWithLink<T> {
470
+ readonly organization: T;
471
+ readonly rung: OrganizationRung | "link";
472
+ /** What the link meant, when it said anything — the host SAYS it (refused / announced / offered). */
473
+ readonly link: LinkOrganizationDecision;
474
+ }
475
+ /**
476
+ * The ONE load ladder with the link folded in: held → last active → start-up → first, then the
477
+ * link's own organization wins ONLY when it is a current membership. Every other link outcome
478
+ * (refused, offered, already-current, admitted) leaves the ladder's answer standing. Null when
479
+ * the person has no memberships. A failed read throws through.
480
+ */
481
+ declare function resolveOrganizationWithLink<T extends {
482
+ id: string;
483
+ name: string;
484
+ slug?: string | null;
485
+ joinedAt?: string;
486
+ }>(input: ResolveWithLinkInput<T>): Promise<ResolvedWithLink<T> | null>;
487
+
488
+ /**
489
+ * THE ACTIVE ORGANIZATION — one per tab / running app, set once when the app loads, never none
490
+ * while the person has a membership (Arman, 2026-10-07; organizations STATE rules 10–15).
491
+ *
492
+ * const active = createActiveOrganization({ client: supabase });
493
+ * await active.load(); // the ladder: held → last active → start-up → first
494
+ * const orgId = await active.ensureOrgId(); // HELD until the ladder answers; never a guess
495
+ * await active.switchTo(otherId); // a person's click: saves last active for next load
496
+ *
497
+ * HELD-REQUEST SEMANTICS. A write or server call that needs the organization asks
498
+ * `ensureOrgId()`: while the ladder is still resolving the call WAITS (it is held, never sent
499
+ * without an organization and never sent with a guessed one); once resolved it gets the one
500
+ * active organization; with zero memberships or a failed read it is REFUSED with a named error.
501
+ * Acting on a record passes that record's own organization (`ensureOrgId(record.organization_id)`),
502
+ * which wins and never switches the active one.
503
+ *
504
+ * 🚨 NEVER A LIST FILTER. Reads show everything the person can see across all their
505
+ * organizations; nothing here narrows a read. The active organization is only where new things
506
+ * are saved and which organization a server call runs in.
507
+ *
508
+ * The held id is identity-keyed: a different person signing in on the same device never inherits
509
+ * the last person's organization, and a sign-out resets the store.
510
+ */
511
+
512
+ /** Where this tab / app remembers its organization between reloads (identity-keyed). */
513
+ interface HeldOrganizationStore {
514
+ read(userId: string): string | null | Promise<string | null>;
515
+ write(userId: string, organizationId: string): void | Promise<void>;
516
+ clear(): void | Promise<void>;
517
+ }
518
+ type ActiveOrganizationStatus = "idle" | "resolving" | "ready" | "none" | "error";
519
+ interface ActiveOrganizationSnapshot {
520
+ readonly status: ActiveOrganizationStatus;
521
+ readonly userId: string | null;
522
+ readonly organizationId: string | null;
523
+ readonly organization: OrganizationMembership | null;
524
+ readonly memberships: readonly OrganizationMembership[];
525
+ /** Which ladder rung answered (or "switched" after a person's click). */
526
+ readonly rung: OrganizationRung | "switched" | "link" | null;
527
+ readonly error: string | null;
528
+ }
529
+ declare class NoActiveOrganizationError extends Error {
530
+ readonly reason: "signed-out" | "no-memberships" | "unreadable";
531
+ constructor(reason: "signed-out" | "no-memberships" | "unreadable", detail?: string);
532
+ }
533
+ declare class NotAMemberError extends Error {
534
+ readonly organizationId: string;
535
+ constructor(organizationId: string);
536
+ }
537
+ interface ActiveOrganization {
538
+ snapshot(): ActiveOrganizationSnapshot;
539
+ subscribe(listener: () => void): () => void;
540
+ /** Run the ladder (again). Resolves with the settled snapshot; never throws. */
541
+ load(): Promise<ActiveOrganizationSnapshot>;
542
+ /** The active organization id now, or null while resolving / with none. Sync (api's getter). */
543
+ current(): string | null;
544
+ /** THE HELD REQUEST: the record's own organization, else the active one once resolved. */
545
+ ensureOrgId(recordOrganizationId?: string | null): Promise<string>;
546
+ /** A person's click: switch, remember for this tab, save as last active for the next load. */
547
+ switchTo(organizationId: string): Promise<void>;
548
+ /** Feed a server refusal in: a lapsed organization re-runs the ladder. Returns the class. */
549
+ handleRefusal(status: number, body: unknown): Promise<"lapsed" | "retry" | null>;
550
+ dispose(): void;
551
+ }
552
+ interface CreateActiveOrganizationOptions {
553
+ /** The person's Supabase client (any supabase-js client). */
554
+ readonly client: OrganizationsDb & AuthClient;
555
+ /** Where this tab/app holds its choice. Default: browser `sessionStorage` (per tab), else memory. */
556
+ readonly held?: HeldOrganizationStore | undefined;
557
+ /** An extra first rung (the web's `?org=` link). Kept only if it is a current membership. */
558
+ readonly linked?: (() => string | null | undefined) | undefined;
559
+ /**
560
+ * The link's own organization (`?org=` uuid or address) as a first-class rung: honoured only when
561
+ * it is a current membership, otherwise the ladder stands and `onDecision` receives the refusal /
562
+ * offer / announcement for the host to SAY. Supersedes `linked` when both are given.
563
+ */
564
+ readonly link?: {
565
+ readonly source: () => string | URLSearchParams | null | undefined;
566
+ readonly switchWhenALinkAsks?: (() => boolean | undefined) | undefined;
567
+ readonly signedInAs?: (() => string | null | undefined) | undefined;
568
+ readonly messages?: Partial<LinkOrganizationMessages> | undefined;
569
+ /** A share admits without making a member (asked only after a not-a-member verdict). */
570
+ readonly admittedByAShare?: ((organizationId: string) => Promise<boolean>) | undefined;
571
+ readonly onDecision?: ((decision: LinkOrganizationDecision) => void) | undefined;
572
+ } | undefined;
573
+ /**
574
+ * THE ADMIN SEAT: while the host's admin lane is open, the admin's own organization IS the active
575
+ * one for every reader (`current()`, `ensureOrgId()`); off the lane the person's own is untouched.
576
+ * Ignored unless the seat is a current membership. The snapshot always keeps the person's own.
577
+ */
578
+ readonly adminSeat?: (() => AdminSeatInput | null | undefined) | undefined;
579
+ /** Marked resolved whenever the ladder settles (organization or none). */
580
+ readonly bootstrapGate?: OrganizationBootstrapGate | undefined;
581
+ /** Minimum gap between ladder re-runs caused by a not-a-member refusal. Default 30 s. */
582
+ readonly refusalRecheckGapMs?: number | undefined;
583
+ /** Load as soon as it is created (default true). */
584
+ readonly autoLoad?: boolean | undefined;
585
+ }
586
+ /** A held store over a `Storage` (browser `sessionStorage` = per tab), identity-keyed. */
587
+ declare function storageHeldOrganization(storage: Pick<Storage, "getItem" | "setItem" | "removeItem">, key?: string): HeldOrganizationStore & {
588
+ read(userId: string): string | null;
589
+ };
590
+ /** Held in memory: the running app's own choice (desktop, scripts, tests). */
591
+ declare function memoryHeldOrganization(): HeldOrganizationStore;
592
+ declare function createActiveOrganization(options: CreateActiveOrganizationOptions): ActiveOrganization;
593
+
594
+ export { decideLinkOrganization as $, type AuthClient as A, NotAMemberError as B, type CreateActiveOrganizationOptions as C, type NotAMemberRefusalNotice as D, ORGANIZATION_RESOLUTION_TIMEOUT_MS as E, type OrganizationBootstrapGate as F, type OrganizationMembership as G, type HeldOrganizationStore as H, type OrganizationRefusal as I, type OrganizationRung as J, type OrganizationsDb as K, LINK_ORGANIZATION_QUERY_KEY as L, type ResolveWithLinkInput as M, NOT_A_MEMBER_RECHECK_GAP_MS as N, ORGANIZATION_ENTITY_TYPE as O, type ParentOrganization as P, type ResolvedOrganization as Q, type ReadMembershipsOptions as R, type SessionSourceLike as S, type ResolvedWithLink as T, type UserCredentialsPort as U, adminSeatReplacesUserContext as V, classifyLinkOrganizationValue as W, classifyOrganizationRefusal as X, createActiveOrganization as Y, createNotAMemberRefusalNotice as Z, createOrganizationBootstrapGate as _, SignInError as a, isNotAMemberRefusal as a0, linkOrganizationMalformedMessage as a1, linkOrganizationNotAMemberMessage as a2, linkOrganizationOfferMessage as a3, linkOrganizationSwitchAnnouncement as a4, memoryHeldOrganization as a5, oldestMembership as a6, orderOrganizations as a7, organizationMatches as a8, readAccountOrganizationChoices as a9, readLinkOrganizationParam as aa, readMemberships as ab, readOrganizationFavourites as ac, resolveActiveOrganization as ad, resolveActiveOrganizationLazily as ae, resolveAdminSeat as af, resolveChildOrganizationId as ag, resolveOrganizationWithLink as ah, setOrganizationFavourite as ai, storageHeldOrganization as aj, toAccountOrganizationChoices as ak, writeLastActiveOrganization as al, writeStartupOrganization as am, getSession as b, signInWithOAuth as c, signInWithPassword as d, signOut as e, userCredentials as f, getPerson as g, type AccountOrganizationChoices as h, invalidatePerson as i, type ActiveOrganization as j, type ActiveOrganizationSnapshot as k, type ActiveOrganizationStatus as l, type AdminSeat as m, type AdminSeatInput as n, onSessionChange as o, type ArchiveFilter as p, type LadderInput as q, refreshSession as r, sessionCredentials as s, type LinkOrganizationDecision as t, updatePerson as u, type LinkOrganizationDecisionInput as v, type LinkOrganizationMembership as w, type LinkOrganizationMessages as x, type LinkOrganizationParam as y, NoActiveOrganizationError as z };