@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.
@@ -1,227 +1,5 @@
1
- import { SupabaseClient } from '@supabase/supabase-js';
2
- import { A as AuthClient } from './session-DdQiStp6.js';
3
-
4
- /**
5
- * @ai-matrx/auth/organizations (moved from @ai-matrx/data/organizations, 2026-10-09) — THE active-organization ladder and the account's organization
6
- * facts, for every Matrx client (the web, Matrx 2, the extension, Studio).
7
- *
8
- * 🚨 ONE ACTIVE ORGANIZATION, SET ONCE WHEN THE APP LOADS, NEVER NONE (Arman, 2026-10-07;
9
- * organizations STATE rules 10–15). The ladder picks what the UI OPENS to; from then on every
10
- * request carries the one active organization the person can see. Reads never filter by it.
11
- *
12
- * THE RUNGS, each kept only if it is a CURRENT membership:
13
- * 1. HELD — what this tab (web) or this running app (desktop, in memory) already works in;
14
- * 2. LAST ACTIVE — `users.user_preferences.last_active_organization_id` (follows the person
15
- * across devices: STATE rule 12);
16
- * 3. START-UP — `users.user_preferences.startup_organization_id` (the "Start-up organization"
17
- * setting);
18
- * 4. FIRST — the oldest active membership.
19
- * Zero memberships → null. A failed read THROWS (the host shows "could not check" and a retry,
20
- * never a guess). The ladder's own answer never writes anything (rule 14): only a person's click
21
- * writes last active.
22
- *
23
- * Hosts with extra rungs keep them (the web's `?org=` link rung) and call the pure core here.
24
- */
25
-
26
- /** One organization the person is an active member of, as every picker draws it. */
27
- interface OrganizationMembership {
28
- readonly id: string;
29
- readonly name: string;
30
- readonly slug: string | null;
31
- /** The database-validated 2–3 letters, when the organization has them. */
32
- readonly abbreviation: string | null;
33
- readonly logoUrl: string | null;
34
- /** owner | admin | member (the membership's role). */
35
- readonly role: string;
36
- /** The person created it (drawn under the favourites, before the rest). */
37
- readonly isOwn: boolean;
38
- /** Classified in `iam.organizations.settings.test_fixture` — never guessed from a name. */
39
- readonly isTestFixture: boolean;
40
- /** When the membership began (ISO): the oldest is rung 4. */
41
- readonly joinedAt: string;
42
- }
43
- interface AccountOrganizationChoices {
44
- readonly lastActiveOrganizationId: string | null;
45
- readonly startupOrganizationId: string | null;
46
- }
47
- type OrganizationRung = "held" | "last-active" | "startup" | "first";
48
- interface ResolvedOrganization<T extends {
49
- id: string;
50
- }> {
51
- readonly organization: T;
52
- /** Which rung answered (for logs and tests; never shown as a choice). */
53
- readonly rung: OrganizationRung;
54
- }
55
- interface LadderInput<T extends {
56
- id: string;
57
- }> {
58
- readonly memberships: readonly T[];
59
- readonly held?: string | null;
60
- readonly account?: AccountOrganizationChoices | null;
61
- /** Rung 4: the oldest active membership. Omitted: the oldest by `joinedAt`, else the first given. */
62
- readonly first?: T | null;
63
- }
64
- /**
65
- * THE LADDER. Pure: no read, no write. Null only when there are no memberships.
66
- */
67
- declare function resolveActiveOrganization<T extends {
68
- id: string;
69
- joinedAt?: string;
70
- }>(input: LadderInput<T>): ResolvedOrganization<T> | null;
71
- /**
72
- * The ladder with its reads deferred: the account's choices are read only when no held
73
- * organization answers, and the first membership only when nothing else does. A failed read
74
- * throws through.
75
- */
76
- declare function resolveActiveOrganizationLazily<T extends {
77
- id: string;
78
- joinedAt?: string;
79
- }>(input: {
80
- readonly memberships: readonly T[];
81
- readonly held?: string | null;
82
- readonly readAccount: () => Promise<AccountOrganizationChoices>;
83
- readonly first?: () => Promise<T | null>;
84
- }): Promise<ResolvedOrganization<T> | null>;
85
- /** The oldest membership by `joinedAt` (ties and missing dates keep the given order). */
86
- declare function oldestMembership<T extends {
87
- joinedAt?: string;
88
- }>(memberships: readonly T[]): T | null;
89
- /**
90
- * THE ORDER every organization list uses (the shared OrganizationPicker's): favourites, then the
91
- * person's own organizations, then the rest; inside each band the given order is kept.
92
- */
93
- declare function orderOrganizations<T extends {
94
- id: string;
95
- isOwn?: boolean;
96
- }>(organizations: readonly T[], favouriteIds: readonly string[]): T[];
97
- /** Search over the two things a person types: the name and the abbreviation. */
98
- declare function organizationMatches(org: {
99
- name: string;
100
- abbreviation?: string | null;
101
- }, query: string): boolean;
102
- /** Any supabase-js client (typed or not). */
103
- type OrganizationsDb = SupabaseClient<any, any, any>;
104
- /**
105
- * Every organization the signed-in person is an ACTIVE member of (archived organizations left
106
- * out), through the canonical membership door (`mbr_for_user`, paged) and the organizations'
107
- * own rows. Throws when either read fails; [] when there are none. Ordered by name.
108
- */
109
- declare function readMemberships(db: OrganizationsDb, userId: string): Promise<OrganizationMembership[]>;
110
- interface ChoicesRow {
111
- last_active_organization_id: string | null;
112
- startup_organization_id: string | null;
113
- }
114
- /** The two columns as the ladder's input. A person with no preferences row has neither. */
115
- declare function toAccountOrganizationChoices(row: Partial<ChoicesRow> | null | undefined): AccountOrganizationChoices;
116
- /**
117
- * The account's last active and start-up organizations. 🚨 ONLY THE LADDER READS THESE: they
118
- * choose what the shell opens to and nothing else. Throws when the read fails.
119
- */
120
- declare function readAccountOrganizationChoices(db: OrganizationsDb, userId: string): Promise<AccountOrganizationChoices>;
121
- /** Save where the person now works, for their next load on any device. Only a click calls this. Throws on refusal. */
122
- declare function writeLastActiveOrganization(db: OrganizationsDb, organizationId: string): Promise<void>;
123
- /** Set (or clear, with null) the Start-up organization setting. Throws on refusal. */
124
- declare function writeStartupOrganization(db: OrganizationsDb, organizationId: string | null): Promise<void>;
125
- /** The favourites store's entity type (`platform.user_entity_state`). */
126
- declare const ORGANIZATION_ENTITY_TYPE = "organization";
127
- /**
128
- * The person's favourite organizations: the platform's per-person flag store
129
- * (`public.ues_list`, kind favorite), the same store agent and model favourites use. Throws on
130
- * failure.
131
- */
132
- declare function readOrganizationFavourites(db: OrganizationsDb): Promise<string[]>;
133
- /** Star (true) or unstar (false) one organization. `ues_set` refuses one the person cannot open. Throws on refusal. */
134
- declare function setOrganizationFavourite(db: OrganizationsDb, organizationId: string, favourite: boolean): Promise<void>;
135
- /**
136
- * What the AI Matrx server's refusal of a request's organization means for the ladder
137
- * (`matrx_connect` AuthMiddleware._organization_admission_words via `organization_free.py`):
138
- * - 400 with `detail.organization_admission` "denied" (not a member), "unknown" (no such
139
- * organization) or "archived" → the organization is gone for this person: re-run the ladder;
140
- * - 503 with "unverifiable" (the lookup failed; Retry-After 5) → keep it and retry;
141
- * - anything else → not an organization refusal.
142
- */
143
- type OrganizationRefusal = "lapsed" | "retry" | null;
144
- declare function classifyOrganizationRefusal(status: number, body: unknown): OrganizationRefusal;
145
-
146
- /**
147
- * THE ACTIVE ORGANIZATION — one per tab / running app, set once when the app loads, never none
148
- * while the person has a membership (Arman, 2026-10-07; organizations STATE rules 10–15).
149
- *
150
- * const active = createActiveOrganization({ client: supabase });
151
- * await active.load(); // the ladder: held → last active → start-up → first
152
- * const orgId = await active.ensureOrgId(); // HELD until the ladder answers; never a guess
153
- * await active.switchTo(otherId); // a person's click: saves last active for next load
154
- *
155
- * HELD-REQUEST SEMANTICS. A write or server call that needs the organization asks
156
- * `ensureOrgId()`: while the ladder is still resolving the call WAITS (it is held, never sent
157
- * without an organization and never sent with a guessed one); once resolved it gets the one
158
- * active organization; with zero memberships or a failed read it is REFUSED with a named error.
159
- * Acting on a record passes that record's own organization (`ensureOrgId(record.organization_id)`),
160
- * which wins and never switches the active one.
161
- *
162
- * 🚨 NEVER A LIST FILTER. Reads show everything the person can see across all their
163
- * organizations; nothing here narrows a read. The active organization is only where new things
164
- * are saved and which organization a server call runs in.
165
- *
166
- * The held id is identity-keyed: a different person signing in on the same device never inherits
167
- * the last person's organization, and a sign-out resets the store.
168
- */
169
-
170
- /** Where this tab / app remembers its organization between reloads (identity-keyed). */
171
- interface HeldOrganizationStore {
172
- read(userId: string): string | null | Promise<string | null>;
173
- write(userId: string, organizationId: string): void | Promise<void>;
174
- clear(): void | Promise<void>;
175
- }
176
- type ActiveOrganizationStatus = "idle" | "resolving" | "ready" | "none" | "error";
177
- interface ActiveOrganizationSnapshot {
178
- readonly status: ActiveOrganizationStatus;
179
- readonly userId: string | null;
180
- readonly organizationId: string | null;
181
- readonly organization: OrganizationMembership | null;
182
- readonly memberships: readonly OrganizationMembership[];
183
- /** Which ladder rung answered (or "switched" after a person's click). */
184
- readonly rung: OrganizationRung | "switched" | null;
185
- readonly error: string | null;
186
- }
187
- declare class NoActiveOrganizationError extends Error {
188
- readonly reason: "signed-out" | "no-memberships" | "unreadable";
189
- constructor(reason: "signed-out" | "no-memberships" | "unreadable", detail?: string);
190
- }
191
- declare class NotAMemberError extends Error {
192
- readonly organizationId: string;
193
- constructor(organizationId: string);
194
- }
195
- interface ActiveOrganization {
196
- snapshot(): ActiveOrganizationSnapshot;
197
- subscribe(listener: () => void): () => void;
198
- /** Run the ladder (again). Resolves with the settled snapshot; never throws. */
199
- load(): Promise<ActiveOrganizationSnapshot>;
200
- /** The active organization id now, or null while resolving / with none. Sync (api's getter). */
201
- current(): string | null;
202
- /** THE HELD REQUEST: the record's own organization, else the active one once resolved. */
203
- ensureOrgId(recordOrganizationId?: string | null): Promise<string>;
204
- /** A person's click: switch, remember for this tab, save as last active for the next load. */
205
- switchTo(organizationId: string): Promise<void>;
206
- /** Feed a server refusal in: a lapsed organization re-runs the ladder. Returns the class. */
207
- handleRefusal(status: number, body: unknown): Promise<"lapsed" | "retry" | null>;
208
- dispose(): void;
209
- }
210
- interface CreateActiveOrganizationOptions {
211
- /** The person's Supabase client (any supabase-js client). */
212
- readonly client: OrganizationsDb & AuthClient;
213
- /** Where this tab/app holds its choice. Default: browser `sessionStorage` (per tab), else memory. */
214
- readonly held?: HeldOrganizationStore | undefined;
215
- /** An extra first rung (the web's `?org=` link). Kept only if it is a current membership. */
216
- readonly linked?: (() => string | null | undefined) | undefined;
217
- /** Load as soon as it is created (default true). */
218
- readonly autoLoad?: boolean | undefined;
219
- }
220
- /** A held store over a `Storage` (browser `sessionStorage` = per tab), identity-keyed. */
221
- declare function storageHeldOrganization(storage: Pick<Storage, "getItem" | "setItem" | "removeItem">, key?: string): HeldOrganizationStore;
222
- /** Held in memory: the running app's own choice (desktop, scripts, tests). */
223
- declare function memoryHeldOrganization(): HeldOrganizationStore;
224
- declare function createActiveOrganization(options: CreateActiveOrganizationOptions): ActiveOrganization;
1
+ export { h as AccountOrganizationChoices, j as ActiveOrganization, k as ActiveOrganizationSnapshot, l as ActiveOrganizationStatus, m as AdminSeat, n as AdminSeatInput, p as ArchiveFilter, C as CreateActiveOrganizationOptions, H as HeldOrganizationStore, L as LINK_ORGANIZATION_QUERY_KEY, q as LadderInput, t as LinkOrganizationDecision, v as LinkOrganizationDecisionInput, w as LinkOrganizationMembership, x as LinkOrganizationMessages, y as LinkOrganizationParam, N as NOT_A_MEMBER_RECHECK_GAP_MS, z as NoActiveOrganizationError, B as NotAMemberError, D as NotAMemberRefusalNotice, O as ORGANIZATION_ENTITY_TYPE, E as ORGANIZATION_RESOLUTION_TIMEOUT_MS, F as OrganizationBootstrapGate, G as OrganizationMembership, I as OrganizationRefusal, J as OrganizationRung, K as OrganizationsDb, P as ParentOrganization, R as ReadMembershipsOptions, M as ResolveWithLinkInput, Q as ResolvedOrganization, T as ResolvedWithLink, V as adminSeatReplacesUserContext, W as classifyLinkOrganizationValue, X as classifyOrganizationRefusal, Y as createActiveOrganization, Z as createNotAMemberRefusalNotice, _ as createOrganizationBootstrapGate, $ as decideLinkOrganization, a0 as isNotAMemberRefusal, a1 as linkOrganizationMalformedMessage, a2 as linkOrganizationNotAMemberMessage, a3 as linkOrganizationOfferMessage, a4 as linkOrganizationSwitchAnnouncement, a5 as memoryHeldOrganization, a6 as oldestMembership, a7 as orderOrganizations, a8 as organizationMatches, a9 as readAccountOrganizationChoices, aa as readLinkOrganizationParam, ab as readMemberships, ac as readOrganizationFavourites, ad as resolveActiveOrganization, ae as resolveActiveOrganizationLazily, af as resolveAdminSeat, ag as resolveChildOrganizationId, ah as resolveOrganizationWithLink, ai as setOrganizationFavourite, aj as storageHeldOrganization, ak as toAccountOrganizationChoices, al as writeLastActiveOrganization, am as writeStartupOrganization } from './active-organization-Dy4UtiyR.js';
2
+ import '@supabase/supabase-js';
225
3
 
