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