@ai-matrx/auth 0.2.5 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog — @ai-matrx/auth
2
2
 
3
+ ## 0.3.1
4
+ - `clearStaleHeld` fix: a held choice already stale when the read began is cleared even when no organization can be answered,
5
+ and a choice made while the read was in flight is never cleared. Test: `active-organization.test.ts`.
6
+
7
+ ## 0.3.0
8
+ ### auth consolidation tail (round 3)
9
+
10
+ - `/organizations`: `organization-state` — pure decisions the apps' stores made themselves:
11
+ `shouldPromptForOrganization`, `organizationReadFailureReason`, `organizationWasReplaced`,
12
+ `reconcileRehydratedOrganization` (+ type `OrganizationStoreFields`).
13
+ - `.`: `looksLikeJwt(token)`, `profileFromClaims(claims)` (+ type `ClaimsProfile`).
14
+ - `createActiveOrganization`: `clearStaleHeld` option (default false) clears a held choice that is no longer a membership.
15
+ - `/react`: `useActiveOrganizationView({ signedIn, resolve, list, choose, deps?, subscribe? })` — load / error vs none /
16
+ must-choose / choose / reload state machine over host-injected reads.
17
+ - Tests: `organization-state`, `react-view`, `completeSignIn` replay (already-used code and burned code refuse cleanly), claims, clearStaleHeld.
18
+
3
19
  ## 0.2.5
4
20
 
