@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.
Files changed (239) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +350 -3
  3. package/dist/identity/adminAuthBinding.d.ts +28 -0
  4. package/dist/identity/adminAuthBinding.js +66 -0
  5. package/dist/identity/adminAuthorization.d.ts +34 -0
  6. package/dist/identity/adminAuthorization.js +72 -0
  7. package/dist/identity/adminContracts.d.ts +70 -0
  8. package/dist/identity/adminContracts.js +43 -0
  9. package/dist/identity/adminMagicLinkHandler.d.ts +39 -0
  10. package/dist/identity/adminMagicLinkHandler.js +94 -0
  11. package/dist/identity/adminMeHandler.d.ts +15 -0
  12. package/dist/identity/adminMeHandler.js +49 -0
  13. package/dist/identity/adminPreflight.d.ts +16 -0
  14. package/dist/identity/adminPreflight.js +38 -0
  15. package/dist/identity/adminUserActionPorts.d.ts +31 -0
  16. package/dist/identity/adminUserActionPorts.js +51 -0
  17. package/dist/identity/adminUserHandlers.d.ts +38 -0
  18. package/dist/identity/adminUserHandlers.js +140 -0
  19. package/dist/identity/authVerifyUrl.d.ts +6 -0
  20. package/dist/identity/authVerifyUrl.js +54 -0
  21. package/dist/identity/contracts/contracts.d.ts +8 -0
  22. package/dist/identity/contracts/contracts.js +2 -0
  23. package/dist/identity/contracts/emailTrust.d.ts +3 -0
  24. package/dist/identity/contracts/emailTrust.js +4 -0
  25. package/dist/identity/contracts/index.d.ts +8 -0
  26. package/dist/identity/contracts/index.js +8 -0
  27. package/dist/identity/contracts/oidc.d.ts +3 -0
  28. package/dist/identity/contracts/oidc.js +9 -0
  29. package/dist/identity/contracts/operatorAuth.d.ts +35 -0
  30. package/dist/identity/contracts/operatorAuth.js +53 -0
  31. package/dist/identity/contracts/operatorInvitations.d.ts +37 -0
  32. package/dist/identity/contracts/operatorInvitations.js +1 -0
  33. package/dist/identity/contracts/ports.d.ts +22 -0
  34. package/dist/identity/contracts/ports.js +1 -0
  35. package/dist/identity/contracts/privacy.d.ts +62 -0
  36. package/dist/identity/contracts/privacy.js +99 -0
  37. package/dist/identity/contracts/providerList.d.ts +3 -0
  38. package/dist/identity/contracts/providerList.js +11 -0
  39. package/dist/identity/contracts/types.d.ts +16 -0
  40. package/dist/identity/contracts/types.js +4 -0
  41. package/dist/identity/contribution.d.ts +8 -0
  42. package/dist/identity/contribution.js +22 -0
  43. package/dist/identity/customerAuth.d.ts +8 -0
  44. package/dist/identity/customerAuth.js +1 -0
  45. package/dist/identity/customerAuthorization.d.ts +5 -0
  46. package/dist/identity/customerAuthorization.js +22 -0
  47. package/dist/identity/customerContracts.d.ts +42 -0
  48. package/dist/identity/customerContracts.js +43 -0
  49. package/dist/identity/customerIdentityBinding.d.ts +44 -0
  50. package/dist/identity/customerIdentityBinding.js +60 -0
  51. package/dist/identity/customerIdentityVerifier.d.ts +8 -0
  52. package/dist/identity/customerIdentityVerifier.js +101 -0
  53. package/dist/identity/customerMagicLinkHandler.d.ts +38 -0
  54. package/dist/identity/customerMagicLinkHandler.js +115 -0
  55. package/dist/identity/customerMeHandler.d.ts +11 -0
  56. package/dist/identity/customerMeHandler.js +46 -0
  57. package/dist/identity/customerPorts.d.ts +4 -0
  58. package/dist/identity/customerPorts.js +1 -0
  59. package/dist/identity/customerSessionBinding.d.ts +38 -0
  60. package/dist/identity/customerSessionBinding.js +80 -0
  61. package/dist/identity/customerSessionIssuer.d.ts +10 -0
  62. package/dist/identity/customerSessionIssuer.js +57 -0
  63. package/dist/identity/customerVerifyOtpHandler.d.ts +8 -0
  64. package/dist/identity/customerVerifyOtpHandler.js +41 -0
  65. package/dist/identity/directOperatorInvitationStore.d.ts +19 -0
  66. package/dist/identity/directOperatorInvitationStore.js +193 -0
  67. package/dist/identity/emailTrustPolicy.d.ts +14 -0
  68. package/dist/identity/emailTrustPolicy.js +16 -0
  69. package/dist/identity/identityPersonalDataContributor.d.ts +7 -0
  70. package/dist/identity/identityPersonalDataContributor.js +46 -0
  71. package/dist/identity/identityReadiness.d.ts +10 -0
  72. package/dist/identity/identityReadiness.js +25 -0
  73. package/dist/identity/index.d.ts +67 -0
  74. package/dist/identity/index.js +66 -0
  75. package/dist/identity/inviteAdminUserUseCase.d.ts +89 -0
  76. package/dist/identity/inviteAdminUserUseCase.js +75 -0
  77. package/dist/identity/jwtLibrary.d.ts +54 -0
  78. package/dist/identity/jwtLibrary.js +1 -0
  79. package/dist/identity/machineActorWriteFence.d.ts +4 -0
  80. package/dist/identity/machineActorWriteFence.js +13 -0
  81. package/dist/identity/magicLinkPorts.d.ts +28 -0
  82. package/dist/identity/magicLinkPorts.js +1 -0
  83. package/dist/identity/managedAccountLinkStore.d.ts +2 -0
  84. package/dist/identity/managedAccountLinkStore.js +93 -0
  85. package/dist/identity/managedAdminAuthVerifier.d.ts +7 -0
  86. package/dist/identity/managedAdminAuthVerifier.js +27 -0
  87. package/dist/identity/managedAdminUserGateways.d.ts +123 -0
  88. package/dist/identity/managedAdminUserGateways.js +127 -0
  89. package/dist/identity/managedClient.d.ts +90 -0
  90. package/dist/identity/managedClient.js +10 -0
  91. package/dist/identity/managedClientAuthLink.d.ts +3 -0
  92. package/dist/identity/managedClientAuthLink.js +115 -0
  93. package/dist/identity/managedCustomerMe.d.ts +13 -0
  94. package/dist/identity/managedCustomerMe.js +30 -0
  95. package/dist/identity/managedEmailTrustPolicy.d.ts +11 -0
  96. package/dist/identity/managedEmailTrustPolicy.js +105 -0
  97. package/dist/identity/managedIdentityVerifier.d.ts +2 -0
  98. package/dist/identity/managedIdentityVerifier.js +16 -0
  99. package/dist/identity/managedPrivacy.d.ts +4 -0
  100. package/dist/identity/managedPrivacy.js +39 -0
  101. package/dist/identity/messages.d.ts +29 -0
  102. package/dist/identity/messages.js +39 -0
  103. package/dist/identity/oidcIdentityVerifier.d.ts +45 -0
  104. package/dist/identity/oidcIdentityVerifier.js +309 -0
  105. package/dist/identity/oidcPrincipal.d.ts +2 -0
  106. package/dist/identity/oidcPrincipal.js +10 -0
  107. package/dist/identity/operatorAdminAuth.d.ts +6 -0
  108. package/dist/identity/operatorAdminAuth.js +13 -0
  109. package/dist/identity/operatorAuthHandlers.d.ts +18 -0
  110. package/dist/identity/operatorAuthHandlers.js +71 -0
  111. package/dist/identity/operatorIdentityVerifier.d.ts +8 -0
  112. package/dist/identity/operatorIdentityVerifier.js +19 -0
  113. package/dist/identity/operatorInvitationLifecycle.d.ts +7 -0
  114. package/dist/identity/operatorInvitationLifecycle.js +15 -0
  115. package/dist/identity/operatorInvitationPorts.d.ts +31 -0
  116. package/dist/identity/operatorInvitationPorts.js +1 -0
  117. package/dist/identity/operatorPorts.d.ts +37 -0
  118. package/dist/identity/operatorPorts.js +1 -0
  119. package/dist/identity/operatorSession.d.ts +13 -0
  120. package/dist/identity/operatorSession.js +31 -0
  121. package/dist/identity/operatorTokenAdminAuth.d.ts +15 -0
  122. package/dist/identity/operatorTokenAdminAuth.js +50 -0
  123. package/dist/identity/personalDataRegistry.d.ts +4 -0
  124. package/dist/identity/personalDataRegistry.js +11 -0
  125. package/dist/identity/platformAdminAuth.d.ts +15 -0
  126. package/dist/identity/platformAdminAuth.js +1 -0
  127. package/dist/identity/ports.d.ts +127 -0
  128. package/dist/identity/ports.js +38 -0
  129. package/dist/identity/postgresAccountLinkStore.d.ts +7 -0
  130. package/dist/identity/postgresAccountLinkStore.js +52 -0
  131. package/dist/identity/postgresCustomerMe.d.ts +3 -0
  132. package/dist/identity/postgresCustomerMe.js +21 -0
  133. package/dist/identity/postgresCustomerRecovery.d.ts +11 -0
  134. package/dist/identity/postgresCustomerRecovery.js +57 -0
  135. package/dist/identity/postgresOperatorIdentity.d.ts +5 -0
  136. package/dist/identity/postgresOperatorIdentity.js +137 -0
  137. package/dist/identity/postgresPrivacy.d.ts +7 -0
  138. package/dist/identity/postgresPrivacy.js +126 -0
  139. package/dist/identity/privacyHandlers.d.ts +10 -0
  140. package/dist/identity/privacyHandlers.js +35 -0
  141. package/dist/identity/privacyPorts.d.ts +72 -0
  142. package/dist/identity/privacyPorts.js +8 -0
  143. package/dist/identity/privacyUseCases.d.ts +5 -0
  144. package/dist/identity/privacyUseCases.js +55 -0
  145. package/dist/identity/reconcileAccountForPrincipal.d.ts +20 -0
  146. package/dist/identity/reconcileAccountForPrincipal.js +66 -0
  147. package/dist/identity/reconcileAccountHandler.d.ts +11 -0
  148. package/dist/identity/reconcileAccountHandler.js +30 -0
  149. package/dist/identity/removeAdminUserUseCase.d.ts +31 -0
  150. package/dist/identity/removeAdminUserUseCase.js +60 -0
  151. package/dist/identity/response.d.ts +3 -0
  152. package/dist/identity/response.js +2 -0
  153. package/dist/identity/sql.d.ts +44 -0
  154. package/dist/identity/sql.js +10 -0
  155. package/package.json +12 -2
  156. package/release-gates.json +22 -0
  157. package/sql/20260810180001_self_host_actor_auth.sql +40 -0
  158. package/sql/20260810204057_customer_identity_read.sql +31 -0
  159. package/sql/20260814090000_customer_selfservice_recovery.sql +477 -0
  160. package/sql/20260901143000_admin_membership_authority_foundation.sql +234 -0
  161. package/sql/20261018120000_identity_operator_invitations.sql +42 -0
  162. package/sql/20261018180000_identity_operator_challenges.sql +56 -0
  163. package/sql/20261018190000_identity_privacy_erasures.sql +15 -0
  164. package/src/identity/adminAuthBinding.ts +76 -0
  165. package/src/identity/adminAuthorization.ts +106 -0
  166. package/src/identity/adminContracts.ts +52 -0
  167. package/src/identity/adminMagicLinkHandler.ts +132 -0
  168. package/src/identity/adminMeHandler.ts +64 -0
  169. package/src/identity/adminPreflight.ts +46 -0
  170. package/src/identity/adminUserActionPorts.ts +78 -0
  171. package/src/identity/adminUserHandlers.ts +174 -0
  172. package/src/identity/authVerifyUrl.ts +56 -0
  173. package/src/identity/contracts/contracts.ts +15 -0
  174. package/src/identity/contracts/emailTrust.ts +5 -0
  175. package/src/identity/contracts/index.ts +8 -0
  176. package/src/identity/contracts/oidc.ts +10 -0
  177. package/src/identity/contracts/operatorAuth.ts +61 -0
  178. package/src/identity/contracts/operatorInvitations.ts +32 -0
  179. package/src/identity/contracts/ports.ts +25 -0
  180. package/src/identity/contracts/privacy.ts +86 -0
  181. package/src/identity/contracts/providerList.ts +13 -0
  182. package/src/identity/contracts/types.ts +25 -0
  183. package/src/identity/contribution.ts +29 -0
  184. package/src/identity/customerAuth.ts +3 -0
  185. package/src/identity/customerAuthorization.ts +38 -0
  186. package/src/identity/customerContracts.ts +52 -0
  187. package/src/identity/customerIdentityBinding.ts +125 -0
  188. package/src/identity/customerIdentityVerifier.ts +141 -0
  189. package/src/identity/customerMagicLinkHandler.ts +171 -0
  190. package/src/identity/customerMeHandler.ts +64 -0
  191. package/src/identity/customerPorts.ts +2 -0
  192. package/src/identity/customerSessionBinding.ts +114 -0
  193. package/src/identity/customerSessionIssuer.ts +71 -0
  194. package/src/identity/customerVerifyOtpHandler.ts +44 -0
  195. package/src/identity/directOperatorInvitationStore.ts +178 -0
  196. package/src/identity/emailTrustPolicy.ts +16 -0
  197. package/src/identity/identityPersonalDataContributor.ts +48 -0
  198. package/src/identity/identityReadiness.ts +26 -0
  199. package/src/identity/index.ts +69 -0
  200. package/src/identity/inviteAdminUserUseCase.ts +134 -0
  201. package/src/identity/jwtLibrary.ts +24 -0
  202. package/src/identity/machineActorWriteFence.ts +19 -0
  203. package/src/identity/magicLinkPorts.ts +7 -0
  204. package/src/identity/managedAccountLinkStore.ts +105 -0
  205. package/src/identity/managedAdminAuthVerifier.ts +40 -0
  206. package/src/identity/managedAdminUserGateways.ts +164 -0
  207. package/src/identity/managedClient.ts +58 -0
  208. package/src/identity/managedClientAuthLink.ts +120 -0
  209. package/src/identity/managedCustomerMe.ts +52 -0
  210. package/src/identity/managedEmailTrustPolicy.ts +90 -0
  211. package/src/identity/managedIdentityVerifier.ts +20 -0
  212. package/src/identity/managedPrivacy.ts +37 -0
  213. package/src/identity/messages.ts +41 -0
  214. package/src/identity/oidcIdentityVerifier.ts +229 -0
  215. package/src/identity/oidcPrincipal.ts +10 -0
  216. package/src/identity/operatorAdminAuth.ts +14 -0
  217. package/src/identity/operatorAuthHandlers.ts +52 -0
  218. package/src/identity/operatorIdentityVerifier.ts +19 -0
  219. package/src/identity/operatorInvitationLifecycle.ts +14 -0
  220. package/src/identity/operatorInvitationPorts.ts +11 -0
  221. package/src/identity/operatorPorts.ts +11 -0
  222. package/src/identity/operatorSession.ts +30 -0
  223. package/src/identity/operatorTokenAdminAuth.ts +79 -0
  224. package/src/identity/personalDataRegistry.ts +8 -0
  225. package/src/identity/platformAdminAuth.ts +7 -0
  226. package/src/identity/ports.ts +158 -0
  227. package/src/identity/postgresAccountLinkStore.ts +49 -0
  228. package/src/identity/postgresCustomerMe.ts +33 -0
  229. package/src/identity/postgresCustomerRecovery.ts +72 -0
  230. package/src/identity/postgresOperatorIdentity.ts +123 -0
  231. package/src/identity/postgresPrivacy.ts +117 -0
  232. package/src/identity/privacyHandlers.ts +33 -0
  233. package/src/identity/privacyPorts.ts +52 -0
  234. package/src/identity/privacyUseCases.ts +48 -0
  235. package/src/identity/reconcileAccountForPrincipal.ts +99 -0
  236. package/src/identity/reconcileAccountHandler.ts +36 -0
  237. package/src/identity/removeAdminUserUseCase.ts +85 -0
  238. package/src/identity/response.ts +3 -0
  239. package/src/identity/sql.ts +10 -0
