@ai-matrx/auth 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/node.d.cts CHANGED
@@ -1,3 +1,5 @@
1
+ import { JWK, JwtPayload, AuthError, UserAppMetadata, UserMetadata, SupabaseClient } from '@supabase/supabase-js';
2
+
1
3
  /**
2
4
  * Session storage per platform — where a Supabase session lives between runs.
3
5
  *
@@ -21,6 +23,120 @@ interface SessionStorage {
21
23
  removeItem(key: string): void | Promise<void>;
22
24
  }
23
25
 
26
+ /**
27
+ * THE identity read every server and client path shares (moved from the website's
28
+ * `utils/supabase/{claimsUser,projectSigningKeys,unverifiableSession}.ts`, 2026-10-12).
29
+ *
30
+ * Verifies the access token LOCALLY (WebCrypto against the project's JWKS; the live project signs
31
+ * ES256) and never asks the auth server who the caller is. `getClaims()`, NEVER `getUser()`:
32
+ * `getUser()` is one auth-server round trip per call, with auth-js retrying a stalled refresh for
33
+ * up to 30s — on 2026-09-20 and 09-21 that put 504s in front of real people.
34
+ *
35
+ * Framework-free: no `next/*`, no `window` at import time. Server doors (`createServerAuth` in
36
+ * `/next`, `verifyRequest` in `/node`) and the browser's de-duplicated read (`createClaimsCache`)
37
+ * are thin wrappers over `getClaimsUser`.
38
+ */
39
+
40
+ /**
41
+ * The verified caller, built from the access token's claims. `id` is `sub`; `app_metadata` /
42
+ * `user_metadata` default to `{}` so a reader never null-checks what `getUser()` always handed it.
43
+ */
44
+ interface ApiClaimsUser extends JwtPayload {
45
+ /** The `sub` claim — the user id. */
46
+ id: string;
47
+ app_metadata: UserAppMetadata;
48
+ user_metadata: UserMetadata;
49
+ }
50
+ /** Any Supabase client — the only thing this door needs is `auth.getClaims`. */
51
+ type ClaimsCapableClient = {
52
+ auth: {
53
+ getClaims(jwt?: string, options?: {
54
+ jwks?: {
55
+ keys: JWK[];
56
+ };
57
+ }): Promise<{
58
+ data: {
59
+ claims?: JwtPayload;
60
+ } | null;
61
+ error: AuthError | null;
62
+ }>;
63
+ };
64
+ };
65
+
66
+ /**
67
+ * Server identity doors (moved from the website's `utils/supabase/{getServerAuth,resolveUser,
68
+ * adminClient}.ts`, 2026-10-12). Framework-free: the app injects how it builds its cookie client,
69
+ * and (for Next) React's `cache()` so one request resolves once. Exported from `/next` and `/node`.
70
+ */
71
+
72
+ interface ServerAuthState {
73
+ /** `true` when the request carries a locally verified access token. */
74
+ isAuthenticated: boolean;
75
+ /** The verified caller (claims, `id` = `sub`), or `null` for guests and unresolvable requests. */
76
+ user: ApiClaimsUser | null;
77
+ /**
78
+ * `true` when no verdict could be REACHED (authority/JWKS unreachable, identity budget spent).
79
+ * The person is NOT signed out: never bounce to sign-in on it. Never set for a token that was
80
+ * judged bad — that is a settled answer, the same line the proxy draws.
81
+ */
82
+ authUnavailable: boolean;
83
+ /** `true` for a guest (anonymous) session. */
84
+ isGuest: boolean;
85
+ /** `isAuthenticated && !isGuest` — the flag account UI keys on. */
86
+ hasAccount: boolean;
87
+ }
88
+ interface ServerAuthOptions {
89
+ /** A FRESH cookie-bound client per attempt (the identity budget belongs to the client). */
90
+ createClient: () => Promise<ClaimsCapableClient> | ClaimsCapableClient;
91
+ /** The URL the app talks to — decides which pinned keys apply. */
92
+ supabaseUrl: string | null | undefined;
93
+ /** React's `cache` on a Next server, so the whole tree shares one resolve per request. */
94
+ cache?: <F extends (...args: never[]) => unknown>(fn: F) => F;
95
+ /** Wait before the one retry (default 250ms). */
96
+ retryAfterMs?: number;
97
+ /** Where the "could not verify" warnings go (default `console.warn`). */
98
+ warn?: (message: string) => void;
99
+ }
100
+ /**
101
+ * Build THE server identity door: claims verified locally, asked twice before it hands anyone
102
+ * "could not verify" (that is almost always one cold first load spending its budget).
103
+ *
104
+ * export const getServerAuth = createServerAuth({ createClient, supabaseUrl, cache });
105
+ */
106
+ declare function createServerAuth(options: ServerAuthOptions): () => Promise<ServerAuthState>;
107
+ /** The bit of a request this needs: its headers (a `Request`, a `NextRequest`, a Node shim). */
108
+ interface RequestLike {
109
+ headers: {
110
+ get(name: string): string | null;
111
+ };
112
+ }
113
+ interface VerifyRequestOptions {
114
+ url: string;
115
+ /** The publishable key (`sb_publishable_*`). */
116
+ publishableKey: string;
117
+ /** The app's cookie-bound client, used when the request carries no Bearer token. */
118
+ cookieClient?: () => Promise<ClaimsCapableClient> | ClaimsCapableClient;
119
+ }
120
+ /**
121
+ * Who is calling this API route: a Bearer token (public/mobile/extension/desktop clients) or the
122
+ * session cookie (browser). `user` is null when the caller is signed out OR could not be verified;
123
+ * `error` tells the two apart (null = settled signed out).
124
+ */
125
+ declare function verifyRequest(request: RequestLike, options: VerifyRequestOptions): Promise<{
126
+ user: ApiClaimsUser | null;
127
+ error: Error | null;
128
+ }>;
129
+ /**
130
+ * THE service (secret-key) client — server-only, bypasses row security. The package's browser
131
+ * clients refuse a secret key by design (`SecretKeyRefusedError`); this is the one door for it.
132
+ * Never ship it to a browser bundle: it lives in `/node` and `/next` only.
133
+ */
134
+ declare function createServiceClient<Database = unknown>(options: {
135
+ url: string | null | undefined;
136
+ /** `sb_secret_*` (the legacy service-role JWT is banned). */
137
+ secretKey: string | null | undefined;
138
+ }): SupabaseClient<Database>;
139
+
24
140
  /**
25
141
  * @ai-matrx/auth/node — session storage for Node scripts and CLIs: the session survives between
26
142
  * runs in one JSON file (mode 0600), so a script signs in once and refreshes after.
@@ -30,4 +146,4 @@ interface SessionStorage {
30
146
 
31
147
  declare function fileStorage(path: string): SessionStorage;
32
148
 
33
- export { fileStorage };
149
+ export { type RequestLike, type ServerAuthOptions, type ServerAuthState, type VerifyRequestOptions, createServerAuth, createServiceClient, fileStorage, verifyRequest };
package/dist/node.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { JWK, JwtPayload, AuthError, UserAppMetadata, UserMetadata, SupabaseClient } from '@supabase/supabase-js';
2
+
1
3
  /**
2
4
  * Session storage per platform — where a Supabase session lives between runs.
3
5
  *
@@ -21,6 +23,120 @@ interface SessionStorage {
21
23
  removeItem(key: string): void | Promise<void>;
22
24
  }
23
25
 
26
+ /**
27
+ * THE identity read every server and client path shares (moved from the website's
28
+ * `utils/supabase/{claimsUser,projectSigningKeys,unverifiableSession}.ts`, 2026-10-12).
29
+ *
30
+ * Verifies the access token LOCALLY (WebCrypto against the project's JWKS; the live project signs
31
+ * ES256) and never asks the auth server who the caller is. `getClaims()`, NEVER `getUser()`:
32
+ * `getUser()` is one auth-server round trip per call, with auth-js retrying a stalled refresh for
33
+ * up to 30s — on 2026-09-20 and 09-21 that put 504s in front of real people.
34
+ *
35
+ * Framework-free: no `next/*`, no `window` at import time. Server doors (`createServerAuth` in
36
+ * `/next`, `verifyRequest` in `/node`) and the browser's de-duplicated read (`createClaimsCache`)
37
+ * are thin wrappers over `getClaimsUser`.
38
+ */
39
+
40
+ /**
41
+ * The verified caller, built from the access token's claims. `id` is `sub`; `app_metadata` /
42
+ * `user_metadata` default to `{}` so a reader never null-checks what `getUser()` always handed it.
43
+ */
44
+ interface ApiClaimsUser extends JwtPayload {
45
+ /** The `sub` claim — the user id. */
46
+ id: string;
47
+ app_metadata: UserAppMetadata;
48
+ user_metadata: UserMetadata;
49
+ }
50
+ /** Any Supabase client — the only thing this door needs is `auth.getClaims`. */
51
+ type ClaimsCapableClient = {
52
+ auth: {
53
+ getClaims(jwt?: string, options?: {
54
+ jwks?: {
55
+ keys: JWK[];
56
+ };
57
+ }): Promise<{
58
+ data: {
59
+ claims?: JwtPayload;
60
+ } | null;
61
+ error: AuthError | null;
62
+ }>;
63
+ };
64
+ };
65
+
66
+ /**
67
+ * Server identity doors (moved from the website's `utils/supabase/{getServerAuth,resolveUser,
68
+ * adminClient}.ts`, 2026-10-12). Framework-free: the app injects how it builds its cookie client,
69
+ * and (for Next) React's `cache()` so one request resolves once. Exported from `/next` and `/node`.
70
+ */
71
+
72
+ interface ServerAuthState {
73
+ /** `true` when the request carries a locally verified access token. */
74
+ isAuthenticated: boolean;
75
+ /** The verified caller (claims, `id` = `sub`), or `null` for guests and unresolvable requests. */
76
+ user: ApiClaimsUser | null;
77
+ /**
78
+ * `true` when no verdict could be REACHED (authority/JWKS unreachable, identity budget spent).
79
+ * The person is NOT signed out: never bounce to sign-in on it. Never set for a token that was
80
+ * judged bad — that is a settled answer, the same line the proxy draws.
81
+ */
82
+ authUnavailable: boolean;
83
+ /** `true` for a guest (anonymous) session. */
84
+ isGuest: boolean;
85
+ /** `isAuthenticated && !isGuest` — the flag account UI keys on. */
86
+ hasAccount: boolean;
87
+ }
88
+ interface ServerAuthOptions {
89
+ /** A FRESH cookie-bound client per attempt (the identity budget belongs to the client). */
90
+ createClient: () => Promise<ClaimsCapableClient> | ClaimsCapableClient;
91
+ /** The URL the app talks to — decides which pinned keys apply. */
92
+ supabaseUrl: string | null | undefined;
93
+ /** React's `cache` on a Next server, so the whole tree shares one resolve per request. */
94
+ cache?: <F extends (...args: never[]) => unknown>(fn: F) => F;
95
+ /** Wait before the one retry (default 250ms). */
96
+ retryAfterMs?: number;
97
+ /** Where the "could not verify" warnings go (default `console.warn`). */
98
+ warn?: (message: string) => void;
99
+ }
100
+ /**
101
+ * Build THE server identity door: claims verified locally, asked twice before it hands anyone
102
+ * "could not verify" (that is almost always one cold first load spending its budget).
103
+ *
104
+ * export const getServerAuth = createServerAuth({ createClient, supabaseUrl, cache });
105
+ */
106
+ declare function createServerAuth(options: ServerAuthOptions): () => Promise<ServerAuthState>;
107
+ /** The bit of a request this needs: its headers (a `Request`, a `NextRequest`, a Node shim). */
108
+ interface RequestLike {
109
+ headers: {
110
+ get(name: string): string | null;
111
+ };
112
+ }
113
+ interface VerifyRequestOptions {
114
+ url: string;
115
+ /** The publishable key (`sb_publishable_*`). */
116
+ publishableKey: string;
117
+ /** The app's cookie-bound client, used when the request carries no Bearer token. */
118
+ cookieClient?: () => Promise<ClaimsCapableClient> | ClaimsCapableClient;
119
+ }
120
+ /**
121
+ * Who is calling this API route: a Bearer token (public/mobile/extension/desktop clients) or the
122
+ * session cookie (browser). `user` is null when the caller is signed out OR could not be verified;
123
+ * `error` tells the two apart (null = settled signed out).
124
+ */
125
+ declare function verifyRequest(request: RequestLike, options: VerifyRequestOptions): Promise<{
126
+ user: ApiClaimsUser | null;
127
+ error: Error | null;
128
+ }>;
129
+ /**
130
+ * THE service (secret-key) client — server-only, bypasses row security. The package's browser
131
+ * clients refuse a secret key by design (`SecretKeyRefusedError`); this is the one door for it.
132
+ * Never ship it to a browser bundle: it lives in `/node` and `/next` only.
133
+ */
134
+ declare function createServiceClient<Database = unknown>(options: {
135
+ url: string | null | undefined;
136
+ /** `sb_secret_*` (the legacy service-role JWT is banned). */
137
+ secretKey: string | null | undefined;
138
+ }): SupabaseClient<Database>;
139
+
24
140
  /**
25
141
  * @ai-matrx/auth/node — session storage for Node scripts and CLIs: the session survives between
26
142
  * runs in one JSON file (mode 0600), so a script signs in once and refreshes after.
@@ -30,4 +146,4 @@ interface SessionStorage {
30
146
 
31
147
  declare function fileStorage(path: string): SessionStorage;
32
148
 
33
- export { fileStorage };
149
+ export { type RequestLike, type ServerAuthOptions, type ServerAuthState, type VerifyRequestOptions, createServerAuth, createServiceClient, fileStorage, verifyRequest };
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,16 +20,29 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/organizations.ts
21
21
  var organizations_exports = {};
22
22
  __export(organizations_exports, {
23
+ MEMBERSHIPS_URL: () => MEMBERSHIPS_URL,
23
24
  NoActiveOrganizationError: () => NoActiveOrganizationError,
24
25
  NotAMemberError: () => NotAMemberError,
26
+ ORGANIZATION_CONTEXT_REQUIRED_CODE: () => ORGANIZATION_CONTEXT_REQUIRED_CODE,
25
27
  ORGANIZATION_ENTITY_TYPE: () => ORGANIZATION_ENTITY_TYPE,
28
+ ORGANIZATION_REQUIRED_CODE: () => ORGANIZATION_REQUIRED_CODE,
29
+ ORGANIZATION_REQUIRED_WIRE_CODE: () => ORGANIZATION_REQUIRED_WIRE_CODE,
30
+ OrganizationRequiredServerError: () => OrganizationRequiredServerError,
26
31
  classifyOrganizationRefusal: () => classifyOrganizationRefusal,
27
32
  createActiveOrganization: () => createActiveOrganization,
33
+ extractOrganizationHoldMemberships: () => extractOrganizationHoldMemberships,
34
+ isOrganizationRequiredEnvelope: () => isOrganizationRequiredEnvelope,
35
+ isOrganizationRequiredError: () => isOrganizationRequiredError,
36
+ isOrganizationRequiredServerError: () => isOrganizationRequiredServerError,
28
37
  memoryHeldOrganization: () => memoryHeldOrganization,
29
38
  oldestMembership: () => oldestMembership,
30
39
  orderOrganizations: () => orderOrganizations,
31
40
  organizationMatches: () => organizationMatches,
41
+ organizationRequired: () => organizationRequired,
42
+ organizationRequiredEnvelope: () => organizationRequiredEnvelope,
43
+ organizationRequiredResponse: () => organizationRequiredResponse,
32
44
  readAccountOrganizationChoices: () => readAccountOrganizationChoices,
45
+ readCallerMemberships: () => readCallerMemberships,
33
46
  readMemberships: () => readMemberships,
34
47
  readOrganizationFavourites: () => readOrganizationFavourites,
35
48
  resolveActiveOrganization: () => resolveActiveOrganization,
@@ -182,10 +195,43 @@ function check(door, data, error) {
182
195
  if (error) throw new SignInError(door, error.message, error.status, error.code);
183
196
  return data;
184
197
  }
198
+ var personCache = /* @__PURE__ */ new WeakMap();
199
+ function personEntry(client) {
200
+ let entry = personCache.get(client);
201
+ if (!entry) {
202
+ const fresh = { read: null, epoch: 0 };
203
+ entry = fresh;
204
+ personCache.set(client, fresh);
205
+ const subscribe = client.auth?.onAuthStateChange;
206
+ if (typeof subscribe === "function") {
207
+ subscribe.call(client.auth, async (event) => {
208
+ if (event === "SIGNED_IN" || event === "SIGNED_OUT" || event === "USER_UPDATED") invalidatePerson(client);
209
+ });
210
+ }
211
+ }
212
+ return entry;
213
+ }
214
+ function invalidatePerson(client) {
215
+ const entry = personCache.get(client);
216
+ if (!entry) return;
217
+ entry.epoch += 1;
218
+ entry.read = null;
219
+ }
185
220
  async function getPerson(client) {
186
- const { data, error } = await client.auth.getUser();
187
- if (error && (error.name === "AuthSessionMissingError" || error.status === 401)) return null;
188
- return check("getPerson", data, error).user;
221
+ const entry = personEntry(client);
222
+ if (entry.read) return entry.read;
223
+ const epoch = entry.epoch;
224
+ const read = (async () => {
225
+ const { data, error } = await client.auth.getUser();
226
+ if (error && (error.name === "AuthSessionMissingError" || error.status === 401)) return null;
227
+ return check("getPerson", data, error).user;
228
+ })();
229
+ entry.read = read;
230
+ const settle = (keep) => {
231
+ if (entry.epoch === epoch && entry.read === read && !keep) entry.read = null;
232
+ };
233
+ read.then((user) => settle(user !== null), () => settle(false));
234
+ return read;
189
235
  }