226
4
  /**
227
5
  * "This request needs an organization and names none" — the ONE recogniser, the server's throwable
@@ -329,4 +107,4 @@ declare function organizationRequiredEnvelope(error: OrganizationRequiredServerE
329
107
  /** The standard `Response` (status 400) a route answers the refusal with — no framework needed. */
330
108
  declare function organizationRequiredResponse(error: OrganizationRequiredServerError): Response;
331
109
 
332
- export { type AccountOrganizationChoices, type ActiveOrganization, type ActiveOrganizationSnapshot, type ActiveOrganizationStatus, type CreateActiveOrganizationOptions, type HeldOrganizationStore, type LadderInput, MEMBERSHIPS_URL, type MembershipReadClient, NoActiveOrganizationError, NotAMemberError, ORGANIZATION_CONTEXT_REQUIRED_CODE, ORGANIZATION_ENTITY_TYPE, ORGANIZATION_REQUIRED_CODE, ORGANIZATION_REQUIRED_WIRE_CODE, type OrganizationHoldDetails, type OrganizationHoldSetOn, type OrganizationMembership, type OrganizationMembershipSummary, type OrganizationRefusal, type OrganizationRequiredEnvelope, OrganizationRequiredServerError, type OrganizationRequiredWireMembership, type OrganizationRung, type OrganizationsDb, type ResolvedOrganization, classifyOrganizationRefusal, createActiveOrganization, extractOrganizationHoldMemberships, isOrganizationRequiredEnvelope, isOrganizationRequiredError, isOrganizationRequiredServerError, memoryHeldOrganization, oldestMembership, orderOrganizations, organizationMatches, organizationRequired, organizationRequiredEnvelope, organizationRequiredResponse, readAccountOrganizationChoices, readCallerMemberships, readMemberships, readOrganizationFavourites, resolveActiveOrganization, resolveActiveOrganizationLazily, setOrganizationFavourite, storageHeldOrganization, toAccountOrganizationChoices, writeLastActiveOrganization, writeStartupOrganization };
110
+ export { MEMBERSHIPS_URL, type MembershipReadClient, ORGANIZATION_CONTEXT_REQUIRED_CODE, ORGANIZATION_REQUIRED_CODE, ORGANIZATION_REQUIRED_WIRE_CODE, type OrganizationHoldDetails, type OrganizationHoldSetOn, type OrganizationMembershipSummary, type OrganizationRequiredEnvelope, OrganizationRequiredServerError, type OrganizationRequiredWireMembership, extractOrganizationHoldMemberships, isOrganizationRequiredEnvelope, isOrganizationRequiredError, isOrganizationRequiredServerError, organizationRequired, organizationRequiredEnvelope, organizationRequiredResponse, readCallerMemberships };
@@ -45,7 +45,8 @@ function organizationMatches(org, query) {
45
45
  }
