@ai-matrx/auth 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/node.js CHANGED
@@ -1,3 +1,135 @@
1
+ // src/server-auth.ts
2
+ import { createClient } from "@supabase/supabase-js";
3
+
4
+ // src/claims.ts
5
+ import {
6
+ AuthError,
7
+ AuthSessionMissingError
8
+ } from "@supabase/supabase-js";
9
+ async function getClaimsUser(client, jwt, options) {
10
+ const { data, error } = options ? await client.auth.getClaims(jwt, options) : await client.auth.getClaims(jwt);
11
+ if (error) {
12
+ if (isSignedOutError(error)) return { data: { user: null }, error: null };
13
+ return { data: { user: null }, error };
14
+ }
15
+ if (!data?.claims) return { data: { user: null }, error: null };
16
+ const user = userFromClaims(data.claims);
17
+ if (!user) {
18
+ return {
19
+ data: { user: null },
20
+ error: new AuthError("Verified token carries no `sub` claim", 401, "bad_jwt")
21
+ };
22
+ }
23
+ return { data: { user }, error: null };
24
+ }
25
+ var BUDGET_EXHAUSTED_STATUS = 408;
26
+ var BUDGET_EXHAUSTED_CODE = "auth_budget_exhausted";
27
+ function isAuthTransportFailure(error) {
28
+ if (!error || typeof error !== "object") return false;
29
+ const name = String(error.name ?? "");
30
+ if (name === "AuthRetryableFetchError" || name === "TimeoutError" || name === "AbortError") return true;
31
+ const status = error.status;
32
+ if (status === 0 || status === 504 || status === 503) return true;
33
+ return status === BUDGET_EXHAUSTED_STATUS || error.code === BUDGET_EXHAUSTED_CODE;
34
+ }
35
+ function isSignedOutError(error) {
36
+ return error instanceof AuthSessionMissingError || error.name === "AuthSessionMissingError" || error.code === "session_not_found";
37
+ }
38
+ function userFromClaims(claims) {
39
+ if (!claims || typeof claims.sub !== "string") return null;
40
+ return {
41
+ ...claims,
42
+ id: claims.sub,
43
+ app_metadata: claims.app_metadata ?? {},
44
+ user_metadata: claims.user_metadata ?? {}
45
+ };
46
+ }
47
+ function accountFlagsFor(claims) {
48
+ if (!claims) return { isGuest: false, hasAccount: false };
49
+ const isGuest = claims.is_anonymous === true;
50
+ return { isGuest, hasAccount: !isGuest };
51
+ }
52
+ var PROJECT_SIGNING_KEY_AUTHORITIES = [
53
+ "db.matrxserver.com",
54
+ "brsgrqvjdzwihsvnfqkf.supabase.co"
55
+ ];
56
+ var PROJECT_SIGNING_KEYS = [
57
+ {
58
+ alg: "ES256",
59
+ crv: "P-256",
60
+ ext: true,
61
+ key_ops: ["verify"],
62
+ kid: "a00aed0b-202c-483a-91ff-fc74719a3bb2",
63
+ kty: "EC",
64
+ use: "sig",
65
+ x: "guqOsgjJqGVU6g_K0iIXKrzixlkeXKOqkPUQCconp6U",
66
+ y: "39zQtkzlfQuPf_MtIhYmMdxNQpIR2Pkl11AToywaslY"
67
+ }
68
+ ];
69
+ function pinnedSigningKeysFor(supabaseUrl) {
70
+ if (!supabaseUrl) return [];
71
+ let host;
72
+ try {
73
+ host = new URL(supabaseUrl).host.toLowerCase();
74
+ } catch {
75
+ return [];
76
+ }
77
+ return PROJECT_SIGNING_KEY_AUTHORITIES.includes(host) ? PROJECT_SIGNING_KEYS : [];
78
+ }
79
+
80
+ // src/server-auth.ts
81
+ function createServerAuth(options) {
82
+ const warn = options.warn ?? ((m) => console.warn(m));
83
+ const resolveOnce = async () => {
84
+ const client = await options.createClient();
85
+ const {
86
+ data: { user },
87
+ error
88
+ } = await getClaimsUser(client, void 0, { jwks: { keys: pinnedSigningKeysFor(options.supabaseUrl) } });
89
+ const authUnavailable = !user && error !== null && isAuthTransportFailure(error);
90
+ if (authUnavailable) {
91
+ warn(
92
+ `[getServerAuth] identity could not be verified on this attempt \u2014 rendering without a user, NOT as signed out. Cause: ${error?.name ?? "unknown"}: ${error?.message ?? ""}`
93
+ );
94
+ }
95
+ return { isAuthenticated: !!user, user, authUnavailable, ...accountFlagsFor(user) };
96
+ };
97
+ const door = async () => {
98
+ const first = await resolveOnce();
99
+ if (!first.authUnavailable) return first;
100
+ await new Promise((resolve) => setTimeout(resolve, options.retryAfterMs ?? 250));
101
+ const second = await resolveOnce();
102
+ if (!second.authUnavailable) warn("[getServerAuth] identity verified on the retry \u2014 the first attempt's budget was spent.");
103
+ return second;
104
+ };
105
+ return options.cache ? options.cache(door) : door;
106
+ }
107
+ async function verifyRequest(request, options) {
108
+ const header = request.headers.get("Authorization") ?? request.headers.get("authorization");
109
+ if (header?.startsWith("Bearer ")) {
110
+ const token = header.slice(7);
111
+ const client2 = createClient(options.url, options.publishableKey, {
112
+ global: { headers: { Authorization: `Bearer ${token}` } },
113
+ auth: { persistSession: false, autoRefreshToken: false }
114
+ });
115
+ const { data: data2, error: error2 } = await getClaimsUser(client2, token, {
116
+ jwks: { keys: pinnedSigningKeysFor(options.url) }
117
+ });
118
+ return { user: data2.user, error: error2 };
119
+ }
120
+ if (!options.cookieClient) return { user: null, error: null };
121
+ const client = await options.cookieClient();
122
+ const { data, error } = await getClaimsUser(client, void 0, { jwks: { keys: pinnedSigningKeysFor(options.url) } });
123
+ return { user: data.user, error };
124
+ }
125
+ function createServiceClient(options) {
126
+ const url = options.url?.trim();
127
+ const key = options.secretKey?.trim();
128
+ if (!url) throw new Error("createServiceClient: the Supabase URL is not configured");
129
+ if (!key) throw new Error("createServiceClient: the secret key (SUPABASE_SECRET_KEY) is not configured");
130
+ return createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false } });
131
+ }
132
+
1
133
  // src/node.ts