5
21
  Automatic changed-only republish (docs/metadata drift since the last tag — see
@@ -638,6 +638,12 @@ interface CreateActiveOrganizationOptions {
638
638
  * explicit `switchTo` writes the held choice (the extension's rule: a computed answer is never a choice).
639
639
  */
640
640
  readonly holdResolved?: boolean | undefined;
641
+ /**
642
+ * After a read, a held choice that is no longer a membership is CLEARED from the held store (a
643
+ * selection must not outlive the membership). Default false. With `holdResolved` true the
644
+ * ladder's answer has already replaced it, so this matters for `holdResolved: false`.
645
+ */
646
+ readonly clearStaleHeld?: boolean | undefined;
641
647
  }
642
648
  /** A held store over a `Storage` (browser `sessionStorage` = per tab), identity-keyed. */
643
649
  declare function storageHeldOrganization(storage: Pick<Storage, "getItem" | "setItem" | "removeItem">, key?: string): Omit<HeldOrganizationStore, "read"> & {
@@ -638,6 +638,12 @@ interface CreateActiveOrganizationOptions {
638
638
  * explicit `switchTo` writes the held choice (the extension's rule: a computed answer is never a choice).
639
639
  */
640
640
  readonly holdResolved?: boolean | undefined;
641
+ /**
642
+ * After a read, a held choice that is no longer a membership is CLEARED from the held store (a
643
+ * selection must not outlive the membership). Default false. With `holdResolved` true the
644
+ * ladder's answer has already replaced it, so this matters for `holdResolved: false`.
645
+ */
646
+ readonly clearStaleHeld?: boolean | undefined;
641
647
  }
642
648
  /** A held store over a `Storage` (browser `sessionStorage` = per tab), identity-keyed. */
643
649
  declare function storageHeldOrganization(storage: Pick<Storage, "getItem" | "setItem" | "removeItem">, key?: string): Omit<HeldOrganizationStore, "read"> & {
@@ -74,6 +74,19 @@ declare function isSignedOutError(error: AuthError | {
74
74
  }): boolean;
75
75
  /** The verified claims as a `{ id, ... }` user, or `null` when there are none. */
76
76
  declare function userFromClaims(claims: JwtPayload | undefined): ApiClaimsUser | null;
77
+ /** Three segments whose header and payload decode as JSON. A malformed token is INVALID, not "no verdict". */
78
+ declare function looksLikeJwt(token: string): boolean;
79
+ /** The profile fields a verified token states (names match the `users` profile row). */
80
+ interface ClaimsProfile {
81
+ id: string;
82
+ email: string | null;
83
+ full_name: string | null;
84
+ avatar_url: string | null;
85
+ /** Only when the token carries it. */
86
+ email_verified?: boolean;
87
+ }
88
+ /** Map verified claims to a profile, or `null` when the token carries no subject. */
89
+ declare function profileFromClaims(claims: Record<string, unknown> | null | undefined): ClaimsProfile | null;
77
90
  /**
78
91
  * GUEST vs ACCOUNT. A signed-in session is a person with an ACCOUNT or a GUEST (anonymous session,
79
92
  * `is_anonymous: true`). Account UI (menu, billing, sign-out, org picker) keys on `hasAccount`.
@@ -135,4 +148,4 @@ declare function createClaimsCache(client: ClaimsCapableClient & {
135
148
  };
136
149
  }, options: ClaimsCacheOptions): () => Promise<ClaimsResult>;
137
150
 
138
- export { type ApiClaimsUser as A, type ClaimsCacheOptions as C, PROJECT_SIGNING_KEYS as P, type ClaimsCapableClient as a, type ClaimsResult as b, PROJECT_SIGNING_KEY_AUTHORITIES as c, accountFlagsFor as d, confirmUnverifiableSession as e, createClaimsCache as f, getClaimsUser as g, isSignedOutError as h, isAuthTransportFailure as i, isUnverifiableTokenError as j, isUnverifiableTokenRefusal as k, pinnedSigningKeysFor as p, userFromClaims as u };
151
+ export { type ApiClaimsUser as A, type ClaimsCacheOptions as C, PROJECT_SIGNING_KEYS as P, type ClaimsCapableClient as a, type ClaimsProfile as b, type ClaimsResult as c, PROJECT_SIGNING_KEY_AUTHORITIES as d, accountFlagsFor as e, confirmUnverifiableSession as f, createClaimsCache as g, getClaimsUser as h, isAuthTransportFailure as i, isSignedOutError as j, isUnverifiableTokenError as k, isUnverifiableTokenRefusal as l, looksLikeJwt as m, profileFromClaims as n, pinnedSigningKeysFor as p, userFromClaims as u };
@@ -74,6 +74,19 @@ declare function isSignedOutError(error: AuthError | {
74
74
  }): boolean;
75
75
  /** The verified claims as a `{ id, ... }` user, or `null` when there are none. */
76
76
  declare function userFromClaims(claims: JwtPayload | undefined): ApiClaimsUser | null;
77
+ /** Three segments whose header and payload decode as JSON. A malformed token is INVALID, not "no verdict". */
78
+ declare function looksLikeJwt(token: string): boolean;
79
+ /** The profile fields a verified token states (names match the `users` profile row). */
80
+ interface ClaimsProfile {
81
+ id: string;
82
+ email: string | null;
83
+ full_name: string | null;
84
+ avatar_url: string | null;
85
+ /** Only when the token carries it. */
86
+ email_verified?: boolean;
87
+ }
88
+ /** Map verified claims to a profile, or `null` when the token carries no subject. */
89
+ declare function profileFromClaims(claims: Record<string, unknown> | null | undefined): ClaimsProfile | null;
77
90
  /**
78
91
  * GUEST vs ACCOUNT. A signed-in session is a person with an ACCOUNT or a GUEST (anonymous session,
79
92
  * `is_anonymous: true`). Account UI (menu, billing, sign-out, org picker) keys on `hasAccount`.
@@ -135,4 +148,4 @@ declare function createClaimsCache(client: ClaimsCapableClient & {
135
148
  };
136
149
  }, options: ClaimsCacheOptions): () => Promise<ClaimsResult>;
137
150
 
138
- export { type ApiClaimsUser as A, type ClaimsCacheOptions as C, PROJECT_SIGNING_KEYS as P, type ClaimsCapableClient as a, type ClaimsResult as b, PROJECT_SIGNING_KEY_AUTHORITIES as c, accountFlagsFor as d, confirmUnverifiableSession as e, createClaimsCache as f, getClaimsUser as g, isSignedOutError as h, isAuthTransportFailure as i, isUnverifiableTokenError as j, isUnverifiableTokenRefusal as k, pinnedSigningKeysFor as p, userFromClaims as u };
151
+ export { type ApiClaimsUser as A, type ClaimsCacheOptions as C, PROJECT_SIGNING_KEYS as P, type ClaimsCapableClient as a, type ClaimsProfile as b, type ClaimsResult as c, PROJECT_SIGNING_KEY_AUTHORITIES as d, accountFlagsFor as e, confirmUnverifiableSession as f, createClaimsCache as g, getClaimsUser as h, isAuthTransportFailure as i, isSignedOutError as j, isUnverifiableTokenError as k, isUnverifiableTokenRefusal as l, looksLikeJwt as m, profileFromClaims as n, pinnedSigningKeysFor as p, userFromClaims as u };
package/dist/index.cjs CHANGED
@@ -46,10 +46,12 @@ __export(src_exports, {
46
46
  isUnverifiableTokenError: () => isUnverifiableTokenError,
47
47
  isUnverifiableTokenRefusal: () => isUnverifiableTokenRefusal,
48
48
  keyValueStorage: () => keyValueStorage,
49
+ looksLikeJwt: () => looksLikeJwt,
49
50
  memoryStorage: () => memoryStorage,
50
51
  oauthAuthClient: () => oauthAuthClient,
51
52
  onSessionChange: () => onSessionChange,
52
53
  pinnedSigningKeysFor: () => pinnedSigningKeysFor,
54
+ profileFromClaims: () => profileFromClaims,
53
55
  refreshSession: () => refreshSession,
54
56
  sessionCredentials: () => sessionCredentials,
55
57
  signInWithOAuth: () => signInWithOAuth,
@@ -102,6 +104,36 @@ function userFromClaims(claims) {
102
104
  user_metadata: claims.user_metadata ?? {}
103
105
  };
104
106
  }
107
+ function looksLikeJwt(token) {
108
+ const parts = token.split(".");
109
+ if (parts.length !== 3) return false;
110
+ try {
111
+ for (const part of parts.slice(0, 2)) {
112
+ const normalized = part.replace(/-/g, "+").replace(/_/g, "/");
113
+ const binary = atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="));
114
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
115
+ JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
116
+ }
117
+ return true;
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+ function profileFromClaims(claims) {
123
+ if (!claims) return null;
124
+ const text = (value) => typeof value === "string" && value.length > 0 ? value : null;
125
+ const id = text(claims["sub"]);
126
+ if (!id) return null;
127
+ const raw = claims["user_metadata"];
128
+ const meta = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
129
+ return {
130
+ id,
131
+ email: text(claims["email"]),
132
+ full_name: (typeof meta["full_name"] === "string" ? meta["full_name"] : null) ?? (typeof meta["name"] === "string" ? meta["name"] : null),
133
+ avatar_url: typeof meta["avatar_url"] === "string" ? meta["avatar_url"] : null,
134
+ ...typeof meta["email_verified"] === "boolean" && { email_verified: meta["email_verified"] }
135
+ };
136
+ }
105
137
  function accountFlagsFor(claims) {
106
138
  if (!claims) return { isGuest: false, hasAccount: false };
107
139
  const isGuest = claims.is_anonymous === true;
package/dist/index.d.cts CHANGED
@@ -1,11 +1,11 @@
1
- import { H as HeldOrganizationStore, A as AuthClient } from './active-organization-DHitxVeN.cjs';
2
- export { S as SessionSourceLike, a as SignInError, U as UserCredentialsPort, g as getPerson, b as getSession, i as invalidatePerson, o as onSessionChange, r as refreshSession, s as sessionCredentials, c as signInWithOAuth, d as signInWithPassword, e as signOut, u as updatePerson, f as userCredentials } from './active-organization-DHitxVeN.cjs';
1
+ import { H as HeldOrganizationStore, A as AuthClient } from './active-organization-B6s9ONIe.cjs';
2
+ export { S as SessionSourceLike, a as SignInError, U as UserCredentialsPort, g as getPerson, b as getSession, i as invalidatePerson, o as onSessionChange, r as refreshSession, s as sessionCredentials, c as signInWithOAuth, d as signInWithPassword, e as signOut, u as updatePerson, f as userCredentials } from './active-organization-B6s9ONIe.cjs';
3
3
  import { S as SessionStorage, M as MatrxOAuthClient } from './matrx-oauth-_Ji8X5Wm.cjs';
4
4
  export { C as ChromeStorageAreaLike, a as StorageUnavailableError, b as browserStorage, e as extensionStorage, k as keyValueStorage, m as memoryStorage } from './matrx-oauth-_Ji8X5Wm.cjs';
5
5
  import { SupabaseClient, User } from '@supabase/supabase-js';
6
6
  export { AuthChangeEvent, Session, User, UserAttributes } from '@supabase/supabase-js';
7
7
  export { CredentialsPort, MatrxCredential, MatrxCredentialKind } from './credentials.cjs';
8
- export { A as ApiClaimsUser, C as ClaimsCacheOptions, a as ClaimsCapableClient, b as ClaimsResult, P as PROJECT_SIGNING_KEYS, c as PROJECT_SIGNING_KEY_AUTHORITIES, d as accountFlagsFor, e as confirmUnverifiableSession, f as createClaimsCache, g as getClaimsUser, i as isAuthTransportFailure, h as isSignedOutError, j as isUnverifiableTokenError, k as isUnverifiableTokenRefusal, p as pinnedSigningKeysFor, u as userFromClaims } from './claims-DXdbe3cW.cjs';
8
+ export { A as ApiClaimsUser, C as ClaimsCacheOptions, a as ClaimsCapableClient, b as ClaimsProfile, c as ClaimsResult, P as PROJECT_SIGNING_KEYS, d as PROJECT_SIGNING_KEY_AUTHORITIES, e as accountFlagsFor, f as confirmUnverifiableSession, g as createClaimsCache, h as getClaimsUser, i as isAuthTransportFailure, j as isSignedOutError, k as isUnverifiableTokenError, l as isUnverifiableTokenRefusal, m as looksLikeJwt, p as pinnedSigningKeysFor, n as profileFromClaims, u as userFromClaims } from './claims-KrRxsfaG.cjs';
9
9
 
10
10
  /** How long a request may wait for the first auth report before going anyway. */
11
11
  declare const SESSION_ATTACH_BUDGET_MS = 3000;
package/dist/index.d.ts CHANGED
@@ -1,11 +1,11 @@
1
- import { H as HeldOrganizationStore, A as AuthClient } from './active-organization-DHitxVeN.js';
2
- export { S as SessionSourceLike, a as SignInError, U as UserCredentialsPort, g as getPerson, b as getSession, i as invalidatePerson, o as onSessionChange, r as refreshSession, s as sessionCredentials, c as signInWithOAuth, d as signInWithPassword, e as signOut, u as updatePerson, f as userCredentials } from './active-organization-DHitxVeN.js';
1
+ import { H as HeldOrganizationStore, A as AuthClient } from './active-organization-B6s9ONIe.js';
2
+ export { S as SessionSourceLike, a as SignInError, U as UserCredentialsPort, g as getPerson, b as getSession, i as invalidatePerson, o as onSessionChange, r as refreshSession, s as sessionCredentials, c as signInWithOAuth, d as signInWithPassword, e as signOut, u as updatePerson, f as userCredentials } from './active-organization-B6s9ONIe.js';
3
3
  import { S as SessionStorage, M as MatrxOAuthClient } from './matrx-oauth-_Ji8X5Wm.js';
4
4
  export { C as ChromeStorageAreaLike, a as StorageUnavailableError, b as browserStorage, e as extensionStorage, k as keyValueStorage, m as memoryStorage } from './matrx-oauth-_Ji8X5Wm.js';
5
5
  import { SupabaseClient, User } from '@supabase/supabase-js';
6
6
  export { AuthChangeEvent, Session, User, UserAttributes } from '@supabase/supabase-js';
7
7
  export { CredentialsPort, MatrxCredential, MatrxCredentialKind } from './credentials.js';
8
- export { A as ApiClaimsUser, C as ClaimsCacheOptions, a as ClaimsCapableClient, b as ClaimsResult, P as PROJECT_SIGNING_KEYS, c as PROJECT_SIGNING_KEY_AUTHORITIES, d as accountFlagsFor, e as confirmUnverifiableSession, f as createClaimsCache, g as getClaimsUser, i as isAuthTransportFailure, h as isSignedOutError, j as isUnverifiableTokenError, k as isUnverifiableTokenRefusal, p as pinnedSigningKeysFor, u as userFromClaims } from './claims-DXdbe3cW.js';
8
+ export { A as ApiClaimsUser, C as ClaimsCacheOptions, a as ClaimsCapableClient, b as ClaimsProfile, c as ClaimsResult, P as PROJECT_SIGNING_KEYS, d as PROJECT_SIGNING_KEY_AUTHORITIES, e as accountFlagsFor, f as confirmUnverifiableSession, g as createClaimsCache, h as getClaimsUser, i as isAuthTransportFailure, j as isSignedOutError, k as isUnverifiableTokenError, l as isUnverifiableTokenRefusal, m as looksLikeJwt, p as pinnedSigningKeysFor, n as profileFromClaims, u as userFromClaims } from './claims-KrRxsfaG.js';
9
9
 
10
10
  /** How long a request may wait for the first auth report before going anyway. */
11
11
  declare const SESSION_ATTACH_BUDGET_MS = 3000;
package/dist/index.js CHANGED
@@ -41,6 +41,36 @@ function userFromClaims(claims) {
41
41
  user_metadata: claims.user_metadata ?? {}
42
42
  };
43
43
  }
44
+ function looksLikeJwt(token) {
45
+ const parts = token.split(".");
46
+ if (parts.length !== 3) return false;
47
+ try {
48
+ for (const part of parts.slice(0, 2)) {
49
+ const normalized = part.replace(/-/g, "+").replace(/_/g, "/");
50
+ const binary = atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="));
51
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
52
+ JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
53
+ }
54
+ return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+ function profileFromClaims(claims) {
60
+ if (!claims) return null;
61
+ const text = (value) => typeof value === "string" && value.length > 0 ? value : null;
62
+ const id = text(claims["sub"]);
63
+ if (!id) return null;
64
+ const raw = claims["user_metadata"];
65
+ const meta = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
66
+ return {
67
+ id,
68
+ email: text(claims["email"]),
69
+ full_name: (typeof meta["full_name"] === "string" ? meta["full_name"] : null) ?? (typeof meta["name"] === "string" ? meta["name"] : null),
70
+ avatar_url: typeof meta["avatar_url"] === "string" ? meta["avatar_url"] : null,
71
+ ...typeof meta["email_verified"] === "boolean" && { email_verified: meta["email_verified"] }
72
+ };
73
+ }
44
74
  function accountFlagsFor(claims) {
45
75
  if (!claims) return { isGuest: false, hasAccount: false };
46
76
  const isGuest = claims.is_anonymous === true;
@@ -726,10 +756,12 @@ export {
726
756
  isUnverifiableTokenError,
727
757
  isUnverifiableTokenRefusal,
728
758
  keyValueStorage,
759
+ looksLikeJwt,
729
760
  memoryStorage,
730
761
  oauthAuthClient,
731
762
  onSessionChange,
732
763
  pinnedSigningKeysFor,
764
+ profileFromClaims,
733
765
  refreshSession,
734
766
  sessionCredentials,
735
767
  signInWithOAuth,
package/dist/next.d.cts CHANGED
@@ -2,7 +2,7 @@ import { createServerClient, createBrowserClient } from '@supabase/ssr';
2
2
  import { b as AuthCookieValue, A as AuthCookie, a as AuthCookieOptions } from './auth-cookie-DWx6Q3xo.cjs';
3
3
  export { C as CreateAuthCookieArgs, D as DEFAULT_AUTH_COOKIE_NAME, c as createAuthCookie } from './auth-cookie-DWx6Q3xo.cjs';
4
4
  import { SupabaseClient } from '@supabase/supabase-js';
5
- import { a as ClaimsCapableClient, A as ApiClaimsUser } from './claims-DXdbe3cW.cjs';
5
+ import { a as ClaimsCapableClient, A as ApiClaimsUser } from './claims-KrRxsfaG.cjs';
6
6
 
7
7
  /**
8
8
  * @ai-matrx/data/next — the Next.js Supabase binding.
package/dist/next.d.ts CHANGED
@@ -2,7 +2,7 @@ import { createServerClient, createBrowserClient } from '@supabase/ssr';
2
2
  import { b as AuthCookieValue, A as AuthCookie, a as AuthCookieOptions } from './auth-cookie-DWx6Q3xo.js';
3
3
  export { C as CreateAuthCookieArgs, D as DEFAULT_AUTH_COOKIE_NAME, c as createAuthCookie } from './auth-cookie-DWx6Q3xo.js';
4
4
  import { SupabaseClient } from '@supabase/supabase-js';
5
- import { a as ClaimsCapableClient, A as ApiClaimsUser } from './claims-DXdbe3cW.js';
5
+ import { a as ClaimsCapableClient, A as ApiClaimsUser } from './claims-KrRxsfaG.js';
6
6
 
7
7
  /**
8
8
  * @ai-matrx/data/next — the Next.js Supabase binding.
@@ -52,21 +52,25 @@ __export(organizations_exports, {
52
52
  oldestMembership: () => oldestMembership,
53
53
  orderOrganizations: () => orderOrganizations,
54
54
  organizationMatches: () => organizationMatches,
55
+ organizationReadFailureReason: () => organizationReadFailureReason,
55
56
  organizationRequired: () => organizationRequired,
56
57
  organizationRequiredEnvelope: () => organizationRequiredEnvelope,
57
58
  organizationRequiredResponse: () => organizationRequiredResponse,
59
+ organizationWasReplaced: () => organizationWasReplaced,
58
60
  readAccountOrganizationChoices: () => readAccountOrganizationChoices,
59
61
  readCallerMemberships: () => readCallerMemberships,
60
62
  readLinkOrganizationParam: () => readLinkOrganizationParam,
61
63
  readMembershipRows: () => readMembershipRows,
62
64
  readMemberships: () => readMemberships,
63
65
  readOrganizationFavourites: () => readOrganizationFavourites,
66
+ reconcileRehydratedOrganization: () => reconcileRehydratedOrganization,
64
67
  resolveActiveOrganization: () => resolveActiveOrganization,
65
68
  resolveActiveOrganizationLazily: () => resolveActiveOrganizationLazily,
66
69
  resolveAdminSeat: () => resolveAdminSeat,
67
70
  resolveChildOrganizationId: () => resolveChildOrganizationId,
68
71
  resolveOrganizationWithLink: () => resolveOrganizationWithLink,
69
72
  setOrganizationFavourite: () => setOrganizationFavourite,
73
+ shouldPromptForOrganization: () => shouldPromptForOrganization,
70
74
  storageHeldOrganization: () => storageHeldOrganization,
71
75
  toAccountOrganizationChoices: () => toAccountOrganizationChoices,
72
76
  writeLastActiveOrganization: () => writeLastActiveOrganization,
@@ -579,6 +583,7 @@ function createActiveOrganization(options) {
579
583
  const memberships = options.accountChoices ? orderOrganizations(rawMemberships, favourites) : rawMemberships;
580
584
  const readAccount = () => account ? Promise.resolve(account) : readAccountOrganizationChoices(client, person.id);
581
585
  const before = prior;
586
+ const heldAtStart = options.clearStaleHeld ? await held.read(person.id) : null;
582
587
  const linkOptions = options.link;
583
588
  let resolved;
584
589
  let linkDecision = null;
@@ -605,6 +610,9 @@ function createActiveOrganization(options) {
605
610
  });
606
611
  }
607
612
  if (gen !== generation) return current;
613
+ if (heldAtStart && !memberships.some((m) => m.id === heldAtStart) && await held.read(person.id) === heldAtStart) {
614
+ await held.clear();
615
+ }
608
616
  if (!resolved) {
609
617
  publish({ ...IDLE, status: "none", userId: person.id, error: "no-memberships" });
610
618
  return current;
@@ -874,3 +882,37 @@ async function readMembershipRows(db, containerType, pageSize = MEMBERSHIP_ROWS_
874
882
  }
875
883
  return { data: rows, error: null };
876
884
  }
885
+
886
+ // src/organization-state.ts
887
+ function shouldPromptForOrganization(input) {
888
+ return input.resolved === true && (input.organizationId ?? null) == null && (input.failure ?? null) == null;
889
+ }
890
+ function organizationReadFailureReason(error) {
891
+ return error instanceof Error && error.message ? `the organization read failed: ${error.message}` : "the organization read failed";
892
+ }
893
+ function organizationWasReplaced(input) {
894
+ return Boolean(input.held && input.chosen && input.chosen !== input.held && input.linkKind !== "honoured");
895
+ }
896
+ function reconcileRehydratedOrganization(current, loaded) {
897
+ const fromLadder = loaded.orgBootstrapResolved === true;
898
+ if (!fromLadder) {
899
+ if (current.organization_id == null && loaded.organization_id) {
900
+ return { organization_id: loaded.organization_id, organization_name: loaded.organization_name ?? null };
901
+ }
902
+ return {};
903
+ }
904
+ const heldHere = current.orgBootstrapResolved && current.organization_id != null;
905
+ const patch = { orgBootstrapResolved: true };
906
+ if (loaded.orgBootstrapFailure !== void 0) {
907
+ patch.orgBootstrapFailure = loaded.organization_id != null ? null : loaded.orgBootstrapFailure;
908
+ }
909
+ if (loaded.organization_id === void 0) return patch;
910
+ if (heldHere && loaded.organization_id === current.organization_id) {
911
+ if (loaded.organization_name != null) patch.organization_name = loaded.organization_name;
912
+ return patch;
913
+ }
914
+ if (heldHere) return patch;
915
+ patch.organization_id = loaded.organization_id;
916
+ patch.organization_name = loaded.organization_name ?? null;
917
+ return patch;
918
+ }
@@ -1,5 +1,5 @@
1
- import { O as OrganizationsDb } from './active-organization-DHitxVeN.cjs';
2
- export { h as AccountOrganizationChoices, j as ActiveOrganization, k as ActiveOrganizationSnapshot, l as ActiveOrganizationStatus, m as AdminSeat, n as AdminSeatInput, p as ArchiveFilter, C as CachedActiveOrganization, q as CreateActiveOrganizationOptions, H as HeldOrganizationStore, L as LINK_ORGANIZATION_QUERY_KEY, t as LadderInput, v as LinkOrganizationDecision, w as LinkOrganizationDecisionInput, x as LinkOrganizationMembership, y as LinkOrganizationMessages, z as LinkOrganizationParam, N as NOT_A_MEMBER_RECHECK_GAP_MS, B as NoActiveOrganizationError, D as NotAMemberError, E as NotAMemberRefusalNotice, F as ORGANIZATION_ENTITY_TYPE, G as ORGANIZATION_RESOLUTION_TIMEOUT_MS, I as OrganizationBootstrapGate, J as OrganizationLapse, K as OrganizationMembership, M as OrganizationRefusal, P as OrganizationRung, Q as ParentOrganization, R as ReadMembershipsOptions, T as ResolveWithLinkInput, V as ResolvedOrganization, W as ResolvedWithLink, X as adminSeatReplacesUserContext, Y as classifyLinkOrganizationValue, Z as classifyOrganizationRefusal, _ as createActiveOrganization, $ as createNotAMemberRefusalNotice, a0 as createOrganizationBootstrapGate, a1 as decideLinkOrganization, a2 as isNotAMemberRefusal, a3 as linkOrganizationMalformedMessage, a4 as linkOrganizationNotAMemberMessage, a5 as linkOrganizationOfferMessage, a6 as linkOrganizationSwitchAnnouncement, a7 as memoryHeldOrganization, a8 as oldestMembership, a9 as orderOrganizations, aa as organizationMatches, ab as readAccountOrganizationChoices, ac as readLinkOrganizationParam, ad as readMemberships, ae as readOrganizationFavourites, af as resolveActiveOrganization, ag as resolveActiveOrganizationLazily, ah as resolveAdminSeat, ai as resolveChildOrganizationId, aj as resolveOrganizationWithLink, ak as setOrganizationFavourite, al as storageHeldOrganization, am as toAccountOrganizationChoices, an as writeLastActiveOrganization, ao as writeStartupOrganization } from './active-organization-DHitxVeN.cjs';
1
+ import { O as OrganizationsDb } from './active-organization-B6s9ONIe.cjs';
2
+ export { h as AccountOrganizationChoices, j as ActiveOrganization, k as ActiveOrganizationSnapshot, l as ActiveOrganizationStatus, m as AdminSeat, n as AdminSeatInput, p as ArchiveFilter, C as CachedActiveOrganization, q as CreateActiveOrganizationOptions, H as HeldOrganizationStore, L as LINK_ORGANIZATION_QUERY_KEY, t as LadderInput, v as LinkOrganizationDecision, w as LinkOrganizationDecisionInput, x as LinkOrganizationMembership, y as LinkOrganizationMessages, z as LinkOrganizationParam, N as NOT_A_MEMBER_RECHECK_GAP_MS, B as NoActiveOrganizationError, D as NotAMemberError, E as NotAMemberRefusalNotice, F as ORGANIZATION_ENTITY_TYPE, G as ORGANIZATION_RESOLUTION_TIMEOUT_MS, I as OrganizationBootstrapGate, J as OrganizationLapse, K as OrganizationMembership, M as OrganizationRefusal, P as OrganizationRung, Q as ParentOrganization, R as ReadMembershipsOptions, T as ResolveWithLinkInput, V as ResolvedOrganization, W as ResolvedWithLink, X as adminSeatReplacesUserContext, Y as classifyLinkOrganizationValue, Z as classifyOrganizationRefusal, _ as createActiveOrganization, $ as createNotAMemberRefusalNotice, a0 as createOrganizationBootstrapGate, a1 as decideLinkOrganization, a2 as isNotAMemberRefusal, a3 as linkOrganizationMalformedMessage, a4 as linkOrganizationNotAMemberMessage, a5 as linkOrganizationOfferMessage, a6 as linkOrganizationSwitchAnnouncement, a7 as memoryHeldOrganization, a8 as oldestMembership, a9 as orderOrganizations, aa as organizationMatches, ab as readAccountOrganizationChoices, ac as readLinkOrganizationParam, ad as readMemberships, ae as readOrganizationFavourites, af as resolveActiveOrganization, ag as resolveActiveOrganizationLazily, ah as resolveAdminSeat, ai as resolveChildOrganizationId, aj as resolveOrganizationWithLink, ak as setOrganizationFavourite, al as storageHeldOrganization, am as toAccountOrganizationChoices, an as writeLastActiveOrganization, ao as writeStartupOrganization } from './active-organization-B6s9ONIe.cjs';
3
3
  import '@supabase/supabase-js';
4
4
 
5
5
  /**
@@ -130,4 +130,48 @@ interface MembershipRowsAnswer<Row> {
130
130
  }
131
131
  declare function readMembershipRows<Row = Record<string, unknown>>(db: OrganizationsDb, containerType: string, pageSize?: number): Promise<MembershipRowsAnswer<Row>>;
132
132
 
133
- export { MEMBERSHIPS_URL, MEMBERSHIP_ROWS_PAGE, type MembershipReadClient, type MembershipRowsAnswer, ORGANIZATION_CONTEXT_REQUIRED_CODE, ORGANIZATION_REQUIRED_CODE, ORGANIZATION_REQUIRED_WIRE_CODE, type OrganizationHoldDetails, type OrganizationHoldSetOn, type OrganizationMembershipSummary, type OrganizationRequiredEnvelope, OrganizationRequiredServerError, type OrganizationRequiredWireMembership, OrganizationsDb, extractOrganizationHoldMemberships, isOrganizationRequiredEnvelope, isOrganizationRequiredError, isOrganizationRequiredServerError, organizationRequired, organizationRequiredEnvelope, organizationRequiredResponse, readCallerMemberships, readMembershipRows };
133
+ /**
134
+ * The DECISIONS an app's organization store used to make for itself (moved from the website's
135
+ * `shouldPromptForOrganization`, `appContextSlice` rehydrate reducer and `activeOrgBootstrap`
136
+ * thunk). Pure: an app's Redux/zustand slice only calls these and assigns what they return.
137
+ */
138
+ /** The organization fields an app keeps in its store. */
139
+ interface OrganizationStoreFields {
140
+ readonly organization_id: string | null;
141
+ readonly organization_name: string | null;
142
+ readonly orgBootstrapResolved: boolean;
143
+ readonly orgBootstrapFailure: string | null;
144
+ }
145
+ /**
146
+ * True when the UI should nudge the person to choose an organization: the bootstrap has resolved,
147
+ * none is selected, AND the read of their memberships succeeded. A failed read is never the nudge
148
+ * ("we could not check" speaks instead) — a member of thirteen organizations was once told to pick
149
+ * one after a single failed fetch.
150
+ */
151
+ declare function shouldPromptForOrganization(input: {
152
+ readonly resolved: boolean;
153
+ readonly organizationId: string | null | undefined;
154
+ readonly failure: string | null | undefined;
155
+ }): boolean;
156
+ /** The words for a failed membership read — one sentence for every host. */
157
+ declare function organizationReadFailureReason(error: unknown): string;
158
+ /**
159
+ * A held organization was taken away (removed, archived) and the ladder chose another, and the
160
+ * choice is not the link's own: switch, and say so.
161
+ */
162
+ declare function organizationWasReplaced(input: {
163
+ readonly held: string | null | undefined;
164
+ readonly chosen: string | null | undefined;
165
+ readonly linkKind?: string | undefined;
166
+ }): boolean;
167
+ /**
168
+ * What a REHYDRATE does to the organization fields. Two kinds:
169
+ * - the browser CACHE (no `orgBootstrapResolved`) only PAINTS an empty store and never marks the
170
+ * question answered, so no request leaves on it;
171
+ * - the LOAD LADDER's answer (`orgBootstrapResolved: true`) is the account's and wins over the
172
+ * painted cache — unless this store already holds an organization the person chose here.
173
+ * Returns the fields to assign (empty = change nothing).
174
+ */
175
+ declare function reconcileRehydratedOrganization(current: Pick<OrganizationStoreFields, "organization_id" | "orgBootstrapResolved">, loaded: Partial<OrganizationStoreFields>): Partial<OrganizationStoreFields>;
176
+
177
+ export { MEMBERSHIPS_URL, MEMBERSHIP_ROWS_PAGE, type MembershipReadClient, type MembershipRowsAnswer, ORGANIZATION_CONTEXT_REQUIRED_CODE, ORGANIZATION_REQUIRED_CODE, ORGANIZATION_REQUIRED_WIRE_CODE, type OrganizationHoldDetails, type OrganizationHoldSetOn, type OrganizationMembershipSummary, type OrganizationRequiredEnvelope, OrganizationRequiredServerError, type OrganizationRequiredWireMembership, type OrganizationStoreFields, OrganizationsDb, extractOrganizationHoldMemberships, isOrganizationRequiredEnvelope, isOrganizationRequiredError, isOrganizationRequiredServerError, organizationReadFailureReason, organizationRequired, organizationRequiredEnvelope, organizationRequiredResponse, organizationWasReplaced, readCallerMemberships, readMembershipRows, reconcileRehydratedOrganization, shouldPromptForOrganization };
@@ -1,5 +1,5 @@
1
- import { O as OrganizationsDb } from './active-organization-DHitxVeN.js';
2
- export { h as AccountOrganizationChoices, j as ActiveOrganization, k as ActiveOrganizationSnapshot, l as ActiveOrganizationStatus, m as AdminSeat, n as AdminSeatInput, p as ArchiveFilter, C as CachedActiveOrganization, q as CreateActiveOrganizationOptions, H as HeldOrganizationStore, L as LINK_ORGANIZATION_QUERY_KEY, t as LadderInput, v as LinkOrganizationDecision, w as LinkOrganizationDecisionInput, x as LinkOrganizationMembership, y as LinkOrganizationMessages, z as LinkOrganizationParam, N as NOT_A_MEMBER_RECHECK_GAP_MS, B as NoActiveOrganizationError, D as NotAMemberError, E as NotAMemberRefusalNotice, F as ORGANIZATION_ENTITY_TYPE, G as ORGANIZATION_RESOLUTION_TIMEOUT_MS, I as OrganizationBootstrapGate, J as OrganizationLapse, K as OrganizationMembership, M as OrganizationRefusal, P as OrganizationRung, Q as ParentOrganization, R as ReadMembershipsOptions, T as ResolveWithLinkInput, V as ResolvedOrganization, W as ResolvedWithLink, X as adminSeatReplacesUserContext, Y as classifyLinkOrganizationValue, Z as classifyOrganizationRefusal, _ as createActiveOrganization, $ as createNotAMemberRefusalNotice, a0 as createOrganizationBootstrapGate, a1 as decideLinkOrganization, a2 as isNotAMemberRefusal, a3 as linkOrganizationMalformedMessage, a4 as linkOrganizationNotAMemberMessage, a5 as linkOrganizationOfferMessage, a6 as linkOrganizationSwitchAnnouncement, a7 as memoryHeldOrganization, a8 as oldestMembership, a9 as orderOrganizations, aa as organizationMatches, ab as readAccountOrganizationChoices, ac as readLinkOrganizationParam, ad as readMemberships, ae as readOrganizationFavourites, af as resolveActiveOrganization, ag as resolveActiveOrganizationLazily, ah as resolveAdminSeat, ai as resolveChildOrganizationId, aj as resolveOrganizationWithLink, ak as setOrganizationFavourite, al as storageHeldOrganization, am as toAccountOrganizationChoices, an as writeLastActiveOrganization, ao as writeStartupOrganization } from './active-organization-DHitxVeN.js';
1
+ import { O as OrganizationsDb } from './active-organization-B6s9ONIe.js';
2
+ export { h as AccountOrganizationChoices, j as ActiveOrganization, k as ActiveOrganizationSnapshot, l as ActiveOrganizationStatus, m as AdminSeat, n as AdminSeatInput, p as ArchiveFilter, C as CachedActiveOrganization, q as CreateActiveOrganizationOptions, H as HeldOrganizationStore, L as LINK_ORGANIZATION_QUERY_KEY, t as LadderInput, v as LinkOrganizationDecision, w as LinkOrganizationDecisionInput, x as LinkOrganizationMembership, y as LinkOrganizationMessages, z as LinkOrganizationParam, N as NOT_A_MEMBER_RECHECK_GAP_MS, B as NoActiveOrganizationError, D as NotAMemberError, E as NotAMemberRefusalNotice, F as ORGANIZATION_ENTITY_TYPE, G as ORGANIZATION_RESOLUTION_TIMEOUT_MS, I as OrganizationBootstrapGate, J as OrganizationLapse, K as OrganizationMembership, M as OrganizationRefusal, P as OrganizationRung, Q as ParentOrganization, R as ReadMembershipsOptions, T as ResolveWithLinkInput, V as ResolvedOrganization, W as ResolvedWithLink, X as adminSeatReplacesUserContext, Y as classifyLinkOrganizationValue, Z as classifyOrganizationRefusal, _ as createActiveOrganization, $ as createNotAMemberRefusalNotice, a0 as createOrganizationBootstrapGate, a1 as decideLinkOrganization, a2 as isNotAMemberRefusal, a3 as linkOrganizationMalformedMessage, a4 as linkOrganizationNotAMemberMessage, a5 as linkOrganizationOfferMessage, a6 as linkOrganizationSwitchAnnouncement, a7 as memoryHeldOrganization, a8 as oldestMembership, a9 as orderOrganizations, aa as organizationMatches, ab as readAccountOrganizationChoices, ac as readLinkOrganizationParam, ad as readMemberships, ae as readOrganizationFavourites, af as resolveActiveOrganization, ag as resolveActiveOrganizationLazily, ah as resolveAdminSeat, ai as resolveChildOrganizationId, aj as resolveOrganizationWithLink, ak as setOrganizationFavourite, al as storageHeldOrganization, am as toAccountOrganizationChoices, an as writeLastActiveOrganization, ao as writeStartupOrganization } from './active-organization-B6s9ONIe.js';
3
3
  import '@supabase/supabase-js';
4
4
 
5
5
  /**
@@ -130,4 +130,48 @@ interface MembershipRowsAnswer<Row> {
130
130
  }
131
131
  declare function readMembershipRows<Row = Record<string, unknown>>(db: OrganizationsDb, containerType: string, pageSize?: number): Promise<MembershipRowsAnswer<Row>>;
132
132
 
133
- export { MEMBERSHIPS_URL, MEMBERSHIP_ROWS_PAGE, type MembershipReadClient, type MembershipRowsAnswer, ORGANIZATION_CONTEXT_REQUIRED_CODE, ORGANIZATION_REQUIRED_CODE, ORGANIZATION_REQUIRED_WIRE_CODE, type OrganizationHoldDetails, type OrganizationHoldSetOn, type OrganizationMembershipSummary, type OrganizationRequiredEnvelope, OrganizationRequiredServerError, type OrganizationRequiredWireMembership, OrganizationsDb, extractOrganizationHoldMemberships, isOrganizationRequiredEnvelope, isOrganizationRequiredError, isOrganizationRequiredServerError, organizationRequired, organizationRequiredEnvelope, organizationRequiredResponse, readCallerMemberships, readMembershipRows };
133
+ /**
134
+ * The DECISIONS an app's organization store used to make for itself (moved from the website's
135
+ * `shouldPromptForOrganization`, `appContextSlice` rehydrate reducer and `activeOrgBootstrap`
136
+ * thunk). Pure: an app's Redux/zustand slice only calls these and assigns what they return.
137
+ */
138
+ /** The organization fields an app keeps in its store. */
139
+ interface OrganizationStoreFields {
140
+ readonly organization_id: string | null;
141
+ readonly organization_name: string | null;
142
+ readonly orgBootstrapResolved: boolean;
143
+ readonly orgBootstrapFailure: string | null;
144
+ }
145
+ /**
146
+ * True when the UI should nudge the person to choose an organization: the bootstrap has resolved,
147
+ * none is selected, AND the read of their memberships succeeded. A failed read is never the nudge
148
+ * ("we could not check" speaks instead) — a member of thirteen organizations was once told to pick
149
+ * one after a single failed fetch.
150
+ */
151
+ declare function shouldPromptForOrganization(input: {
152
+ readonly resolved: boolean;
153
+ readonly organizationId: string | null | undefined;
154
+ readonly failure: string | null | undefined;
155
+ }): boolean;
156
+ /** The words for a failed membership read — one sentence for every host. */
157
+ declare function organizationReadFailureReason(error: unknown): string;
158
+ /**
159
+ * A held organization was taken away (removed, archived) and the ladder chose another, and the
160
+ * choice is not the link's own: switch, and say so.
161
+ */
162
+ declare function organizationWasReplaced(input: {
163
+ readonly held: string | null | undefined;
164
+ readonly chosen: string | null | undefined;
165
+ readonly linkKind?: string | undefined;
166
+ }): boolean;
167
+ /**
168
+ * What a REHYDRATE does to the organization fields. Two kinds:
169
+ * - the browser CACHE (no `orgBootstrapResolved`) only PAINTS an empty store and never marks the
170
+ * question answered, so no request leaves on it;
171
+ * - the LOAD LADDER's answer (`orgBootstrapResolved: true`) is the account's and wins over the
172
+ * painted cache — unless this store already holds an organization the person chose here.
173
+ * Returns the fields to assign (empty = change nothing).
174
+ */
175
+ declare function reconcileRehydratedOrganization(current: Pick<OrganizationStoreFields, "organization_id" | "orgBootstrapResolved">, loaded: Partial<OrganizationStoreFields>): Partial<OrganizationStoreFields>;
176
+
177
+ export { MEMBERSHIPS_URL, MEMBERSHIP_ROWS_PAGE, type MembershipReadClient, type MembershipRowsAnswer, ORGANIZATION_CONTEXT_REQUIRED_CODE, ORGANIZATION_REQUIRED_CODE, ORGANIZATION_REQUIRED_WIRE_CODE, type OrganizationHoldDetails, type OrganizationHoldSetOn, type OrganizationMembershipSummary, type OrganizationRequiredEnvelope, OrganizationRequiredServerError, type OrganizationRequiredWireMembership, type OrganizationStoreFields, OrganizationsDb, extractOrganizationHoldMemberships, isOrganizationRequiredEnvelope, isOrganizationRequiredError, isOrganizationRequiredServerError, organizationReadFailureReason, organizationRequired, organizationRequiredEnvelope, organizationRequiredResponse, organizationWasReplaced, readCallerMemberships, readMembershipRows, reconcileRehydratedOrganization, shouldPromptForOrganization };
@@ -503,6 +503,7 @@ function createActiveOrganization(options) {
503
503
  const memberships = options.accountChoices ? orderOrganizations(rawMemberships, favourites) : rawMemberships;
504
504
  const readAccount = () => account ? Promise.resolve(account) : readAccountOrganizationChoices(client, person.id);
505
505
  const before = prior;
506
+ const heldAtStart = options.clearStaleHeld ? await held.read(person.id) : null;
506
507
  const linkOptions = options.link;
507
508
  let resolved;
508
509
  let linkDecision = null;
@@ -529,6 +530,9 @@ function createActiveOrganization(options) {
529
530
  });
530
531
  }
531
532
  if (gen !== generation) return current;
533
+ if (heldAtStart && !memberships.some((m) => m.id === heldAtStart) && await held.read(person.id) === heldAtStart) {
534
+ await held.clear();
535
+ }
532
536
  if (!resolved) {
533
537
  publish({ ...IDLE, status: "none", userId: person.id, error: "no-memberships" });
534
538
  return current;
@@ -798,6 +802,40 @@ async function readMembershipRows(db, containerType, pageSize = MEMBERSHIP_ROWS_
798
802
  }
799
803
  return { data: rows, error: null };
800
804
  }
805
+
806
+ // src/organization-state.ts
807
+ function shouldPromptForOrganization(input) {
808
+ return input.resolved === true && (input.organizationId ?? null) == null && (input.failure ?? null) == null;
809
+ }
810
+ function organizationReadFailureReason(error) {
811
+ return error instanceof Error && error.message ? `the organization read failed: ${error.message}` : "the organization read failed";
812
+ }
813
+ function organizationWasReplaced(input) {
814
+ return Boolean(input.held && input.chosen && input.chosen !== input.held && input.linkKind !== "honoured");
815
+ }
816
+ function reconcileRehydratedOrganization(current, loaded) {
817
+ const fromLadder = loaded.orgBootstrapResolved === true;
818
+ if (!fromLadder) {
819
+ if (current.organization_id == null && loaded.organization_id) {
820
+ return { organization_id: loaded.organization_id, organization_name: loaded.organization_name ?? null };
821
+ }
822
+ return {};
823
+ }
824
+ const heldHere = current.orgBootstrapResolved && current.organization_id != null;
825
+ const patch = { orgBootstrapResolved: true };
826
+ if (loaded.orgBootstrapFailure !== void 0) {
827
+ patch.orgBootstrapFailure = loaded.organization_id != null ? null : loaded.orgBootstrapFailure;
828
+ }
829
+ if (loaded.organization_id === void 0) return patch;
830
+ if (heldHere && loaded.organization_id === current.organization_id) {
831
+ if (loaded.organization_name != null) patch.organization_name = loaded.organization_name;
832
+ return patch;
833
+ }
834
+ if (heldHere) return patch;
835
+ patch.organization_id = loaded.organization_id;
836
+ patch.organization_name = loaded.organization_name ?? null;
837
+ return patch;
838
+ }
801
839
  export {
802
840
  LINK_ORGANIZATION_QUERY_KEY,
803
841
  MEMBERSHIPS_URL,
@@ -831,21 +869,25 @@ export {
831
869
  oldestMembership,
832
870
  orderOrganizations,
833
871
  organizationMatches,
872
+ organizationReadFailureReason,
834
873
  organizationRequired,
835
874
  organizationRequiredEnvelope,
836
875
  organizationRequiredResponse,
876
+ organizationWasReplaced,
837
877
  readAccountOrganizationChoices,
838
878
  readCallerMemberships,
839
879
  readLinkOrganizationParam,
840
880
  readMembershipRows,
841
881
  readMemberships,
842
882
  readOrganizationFavourites,
883
+ reconcileRehydratedOrganization,
843
884
  resolveActiveOrganization,
844
885
  resolveActiveOrganizationLazily,
845
886
  resolveAdminSeat,
846
887
  resolveChildOrganizationId,
847
888
  resolveOrganizationWithLink,
848
889
  setOrganizationFavourite,
890
+ shouldPromptForOrganization,
849
891
  storageHeldOrganization,
850
892
  toAccountOrganizationChoices,
851
893
  writeLastActiveOrganization,
package/dist/react.cjs CHANGED
@@ -23,6 +23,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
23
23
  var react_exports = {};
24
24
  __export(react_exports, {
25
25
  useActiveOrganization: () => useActiveOrganization,
26
+ useActiveOrganizationView: () => useActiveOrganizationView,
26
27
  useSession: () => useSession,
27
28
  useSessionIdentity: () => useSessionIdentity
28
29
  });
@@ -206,3 +207,47 @@ function useSessionIdentity(options) {
206
207
  const source = identity ?? NO_IDENTITY;
207
208
  return (0, import_react.useSyncExternalStore)(source.subscribe, source.snapshot, () => SERVER_SESSION_IDENTITY);
208
209
  }
210
+ function useActiveOrganizationView(options) {
211
+ const latest = (0, import_react.useRef)(options);
212
+ latest.current = options;
213
+ const [active, setActive] = (0, import_react.useState)(null);
214
+ const [organizations, setOrganizations] = (0, import_react.useState)([]);
215
+ const [loading, setLoading] = (0, import_react.useState)(true);
216
+ const [error, setError] = (0, import_react.useState)(null);
217
+ const [nonce, setNonce] = (0, import_react.useState)(0);
218
+ const reload = (0, import_react.useRef)(() => setNonce((n) => n + 1)).current;
219
+ (0, import_react.useEffect)(() => {
220
+ let cancelled = false;
221
+ if (!options.signedIn) {
222
+ setActive(null);
223
+ setOrganizations([]);
224
+ setLoading(false);
225
+ setError(null);
226
+ return;
227
+ }
228
+ setLoading(true);
229
+ void (async () => {
230
+ try {
231
+ const [resolved, all] = await Promise.all([latest.current.resolve(), latest.current.list()]);
232
+ if (cancelled) return;
233
+ setActive(resolved);
234
+ setOrganizations(all);
235
+ setError(null);
236
+ } catch (err) {
237
+ if (cancelled) return;
238
+ setError(err instanceof Error ? err.message : String(err));
239
+ } finally {
240
+ if (!cancelled) setLoading(false);
241
+ }
242
+ })();
243
+ return () => {
244
+ cancelled = true;
245
+ };
246
+ }, [options.signedIn, nonce, ...options.deps ?? []]);
247
+ const subscribe = options.subscribe;
248
+ (0, import_react.useEffect)(() => subscribe ? subscribe(reload) : void 0, [subscribe, reload]);
249
+ const choose = (0, import_react.useRef)(async (organizationId) => {
250
+ setActive(await latest.current.choose(organizationId));
251
+ }).current;
252
+ return { active, organizations, loading, error, mustChoose: options.signedIn && !loading && !error && !active, choose, reload };
253
+ }
package/dist/react.d.cts CHANGED
@@ -180,5 +180,36 @@ declare function useActiveOrganization(store: ActiveOrganization): ActiveOrganiz
180
180
  * the first session read answers; StrictMode-safe (the store is made and disposed in an effect).
181
181
  */
182
182
  declare function useSessionIdentity<Id extends string = string>(options: SessionIdentityOptions<Id>): SessionIdentitySnapshot<Id>;
183
+ interface ActiveOrganizationViewOptions<M> {
184
+ /** Nobody signed in: the view is empty and loads nothing. */
185
+ readonly signedIn: boolean;
186
+ /** The organization every request carries now, verified against live membership. Null = must choose. */
187
+ readonly resolve: () => Promise<M | null>;
188
+ /** The organizations to list (the host picks the archive filter). */
189
+ readonly list: () => Promise<readonly M[]>;
190
+ /** Make `organizationId` the active one; answers the organization now active. */
191
+ readonly choose: (organizationId: string) => Promise<M | null>;
192
+ /** Anything that changes what `resolve`/`list` answer (the archive filter, the person). Re-reads when it changes. */
193
+ readonly deps?: readonly unknown[];
194
+ /** Another context moved the active organization: call `reload` when it fires. Returns the unsubscribe. */
195
+ readonly subscribe?: (reload: () => void) => () => void;
196
+ }
197
+ interface ActiveOrganizationView<M> {
198
+ readonly active: M | null;
199
+ readonly organizations: readonly M[];
200
+ readonly loading: boolean;
201
+ /** Non-null when the organizations could not be READ — never presented as "no organization". */
202
+ readonly error: string | null;
203
+ /** True only when signed in, the read succeeded, and no organization is active. */
204
+ readonly mustChoose: boolean;
205
+ readonly choose: (organizationId: string) => Promise<void>;
206
+ readonly reload: () => void;
207
+ }
208
+ /**
209
+ * The state machine every picker/header needs around the active organization: load, error vs none,
210
+ * must-choose, choose, reload. The host injects its reads (so a UI and a request kernel that resolve
211
+ * through the same function can never disagree); this owns the cancellation and the honest states.
212
+ */
213
+ declare function useActiveOrganizationView<M>(options: ActiveOrganizationViewOptions<M>): ActiveOrganizationView<M>;
183
214
 
184
- export { type SessionState, useActiveOrganization, useSession, useSessionIdentity };
215
+ export { type ActiveOrganizationView, type ActiveOrganizationViewOptions, type SessionState, useActiveOrganization, useActiveOrganizationView, useSession, useSessionIdentity };
package/dist/react.d.ts CHANGED
@@ -180,5 +180,36 @@ declare function useActiveOrganization(store: ActiveOrganization): ActiveOrganiz
180
180
  * the first session read answers; StrictMode-safe (the store is made and disposed in an effect).
181
181
  */
182
182
  declare function useSessionIdentity<Id extends string = string>(options: SessionIdentityOptions<Id>): SessionIdentitySnapshot<Id>;
183
+ interface ActiveOrganizationViewOptions<M> {
184
+ /** Nobody signed in: the view is empty and loads nothing. */
185
+ readonly signedIn: boolean;
186
+ /** The organization every request carries now, verified against live membership. Null = must choose. */
187
+ readonly resolve: () => Promise<M | null>;
188
+ /** The organizations to list (the host picks the archive filter). */
189
+ readonly list: () => Promise<readonly M[]>;
190
+ /** Make `organizationId` the active one; answers the organization now active. */
191
+ readonly choose: (organizationId: string) => Promise<M | null>;
192
+ /** Anything that changes what `resolve`/`list` answer (the archive filter, the person). Re-reads when it changes. */
193
+ readonly deps?: readonly unknown[];
194
+ /** Another context moved the active organization: call `reload` when it fires. Returns the unsubscribe. */
195
+ readonly subscribe?: (reload: () => void) => () => void;
196
+ }
197
+ interface ActiveOrganizationView<M> {
198
+ readonly active: M | null;
199
+ readonly organizations: readonly M[];
200
+ readonly loading: boolean;
201
+ /** Non-null when the organizations could not be READ — never presented as "no organization". */
202
+ readonly error: string | null;
203
+ /** True only when signed in, the read succeeded, and no organization is active. */
204
+ readonly mustChoose: boolean;
205
+ readonly choose: (organizationId: string) => Promise<void>;
206
+ readonly reload: () => void;
207
+ }
208
+ /**
209
+ * The state machine every picker/header needs around the active organization: load, error vs none,
210
+ * must-choose, choose, reload. The host injects its reads (so a UI and a request kernel that resolve
211
+ * through the same function can never disagree); this owns the cancellation and the honest states.
212
+ */
213
+ declare function useActiveOrganizationView<M>(options: ActiveOrganizationViewOptions<M>): ActiveOrganizationView<M>;
183
214
 
184
- export { type SessionState, useActiveOrganization, useSession, useSessionIdentity };
215
+ export { type ActiveOrganizationView, type ActiveOrganizationViewOptions, type SessionState, useActiveOrganization, useActiveOrganizationView, useSession, useSessionIdentity };
package/dist/react.js CHANGED
@@ -181,8 +181,53 @@ function useSessionIdentity(options) {
181
181
  const source = identity ?? NO_IDENTITY;
182
182
  return useSyncExternalStore(source.subscribe, source.snapshot, () => SERVER_SESSION_IDENTITY);
183
183
  }
184
+ function useActiveOrganizationView(options) {
185
+ const latest = useRef(options);
186
+ latest.current = options;
187
+ const [active, setActive] = useState(null);
188
+ const [organizations, setOrganizations] = useState([]);
189
+ const [loading, setLoading] = useState(true);
190
+ const [error, setError] = useState(null);
191
+ const [nonce, setNonce] = useState(0);
192
+ const reload = useRef(() => setNonce((n) => n + 1)).current;
193
+ useEffect(() => {
194
+ let cancelled = false;
195
+ if (!options.signedIn) {
196
+ setActive(null);
197
+ setOrganizations([]);
198
+ setLoading(false);
199
+ setError(null);
200
+ return;
201
+ }
202
+ setLoading(true);
203
+ void (async () => {
204
+ try {
205
+ const [resolved, all] = await Promise.all([latest.current.resolve(), latest.current.list()]);
206
+ if (cancelled) return;
207
+ setActive(resolved);
208
+ setOrganizations(all);
209
+ setError(null);
210
+ } catch (err) {
211
+ if (cancelled) return;
212
+ setError(err instanceof Error ? err.message : String(err));
213
+ } finally {
214
+ if (!cancelled) setLoading(false);
215
+ }
216
+ })();
217
+ return () => {
218
+ cancelled = true;
219
+ };
220
+ }, [options.signedIn, nonce, ...options.deps ?? []]);
221
+ const subscribe = options.subscribe;
222
+ useEffect(() => subscribe ? subscribe(reload) : void 0, [subscribe, reload]);
223
+ const choose = useRef(async (organizationId) => {
224
+ setActive(await latest.current.choose(organizationId));
225
+ }).current;
226
+ return { active, organizations, loading, error, mustChoose: options.signedIn && !loading && !error && !active, choose, reload };
227
+ }
184
228
  export {
185
229
  useActiveOrganization,
230
+ useActiveOrganizationView,
186
231
  useSession,
187
232
  useSessionIdentity
188
233
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-matrx/auth",
3
- "version": "0.2.5",
3
+ "version": "0.3.1",
4
4
  "description": "AI Matrx sign-in, session and organizations for every app: sign in/out, session storage and refresh per platform (browser, Next.js, extension, desktop, Node), the current person, their organizations and favourites, the one active organization (held requests, never a list filter), and the credentials @ai-matrx/data and @ai-matrx/api read.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -211,5 +211,5 @@
211
211
  "test": "vitest run",
212
212
  "verify:tarball": "node ./scripts/verify-tarball.mjs"
213
213
  },
214
- "gitHead": "9517977774bcd6e3ed1018bd331ca397a8e7ddfd"
214
+ "gitHead": "63d7fd1430cbe05b6ed9f0e638ae4c630232497a"
215
215
  }