@@ -0,0 +1,50 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+ const MIN_TOKEN_BYTES = 32;
3
+ const MAX_TOKEN_BYTES = 512;
4
+ 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;
5
+ function byteLength(value) {
6
+ return Buffer.byteLength(value, "utf8");
7
+ }
8
+ export function resolvePlatformOperatorAuthConfig(env) {
9
+ const operatorToken = env.PLATFORM_OPERATOR_TOKEN?.trim() ?? "";
10
+ if (!operatorToken)
11
+ return { error: "platform_operator_token_required" };
12
+ const tokenBytes = byteLength(operatorToken);
13
+ if (tokenBytes < MIN_TOKEN_BYTES || tokenBytes > MAX_TOKEN_BYTES) {
14
+ return { error: "platform_operator_token_invalid" };
15
+ }
16
+ const operatorId = env.PLATFORM_OPERATOR_ID?.trim() ?? "";
17
+ if (!operatorId)
18
+ return { error: "platform_operator_id_required" };
19
+ if (!UUID_PATTERN.test(operatorId))
20
+ return { error: "platform_operator_id_invalid" };
21
+ return { config: { operatorToken, operatorId: operatorId.toLowerCase() } };
22
+ }
23
+ function matchesOperatorToken(candidate, expected) {
24
+ if (!candidate || byteLength(candidate) > MAX_TOKEN_BYTES)
25
+ return false;
26
+ const actualDigest = createHash("sha256").update(candidate, "utf8").digest();
27
+ const expectedDigest = createHash("sha256").update(expected, "utf8").digest();
28
+ return timingSafeEqual(actualDigest, expectedDigest);
29
+ }
30
+ export function createOperatorTokenAdminAuth(config, isOperatorAllowed) {
31
+ return {
32
+ async authorize(accessToken, options = {}) {
33
+ if (!matchesOperatorToken(accessToken, config.operatorToken)) {
34
+ return { ok: false, code: "UNAUTHORIZED", message: "Admin session required" };
35
+ }
36
+ if (options.allowedRoles && !options.allowedRoles.includes("admin")) {
37
+ return { ok: false, code: "FORBIDDEN", message: "Admin role required" };
38
+ }
39
+ if (!(await isOperatorAllowed(config.operatorId))) {
40
+ return { ok: false, code: "FORBIDDEN", message: "Admin role required" };
41
+ }
42
+ return {
43
+ ok: true,
44
+ principalId: config.operatorId,
45
+ role: "admin",
46
+ isMachineActor: true,
47
+ };
48
+ },
49
+ };
50
+ }
@@ -0,0 +1,4 @@
1
+ import { type PersonalDataContributor, type PersonalDataContributorRegistry } from "./privacyPorts.js";
2
+ export declare function createPersonalDataContributorRegistry<Context>(contributors: readonly PersonalDataContributor<Context>[], options: {
3
+ requiredContributorIds: readonly string[];
4
+ }): PersonalDataContributorRegistry<Context>;
@@ -0,0 +1,11 @@
1
+ import { PrivacyRefusal } from "./privacyPorts.js";
2
+ export function createPersonalDataContributorRegistry(contributors, options) {
3
+ const ids = contributors.map(c => c.id);
4
+ if (new Set(ids).size !== ids.length)
5
+ throw new PrivacyRefusal("PRIVACY_CONTRIBUTOR_DUPLICATE");
6
+ 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)))
7
+ throw new PrivacyRefusal("PRIVACY_OWNERSHIP_UNDECLARED");
8
+ if (contributors.some(c => !Array.isArray(c.ownedResources) || c.ownedResources.length === 0 || c.ownedResources.some(resource => !resource.trim())))
9
+ throw new PrivacyRefusal("PRIVACY_OWNERSHIP_UNDECLARED");
10
+ return Object.freeze({ contributors: Object.freeze([...contributors]), missingContributors: Object.freeze(options.requiredContributorIds.filter(id => !ids.includes(id))) });
11
+ }
@@ -0,0 +1,15 @@
1
+ export type PlatformAdminAuthorizationResult = {
2
+ ok: true;
3
+ } | {
4
+ ok: false;
5
+ code: "UNAUTHORIZED" | "FORBIDDEN";
6
+ message: string;
7
+ };
8
+ export type PlatformUserAuthenticationResult = {
9
+ ok: true;
10
+ userId: string;
11
+ } | {
12
+ ok: false;
13
+ code: "UNAUTHORIZED";
14
+ message: string;
15
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,127 @@
1
+ import { type EmailTrustPolicy } from "./emailTrustPolicy.js";
2
+ import type { EmailTrustFailureCode } from "./contracts/emailTrust.js";
3
+ export interface VerifiedPrincipal {
4
+ principalId: string;
5
+ email: string | null;
6
+ emailVerified: boolean;
7
+ }
8
+ export interface IdentityVerifierPort {
9
+ verifyAccessToken(accessToken: string): Promise<VerifiedPrincipal | null>;
10
+ }
11
+ export interface CustomerChallengeStore {
12
+ issue(input: {
13
+ email: string;
14
+ tokenHash: string;
15
+ expiresAt: string;
16
+ }): Promise<{
17
+ deliverable: boolean;
18
+ }>;
19
+ redeem(input: {
20
+ tokenHash: string;
21
+ now: string;
22
+ }): Promise<{
23
+ principalId: string;
24
+ email: string;
25
+ } | null>;
26
+ }
27
+ export interface CustomerChallengeDeliveryPort {
28
+ deliver(input: {
29
+ email: string;
30
+ token: string;
31
+ expiresAt: string;
32
+ }): Promise<void>;
33
+ }
34
+ export interface CustomerSessionIssuerPort {
35
+ issue(input: {
36
+ principalId: string;
37
+ email: string;
38
+ }): Promise<{
39
+ accessToken: string;
40
+ expiresAt: string;
41
+ }>;
42
+ }
43
+ export type LinkClientAuthUserResult = {
44
+ ok: true;
45
+ clientId: string;
46
+ authUserId: string;
47
+ created: boolean;
48
+ alreadyLinked: boolean;
49
+ adminSelfLink: boolean;
50
+ } | {
51
+ ok: false;
52
+ code: EmailTrustFailureCode | "CLIENT_NOT_FOUND" | "MACHINE_ACTOR_REFUSED" | "CLIENT_AUTH_LINK_CONFLICT";
53
+ message: string;
54
+ };
55
+ export type SetClientAuthUserIdResult = {
56
+ ok: true;
57
+ alreadyLinked: boolean;
58
+ } | {
59
+ ok: false;
60
+ code: "CLIENT_AUTH_LINK_CONFLICT";
61
+ existingAuthUserId: string | null;
62
+ };
63
+ export interface ClientAuthLinkStore {
64
+ emailTrustPolicy: EmailTrustPolicy;
65
+ findClientByEmail(email: string): Promise<{
66
+ id: string;
67
+ authUserId: string | null;
68
+ } | null>;
69
+ findAuthUserIdByEmail(email: string): Promise<string | null>;
70
+ findAuthUserEmailById(authUserId: string): Promise<string | null>;
71
+ /** Provision an unconfirmed principal; null means an existing identity needs lookup. */
72
+ createAuthUser(email: string): Promise<string | null>;
73
+ findAdminIdentity(authUserId: string): Promise<{
74
+ isMachineActor: boolean;
75
+ } | null>;
76
+ setClientAuthUserId(clientId: string, authUserId: string): Promise<SetClientAuthUserIdResult>;
77
+ }
78
+ /** Link a commerce contact without proving mailbox ownership; failed links never delete identities. */
79
+ export declare function linkClientAuthUser(store: ClientAuthLinkStore, rawEmail: string): Promise<LinkClientAuthUserResult>;
80
+ export type AdminPrincipalRole = "admin" | "distributor";
81
+ export type AdminAuthorizationResult = {
82
+ ok: true;
83
+ principalId: string;
84
+ role: AdminPrincipalRole;
85
+ isMachineActor: boolean;
86
+ } | {
87
+ ok: false;
88
+ code: "UNAUTHORIZED" | "FORBIDDEN";
89
+ message: string;
90
+ };
91
+ export interface AdminAuthPort {
92
+ authorize(accessToken: string | null, options?: {
93
+ allowedRoles?: readonly AdminPrincipalRole[];
94
+ }): Promise<AdminAuthorizationResult>;
95
+ }
96
+ export interface CustomerAccount {
97
+ id: string;
98
+ email: string | null;
99
+ principalId: string | null;
100
+ }
101
+ export interface CreateAccountInput {
102
+ email: string;
103
+ principalId: string;
104
+ acquisitionSource: string;
105
+ lifecycleStage: string;
106
+ }
107
+ export type LinkPrincipalResult = {
108
+ ok: true;
109
+ alreadyLinked: boolean;
110
+ } | {
111
+ ok: false;
112
+ existingPrincipalId: string | null;
113
+ };
114
+ export interface AccountLinkStore {
115
+ findAccountByPrincipalId(principalId: string): Promise<CustomerAccount | null>;
116
+ findAccountByEmail(email: string): Promise<CustomerAccount | null>;
117
+ isReservedPrincipal(principalId: string): Promise<boolean>;
118
+ linkPrincipalToAccount(accountId: string, principalId: string): Promise<LinkPrincipalResult>;
119
+ createAccount(input: CreateAccountInput): Promise<{
120
+ id: string;
121
+ }>;
122
+ }
123
+ /** Attempt creation before scanning the existing identity directory. */
124
+ export declare function findOrCreateAuthUserId(store: Pick<ClientAuthLinkStore, "createAuthUser" | "findAuthUserIdByEmail">, email: string): Promise<{
125
+ authUserId: string;
126
+ created: boolean;
127
+ }>;
@@ -0,0 +1,38 @@
1
+ import { assessEmailTrust } from "./emailTrustPolicy.js";
2
+ /** Link a commerce contact without proving mailbox ownership; failed links never delete identities. */
3
+ export async function linkClientAuthUser(store, rawEmail) {
4
+ const email = rawEmail.trim().toLowerCase();
5
+ const client = await store.findClientByEmail(email);
6
+ if (!client)
7
+ return { ok: false, code: "CLIENT_NOT_FOUND", message: "No client row exists for this email; provision the client first." };
8
+ if (client.authUserId) {
9
+ const linkedEmail = await store.findAuthUserEmailById(client.authUserId);
10
+ if (linkedEmail?.trim().toLowerCase() !== email) {
11
+ return { ok: false, code: "CLIENT_AUTH_LINK_CONFLICT", message: "Client account is linked to a different auth identity." };
12
+ }
13
+ return { ok: true, clientId: client.id, authUserId: client.authUserId, created: false, alreadyLinked: true, adminSelfLink: false };
14
+ }
15
+ const trust = await assessEmailTrust(store.emailTrustPolicy);
16
+ if (!trust.trusted)
17
+ return { ok: false, code: trust.code, message: "Soft-account provisioning requires a trusted email envelope." };
18
+ const { authUserId, created } = await findOrCreateAuthUserId(store, email);
19
+ const adminIdentity = await store.findAdminIdentity(authUserId);
20
+ if (adminIdentity?.isMachineActor) {
21
+ return { ok: false, code: "MACHINE_ACTOR_REFUSED", message: "Refusing to link a machine/service admin identity to a customer client row." };
22
+ }
23
+ const link = await store.setClientAuthUserId(client.id, authUserId);
24
+ if (!link.ok) {
25
+ return { ok: false, code: "CLIENT_AUTH_LINK_CONFLICT", message: "Client account was linked to another auth identity concurrently." };
26
+ }
27
+ return { ok: true, clientId: client.id, authUserId, created, alreadyLinked: link.alreadyLinked, adminSelfLink: adminIdentity !== null };
28
+ }
29
+ /** Attempt creation before scanning the existing identity directory. */
30
+ export async function findOrCreateAuthUserId(store, email) {
31
+ const createdId = await store.createAuthUser(email);
32
+ if (createdId)
33
+ return { authUserId: createdId, created: true };
34
+ const existingId = await store.findAuthUserIdByEmail(email);
35
+ if (!existingId)
36
+ throw new Error("auth_user_registered_but_unresolved");
37
+ return { authUserId: existingId, created: false };
38
+ }
@@ -0,0 +1,7 @@
1
+ import type { AccountLinkStore } from "./ports.js";
2
+ /** Trusted contact-link capability; conditional writes never replace an existing principal. */
3
+ export declare function createPostgresAccountLinkStore(env: {
4
+ connectionString: string;
5
+ }): AccountLinkStore & {
6
+ close(): Promise<void>;
7
+ };
@@ -0,0 +1,52 @@
1
+ import { createPostgresRoleFreeTransactionLane } from "../data/index.js";
2
+ /** Trusted contact-link capability; conditional writes never replace an existing principal. */
3
+ export function createPostgresAccountLinkStore(env) {
4
+ const lane = createPostgresRoleFreeTransactionLane(env);
5
+ const run = (work) => lane.run(db => work(db));
6
+ const account = (row) => row
7
+ ? { id: String(row.id), email: row.email === null ? null : String(row.email), principalId: row.principal_id === null ? null : String(row.principal_id) } : null;
8
+ async function writable(db, principalId, clientId = null) {
9
+ // Same receipt-before-contact lock order as privacy erasure; no concurrent resurrection.
10
+ await db.query("LOCK TABLE public.identity_privacy_erasures IN SHARE MODE");
11
+ 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)
12
+ 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)
22
+ throw new Error("account_normalized_email_ambiguous");
23
+ return account(rows[0]);
24
+ });
25
+ },
26
+ isReservedPrincipal(id) {
27
+ 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);
28
+ },
29
+ linkPrincipalToAccount(id, principalId) {
30
+ return run(async (db) => {
31
+ await writable(db, principalId, id);
32
+ const updated = await db.query("UPDATE public.clients SET principal_id = $2 WHERE id = $1 AND principal_id IS NULL RETURNING id", [id, principalId]);
33
+ if (updated.rows.length)
34
+ return { ok: true, alreadyLinked: false };
35
+ const row = (await db.query("SELECT principal_id FROM public.clients WHERE id = $1", [id])).rows[0];
36
+ return row?.principal_id === principalId ? { ok: true, alreadyLinked: true }
37
+ : { ok: false, existingPrincipalId: row?.principal_id == null ? null : String(row.principal_id) };
38
+ });
39
+ },
40
+ createAccount(input) {
41
+ return run(async (db) => {
42
+ await writable(db, input.principalId);
43
+ const row = (await db.query(`INSERT INTO public.clients (email, principal_id, acquisition_source, lifecycle_stage)
44
+ VALUES ($1, $2, $3, $4) RETURNING id`, [input.email, input.principalId, input.acquisitionSource, input.lifecycleStage])).rows[0];
45
+ if (!row)
46
+ throw new Error("account_insert_failed");
47
+ return { id: String(row.id) };
48
+ });
49
+ },
50
+ close: lane.close,
51
+ };
52
+ }
@@ -0,0 +1,3 @@
1
+ import type { CustomerMePort } from "./customerPorts.js";
2
+ import type { PgGatewayClient } from "../data/index.js";
3
+ export declare function createPostgresCustomerMePort(gateway: PgGatewayClient): CustomerMePort;
@@ -0,0 +1,21 @@
1
+ export function createPostgresCustomerMePort(gateway) {
2
+ return {
3
+ async getCustomerMe() {
4
+ const { data, error } = await gateway
5
+ .from("clients")
6
+ .select("id, email, first_name, last_name, lifecycle_stage")
7
+ .maybeSingle();
8
+ if (error)
9
+ throw error;
10
+ if (!data)
11
+ return null;
12
+ return {
13
+ clientId: data.id,
14
+ email: data.email,
15
+ firstName: data.first_name,
16
+ lastName: data.last_name,
17
+ lifecycleStage: data.lifecycle_stage,
18
+ };
19
+ },
20
+ };
21
+ }
@@ -0,0 +1,11 @@
1
+ import type { CustomerChallengeStore } from "./ports.js";
2
+ import { type PostgresDataGatewayEnv, type PostgresDataGatewayOptions } from "../data/index.js";
3
+ export type PostgresCustomerChallengeStore = CustomerChallengeStore & {
4
+ close(): Promise<void>;
5
+ pruneChallenges(input: {
6
+ limit: number;
7
+ }): Promise<{
8
+ deleted: number;
9
+ }>;
10
+ };
11
+ export declare function createPostgresCustomerChallengeStore(env: PostgresDataGatewayEnv, options?: PostgresDataGatewayOptions): PostgresCustomerChallengeStore;
@@ -0,0 +1,57 @@
1
+ import { createPostgresRoleFreeTransactionLane, } from "../data/index.js";
2
+ export function createPostgresCustomerChallengeStore(env, options = {}) {
3
+ const lane = createPostgresRoleFreeTransactionLane(env, options);
4
+ return {
5
+ async issue(input) {
6
+ return lane.run(async (executor) => {
7
+ const db = executor;
8
+ // EXCLUSIVE conflicts with erasure's EXCLUSIVE lock and stays held through the RPC.
9
+ // Receipt reads use ACCESS SHARE, compatible with erasure's receipt-table lock.
10
+ await db.query("LOCK TABLE public.customer_identity_challenges IN EXCLUSIVE MODE");
11
+ 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
12
+ WHERE lower(c.email) = lower(btrim($1))`, [input.email]);
13
+ if (erased.rows.length)
14
+ return { deliverable: false };
15
+ const { rows } = await db.query("SELECT public.customer_identity_challenge_issue($1,$2,$3::timestamptz,now()) AS result", [input.email, input.tokenHash, input.expiresAt]);
16
+ const result = rows[0]?.result;
17
+ if (!result || typeof result !== "object")
18
+ throw new Error("customer challenge issue response invalid");
19
+ return { deliverable: result.deliverable === true };
20
+ });
21
+ },
22
+ async redeem(input) {
23
+ return lane.run(async (executor) => {
24
+ const db = executor;
25
+ // EXCLUSIVE conflicts with erasure's EXCLUSIVE lock and stays held through the RPC.
26
+ // Receipt reads use ACCESS SHARE, compatible with erasure's receipt-table lock.
27
+ await db.query("LOCK TABLE public.customer_identity_challenges IN EXCLUSIVE MODE");
28
+ const erased = await db.query(`SELECT 1 FROM public.customer_identity_challenges c
29
+ 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]);
30
+ if (erased.rows.length)
31
+ return null;
32
+ const { rows } = await db.query("SELECT public.customer_identity_challenge_redeem($1,$2::timestamptz) AS result", [input.tokenHash, input.now]);
33
+ const result = rows[0]?.result;
34
+ if (result === null || result === undefined)
35
+ return null;
36
+ if (!result || typeof result !== "object")
37
+ throw new Error("customer challenge redeem response invalid");
38
+ const value = result;
39
+ if (typeof value.principalId !== "string" || typeof value.email !== "string") {
40
+ throw new Error("customer challenge redeem response invalid");
41
+ }
42
+ return { principalId: value.principalId, email: value.email };
43
+ });
44
+ },
45
+ async pruneChallenges({ limit }) {
46
+ if (!Number.isInteger(limit) || limit < 1 || limit > 500)
47
+ throw new Error("customer_prune_limit_invalid");
48
+ return lane.run(async (executor) => {
49
+ const deleted = await executor.query(`WITH batch AS (SELECT id FROM public.customer_identity_challenges
50
+ WHERE expires_at < clock_timestamp() - interval '24 hours' ORDER BY expires_at, id LIMIT $1 FOR UPDATE SKIP LOCKED)
51
+ DELETE FROM public.customer_identity_challenges c USING batch WHERE c.id = batch.id RETURNING c.id`, [limit]);
52
+ return { deleted: deleted.rows.length };
53
+ });
54
+ },
55
+ close: lane.close,
56
+ };
57
+ }
@@ -0,0 +1,5 @@
1
+ import type { OperatorIdentityStore } from "./operatorPorts.js";
2
+ /** Dedicated owner lane: no actor gateway, customer writes or machine grants. */
3
+ export declare function createPostgresOperatorIdentityStore(env: {
4
+ connectionString: string;
5
+ }): OperatorIdentityStore;
@@ -0,0 +1,137 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createPostgresRoleFreeTransactionLane } from "../data/index.js";
3
+ import { acceptDirectOperatorInvitationInTransaction } from "./directOperatorInvitationStore.js";
4
+ import { operatorChallengeRequestSchema } from "./contracts/operatorAuth.js";
5
+ const fingerprint = (email) => createHash("sha256").update(email.trim().toLowerCase()).digest("hex");
6
+ const allowed = (role) => role === "admin" || role === "distributor";
7
+ /** Dedicated owner lane: no actor gateway, customer writes or machine grants. */
8
+ export function createPostgresOperatorIdentityStore(env) {
9
+ const lane = createPostgresRoleFreeTransactionLane(env);
10
+ const run = (work) => lane.run(db => work(db));
11
+ async function safeStore(db) {
12
+ // Includes privileges inherited via memberships/default ACLs, not just explicit grants.
13
+ const unsafe = (await db.query(`SELECT EXISTS (
14
+ SELECT 1 FROM pg_roles r WHERE r.rolname IN ('anon', 'authenticated', 'authenticator')
15
+ AND (has_table_privilege(r.oid, 'public.operator_identity_challenges', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER')
16
+ OR has_any_column_privilege(r.oid, 'public.operator_identity_challenges', 'SELECT,INSERT,UPDATE,REFERENCES'))
17
+ ) OR EXISTS (SELECT 1 FROM pg_class c, LATERAL aclexplode(coalesce(c.relacl, acldefault('r', c.relowner))) a
18
+ WHERE c.oid = 'public.operator_identity_challenges'::regclass AND a.grantee = 0)
19
+ OR EXISTS (SELECT 1 FROM pg_attribute c, LATERAL aclexplode(c.attacl) a
20
+ WHERE c.attrelid = 'public.operator_identity_challenges'::regclass AND a.grantee = 0) AS unsafe`)).rows[0]?.unsafe;
21
+ if (unsafe !== false)
22
+ throw new Error("operator_challenge_store_privileges_unsafe");
23
+ }
24
+ async function lockPrincipal(db, email) {
25
+ // Same serialization key as the invitation lifecycle, before row locks.
26
+ await db.query("SELECT pg_advisory_xact_lock(hashtext($1)::bigint)", [`operator_invitation:${email}`]);
27
+ const matches = (await db.query("SELECT principal_id, email FROM public.platform_human_principals WHERE lower(btrim(email)) = $1 FOR UPDATE", [email])).rows;
28
+ if (matches.length > 1)
29
+ throw new Error("operator_normalized_email_conflict");
30
+ return matches[0] ?? null;
31
+ }
32
+ async function eligible(db, id, email) {
33
+ if ((await db.query("SELECT 1 FROM public.platform_control_operators WHERE principal_id = $1", [id])).rows.length)
34
+ return false;
35
+ const member = (await db.query("SELECT role, membership_state FROM public.platform_human_memberships WHERE principal_id = $1 FOR UPDATE", [id])).rows[0];
36
+ if (member?.membership_state === "active")
37
+ return allowed(member.role);
38
+ const pending = (await db.query(`SELECT role FROM public.identity_operator_invitations WHERE principal_id = $1
39
+ AND email_key = $2 AND phase = 'invited' AND accepted_at IS NULL AND closed_at IS NULL
40
+ AND expires_at > clock_timestamp() FOR UPDATE`, [id, email])).rows[0];
41
+ return !!pending && allowed(pending.role);
42
+ }
43
+ return {
44
+ issue({ email, tokenHash }) {
45
+ return run(async (db) => {
46
+ await safeStore(db);
47
+ const principal = await lockPrincipal(db, email);
48
+ if (!principal || !await eligible(db, String(principal.principal_id), email))
49
+ return { deliverable: false };
50
+ const stamp = (await db.query("SELECT clock_timestamp() AS at")).rows[0].at;
51
+ const counts = (await db.query(`SELECT count(*) AS hour_count,
52
+ count(*) FILTER (WHERE created_at > $2::timestamptz - interval '60 seconds') AS minute_count
53
+ FROM public.operator_identity_challenges WHERE principal_id = $1 AND created_at > $2::timestamptz - interval '1 hour'`, [principal.principal_id, stamp])).rows[0];
54
+ if (Number(counts.minute_count) >= 1 || Number(counts.hour_count) >= 5)
55
+ return { deliverable: false };
56
+ const row = (await db.query(`INSERT INTO public.operator_identity_challenges
57
+ (principal_id, email_fingerprint, token_hash, created_at, expires_at)
58
+ VALUES ($1, $2, $3, $4, $4::timestamptz + interval '10 minutes') RETURNING expires_at`, [principal.principal_id, fingerprint(email), tokenHash, stamp])).rows[0];
59
+ return { deliverable: true, email: String(principal.email).trim().toLowerCase(), expiresAt: new Date(String(row.expires_at)).toISOString() };
60
+ });
61
+ },
62
+ redeem({ tokenHash }) {
63
+ return run(async (db) => {
64
+ await safeStore(db);
65
+ const candidate = (await db.query(`SELECT p.email, p.principal_id FROM public.operator_identity_challenges c
66
+ JOIN public.platform_human_principals p USING (principal_id) WHERE c.token_hash = $1`, [tokenHash])).rows[0];
67
+ if (!candidate)
68
+ return null;
69
+ const email = String(candidate.email).trim().toLowerCase();
70
+ const principal = await lockPrincipal(db, email);
71
+ if (!principal || principal.principal_id !== candidate.principal_id || !await eligible(db, String(principal.principal_id), email))
72
+ return null;
73
+ const challenge = (await db.query(`SELECT * FROM public.operator_identity_challenges WHERE token_hash = $1
74
+ AND principal_id = $2 AND used_at IS NULL AND expires_at > clock_timestamp() FOR UPDATE`, [tokenHash, principal.principal_id])).rows[0];
75
+ if (!challenge || challenge.email_fingerprint !== fingerprint(email))
76
+ return null;
77
+ const accepted = await acceptDirectOperatorInvitationInTransaction(db, String(principal.principal_id));
78
+ if ((accepted.code !== "ACTIVATED" && accepted.code !== "ALREADY_ACTIVE") || !allowed(accepted.role))
79
+ return null;
80
+ const consumed = await db.query(`WITH stamp AS (SELECT clock_timestamp() AS at)
81
+ UPDATE public.operator_identity_challenges SET used_at = stamp.at FROM stamp
82
+ WHERE id = $1 AND used_at IS NULL AND expires_at > stamp.at RETURNING id`, [challenge.id]);
83
+ // Throw to roll activation back if the challenge expired during activation.
84
+ if (!consumed.rows.length)
85
+ throw new Error("operator_challenge_expired_during_activation");
86
+ return { principalId: String(principal.principal_id), email };
87
+ }).catch(error => {
88
+ if (error instanceof Error && error.message === "operator_challenge_expired_during_activation")
89
+ return null;
90
+ throw error;
91
+ });
92
+ },
93
+ findMembership(id) {
94
+ return run(async (db) => {
95
+ await safeStore(db);
96
+ const row = (await db.query(`SELECT p.principal_id, p.email, m.role, m.membership_state
97
+ FROM public.platform_human_principals p JOIN public.platform_human_memberships m USING (principal_id)
98
+ WHERE p.principal_id = $1 AND NOT EXISTS (SELECT 1 FROM public.platform_control_operators o WHERE o.principal_id = p.principal_id)`, [id])).rows[0];
99
+ return row ? { principalId: String(row.principal_id), email: String(row.email), role: row.role, membershipState: String(row.membership_state) } : null;
100
+ });
101
+ },
102
+ bootstrapFirstAdministrator(input) {
103
+ const { email } = operatorChallengeRequestSchema.parse(input);
104
+ return run(async (db) => {
105
+ await safeStore(db);
106
+ await db.query("LOCK TABLE public.platform_human_memberships, public.identity_operator_invitations IN SHARE ROW EXCLUSIVE MODE");
107
+ if ((await db.query(`SELECT 1 FROM public.platform_human_memberships UNION ALL SELECT 1 FROM public.identity_operator_invitations LIMIT 1`)).rows.length)
108
+ throw new Error("OPERATOR_BOOTSTRAP_ALREADY_INITIALIZED");
109
+ let principal = await lockPrincipal(db, email);
110
+ if (!principal)
111
+ principal = (await db.query("INSERT INTO public.platform_human_principals (principal_id, email) VALUES (gen_random_uuid(), $1) RETURNING principal_id, email", [email])).rows[0];
112
+ const id = String(principal.principal_id);
113
+ if ((await db.query("SELECT 1 FROM public.platform_control_operators WHERE principal_id = $1", [id])).rows.length)
114
+ throw new Error("operator_bootstrap_machine_refused");
115
+ const pending = (await db.query(`WITH stamp AS (SELECT clock_timestamp() AS at)
116
+ INSERT INTO public.identity_operator_invitations
117
+ (principal_id, invitation_id, email_key, role, phase, invited_by, invited_at, expires_at)
118
+ SELECT $1, gen_random_uuid(), $2, 'admin', 'invited', $1, at, at + interval '7 days' FROM stamp RETURNING *`, [id, email])).rows[0];
119
+ await db.query(`INSERT INTO public.identity_operator_invitation_events
120
+ (event_id, invitation_id, principal_id, action, actor_kind, old_value, new_value, occurred_at)
121
+ VALUES (gen_random_uuid(), $1, $2, 'issue', 'system', '{}'::jsonb, $3::jsonb, clock_timestamp())`, [pending.invitation_id, id, JSON.stringify(pending)]);
122
+ return { principalId: id, membershipState: "invited" };
123
+ });
124
+ },
125
+ pruneChallenges({ limit }) {
126
+ if (!Number.isInteger(limit) || limit < 1 || limit > 500)
127
+ throw new Error("operator_prune_limit_invalid");
128
+ return run(async (db) => {
129
+ const deleted = await db.query(`WITH batch AS (SELECT id FROM public.operator_identity_challenges
130
+ WHERE expires_at < clock_timestamp() - interval '24 hours' ORDER BY expires_at, id LIMIT $1 FOR UPDATE SKIP LOCKED)
131
+ DELETE FROM public.operator_identity_challenges c USING batch WHERE c.id = batch.id RETURNING c.id`, [limit]);
132
+ return { deleted: deleted.rows.length };
133
+ });
134
+ },
135
+ close: lane.close,
136
+ };
137
+ }
@@ -0,0 +1,7 @@
1
+ import { type PgQueryExecutor, type PostgresDataGatewayEnv, type PostgresDataGatewayOptions } from "../data/index.js";
2
+ import { type PrivacyStore } from "./privacyPorts.js";
3
+ export declare function createPostgresPrivacyStore(): PrivacyStore<PgQueryExecutor>;
4
+ export declare function createPostgresPrivacyUnitOfWork(env: PostgresDataGatewayEnv, options?: PostgresDataGatewayOptions): {
5
+ run: <T>(work: (context: PgQueryExecutor) => Promise<T>) => Promise<T>;
6
+ close: () => Promise<void>;
7
+ };