@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,10 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/** Frozen UTF-8, domain-separated SHA-256 UUIDv8 mapping of the exact issuer/subject pair. */
|
|
3
|
+
export function deriveOidcPrincipalId(issuer: string, subject: string): string {
|
|
4
|
+
const bytes = createHash("sha256").update("openlup:oidc-principal:v1\0", "utf8")
|
|
5
|
+
.update(JSON.stringify([issuer, subject]), "utf8").digest().subarray(0, 16);
|
|
6
|
+
bytes[6] = (bytes[6]! & 0x0f) | 0x80;
|
|
7
|
+
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
|
8
|
+
const hex = bytes.toString("hex");
|
|
9
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
10
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AdminAuthPort, IdentityVerifierPort } from "./ports.js";
|
|
2
|
+
import type { OperatorIdentityStore } from "./operatorPorts.js";
|
|
3
|
+
export function createOperatorAdminAuth(options: { verifier: IdentityVerifierPort; authority: Pick<OperatorIdentityStore, "findMembership"> }): AdminAuthPort {
|
|
4
|
+
return { async authorize(token, restrictions) {
|
|
5
|
+
const principal = token ? await options.verifier.verifyAccessToken(token) : null;
|
|
6
|
+
if (!principal) return { ok: false, code: "UNAUTHORIZED", message: "Operator authentication required" };
|
|
7
|
+
const member = await options.authority.findMembership(principal.principalId);
|
|
8
|
+
if (!member || member.membershipState !== "active" || (member.role !== "admin" && member.role !== "distributor")
|
|
9
|
+
|| (restrictions?.allowedRoles && !restrictions.allowedRoles.includes(member.role))) {
|
|
10
|
+
return { ok: false, code: "FORBIDDEN", message: "Operator access denied" };
|
|
11
|
+
}
|
|
12
|
+
return { ok: true, principalId: principal.principalId, role: member.role, isMachineActor: false };
|
|
13
|
+
} };
|
|
14
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { operatorChallengeRequestSchema, operatorChallengeVerificationSchema, operatorSessionSchema } from "./contracts/operatorAuth.js";
|
|
2
|
+
import { createMagicLinkOriginResolver } from "./authVerifyUrl.js";
|
|
3
|
+
import { createIdentityResponder, type IdentityResponseOptions } from "./response.js";
|
|
4
|
+
import type { HttpRequest, HttpResponse } from "../http/index.js";
|
|
5
|
+
import type { createOperatorSessionService } from "./operatorSession.js";
|
|
6
|
+
export interface OperatorAuthMessages { invalidRequest: string; forbidden: string; unauthorized: string; unavailable: string }
|
|
7
|
+
export function createOperatorAuthHandlers(options: IdentityResponseOptions & {
|
|
8
|
+
sessions: ReturnType<typeof createOperatorSessionService>; origin: string; messages: OperatorAuthMessages;
|
|
9
|
+
padResponseTime(startedAt: number, minimumMs: number): Promise<void>;
|
|
10
|
+
}) {
|
|
11
|
+
const responder = createIdentityResponder(options);
|
|
12
|
+
const origin = createMagicLinkOriginResolver([options.origin]);
|
|
13
|
+
function admitted(req: HttpRequest, res: HttpResponse): boolean {
|
|
14
|
+
if (req.method !== "POST") { responder.sendMethodNotAllowed(res, ["POST"]); return false; }
|
|
15
|
+
const configured = origin(req);
|
|
16
|
+
if (!configured) { responder.sendBffError(res, "UPSTREAM_UNAVAILABLE", options.messages.unavailable); return false; }
|
|
17
|
+
const supplied = req.headers.origin;
|
|
18
|
+
const contentType = req.headers["content-type"];
|
|
19
|
+
if (supplied !== configured || typeof contentType !== "string" || !/^application\/json(?:;|$)/i.test(contentType)) {
|
|
20
|
+
responder.sendBffError(res, "FORBIDDEN", options.messages.forbidden); return false;
|
|
21
|
+
}
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
async requestChallenge(req: HttpRequest, res: HttpResponse): Promise<void> {
|
|
26
|
+
if (!admitted(req, res)) return;
|
|
27
|
+
const parsed = operatorChallengeRequestSchema.safeParse(req.body);
|
|
28
|
+
if (!parsed.success) { responder.sendBffError(res, "BAD_REQUEST", options.messages.invalidRequest); return; }
|
|
29
|
+
const started = Date.now();
|
|
30
|
+
try {
|
|
31
|
+
await options.sessions.requestChallenge(parsed.data.email, async () => {
|
|
32
|
+
await options.padResponseTime(started, 400);
|
|
33
|
+
responder.sendBffSuccess(res, { accepted: true });
|
|
34
|
+
});
|
|
35
|
+
} catch { responder.sendBffError(res, "UPSTREAM_UNAVAILABLE", options.messages.unavailable); }
|
|
36
|
+
},
|
|
37
|
+
async verifyChallenge(req: HttpRequest, res: HttpResponse): Promise<void> {
|
|
38
|
+
if (!admitted(req, res)) return;
|
|
39
|
+
// A token-shaped refusal is authentication failure; an invalid envelope is a bad request.
|
|
40
|
+
if (!req.body || typeof req.body !== "object" || Object.keys(req.body).length !== 1 || typeof (req.body as { token?: unknown }).token !== "string") {
|
|
41
|
+
responder.sendBffError(res, "BAD_REQUEST", options.messages.invalidRequest); return;
|
|
42
|
+
}
|
|
43
|
+
const parsed = operatorChallengeVerificationSchema.safeParse(req.body);
|
|
44
|
+
if (!parsed.success) { responder.sendBffError(res, "UNAUTHORIZED", options.messages.unauthorized); return; }
|
|
45
|
+
try {
|
|
46
|
+
const session = await options.sessions.verifyChallenge(parsed.data.token);
|
|
47
|
+
if (!session) { responder.sendBffError(res, "UNAUTHORIZED", options.messages.unauthorized); return; }
|
|
48
|
+
responder.sendBffSuccess(res, operatorSessionSchema.parse(session));
|
|
49
|
+
} catch { responder.sendBffError(res, "UPSTREAM_UNAVAILABLE", options.messages.unavailable); }
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createCustomerIdentityVerifier } from "./customerIdentityVerifier.js";
|
|
2
|
+
import type { IdentityJwtLibraryLoader } from "./jwtLibrary.js";
|
|
3
|
+
import type { IdentityVerifierPort } from "./ports.js";
|
|
4
|
+
export function createOperatorIdentityVerifier(config: { issuer: string; audience: string; customerAudience: string; jwks: string }, loadJwt: IdentityJwtLibraryLoader): IdentityVerifierPort {
|
|
5
|
+
if (!config.customerAudience.trim() || config.audience === config.customerAudience) throw new Error("operator_audience_must_be_distinct");
|
|
6
|
+
// Inspect the payload returned by the same cryptographic verification, never an unverified decode.
|
|
7
|
+
return createCustomerIdentityVerifier(config, async () => {
|
|
8
|
+
const library = await loadJwt();
|
|
9
|
+
return { ...library, async jwtVerify(token, key, options) {
|
|
10
|
+
const verified = await library.jwtVerify(token, key, options);
|
|
11
|
+
const payload = verified.payload as Record<string, unknown>;
|
|
12
|
+
const { iat, exp } = payload;
|
|
13
|
+
if (payload.aud !== config.audience || typeof iat !== "number" || typeof exp !== "number"
|
|
14
|
+
|| !Number.isInteger(iat) || !Number.isInteger(exp) || exp <= iat || exp - iat > 900
|
|
15
|
+
|| iat > Math.floor(Date.now() / 1000) || exp <= Math.floor(Date.now() / 1000)) throw new Error("operator_token_profile_invalid");
|
|
16
|
+
return verified;
|
|
17
|
+
} };
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function assertOperatorInvitationTtl(value: number): void {
|
|
2
|
+
if (!Number.isInteger(value) || value < 60 || value > 2592000) throw new Error("operator_invitation_ttl_invalid");
|
|
3
|
+
}
|
|
4
|
+
export function invitationFailure(identityCode: string, bffCode = "CONFLICT"): Error & { identityCode: string; bffCode: string } {
|
|
5
|
+
return Object.assign(new Error(identityCode), { identityCode, bffCode });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Pure parser; direct hosts call it before effects and pass the number to identity. */
|
|
9
|
+
export function parseOperatorInvitationTtl(raw?: string): number {
|
|
10
|
+
if (raw !== undefined && !/^\d+$/.test(raw)) throw new Error("operator_invitation_ttl_invalid");
|
|
11
|
+
const value = raw === undefined ? 604800 : Number(raw);
|
|
12
|
+
assertOperatorInvitationTtl(value);
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { OperatorInvitationAcceptance, OperatorInvitationConflict, OperatorInvitationResult, OperatorMembershipSnapshot, OperatorRole } from "./contracts/operatorInvitations.js";
|
|
2
|
+
export interface OperatorIdentityDirectoryPort {
|
|
3
|
+
ensurePrincipal(input: { email: string; redirectTo: string }): Promise<{ principalId: string; created: boolean }>;
|
|
4
|
+
}
|
|
5
|
+
export interface OperatorInvitationStore {
|
|
6
|
+
issue(input: { actorPrincipalId: string; targetPrincipalId: string; email: string; role: OperatorRole; ttlSeconds: number }): Promise<OperatorInvitationResult | { code: OperatorInvitationConflict }>;
|
|
7
|
+
read(principalId: string): Promise<OperatorMembershipSnapshot | null>;
|
|
8
|
+
/** Trusted server input only: principal must come from successful authentication. */
|
|
9
|
+
acceptOperatorInvitation(principalId: string): Promise<OperatorInvitationAcceptance>;
|
|
10
|
+
remove(input: { actorPrincipalId: string; targetPrincipalId: string; reason?: string }): Promise<{ revoked: true }>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface OperatorIdentity { principalId: string; email: string }
|
|
2
|
+
export interface OperatorMembership extends OperatorIdentity { membershipState: string; role: unknown }
|
|
3
|
+
export interface OperatorIdentityStore {
|
|
4
|
+
issue(input: { email: string; tokenHash: string }): Promise<{ deliverable: false } | { deliverable: true; email: string; expiresAt: string }>;
|
|
5
|
+
/** Activation and consumption commit together before signing. */
|
|
6
|
+
redeem(input: { tokenHash: string }): Promise<OperatorIdentity | null>;
|
|
7
|
+
findMembership(principalId: string): Promise<OperatorMembership | null>;
|
|
8
|
+
bootstrapFirstAdministrator(input: { email: string }): Promise<{ principalId: string; membershipState: "invited" }>;
|
|
9
|
+
pruneChallenges(input: { limit: number }): Promise<{ deleted: number }>;
|
|
10
|
+
close(): Promise<void>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import type { CustomerChallengeDeliveryPort, CustomerSessionIssuerPort } from "./ports.js";
|
|
3
|
+
import type { OperatorIdentityStore } from "./operatorPorts.js";
|
|
4
|
+
import type { OperatorSession } from "./contracts/operatorAuth.js";
|
|
5
|
+
export function createOperatorSessionService(options: {
|
|
6
|
+
store: OperatorIdentityStore; delivery: CustomerChallengeDeliveryPort; issuer: CustomerSessionIssuerPort;
|
|
7
|
+
generateToken?: () => string;
|
|
8
|
+
deliveryFailed?: (code: "operator_challenge_delivery_failed") => void;
|
|
9
|
+
}) {
|
|
10
|
+
const hash = (value: string) => createHash("sha256").update(value, "utf8").digest("hex");
|
|
11
|
+
return {
|
|
12
|
+
async requestChallenge(email: string, accept?: () => Promise<void>): Promise<void> {
|
|
13
|
+
const token = options.generateToken?.() ?? randomBytes(32).toString("base64url");
|
|
14
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(token)) throw new Error("operator_challenge_generator_invalid");
|
|
15
|
+
const result = await options.store.issue({ email: email.trim().toLowerCase(), tokenHash: hash(token) });
|
|
16
|
+
// Send the generic acceptance before mailbox transport, for every eligibility result.
|
|
17
|
+
await accept?.();
|
|
18
|
+
if (!result.deliverable) return;
|
|
19
|
+
try { await options.delivery.deliver({ email: result.email, expiresAt: result.expiresAt, token }); }
|
|
20
|
+
catch { options.deliveryFailed?.("operator_challenge_delivery_failed"); }
|
|
21
|
+
},
|
|
22
|
+
async verifyChallenge(token: string): Promise<OperatorSession | null> {
|
|
23
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(token)) return null;
|
|
24
|
+
const identity = await options.store.redeem({ tokenHash: hash(token) });
|
|
25
|
+
if (!identity) return null;
|
|
26
|
+
const session = await options.issuer.issue(identity);
|
|
27
|
+
return { ...session, user: { id: identity.principalId, email: identity.email } };
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AdminAuthorizationResult,
|
|
5
|
+
AdminAuthPort,
|
|
6
|
+
AdminPrincipalRole,
|
|
7
|
+
} from "./ports.js";
|
|
8
|
+
|
|
9
|
+
const MIN_TOKEN_BYTES = 32;
|
|
10
|
+
const MAX_TOKEN_BYTES = 512;
|
|
11
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
12
|
+
|
|
13
|
+
export type PlatformOperatorAuthConfig = {
|
|
14
|
+
readonly operatorToken: string;
|
|
15
|
+
readonly operatorId: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type PlatformOperatorAllowlistCheck = (principalId: string) => Promise<boolean>;
|
|
19
|
+
|
|
20
|
+
export type PlatformOperatorAuthConfigResolution =
|
|
21
|
+
| { readonly config: PlatformOperatorAuthConfig; readonly error?: undefined }
|
|
22
|
+
| { readonly config?: undefined; readonly error: "platform_operator_token_required" | "platform_operator_token_invalid" | "platform_operator_id_required" | "platform_operator_id_invalid" };
|
|
23
|
+
|
|
24
|
+
function byteLength(value: string): number {
|
|
25
|
+
return Buffer.byteLength(value, "utf8");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
export function resolvePlatformOperatorAuthConfig(
|
|
30
|
+
env: Record<string, string | undefined>,
|
|
31
|
+
): PlatformOperatorAuthConfigResolution {
|
|
32
|
+
const operatorToken = env.PLATFORM_OPERATOR_TOKEN?.trim() ?? "";
|
|
33
|
+
if (!operatorToken) return { error: "platform_operator_token_required" };
|
|
34
|
+
const tokenBytes = byteLength(operatorToken);
|
|
35
|
+
if (tokenBytes < MIN_TOKEN_BYTES || tokenBytes > MAX_TOKEN_BYTES) {
|
|
36
|
+
return { error: "platform_operator_token_invalid" };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const operatorId = env.PLATFORM_OPERATOR_ID?.trim() ?? "";
|
|
40
|
+
if (!operatorId) return { error: "platform_operator_id_required" };
|
|
41
|
+
if (!UUID_PATTERN.test(operatorId)) return { error: "platform_operator_id_invalid" };
|
|
42
|
+
return { config: { operatorToken, operatorId: operatorId.toLowerCase() } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function matchesOperatorToken(candidate: string | null, expected: string): boolean {
|
|
46
|
+
if (!candidate || byteLength(candidate) > MAX_TOKEN_BYTES) return false;
|
|
47
|
+
const actualDigest = createHash("sha256").update(candidate, "utf8").digest();
|
|
48
|
+
const expectedDigest = createHash("sha256").update(expected, "utf8").digest();
|
|
49
|
+
return timingSafeEqual(actualDigest, expectedDigest);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
export function createOperatorTokenAdminAuth(
|
|
54
|
+
config: PlatformOperatorAuthConfig,
|
|
55
|
+
isOperatorAllowed: PlatformOperatorAllowlistCheck,
|
|
56
|
+
): AdminAuthPort {
|
|
57
|
+
return {
|
|
58
|
+
async authorize(
|
|
59
|
+
accessToken: string | null,
|
|
60
|
+
options: { allowedRoles?: readonly AdminPrincipalRole[] } = {},
|
|
61
|
+
): Promise<AdminAuthorizationResult> {
|
|
62
|
+
if (!matchesOperatorToken(accessToken, config.operatorToken)) {
|
|
63
|
+
return { ok: false, code: "UNAUTHORIZED", message: "Admin session required" };
|
|
64
|
+
}
|
|
65
|
+
if (options.allowedRoles && !options.allowedRoles.includes("admin")) {
|
|
66
|
+
return { ok: false, code: "FORBIDDEN", message: "Admin role required" };
|
|
67
|
+
}
|
|
68
|
+
if (!(await isOperatorAllowed(config.operatorId))) {
|
|
69
|
+
return { ok: false, code: "FORBIDDEN", message: "Admin role required" };
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
ok: true,
|
|
73
|
+
principalId: config.operatorId,
|
|
74
|
+
role: "admin",
|
|
75
|
+
isMachineActor: true,
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { PrivacyRefusal, type PersonalDataContributor, type PersonalDataContributorRegistry } from "./privacyPorts.js";
|
|
2
|
+
export function createPersonalDataContributorRegistry<Context>(contributors: readonly PersonalDataContributor<Context>[], options: { requiredContributorIds: readonly string[] }): PersonalDataContributorRegistry<Context> {
|
|
3
|
+
const ids = contributors.map(c => c.id);
|
|
4
|
+
if (new Set(ids).size !== ids.length) throw new PrivacyRefusal("PRIVACY_CONTRIBUTOR_DUPLICATE");
|
|
5
|
+
if (contributors.some(c => !/^[a-z][a-z0-9.-]{0,99}$/.test(c.id) || !/^[a-z][a-z0-9.-]*\.v[1-9][0-9]*$/.test(c.version))) throw new PrivacyRefusal("PRIVACY_OWNERSHIP_UNDECLARED");
|
|
6
|
+
if (contributors.some(c => !Array.isArray(c.ownedResources) || c.ownedResources.length === 0 || c.ownedResources.some(resource => !resource.trim()))) throw new PrivacyRefusal("PRIVACY_OWNERSHIP_UNDECLARED");
|
|
7
|
+
return Object.freeze({ contributors: Object.freeze([...contributors]), missingContributors: Object.freeze(options.requiredContributorIds.filter(id => !ids.includes(id))) });
|
|
8
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type PlatformAdminAuthorizationResult =
|
|
2
|
+
| { ok: true }
|
|
3
|
+
| { ok: false; code: "UNAUTHORIZED" | "FORBIDDEN"; message: string };
|
|
4
|
+
|
|
5
|
+
export type PlatformUserAuthenticationResult =
|
|
6
|
+
| { ok: true; userId: string }
|
|
7
|
+
| { ok: false; code: "UNAUTHORIZED"; message: string };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { assessEmailTrust, type EmailTrustPolicy } from "./emailTrustPolicy.js";
|
|
2
|
+
import type { EmailTrustFailureCode } from "./contracts/emailTrust.js";
|
|
3
|
+
|
|
4
|
+
export interface VerifiedPrincipal {
|
|
5
|
+
principalId: string;
|
|
6
|
+
email: string | null;
|
|
7
|
+
emailVerified: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
export interface IdentityVerifierPort {
|
|
12
|
+
verifyAccessToken(accessToken: string): Promise<VerifiedPrincipal | null>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CustomerChallengeStore {
|
|
16
|
+
issue(input: {
|
|
17
|
+
email: string;
|
|
18
|
+
tokenHash: string;
|
|
19
|
+
expiresAt: string;
|
|
20
|
+
}): Promise<{ deliverable: boolean }>;
|
|
21
|
+
redeem(input: { tokenHash: string; now: string }): Promise<{
|
|
22
|
+
principalId: string;
|
|
23
|
+
email: string;
|
|
24
|
+
} | null>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CustomerChallengeDeliveryPort {
|
|
28
|
+
deliver(input: { email: string; token: string; expiresAt: string }): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CustomerSessionIssuerPort {
|
|
32
|
+
issue(input: { principalId: string; email: string }): Promise<{
|
|
33
|
+
accessToken: string;
|
|
34
|
+
expiresAt: string;
|
|
35
|
+
}>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
export type LinkClientAuthUserResult =
|
|
40
|
+
| {
|
|
41
|
+
ok: true;
|
|
42
|
+
clientId: string;
|
|
43
|
+
authUserId: string;
|
|
44
|
+
created: boolean;
|
|
45
|
+
alreadyLinked: boolean;
|
|
46
|
+
adminSelfLink: boolean;
|
|
47
|
+
}
|
|
48
|
+
| {
|
|
49
|
+
ok: false;
|
|
50
|
+
code: EmailTrustFailureCode | "CLIENT_NOT_FOUND" | "MACHINE_ACTOR_REFUSED" | "CLIENT_AUTH_LINK_CONFLICT";
|
|
51
|
+
message: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type SetClientAuthUserIdResult =
|
|
55
|
+
| { ok: true; alreadyLinked: boolean }
|
|
56
|
+
| { ok: false; code: "CLIENT_AUTH_LINK_CONFLICT"; existingAuthUserId: string | null };
|
|
57
|
+
|
|
58
|
+
export interface ClientAuthLinkStore {
|
|
59
|
+
emailTrustPolicy: EmailTrustPolicy;
|
|
60
|
+
findClientByEmail(email: string): Promise<{ id: string; authUserId: string | null } | null>;
|
|
61
|
+
findAuthUserIdByEmail(email: string): Promise<string | null>;
|
|
62
|
+
findAuthUserEmailById(authUserId: string): Promise<string | null>;
|
|
63
|
+
/** Provision an unconfirmed principal; null means an existing identity needs lookup. */
|
|
64
|
+
createAuthUser(email: string): Promise<string | null>;
|
|
65
|
+
findAdminIdentity(authUserId: string): Promise<{ isMachineActor: boolean } | null>;
|
|
66
|
+
setClientAuthUserId(clientId: string, authUserId: string): Promise<SetClientAuthUserIdResult>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Link a commerce contact without proving mailbox ownership; failed links never delete identities. */
|
|
70
|
+
export async function linkClientAuthUser(
|
|
71
|
+
store: ClientAuthLinkStore,
|
|
72
|
+
rawEmail: string,
|
|
73
|
+
): Promise<LinkClientAuthUserResult> {
|
|
74
|
+
const email = rawEmail.trim().toLowerCase();
|
|
75
|
+
const client = await store.findClientByEmail(email);
|
|
76
|
+
if (!client) return { ok: false, code: "CLIENT_NOT_FOUND", message: "No client row exists for this email; provision the client first." };
|
|
77
|
+
|
|
78
|
+
if (client.authUserId) {
|
|
79
|
+
const linkedEmail = await store.findAuthUserEmailById(client.authUserId);
|
|
80
|
+
if (linkedEmail?.trim().toLowerCase() !== email) {
|
|
81
|
+
return { ok: false, code: "CLIENT_AUTH_LINK_CONFLICT", message: "Client account is linked to a different auth identity." };
|
|
82
|
+
}
|
|
83
|
+
return { ok: true, clientId: client.id, authUserId: client.authUserId, created: false, alreadyLinked: true, adminSelfLink: false };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const trust = await assessEmailTrust(store.emailTrustPolicy);
|
|
87
|
+
if (!trust.trusted) return { ok: false, code: trust.code, message: "Soft-account provisioning requires a trusted email envelope." };
|
|
88
|
+
|
|
89
|
+
const { authUserId, created } = await findOrCreateAuthUserId(store, email);
|
|
90
|
+
const adminIdentity = await store.findAdminIdentity(authUserId);
|
|
91
|
+
if (adminIdentity?.isMachineActor) {
|
|
92
|
+
return { ok: false, code: "MACHINE_ACTOR_REFUSED", message: "Refusing to link a machine/service admin identity to a customer client row." };
|
|
93
|
+
}
|
|
94
|
+
const link = await store.setClientAuthUserId(client.id, authUserId);
|
|
95
|
+
if (!link.ok) {
|
|
96
|
+
return { ok: false, code: "CLIENT_AUTH_LINK_CONFLICT", message: "Client account was linked to another auth identity concurrently." };
|
|
97
|
+
}
|
|
98
|
+
return { ok: true, clientId: client.id, authUserId, created, alreadyLinked: link.alreadyLinked, adminSelfLink: adminIdentity !== null };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
export type AdminPrincipalRole = "admin" | "distributor";
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
export type AdminAuthorizationResult =
|
|
106
|
+
| { ok: true; principalId: string; role: AdminPrincipalRole; isMachineActor: boolean }
|
|
107
|
+
| { ok: false; code: "UNAUTHORIZED" | "FORBIDDEN"; message: string };
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
export interface AdminAuthPort {
|
|
111
|
+
authorize(
|
|
112
|
+
accessToken: string | null,
|
|
113
|
+
options?: { allowedRoles?: readonly AdminPrincipalRole[] },
|
|
114
|
+
): Promise<AdminAuthorizationResult>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
export interface CustomerAccount {
|
|
119
|
+
id: string;
|
|
120
|
+
|
|
121
|
+
email: string | null;
|
|
122
|
+
|
|
123
|
+
principalId: string | null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface CreateAccountInput {
|
|
127
|
+
email: string;
|
|
128
|
+
principalId: string;
|
|
129
|
+
acquisitionSource: string;
|
|
130
|
+
lifecycleStage: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type LinkPrincipalResult =
|
|
134
|
+
| { ok: true; alreadyLinked: boolean }
|
|
135
|
+
| { ok: false; existingPrincipalId: string | null };
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
export interface AccountLinkStore {
|
|
139
|
+
findAccountByPrincipalId(principalId: string): Promise<CustomerAccount | null>;
|
|
140
|
+
findAccountByEmail(email: string): Promise<CustomerAccount | null>;
|
|
141
|
+
|
|
142
|
+
isReservedPrincipal(principalId: string): Promise<boolean>;
|
|
143
|
+
|
|
144
|
+
linkPrincipalToAccount(accountId: string, principalId: string): Promise<LinkPrincipalResult>;
|
|
145
|
+
createAccount(input: CreateAccountInput): Promise<{ id: string }>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Attempt creation before scanning the existing identity directory. */
|
|
149
|
+
export async function findOrCreateAuthUserId(
|
|
150
|
+
store: Pick<ClientAuthLinkStore, "createAuthUser" | "findAuthUserIdByEmail">,
|
|
151
|
+
email: string,
|
|
152
|
+
): Promise<{ authUserId: string; created: boolean }> {
|
|
153
|
+
const createdId = await store.createAuthUser(email);
|
|
154
|
+
if (createdId) return { authUserId: createdId, created: true };
|
|
155
|
+
const existingId = await store.findAuthUserIdByEmail(email);
|
|
156
|
+
if (!existingId) throw new Error("auth_user_registered_but_unresolved");
|
|
157
|
+
return { authUserId: existingId, created: false };
|
|
158
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createPostgresRoleFreeTransactionLane, type PgQueryExecutor } from "../data/index.js";
|
|
2
|
+
import type { AccountLinkStore, CustomerAccount } from "./ports.js";
|
|
3
|
+
/** Trusted contact-link capability; conditional writes never replace an existing principal. */
|
|
4
|
+
export function createPostgresAccountLinkStore(env: { connectionString: string }): AccountLinkStore & { close(): Promise<void> } {
|
|
5
|
+
const lane = createPostgresRoleFreeTransactionLane(env);
|
|
6
|
+
const run = <T>(work: (db: PgQueryExecutor) => Promise<T>) => lane.run(db => work(db as PgQueryExecutor));
|
|
7
|
+
const account = (row: Record<string, unknown> | undefined): CustomerAccount | null => row
|
|
8
|
+
? { id: String(row.id), email: row.email === null ? null : String(row.email), principalId: row.principal_id === null ? null : String(row.principal_id) } : null;
|
|
9
|
+
async function writable(db: PgQueryExecutor, principalId: string, clientId: string | null = null) {
|
|
10
|
+
// Same receipt-before-contact lock order as privacy erasure; no concurrent resurrection.
|
|
11
|
+
await db.query("LOCK TABLE public.identity_privacy_erasures IN SHARE MODE");
|
|
12
|
+
if ((await db.query("SELECT 1 FROM public.identity_privacy_erasures WHERE principal_id = $1 OR client_id = $2 LIMIT 1", [principalId, clientId])).rows.length) throw new Error("account_erased_refused");
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
findAccountByPrincipalId(id) {
|
|
16
|
+
return run(async db => account((await db.query("SELECT id, email, principal_id FROM public.clients WHERE principal_id = $1", [id])).rows[0]));
|
|
17
|
+
},
|
|
18
|
+
findAccountByEmail(email) {
|
|
19
|
+
return run(async db => {
|
|
20
|
+
const rows = (await db.query("SELECT id, email, principal_id FROM public.clients WHERE lower(btrim(email)) = $1 LIMIT 2", [email.trim().toLowerCase()])).rows;
|
|
21
|
+
if (rows.length > 1) throw new Error("account_normalized_email_ambiguous");
|
|
22
|
+
return account(rows[0]);
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
isReservedPrincipal(id) {
|
|
26
|
+
return run(async db => (await db.query("SELECT 1 FROM public.platform_control_operators WHERE principal_id = $1 UNION ALL SELECT 1 FROM public.identity_privacy_erasures WHERE principal_id = $1 LIMIT 1", [id])).rows.length > 0);
|
|
27
|
+
},
|
|
28
|
+
linkPrincipalToAccount(id, principalId) {
|
|
29
|
+
return run(async db => {
|
|
30
|
+
await writable(db, principalId, id);
|
|
31
|
+
const updated = await db.query("UPDATE public.clients SET principal_id = $2 WHERE id = $1 AND principal_id IS NULL RETURNING id", [id, principalId]);
|
|
32
|
+
if (updated.rows.length) return { ok: true, alreadyLinked: false };
|
|
33
|
+
const row = (await db.query("SELECT principal_id FROM public.clients WHERE id = $1", [id])).rows[0];
|
|
34
|
+
return row?.principal_id === principalId ? { ok: true, alreadyLinked: true }
|
|
35
|
+
: { ok: false, existingPrincipalId: row?.principal_id == null ? null : String(row.principal_id) };
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
createAccount(input) {
|
|
39
|
+
return run(async db => {
|
|
40
|
+
await writable(db, input.principalId);
|
|
41
|
+
const row = (await db.query(`INSERT INTO public.clients (email, principal_id, acquisition_source, lifecycle_stage)
|
|
42
|
+
VALUES ($1, $2, $3, $4) RETURNING id`, [input.email, input.principalId, input.acquisitionSource, input.lifecycleStage])).rows[0];
|
|
43
|
+
if (!row) throw new Error("account_insert_failed");
|
|
44
|
+
return { id: String(row.id) };
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
close: lane.close,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { CustomerMeResponse } from "./customerContracts.js";
|
|
2
|
+
import type { CustomerMePort } from "./customerPorts.js";
|
|
3
|
+
import type { PgGatewayClient } from "../data/index.js";
|
|
4
|
+
|
|
5
|
+
type CustomerMeRow = {
|
|
6
|
+
id: string;
|
|
7
|
+
email: string;
|
|
8
|
+
first_name: string | null;
|
|
9
|
+
last_name: string | null;
|
|
10
|
+
lifecycle_stage: CustomerMeResponse["lifecycleStage"];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
export function createPostgresCustomerMePort(gateway: PgGatewayClient): CustomerMePort {
|
|
15
|
+
return {
|
|
16
|
+
async getCustomerMe(): Promise<CustomerMeResponse | null> {
|
|
17
|
+
const { data, error } = await gateway
|
|
18
|
+
.from<CustomerMeRow>("clients")
|
|
19
|
+
.select("id, email, first_name, last_name, lifecycle_stage")
|
|
20
|
+
.maybeSingle();
|
|
21
|
+
if (error) throw error;
|
|
22
|
+
if (!data) return null;
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
clientId: data.id,
|
|
26
|
+
email: data.email,
|
|
27
|
+
firstName: data.first_name,
|
|
28
|
+
lastName: data.last_name,
|
|
29
|
+
lifecycleStage: data.lifecycle_stage as CustomerMeResponse["lifecycleStage"],
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { CustomerChallengeStore } from "./ports.js";
|
|
2
|
+
import {
|
|
3
|
+
createPostgresRoleFreeTransactionLane,
|
|
4
|
+
type PostgresDataGatewayEnv,
|
|
5
|
+
type PostgresDataGatewayOptions,
|
|
6
|
+
} from "../data/index.js";
|
|
7
|
+
import type { PgQueryExecutor } from "../data/index.js";
|
|
8
|
+
|
|
9
|
+
export type PostgresCustomerChallengeStore = CustomerChallengeStore & {
|
|
10
|
+
close(): Promise<void>;
|
|
11
|
+
pruneChallenges(input: { limit: number }): Promise<{ deleted: number }>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function createPostgresCustomerChallengeStore(
|
|
15
|
+
env: PostgresDataGatewayEnv,
|
|
16
|
+
options: PostgresDataGatewayOptions = {},
|
|
17
|
+
): PostgresCustomerChallengeStore {
|
|
18
|
+
const lane = createPostgresRoleFreeTransactionLane(env, options);
|
|
19
|
+
return {
|
|
20
|
+
async issue(input) {
|
|
21
|
+
return lane.run(async (executor) => {
|
|
22
|
+
const db = executor as PgQueryExecutor;
|
|
23
|
+
// EXCLUSIVE conflicts with erasure's EXCLUSIVE lock and stays held through the RPC.
|
|
24
|
+
// Receipt reads use ACCESS SHARE, compatible with erasure's receipt-table lock.
|
|
25
|
+
await db.query("LOCK TABLE public.customer_identity_challenges IN EXCLUSIVE MODE");
|
|
26
|
+
const erased = await db.query(`SELECT 1 FROM public.clients c JOIN public.identity_privacy_erasures e ON e.client_id = c.id OR e.principal_id = c.principal_id
|
|
27
|
+
WHERE lower(c.email) = lower(btrim($1))`, [input.email]);
|
|
28
|
+
if (erased.rows.length) return { deliverable: false };
|
|
29
|
+
const { rows } = await db.query(
|
|
30
|
+
"SELECT public.customer_identity_challenge_issue($1,$2,$3::timestamptz,now()) AS result",
|
|
31
|
+
[input.email, input.tokenHash, input.expiresAt],
|
|
32
|
+
);
|
|
33
|
+
const result = rows[0]?.result;
|
|
34
|
+
if (!result || typeof result !== "object") throw new Error("customer challenge issue response invalid");
|
|
35
|
+
return { deliverable: (result as { deliverable?: unknown }).deliverable === true };
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
async redeem(input) {
|
|
39
|
+
return lane.run(async (executor) => {
|
|
40
|
+
const db = executor as PgQueryExecutor;
|
|
41
|
+
// EXCLUSIVE conflicts with erasure's EXCLUSIVE lock and stays held through the RPC.
|
|
42
|
+
// Receipt reads use ACCESS SHARE, compatible with erasure's receipt-table lock.
|
|
43
|
+
await db.query("LOCK TABLE public.customer_identity_challenges IN EXCLUSIVE MODE");
|
|
44
|
+
const erased = await db.query(`SELECT 1 FROM public.customer_identity_challenges c
|
|
45
|
+
JOIN public.identity_privacy_erasures e ON e.client_id = c.client_id OR e.principal_id = c.principal_id WHERE c.token_hash = $1`, [input.tokenHash]);
|
|
46
|
+
if (erased.rows.length) return null;
|
|
47
|
+
const { rows } = await db.query(
|
|
48
|
+
"SELECT public.customer_identity_challenge_redeem($1,$2::timestamptz) AS result",
|
|
49
|
+
[input.tokenHash, input.now],
|
|
50
|
+
);
|
|
51
|
+
const result = rows[0]?.result;
|
|
52
|
+
if (result === null || result === undefined) return null;
|
|
53
|
+
if (!result || typeof result !== "object") throw new Error("customer challenge redeem response invalid");
|
|
54
|
+
const value = result as Record<string, unknown>;
|
|
55
|
+
if (typeof value.principalId !== "string" || typeof value.email !== "string") {
|
|
56
|
+
throw new Error("customer challenge redeem response invalid");
|
|
57
|
+
}
|
|
58
|
+
return { principalId: value.principalId, email: value.email };
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
async pruneChallenges({ limit }) {
|
|
62
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 500) throw new Error("customer_prune_limit_invalid");
|
|
63
|
+
return lane.run(async executor => {
|
|
64
|
+
const deleted = await (executor as PgQueryExecutor).query(`WITH batch AS (SELECT id FROM public.customer_identity_challenges
|
|
65
|
+
WHERE expires_at < clock_timestamp() - interval '24 hours' ORDER BY expires_at, id LIMIT $1 FOR UPDATE SKIP LOCKED)
|
|
66
|
+
DELETE FROM public.customer_identity_challenges c USING batch WHERE c.id = batch.id RETURNING c.id`, [limit]);
|
|
67
|
+
return { deleted: deleted.rows.length };
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
close: lane.close,
|
|
71
|
+
};
|
|
72
|
+
}
|