190
236
  function onSessionChange(client, cb) {
191
237
  const { data } = client.auth.onAuthStateChange(cb);
@@ -318,6 +364,7 @@ function createActiveOrganization(options) {
318
364
  const load = () => {
319
365
  if (disposed) return Promise.resolve(current);
320
366
  generation += 1;
367
+ invalidatePerson(client);
321
368
  inflight = run(generation).finally(() => {
322
369
  inflight = null;
323
370
  });
@@ -362,6 +409,7 @@ function createActiveOrganization(options) {
362
409
  const organization = snap.memberships.find((m) => m.id === organizationId);
363
410
  if (!organization || !snap.userId) throw new NotAMemberError(organizationId);
364
411
  generation += 1;
412
+ invalidatePerson(client);
365
413
  await held.write(snap.userId, organizationId);
366
414
  publish({ ...snap, status: "ready", organizationId, organization, rung: "switched", error: null });
367
415
  await writeLastActiveOrganization(client, organizationId);
@@ -387,3 +435,84 @@ function createActiveOrganization(options) {
387
435
  if (options.autoLoad ?? true) void load();
388
436
  return api;
389
437
  }
438
+
439
+ // src/organization-required.ts
440
+ var ORGANIZATION_REQUIRED_CODE = "organization_required";
441
+ var ORGANIZATION_REQUIRED_WIRE_CODE = ORGANIZATION_REQUIRED_CODE;
442
+ var ORGANIZATION_CONTEXT_REQUIRED_CODE = "organization_context_required";
443
+ var MEMBERSHIPS_URL = "/auth/organizations";
444
+ function isOrganizationRequiredError(err) {
445
+ if (!(err instanceof Error)) return false;
446
+ const code = err.code;
447
+ return code === ORGANIZATION_CONTEXT_REQUIRED_CODE || code === ORGANIZATION_REQUIRED_CODE;
448
+ }
449
+ function isOrganizationRequiredEnvelope(body) {
450
+ return typeof body === "object" && body !== null && body.code === ORGANIZATION_REQUIRED_CODE;
451
+ }
452
+ function extractOrganizationHoldMemberships(err) {
453
+ if (!err || typeof err !== "object") return null;
454
+ if (err.code !== ORGANIZATION_REQUIRED_CODE) return null;
455
+ const container = err;
456
+ return readOrganizationsField(container.details) ?? readOrganizationsField(container.serverDetail);
457
+ }
458
+ function readOrganizationsField(details) {
459
+ if (!details || typeof details !== "object") return null;
460
+ const organizations = details.organizations;
461
+ if (!Array.isArray(organizations)) return null;
462
+ return organizations.filter(
463
+ (org) => !!org && typeof org === "object" && typeof org.id === "string" && typeof org.name === "string"
464
+ );
465
+ }
466
+ var OrganizationRequiredServerError = class extends Error {
467
+ name = "OrganizationRequiredServerError";
468
+ code = ORGANIZATION_REQUIRED_CODE;
469
+ /** `null` only when the list could not be read ("could not ask"), distinct from `[]`. */
470
+ memberships;
471
+ constructor(message, memberships = null) {
472
+ super(message);
473
+ this.memberships = memberships;
474
+ }
475
+ };
476
+ function isOrganizationRequiredServerError(error) {
477
+ return error instanceof OrganizationRequiredServerError || error instanceof Error && error.name === "OrganizationRequiredServerError";
478
+ }
479
+ async function readCallerMemberships(client) {
480
+ try {
481
+ const { data: claims } = await client.auth.getClaims();
482
+ const userId = claims?.claims?.sub;
483
+ if (typeof userId !== "string" || !userId) return null;
484
+ const query = client.schema("iam").from("memberships").select("organization_id, organizations:organization_id(id,name,abbreviation)");
485
+ const { data, error } = await query.eq("user_id", userId).eq("container_type", "organization").eq("status", "active").is("deleted_at", null);
486
+ if (error || !Array.isArray(data)) return null;
487
+ const rows = data;
488
+ return rows.map((row) => ({
489
+ id: row.organizations?.id ?? row.organization_id,
490
+ name: row.organizations?.name ?? "Untitled organization",
491
+ ...row.organizations?.abbreviation ? { abbreviation: row.organizations.abbreviation } : {}
492
+ })).sort((a, b) => a.name.localeCompare(b.name));
493
+ } catch {
494
+ return null;
495
+ }
496
+ }
497
+ async function organizationRequired(client, message) {
498
+ throw new OrganizationRequiredServerError(message, await readCallerMemberships(client));
499
+ }
500
+ function organizationRequiredEnvelope(error, setOn = "request") {
501
+ return {
502
+ error: ORGANIZATION_REQUIRED_CODE,
503
+ code: ORGANIZATION_REQUIRED_CODE,
504
+ message: error.message,
505
+ user_message: "Choose the organization you're working in, then try again.",
506
+ details: {
507
+ hold: ORGANIZATION_REQUIRED_CODE,
508
+ can_choose: true,
509
+ set_on: setOn,
510
+ remedy: "Choose the organization you're working in and send it with the request (the X-Organization-Id header), then try again.",
511
+ organizations: error.memberships,
512
+ memberships_url: MEMBERSHIPS_URL
513
+ }
514
+ };
515
+ }
516
+ function organizationRequiredResponse(error) {
517
+ return Response.json(organizationRequiredEnvelope(error), { status: 400 });
518
+ }