2
134
  function builtin(name) {
3
135
  const get = globalThis.process?.getBuiltinModule;
@@ -32,5 +164,8 @@ function fileStorage(path) {
32
164
  };
33
165
  }
34
166
  export {
35
- fileStorage
167
+ createServerAuth,
168
+ createServiceClient,
169
+ fileStorage,
170
+ verifyRequest
36
171
  };
@@ -20,20 +20,50 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/organizations.ts
21
21
  var organizations_exports = {};
22
22
  __export(organizations_exports, {
23
+ LINK_ORGANIZATION_QUERY_KEY: () => LINK_ORGANIZATION_QUERY_KEY,
24
+ MEMBERSHIPS_URL: () => MEMBERSHIPS_URL,
25
+ NOT_A_MEMBER_RECHECK_GAP_MS: () => NOT_A_MEMBER_RECHECK_GAP_MS,
23
26
  NoActiveOrganizationError: () => NoActiveOrganizationError,
24
27
  NotAMemberError: () => NotAMemberError,
28
+ ORGANIZATION_CONTEXT_REQUIRED_CODE: () => ORGANIZATION_CONTEXT_REQUIRED_CODE,
25
29
  ORGANIZATION_ENTITY_TYPE: () => ORGANIZATION_ENTITY_TYPE,
30
+ ORGANIZATION_REQUIRED_CODE: () => ORGANIZATION_REQUIRED_CODE,
31
+ ORGANIZATION_REQUIRED_WIRE_CODE: () => ORGANIZATION_REQUIRED_WIRE_CODE,
32
+ ORGANIZATION_RESOLUTION_TIMEOUT_MS: () => ORGANIZATION_RESOLUTION_TIMEOUT_MS,
33
+ OrganizationRequiredServerError: () => OrganizationRequiredServerError,
34
+ adminSeatReplacesUserContext: () => adminSeatReplacesUserContext,
35
+ classifyLinkOrganizationValue: () => classifyLinkOrganizationValue,
26
36
  classifyOrganizationRefusal: () => classifyOrganizationRefusal,
27
37
  createActiveOrganization: () => createActiveOrganization,
38
+ createNotAMemberRefusalNotice: () => createNotAMemberRefusalNotice,
39
+ createOrganizationBootstrapGate: () => createOrganizationBootstrapGate,
40
+ decideLinkOrganization: () => decideLinkOrganization,
41
+ extractOrganizationHoldMemberships: () => extractOrganizationHoldMemberships,
42
+ isNotAMemberRefusal: () => isNotAMemberRefusal,
43
+ isOrganizationRequiredEnvelope: () => isOrganizationRequiredEnvelope,
44
+ isOrganizationRequiredError: () => isOrganizationRequiredError,
45
+ isOrganizationRequiredServerError: () => isOrganizationRequiredServerError,
46
+ linkOrganizationMalformedMessage: () => linkOrganizationMalformedMessage,
47
+ linkOrganizationNotAMemberMessage: () => linkOrganizationNotAMemberMessage,
48
+ linkOrganizationOfferMessage: () => linkOrganizationOfferMessage,
49
+ linkOrganizationSwitchAnnouncement: () => linkOrganizationSwitchAnnouncement,
28
50
  memoryHeldOrganization: () => memoryHeldOrganization,
29
51
  oldestMembership: () => oldestMembership,
30
52
  orderOrganizations: () => orderOrganizations,
31
53
  organizationMatches: () => organizationMatches,
54
+ organizationRequired: () => organizationRequired,
55
+ organizationRequiredEnvelope: () => organizationRequiredEnvelope,
56
+ organizationRequiredResponse: () => organizationRequiredResponse,
32
57
  readAccountOrganizationChoices: () => readAccountOrganizationChoices,
58
+ readCallerMemberships: () => readCallerMemberships,
59
+ readLinkOrganizationParam: () => readLinkOrganizationParam,
33
60
  readMemberships: () => readMemberships,
34
61
  readOrganizationFavourites: () => readOrganizationFavourites,
35
62
  resolveActiveOrganization: () => resolveActiveOrganization,
36
63
  resolveActiveOrganizationLazily: () => resolveActiveOrganizationLazily,
64
+ resolveAdminSeat: () => resolveAdminSeat,
65
+ resolveChildOrganizationId: () => resolveChildOrganizationId,
66
+ resolveOrganizationWithLink: () => resolveOrganizationWithLink,
37
67
  setOrganizationFavourite: () => setOrganizationFavourite,
38
68
  storageHeldOrganization: () => storageHeldOrganization,
39
69
  toAccountOrganizationChoices: () => toAccountOrganizationChoices,
@@ -225,6 +255,209 @@ function onSessionChange(client, cb) {
225
255
  return () => data.subscription.unsubscribe();
226
256
  }
227
257
 
258
+ // src/organization-gate.ts
259
+ var ORGANIZATION_RESOLUTION_TIMEOUT_MS = 1e4;
260
+ function createOrganizationBootstrapGate(defaultTimeoutMs = ORGANIZATION_RESOLUTION_TIMEOUT_MS) {
261
+ let settled = false;
262
+ let announce = null;
263
+ let resolution = new Promise((resolve) => {
264
+ announce = resolve;
265
+ });
266
+ return {
267
+ isResolved: () => settled,
268
+ markResolved() {
269
+ if (settled) return;
270
+ settled = true;
271
+ announce?.();
272
+ },
273
+ async whenResolved(timeoutMs = defaultTimeoutMs) {
274
+ if (settled) return;
275
+ let timer;
276
+ await Promise.race([
277
+ resolution,
278
+ new Promise((resolve) => {
279
+ timer = setTimeout(resolve, timeoutMs);
280
+ })
281
+ ]);
282
+ if (timer !== void 0) clearTimeout(timer);
283
+ },
284
+ reset() {
285
+ announce?.();
286
+ settled = false;
287
+ resolution = new Promise((resolve) => {
288
+ announce = resolve;
289
+ });
290
+ }
291
+ };
292
+ }
293
+ var NOT_A_MEMBER_CODES = /* @__PURE__ */ new Set(["organization_forbidden", "organization_not_member"]);
294
+ function isNotAMemberRefusal(body) {
295
+ if (!body || typeof body !== "object") return false;
296
+ const record = body;
297
+ if (typeof record.code === "string" && NOT_A_MEMBER_CODES.has(record.code)) return true;
298
+ const detail = record.detail;
299
+ return !!detail && typeof detail === "object" && typeof detail.code === "string" && NOT_A_MEMBER_CODES.has(detail.code);
300
+ }
301
+ var NOT_A_MEMBER_RECHECK_GAP_MS = 3e4;
302
+ function createNotAMemberRefusalNotice(recheck, gapMs = NOT_A_MEMBER_RECHECK_GAP_MS) {
303
+ let last = Number.NEGATIVE_INFINITY;
304
+ return {
305
+ notice(body, now = Date.now()) {
306
+ if (!isNotAMemberRefusal(body)) return false;
307
+ if (now - last < gapMs) return false;
308
+ last = now;
309
+ recheck();
310
+ return true;
311
+ },
312
+ reset() {
313
+ last = Number.NEGATIVE_INFINITY;
314
+ }
315
+ };
316
+ }
317
+ function resolveChildOrganizationId(parent, explicit, label = "child record", onDisagree = (m) => console.warn(m)) {
318
+ const parentOrg = typeof parent === "string" ? parent : parent?.organization_id ?? null;
319
+ if (parentOrg) {
320
+ if (explicit && explicit !== parentOrg) {
321
+ onDisagree(`[child org] ${label} filed in its parent's organization ${parentOrg}, not ${explicit} \u2014 a child belongs with its parent.`);
322
+ }
323
+ return parentOrg;
324
+ }
325
+ return explicit ?? null;
326
+ }
327
+ function resolveAdminSeat(input) {
328
+ if (!input || input.laneOpen !== true || !input.organizationId) return null;
329
+ return { id: input.organizationId, name: input.organizationName ?? null };
330
+ }
331
+ function adminSeatReplacesUserContext(input, personOrganizationId) {
332
+ const seat = resolveAdminSeat(input);
333
+ return seat !== null && seat.id !== (personOrganizationId ?? null);
334
+ }
335
+
336
+ // src/organization-link.ts
337
+ var import_uuid = require("@ai-matrx/kit/uuid");
338
+ var LINK_ORGANIZATION_QUERY_KEY = "org";
339
+ var SLUG = /^[a-z0-9][a-z0-9-]{0,99}$/i;
340
+ function classifyLinkOrganizationValue(raw) {
341
+ const value = raw.trim();
342
+ if ((0, import_uuid.isRfc4122Uuid)(value)) return { kind: "named", organizationId: value };
343
+ if (SLUG.test(value)) return { kind: "addressed", slug: value.toLowerCase() };
344
+ return { kind: "malformed", raw: value };
345
+ }
346
+ function readLinkOrganizationParam(source) {
347
+ if (source == null) return { kind: "absent" };
348
+ let params;
349
+ if (source instanceof URLSearchParams) {
350
+ params = source;
351
+ } else {
352
+ try {
353
+ const q = source.includes("?") ? source.slice(source.indexOf("?")) : source;
354
+ params = new URLSearchParams(q);
355
+ } catch {
356
+ return { kind: "absent" };
357
+ }
358
+ }
359
+ if (!params.has(LINK_ORGANIZATION_QUERY_KEY)) return { kind: "absent" };
360
+ return classifyLinkOrganizationValue(params.get(LINK_ORGANIZATION_QUERY_KEY) ?? "");
361
+ }
362
+ function linkOrganizationNotAMemberMessage(signedInAs) {
363
+ const account = signedInAs ? `You are signed in as ${signedInAs}.` : "Check which account you are signed in as.";
364
+ 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.`;
365
+ }
366
+ function linkOrganizationMalformedMessage(raw) {
367
+ const quoted = raw.length > 0 ? ` ("${raw}")` : "";
368
+ 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.`;
369
+ }
370
+ function linkOrganizationSwitchAnnouncement(toName, fromName) {
371
+ const from = fromName ? ` You were working in ${fromName}.` : "";
372
+ 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.`;
373
+ }
374
+ function linkOrganizationOfferMessage(toName, fromName) {
375
+ const where = fromName ? `, and you are working in ${fromName}` : "";
376
+ 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.`;
377
+ }
378
+ function decideLinkOrganization(input) {
379
+ const { param, memberships, currentOrganizationId } = input;
380
+ const switchWhenALinkAsks = input.switchWhenALinkAsks ?? true;
381
+ const say = {
382
+ notAMember: linkOrganizationNotAMemberMessage,
383
+ malformed: linkOrganizationMalformedMessage,
384
+ switched: linkOrganizationSwitchAnnouncement,
385
+ offer: linkOrganizationOfferMessage,
386
+ offerAction: (name) => `Switch to ${name}`,
387
+ ...input.messages
388
+ };
389
+ if (param.kind === "absent") return { kind: "no-link" };
390
+ if (param.kind === "malformed") {
391
+ return {
392
+ kind: "refused",
393
+ reason: "malformed",
394
+ message: say.malformed(param.raw)
395
+ };
396
+ }
397
+ const match = param.kind === "named" ? memberships.find((o) => o.id === param.organizationId) : memberships.find((o) => o.slug?.trim().toLowerCase() === param.slug);
398
+ if (!match && param.kind === "named" && input.admittedByAShare) {
399
+ return { kind: "admitted", organizationId: param.organizationId };
400
+ }
401
+ if (!match) {
402
+ return {
403
+ kind: "refused",
404
+ reason: "not-a-member",
405
+ message: say.notAMember(input.signedInAs)
406
+ };
407
+ }
408
+ if (currentOrganizationId === match.id) {
409
+ return { kind: "already-current", organizationId: match.id };
410
+ }
411
+ const fromName = currentOrganizationId ? input.currentOrganizationName ?? memberships.find((o) => o.id === currentOrganizationId)?.name ?? null : null;
412
+ if (currentOrganizationId && !switchWhenALinkAsks) {
413
+ return {
414
+ kind: "offered",
415
+ organizationId: match.id,
416
+ organizationName: match.name,
417
+ message: say.offer(match.name, fromName),
418
+ actionLabel: say.offerAction(match.name)
419
+ };
420
+ }
421
+ return {
422
+ kind: "honoured",
423
+ organizationId: match.id,
424
+ organizationName: match.name,
425
+ // Nothing to announce on a cold arrival: they were working nowhere, so
426
+ // they were not moved. A move, however, is always announced.
427
+ announcement: currentOrganizationId ? say.switched(match.name, fromName) : null
428
+ };
429
+ }
430
+ async function resolveOrganizationWithLink(input) {
431
+ const ladder = await resolveActiveOrganizationLazily({
432
+ memberships: input.memberships,
433
+ held: input.held ?? null,
434
+ readAccount: input.readAccount,
435
+ ...input.first ? { first: input.first } : {}
436
+ });
437
+ if (!ladder) return null;
438
+ const param = typeof input.link === "string" ? input.link.includes("=") ? readLinkOrganizationParam(input.link) : classifyLinkOrganizationValue(input.link) : readLinkOrganizationParam(input.link ?? null);
439
+ const decisionInput = {
440
+ param,
441
+ memberships: input.memberships.map((o) => ({ id: o.id, name: o.name, slug: o.slug ?? null })),
442
+ currentOrganizationId: ladder.organization.id,
443
+ currentOrganizationName: ladder.organization.name,
444
+ ...input.switchWhenALinkAsks === void 0 ? {} : { switchWhenALinkAsks: input.switchWhenALinkAsks },
445
+ ...input.signedInAs === void 0 ? {} : { signedInAs: input.signedInAs },
446
+ ...input.messages ? { messages: input.messages } : {}
447
+ };
448
+ let link = decideLinkOrganization(decisionInput);
449
+ if (link.kind === "refused" && link.reason === "not-a-member" && param.kind === "named" && input.admittedByAShare) {
450
+ if (await input.admittedByAShare(param.organizationId)) {
451
+ link = decideLinkOrganization({ ...decisionInput, admittedByAShare: true });
452
+ }
453
+ }
454
+ if (link.kind === "honoured") {
455
+ const organization = input.memberships.find((m) => m.id === link.organizationId);
456
+ if (organization) return { organization, rung: "link", link };
457
+ }
458
+ return { organization: ladder.organization, rung: ladder.rung, link };
459
+ }
460
+
228
461
  // src/active-organization.ts
229
462
  var NoActiveOrganizationError = class extends Error {
230
463
  constructor(reason, detail) {
@@ -302,6 +535,7 @@ function createActiveOrganization(options) {
302
535
  const publish = (next) => {
303
536
  current = Object.freeze(next);
304
537
  if (current.status !== "resolving" && current.status !== "idle") {
538
+ options.bootstrapGate?.markResolved();
305
539
  const ready = waiters;
306
540
  waiters = [];
307
541
  for (const w of ready) w();
@@ -319,19 +553,38 @@ function createActiveOrganization(options) {
319
553
  }
320
554
  const memberships = await readMemberships(client, person.id);
321
555
  if (gen !== generation) return current;
322
- const linked = options.linked?.() ?? null;
323
- const heldId = (linked && memberships.some((m) => m.id === linked) ? linked : null) ?? await held.read(person.id);
324
- const resolved = await resolveActiveOrganizationLazily({
325
- memberships,
326
- held: heldId,
327
- readAccount: () => readAccountOrganizationChoices(client, person.id)
328
- });
556
+ const linkOptions = options.link;
557
+ let resolved;
558
+ let linkDecision = null;
559
+ if (linkOptions) {
560
+ const r = await resolveOrganizationWithLink({
561
+ memberships,
562
+ held: await held.read(person.id),
563
+ readAccount: () => readAccountOrganizationChoices(client, person.id),
564
+ link: linkOptions.source() ?? null,
565
+ switchWhenALinkAsks: linkOptions.switchWhenALinkAsks?.(),
566
+ signedInAs: linkOptions.signedInAs?.(),
567
+ messages: linkOptions.messages,
568
+ admittedByAShare: linkOptions.admittedByAShare
569
+ });
570
+ resolved = r;
571
+ linkDecision = r?.link ?? null;
572
+ } else {
573
+ const linked = options.linked?.() ?? null;
574
+ const heldId = (linked && memberships.some((m) => m.id === linked) ? linked : null) ?? await held.read(person.id);
575
+ resolved = await resolveActiveOrganizationLazily({
576
+ memberships,
577
+ held: heldId,
578
+ readAccount: () => readAccountOrganizationChoices(client, person.id)
579
+ });
580
+ }
329
581
  if (gen !== generation) return current;
330
582
  if (!resolved) {
331
583
  publish({ ...IDLE, status: "none", userId: person.id, error: "no-memberships" });
332
584
  return current;
333
585
  }
334
586
  await held.write(person.id, resolved.organization.id);
587
+ if (linkDecision && linkDecision.kind !== "no-link") options.link?.onDecision?.(linkDecision);
335
588
  publish({
336
589
  status: "ready",
337
590
  userId: person.id,
@@ -367,6 +620,11 @@ function createActiveOrganization(options) {
367
620
  const userId = session.user?.id ?? null;
368
621
  if (event === "SIGNED_IN" && userId !== current.userId) void load();
369
622
  });
623
+ const seatFor = (snap) => {
624
+ const seat = resolveAdminSeat(options.adminSeat?.());
625
+ return seat && snap.memberships.some((m) => m.id === seat.id) ? seat.id : null;
626
+ };
627
+ let lastNotAMemberRecheck = Number.NEGATIVE_INFINITY;
370
628
  const settled = async () => {
371
629
  if (current.status === "idle") return load();
372
630
  if (current.status === "resolving") {
@@ -383,11 +641,11 @@ function createActiveOrganization(options) {
383
641
  };
384
642
  },
385
643
  load,
386
- current: () => current.status === "ready" ? current.organizationId : null,
644
+ current: () => current.status === "ready" ? seatFor(current) ?? current.organizationId : null,
387
645
  async ensureOrgId(recordOrganizationId) {
388
646
  if (recordOrganizationId) return recordOrganizationId;
389
647
  const snap = await settled();
390
- if (snap.status === "ready" && snap.organizationId) return snap.organizationId;
648
+ if (snap.status === "ready" && snap.organizationId) return seatFor(snap) ?? snap.organizationId;
391
649
  if (snap.status === "error") throw new NoActiveOrganizationError("unreadable", snap.error ?? void 0);
392
650
  throw new NoActiveOrganizationError(snap.error === "signed-out" ? "signed-out" : "no-memberships");
393
651
  },
@@ -402,7 +660,16 @@ function createActiveOrganization(options) {
402
660
  await writeLastActiveOrganization(client, organizationId);
403
661
  },
404
662
  async handleRefusal(status, body) {
405
- const kind = classifyOrganizationRefusal(status, body);
663
+ let kind = classifyOrganizationRefusal(status, body);
664
+ if (kind === null && isNotAMemberRefusal(body)) {
665
+ const now = Date.now();
666
+ if (now - lastNotAMemberRecheck >= (options.refusalRecheckGapMs ?? NOT_A_MEMBER_RECHECK_GAP_MS)) {
667
+ lastNotAMemberRecheck = now;
668
+ kind = "lapsed";
669
+ } else {
670
+ return null;
671
+ }
672
+ }
406
673
  if (kind === "lapsed") {
407
674
  if (current.userId) await held.clear();
408
675
  await load();
@@ -422,3 +689,84 @@ function createActiveOrganization(options) {
422
689
  if (options.autoLoad ?? true) void load();
423
690
  return api;
424
691
  }
692
+
693
+ // src/organization-required.ts
694
+ var ORGANIZATION_REQUIRED_CODE = "organization_required";
695
+ var ORGANIZATION_REQUIRED_WIRE_CODE = ORGANIZATION_REQUIRED_CODE;
696
+ var ORGANIZATION_CONTEXT_REQUIRED_CODE = "organization_context_required";
697
+ var MEMBERSHIPS_URL = "/auth/organizations";
698
+ function isOrganizationRequiredError(err) {
699
+ if (!(err instanceof Error)) return false;
700
+ const code = err.code;
701
+ return code === ORGANIZATION_CONTEXT_REQUIRED_CODE || code === ORGANIZATION_REQUIRED_CODE;
702
+ }
703
+ function isOrganizationRequiredEnvelope(body) {
704
+ return typeof body === "object" && body !== null && body.code === ORGANIZATION_REQUIRED_CODE;
705
+ }
706
+ function extractOrganizationHoldMemberships(err) {
707
+ if (!err || typeof err !== "object") return null;
708
+ if (err.code !== ORGANIZATION_REQUIRED_CODE) return null;
709
+ const container = err;
710
+ return readOrganizationsField(container.details) ?? readOrganizationsField(container.serverDetail);
711
+ }
712
+ function readOrganizationsField(details) {
713
+ if (!details || typeof details !== "object") return null;
714
+ const organizations = details.organizations;
715
+ if (!Array.isArray(organizations)) return null;
716
+ return organizations.filter(
717
+ (org) => !!org && typeof org === "object" && typeof org.id === "string" && typeof org.name === "string"
718
+ );
719
+ }
720
+ var OrganizationRequiredServerError = class extends Error {
721
+ name = "OrganizationRequiredServerError";
722
+ code = ORGANIZATION_REQUIRED_CODE;
723
+ /** `null` only when the list could not be read ("could not ask"), distinct from `[]`. */
724
+ memberships;
725
+ constructor(message, memberships = null) {
726
+ super(message);
727
+ this.memberships = memberships;
728
+ }
729
+ };
730
+ function isOrganizationRequiredServerError(error) {
731
+ return error instanceof OrganizationRequiredServerError || error instanceof Error && error.name === "OrganizationRequiredServerError";
732
+ }
733
+ async function readCallerMemberships(client) {
734
+ try {
735
+ const { data: claims } = await client.auth.getClaims();
736
+ const userId = claims?.claims?.sub;
737
+ if (typeof userId !== "string" || !userId) return null;
738
+ const query = client.schema("iam").from("memberships").select("organization_id, organizations:organization_id(id,name,abbreviation)");
739
+ const { data, error } = await query.eq("user_id", userId).eq("container_type", "organization").eq("status", "active").is("deleted_at", null);
740
+ if (error || !Array.isArray(data)) return null;
741
+ const rows = data;
742
+ return rows.map((row) => ({
743
+ id: row.organizations?.id ?? row.organization_id,
744
+ name: row.organizations?.name ?? "Untitled organization",
745
+ ...row.organizations?.abbreviation ? { abbreviation: row.organizations.abbreviation } : {}
746
+ })).sort((a, b) => a.name.localeCompare(b.name));
747
+ } catch {
748
+ return null;
749
+ }
750
+ }
751
+ async function organizationRequired(client, message) {
752
+ throw new OrganizationRequiredServerError(message, await readCallerMemberships(client));
753
+ }
754
+ function organizationRequiredEnvelope(error, setOn = "request") {
755
+ return {
756
+ error: ORGANIZATION_REQUIRED_CODE,
757
+ code: ORGANIZATION_REQUIRED_CODE,
758
+ message: error.message,
759
+ user_message: "Choose the organization you're working in, then try again.",
760
+ details: {
761
+ hold: ORGANIZATION_REQUIRED_CODE,
762
+ can_choose: true,
763
+ set_on: setOn,
764
+ remedy: "Choose the organization you're working in and send it with the request (the X-Organization-Id header), then try again.",
765
+ organizations: error.memberships,
766
+ memberships_url: MEMBERSHIPS_URL
767
+ }
768
+ };
769
+ }
770
+ function organizationRequiredResponse(error) {
771
+ return Response.json(organizationRequiredEnvelope(error), { status: 400 });
772
+ }