@openlup/platform 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +350 -3
- package/dist/identity/adminAuthBinding.d.ts +28 -0
- package/dist/identity/adminAuthBinding.js +66 -0
- package/dist/identity/adminAuthorization.d.ts +34 -0
- package/dist/identity/adminAuthorization.js +72 -0
- package/dist/identity/adminContracts.d.ts +70 -0
- package/dist/identity/adminContracts.js +43 -0
- package/dist/identity/adminMagicLinkHandler.d.ts +39 -0
- package/dist/identity/adminMagicLinkHandler.js +94 -0
- package/dist/identity/adminMeHandler.d.ts +15 -0
- package/dist/identity/adminMeHandler.js +49 -0
- package/dist/identity/adminPreflight.d.ts +16 -0
- package/dist/identity/adminPreflight.js +38 -0
- package/dist/identity/adminUserActionPorts.d.ts +31 -0
- package/dist/identity/adminUserActionPorts.js +51 -0
- package/dist/identity/adminUserHandlers.d.ts +38 -0
- package/dist/identity/adminUserHandlers.js +140 -0
- package/dist/identity/authVerifyUrl.d.ts +6 -0
- package/dist/identity/authVerifyUrl.js +54 -0
- package/dist/identity/contracts/contracts.d.ts +8 -0
- package/dist/identity/contracts/contracts.js +2 -0
- package/dist/identity/contracts/emailTrust.d.ts +3 -0
- package/dist/identity/contracts/emailTrust.js +4 -0
- package/dist/identity/contracts/index.d.ts +8 -0
- package/dist/identity/contracts/index.js +8 -0
- package/dist/identity/contracts/oidc.d.ts +3 -0
- package/dist/identity/contracts/oidc.js +9 -0
- package/dist/identity/contracts/operatorAuth.d.ts +35 -0
- package/dist/identity/contracts/operatorAuth.js +53 -0
- package/dist/identity/contracts/operatorInvitations.d.ts +37 -0
- package/dist/identity/contracts/operatorInvitations.js +1 -0
- package/dist/identity/contracts/ports.d.ts +22 -0
- package/dist/identity/contracts/ports.js +1 -0
- package/dist/identity/contracts/privacy.d.ts +62 -0
- package/dist/identity/contracts/privacy.js +99 -0
- package/dist/identity/contracts/providerList.d.ts +3 -0
- package/dist/identity/contracts/providerList.js +11 -0
- package/dist/identity/contracts/types.d.ts +16 -0
- package/dist/identity/contracts/types.js +4 -0
- package/dist/identity/contribution.d.ts +8 -0
- package/dist/identity/contribution.js +22 -0
- package/dist/identity/customerAuth.d.ts +8 -0
- package/dist/identity/customerAuth.js +1 -0
- package/dist/identity/customerAuthorization.d.ts +5 -0
- package/dist/identity/customerAuthorization.js +22 -0
- package/dist/identity/customerContracts.d.ts +42 -0
- package/dist/identity/customerContracts.js +43 -0
- package/dist/identity/customerIdentityBinding.d.ts +44 -0
- package/dist/identity/customerIdentityBinding.js +60 -0
- package/dist/identity/customerIdentityVerifier.d.ts +8 -0
- package/dist/identity/customerIdentityVerifier.js +101 -0
- package/dist/identity/customerMagicLinkHandler.d.ts +38 -0
- package/dist/identity/customerMagicLinkHandler.js +115 -0
- package/dist/identity/customerMeHandler.d.ts +11 -0
- package/dist/identity/customerMeHandler.js +46 -0
- package/dist/identity/customerPorts.d.ts +4 -0
- package/dist/identity/customerPorts.js +1 -0
- package/dist/identity/customerSessionBinding.d.ts +38 -0
- package/dist/identity/customerSessionBinding.js +80 -0
- package/dist/identity/customerSessionIssuer.d.ts +10 -0
- package/dist/identity/customerSessionIssuer.js +57 -0
- package/dist/identity/customerVerifyOtpHandler.d.ts +8 -0
- package/dist/identity/customerVerifyOtpHandler.js +41 -0
- package/dist/identity/directOperatorInvitationStore.d.ts +19 -0
- package/dist/identity/directOperatorInvitationStore.js +193 -0
- package/dist/identity/emailTrustPolicy.d.ts +14 -0
- package/dist/identity/emailTrustPolicy.js +16 -0
- package/dist/identity/identityPersonalDataContributor.d.ts +7 -0
- package/dist/identity/identityPersonalDataContributor.js +46 -0
- package/dist/identity/identityReadiness.d.ts +10 -0
- package/dist/identity/identityReadiness.js +25 -0
- package/dist/identity/index.d.ts +67 -0
- package/dist/identity/index.js +66 -0
- package/dist/identity/inviteAdminUserUseCase.d.ts +89 -0
- package/dist/identity/inviteAdminUserUseCase.js +75 -0
- package/dist/identity/jwtLibrary.d.ts +54 -0
- package/dist/identity/jwtLibrary.js +1 -0
- package/dist/identity/machineActorWriteFence.d.ts +4 -0
- package/dist/identity/machineActorWriteFence.js +13 -0
- package/dist/identity/magicLinkPorts.d.ts +28 -0
- package/dist/identity/magicLinkPorts.js +1 -0
- package/dist/identity/managedAccountLinkStore.d.ts +2 -0
- package/dist/identity/managedAccountLinkStore.js +93 -0
- package/dist/identity/managedAdminAuthVerifier.d.ts +7 -0
- package/dist/identity/managedAdminAuthVerifier.js +27 -0
- package/dist/identity/managedAdminUserGateways.d.ts +123 -0
- package/dist/identity/managedAdminUserGateways.js +127 -0
- package/dist/identity/managedClient.d.ts +90 -0
- package/dist/identity/managedClient.js +10 -0
- package/dist/identity/managedClientAuthLink.d.ts +3 -0
- package/dist/identity/managedClientAuthLink.js +115 -0
- package/dist/identity/managedCustomerMe.d.ts +13 -0
- package/dist/identity/managedCustomerMe.js +30 -0
- package/dist/identity/managedEmailTrustPolicy.d.ts +11 -0
- package/dist/identity/managedEmailTrustPolicy.js +105 -0
- package/dist/identity/managedIdentityVerifier.d.ts +2 -0
- package/dist/identity/managedIdentityVerifier.js +16 -0
- package/dist/identity/managedPrivacy.d.ts +4 -0
- package/dist/identity/managedPrivacy.js +39 -0
- package/dist/identity/messages.d.ts +29 -0
- package/dist/identity/messages.js +39 -0
- package/dist/identity/oidcIdentityVerifier.d.ts +45 -0
- package/dist/identity/oidcIdentityVerifier.js +309 -0
- package/dist/identity/oidcPrincipal.d.ts +2 -0
- package/dist/identity/oidcPrincipal.js +10 -0
- package/dist/identity/operatorAdminAuth.d.ts +6 -0
- package/dist/identity/operatorAdminAuth.js +13 -0
- package/dist/identity/operatorAuthHandlers.d.ts +18 -0
- package/dist/identity/operatorAuthHandlers.js +71 -0
- package/dist/identity/operatorIdentityVerifier.d.ts +8 -0
- package/dist/identity/operatorIdentityVerifier.js +19 -0
- package/dist/identity/operatorInvitationLifecycle.d.ts +7 -0
- package/dist/identity/operatorInvitationLifecycle.js +15 -0
- package/dist/identity/operatorInvitationPorts.d.ts +31 -0
- package/dist/identity/operatorInvitationPorts.js +1 -0
- package/dist/identity/operatorPorts.d.ts +37 -0
- package/dist/identity/operatorPorts.js +1 -0
- package/dist/identity/operatorSession.d.ts +13 -0
- package/dist/identity/operatorSession.js +31 -0
- package/dist/identity/operatorTokenAdminAuth.d.ts +15 -0
- package/dist/identity/operatorTokenAdminAuth.js +50 -0
- package/dist/identity/personalDataRegistry.d.ts +4 -0
- package/dist/identity/personalDataRegistry.js +11 -0
- package/dist/identity/platformAdminAuth.d.ts +15 -0
- package/dist/identity/platformAdminAuth.js +1 -0
- package/dist/identity/ports.d.ts +127 -0
- package/dist/identity/ports.js +38 -0
- package/dist/identity/postgresAccountLinkStore.d.ts +7 -0
- package/dist/identity/postgresAccountLinkStore.js +52 -0
- package/dist/identity/postgresCustomerMe.d.ts +3 -0
- package/dist/identity/postgresCustomerMe.js +21 -0
- package/dist/identity/postgresCustomerRecovery.d.ts +11 -0
- package/dist/identity/postgresCustomerRecovery.js +57 -0
- package/dist/identity/postgresOperatorIdentity.d.ts +5 -0
- package/dist/identity/postgresOperatorIdentity.js +137 -0
- package/dist/identity/postgresPrivacy.d.ts +7 -0
- package/dist/identity/postgresPrivacy.js +126 -0
- package/dist/identity/privacyHandlers.d.ts +10 -0
- package/dist/identity/privacyHandlers.js +35 -0
- package/dist/identity/privacyPorts.d.ts +72 -0
- package/dist/identity/privacyPorts.js +8 -0
- package/dist/identity/privacyUseCases.d.ts +5 -0
- package/dist/identity/privacyUseCases.js +55 -0
- package/dist/identity/reconcileAccountForPrincipal.d.ts +20 -0
- package/dist/identity/reconcileAccountForPrincipal.js +66 -0
- package/dist/identity/reconcileAccountHandler.d.ts +11 -0
- package/dist/identity/reconcileAccountHandler.js +30 -0
- package/dist/identity/removeAdminUserUseCase.d.ts +31 -0
- package/dist/identity/removeAdminUserUseCase.js +60 -0
- package/dist/identity/response.d.ts +3 -0
- package/dist/identity/response.js +2 -0
- package/dist/identity/sql.d.ts +44 -0
- package/dist/identity/sql.js +10 -0
- package/package.json +12 -2
- package/release-gates.json +22 -0
- package/sql/20260810180001_self_host_actor_auth.sql +40 -0
- package/sql/20260810204057_customer_identity_read.sql +31 -0
- package/sql/20260814090000_customer_selfservice_recovery.sql +477 -0
- package/sql/20260901143000_admin_membership_authority_foundation.sql +234 -0
- package/sql/20261018120000_identity_operator_invitations.sql +42 -0
- package/sql/20261018180000_identity_operator_challenges.sql +56 -0
- package/sql/20261018190000_identity_privacy_erasures.sql +15 -0
- package/src/identity/adminAuthBinding.ts +76 -0
- package/src/identity/adminAuthorization.ts +106 -0
- package/src/identity/adminContracts.ts +52 -0
- package/src/identity/adminMagicLinkHandler.ts +132 -0
- package/src/identity/adminMeHandler.ts +64 -0
- package/src/identity/adminPreflight.ts +46 -0
- package/src/identity/adminUserActionPorts.ts +78 -0
- package/src/identity/adminUserHandlers.ts +174 -0
- package/src/identity/authVerifyUrl.ts +56 -0
- package/src/identity/contracts/contracts.ts +15 -0
- package/src/identity/contracts/emailTrust.ts +5 -0
- package/src/identity/contracts/index.ts +8 -0
- package/src/identity/contracts/oidc.ts +10 -0
- package/src/identity/contracts/operatorAuth.ts +61 -0
- package/src/identity/contracts/operatorInvitations.ts +32 -0
- package/src/identity/contracts/ports.ts +25 -0
- package/src/identity/contracts/privacy.ts +86 -0
- package/src/identity/contracts/providerList.ts +13 -0
- package/src/identity/contracts/types.ts +25 -0
- package/src/identity/contribution.ts +29 -0
- package/src/identity/customerAuth.ts +3 -0
- package/src/identity/customerAuthorization.ts +38 -0
- package/src/identity/customerContracts.ts +52 -0
- package/src/identity/customerIdentityBinding.ts +125 -0
- package/src/identity/customerIdentityVerifier.ts +141 -0
- package/src/identity/customerMagicLinkHandler.ts +171 -0
- package/src/identity/customerMeHandler.ts +64 -0
- package/src/identity/customerPorts.ts +2 -0
- package/src/identity/customerSessionBinding.ts +114 -0
- package/src/identity/customerSessionIssuer.ts +71 -0
- package/src/identity/customerVerifyOtpHandler.ts +44 -0
- package/src/identity/directOperatorInvitationStore.ts +178 -0
- package/src/identity/emailTrustPolicy.ts +16 -0
- package/src/identity/identityPersonalDataContributor.ts +48 -0
- package/src/identity/identityReadiness.ts +26 -0
- package/src/identity/index.ts +69 -0
- package/src/identity/inviteAdminUserUseCase.ts +134 -0
- package/src/identity/jwtLibrary.ts +24 -0
- package/src/identity/machineActorWriteFence.ts +19 -0
- package/src/identity/magicLinkPorts.ts +7 -0
- package/src/identity/managedAccountLinkStore.ts +105 -0
- package/src/identity/managedAdminAuthVerifier.ts +40 -0
- package/src/identity/managedAdminUserGateways.ts +164 -0
- package/src/identity/managedClient.ts +58 -0
- package/src/identity/managedClientAuthLink.ts +120 -0
- package/src/identity/managedCustomerMe.ts +52 -0
- package/src/identity/managedEmailTrustPolicy.ts +90 -0
- package/src/identity/managedIdentityVerifier.ts +20 -0
- package/src/identity/managedPrivacy.ts +37 -0
- package/src/identity/messages.ts +41 -0
- package/src/identity/oidcIdentityVerifier.ts +229 -0
- package/src/identity/oidcPrincipal.ts +10 -0
- package/src/identity/operatorAdminAuth.ts +14 -0
- package/src/identity/operatorAuthHandlers.ts +52 -0
- package/src/identity/operatorIdentityVerifier.ts +19 -0
- package/src/identity/operatorInvitationLifecycle.ts +14 -0
- package/src/identity/operatorInvitationPorts.ts +11 -0
- package/src/identity/operatorPorts.ts +11 -0
- package/src/identity/operatorSession.ts +30 -0
- package/src/identity/operatorTokenAdminAuth.ts +79 -0
- package/src/identity/personalDataRegistry.ts +8 -0
- package/src/identity/platformAdminAuth.ts +7 -0
- package/src/identity/ports.ts +158 -0
- package/src/identity/postgresAccountLinkStore.ts +49 -0
- package/src/identity/postgresCustomerMe.ts +33 -0
- package/src/identity/postgresCustomerRecovery.ts +72 -0
- package/src/identity/postgresOperatorIdentity.ts +123 -0
- package/src/identity/postgresPrivacy.ts +117 -0
- package/src/identity/privacyHandlers.ts +33 -0
- package/src/identity/privacyPorts.ts +52 -0
- package/src/identity/privacyUseCases.ts +48 -0
- package/src/identity/reconcileAccountForPrincipal.ts +99 -0
- package/src/identity/reconcileAccountHandler.ts +36 -0
- package/src/identity/removeAdminUserUseCase.ts +85 -0
- package/src/identity/response.ts +3 -0
- package/src/identity/sql.ts +10 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { PersonalDataObligation, PrivacyJson } from "./contracts/privacy.js";
|
|
2
|
+
import { PrivacyRefusal, type PersonalDataContributor, type PrivacyStore } from "./privacyPorts.js";
|
|
3
|
+
|
|
4
|
+
export interface IdentityPrivacyPolicy {
|
|
5
|
+
/** Explicit dispositions for populated opaque contact fields; absence refuses erasure. */
|
|
6
|
+
retainedContactFields: Readonly<Record<string, PersonalDataObligation>>;
|
|
7
|
+
}
|
|
8
|
+
const owned = new Set(["id", "email", "first_name", "last_name", "phone", "country", "principal_id", "created_at", "updated_at"]);
|
|
9
|
+
const populated = (value: PrivacyJson) => value !== null && value !== "" && !(typeof value === "object" && Object.keys(value).length === 0);
|
|
10
|
+
export function createIdentityPersonalDataContributor<C>(store: PrivacyStore<C>, policy: IdentityPrivacyPolicy, randomId: () => string): PersonalDataContributor<C> {
|
|
11
|
+
function obligations(identity: { [key: string]: PrivacyJson }): PersonalDataObligation[] {
|
|
12
|
+
const contact = identity.contact;
|
|
13
|
+
const result: PersonalDataObligation[] = [{ owner: "identity", resource: "identity.audit-and-receipt", disposition: "retain", reasonCode: "PSEUDONYMOUS_AUDIT", remote: false }];
|
|
14
|
+
if (contact && typeof contact === "object" && !Array.isArray(contact)) {
|
|
15
|
+
for (const [key, value] of Object.entries(contact)) {
|
|
16
|
+
if (owned.has(key) || !populated(value)) continue;
|
|
17
|
+
const declaration = policy.retainedContactFields[key];
|
|
18
|
+
result.push(declaration?.disposition === "retain" && declaration.resource === `clients.${key}` ? declaration : { owner: "unassigned", resource: `clients.${key}`, disposition: "unresolved", reasonCode: "PRIVACY_OWNERSHIP_UNDECLARED", remote: false });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
id: "identity", version: "identity.personal-data.v1",
|
|
25
|
+
ownedResources: ["identity.contact-profile", "identity.principal", "identity.membership", "identity.invitations", "identity.audit-and-receipt"],
|
|
26
|
+
async prepareErasure() {},
|
|
27
|
+
async export(_context, subject) {
|
|
28
|
+
const unavailable = Array.isArray(subject.identity.unavailable) ? subject.identity.unavailable.map(String) : [];
|
|
29
|
+
const data = { ...subject.identity };
|
|
30
|
+
const contact = data.contact;
|
|
31
|
+
if (contact && typeof contact === "object" && !Array.isArray(contact)) {
|
|
32
|
+
data.contact = Object.fromEntries(Object.entries(contact).filter(([key]) => owned.has(key) || key === "auth_user_id"));
|
|
33
|
+
for (const key of Object.keys(contact)) if (!owned.has(key) && key !== "auth_user_id") unavailable.push(`contact.${key}`);
|
|
34
|
+
}
|
|
35
|
+
return { contributorId: "identity", version: "identity.personal-data.v1", data,
|
|
36
|
+
coverage: { complete: unavailable.length === 0, included: Object.keys(subject.identity).filter(k => k !== "unavailable"), unavailable }, obligations: obligations(subject.identity) };
|
|
37
|
+
},
|
|
38
|
+
async assessErasure(_context, subject) {
|
|
39
|
+
const declared = obligations(subject.identity);
|
|
40
|
+
if (declared.some(o => o.disposition === "unresolved")) throw new PrivacyRefusal("PRIVACY_OWNERSHIP_UNDECLARED");
|
|
41
|
+
return { obligations: declared };
|
|
42
|
+
},
|
|
43
|
+
async eraseOrRetain(context, subject, assessment, actor) {
|
|
44
|
+
await store.eraseIdentity(context, subject, actor, `erased+${randomId()}@identity.invalid`);
|
|
45
|
+
return { contributorId: "identity", version: "identity.personal-data.v1", obligations: assessment.obligations };
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ReadinessIssue } from "@openlup/core/readiness";
|
|
2
|
+
import { assessEmailTrust, type EmailTrustPolicy } from "./emailTrustPolicy.js";
|
|
3
|
+
|
|
4
|
+
/** The module field stays structural so older core readiness types still compile; no new core code is needed. */
|
|
5
|
+
export async function identityEmailTrustReadiness(policy: EmailTrustPolicy): Promise<readonly (ReadinessIssue & { module: "identity" })[]> {
|
|
6
|
+
const result = await assessEmailTrust(policy);
|
|
7
|
+
if (result.trusted) return [];
|
|
8
|
+
return [{
|
|
9
|
+
code: "OPENLUP_E_PORT_MISSING", package: "@openlup/platform", module: "identity",
|
|
10
|
+
subject: "emailTrustPolicy", observation: result.code,
|
|
11
|
+
fix: result.code === "EMAIL_TRUST_ENVELOPE_REFUSED"
|
|
12
|
+
? "Disable automatic confirmation and declare only providers that verify email."
|
|
13
|
+
: "Restore access to valid public identity settings or bind an explicit email-trust policy.",
|
|
14
|
+
}];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Discovery and fresh key availability are independent readiness observations. */
|
|
18
|
+
export async function identityOidcReadiness(verifier: import("./oidcIdentityVerifier.js").OidcIdentityVerifier): Promise<readonly (ReadinessIssue & { module: "identity" })[]> {
|
|
19
|
+
const report = await verifier.readiness();
|
|
20
|
+
if (report.state === "ready") return [];
|
|
21
|
+
return [
|
|
22
|
+
...(!report.discovery.valid ? ["oidcDiscovery"] : []),
|
|
23
|
+
...(!report.jwks.loaded ? ["oidcJwks"] : []),
|
|
24
|
+
].map(subject => ({ code: "OPENLUP_E_PORT_MISSING", package: "@openlup/platform", module: "identity", subject,
|
|
25
|
+
observation: report.code ?? "OIDC_JWKS_INVALID", fix: "Restore valid issuer discovery and fresh same-origin signing keys." }));
|
|
26
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export * from "./adminMagicLinkHandler.js";
|
|
2
|
+
export * from "./reconcileAccountForPrincipal.js";
|
|
3
|
+
export * from "./customerVerifyOtpHandler.js";
|
|
4
|
+
export * from "./authVerifyUrl.js";
|
|
5
|
+
export * from "./customerIdentityVerifier.js";
|
|
6
|
+
export * from "./operatorTokenAdminAuth.js";
|
|
7
|
+
export * from "./managedIdentityVerifier.js";
|
|
8
|
+
export * from "./managedAdminAuthVerifier.js";
|
|
9
|
+
export * from "./managedCustomerMe.js";
|
|
10
|
+
export * from "./sql.js";
|
|
11
|
+
export * from "./customerMeHandler.js";
|
|
12
|
+
export * from "./messages.js";
|
|
13
|
+
export * from "./managedAccountLinkStore.js";
|
|
14
|
+
export * from "./customerIdentityBinding.js";
|
|
15
|
+
export * from "./adminUserActionPorts.js";
|
|
16
|
+
export * from "./contribution.js";
|
|
17
|
+
export * from "./customerContracts.js";
|
|
18
|
+
export * from "./machineActorWriteFence.js";
|
|
19
|
+
export * from "./managedClient.js";
|
|
20
|
+
export * from "./customerAuthorization.js";
|
|
21
|
+
export * from "./customerSessionIssuer.js";
|
|
22
|
+
export * from "./managedClientAuthLink.js";
|
|
23
|
+
export * from "./customerAuth.js";
|
|
24
|
+
export * from "./reconcileAccountHandler.js";
|
|
25
|
+
export * from "./postgresCustomerMe.js";
|
|
26
|
+
export * from "./ports.js";
|
|
27
|
+
export * from "./postgresCustomerRecovery.js";
|
|
28
|
+
export * from "./customerMagicLinkHandler.js";
|
|
29
|
+
export * from "./customerSessionBinding.js";
|
|
30
|
+
export * from "./adminUserHandlers.js";
|
|
31
|
+
export * from "./adminPreflight.js";
|
|
32
|
+
export * from "./adminAuthBinding.js";
|
|
33
|
+
export * from "./customerPorts.js";
|
|
34
|
+
export * from "./magicLinkPorts.js";
|
|
35
|
+
export * from "./inviteAdminUserUseCase.js";
|
|
36
|
+
export * from "./adminAuthorization.js";
|
|
37
|
+
export * from "./removeAdminUserUseCase.js";
|
|
38
|
+
export * from "./adminMeHandler.js";
|
|
39
|
+
export * from "./managedAdminUserGateways.js";
|
|
40
|
+
export * from "./jwtLibrary.js";
|
|
41
|
+
export * from "./platformAdminAuth.js";
|
|
42
|
+
export * from "./adminContracts.js";
|
|
43
|
+
export * from "./contracts/emailTrust.js";
|
|
44
|
+
export { createVerifierResultOnlyEmailTrustPolicy } from "./emailTrustPolicy.js";
|
|
45
|
+
export type { EmailTrustAssessment, EmailTrustPolicy } from "./emailTrustPolicy.js";
|
|
46
|
+
export * from "./managedEmailTrustPolicy.js";
|
|
47
|
+
export * from "./identityReadiness.js";
|
|
48
|
+
|
|
49
|
+
export * from "./operatorInvitationPorts.js";
|
|
50
|
+
export * from "./operatorInvitationLifecycle.js";
|
|
51
|
+
export * from "./directOperatorInvitationStore.js";
|
|
52
|
+
export * from "./operatorPorts.js";
|
|
53
|
+
export * from "./operatorSession.js";
|
|
54
|
+
export * from "./operatorIdentityVerifier.js";
|
|
55
|
+
export * from "./operatorAdminAuth.js";
|
|
56
|
+
export * from "./operatorAuthHandlers.js";
|
|
57
|
+
export * from "./postgresOperatorIdentity.js";
|
|
58
|
+
export * from "./privacyPorts.js";
|
|
59
|
+
export * from "./personalDataRegistry.js";
|
|
60
|
+
export * from "./privacyUseCases.js";
|
|
61
|
+
export * from "./identityPersonalDataContributor.js";
|
|
62
|
+
export * from "./postgresPrivacy.js";
|
|
63
|
+
export * from "./managedPrivacy.js";
|
|
64
|
+
export * from "./privacyHandlers.js";
|
|
65
|
+
|
|
66
|
+
export * from "./oidcIdentityVerifier.js";
|
|
67
|
+
export * from "./oidcPrincipal.js";
|
|
68
|
+
export * from "./postgresAccountLinkStore.js";
|
|
69
|
+
export * from "./contracts/oidc.js";
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { ManagedOperatorMembershipResult, OperatorInvitationConflict } from "./contracts/operatorInvitations.js";
|
|
2
|
+
import type { OperatorInvitationStore } from "./operatorInvitationPorts.js";
|
|
3
|
+
import { assertOperatorInvitationTtl } from "./operatorInvitationLifecycle.js";
|
|
4
|
+
import { identityMessages, type IdentityMessages } from "./messages.js";
|
|
5
|
+
export type AdminUserInviteRole = "admin" | "distributor";
|
|
6
|
+
export const ADMIN_ROLE_NOTIFICATION_LOCALE = "en" as const;
|
|
7
|
+
export type AdminRoleNotificationSkipReason = "admin_disabled" | "egress_suppressed";
|
|
8
|
+
|
|
9
|
+
export type InviteAdminUserResponse = {
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
success?: boolean;
|
|
12
|
+
message?: string;
|
|
13
|
+
error?: string;
|
|
14
|
+
code?: string;
|
|
15
|
+
deliveryReference?: string | null;
|
|
16
|
+
deliveryReferencePresent?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface InviteAdminUserGateway {
|
|
20
|
+
getUser: (accessToken: string) => Promise<{
|
|
21
|
+
user: { id: string } | null;
|
|
22
|
+
error: unknown;
|
|
23
|
+
}>;
|
|
24
|
+
findAdminUser: (userId: string) => Promise<{ id: string; role: string; membership_state: string; is_machine_actor: boolean } | null>;
|
|
25
|
+
findAdminUserByEmail: (email: string) => Promise<{ id: string } | null>;
|
|
26
|
+
findAuthUserByEmail: (email: string) => Promise<{ id: string; email?: string | null } | null>;
|
|
27
|
+
inviteAuthUser: (input: {
|
|
28
|
+
email: string;
|
|
29
|
+
redirectTo: string;
|
|
30
|
+
role: AdminUserInviteRole;
|
|
31
|
+
}) => Promise<{
|
|
32
|
+
user: { id: string } | null;
|
|
33
|
+
error: { message: string } | null;
|
|
34
|
+
}>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AdminRoleNotificationPort {
|
|
38
|
+
sendRoleGranted: (input: {
|
|
39
|
+
email: string;
|
|
40
|
+
authUserId: string;
|
|
41
|
+
role: AdminUserInviteRole;
|
|
42
|
+
locale: "pl" | "en";
|
|
43
|
+
invitationId?: string;
|
|
44
|
+
expiresAt?: string;
|
|
45
|
+
}) => Promise<{
|
|
46
|
+
ok: boolean;
|
|
47
|
+
providerMessageId: string | null;
|
|
48
|
+
providerError: string | null;
|
|
49
|
+
skipped: boolean;
|
|
50
|
+
skipReason: AdminRoleNotificationSkipReason | null;
|
|
51
|
+
}>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function runInviteAdminUserUseCase(
|
|
55
|
+
request: { accessToken: string | null; email: string; role: string },
|
|
56
|
+
deps: {
|
|
57
|
+
gateway: InviteAdminUserGateway;
|
|
58
|
+
roleNotificationPort: AdminRoleNotificationPort;
|
|
59
|
+
siteUrl: string;
|
|
60
|
+
|
|
61
|
+
messages?: IdentityMessages;
|
|
62
|
+
notificationLocale?: "pl" | "en";
|
|
63
|
+
} & (
|
|
64
|
+
| { invitationStore: OperatorInvitationStore; ttlSeconds: number }
|
|
65
|
+
| { activateMembership: (input: { principalId: string; email: string; role: AdminUserInviteRole }) => Promise<ManagedOperatorMembershipResult | { code: OperatorInvitationConflict }> }
|
|
66
|
+
),
|
|
67
|
+
): Promise<{ status: number; body: InviteAdminUserResponse }> {
|
|
68
|
+
const messages = deps.messages ?? identityMessages.en;
|
|
69
|
+
try {
|
|
70
|
+
if ("invitationStore" in deps) assertOperatorInvitationTtl(deps.ttlSeconds);
|
|
71
|
+
if (!request.accessToken) {
|
|
72
|
+
return { status: 401, body: { code: "UNAUTHORIZED", error: messages.unauthorized } };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const { user, error: authError } = await deps.gateway.getUser(request.accessToken);
|
|
76
|
+
if (authError || !user) {
|
|
77
|
+
return { status: 401, body: { code: "INVALID_TOKEN", error: messages.invalidToken } };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const callerAdmin = await deps.gateway.findAdminUser(user.id);
|
|
81
|
+
if (!callerAdmin || callerAdmin.role !== "admin" || callerAdmin.membership_state !== "active" || callerAdmin.is_machine_actor !== false) {
|
|
82
|
+
return { status: 403, body: { code: "INVITE_FORBIDDEN", error: messages.inviteForbidden } };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!request.email) {
|
|
86
|
+
return { status: 400, body: { code: "EMAIL_REQUIRED", error: messages.emailRequired } };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!isInviteRole(request.role)) {
|
|
90
|
+
return { status: 400, body: { code: "INVALID_ROLE", error: messages.invalidRole } };
|
|
91
|
+
}
|
|
92
|
+
const role = request.role;
|
|
93
|
+
|
|
94
|
+
const email = request.email.trim().toLowerCase();
|
|
95
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { status: 400, body: { code: "INVALID_EMAIL", error: "Invalid email" } };
|
|
96
|
+
const existingMembership = await deps.gateway.findAdminUserByEmail(email);
|
|
97
|
+
let principal = await deps.gateway.findAuthUserByEmail(email);
|
|
98
|
+
if (existingMembership && existingMembership.id !== principal?.id) return { status: 409, body: { code: "IDENTITY_CONFLICT", error: "IDENTITY_CONFLICT" } };
|
|
99
|
+
if (!principal) {
|
|
100
|
+
const invite = await deps.gateway.inviteAuthUser({ email, role, redirectTo: new URL("/admin/auth/callback", deps.siteUrl).toString() });
|
|
101
|
+
if (invite.error || !invite.user) {
|
|
102
|
+
// A provider duplicate race reuses the durable identity. Never delete it.
|
|
103
|
+
principal = await deps.gateway.findAuthUserByEmail(email);
|
|
104
|
+
if (!principal) throw new Error("operator_identity_creation_failed");
|
|
105
|
+
} else principal = invite.user;
|
|
106
|
+
}
|
|
107
|
+
const issued = "invitationStore" in deps
|
|
108
|
+
? await deps.invitationStore.issue({ actorPrincipalId: user.id, targetPrincipalId: principal.id, email, role, ttlSeconds: deps.ttlSeconds })
|
|
109
|
+
: await deps.activateMembership({ principalId: principal.id, email, role });
|
|
110
|
+
if (!("membershipState" in issued)) return { status: 409, body: { code: issued.code, error: issued.code } };
|
|
111
|
+
let delivery: Pick<InviteAdminUserResponse, "deliveryReference" | "deliveryReferencePresent"> = {};
|
|
112
|
+
if (issued.code !== "ALREADY_INVITED" && issued.code !== "ALREADY_ACTIVE") {
|
|
113
|
+
try {
|
|
114
|
+
const notification = await deps.roleNotificationPort.sendRoleGranted({ email, authUserId: principal.id, role, locale: deps.notificationLocale ?? ADMIN_ROLE_NOTIFICATION_LOCALE, ...(issued.membershipState === "invited" ? { invitationId: issued.invitationId, expiresAt: issued.expiresAt } : {}) });
|
|
115
|
+
if (!notification.ok) throw new Error("operator_invitation_notification_failed");
|
|
116
|
+
delivery = { deliveryReference: notification.skipped ? null : notification.providerMessageId, deliveryReferencePresent: !notification.skipped && Boolean(notification.providerMessageId) };
|
|
117
|
+
} catch {
|
|
118
|
+
return { status: 503, body: issued.membershipState === "invited"
|
|
119
|
+
? { code: "INVITATION_NOTIFICATION_FAILED", error: "Invitation persisted; notification unavailable", invitationId: issued.invitationId }
|
|
120
|
+
: { ...issued, code: "INVITATION_NOTIFICATION_FAILED", error: "Membership active; notification unavailable" } };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { status: 200, body: { success: true, ...issued, ...delivery, message: issued.membershipState === "invited" ? `Invitation pending until ${issued.expiresAt}` : "Operator membership active" } };
|
|
124
|
+
|
|
125
|
+
} catch (err) {
|
|
126
|
+
const failure = err as { identityCode?: string; bffCode?: string };
|
|
127
|
+
if (failure.identityCode) return { status: failure.bffCode === "FORBIDDEN" ? 403 : 409, body: { code: failure.identityCode, error: failure.identityCode } };
|
|
128
|
+
return { status: 503, body: { code: "IDENTITY_ACTION_FAILED", error: String(err) } };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isInviteRole(value: string): value is AdminUserInviteRole {
|
|
133
|
+
return value === "admin" || value === "distributor";
|
|
134
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface IdentityJwk {
|
|
2
|
+
[key: string]: unknown;
|
|
3
|
+
kty?: string; alg?: string; crv?: string; kid?: string; use?: string; x?: string; y?: string; d?: string;
|
|
4
|
+
}
|
|
5
|
+
type Key = CryptoKey | Uint8Array;
|
|
6
|
+
interface Signer {
|
|
7
|
+
setProtectedHeader(header: { alg: string; kid: string; typ: string }): Signer;
|
|
8
|
+
setSubject(value: string): Signer; setIssuer(value: string): Signer;
|
|
9
|
+
setAudience(value: string): Signer; setIssuedAt(value: number): Signer;
|
|
10
|
+
setExpirationTime(value: number): Signer; sign(key: Key): Promise<string>;
|
|
11
|
+
}
|
|
12
|
+
/** The host loads its selected JWT implementation only for the selected adapter. */
|
|
13
|
+
export interface IdentityJwtLibrary {
|
|
14
|
+
importJWK(jwk: IdentityJwk, alg: string): Promise<Key>;
|
|
15
|
+
jwtVerify(token: string, key: (header: { alg?: string; kid?: string }) => Promise<Key>, options: { algorithms: string[]; audience: string; issuer: string; currentDate?: Date; clockTolerance?: number }): Promise<{ payload: IdentityJwtClaims }>;
|
|
16
|
+
SignJWT: new (payload: Record<string, unknown>) => Signer;
|
|
17
|
+
}
|
|
18
|
+
export type IdentityJwtLibraryLoader = () => Promise<IdentityJwtLibrary>;
|
|
19
|
+
|
|
20
|
+
/** Claims returned only after signature verification by the host library. */
|
|
21
|
+
export interface IdentityJwtClaims {
|
|
22
|
+
[claim: string]: unknown;
|
|
23
|
+
sub?: string; iss?: string; aud?: string | string[]; exp?: number; iat?: number; nbf?: number;
|
|
24
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { HttpResponse } from "../http/index.js";
|
|
2
|
+
import { createIdentityResponder, type IdentityResponseOptions } from "./response.js";
|
|
3
|
+
|
|
4
|
+
export function refusesMachineActorRequest(
|
|
5
|
+
route: string,
|
|
6
|
+
method: string | undefined,
|
|
7
|
+
isMachineActor: boolean | undefined,
|
|
8
|
+
writeRoutes: readonly string[] = [],
|
|
9
|
+
): boolean {
|
|
10
|
+
if (isMachineActor === false) return false;
|
|
11
|
+
const verb = (method ?? "").toUpperCase();
|
|
12
|
+
return verb !== "GET" && verb !== "HEAD" && !writeRoutes.includes(route);
|
|
13
|
+
}
|
|
14
|
+
export function sendMachineActorWriteRefused(res: HttpResponse, options: IdentityResponseOptions = {}): void {
|
|
15
|
+
const { sendBffError } = createIdentityResponder(options);
|
|
16
|
+
sendBffError(res, "FORBIDDEN", "Human operator required", {
|
|
17
|
+
details: { reason: "machine_actor_write_refused" },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface MagicLinkAuthClient {
|
|
2
|
+
auth: { signInWithOtp(input: { email: string; options: { shouldCreateUser: false; emailRedirectTo: string } }): Promise<{ error: { name?: string; message?: string } | null }> };
|
|
3
|
+
}
|
|
4
|
+
export interface MagicLinkRateLimitClient {
|
|
5
|
+
rpc(fn: string, args: Record<string, unknown>): Promise<{ data: unknown; error: { message?: string } | null }>;
|
|
6
|
+
}
|
|
7
|
+
export interface MagicLinkDecision { allowed: boolean; reason?: string }
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { ManagedIdentityClient, ManagedIdentityRow } from "./managedClient.js";
|
|
2
|
+
import type {
|
|
3
|
+
AccountLinkStore,
|
|
4
|
+
CreateAccountInput,
|
|
5
|
+
CustomerAccount,
|
|
6
|
+
LinkPrincipalResult,
|
|
7
|
+
} from "./ports.js";
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
export function createSupabaseAccountLinkStore(rawClient: unknown): AccountLinkStore {
|
|
11
|
+
const client = rawClient as ManagedIdentityClient;
|
|
12
|
+
return {
|
|
13
|
+
async findAccountByPrincipalId(principalId: string): Promise<CustomerAccount | null> {
|
|
14
|
+
const { data, error } = await client
|
|
15
|
+
.from("clients")
|
|
16
|
+
.select("id, email, auth_user_id")
|
|
17
|
+
.eq("auth_user_id", principalId)
|
|
18
|
+
.maybeSingle();
|
|
19
|
+
if (error) throw error;
|
|
20
|
+
if (!data) return null;
|
|
21
|
+
return { id: data.id, email: data.email ?? null, principalId: data.auth_user_id ?? null };
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
async findAccountByEmail(email: string): Promise<CustomerAccount | null> {
|
|
25
|
+
const rows = await findManagedContactsByEmail(client, email, false);
|
|
26
|
+
if (rows.length > 1) {
|
|
27
|
+
throw Object.assign(new Error("multiple rows returned"), { code: "PGRST116" });
|
|
28
|
+
}
|
|
29
|
+
const data = rows[0];
|
|
30
|
+
if (!data) return null;
|
|
31
|
+
return { id: data.id, email: data.email ?? null, principalId: data.auth_user_id ?? null };
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async isReservedPrincipal(principalId: string): Promise<boolean> {
|
|
35
|
+
const { data, error } = await client
|
|
36
|
+
.from("admin_users")
|
|
37
|
+
.select("id, is_machine_actor")
|
|
38
|
+
.eq("id", principalId)
|
|
39
|
+
.maybeSingle();
|
|
40
|
+
if (error) throw error;
|
|
41
|
+
if (!data) return false;
|
|
42
|
+
return data.is_machine_actor !== false;
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async linkPrincipalToAccount(
|
|
46
|
+
accountId: string,
|
|
47
|
+
principalId: string,
|
|
48
|
+
): Promise<LinkPrincipalResult> {
|
|
49
|
+
const { data, error } = await client
|
|
50
|
+
.from("clients")
|
|
51
|
+
.update({ auth_user_id: principalId })
|
|
52
|
+
.eq("id", accountId)
|
|
53
|
+
.is("auth_user_id", null)
|
|
54
|
+
.select("auth_user_id")
|
|
55
|
+
.maybeSingle();
|
|
56
|
+
if (error) throw error;
|
|
57
|
+
if (data?.auth_user_id === principalId) {
|
|
58
|
+
return { ok: true, alreadyLinked: false };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const { data: current, error: currentError } = await client
|
|
62
|
+
.from("clients")
|
|
63
|
+
.select("auth_user_id")
|
|
64
|
+
.eq("id", accountId)
|
|
65
|
+
.maybeSingle();
|
|
66
|
+
if (currentError) throw currentError;
|
|
67
|
+
if (current?.auth_user_id === principalId) {
|
|
68
|
+
return { ok: true, alreadyLinked: true };
|
|
69
|
+
}
|
|
70
|
+
return { ok: false, existingPrincipalId: current?.auth_user_id ?? null };
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
async createAccount(input: CreateAccountInput): Promise<{ id: string }> {
|
|
74
|
+
const { data, error } = await client
|
|
75
|
+
.from("clients")
|
|
76
|
+
.insert({
|
|
77
|
+
email: input.email,
|
|
78
|
+
auth_user_id: input.principalId,
|
|
79
|
+
acquisition_source: input.acquisitionSource,
|
|
80
|
+
lifecycle_stage: input.lifecycleStage,
|
|
81
|
+
})
|
|
82
|
+
.select("id")
|
|
83
|
+
.single();
|
|
84
|
+
if (error) throw error;
|
|
85
|
+
return { id: data!.id };
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function findManagedContactsByEmail(
|
|
91
|
+
client: ManagedIdentityClient,
|
|
92
|
+
email: string,
|
|
93
|
+
oldestFirst: boolean,
|
|
94
|
+
): Promise<ManagedIdentityRow[]> {
|
|
95
|
+
const normalized = email.trim().toLowerCase();
|
|
96
|
+
// Escaping bounds the database prefilter; Unicode regex folding can still
|
|
97
|
+
// widen it, so only exact normalized addresses may cross this boundary.
|
|
98
|
+
let query = client.from("clients")
|
|
99
|
+
.select("id, email, auth_user_id")
|
|
100
|
+
.filter("email", "imatch", `^${normalized.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&")}$`);
|
|
101
|
+
if (oldestFirst) query = query.order("created_at", { ascending: true });
|
|
102
|
+
const { data, error } = await query.limit(10);
|
|
103
|
+
if (error) throw error;
|
|
104
|
+
return (data ?? []).filter((row) => row.email?.trim().toLowerCase() === normalized);
|
|
105
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { SupabaseCreateClient } from "../data/index.js";
|
|
2
|
+
import type { ManagedIdentityClient } from "./managedClient.js";
|
|
3
|
+
import type { AdminAuthPort, AdminAuthorizationResult, AdminPrincipalRole } from "./ports.js";
|
|
4
|
+
import { authorizeAdminWithUser, type AdminAuthorization } from "./adminAuthorization.js";
|
|
5
|
+
|
|
6
|
+
export function createSupabaseAdminAuth(rawClient: unknown): AdminAuthPort {
|
|
7
|
+
const client = rawClient as ManagedIdentityClient;
|
|
8
|
+
return {
|
|
9
|
+
async authorize(
|
|
10
|
+
accessToken: string | null,
|
|
11
|
+
options?: { allowedRoles?: readonly AdminPrincipalRole[] },
|
|
12
|
+
): Promise<AdminAuthorizationResult> {
|
|
13
|
+
const authz: AdminAuthorization = await authorizeAdminWithUser(client, accessToken, options);
|
|
14
|
+
if (authz.ok === false) {
|
|
15
|
+
return { ok: false, code: authz.code, message: authz.message };
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
ok: true,
|
|
19
|
+
principalId: authz.userId,
|
|
20
|
+
role: authz.role,
|
|
21
|
+
isMachineActor: authz.isMachineActor,
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
export function createSupabaseAdminAuthFromEnv(
|
|
29
|
+
env: { url: string; anonKey: string },
|
|
30
|
+
accessToken: string | null,
|
|
31
|
+
createClient: SupabaseCreateClient,
|
|
32
|
+
): AdminAuthPort {
|
|
33
|
+
const client = createClient(env.url, env.anonKey, {
|
|
34
|
+
auth: { persistSession: false, autoRefreshToken: false },
|
|
35
|
+
global: {
|
|
36
|
+
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
return createSupabaseAdminAuth(client as ManagedIdentityClient);
|
|
40
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { AdminUserInviteRole } from "./inviteAdminUserUseCase.js";
|
|
2
|
+
import type { ManagedOperatorMembershipResult, OperatorInvitationConflict } from "./contracts/operatorInvitations.js";
|
|
3
|
+
import type { ManagedIdentityClient } from "./managedClient.js";
|
|
4
|
+
import type { InviteAdminUserGateway } from "./inviteAdminUserUseCase.js";
|
|
5
|
+
import type { RemoveAdminUserGateway } from "./removeAdminUserUseCase.js";
|
|
6
|
+
interface InviteAdminUserQuery {
|
|
7
|
+
insert(row: { id: string; email: string; role: AdminUserInviteRole; membership_state: "active"; membership_provenance: "invitation"; membership_accepted_at: string }): PromiseLike<{ error: { code?: string; message?: string } | null }>;
|
|
8
|
+
select(columns: string): { eq(column: string, value: string): {
|
|
9
|
+
maybeSingle(): PromiseLike<{ data: { id: string; role?: string; membership_state?: string; is_machine_actor?: boolean | null } | null; error?: unknown }>;
|
|
10
|
+
} };
|
|
11
|
+
}
|
|
12
|
+
export interface InviteAdminUserClient {
|
|
13
|
+
auth: {
|
|
14
|
+
getUser(token: string): PromiseLike<{ data: { user: { id: string } | null }; error: unknown }>;
|
|
15
|
+
admin: {
|
|
16
|
+
listUsers(options: { page: number; perPage: number }): PromiseLike<{ data: { users: Array<{ id: string; email?: string | null }>; nextPage?: number | null } | null; error: unknown }>;
|
|
17
|
+
inviteUserByEmail(email: string, options: { redirectTo: string }): PromiseLike<{
|
|
18
|
+
data: { user: { id: string } | null }; error: { message: string } | null;
|
|
19
|
+
}>;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
from(table: "admin_users"): InviteAdminUserQuery;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface RemoveAdminUserQuery {
|
|
26
|
+
select(columns: string): {
|
|
27
|
+
eq(column: string, value: string): RemoveAdminUserQueryResult;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface RemoveAdminUserQueryResult {
|
|
32
|
+
eq(column: string, value: string): RemoveAdminUserQueryResult;
|
|
33
|
+
maybeSingle(): PromiseLike<{ data: { id: string; role: string } | null; error: { message: string } | null }>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RemoveAdminUserClient {
|
|
37
|
+
auth: {
|
|
38
|
+
getUser(token: string): PromiseLike<{ data: { user: { id: string } | null }; error: unknown }>;
|
|
39
|
+
};
|
|
40
|
+
from(table: "admin_users"): RemoveAdminUserQuery;
|
|
41
|
+
rpc(
|
|
42
|
+
fn: "admin_revoke_admin_user",
|
|
43
|
+
args: { p_target: string; p_reason: string | null },
|
|
44
|
+
): PromiseLike<{ error: { message: string } | null }>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createInviteAdminUserGateway(clientFactory: () => InviteAdminUserClient): InviteAdminUserGateway & {
|
|
48
|
+
activateMembership(input: { principalId: string; email: string; role: AdminUserInviteRole }): Promise<ManagedOperatorMembershipResult | { code: OperatorInvitationConflict }>;
|
|
49
|
+
} {
|
|
50
|
+
let client: InviteAdminUserClient | null = null;
|
|
51
|
+
const getClient = (): InviteAdminUserClient => client ??= clientFactory();
|
|
52
|
+
|
|
53
|
+
async function membership(principalId: string) {
|
|
54
|
+
const { data, error } = await getClient().from("admin_users").select("id, role, membership_state, is_machine_actor").eq("id", principalId).maybeSingle();
|
|
55
|
+
if (error) throw error;
|
|
56
|
+
return data;
|
|
57
|
+
}
|
|
58
|
+
function existing(row: NonNullable<Awaited<ReturnType<typeof membership>>>, role: AdminUserInviteRole): ManagedOperatorMembershipResult | { code: OperatorInvitationConflict } {
|
|
59
|
+
if (row.is_machine_actor !== false) return { code: "IDENTITY_CONFLICT" };
|
|
60
|
+
if (row.membership_state === "revoked") return { code: "MANAGED_REINVITE_UNSUPPORTED" };
|
|
61
|
+
if (row.membership_state !== "active") return { code: "IDENTITY_CONFLICT" };
|
|
62
|
+
if (row.role !== role) return { code: "INVITATION_ROLE_CONFLICT" };
|
|
63
|
+
return { principalId: row.id, role, membershipState: "active", code: "ALREADY_ACTIVE" };
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
async activateMembership(input) {
|
|
67
|
+
const before = await membership(input.principalId);
|
|
68
|
+
if (before) return existing(before, input.role);
|
|
69
|
+
const { error } = await getClient().from("admin_users").insert({ id: input.principalId, email: input.email, role: input.role,
|
|
70
|
+
membership_state: "active", membership_provenance: "invitation", membership_accepted_at: new Date().toISOString() });
|
|
71
|
+
if (error) {
|
|
72
|
+
if (error.code !== "23505") throw error;
|
|
73
|
+
const raced = await membership(input.principalId);
|
|
74
|
+
if (!raced) throw error;
|
|
75
|
+
return existing(raced, input.role);
|
|
76
|
+
}
|
|
77
|
+
return { principalId: input.principalId, role: input.role, membershipState: "active", code: "MEMBERSHIP_CREATED" };
|
|
78
|
+
},
|
|
79
|
+
async getUser(accessToken) {
|
|
80
|
+
const { data, error } = await getClient().auth.getUser(accessToken);
|
|
81
|
+
return { user: data?.user ?? null, error };
|
|
82
|
+
},
|
|
83
|
+
async findAdminUser(userId) {
|
|
84
|
+
const { data, error } = await getClient()
|
|
85
|
+
.from("admin_users")
|
|
86
|
+
.select("id, role, membership_state, is_machine_actor")
|
|
87
|
+
.eq("id", userId)
|
|
88
|
+
.maybeSingle();
|
|
89
|
+
if (error) throw error;
|
|
90
|
+
return data && typeof data.role === "string" && typeof data.membership_state === "string" ? { id: data.id, role: data.role, membership_state: data.membership_state, is_machine_actor: data.is_machine_actor !== false } : null;
|
|
91
|
+
},
|
|
92
|
+
async findAdminUserByEmail(email) {
|
|
93
|
+
const { data, error } = await getClient()
|
|
94
|
+
.from("admin_users")
|
|
95
|
+
.select("id")
|
|
96
|
+
.eq("email", email)
|
|
97
|
+
.maybeSingle();
|
|
98
|
+
if (error) throw error;
|
|
99
|
+
return data ? { id: data.id } : null;
|
|
100
|
+
},
|
|
101
|
+
async findAuthUserByEmail(email) {
|
|
102
|
+
const normalizedEmail = email.trim().toLowerCase();
|
|
103
|
+
const perPage = 1000;
|
|
104
|
+
const maxUsers = 5000;
|
|
105
|
+
let page = 1;
|
|
106
|
+
let scanned = 0;
|
|
107
|
+
while (scanned < maxUsers) {
|
|
108
|
+
const { data, error } = await getClient().auth.admin.listUsers({ page, perPage });
|
|
109
|
+
if (error) throw error;
|
|
110
|
+
const users = data?.users ?? [];
|
|
111
|
+
const match = users.find((user) => (user.email ?? "").trim().toLowerCase() === normalizedEmail);
|
|
112
|
+
if (match) return { id: match.id, email: match.email };
|
|
113
|
+
scanned += users.length;
|
|
114
|
+
if (!data?.nextPage) return null;
|
|
115
|
+
page = data.nextPage;
|
|
116
|
+
}
|
|
117
|
+
throw new Error("auth_user_lookup_cap_exceeded");
|
|
118
|
+
},
|
|
119
|
+
async inviteAuthUser(input) {
|
|
120
|
+
const { data, error } = await getClient().auth.admin.inviteUserByEmail(input.email, {
|
|
121
|
+
redirectTo: input.redirectTo,
|
|
122
|
+
});
|
|
123
|
+
return { user: data.user, error };
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function createRemoveAdminUserGateway(clientFactory: () => RemoveAdminUserClient): RemoveAdminUserGateway {
|
|
129
|
+
let client: RemoveAdminUserClient | null = null;
|
|
130
|
+
const getClient = (): RemoveAdminUserClient => client ??= clientFactory();
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
async getUser(accessToken) {
|
|
134
|
+
const { data, error } = await getClient().auth.getUser(accessToken);
|
|
135
|
+
return { user: data?.user ?? null, error };
|
|
136
|
+
},
|
|
137
|
+
async findAdminUser(userId) {
|
|
138
|
+
const { data, error } = await getClient()
|
|
139
|
+
.from("admin_users")
|
|
140
|
+
.select("id, role")
|
|
141
|
+
.eq("id", userId)
|
|
142
|
+
.eq("membership_state", "active")
|
|
143
|
+
.maybeSingle();
|
|
144
|
+
if (error) throw error;
|
|
145
|
+
return data;
|
|
146
|
+
},
|
|
147
|
+
async revokeAdminUser(userId) {
|
|
148
|
+
return await getClient().rpc("admin_revoke_admin_user", {
|
|
149
|
+
p_target: userId,
|
|
150
|
+
p_reason: null,
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
/** Managed eligibility depends only on active native membership and known roles. */
|
|
158
|
+
export async function managedOperatorMagicLinkEligibility(rawClient: unknown, email: string): Promise<"eligible" | "not_allowlisted" | "role_not_allowed"> {
|
|
159
|
+
const client = rawClient as ManagedIdentityClient;
|
|
160
|
+
const { data, error } = await client.from("admin_users").select("id, role").eq("email", email.trim().toLowerCase()).eq("membership_state", "active").maybeSingle();
|
|
161
|
+
if (error) throw error;
|
|
162
|
+
if (!data) return "not_allowlisted";
|
|
163
|
+
return data.role === "admin" || data.role === "distributor" ? "eligible" : "role_not_allowed";
|
|
164
|
+
}
|