@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,82 @@
1
+ import { z } from 'zod';
2
+ import { MFA_SETTING_CODES } from '@endora-commerce/contracts';
3
+ import { MfaOrganizationPolicy } from '../entities/mfa-organization-policy.entity.js';
4
+ export class MfaPolicyResolver {
5
+ settings;
6
+ emFactory;
7
+ resolveDefaultChannelId;
8
+ constructor(settings, emFactory,
9
+ /**
10
+ * Resolves the system-default sales-channel id for a storefront request
11
+ * that arrived without one. `settingsService.get` requires a real channel
12
+ * uuid or `null`, so a literal sentinel cannot be used.
13
+ */
14
+ resolveDefaultChannelId = async () => null) {
15
+ this.settings = settings;
16
+ this.emFactory = emFactory;
17
+ this.resolveDefaultChannelId = resolveDefaultChannelId;
18
+ }
19
+ async resolve(subject, ctx) {
20
+ const isAdmin = subject.subjectType === 'admin';
21
+ // The admin surface has no storefront, so its four `mfa.admin.*` settings
22
+ // are platform-wide by nature: `null` reads `global_value ?? default_value`
23
+ // directly (feature 072, D-41 case c). It used to resolve the system-default
24
+ // *channel* and read there, then coerce a missing one to `''` — which is not
25
+ // a uuid, so the query threw in the driver and the `catch` below reported
26
+ // every policy as `false`, i.e. MFA quietly off.
27
+ //
28
+ // A storefront subject keeps its channel: `mfa.storefront.*` genuinely
29
+ // differs per storefront. With no request channel it falls back to the
30
+ // system default, and to platform-wide only if there is none.
31
+ const channelId = isAdmin
32
+ ? null
33
+ : ctx.salesChannelId ?? (await this.resolveDefaultChannelId());
34
+ const codes = isAdmin
35
+ ? {
36
+ enabled: MFA_SETTING_CODES.ADMIN_TOTP_ENABLED,
37
+ enforced: MFA_SETTING_CODES.ADMIN_TOTP_ENFORCED,
38
+ google: MFA_SETTING_CODES.ADMIN_GOOGLE_ENABLED,
39
+ microsoft: MFA_SETTING_CODES.ADMIN_MICROSOFT_ENABLED,
40
+ }
41
+ : {
42
+ enabled: MFA_SETTING_CODES.STOREFRONT_TOTP_ENABLED,
43
+ enforced: MFA_SETTING_CODES.STOREFRONT_TOTP_ENFORCED,
44
+ google: MFA_SETTING_CODES.STOREFRONT_GOOGLE_ENABLED,
45
+ microsoft: MFA_SETTING_CODES.STOREFRONT_MICROSOFT_ENABLED,
46
+ };
47
+ const [totpEnabledRaw, totpEnforcedRaw, googleEnabled, microsoftEnabled] = await Promise.all([
48
+ this.readBool(codes.enabled, channelId),
49
+ this.readBool(codes.enforced, channelId),
50
+ this.readBool(codes.google, channelId),
51
+ this.readBool(codes.microsoft, channelId),
52
+ ]);
53
+ // Organization enforcement is additive (customers only).
54
+ let orgEnforced = false;
55
+ if (!isAdmin && ctx.organizationId) {
56
+ orgEnforced = await this.readOrgEnforcement(ctx.organizationId);
57
+ }
58
+ const totpEnforced = totpEnforcedRaw || orgEnforced;
59
+ // Invariant FR-016: enforcement implies enablement.
60
+ const totpEnabled = totpEnabledRaw || totpEnforced;
61
+ return { totpEnabled, totpEnforced, googleEnabled, microsoftEnabled };
62
+ }
63
+ async readBool(code, channelId) {
64
+ try {
65
+ return await this.settings.get(code, channelId, z.boolean());
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ async readOrgEnforcement(organizationId) {
72
+ try {
73
+ const em = this.emFactory();
74
+ const policy = await em.findOne(MfaOrganizationPolicy, { organizationId });
75
+ return policy?.enforceTotp ?? false;
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
81
+ }
82
+ //# sourceMappingURL=mfa-policy-resolver.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mfa-policy-resolver.js","sourceRoot":"","sources":["../../../src/backend/services/mfa-policy-resolver.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Federated sign-in provider port (feature 042, US4/US5). The `mfa` routes
3
+ * depend on this interface; production wires the `openid-client`-backed
4
+ * implementation below, tests inject a deterministic fake. Provider client
5
+ * id/secret come from backend config/env — never the Settings table (R2).
6
+ */
7
+ export type OAuthProviderName = 'google' | 'microsoft';
8
+ export interface OAuthIdentity {
9
+ provider: OAuthProviderName;
10
+ /** Stable provider subject (`sub` claim). */
11
+ sub: string;
12
+ email: string;
13
+ emailVerified: boolean;
14
+ }
15
+ export interface OAuthProviderPort {
16
+ /** Whether the provider has client credentials configured. */
17
+ isEnabled(provider: OAuthProviderName): boolean;
18
+ buildAuthorizationUrl(provider: OAuthProviderName, args: {
19
+ state: string;
20
+ codeVerifier: string;
21
+ nonce: string;
22
+ redirectUri: string;
23
+ }): Promise<string>;
24
+ exchangeCode(provider: OAuthProviderName, args: {
25
+ code: string;
26
+ state: string;
27
+ codeVerifier: string;
28
+ nonce: string;
29
+ redirectUri: string;
30
+ }): Promise<OAuthIdentity>;
31
+ }
32
+ export interface OAuthProviderConfig {
33
+ google?: {
34
+ clientId: string;
35
+ clientSecret: string;
36
+ } | undefined;
37
+ microsoft?: {
38
+ clientId: string;
39
+ clientSecret: string;
40
+ tenant?: string | undefined;
41
+ } | undefined;
42
+ }
43
+ /** Builds the provider config from environment, or `null` when nothing is set. */
44
+ export declare function readOAuthConfigFromEnv(env?: NodeJS.ProcessEnv): OAuthProviderConfig;
45
+ export declare class OpenIdOAuthProvider implements OAuthProviderPort {
46
+ private readonly cfg;
47
+ private readonly configs;
48
+ constructor(cfg: OAuthProviderConfig);
49
+ isEnabled(provider: OAuthProviderName): boolean;
50
+ private config;
51
+ buildAuthorizationUrl(provider: OAuthProviderName, args: {
52
+ state: string;
53
+ codeVerifier: string;
54
+ nonce: string;
55
+ redirectUri: string;
56
+ }): Promise<string>;
57
+ exchangeCode(provider: OAuthProviderName, args: {
58
+ code: string;
59
+ state: string;
60
+ codeVerifier: string;
61
+ nonce: string;
62
+ redirectUri: string;
63
+ }): Promise<OAuthIdentity>;
64
+ }
65
+ //# sourceMappingURL=oauth-provider-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oauth-provider-service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/oauth-provider-service.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,MAAM,MAAM,iBAAiB,GAAG,QAAQ,GAAG,WAAW,CAAC;AAEvD,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,6CAA6C;IAC7C,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,iBAAiB;IAChC,8DAA8D;IAC9D,SAAS,CAAC,QAAQ,EAAE,iBAAiB,GAAG,OAAO,CAAC;IAChD,qBAAqB,CACnB,QAAQ,EAAE,iBAAiB,EAC3B,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,GAChF,OAAO,CAAC,MAAM,CAAC,CAAC;IACnB,YAAY,CACV,QAAQ,EAAE,iBAAiB,EAC3B,IAAI,EAAE;QACJ,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;QACd,YAAY,EAAE,MAAM,CAAC;QACrB,KAAK,EAAE,MAAM,CAAC;QACd,WAAW,EAAE,MAAM,CAAC;KACrB,GACA,OAAO,CAAC,aAAa,CAAC,CAAC;CAC3B;AAED,MAAM,WAAW,mBAAmB;IAClC,MAAM,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC;IAChE,SAAS,CAAC,EACN;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;KAAE,GACvE,SAAS,CAAC;CACf;AAED,kFAAkF;AAClF,wBAAgB,sBAAsB,CAAC,GAAG,oBAAc,GAAG,mBAAmB,CAgB7E;AAED,qBAAa,mBAAoB,YAAW,iBAAiB;IAG/C,OAAO,CAAC,QAAQ,CAAC,GAAG;IAFhC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAoD;gBAE/C,GAAG,EAAE,mBAAmB;IAErD,SAAS,CAAC,QAAQ,EAAE,iBAAiB,GAAG,OAAO;YAIjC,MAAM;IAwBd,qBAAqB,CACzB,QAAQ,EAAE,iBAAiB,EAC3B,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,GAChF,OAAO,CAAC,MAAM,CAAC;IAcZ,YAAY,CAChB,QAAQ,EAAE,iBAAiB,EAC3B,IAAI,EAAE;QACJ,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;QACd,YAAY,EAAE,MAAM,CAAC;QACrB,KAAK,EAAE,MAAM,CAAC;QACd,WAAW,EAAE,MAAM,CAAC;KACrB,GACA,OAAO,CAAC,aAAa,CAAC;CAmB1B"}
@@ -0,0 +1,80 @@
1
+ import * as oidc from 'openid-client';
2
+ /** Builds the provider config from environment, or `null` when nothing is set. */
3
+ export function readOAuthConfigFromEnv(env = process.env) {
4
+ const cfg = {};
5
+ if (env['MFA_OAUTH_GOOGLE_CLIENT_ID'] && env['MFA_OAUTH_GOOGLE_CLIENT_SECRET']) {
6
+ cfg.google = {
7
+ clientId: env['MFA_OAUTH_GOOGLE_CLIENT_ID'],
8
+ clientSecret: env['MFA_OAUTH_GOOGLE_CLIENT_SECRET'],
9
+ };
10
+ }
11
+ if (env['MFA_OAUTH_MICROSOFT_CLIENT_ID'] && env['MFA_OAUTH_MICROSOFT_CLIENT_SECRET']) {
12
+ cfg.microsoft = {
13
+ clientId: env['MFA_OAUTH_MICROSOFT_CLIENT_ID'],
14
+ clientSecret: env['MFA_OAUTH_MICROSOFT_CLIENT_SECRET'],
15
+ tenant: env['MFA_OAUTH_MICROSOFT_TENANT'] ?? 'common',
16
+ };
17
+ }
18
+ return cfg;
19
+ }
20
+ export class OpenIdOAuthProvider {
21
+ cfg;
22
+ configs = new Map();
23
+ constructor(cfg) {
24
+ this.cfg = cfg;
25
+ }
26
+ isEnabled(provider) {
27
+ return provider === 'google' ? Boolean(this.cfg.google) : Boolean(this.cfg.microsoft);
28
+ }
29
+ async config(provider) {
30
+ const cached = this.configs.get(provider);
31
+ if (cached)
32
+ return cached;
33
+ if (provider === 'google') {
34
+ if (!this.cfg.google)
35
+ throw new Error('Google OAuth is not configured.');
36
+ const config = await oidc.discovery(new URL('https://accounts.google.com'), this.cfg.google.clientId, this.cfg.google.clientSecret);
37
+ this.configs.set(provider, config);
38
+ return config;
39
+ }
40
+ if (!this.cfg.microsoft)
41
+ throw new Error('Microsoft OAuth is not configured.');
42
+ const tenant = this.cfg.microsoft.tenant ?? 'common';
43
+ const config = await oidc.discovery(new URL(`https://login.microsoftonline.com/${tenant}/v2.0`), this.cfg.microsoft.clientId, this.cfg.microsoft.clientSecret);
44
+ this.configs.set(provider, config);
45
+ return config;
46
+ }
47
+ async buildAuthorizationUrl(provider, args) {
48
+ const config = await this.config(provider);
49
+ const codeChallenge = await oidc.calculatePKCECodeChallenge(args.codeVerifier);
50
+ const url = oidc.buildAuthorizationUrl(config, {
51
+ redirect_uri: args.redirectUri,
52
+ scope: 'openid email profile',
53
+ code_challenge: codeChallenge,
54
+ code_challenge_method: 'S256',
55
+ state: args.state,
56
+ nonce: args.nonce,
57
+ });
58
+ return url.href;
59
+ }
60
+ async exchangeCode(provider, args) {
61
+ const config = await this.config(provider);
62
+ const currentUrl = new URL(args.redirectUri);
63
+ currentUrl.searchParams.set('code', args.code);
64
+ currentUrl.searchParams.set('state', args.state);
65
+ const tokens = await oidc.authorizationCodeGrant(config, currentUrl, {
66
+ pkceCodeVerifier: args.codeVerifier,
67
+ expectedNonce: args.nonce,
68
+ expectedState: args.state,
69
+ idTokenExpected: true,
70
+ });
71
+ const claims = tokens.claims();
72
+ return {
73
+ provider,
74
+ sub: String(claims?.sub ?? ''),
75
+ email: typeof claims?.['email'] === 'string' ? claims['email'] : '',
76
+ emailVerified: claims?.['email_verified'] === true,
77
+ };
78
+ }
79
+ }
80
+ //# sourceMappingURL=oauth-provider-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oauth-provider-service.js","sourceRoot":"","sources":["../../../src/backend/services/oauth-provider-service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Recovery (backup) code helpers (feature 042). Codes are shown once at
3
+ * activation/regeneration; only their SHA-256 hashes are stored. Matching is
4
+ * case-insensitive and whitespace-tolerant (the hash normalizes the input).
5
+ */
6
+ export declare const RECOVERY_CODE_COUNT = 10;
7
+ export declare function generateRecoveryCodes(count?: number): string[];
8
+ export declare function hashRecoveryCode(code: string): string;
9
+ export declare function normalizeRecoveryCode(code: string): string;
10
+ //# sourceMappingURL=recovery-codes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recovery-codes.d.ts","sourceRoot":"","sources":["../../../src/backend/services/recovery-codes.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAGtC,wBAAgB,qBAAqB,CAAC,KAAK,GAAE,MAA4B,GAAG,MAAM,EAAE,CAInF;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAErD;AAED,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1D"}
@@ -0,0 +1,18 @@
1
+ import { createHash, randomBytes } from 'crypto';
2
+ /**
3
+ * Recovery (backup) code helpers (feature 042). Codes are shown once at
4
+ * activation/regeneration; only their SHA-256 hashes are stored. Matching is
5
+ * case-insensitive and whitespace-tolerant (the hash normalizes the input).
6
+ */
7
+ export const RECOVERY_CODE_COUNT = 10;
8
+ const CODE_BYTES = 5; // → 10 hex chars
9
+ export function generateRecoveryCodes(count = RECOVERY_CODE_COUNT) {
10
+ return Array.from({ length: count }, () => randomBytes(CODE_BYTES).toString('hex').toUpperCase());
11
+ }
12
+ export function hashRecoveryCode(code) {
13
+ return createHash('sha256').update(normalizeRecoveryCode(code)).digest('hex');
14
+ }
15
+ export function normalizeRecoveryCode(code) {
16
+ return code.replace(/\s+/g, '').toUpperCase();
17
+ }
18
+ //# sourceMappingURL=recovery-codes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recovery-codes.js","sourceRoot":"","sources":["../../../src/backend/services/recovery-codes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AAEjD;;;;GAIG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,EAAE,CAAC;AACtC,MAAM,UAAU,GAAG,CAAC,CAAC,CAAC,iBAAiB;AAEvC,MAAM,UAAU,qBAAqB,CAAC,QAAgB,mBAAmB;IACvE,OAAO,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,GAAG,EAAE,CACxC,WAAW,CAAC,UAAU,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,WAAW,EAAE,CACtD,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,IAAY;IAC3C,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,qBAAqB,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAChF,CAAC;AAED,MAAM,UAAU,qBAAqB,CAAC,IAAY;IAChD,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;AAChD,CAAC"}
@@ -0,0 +1,16 @@
1
+ export interface EncryptedSecret {
2
+ ciphertext: Buffer;
3
+ iv: Buffer;
4
+ authTag: Buffer;
5
+ }
6
+ export declare class SecretCipher {
7
+ private readonly key;
8
+ constructor(keyBase64: string);
9
+ /** Encrypt a plaintext secret (the base32 TOTP secret). */
10
+ encrypt(plaintext: string): EncryptedSecret;
11
+ /** Decrypt; throws if the ciphertext or tag has been tampered with. */
12
+ decrypt(secret: EncryptedSecret): string;
13
+ }
14
+ /** Timing-safe equality for two same-length hex/base64 strings. */
15
+ export declare function safeEqual(a: string, b: string): boolean;
16
+ //# sourceMappingURL=secret-cipher.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"secret-cipher.d.ts","sourceRoot":"","sources":["../../../src/backend/services/secret-cipher.ts"],"names":[],"mappings":"AAmBA,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;gBAEjB,SAAS,EAAE,MAAM;IAU7B,2DAA2D;IAC3D,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,eAAe;IAW3C,uEAAuE;IACvE,OAAO,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM;CASzC;AAED,mEAAmE;AACnE,wBAAgB,SAAS,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAKvD"}
@@ -0,0 +1,52 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual, } from 'crypto';
2
+ /**
3
+ * Authenticated encryption for the TOTP secret at rest (feature 042, R7).
4
+ *
5
+ * AES-256-GCM via Node's built-in crypto — no new dependency. The 32-byte key
6
+ * comes from `MFA_SECRET_ENCRYPTION_KEY` (base64). Each enrolment row stores
7
+ * its own random 12-byte IV and the GCM auth tag, so a leaked database does
8
+ * not yield working TOTP secrets.
9
+ */
10
+ const ALGORITHM = 'aes-256-gcm';
11
+ const IV_LENGTH = 12;
12
+ const KEY_LENGTH = 32;
13
+ export class SecretCipher {
14
+ key;
15
+ constructor(keyBase64) {
16
+ const key = Buffer.from(keyBase64, 'base64');
17
+ if (key.length !== KEY_LENGTH) {
18
+ throw new Error(`MFA_SECRET_ENCRYPTION_KEY must decode to ${KEY_LENGTH} bytes (got ${key.length}).`);
19
+ }
20
+ this.key = key;
21
+ }
22
+ /** Encrypt a plaintext secret (the base32 TOTP secret). */
23
+ encrypt(plaintext) {
24
+ const iv = randomBytes(IV_LENGTH);
25
+ const cipher = createCipheriv(ALGORITHM, this.key, iv);
26
+ const ciphertext = Buffer.concat([
27
+ cipher.update(plaintext, 'utf8'),
28
+ cipher.final(),
29
+ ]);
30
+ const authTag = cipher.getAuthTag();
31
+ return { ciphertext, iv, authTag };
32
+ }
33
+ /** Decrypt; throws if the ciphertext or tag has been tampered with. */
34
+ decrypt(secret) {
35
+ const decipher = createDecipheriv(ALGORITHM, this.key, secret.iv);
36
+ decipher.setAuthTag(secret.authTag);
37
+ const plaintext = Buffer.concat([
38
+ decipher.update(secret.ciphertext),
39
+ decipher.final(),
40
+ ]);
41
+ return plaintext.toString('utf8');
42
+ }
43
+ }
44
+ /** Timing-safe equality for two same-length hex/base64 strings. */
45
+ export function safeEqual(a, b) {
46
+ const bufA = Buffer.from(a);
47
+ const bufB = Buffer.from(b);
48
+ if (bufA.length !== bufB.length)
49
+ return false;
50
+ return timingSafeEqual(bufA, bufB);
51
+ }
52
+ //# sourceMappingURL=secret-cipher.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"secret-cipher.js","sourceRoot":"","sources":["../../../src/backend/services/secret-cipher.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,gBAAgB,EAChB,WAAW,EACX,eAAe,GAChB,MAAM,QAAQ,CAAC;AAEhB;;;;;;;GAOG;AACH,MAAM,SAAS,GAAG,aAAa,CAAC;AAChC,MAAM,SAAS,GAAG,EAAE,CAAC;AACrB,MAAM,UAAU,GAAG,EAAE,CAAC;AAQtB,MAAM,OAAO,YAAY;IACN,GAAG,CAAS;IAE7B,YAAY,SAAiB;QAC3B,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;QAC7C,IAAI,GAAG,CAAC,MAAM,KAAK,UAAU,EAAE,CAAC;YAC9B,MAAM,IAAI,KAAK,CACb,4CAA4C,UAAU,eAAe,GAAG,CAAC,MAAM,IAAI,CACpF,CAAC;QACJ,CAAC;QACD,IAAI,CAAC,GAAG,GAAG,GAAG,CAAC;IACjB,CAAC;IAED,2DAA2D;IAC3D,OAAO,CAAC,SAAiB;QACvB,MAAM,EAAE,GAAG,WAAW,CAAC,SAAS,CAAC,CAAC;QAClC,MAAM,MAAM,GAAG,cAAc,CAAC,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;QACvD,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,CAAC;YAC/B,MAAM,CAAC,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC;YAChC,MAAM,CAAC,KAAK,EAAE;SACf,CAAC,CAAC;QACH,MAAM,OAAO,GAAG,MAAM,CAAC,UAAU,EAAE,CAAC;QACpC,OAAO,EAAE,UAAU,EAAE,EAAE,EAAE,OAAO,EAAE,CAAC;IACrC,CAAC;IAED,uEAAuE;IACvE,OAAO,CAAC,MAAuB;QAC7B,MAAM,QAAQ,GAAG,gBAAgB,CAAC,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC;QAClE,QAAQ,CAAC,UAAU,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;QACpC,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,CAAC;YAC9B,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAC,UAAU,CAAC;YAClC,QAAQ,CAAC,KAAK,EAAE;SACjB,CAAC,CAAC;QACH,OAAO,SAAS,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IACpC,CAAC;CACF;AAED,mEAAmE;AACnE,MAAM,UAAU,SAAS,CAAC,CAAS,EAAE,CAAS;IAC5C,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC5B,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC5B,IAAI,IAAI,CAAC,MAAM,KAAK,IAAI,CAAC,MAAM;QAAE,OAAO,KAAK,CAAC;IAC9C,OAAO,eAAe,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;AACrC,CAAC"}
@@ -0,0 +1,40 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql';
2
+ import type { AuditPort } from '@endora-commerce/platform/kernel';
3
+ import type { OAuthIdentity } from './oauth-provider-service.js';
4
+ /**
5
+ * Resolves a federated identity to a platform account and records the link
6
+ * (feature 042, R6). Account find/create is delegated to injected ports so the
7
+ * `mfa` module never reaches into the customer/admin internals (Principle I):
8
+ * - Storefront: match by verified email, else auto-create a standalone
9
+ * customer (when registration-without-organization is allowed).
10
+ * - Admin: match an existing admin user only — never auto-create.
11
+ */
12
+ export interface SocialIdentityDeps {
13
+ resolveCustomerByEmail: (email: string) => Promise<{
14
+ id: string;
15
+ } | null>;
16
+ /** Returns the new account id, or null when standalone registration is off. */
17
+ autoCreateCustomer: (email: string) => Promise<{
18
+ id: string;
19
+ } | null>;
20
+ resolveAdminByEmail: (email: string) => Promise<{
21
+ id: string;
22
+ } | null>;
23
+ }
24
+ export type SocialSignInResult = {
25
+ ok: true;
26
+ subjectId: string;
27
+ } | {
28
+ ok: false;
29
+ reason: 'unverified' | 'registration_required' | 'no_account';
30
+ };
31
+ export declare class SocialIdentityService {
32
+ private readonly emFactory;
33
+ private readonly deps;
34
+ private readonly auditLogService;
35
+ constructor(emFactory: () => EntityManager, deps: SocialIdentityDeps, auditLogService: AuditPort);
36
+ signInCustomer(identity: OAuthIdentity): Promise<SocialSignInResult>;
37
+ signInAdmin(identity: OAuthIdentity): Promise<SocialSignInResult>;
38
+ private upsertLink;
39
+ }
40
+ //# sourceMappingURL=social-identity-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"social-identity-service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/social-identity-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kCAAkC,CAAC;AAElE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AAEjE;;;;;;;GAOG;AACH,MAAM,WAAW,kBAAkB;IACjC,sBAAsB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;IAC1E,+EAA+E;IAC/E,kBAAkB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;IACtE,mBAAmB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;CACxE;AAED,MAAM,MAAM,kBAAkB,GAC1B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAC/B;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,YAAY,GAAG,uBAAuB,GAAG,YAAY,CAAA;CAAE,CAAC;AAEjF,qBAAa,qBAAqB;IAE9B,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,eAAe;gBAFf,SAAS,EAAE,MAAM,aAAa,EAC9B,IAAI,EAAE,kBAAkB,EACxB,eAAe,EAAE,SAAS;IAGvC,cAAc,CAAC,QAAQ,EAAE,aAAa,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAmBpE,WAAW,CAAC,QAAQ,EAAE,aAAa,GAAG,OAAO,CAAC,kBAAkB,CAAC;YAazD,UAAU;CA0BzB"}
@@ -0,0 +1,69 @@
1
+ import { MfaSocialIdentity } from '../entities/mfa-social-identity.entity.js';
2
+ export class SocialIdentityService {
3
+ emFactory;
4
+ deps;
5
+ auditLogService;
6
+ constructor(emFactory, deps, auditLogService) {
7
+ this.emFactory = emFactory;
8
+ this.deps = deps;
9
+ this.auditLogService = auditLogService;
10
+ }
11
+ async signInCustomer(identity) {
12
+ if (!identity.emailVerified || !identity.email)
13
+ return { ok: false, reason: 'unverified' };
14
+ const existing = await this.deps.resolveCustomerByEmail(identity.email);
15
+ let account = existing;
16
+ let created = false;
17
+ if (!account) {
18
+ account = await this.deps.autoCreateCustomer(identity.email);
19
+ if (!account)
20
+ return { ok: false, reason: 'registration_required' };
21
+ created = true;
22
+ }
23
+ await this.upsertLink('customer', account.id, identity);
24
+ await this.auditLogService.record({
25
+ action: created ? 'mfa.social_account_created' : 'mfa.social_linked',
26
+ objectType: 'customer_account',
27
+ objectId: account.id,
28
+ });
29
+ return { ok: true, subjectId: account.id };
30
+ }
31
+ async signInAdmin(identity) {
32
+ if (!identity.emailVerified || !identity.email)
33
+ return { ok: false, reason: 'unverified' };
34
+ const account = await this.deps.resolveAdminByEmail(identity.email);
35
+ if (!account)
36
+ return { ok: false, reason: 'no_account' };
37
+ await this.upsertLink('admin', account.id, identity);
38
+ await this.auditLogService.record({
39
+ action: 'mfa.social_linked',
40
+ objectType: 'admin_user',
41
+ objectId: account.id,
42
+ });
43
+ return { ok: true, subjectId: account.id };
44
+ }
45
+ async upsertLink(subjectType, subjectId, identity) {
46
+ const em = this.emFactory();
47
+ let link = await em.findOne(MfaSocialIdentity, {
48
+ provider: identity.provider,
49
+ providerSubject: identity.sub,
50
+ });
51
+ if (!link) {
52
+ link = em.create(MfaSocialIdentity, {
53
+ subjectType,
54
+ subjectId,
55
+ provider: identity.provider,
56
+ providerSubject: identity.sub,
57
+ email: identity.email,
58
+ lastUsedAt: new Date(),
59
+ });
60
+ em.persist(link);
61
+ }
62
+ else {
63
+ link.email = identity.email;
64
+ link.lastUsedAt = new Date();
65
+ }
66
+ await em.flush();
67
+ }
68
+ }
69
+ //# sourceMappingURL=social-identity-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"social-identity-service.js","sourceRoot":"","sources":["../../../src/backend/services/social-identity-service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,63 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql';
2
+ import type { CustomerPasswordStatePort, MfaSocialLinkSummary, MfaSocialProvider, MfaSubjectRef } from '@endora-commerce/contracts';
3
+ import type { CommandBus } from '@endora-commerce/platform/commands';
4
+ /**
5
+ * The account-holder's view of their federated identities, and the one write
6
+ * that removes one (issue #194).
7
+ *
8
+ * Separate from {@link SocialIdentityService}, which resolves an incoming
9
+ * provider identity to an account at sign-in and therefore needs the account
10
+ * resolvers and a configured provider. This surface needs neither: the links a
11
+ * subject already holds stay visible and severable after an operator switches a
12
+ * provider off, which is exactly when somebody wants to look at them.
13
+ *
14
+ * ## Why an unlink can be refused
15
+ *
16
+ * A customer auto-created by a federated sign-in is given a random password at
17
+ * signup (`customer_accounts/backend.ts`) — one nobody was ever told. So
18
+ * `password_hash is not null` is true for such an account and answers a
19
+ * different question from the one that matters here, which is whether the
20
+ * holder has a credential they can actually use.
21
+ *
22
+ * Issue #194 could not ask that question and therefore refused the **last**
23
+ * link of every account, absolutely. Issue #222 added the datum —
24
+ * `customer_accounts.password_set_at`, read here over
25
+ * {@link CustomerPasswordStatePort} — so the rule is now the narrower and
26
+ * truthful one: refuse the last link **only** while the account has no password
27
+ * on record. Where another link remains, or a password has been set, the
28
+ * removal goes through. An active TOTP enrolment still does not unblock it: a
29
+ * second factor is not a first one, and nobody signs in with TOTP alone.
30
+ *
31
+ * That is also what makes the sentence at the seam an instruction again. It
32
+ * told the holder to set a password first, then said plainly that there was no
33
+ * way out (!723), because with the rule counting links the instruction named a
34
+ * step that lifted nothing. It lifts it now, so the wording went back — in the
35
+ * thrown message and in `errors.MFA_SOCIAL_LAST_CREDENTIAL`, both languages.
36
+ *
37
+ * **The admin arm asks nobody.** An `AdminUser` cannot exist without a password
38
+ * somebody supplied: `AdminUserService.create` takes a required one (as does
39
+ * `createAdminUserRequestSchema`), and `SocialIdentityService.signInAdmin`
40
+ * matches an existing admin and refuses when there is none — there is no
41
+ * auto-create on that side to mint a random hash. So the answer is structurally
42
+ * `true` and a second column would record a constant.
43
+ * `test/unit/mfa/admin-password-is-structural.test.ts` is what keeps that
44
+ * premise from rotting quietly.
45
+ */
46
+ export declare class SocialLinkService {
47
+ #private;
48
+ private readonly emFactory;
49
+ private readonly commandBus;
50
+ private readonly passwordState;
51
+ constructor(emFactory: () => EntityManager, commandBus: CommandBus, passwordState: CustomerPasswordStatePort);
52
+ /** The links this subject holds, in link order, each with its unlink verdict. */
53
+ list(subject: MfaSubjectRef): Promise<MfaSocialLinkSummary[]>;
54
+ /**
55
+ * Remove one provider link from this subject.
56
+ *
57
+ * Runs through the Command Bus (Constitution XIII): a security-relevant
58
+ * account change is audited co-transactionally, so a rollback takes the audit
59
+ * row with it.
60
+ */
61
+ unlink(subject: MfaSubjectRef, provider: MfaSocialProvider): Promise<void>;
62
+ }
63
+ //# sourceMappingURL=social-link-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"social-link-service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/social-link-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAE3D,OAAO,KAAK,EACV,yBAAyB,EACzB,oBAAoB,EACpB,iBAAiB,EACjB,aAAa,EACd,MAAM,4BAA4B,CAAC;AAEpC,OAAO,KAAK,EAAW,UAAU,EAAE,MAAM,oCAAoC,CAAC;AAG9E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,qBAAa,iBAAiB;;IAE1B,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,aAAa;gBAFb,SAAS,EAAE,MAAM,aAAa,EAC9B,UAAU,EAAE,UAAU,EACtB,aAAa,EAAE,yBAAyB;IAG3D,iFAAiF;IAC3E,IAAI,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,oBAAoB,EAAE,CAAC;IAkBnE;;;;;;OAMG;IACG,MAAM,CAAC,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;CAqDjF"}