@endora-commerce/mod-mfa 0.100.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 (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -0
  3. package/dist/admin/index.d.ts +37 -0
  4. package/dist/admin/index.d.ts.map +1 -0
  5. package/dist/admin/index.js +12 -0
  6. package/dist/admin/index.js.map +1 -0
  7. package/dist/admin/pages/AdminSecuritySettings.d.ts +3 -0
  8. package/dist/admin/pages/AdminSecuritySettings.d.ts.map +1 -0
  9. package/dist/admin/pages/AdminSecuritySettings.js +71 -0
  10. package/dist/admin/pages/AdminSecuritySettings.js.map +1 -0
  11. package/dist/backend/entities/mfa-enrolment.entity.d.ts +33 -0
  12. package/dist/backend/entities/mfa-enrolment.entity.d.ts.map +1 -0
  13. package/dist/backend/entities/mfa-enrolment.entity.js +95 -0
  14. package/dist/backend/entities/mfa-enrolment.entity.js.map +1 -0
  15. package/dist/backend/entities/mfa-organization-policy.entity.d.ts +19 -0
  16. package/dist/backend/entities/mfa-organization-policy.entity.d.ts.map +1 -0
  17. package/dist/backend/entities/mfa-organization-policy.entity.js +60 -0
  18. package/dist/backend/entities/mfa-organization-policy.entity.js.map +1 -0
  19. package/dist/backend/entities/mfa-recovery-code.entity.d.ts +17 -0
  20. package/dist/backend/entities/mfa-recovery-code.entity.d.ts.map +1 -0
  21. package/dist/backend/entities/mfa-recovery-code.entity.js +54 -0
  22. package/dist/backend/entities/mfa-recovery-code.entity.js.map +1 -0
  23. package/dist/backend/entities/mfa-social-identity.entity.d.ts +24 -0
  24. package/dist/backend/entities/mfa-social-identity.entity.d.ts.map +1 -0
  25. package/dist/backend/entities/mfa-social-identity.entity.js +73 -0
  26. package/dist/backend/entities/mfa-social-identity.entity.js.map +1 -0
  27. package/dist/backend/index.d.ts +131 -0
  28. package/dist/backend/index.d.ts.map +1 -0
  29. package/dist/backend/index.js +173 -0
  30. package/dist/backend/index.js.map +1 -0
  31. package/dist/backend/plugin.d.ts +99 -0
  32. package/dist/backend/plugin.d.ts.map +1 -0
  33. package/dist/backend/plugin.js +111 -0
  34. package/dist/backend/plugin.js.map +1 -0
  35. package/dist/backend/routes.admin.d.ts +23 -0
  36. package/dist/backend/routes.admin.d.ts.map +1 -0
  37. package/dist/backend/routes.admin.js +93 -0
  38. package/dist/backend/routes.admin.js.map +1 -0
  39. package/dist/backend/routes.oauth.d.ts +26 -0
  40. package/dist/backend/routes.oauth.d.ts.map +1 -0
  41. package/dist/backend/routes.oauth.js +155 -0
  42. package/dist/backend/routes.oauth.js.map +1 -0
  43. package/dist/backend/routes.org.d.ts +21 -0
  44. package/dist/backend/routes.org.d.ts.map +1 -0
  45. package/dist/backend/routes.org.js +17 -0
  46. package/dist/backend/routes.org.js.map +1 -0
  47. package/dist/backend/routes.public.d.ts +21 -0
  48. package/dist/backend/routes.public.d.ts.map +1 -0
  49. package/dist/backend/routes.public.js +118 -0
  50. package/dist/backend/routes.public.js.map +1 -0
  51. package/dist/backend/routes.self-service.d.ts +34 -0
  52. package/dist/backend/routes.self-service.d.ts.map +1 -0
  53. package/dist/backend/routes.self-service.js +113 -0
  54. package/dist/backend/routes.self-service.js.map +1 -0
  55. package/dist/backend/services/account-identity.d.ts +71 -0
  56. package/dist/backend/services/account-identity.d.ts.map +1 -0
  57. package/dist/backend/services/account-identity.js +87 -0
  58. package/dist/backend/services/account-identity.js.map +1 -0
  59. package/dist/backend/services/challenge-store.d.ts +40 -0
  60. package/dist/backend/services/challenge-store.d.ts.map +1 -0
  61. package/dist/backend/services/challenge-store.js +90 -0
  62. package/dist/backend/services/challenge-store.js.map +1 -0
  63. package/dist/backend/services/mfa-enrolment-count.service.d.ts +26 -0
  64. package/dist/backend/services/mfa-enrolment-count.service.d.ts.map +1 -0
  65. package/dist/backend/services/mfa-enrolment-count.service.js +34 -0
  66. package/dist/backend/services/mfa-enrolment-count.service.js.map +1 -0
  67. package/dist/backend/services/mfa-enrolment-service.d.ts +54 -0
  68. package/dist/backend/services/mfa-enrolment-service.d.ts.map +1 -0
  69. package/dist/backend/services/mfa-enrolment-service.js +217 -0
  70. package/dist/backend/services/mfa-enrolment-service.js.map +1 -0
  71. package/dist/backend/services/mfa-enrolment-state.service.d.ts +27 -0
  72. package/dist/backend/services/mfa-enrolment-state.service.d.ts.map +1 -0
  73. package/dist/backend/services/mfa-enrolment-state.service.js +33 -0
  74. package/dist/backend/services/mfa-enrolment-state.service.js.map +1 -0
  75. package/dist/backend/services/mfa-login-service.d.ts +56 -0
  76. package/dist/backend/services/mfa-login-service.d.ts.map +1 -0
  77. package/dist/backend/services/mfa-login-service.js +89 -0
  78. package/dist/backend/services/mfa-login-service.js.map +1 -0
  79. package/dist/backend/services/mfa-org-policy-service.d.ts +16 -0
  80. package/dist/backend/services/mfa-org-policy-service.d.ts.map +1 -0
  81. package/dist/backend/services/mfa-org-policy-service.js +43 -0
  82. package/dist/backend/services/mfa-org-policy-service.js.map +1 -0
  83. package/dist/backend/services/mfa-policy-resolver.d.ts +47 -0
  84. package/dist/backend/services/mfa-policy-resolver.d.ts.map +1 -0
  85. package/dist/backend/services/mfa-policy-resolver.js +82 -0
  86. package/dist/backend/services/mfa-policy-resolver.js.map +1 -0
  87. package/dist/backend/services/oauth-provider-service.d.ts +65 -0
  88. package/dist/backend/services/oauth-provider-service.d.ts.map +1 -0
  89. package/dist/backend/services/oauth-provider-service.js +80 -0
  90. package/dist/backend/services/oauth-provider-service.js.map +1 -0
  91. package/dist/backend/services/recovery-codes.d.ts +10 -0
  92. package/dist/backend/services/recovery-codes.d.ts.map +1 -0
  93. package/dist/backend/services/recovery-codes.js +18 -0
  94. package/dist/backend/services/recovery-codes.js.map +1 -0
  95. package/dist/backend/services/secret-cipher.d.ts +16 -0
  96. package/dist/backend/services/secret-cipher.d.ts.map +1 -0
  97. package/dist/backend/services/secret-cipher.js +52 -0
  98. package/dist/backend/services/secret-cipher.js.map +1 -0
  99. package/dist/backend/services/social-identity-service.d.ts +40 -0
  100. package/dist/backend/services/social-identity-service.d.ts.map +1 -0
  101. package/dist/backend/services/social-identity-service.js +69 -0
  102. package/dist/backend/services/social-identity-service.js.map +1 -0
  103. package/dist/backend/services/social-link-service.d.ts +63 -0
  104. package/dist/backend/services/social-link-service.d.ts.map +1 -0
  105. package/dist/backend/services/social-link-service.js +121 -0
  106. package/dist/backend/services/social-link-service.js.map +1 -0
  107. package/dist/backend/services/totp.d.ts +11 -0
  108. package/dist/backend/services/totp.d.ts.map +1 -0
  109. package/dist/backend/services/totp.js +46 -0
  110. package/dist/backend/services/totp.js.map +1 -0
  111. package/dist/manifest.d.ts +184 -0
  112. package/dist/manifest.d.ts.map +1 -0
  113. package/dist/manifest.js +264 -0
  114. package/dist/manifest.js.map +1 -0
  115. package/dist/migrations/20260611T140409_mfa_init.d.ts +22 -0
  116. package/dist/migrations/20260611T140409_mfa_init.d.ts.map +1 -0
  117. package/dist/migrations/20260611T140409_mfa_init.js +94 -0
  118. package/dist/migrations/20260611T140409_mfa_init.js.map +1 -0
  119. package/dist/migrations/index.d.ts +27 -0
  120. package/dist/migrations/index.d.ts.map +1 -0
  121. package/dist/migrations/index.js +29 -0
  122. package/dist/migrations/index.js.map +1 -0
  123. package/i18n/en.json +14 -0
  124. package/i18n/pl.json +14 -0
  125. package/package.json +102 -0
  126. package/tailwind.css +14 -0
@@ -0,0 +1,113 @@
1
+ import { mfaActivateRequestSchema, mfaDisableRequestSchema, mfaRegenerateRequestSchema, mfaSocialUnlinkParamsSchema, } from '@endora-commerce/contracts';
2
+ import { HttpError } from '@endora-commerce/platform/http';
3
+ import { currentSalesChannel } from '@endora-commerce/platform/kernel';
4
+ export async function registerMfaSelfServiceRoutes(app, opts) {
5
+ const { pathPrefix, subjectType, auditObjectType, requireGuard, resolveSubjectId, enrolmentService, policyResolver, socialLinkService, auditLogService, } = opts;
6
+ const subjectOf = (req) => ({
7
+ subjectType,
8
+ subjectId: resolveSubjectId(req),
9
+ });
10
+ app.post(`${pathPrefix}/setup`, { preHandler: requireGuard }, async (request) => {
11
+ const subject = subjectOf(request);
12
+ // FR-001 — enrolment is only permitted when 2FA is enabled for the scope.
13
+ const policy = await policyResolver.resolve(subject, {
14
+ salesChannelId: currentSalesChannel()?.id ?? null,
15
+ organizationId: opts.resolveOrganizationId?.(request) ?? null,
16
+ });
17
+ if (!policy.totpEnabled) {
18
+ throw new HttpError(403, 'MFA_NOT_ENABLED', 'Two-factor authentication is not enabled for your account.');
19
+ }
20
+ const label = (await opts.resolveAccountEmail(subjectType, subject.subjectId)) ?? subject.subjectId;
21
+ return { data: await enrolmentService.setup(subject, label) };
22
+ });
23
+ app.post(`${pathPrefix}/activate`, { preHandler: requireGuard, schema: { body: mfaActivateRequestSchema } }, async (request) => {
24
+ const body = mfaActivateRequestSchema.parse(request.body);
25
+ const subject = subjectOf(request);
26
+ const res = await enrolmentService.activate(subject, body.code);
27
+ await auditLogService.record({
28
+ action: 'mfa.enabled',
29
+ objectType: auditObjectType,
30
+ objectId: subject.subjectId,
31
+ ...(request.ip ? { ipAddress: request.ip } : {}),
32
+ });
33
+ return { data: res };
34
+ });
35
+ app.post(`${pathPrefix}/disable`, { preHandler: requireGuard, schema: { body: mfaDisableRequestSchema } }, async (request) => {
36
+ const body = mfaDisableRequestSchema.parse(request.body);
37
+ const subject = subjectOf(request);
38
+ await reauthenticate(opts, subject, body);
39
+ await enrolmentService.disable(subject);
40
+ await auditLogService.record({
41
+ action: 'mfa.disabled',
42
+ objectType: auditObjectType,
43
+ objectId: subject.subjectId,
44
+ ...(request.ip ? { ipAddress: request.ip } : {}),
45
+ });
46
+ return { data: { status: 'disabled' } };
47
+ });
48
+ app.post(`${pathPrefix}/recovery-codes/regenerate`, { preHandler: requireGuard, schema: { body: mfaRegenerateRequestSchema } }, async (request) => {
49
+ const body = mfaRegenerateRequestSchema.parse(request.body);
50
+ const subject = subjectOf(request);
51
+ const verified = await enrolmentService.verifySecondFactor(subject, body.code);
52
+ if (!verified.ok)
53
+ throw new HttpError(401, 'MFA_INVALID_CODE', 'The code is invalid or expired.');
54
+ const res = await enrolmentService.regenerateRecoveryCodes(subject);
55
+ await auditLogService.record({
56
+ action: 'mfa.recovery_codes_regenerated',
57
+ objectType: auditObjectType,
58
+ objectId: subject.subjectId,
59
+ ...(request.ip ? { ipAddress: request.ip } : {}),
60
+ });
61
+ return { data: res };
62
+ });
63
+ app.get(`${pathPrefix}/status`, { preHandler: requireGuard }, async (request) => {
64
+ const subject = subjectOf(request);
65
+ const status = await enrolmentService.status(subject);
66
+ const policy = await policyResolver.resolve(subject, {
67
+ salesChannelId: currentSalesChannel()?.id ?? null,
68
+ organizationId: opts.resolveOrganizationId?.(request) ?? null,
69
+ });
70
+ return {
71
+ data: {
72
+ totpActive: status.totpActive,
73
+ recoveryCodesRemaining: status.recoveryCodesRemaining,
74
+ totpEnabledForScope: policy.totpEnabled,
75
+ totpEnforcedForScope: policy.totpEnforced,
76
+ // Issue #194 — the contract has declared this field since feature 042
77
+ // and the handler answered `[]` unconditionally, so the account holder
78
+ // could neither see a linked identity nor reach the route that removes
79
+ // one. The verdict travels with each link, so the surface renders the
80
+ // refusal instead of discovering it on the click.
81
+ socialLinks: await socialLinkService.list(subject),
82
+ },
83
+ };
84
+ });
85
+ /**
86
+ * Sever a federated identity (issue #194). Refused when it is the account's
87
+ * only link — see {@link SocialLinkService} for why that is the rule and what
88
+ * would be needed to relax it.
89
+ */
90
+ app.delete(`${pathPrefix}/social-links/:provider`, { preHandler: requireGuard, schema: { params: mfaSocialUnlinkParamsSchema } }, async (request) => {
91
+ const { provider } = mfaSocialUnlinkParamsSchema.parse(request.params);
92
+ const subject = subjectOf(request);
93
+ await socialLinkService.unlink(subject, provider);
94
+ return { data: { status: 'unlinked', provider } };
95
+ });
96
+ }
97
+ /** Re-auth on self-disable: a current TOTP/recovery code or the password. */
98
+ async function reauthenticate(opts, subject, body) {
99
+ if (body.code) {
100
+ const verified = await opts.enrolmentService.verifySecondFactor(subject, body.code);
101
+ if (verified.ok)
102
+ return;
103
+ throw new HttpError(401, 'MFA_INVALID_CODE', 'The code is invalid or expired.');
104
+ }
105
+ if (body.password) {
106
+ const ok = await opts.verifyAccountPassword(subject.subjectType, subject.subjectId, body.password);
107
+ if (ok)
108
+ return;
109
+ throw new HttpError(401, 'INVALID_CREDENTIALS', 'The password is incorrect.');
110
+ }
111
+ throw new HttpError(400, 'MFA_REAUTH_REQUIRED', 'A current code is required to disable 2FA.');
112
+ }
113
+ //# sourceMappingURL=routes.self-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.self-service.js","sourceRoot":"","sources":["../../src/backend/routes.self-service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,71 @@
1
+ import type { AdminPasswordVerificationPort, AdminUserReadPort, CustomerAccountReadPort, CustomerPasswordVerificationPort } from '@endora-commerce/contracts';
2
+ /**
3
+ * The four questions MFA asks the two identity modules about an account
4
+ * (`specs/110-instance-repository/` T118c).
5
+ *
6
+ * All four were `MfaActorBridge` members — closures a composition root wrote,
7
+ * once in `backend/src/composition.ts` and once in
8
+ * `backend/test/helpers/test-server.ts`. None of them was a composition's answer
9
+ * to give: every one is `adminUserReadPort`, `customerAccountReadPort`,
10
+ * `adminPasswordVerificationPort` or `customerPasswordVerificationPort` and
11
+ * nothing else, and the two roots spelled the same body twice — where they
12
+ * spelled it at all. Two of the four the harness did not spell, which is what
13
+ * made them invisible; see the note on each.
14
+ *
15
+ * They are functions of the ports they read rather than of a `ModuleContext`, so
16
+ * `backend/index.ts` supplies the real inputs and this file is testable over a
17
+ * stub with nothing composed.
18
+ *
19
+ * **No `catch` anywhere below, deliberately.** Each call goes through a
20
+ * `lazyPort` proxy that throws `ModuleDisabledError` when its owner is absent,
21
+ * and reading that as "this account has no e-mail" or "the password does not
22
+ * match" is the fail-open the composition checklist's item 7 refuses — on an
23
+ * authentication path, where it is the difference between requiring a second
24
+ * factor and accepting a credential nobody verified. `admin_users` and
25
+ * `customer_accounts` both declare `nonDeactivatable`, so it cannot happen
26
+ * today; the absence of the `catch` is what keeps it fail-closed if that ever
27
+ * changes.
28
+ */
29
+ /**
30
+ * The caller's organisation, and the assertion that they may set policy for it
31
+ * (US3, FR-014).
32
+ *
33
+ * Takes the account id rather than the request: who is asking is
34
+ * `customerActorResolver`'s answer, which the platform contributes and eight
35
+ * other modules already read, and asking it a second time here is the duplicate
36
+ * spelling this drain removes.
37
+ */
38
+ export declare function createOrganizationAdminResolver(accounts: Pick<CustomerAccountReadPort, 'findById'>): (customerAccountId: string) => Promise<{
39
+ organizationId: string;
40
+ actor: string;
41
+ }>;
42
+ /** The accounts a bulk 2FA reset by organisation covers (US6, FR-026/028). */
43
+ export declare function createOrganizationCustomerIdsResolver(accounts: Pick<CustomerAccountReadPort, 'listByOrganization'>): (organizationId: string) => Promise<string[]>;
44
+ /**
45
+ * The address that labels an authenticator entry.
46
+ *
47
+ * **This is one of the two members no test in the tree could see.** Production
48
+ * contributed it and the harness did not, so under test the label fell back to
49
+ * the subject id and the `otpauth://` URI carried a UUID where a real enrolment
50
+ * carries an e-mail address — a difference every MFA test was blind to, because
51
+ * the one assertion over that URI is `toContain('otpauth://totp/')`.
52
+ *
53
+ * `null` for an account that does not resolve; the route falls back to the
54
+ * subject id, which is a worse label and nothing more.
55
+ */
56
+ export declare function createAccountEmailResolver(customers: Pick<CustomerAccountReadPort, 'findById'>, admins: Pick<AdminUserReadPort, 'findById'>): (subjectType: 'customer' | 'admin', subjectId: string) => Promise<string | null>;
57
+ /**
58
+ * Does the account's stored credential match the password presented?
59
+ *
60
+ * **The other member no test could see**, and the one whose absence changed
61
+ * behaviour rather than a label: `reauthenticate` accepts either a current
62
+ * second factor or the account password before disabling 2FA, and with no
63
+ * verifier the password branch is unreachable, so the harness answered 400
64
+ * `MFA_REAUTH_REQUIRED` where production answers 401 or lets the disable
65
+ * through. Every `/disable` call in the tree passes a code, so nothing ever
66
+ * took the branch that differed.
67
+ *
68
+ * Only the boolean crosses: the hash stays behind each owner's port (T052).
69
+ */
70
+ export declare function createAccountPasswordVerifier(customers: CustomerPasswordVerificationPort, admins: AdminPasswordVerificationPort): (subjectType: 'customer' | 'admin', subjectId: string, password: string) => Promise<boolean>;
71
+ //# sourceMappingURL=account-identity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"account-identity.d.ts","sourceRoot":"","sources":["../../../src/backend/services/account-identity.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,6BAA6B,EAC7B,iBAAiB,EACjB,uBAAuB,EACvB,gCAAgC,EACjC,MAAM,4BAA4B,CAAC;AAGpC;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH;;;;;;;;GAQG;AACH,wBAAgB,+BAA+B,CAC7C,QAAQ,EAAE,IAAI,CAAC,uBAAuB,EAAE,UAAU,CAAC,GAClD,CAAC,iBAAiB,EAAE,MAAM,KAAK,OAAO,CAAC;IAAE,cAAc,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAYnF;AAED,8EAA8E;AAC9E,wBAAgB,qCAAqC,CACnD,QAAQ,EAAE,IAAI,CAAC,uBAAuB,EAAE,oBAAoB,CAAC,GAC5D,CAAC,cAAc,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,CAG/C;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,0BAA0B,CACxC,SAAS,EAAE,IAAI,CAAC,uBAAuB,EAAE,UAAU,CAAC,EACpD,MAAM,EAAE,IAAI,CAAC,iBAAiB,EAAE,UAAU,CAAC,GAC1C,CAAC,WAAW,EAAE,UAAU,GAAG,OAAO,EAAE,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAKlF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,6BAA6B,CAC3C,SAAS,EAAE,gCAAgC,EAC3C,MAAM,EAAE,6BAA6B,GACpC,CACD,WAAW,EAAE,UAAU,GAAG,OAAO,EACjC,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,KACb,OAAO,CAAC,OAAO,CAAC,CAKpB"}
@@ -0,0 +1,87 @@
1
+ import { ERROR_CODES } from '@endora-commerce/contracts';
2
+ import { HttpError } from '@endora-commerce/platform/http';
3
+ /**
4
+ * The four questions MFA asks the two identity modules about an account
5
+ * (`specs/110-instance-repository/` T118c).
6
+ *
7
+ * All four were `MfaActorBridge` members — closures a composition root wrote,
8
+ * once in `backend/src/composition.ts` and once in
9
+ * `backend/test/helpers/test-server.ts`. None of them was a composition's answer
10
+ * to give: every one is `adminUserReadPort`, `customerAccountReadPort`,
11
+ * `adminPasswordVerificationPort` or `customerPasswordVerificationPort` and
12
+ * nothing else, and the two roots spelled the same body twice — where they
13
+ * spelled it at all. Two of the four the harness did not spell, which is what
14
+ * made them invisible; see the note on each.
15
+ *
16
+ * They are functions of the ports they read rather than of a `ModuleContext`, so
17
+ * `backend/index.ts` supplies the real inputs and this file is testable over a
18
+ * stub with nothing composed.
19
+ *
20
+ * **No `catch` anywhere below, deliberately.** Each call goes through a
21
+ * `lazyPort` proxy that throws `ModuleDisabledError` when its owner is absent,
22
+ * and reading that as "this account has no e-mail" or "the password does not
23
+ * match" is the fail-open the composition checklist's item 7 refuses — on an
24
+ * authentication path, where it is the difference between requiring a second
25
+ * factor and accepting a credential nobody verified. `admin_users` and
26
+ * `customer_accounts` both declare `nonDeactivatable`, so it cannot happen
27
+ * today; the absence of the `catch` is what keeps it fail-closed if that ever
28
+ * changes.
29
+ */
30
+ /**
31
+ * The caller's organisation, and the assertion that they may set policy for it
32
+ * (US3, FR-014).
33
+ *
34
+ * Takes the account id rather than the request: who is asking is
35
+ * `customerActorResolver`'s answer, which the platform contributes and eight
36
+ * other modules already read, and asking it a second time here is the duplicate
37
+ * spelling this drain removes.
38
+ */
39
+ export function createOrganizationAdminResolver(accounts) {
40
+ return async (customerAccountId) => {
41
+ const account = await accounts.findById(customerAccountId);
42
+ if (!account || account.role !== 'organization_admin' || !account.organizationId) {
43
+ throw new HttpError(403, ERROR_CODES.FORBIDDEN, 'Organization administrator role required.');
44
+ }
45
+ return { organizationId: account.organizationId, actor: account.id };
46
+ };
47
+ }
48
+ /** The accounts a bulk 2FA reset by organisation covers (US6, FR-026/028). */
49
+ export function createOrganizationCustomerIdsResolver(accounts) {
50
+ return async (organizationId) => (await accounts.listByOrganization(organizationId)).map((account) => account.id);
51
+ }
52
+ /**
53
+ * The address that labels an authenticator entry.
54
+ *
55
+ * **This is one of the two members no test in the tree could see.** Production
56
+ * contributed it and the harness did not, so under test the label fell back to
57
+ * the subject id and the `otpauth://` URI carried a UUID where a real enrolment
58
+ * carries an e-mail address — a difference every MFA test was blind to, because
59
+ * the one assertion over that URI is `toContain('otpauth://totp/')`.
60
+ *
61
+ * `null` for an account that does not resolve; the route falls back to the
62
+ * subject id, which is a worse label and nothing more.
63
+ */
64
+ export function createAccountEmailResolver(customers, admins) {
65
+ return async (subjectType, subjectId) => subjectType === 'admin'
66
+ ? ((await admins.findById(subjectId))?.email ?? null)
67
+ : ((await customers.findById(subjectId))?.email ?? null);
68
+ }
69
+ /**
70
+ * Does the account's stored credential match the password presented?
71
+ *
72
+ * **The other member no test could see**, and the one whose absence changed
73
+ * behaviour rather than a label: `reauthenticate` accepts either a current
74
+ * second factor or the account password before disabling 2FA, and with no
75
+ * verifier the password branch is unreachable, so the harness answered 400
76
+ * `MFA_REAUTH_REQUIRED` where production answers 401 or lets the disable
77
+ * through. Every `/disable` call in the tree passes a code, so nothing ever
78
+ * took the branch that differed.
79
+ *
80
+ * Only the boolean crosses: the hash stays behind each owner's port (T052).
81
+ */
82
+ export function createAccountPasswordVerifier(customers, admins) {
83
+ return async (subjectType, subjectId, password) => subjectType === 'admin'
84
+ ? admins.verifyPassword(subjectId, password)
85
+ : customers.verifyPassword(subjectId, password);
86
+ }
87
+ //# sourceMappingURL=account-identity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"account-identity.js","sourceRoot":"","sources":["../../../src/backend/services/account-identity.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAOzD,OAAO,EAAE,SAAS,EAAE,MAAM,gCAAgC,CAAC;AAE3D;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH;;;;;;;;GAQG;AACH,MAAM,UAAU,+BAA+B,CAC7C,QAAmD;IAEnD,OAAO,KAAK,EAAE,iBAAiB,EAAE,EAAE;QACjC,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,QAAQ,CAAC,iBAAiB,CAAC,CAAC;QAC3D,IAAI,CAAC,OAAO,IAAI,OAAO,CAAC,IAAI,KAAK,oBAAoB,IAAI,CAAC,OAAO,CAAC,cAAc,EAAE,CAAC;YACjF,MAAM,IAAI,SAAS,CACjB,GAAG,EACH,WAAW,CAAC,SAAS,EACrB,2CAA2C,CAC5C,CAAC;QACJ,CAAC;QACD,OAAO,EAAE,cAAc,EAAE,OAAO,CAAC,cAAc,EAAE,KAAK,EAAE,OAAO,CAAC,EAAE,EAAE,CAAC;IACvE,CAAC,CAAC;AACJ,CAAC;AAED,8EAA8E;AAC9E,MAAM,UAAU,qCAAqC,CACnD,QAA6D;IAE7D,OAAO,KAAK,EAAE,cAAc,EAAE,EAAE,CAC9B,CAAC,MAAM,QAAQ,CAAC,kBAAkB,CAAC,cAAc,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;AACrF,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,0BAA0B,CACxC,SAAoD,EACpD,MAA2C;IAE3C,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,EAAE,CACtC,WAAW,KAAK,OAAO;QACrB,CAAC,CAAC,CAAC,CAAC,MAAM,MAAM,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,EAAE,KAAK,IAAI,IAAI,CAAC;QACrD,CAAC,CAAC,CAAC,CAAC,MAAM,SAAS,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,EAAE,KAAK,IAAI,IAAI,CAAC,CAAC;AAC/D,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,6BAA6B,CAC3C,SAA2C,EAC3C,MAAqC;IAMrC,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,CAChD,WAAW,KAAK,OAAO;QACrB,CAAC,CAAC,MAAM,CAAC,cAAc,CAAC,SAAS,EAAE,QAAQ,CAAC;QAC5C,CAAC,CAAC,SAAS,CAAC,cAAc,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;AACtD,CAAC"}
@@ -0,0 +1,40 @@
1
+ import type { Redis } from 'ioredis';
2
+ export interface PendingChallenge {
3
+ subjectType: 'customer' | 'admin';
4
+ subjectId: string;
5
+ salesChannelId: string | null;
6
+ attemptsRemaining: number;
7
+ }
8
+ export interface SetupTicket {
9
+ subjectType: 'customer' | 'admin';
10
+ subjectId: string;
11
+ salesChannelId: string | null;
12
+ }
13
+ export interface OAuthTransaction {
14
+ surface: 'customer' | 'admin';
15
+ provider: 'google' | 'microsoft';
16
+ salesChannelId: string | null;
17
+ pkceVerifier: string;
18
+ nonce: string;
19
+ next: string;
20
+ }
21
+ export declare class ChallengeStore {
22
+ private readonly redis;
23
+ constructor(redis: Redis);
24
+ issueChallenge(input: Omit<PendingChallenge, 'attemptsRemaining'>, attemptBudget?: number): Promise<string>;
25
+ getChallenge(id: string): Promise<PendingChallenge | null>;
26
+ /**
27
+ * Record a failed attempt: decrement the budget and burn the challenge when
28
+ * it reaches zero. Returns the remaining budget (0 ⇒ burned).
29
+ */
30
+ recordFailedAttempt(id: string): Promise<number>;
31
+ consumeChallenge(id: string): Promise<void>;
32
+ issueSetupTicket(input: SetupTicket): Promise<string>;
33
+ getSetupTicket(id: string): Promise<SetupTicket | null>;
34
+ consumeSetupTicket(id: string): Promise<void>;
35
+ /** The `state` value is returned to the provider and echoed on callback. */
36
+ issueOAuthTransaction(input: OAuthTransaction): Promise<string>;
37
+ getOAuthTransaction(state: string): Promise<OAuthTransaction | null>;
38
+ consumeOAuthTransaction(state: string): Promise<void>;
39
+ }
40
+ //# sourceMappingURL=challenge-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"challenge-store.d.ts","sourceRoot":"","sources":["../../../src/backend/services/challenge-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAuBrC,MAAM,WAAW,gBAAgB;IAC/B,WAAW,EAAE,UAAU,GAAG,OAAO,CAAC;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,WAAW;IAC1B,WAAW,EAAE,UAAU,GAAG,OAAO,CAAC;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;CAC/B;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC;IAC9B,QAAQ,EAAE,QAAQ,GAAG,WAAW,CAAC;IACjC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;CACd;AAMD,qBAAa,cAAc;IACb,OAAO,CAAC,QAAQ,CAAC,KAAK;gBAAL,KAAK,EAAE,KAAK;IAInC,cAAc,CAClB,KAAK,EAAE,IAAI,CAAC,gBAAgB,EAAE,mBAAmB,CAAC,EAClD,aAAa,SAAyB,GACrC,OAAO,CAAC,MAAM,CAAC;IAeZ,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC;IAKhE;;;OAGG;IACG,mBAAmB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAkBhD,gBAAgB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAM3C,gBAAgB,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC;IAWrD,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC;IAKvD,kBAAkB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAMnD,4EAA4E;IACtE,qBAAqB,CAAC,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAAC,MAAM,CAAC;IAW/D,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC;IAKpE,uBAAuB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAG5D"}
@@ -0,0 +1,90 @@
1
+ import { randomBytes } from 'crypto';
2
+ /**
3
+ * Short-lived, server-held login state in Redis (feature 042, R4/R9).
4
+ *
5
+ * Three kinds of ephemeral token, all TTL-bounded so abandonment never
6
+ * materializes into a session:
7
+ * - pending-login challenge (`mfa:chal:<id>`) — after a correct password,
8
+ * before the second factor; carries an attempt budget;
9
+ * - setup ticket (`mfa:setup:<id>`) — enforced-but-unenrolled subject, may
10
+ * only call the enrolment endpoints until activation;
11
+ * - OAuth transaction (`mfa:oauth:<state>`) — PKCE verifier + nonce + next.
12
+ */
13
+ const CHAL_PREFIX = 'mfa:chal:';
14
+ const SETUP_PREFIX = 'mfa:setup:';
15
+ const OAUTH_PREFIX = 'mfa:oauth:';
16
+ const CHALLENGE_TTL_SECONDS = 5 * 60;
17
+ const SETUP_TTL_SECONDS = 10 * 60;
18
+ const OAUTH_TTL_SECONDS = 10 * 60;
19
+ const DEFAULT_ATTEMPT_BUDGET = 5;
20
+ function newToken() {
21
+ return randomBytes(32).toString('base64url');
22
+ }
23
+ export class ChallengeStore {
24
+ redis;
25
+ constructor(redis) {
26
+ this.redis = redis;
27
+ }
28
+ // --- pending-login challenge ---------------------------------------------
29
+ async issueChallenge(input, attemptBudget = DEFAULT_ATTEMPT_BUDGET) {
30
+ const id = newToken();
31
+ const payload = {
32
+ ...input,
33
+ attemptsRemaining: attemptBudget,
34
+ };
35
+ await this.redis.set(CHAL_PREFIX + id, JSON.stringify(payload), 'EX', CHALLENGE_TTL_SECONDS);
36
+ return id;
37
+ }
38
+ async getChallenge(id) {
39
+ const raw = await this.redis.get(CHAL_PREFIX + id);
40
+ return raw ? JSON.parse(raw) : null;
41
+ }
42
+ /**
43
+ * Record a failed attempt: decrement the budget and burn the challenge when
44
+ * it reaches zero. Returns the remaining budget (0 ⇒ burned).
45
+ */
46
+ async recordFailedAttempt(id) {
47
+ const challenge = await this.getChallenge(id);
48
+ if (!challenge)
49
+ return 0;
50
+ const remaining = challenge.attemptsRemaining - 1;
51
+ if (remaining <= 0) {
52
+ await this.consumeChallenge(id);
53
+ return 0;
54
+ }
55
+ const ttl = await this.redis.ttl(CHAL_PREFIX + id);
56
+ await this.redis.set(CHAL_PREFIX + id, JSON.stringify({ ...challenge, attemptsRemaining: remaining }), 'EX', ttl > 0 ? ttl : CHALLENGE_TTL_SECONDS);
57
+ return remaining;
58
+ }
59
+ async consumeChallenge(id) {
60
+ await this.redis.del(CHAL_PREFIX + id);
61
+ }
62
+ // --- setup ticket ---------------------------------------------------------
63
+ async issueSetupTicket(input) {
64
+ const id = newToken();
65
+ await this.redis.set(SETUP_PREFIX + id, JSON.stringify(input), 'EX', SETUP_TTL_SECONDS);
66
+ return id;
67
+ }
68
+ async getSetupTicket(id) {
69
+ const raw = await this.redis.get(SETUP_PREFIX + id);
70
+ return raw ? JSON.parse(raw) : null;
71
+ }
72
+ async consumeSetupTicket(id) {
73
+ await this.redis.del(SETUP_PREFIX + id);
74
+ }
75
+ // --- OAuth transaction ----------------------------------------------------
76
+ /** The `state` value is returned to the provider and echoed on callback. */
77
+ async issueOAuthTransaction(input) {
78
+ const state = newToken();
79
+ await this.redis.set(OAUTH_PREFIX + state, JSON.stringify(input), 'EX', OAUTH_TTL_SECONDS);
80
+ return state;
81
+ }
82
+ async getOAuthTransaction(state) {
83
+ const raw = await this.redis.get(OAUTH_PREFIX + state);
84
+ return raw ? JSON.parse(raw) : null;
85
+ }
86
+ async consumeOAuthTransaction(state) {
87
+ await this.redis.del(OAUTH_PREFIX + state);
88
+ }
89
+ }
90
+ //# sourceMappingURL=challenge-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"challenge-store.js","sourceRoot":"","sources":["../../../src/backend/services/challenge-store.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,26 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql';
2
+ import type { MfaActiveEnrolmentCounts, MfaEnrolmentCountPort } from '@endora-commerce/contracts';
3
+ /**
4
+ * How many subjects hold an active second factor (owner ruling on D-96.5).
5
+ *
6
+ * The deactivation-confirmation dialog on `/platform/modules` renders one
7
+ * static sentence per dependent, from a manifest. This is the first **live**
8
+ * datum in it, and it is the most decision-relevant fact an operator can be
9
+ * shown before switching this module off: the `whenAbsent` sentence says what
10
+ * stops working, the count says to how many people.
11
+ *
12
+ * It lives here, behind a port, because `mfa_enrolments` is this module's
13
+ * table. The kernel does not name it and no other module queries it; the
14
+ * lifecycle surface asks a question and this module answers it.
15
+ *
16
+ * Nothing about the count is cached, denormalised or persisted outside this
17
+ * table, deliberately: a number that survived deactivation would be a second
18
+ * copy of the fact, and the only moment it is asked for is a moment at which
19
+ * the real one is readable.
20
+ */
21
+ export declare class MfaEnrolmentCountService implements MfaEnrolmentCountPort {
22
+ private readonly emFactory;
23
+ constructor(emFactory: () => EntityManager);
24
+ countActiveEnrolments(): Promise<MfaActiveEnrolmentCounts>;
25
+ }
26
+ //# sourceMappingURL=mfa-enrolment-count.service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mfa-enrolment-count.service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/mfa-enrolment-count.service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EAAE,wBAAwB,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AAGlG;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,wBAAyB,YAAW,qBAAqB;IACxD,OAAO,CAAC,QAAQ,CAAC,SAAS;gBAAT,SAAS,EAAE,MAAM,aAAa;IAErD,qBAAqB,IAAI,OAAO,CAAC,wBAAwB,CAAC;CAQjE"}
@@ -0,0 +1,34 @@
1
+ import { MfaEnrolment } from '../entities/mfa-enrolment.entity.js';
2
+ /**
3
+ * How many subjects hold an active second factor (owner ruling on D-96.5).
4
+ *
5
+ * The deactivation-confirmation dialog on `/platform/modules` renders one
6
+ * static sentence per dependent, from a manifest. This is the first **live**
7
+ * datum in it, and it is the most decision-relevant fact an operator can be
8
+ * shown before switching this module off: the `whenAbsent` sentence says what
9
+ * stops working, the count says to how many people.
10
+ *
11
+ * It lives here, behind a port, because `mfa_enrolments` is this module's
12
+ * table. The kernel does not name it and no other module queries it; the
13
+ * lifecycle surface asks a question and this module answers it.
14
+ *
15
+ * Nothing about the count is cached, denormalised or persisted outside this
16
+ * table, deliberately: a number that survived deactivation would be a second
17
+ * copy of the fact, and the only moment it is asked for is a moment at which
18
+ * the real one is readable.
19
+ */
20
+ export class MfaEnrolmentCountService {
21
+ emFactory;
22
+ constructor(emFactory) {
23
+ this.emFactory = emFactory;
24
+ }
25
+ async countActiveEnrolments() {
26
+ const em = this.emFactory();
27
+ const [admins, customers] = await Promise.all([
28
+ em.count(MfaEnrolment, { status: 'active', subjectType: 'admin' }),
29
+ em.count(MfaEnrolment, { status: 'active', subjectType: 'customer' }),
30
+ ]);
31
+ return { admins, customers };
32
+ }
33
+ }
34
+ //# sourceMappingURL=mfa-enrolment-count.service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mfa-enrolment-count.service.js","sourceRoot":"","sources":["../../../src/backend/services/mfa-enrolment-count.service.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,YAAY,EAAE,MAAM,qCAAqC,CAAC;AAEnE;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,OAAO,wBAAwB;IACN;IAA7B,YAA6B,SAA8B;QAA9B,cAAS,GAAT,SAAS,CAAqB;IAAG,CAAC;IAE/D,KAAK,CAAC,qBAAqB;QACzB,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;QAC5B,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;YAC5C,EAAE,CAAC,KAAK,CAAC,YAAY,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,WAAW,EAAE,OAAO,EAAE,CAAC;YAClE,EAAE,CAAC,KAAK,CAAC,YAAY,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,WAAW,EAAE,UAAU,EAAE,CAAC;SACtE,CAAC,CAAC;QACH,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;IAC/B,CAAC;CACF"}
@@ -0,0 +1,54 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql';
2
+ import type { MfaSubjectRef } from '@endora-commerce/contracts';
3
+ import type { SecretCipher } from './secret-cipher.js';
4
+ import type { AuditPort } from '@endora-commerce/platform/kernel';
5
+ /**
6
+ * TOTP enrolment lifecycle (feature 042): setup → activate (issue recovery
7
+ * codes) → disable / regenerate, plus the second-factor verification used by
8
+ * the login flow. The TOTP secret is encrypted at rest via `SecretCipher`;
9
+ * recovery codes are stored only as SHA-256 hashes.
10
+ */
11
+ export declare class MfaEnrolmentService {
12
+ #private;
13
+ private readonly emFactory;
14
+ private readonly cipher;
15
+ private readonly auditLog?;
16
+ constructor(emFactory: () => EntityManager, cipher: SecretCipher, auditLog?: AuditPort | undefined);
17
+ /** Begin (or restart) enrolment — creates a `pending` enrolment. */
18
+ setup(subject: MfaSubjectRef, accountLabel: string): Promise<{
19
+ secret: string;
20
+ otpauthUri: string;
21
+ }>;
22
+ /** Confirm the pending enrolment with a live code; issue recovery codes. */
23
+ activate(subject: MfaSubjectRef, code: string): Promise<{
24
+ recoveryCodes: string[];
25
+ }>;
26
+ /**
27
+ * Admin reset — clear ALL enrolments (active + pending) for a subject.
28
+ * Recovery codes cascade. Returns whether anything was removed (so a bulk
29
+ * caller can report affected vs skipped). FR-025.
30
+ */
31
+ reset(subject: MfaSubjectRef): Promise<boolean>;
32
+ /** Disable 2FA for the subject (idempotent). Removes secret + recovery codes. */
33
+ disable(subject: MfaSubjectRef): Promise<void>;
34
+ /** Invalidate the old recovery-code set and issue a fresh one. */
35
+ regenerateRecoveryCodes(subject: MfaSubjectRef): Promise<{
36
+ recoveryCodes: string[];
37
+ }>;
38
+ status(subject: MfaSubjectRef): Promise<{
39
+ totpActive: boolean;
40
+ recoveryCodesRemaining: number;
41
+ }>;
42
+ /**
43
+ * Verify a second factor (TOTP or recovery code) for an active enrolment.
44
+ * A 6-digit input is treated as a TOTP code (with replay guard); anything
45
+ * else is matched against unused recovery codes (single-use). Reports which
46
+ * factor matched so callers can audit recovery-code use.
47
+ */
48
+ verifySecondFactor(subject: MfaSubjectRef, code: string): Promise<{
49
+ ok: boolean;
50
+ factor?: 'totp' | 'recovery';
51
+ }>;
52
+ private decryptSecret;
53
+ }
54
+ //# sourceMappingURL=mfa-enrolment-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mfa-enrolment-service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/mfa-enrolment-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAE3D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAGhE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kCAAkC,CAAC;AAOlE;;;;;GAKG;AACH,qBAAa,mBAAmB;;IAE5B,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC;gBAFT,SAAS,EAAE,MAAM,aAAa,EAC9B,MAAM,EAAE,YAAY,EACpB,QAAQ,CAAC,EAAE,SAAS,YAAA;IAevC,oEAAoE;IAC9D,KAAK,CACT,OAAO,EAAE,aAAa,EACtB,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;IAkClD,4EAA4E;IACtE,QAAQ,CACZ,OAAO,EAAE,aAAa,EACtB,IAAI,EAAE,MAAM,GACX,OAAO,CAAC;QAAE,aAAa,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;IAiCvC;;;;OAIG;IACG,KAAK,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,OAAO,CAAC;IAarD,iFAAiF;IAC3E,OAAO,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IAapD,kEAAkE;IAC5D,uBAAuB,CAC3B,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC;QAAE,aAAa,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;IAyBjC,MAAM,CACV,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC;QAAE,UAAU,EAAE,OAAO,CAAC;QAAC,sBAAsB,EAAE,MAAM,CAAA;KAAE,CAAC;IAenE;;;;;OAKG;IACG,kBAAkB,CACtB,OAAO,EAAE,aAAa,EACtB,IAAI,EAAE,MAAM,GACX,OAAO,CAAC;QAAE,EAAE,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,GAAG,UAAU,CAAA;KAAE,CAAC;IAqCzD,OAAO,CAAC,aAAa;CAOtB"}