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