46
46
  var MEMBERSHIP_PAGE = 500;
47
47
  var ID_CHUNK = 100;
48
- async function readMemberships(db, userId) {
48
+ async function readMemberships(db, userId, options = {}) {
49
+ const filter = options.archived ?? "active";
49
50
  const memberships = [];
50
51
  for (let from = 0; ; from += MEMBERSHIP_PAGE) {
51
52
  const { data, error } = await db.rpc("mbr_for_user", { p_container_type: "organization" }).order("id", { ascending: true }).range(from, from + MEMBERSHIP_PAGE - 1);
@@ -68,7 +69,7 @@ async function readMemberships(db, userId) {
68
69
  if (error) throw new Error(`Your organizations could not be read: ${error.message}`);
69
70
  rows.push(...data ?? []);
70
71
  }
71
- return rows.filter((r) => !r.archived_at).map((r) => {
72
+ return rows.filter((r) => filter === "all" ? true : filter === "archived" ? !!r.archived_at : !r.archived_at).map((r) => {
72
73
  const m = active.get(r.id);
73
74
  const settings = r.settings && typeof r.settings === "object" ? r.settings : {};
74
75
  return {
@@ -80,7 +81,8 @@ async function readMemberships(db, userId) {
80
81
  role: m?.role ?? "member",
81
82
  isOwn: !!r.created_by && r.created_by === userId,
82
83
  isTestFixture: settings["test_fixture"] === true,
83
- joinedAt: m?.created_at ?? ""
84
+ joinedAt: m?.created_at ?? "",
85
+ archivedAt: r.archived_at ?? null
84
86
  };
85
87
  }).sort((a, b) => a.name.localeCompare(b.name));
86
88
  }
@@ -181,6 +183,209 @@ function onSessionChange(client, cb) {
181
183
  return () => data.subscription.unsubscribe();
182
184
  }
183
185
 
186
+ // src/organization-gate.ts
187
+ var ORGANIZATION_RESOLUTION_TIMEOUT_MS = 1e4;
188
+ function createOrganizationBootstrapGate(defaultTimeoutMs = ORGANIZATION_RESOLUTION_TIMEOUT_MS) {
189
+ let settled = false;
190
+ let announce = null;
191
+ let resolution = new Promise((resolve) => {
192
+ announce = resolve;
193
+ });
194
+ return {
195
+ isResolved: () => settled,
196
+ markResolved() {
197
+ if (settled) return;
198
+ settled = true;
199
+ announce?.();
200
+ },
201
+ async whenResolved(timeoutMs = defaultTimeoutMs) {
202
+ if (settled) return;
203
+ let timer;
204
+ await Promise.race([
205
+ resolution,
206
+ new Promise((resolve) => {
207
+ timer = setTimeout(resolve, timeoutMs);
208
+ })
209
+ ]);
210
+ if (timer !== void 0) clearTimeout(timer);
211
+ },
212
+ reset() {
213
+ announce?.();
214
+ settled = false;
215
+ resolution = new Promise((resolve) => {
216
+ announce = resolve;
217
+ });
218
+ }
219
+ };
220
+ }
221
+ var NOT_A_MEMBER_CODES = /* @__PURE__ */ new Set(["organization_forbidden", "organization_not_member"]);
222
+ function isNotAMemberRefusal(body) {
223
+ if (!body || typeof body !== "object") return false;
224
+ const record = body;
225
+ if (typeof record.code === "string" && NOT_A_MEMBER_CODES.has(record.code)) return true;
226
+ const detail = record.detail;
227
+ return !!detail && typeof detail === "object" && typeof detail.code === "string" && NOT_A_MEMBER_CODES.has(detail.code);
228
+ }
229
+ var NOT_A_MEMBER_RECHECK_GAP_MS = 3e4;
230
+ function createNotAMemberRefusalNotice(recheck, gapMs = NOT_A_MEMBER_RECHECK_GAP_MS) {
231
+ let last = Number.NEGATIVE_INFINITY;
232
+ return {
233
+ notice(body, now = Date.now()) {
234
+ if (!isNotAMemberRefusal(body)) return false;
235
+ if (now - last < gapMs) return false;
236
+ last = now;
237
+ recheck();
238
+ return true;
239
+ },
240
+ reset() {
241
+ last = Number.NEGATIVE_INFINITY;
242
+ }
243
+ };
244
+ }
245
+ function resolveChildOrganizationId(parent, explicit, label = "child record", onDisagree = (m) => console.warn(m)) {
246
+ const parentOrg = typeof parent === "string" ? parent : parent?.organization_id ?? null;
247
+ if (parentOrg) {
248
+ if (explicit && explicit !== parentOrg) {
249
+ onDisagree(`[child org] ${label} filed in its parent's organization ${parentOrg}, not ${explicit} \u2014 a child belongs with its parent.`);
250
+ }
251
+ return parentOrg;
252
+ }
253
+ return explicit ?? null;
254
+ }
255
+ function resolveAdminSeat(input) {
256
+ if (!input || input.laneOpen !== true || !input.organizationId) return null;
257
+ return { id: input.organizationId, name: input.organizationName ?? null };
258
+ }
259
+ function adminSeatReplacesUserContext(input, personOrganizationId) {
260
+ const seat = resolveAdminSeat(input);
261
+ return seat !== null && seat.id !== (personOrganizationId ?? null);
262
+ }
263
+
264
+ // src/organization-link.ts
265
+ import { isRfc4122Uuid } from "@ai-matrx/kit/uuid";
266
+ var LINK_ORGANIZATION_QUERY_KEY = "org";
267
+ var SLUG = /^[a-z0-9][a-z0-9-]{0,99}$/i;
268
+ function classifyLinkOrganizationValue(raw) {
269
+ const value = raw.trim();
270
+ if (isRfc4122Uuid(value)) return { kind: "named", organizationId: value };
271
+ if (SLUG.test(value)) return { kind: "addressed", slug: value.toLowerCase() };
272
+ return { kind: "malformed", raw: value };
273
+ }
274
+ function readLinkOrganizationParam(source) {
275
+ if (source == null) return { kind: "absent" };
276
+ let params;
277
+ if (source instanceof URLSearchParams) {
278
+ params = source;
279
+ } else {
280
+ try {
281
+ const q = source.includes("?") ? source.slice(source.indexOf("?")) : source;
282
+ params = new URLSearchParams(q);
283
+ } catch {
284
+ return { kind: "absent" };
285
+ }
286
+ }
287
+ if (!params.has(LINK_ORGANIZATION_QUERY_KEY)) return { kind: "absent" };
288
+ return classifyLinkOrganizationValue(params.get(LINK_ORGANIZATION_QUERY_KEY) ?? "");
289
+ }
290
+ function linkOrganizationNotAMemberMessage(signedInAs) {
291
+ const account = signedInAs ? `You are signed in as ${signedInAs}.` : "Check which account you are signed in as.";
292
+ return `The link you followed is for an organization this account is not a member of, so nothing was opened and you were not moved. ${account} Sign in with the account the link was meant for, or ask whoever sent it to give this account access.`;
293
+ }
294
+ function linkOrganizationMalformedMessage(raw) {
295
+ const quoted = raw.length > 0 ? ` ("${raw}")` : "";
296
+ return `The link you followed names an organization we cannot read${quoted}, so nothing was switched and you were left where you were. Ask whoever sent it for the link again, or pick the organization you are working in from the avatar menu.`;
297
+ }
298
+ function linkOrganizationSwitchAnnouncement(toName, fromName) {
299
+ const from = fromName ? ` You were working in ${fromName}.` : "";
300
+ return `You are now working in ${toName} because the link you followed is filed under it.${from} Switch back any time from the avatar menu.`;
301
+ }
302
+ function linkOrganizationOfferMessage(toName, fromName) {
303
+ const where = fromName ? `, and you are working in ${fromName}` : "";
304
+ return `The link you followed is for ${toName}${where}. "Switch organization when a link asks" is turned off in your settings, so nothing changed \u2014 switch to ${toName} to open it.`;
305
+ }
306
+ function decideLinkOrganization(input) {
307
+ const { param, memberships, currentOrganizationId } = input;
308
+ const switchWhenALinkAsks = input.switchWhenALinkAsks ?? true;
309
+ const say = {
310
+ notAMember: linkOrganizationNotAMemberMessage,
311
+ malformed: linkOrganizationMalformedMessage,
312
+ switched: linkOrganizationSwitchAnnouncement,
313
+ offer: linkOrganizationOfferMessage,
314
+ offerAction: (name) => `Switch to ${name}`,
315
+ ...input.messages
316
+ };
317
+ if (param.kind === "absent") return { kind: "no-link" };
318
+ if (param.kind === "malformed") {
319
+ return {
320
+ kind: "refused",
321
+ reason: "malformed",
322
+ message: say.malformed(param.raw)
323
+ };
324
+ }
325
+ const match = param.kind === "named" ? memberships.find((o) => o.id === param.organizationId) : memberships.find((o) => o.slug?.trim().toLowerCase() === param.slug);
326
+ if (!match && param.kind === "named" && input.admittedByAShare) {
327
+ return { kind: "admitted", organizationId: param.organizationId };
328
+ }
329
+ if (!match) {
330
+ return {
331
+ kind: "refused",
332
+ reason: "not-a-member",
333
+ message: say.notAMember(input.signedInAs)
334
+ };
335
+ }
336
+ if (currentOrganizationId === match.id) {
337
+ return { kind: "already-current", organizationId: match.id };
338
+ }
339
+ const fromName = currentOrganizationId ? input.currentOrganizationName ?? memberships.find((o) => o.id === currentOrganizationId)?.name ?? null : null;
340
+ if (currentOrganizationId && !switchWhenALinkAsks) {
341
+ return {
342
+ kind: "offered",
343
+ organizationId: match.id,
344
+ organizationName: match.name,
345
+ message: say.offer(match.name, fromName),
346
+ actionLabel: say.offerAction(match.name)
347
+ };
348
+ }
349
+ return {
350
+ kind: "honoured",
351
+ organizationId: match.id,
352
+ organizationName: match.name,
353
+ // Nothing to announce on a cold arrival: they were working nowhere, so
354
+ // they were not moved. A move, however, is always announced.
355
+ announcement: currentOrganizationId ? say.switched(match.name, fromName) : null
356
+ };
357
+ }
358
+ async function resolveOrganizationWithLink(input) {
359
+ const ladder = await resolveActiveOrganizationLazily({
360
+ memberships: input.memberships,
361
+ held: input.held ?? null,
362
+ readAccount: input.readAccount,
363
+ ...input.first ? { first: input.first } : {}
364
+ });
365
+ if (!ladder) return null;
366
+ const param = typeof input.link === "string" ? input.link.includes("=") ? readLinkOrganizationParam(input.link) : classifyLinkOrganizationValue(input.link) : readLinkOrganizationParam(input.link ?? null);
367
+ const decisionInput = {
368
+ param,
369
+ memberships: input.memberships.map((o) => ({ id: o.id, name: o.name, slug: o.slug ?? null })),
370
+ currentOrganizationId: ladder.organization.id,
371
+ currentOrganizationName: ladder.organization.name,
372
+ ...input.switchWhenALinkAsks === void 0 ? {} : { switchWhenALinkAsks: input.switchWhenALinkAsks },
373
+ ...input.signedInAs === void 0 ? {} : { signedInAs: input.signedInAs },
374
+ ...input.messages ? { messages: input.messages } : {}
375
+ };
376
+ let link = decideLinkOrganization(decisionInput);
377
+ if (link.kind === "refused" && link.reason === "not-a-member" && param.kind === "named" && input.admittedByAShare) {
378
+ if (await input.admittedByAShare(param.organizationId)) {
379
+ link = decideLinkOrganization({ ...decisionInput, admittedByAShare: true });
380
+ }
381
+ }
382
+ if (link.kind === "honoured") {
383
+ const organization = input.memberships.find((m) => m.id === link.organizationId);
384
+ if (organization) return { organization, rung: "link", link };
385
+ }
386
+ return { organization: ladder.organization, rung: ladder.rung, link };
387
+ }
388
+
184
389
  // src/active-organization.ts
185
390
  var NoActiveOrganizationError = class extends Error {
186
391
  constructor(reason, detail) {
@@ -258,6 +463,7 @@ function createActiveOrganization(options) {
258
463
  const publish = (next) => {
259
464
  current = Object.freeze(next);
260
465
  if (current.status !== "resolving" && current.status !== "idle") {
466
+ options.bootstrapGate?.markResolved();
261
467
  const ready = waiters;
262
468
  waiters = [];
263
469
  for (const w of ready) w();
@@ -275,19 +481,38 @@ function createActiveOrganization(options) {
275
481
  }
276
482
  const memberships = await readMemberships(client, person.id);
277
483
  if (gen !== generation) return current;
278
- const linked = options.linked?.() ?? null;
279
- const heldId = (linked && memberships.some((m) => m.id === linked) ? linked : null) ?? await held.read(person.id);
280
- const resolved = await resolveActiveOrganizationLazily({
281
- memberships,
282
- held: heldId,
283
- readAccount: () => readAccountOrganizationChoices(client, person.id)
284
- });
484
+ const linkOptions = options.link;
485
+ let resolved;
486
+ let linkDecision = null;
487
+ if (linkOptions) {
488
+ const r = await resolveOrganizationWithLink({
489
+ memberships,
490
+ held: await held.read(person.id),
491
+ readAccount: () => readAccountOrganizationChoices(client, person.id),
492
+ link: linkOptions.source() ?? null,
493
+ switchWhenALinkAsks: linkOptions.switchWhenALinkAsks?.(),
494
+ signedInAs: linkOptions.signedInAs?.(),
495
+ messages: linkOptions.messages,
496
+ admittedByAShare: linkOptions.admittedByAShare
497
+ });
498
+ resolved = r;
499
+ linkDecision = r?.link ?? null;
500
+ } else {
501
+ const linked = options.linked?.() ?? null;
502
+ const heldId = (linked && memberships.some((m) => m.id === linked) ? linked : null) ?? await held.read(person.id);
503
+ resolved = await resolveActiveOrganizationLazily({
504
+ memberships,
505
+ held: heldId,
506
+ readAccount: () => readAccountOrganizationChoices(client, person.id)
507
+ });
508
+ }
285
509
  if (gen !== generation) return current;
286
510
  if (!resolved) {
287
511
  publish({ ...IDLE, status: "none", userId: person.id, error: "no-memberships" });
288
512
  return current;
289
513
  }
290
514
  await held.write(person.id, resolved.organization.id);
515
+ if (linkDecision && linkDecision.kind !== "no-link") options.link?.onDecision?.(linkDecision);
291
516
  publish({
292
517
  status: "ready",
293
518
  userId: person.id,
@@ -323,6 +548,11 @@ function createActiveOrganization(options) {
323
548
  const userId = session.user?.id ?? null;
324
549
  if (event === "SIGNED_IN" && userId !== current.userId) void load();
325
550
  });
551
+ const seatFor = (snap) => {
552
+ const seat = resolveAdminSeat(options.adminSeat?.());
553
+ return seat && snap.memberships.some((m) => m.id === seat.id) ? seat.id : null;
554
+ };
555
+ let lastNotAMemberRecheck = Number.NEGATIVE_INFINITY;
326
556
  const settled = async () => {
327
557
  if (current.status === "idle") return load();
328
558
  if (current.status === "resolving") {
@@ -339,11 +569,11 @@ function createActiveOrganization(options) {
339
569
  };
340
570
  },
341
571
  load,
342
- current: () => current.status === "ready" ? current.organizationId : null,
572
+ current: () => current.status === "ready" ? seatFor(current) ?? current.organizationId : null,
343
573
  async ensureOrgId(recordOrganizationId) {
344
574
  if (recordOrganizationId) return recordOrganizationId;
345
575
  const snap = await settled();
346
- if (snap.status === "ready" && snap.organizationId) return snap.organizationId;
576
+ if (snap.status === "ready" && snap.organizationId) return seatFor(snap) ?? snap.organizationId;
347
577
  if (snap.status === "error") throw new NoActiveOrganizationError("unreadable", snap.error ?? void 0);
348
578
  throw new NoActiveOrganizationError(snap.error === "signed-out" ? "signed-out" : "no-memberships");
349
579
  },
@@ -358,7 +588,16 @@ function createActiveOrganization(options) {
358
588
  await writeLastActiveOrganization(client, organizationId);
359
589
  },
360
590
  async handleRefusal(status, body) {
361
- const kind = classifyOrganizationRefusal(status, body);
591
+ let kind = classifyOrganizationRefusal(status, body);
592
+ if (kind === null && isNotAMemberRefusal(body)) {
593
+ const now = Date.now();
594
+ if (now - lastNotAMemberRecheck >= (options.refusalRecheckGapMs ?? NOT_A_MEMBER_RECHECK_GAP_MS)) {
595
+ lastNotAMemberRecheck = now;
596
+ kind = "lapsed";
597
+ } else {
598
+ return null;
599
+ }
600
+ }
362
601
  if (kind === "lapsed") {
363
602
  if (current.userId) await held.clear();
364
603
  await load();
@@ -460,20 +699,33 @@ function organizationRequiredResponse(error) {
460
699
  return Response.json(organizationRequiredEnvelope(error), { status: 400 });
461
700
  }
462
701
  export {
702
+ LINK_ORGANIZATION_QUERY_KEY,
463
703
  MEMBERSHIPS_URL,
704
+ NOT_A_MEMBER_RECHECK_GAP_MS,
464
705
  NoActiveOrganizationError,
465
706
  NotAMemberError,
466
707
  ORGANIZATION_CONTEXT_REQUIRED_CODE,
467
708
  ORGANIZATION_ENTITY_TYPE,
468
709
  ORGANIZATION_REQUIRED_CODE,
469
710
  ORGANIZATION_REQUIRED_WIRE_CODE,
711
+ ORGANIZATION_RESOLUTION_TIMEOUT_MS,
470
712
  OrganizationRequiredServerError,
713
+ adminSeatReplacesUserContext,
714
+ classifyLinkOrganizationValue,
471
715
  classifyOrganizationRefusal,
472
716
  createActiveOrganization,
717
+ createNotAMemberRefusalNotice,
718
+ createOrganizationBootstrapGate,
719
+ decideLinkOrganization,
473
720
  extractOrganizationHoldMemberships,
721
+ isNotAMemberRefusal,
474
722
  isOrganizationRequiredEnvelope,
475
723
  isOrganizationRequiredError,
476
724
  isOrganizationRequiredServerError,
725
+ linkOrganizationMalformedMessage,
726
+ linkOrganizationNotAMemberMessage,
727
+ linkOrganizationOfferMessage,
728
+ linkOrganizationSwitchAnnouncement,
477
729
  memoryHeldOrganization,
478
730
  oldestMembership,
479
731
  orderOrganizations,
@@ -483,10 +735,14 @@ export {
483
735
  organizationRequiredResponse,
484
736
  readAccountOrganizationChoices,
485
737
  readCallerMemberships,
738
+ readLinkOrganizationParam,
486
739
  readMemberships,
487
740
  readOrganizationFavourites,
488
741
  resolveActiveOrganization,
489
742
  resolveActiveOrganizationLazily,
743
+ resolveAdminSeat,
744
+ resolveChildOrganizationId,
745
+ resolveOrganizationWithLink,
490
746
  setOrganizationFavourite,
491
747
  storageHeldOrganization,
492
748
  toAccountOrganizationChoices,
package/dist/react.d.cts CHANGED
@@ -61,6 +61,8 @@ interface OrganizationMembership {
61
61
  readonly isTestFixture: boolean;
62
62
  /** When the membership began (ISO): the oldest is rung 4. */
63
63
  readonly joinedAt: string;
64
+ /** When the organization was archived (ISO), or null. Present on every row `readMemberships` returns. */
65
+ readonly archivedAt?: string | null;
64
66
  }
65
67
  type OrganizationRung = "held" | "last-active" | "startup" | "first";
66
68
 
@@ -96,7 +98,7 @@ interface ActiveOrganizationSnapshot {
96
98
  readonly organization: OrganizationMembership | null;
97
99
  readonly memberships: readonly OrganizationMembership[];
98
100
  /** Which ladder rung answered (or "switched" after a person's click). */
99
- readonly rung: OrganizationRung | "switched" | null;
101
+ readonly rung: OrganizationRung | "switched" | "link" | null;
100
102
  readonly error: string | null;
101
103
  }
102
104
  interface ActiveOrganization {
package/dist/react.d.ts CHANGED
@@ -61,6 +61,8 @@ interface OrganizationMembership {
61
61
  readonly isTestFixture: boolean;
62
62
  /** When the membership began (ISO): the oldest is rung 4. */
63
63
  readonly joinedAt: string;
64
+ /** When the organization was archived (ISO), or null. Present on every row `readMemberships` returns. */
65
+ readonly archivedAt?: string | null;
64
66
  }
65
67
  type OrganizationRung = "held" | "last-active" | "startup" | "first";
66
68
 
@@ -96,7 +98,7 @@ interface ActiveOrganizationSnapshot {
96
98
  readonly organization: OrganizationMembership | null;
97
99
  readonly memberships: readonly OrganizationMembership[];
98
100
  /** Which ladder rung answered (or "switched" after a person's click). */
99
- readonly rung: OrganizationRung | "switched" | null;
101
+ readonly rung: OrganizationRung | "switched" | "link" | null;
100
102
  readonly error: string | null;
101
103
  }
102
104
  interface ActiveOrganization {