@openlup/platform 0.28.0 → 0.29.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 (124) hide show
  1. package/CHANGELOG.md +219 -18
  2. package/README.md +143 -4
  3. package/dist/composition/index.d.ts +1 -0
  4. package/dist/composition/index.js +1 -0
  5. package/dist/composition/marketingContribution.d.ts +21 -0
  6. package/dist/composition/marketingContribution.js +29 -0
  7. package/dist/marketing/acquisitionCaseSchemas.d.ts +103 -0
  8. package/dist/marketing/acquisitionCaseSchemas.js +53 -0
  9. package/dist/marketing/acquisitionEvidenceSchemas.d.ts +105 -0
  10. package/dist/marketing/acquisitionEvidenceSchemas.js +55 -0
  11. package/dist/marketing/analytics/dataLayer.d.ts +18 -0
  12. package/dist/marketing/analytics/dataLayer.js +111 -0
  13. package/dist/marketing/analytics/noop.d.ts +9 -0
  14. package/dist/marketing/analytics/noop.js +8 -0
  15. package/dist/marketing/analytics/purchaseFacts.d.ts +4 -0
  16. package/dist/marketing/analytics/purchaseFacts.js +69 -0
  17. package/dist/marketing/analytics/routePolicy.d.ts +10 -0
  18. package/dist/marketing/analytics/routePolicy.js +59 -0
  19. package/dist/marketing/consentEvidence.d.ts +8 -0
  20. package/dist/marketing/consentEvidence.js +32 -0
  21. package/dist/marketing/contactLegal.d.ts +14 -0
  22. package/dist/marketing/contactLegal.js +19 -0
  23. package/dist/marketing/contracts/acquisition.d.ts +88 -0
  24. package/dist/marketing/contracts/acquisition.js +1 -0
  25. package/dist/marketing/contracts/acquisitionPorts.d.ts +49 -0
  26. package/dist/marketing/contracts/acquisitionPorts.js +1 -0
  27. package/dist/marketing/contracts/analytics.d.ts +144 -0
  28. package/dist/marketing/contracts/analytics.js +1 -0
  29. package/dist/marketing/contracts/contactLegal.d.ts +53 -0
  30. package/dist/marketing/contracts/contactLegal.js +1 -0
  31. package/dist/marketing/contracts/index.d.ts +6 -0
  32. package/dist/marketing/contracts/index.js +1 -0
  33. package/dist/marketing/contracts/prelaunch.d.ts +39 -0
  34. package/dist/marketing/contracts/prelaunch.js +1 -0
  35. package/dist/marketing/index.d.ts +16 -0
  36. package/dist/marketing/index.js +14 -0
  37. package/dist/marketing/prelaunchAcquisitionSchemas.d.ts +116 -0
  38. package/dist/marketing/prelaunchAcquisitionSchemas.js +51 -0
  39. package/dist/marketing/seo/jsonLd.d.ts +157 -0
  40. package/dist/marketing/seo/jsonLd.js +127 -0
  41. package/dist/marketing/seo/metadata.d.ts +41 -0
  42. package/dist/marketing/seo/metadata.js +36 -0
  43. package/dist/marketing/seo/serializeJsonLd.d.ts +1 -0
  44. package/dist/marketing/seo/serializeJsonLd.js +6 -0
  45. package/dist/marketing/seo/urls.d.ts +14 -0
  46. package/dist/marketing/seo/urls.js +23 -0
  47. package/dist/marketing/server/acquisitionCaseDirect.d.ts +4 -0
  48. package/dist/marketing/server/acquisitionCaseDirect.js +88 -0
  49. package/dist/marketing/server/acquisitionCaseHandlers.d.ts +28 -0
  50. package/dist/marketing/server/acquisitionCaseHandlers.js +40 -0
  51. package/dist/marketing/server/acquisitionCasePorts.d.ts +42 -0
  52. package/dist/marketing/server/acquisitionCasePorts.js +1 -0
  53. package/dist/marketing/server/acquisitionEvidenceDirect.d.ts +5 -0
  54. package/dist/marketing/server/acquisitionEvidenceDirect.js +100 -0
  55. package/dist/marketing/server/analyticsContext.d.ts +3 -0
  56. package/dist/marketing/server/analyticsContext.js +65 -0
  57. package/dist/marketing/server/analyticsContextPorts.d.ts +31 -0
  58. package/dist/marketing/server/analyticsContextPorts.js +1 -0
  59. package/dist/marketing/server/bindings.d.ts +14 -0
  60. package/dist/marketing/server/bindings.js +18 -0
  61. package/dist/marketing/server/directPorts.d.ts +26 -0
  62. package/dist/marketing/server/directPorts.js +1 -0
  63. package/dist/marketing/server/index.d.ts +14 -0
  64. package/dist/marketing/server/index.js +11 -0
  65. package/dist/marketing/server/postgres/acquisitionCaseRoutines.d.ts +10 -0
  66. package/dist/marketing/server/postgres/acquisitionCaseRoutines.js +204 -0
  67. package/dist/marketing/server/postgres/acquisitionEvidenceRoutines.d.ts +10 -0
  68. package/dist/marketing/server/postgres/acquisitionEvidenceRoutines.js +107 -0
  69. package/dist/marketing/server/postgres/index.d.ts +3 -0
  70. package/dist/marketing/server/postgres/index.js +3 -0
  71. package/dist/marketing/server/prelaunchAcquisitionDirect.d.ts +4 -0
  72. package/dist/marketing/server/prelaunchAcquisitionDirect.js +122 -0
  73. package/dist/marketing/server/privacyContributor.d.ts +66 -0
  74. package/dist/marketing/server/privacyContributor.js +87 -0
  75. package/dist/marketing/server/research/adminSurveyResponsesHandler.d.ts +9 -0
  76. package/dist/marketing/server/research/adminSurveyResponsesHandler.js +53 -0
  77. package/dist/marketing/server/research/managedSurveyResponseSubmit.d.ts +39 -0
  78. package/dist/marketing/server/research/managedSurveyResponseSubmit.js +102 -0
  79. package/dist/marketing/server/research/surveyResponseSubmitHandler.d.ts +8 -0
  80. package/dist/marketing/server/research/surveyResponseSubmitHandler.js +41 -0
  81. package/dist/marketing/validation.d.ts +2 -0
  82. package/dist/marketing/validation.js +3 -0
  83. package/package.json +22 -2
  84. package/release-gates.json +72 -4
  85. package/src/composition/index.ts +1 -0
  86. package/src/composition/marketingContribution.ts +37 -0
  87. package/src/marketing/acquisitionCaseSchemas.ts +75 -0
  88. package/src/marketing/acquisitionEvidenceSchemas.ts +72 -0
  89. package/src/marketing/analytics/dataLayer.ts +100 -0
  90. package/src/marketing/analytics/noop.ts +14 -0
  91. package/src/marketing/analytics/purchaseFacts.ts +63 -0
  92. package/src/marketing/analytics/routePolicy.ts +55 -0
  93. package/src/marketing/consentEvidence.ts +36 -0
  94. package/src/marketing/contactLegal.ts +22 -0
  95. package/src/marketing/contracts/acquisition.ts +50 -0
  96. package/src/marketing/contracts/acquisitionPorts.ts +48 -0
  97. package/src/marketing/contracts/analytics.ts +88 -0
  98. package/src/marketing/contracts/contactLegal.ts +41 -0
  99. package/src/marketing/contracts/index.ts +17 -0
  100. package/src/marketing/contracts/prelaunch.ts +15 -0
  101. package/src/marketing/index.ts +16 -0
  102. package/src/marketing/prelaunchAcquisitionSchemas.ts +64 -0
  103. package/src/marketing/seo/jsonLd.ts +153 -0
  104. package/src/marketing/seo/metadata.ts +62 -0
  105. package/src/marketing/seo/serializeJsonLd.ts +6 -0
  106. package/src/marketing/seo/urls.ts +34 -0
  107. package/src/marketing/server/acquisitionCaseDirect.ts +67 -0
  108. package/src/marketing/server/acquisitionCaseHandlers.ts +57 -0
  109. package/src/marketing/server/acquisitionCasePorts.ts +42 -0
  110. package/src/marketing/server/acquisitionEvidenceDirect.ts +76 -0
  111. package/src/marketing/server/analyticsContext.ts +55 -0
  112. package/src/marketing/server/analyticsContextPorts.ts +28 -0
  113. package/src/marketing/server/bindings.ts +27 -0
  114. package/src/marketing/server/directPorts.ts +27 -0
  115. package/src/marketing/server/index.ts +14 -0
  116. package/src/marketing/server/postgres/acquisitionCaseRoutines.ts +218 -0
  117. package/src/marketing/server/postgres/acquisitionEvidenceRoutines.ts +103 -0
  118. package/src/marketing/server/postgres/index.ts +3 -0
  119. package/src/marketing/server/prelaunchAcquisitionDirect.ts +121 -0
  120. package/src/marketing/server/privacyContributor.ts +123 -0
  121. package/src/marketing/server/research/adminSurveyResponsesHandler.ts +78 -0
  122. package/src/marketing/server/research/managedSurveyResponseSubmit.ts +158 -0
  123. package/src/marketing/server/research/surveyResponseSubmitHandler.ts +57 -0
  124. package/src/marketing/validation.ts +3 -0
@@ -599,6 +599,73 @@
599
599
  ],
600
600
  "entrypoint": "dist/account-support/contracts/index.d.ts",
601
601
  "snapshot": "api/account-support-contracts.api.md"
602
+ },
603
+ "./marketing": {
604
+ "role": "capability",
605
+ "maturity": "experimental",
606
+ "compatibilityPolicy": "internal-candidate",
607
+ "packageSmokeEvidence": [
608
+ "test/packageBoundary.test.ts",
609
+ "test/marketing/schemas.test.ts",
610
+ "test/marketing/contracts.test.ts",
611
+ "test/marketing/browserBoundary.test.ts",
612
+ "test/marketing/noop.test.ts",
613
+ "test/marketing/dataLayer.test.ts",
614
+ "test/marketing/routePolicy.test.ts",
615
+ "test/marketing/purchaseFacts.test.ts",
616
+ "test/marketing/consentEvidence.test.ts"
617
+ ],
618
+ "entrypoint": "dist/marketing/index.d.ts",
619
+ "snapshot": "api/marketing.api.md"
620
+ },
621
+ "./marketing/contracts": {
622
+ "role": "capability",
623
+ "maturity": "experimental",
624
+ "compatibilityPolicy": "internal-candidate",
625
+ "packageSmokeEvidence": [
626
+ "test/packageBoundary.test.ts",
627
+ "test/marketing/schemas.test.ts",
628
+ "test/marketing/contracts.test.ts",
629
+ "test/marketing/browserBoundary.test.ts",
630
+ "test/marketing/noop.test.ts",
631
+ "test/marketing/dataLayer.test.ts",
632
+ "test/marketing/routePolicy.test.ts",
633
+ "test/marketing/purchaseFacts.test.ts",
634
+ "test/marketing/consentEvidence.test.ts"
635
+ ],
636
+ "entrypoint": "dist/marketing/contracts/index.d.ts",
637
+ "snapshot": "api/marketing-contracts.api.md"
638
+ },
639
+ "./marketing/server": {
640
+ "role": "capability",
641
+ "maturity": "experimental",
642
+ "compatibilityPolicy": "internal-candidate",
643
+ "packageSmokeEvidence": [
644
+ "test/packageBoundary.test.ts",
645
+ "test/marketing/browserBoundary.test.ts",
646
+ "test/marketing/server/acquisitionCaseHandlers.test.ts",
647
+ "test/marketing/server/acquisitionCaseDirect.test.ts",
648
+ "test/marketing/server/prelaunchAcquisitionDirect.test.ts",
649
+ "test/marketing/server/acquisitionEvidenceDirect.test.ts",
650
+ "test/marketing/server/research/surveyResponseSubmitHandler.test.ts",
651
+ "test/marketing/server/research/adminSurveyResponsesHandler.test.ts",
652
+ "test/marketing/server/research/managedSurveyResponseSubmit.test.ts",
653
+ "test/marketing/analyticsContext.test.ts"
654
+ ],
655
+ "entrypoint": "dist/marketing/server/index.d.ts",
656
+ "snapshot": "api/marketing-server.api.md"
657
+ },
658
+ "./marketing/server/postgres": {
659
+ "role": "capability",
660
+ "maturity": "experimental",
661
+ "compatibilityPolicy": "internal-candidate",
662
+ "packageSmokeEvidence": [
663
+ "test/packageBoundary.test.ts",
664
+ "test/marketing/server/postgres/acquisitionCaseRoutines.test.ts",
665
+ "test/marketing/server/postgres/acquisitionEvidenceRoutines.test.ts"
666
+ ],
667
+ "entrypoint": "dist/marketing/server/postgres/index.d.ts",
668
+ "snapshot": "api/marketing-server-postgres.api.md"
602
669
  }
603
670
  },
604
671
  "pack": {
@@ -607,15 +674,15 @@
607
674
  "maxFiles": 2520
608
675
  },
609
676
  "docs": {
610
- "maxPackedBytes": 57344,
677
+ "maxPackedBytes": 73728,
611
678
  "maxFiles": 10
612
679
  },
613
680
  "maxPackedBytes": 1925000,
614
681
  "maxUnpackedBytes": 8970000,
615
682
  "maxFiles": 2580,
616
683
  "baseline": {
617
- "version": "0.27.0",
618
- "packedBytes": 1734781
684
+ "version": "0.28.0",
685
+ "packedBytes": 1807006
619
686
  },
620
687
  "browserEntries": {
621
688
  "./catalog/contracts": 4096,
@@ -631,7 +698,8 @@
631
698
  "./invoicing/contracts": 32,
632
699
  "./commerce/contracts": 32,
633
700
  "./pricing/browser": 25600,
634
- "./account-support/contracts": 32
701
+ "./account-support/contracts": 32,
702
+ "./marketing/contracts": 32
635
703
  }
636
704
  }
637
705
  }
@@ -23,3 +23,4 @@ export { createFulfillmentContribution } from "./fulfillmentContribution.js";
23
23
  export { createSubscriptionContribution } from "./subscriptionContribution.js";
24
24
  export { createCommerceContribution } from "./commerceContribution.js";
25
25
  export { createAccountSupportContribution } from "./accountSupportContribution.js";
26
+ export { createMarketingContribution } from "./marketingContribution.js";
@@ -0,0 +1,37 @@
1
+ import type { Contribution, RequiredSchema } from "@openlup/core/readiness";
2
+
3
+ /** Describe existing selected marketing callables; never install another executor. */
4
+ export function createMarketingContribution(input: {
5
+ version: string;
6
+ requiredSchema: RequiredSchema;
7
+ marketing: Readonly<Record<string, ((...args: never[]) => unknown) | null>>;
8
+ handlers: Contribution["handlers"];
9
+ schedules: Contribution["schedules"];
10
+ routes: Contribution["routes"];
11
+ }) {
12
+ for (const [kind, names] of [
13
+ ["handler", input.handlers.map(handler => handler.eventType)],
14
+ ["schedule", input.schedules.map(schedule => schedule.id)],
15
+ ["route", input.routes.map(route => route.id)],
16
+ ] as const) {
17
+ if (new Set(names).size !== names.length) throw new Error(`marketing_duplicate_${kind}`);
18
+ }
19
+ const contribution: Contribution = {
20
+ manifest: {
21
+ name: "@openlup/platform", version: input.version, kind: "rail",
22
+ module: { id: "marketing", kind: "capability", provides: ["./marketing", "./marketing/contracts", "./marketing/server"],
23
+ // The server entry validates obligations with identity's runtime contract schema.
24
+ dependsOn: [{ module: "identity", entries: ["./identity/contracts"] }] },
25
+ emits: [], handles: input.handlers.map(handler => handler.eventType),
26
+ schedules: input.schedules.map(({ id, cadenceSeconds }) => ({ id, cadenceSeconds })), env: [],
27
+ requiredPorts: Object.keys(input.marketing), routes: input.routes.map(route => route.id), requiredSchema: input.requiredSchema,
28
+ },
29
+ handlers: input.handlers.map(({ eventType, timeoutMs }) => Object.freeze({ eventType, timeoutMs })), schedules: input.schedules, routes: input.routes,
30
+ };
31
+ return {
32
+ contributions: [contribution],
33
+ ports: Object.fromEntries(Object.entries(input.marketing).filter(([, fn]) => typeof fn === "function").map(([name, fn]) => [`@openlup/platform/marketing:${name}`, fn])),
34
+ routes: Object.fromEntries(input.routes.map(route => [`@openlup/platform/marketing:${route.id}`, route.handle])),
35
+ triggers: Object.fromEntries(input.schedules.map(schedule => [`@openlup/platform/marketing:${schedule.id}`, schedule.run])),
36
+ };
37
+ }
@@ -0,0 +1,75 @@
1
+ import { z } from "./validation.js";
2
+
3
+ export const ACQUISITION_CASE_CONTRACT_VERSION = "tester_application_v1" as const;
4
+ export const ACQUISITION_CASE_VIEW = "acquisition_case_v1" as const;
5
+ export const ACQUISITION_CASE_SOURCE = "tester_application" as const;
6
+
7
+ export const acquisitionOpaqueReferenceSchema = z.string().trim().min(8).max(160)
8
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/);
9
+ const dateTimeSchema = z.string().datetime({ offset: true });
10
+
11
+ export const acquisitionAddressReferenceSchema = z.object({
12
+ source: z.string().trim().min(1).max(48).regex(/^[a-z][a-z0-9_-]*$/),
13
+ reference: acquisitionOpaqueReferenceSchema,
14
+ revision: z.string().trim().min(1).max(64).regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/),
15
+ provenance: z.string().trim().min(1).max(64).regex(/^[a-z][a-z0-9_-]*$/),
16
+ }).strict();
17
+
18
+
19
+ export const acquisitionCaseStateSchema = z.enum([
20
+ "submitted", "approved", "active", "rejected", "withdrawn",
21
+ ]);
22
+
23
+ export const acquisitionCaseProjectionSchema = z.object({
24
+ contractVersion: z.literal(ACQUISITION_CASE_CONTRACT_VERSION),
25
+ caseRef: acquisitionOpaqueReferenceSchema,
26
+ contactRef: acquisitionOpaqueReferenceSchema,
27
+ sourceKind: z.literal(ACQUISITION_CASE_SOURCE),
28
+ consent: z.object({
29
+ consentVersion: z.string().min(1).max(64),
30
+ policyVersion: z.string().min(1).max(64),
31
+ recordedAt: dateTimeSchema,
32
+ locale: z.enum(["en", "pl"]),
33
+ sourcePath: z.literal("/tester-application"),
34
+ }).strict(),
35
+ addressReference: acquisitionAddressReferenceSchema.nullable(),
36
+ state: acquisitionCaseStateSchema,
37
+ version: z.number().int().positive(),
38
+ createdAt: dateTimeSchema,
39
+ updatedAt: dateTimeSchema,
40
+ }).strict().superRefine((value, context) => {
41
+ if (value.state !== "withdrawn" && value.addressReference === null) {
42
+ context.addIssue({ code: z.ZodIssueCode.custom, path: ["addressReference"], message: "active case requires address reference" });
43
+ }
44
+ });
45
+
46
+ export const acquisitionCaseListRequestSchema = z.object({
47
+ view: z.literal(ACQUISITION_CASE_VIEW),
48
+ cursor: acquisitionOpaqueReferenceSchema.optional(),
49
+ limit: z.number().int().min(1).max(100).default(25),
50
+ }).strict();
51
+
52
+ export const acquisitionCaseListResponseSchema = z.object({
53
+ contractVersion: z.literal(ACQUISITION_CASE_CONTRACT_VERSION),
54
+ cases: z.array(acquisitionCaseProjectionSchema).max(100),
55
+ nextCursor: acquisitionOpaqueReferenceSchema.nullable(),
56
+ }).strict();
57
+
58
+
59
+
60
+
61
+ export const acquisitionCaseActiveCountResponseSchema = z.object({
62
+ activeTesterCount: z.number().int().nonnegative(),
63
+ }).strict();
64
+
65
+ export const acquisitionIdempotencyKeySchema = z.string().trim().min(8).max(200)
66
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/);
67
+
68
+ export type AcquisitionOpaqueReference = z.infer<typeof acquisitionOpaqueReferenceSchema>;
69
+ export type AcquisitionAddressReference = z.infer<typeof acquisitionAddressReferenceSchema>;
70
+ export type AcquisitionCaseState = z.infer<typeof acquisitionCaseStateSchema>;
71
+ export type AcquisitionCaseProjection = z.infer<typeof acquisitionCaseProjectionSchema>;
72
+ export type AcquisitionCaseListRequest = z.infer<typeof acquisitionCaseListRequestSchema>;
73
+ export type AcquisitionCaseListResponse = z.infer<typeof acquisitionCaseListResponseSchema>;
74
+ export type AcquisitionCaseActiveCountResponse = z.infer<typeof acquisitionCaseActiveCountResponseSchema>;
75
+ export type AcquisitionIdempotencyKey = z.infer<typeof acquisitionIdempotencyKeySchema>;
@@ -0,0 +1,72 @@
1
+ import { z } from "./validation.js";
2
+
3
+ export const ACQUISITION_SURVEY_VERSION = "acquisition_survey_v1" as const;
4
+ export const ACQUISITION_NEWSLETTER_VERSION = "acquisition_newsletter_consent_v1" as const;
5
+
6
+ const caseReference = z.string().regex(/^acquisition-case:[0-9a-f-]{36}$/);
7
+ const contactReference = z.string().regex(/^acquisition-contact:[0-9a-f-]{36}$/);
8
+ const commandKey = z.string().min(8).max(200).regex(/^[A-Za-z0-9][A-Za-z0-9._:-]+$/);
9
+ const digest = z.string().regex(/^[0-9a-f]{64}$/);
10
+
11
+ export const acquisitionSurveySubmitSchema = z.object({
12
+ view: z.literal(ACQUISITION_SURVEY_VERSION),
13
+ caseReference,
14
+ surveyType: z.enum(["producer", "consumer"]),
15
+ responseData: z.record(z.string(), z.unknown()),
16
+ });
17
+
18
+ export const acquisitionSurveyEvidenceSchema = z.object({
19
+ evidenceReference: z.string().regex(/^acquisition-survey:[0-9a-f-]{36}$/),
20
+ caseReference,
21
+ contactReference,
22
+ surveyType: z.enum(["producer", "consumer"]),
23
+ responseDigest: digest,
24
+ answerCount: z.number().int().min(1).max(100),
25
+ recordedAt: z.string().datetime({ offset: true }),
26
+ });
27
+
28
+ export const acquisitionSurveySubmitResponseSchema = z.object({
29
+ contractVersion: z.literal(ACQUISITION_SURVEY_VERSION),
30
+ evidence: acquisitionSurveyEvidenceSchema,
31
+ replayed: z.boolean(),
32
+ });
33
+
34
+ export const acquisitionSurveyListQuerySchema = z.object({
35
+ view: z.literal(ACQUISITION_SURVEY_VERSION),
36
+ surveyType: z.enum(["producer", "consumer"]).optional(),
37
+ limit: z.coerce.number().int().min(1).max(100).default(50),
38
+ });
39
+
40
+ export const acquisitionSurveyListResponseSchema = z.object({
41
+ contractVersion: z.literal(ACQUISITION_SURVEY_VERSION),
42
+ rows: z.array(acquisitionSurveyEvidenceSchema),
43
+ totalCount: z.number().int().min(0),
44
+ });
45
+
46
+ export const acquisitionNewsletterConsentEventSchema = z.object({
47
+ contractVersion: z.literal(ACQUISITION_NEWSLETTER_VERSION),
48
+ caseReference,
49
+ contactReference,
50
+ eventReference: commandKey,
51
+ eventType: z.enum(["subscribe", "unsubscribe", "suppress", "complaint", "update"]),
52
+ purpose: z.enum(["marketing_launch_offer", "marketing_newsletter"]),
53
+ explicitOptInEvidence: z.boolean().default(false),
54
+ occurredAt: z.string().datetime({ offset: true }),
55
+ }).strict();
56
+
57
+ export const acquisitionNewsletterConsentResponseSchema = z.object({
58
+ contractVersion: z.literal(ACQUISITION_NEWSLETTER_VERSION),
59
+ eventReference: commandKey,
60
+ outcome: z.enum(["applied", "ignored", "stale"]),
61
+ consentState: z.enum(["granted", "suppressed"]).nullable(),
62
+ auditReference: z.string().regex(/^acquisition-consent:[0-9a-f-]{36}$/),
63
+ replayed: z.boolean(),
64
+ });
65
+
66
+ export type AcquisitionSurveySubmit = z.infer<typeof acquisitionSurveySubmitSchema>;
67
+ export type AcquisitionSurveyEvidence = z.infer<typeof acquisitionSurveyEvidenceSchema>;
68
+ export type AcquisitionSurveySubmitResponse = z.infer<typeof acquisitionSurveySubmitResponseSchema>;
69
+ export type AcquisitionSurveyListQuery = z.infer<typeof acquisitionSurveyListQuerySchema>;
70
+ export type AcquisitionSurveyListResponse = z.infer<typeof acquisitionSurveyListResponseSchema>;
71
+ export type AcquisitionNewsletterConsentEvent = z.infer<typeof acquisitionNewsletterConsentEventSchema>;
72
+ export type AcquisitionNewsletterConsentResponse = z.infer<typeof acquisitionNewsletterConsentResponseSchema>;
@@ -0,0 +1,100 @@
1
+ import type { AnalyticsSinkAttemptMask, AnalyticsSinkAdmission, AnalyticsCookieConsent, AnalyticsDataLayerOptions } from "../contracts/analytics.js";
2
+ export type { AnalyticsSinkAttemptMask, AnalyticsSinkAdmission, AnalyticsCookieConsent, AnalyticsBrowserWindow, AnalyticsDataLayerOptions } from "../contracts/analytics.js";
3
+ import { trackDeploymentEvent } from "./noop.js";
4
+ import type { AnalyticsEventMap, AnalyticsEventName } from "./noop.js";
5
+
6
+ type ConsentState = "granted" | "denied";
7
+
8
+ /** Each instance owns loader state; the host supplies browser and deployment policy. */
9
+ export function createAnalyticsDataLayer<StepName extends string = string>(options: AnalyticsDataLayerOptions<StepName> = {}) {
10
+ const { consentStorageKey = "", consentChangeEvent = "", consentSignalNames, eventPrefix = "",
11
+ readContainerId = () => null, getWindow = () => undefined } = options;
12
+ const deploymentSink = options.trackDeploymentEvent ?? trackDeploymentEvent;
13
+ let analyticsLoaderReady = false;
14
+ const readinessListeners = new Set<() => void>();
15
+ function readCookieConsent(): AnalyticsCookieConsent | null {
16
+ const browser = getWindow();
17
+ if (!browser) return null;
18
+ try {
19
+ const parsed: unknown = JSON.parse(browser.localStorage.getItem(consentStorageKey) ?? "");
20
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
21
+ const value = parsed as Record<string, unknown>;
22
+ if (typeof value.necessary !== "boolean" || typeof value.functional !== "boolean"
23
+ || typeof value.analytics !== "boolean" || typeof value.marketing !== "boolean") return null;
24
+ return { necessary: value.necessary, functional: value.functional,
25
+ analytics: value.analytics, marketing: value.marketing };
26
+ } catch { return null; }
27
+ }
28
+ function hasAnalyticsConsent(consent = readCookieConsent()): boolean { return consent?.analytics === true; }
29
+ function configuredContainerId(): string | null { return readContainerId(); }
30
+ function subscribeToAnalyticsConsent(listener: () => void): () => void {
31
+ const browser = getWindow();
32
+ if (!browser) return () => undefined;
33
+ const onStorage = (event: StorageEvent) => { if (event.key === consentStorageKey) listener(); };
34
+ browser.addEventListener("storage", onStorage);
35
+ browser.addEventListener(consentChangeEvent, listener);
36
+ return () => {
37
+ browser.removeEventListener("storage", onStorage);
38
+ browser.removeEventListener(consentChangeEvent, listener);
39
+ };
40
+ }
41
+ function isAnalyticsLoaderReady(): boolean { return analyticsLoaderReady; }
42
+ function subscribeToAnalyticsLoaderReadiness(listener: () => void): () => void {
43
+ readinessListeners.add(listener);
44
+ return () => readinessListeners.delete(listener);
45
+ }
46
+ function markAnalyticsLoaderReady(): void {
47
+ if (analyticsLoaderReady) return;
48
+ analyticsLoaderReady = true;
49
+ for (const listener of readinessListeners) listener();
50
+ }
51
+ function resetAnalyticsLoaderReadiness(): void { analyticsLoaderReady = false; }
52
+ function pushAnalyticsConsentDefault(): void {
53
+ pushConsentCommand("default", "denied", "denied");
54
+ }
55
+ function pushAnalyticsConsentUpdate(granted: boolean): void {
56
+ const analyticsState: ConsentState = granted ? "granted" : "denied";
57
+ // Read the separate advertising grant at push time; revocation denies all signals.
58
+ const adState: ConsentState = granted && readCookieConsent()?.marketing === true ? "granted" : "denied";
59
+ pushConsentCommand("update", analyticsState, adState);
60
+ }
61
+ function pushAnalyticsEvent<K extends AnalyticsEventName>(event: K, properties: AnalyticsEventMap<StepName>[K]): boolean {
62
+ const browser = getWindow();
63
+ if (!consentSignalNames || !browser || !configuredContainerId() || !hasAnalyticsConsent()) return false;
64
+ if (!browser.dataLayer) {
65
+ browser.dataLayer = [];
66
+ pushAnalyticsConsentDefault();
67
+ pushAnalyticsConsentUpdate(true);
68
+ }
69
+ browser.dataLayer.push({ event: `${eventPrefix}${event}`, ...properties });
70
+ return true;
71
+ }
72
+ function emitAnalyticsEvent<K extends AnalyticsEventName>(event: K, properties: AnalyticsEventMap<StepName>[K],
73
+ attempts: AnalyticsSinkAttemptMask = { deployment: true, optional: true }): AnalyticsSinkAdmission {
74
+ let deployment = false;
75
+ let optional = false;
76
+ if (attempts.deployment) {
77
+ try { deployment = deploymentSink(event, properties); } catch { /* Sink failure never gates the product. */ }
78
+ }
79
+ if (attempts.optional) {
80
+ try { optional = pushAnalyticsEvent(event, properties); } catch { /* Independent best-effort admission. */ }
81
+ }
82
+ return { deployment, optional };
83
+ }
84
+ function pushConsentCommand(action: "default" | "update", analyticsState: ConsentState, adState: ConsentState): void {
85
+ const browser = getWindow();
86
+ if (!consentSignalNames || !browser?.dataLayer) return;
87
+ const settings: Record<string, ConsentState> = { [consentSignalNames.analytics]: analyticsState };
88
+ for (const name of consentSignalNames.advertising) settings[name] = adState;
89
+ function consentCommand(..._args: unknown[]) {
90
+ // Consent commands use the array-like arguments object, not an event object.
91
+ // eslint-disable-next-line prefer-rest-params
92
+ browser?.dataLayer?.push(arguments);
93
+ }
94
+ consentCommand("consent", action, settings);
95
+ }
96
+ return { readCookieConsent, hasAnalyticsConsent, configuredContainerId, subscribeToAnalyticsConsent,
97
+ isAnalyticsLoaderReady, subscribeToAnalyticsLoaderReadiness, markAnalyticsLoaderReady,
98
+ resetAnalyticsLoaderReadiness, pushAnalyticsConsentDefault, pushAnalyticsConsentUpdate,
99
+ pushAnalyticsEvent, emitAnalyticsEvent };
100
+ }
@@ -0,0 +1,14 @@
1
+ import type { AnalyticsEventMap, AnalyticsEventName } from "../contracts/analytics.js";
2
+ export type { AnalyticsStepName, AnalyticsCheckoutItem, AnalyticsPurchaseItem, AnalyticsCheckoutMode, AnalyticsPaymentWaitOutcome, AnalyticsEventMap, AnalyticsEventName } from "../contracts/analytics.js";
3
+ import type { AnalyticsPort } from "@openlup/core/platform-runtime";
4
+
5
+ export function initializeDeploymentAnalytics(): void {}
6
+ export function trackDeploymentEvent<K extends AnalyticsEventName>(
7
+ _event: K, _properties: AnalyticsEventMap[K],
8
+ ): boolean { return false; }
9
+ export function syncAdvertisingConsent(): void {}
10
+ export function trackAdvertisingPurchase(_payload: AnalyticsEventMap["purchase"]): boolean { return false; }
11
+ /** Inert composition satisfies the existing platform runtime port. */
12
+ export function createNoopAnalytics(): AnalyticsPort {
13
+ return { trackPageView() {}, trackEvent() {} };
14
+ }
@@ -0,0 +1,63 @@
1
+ import type { PurchaseOrder, PurchaseSuppressionReason } from "../contracts/analytics.js";
2
+ export type { PurchaseOrder, PurchaseSuppressionReason } from "../contracts/analytics.js";
3
+ import type { AnalyticsEventMap, AnalyticsPurchaseItem } from "./noop.js";
4
+
5
+ const MAX_ITEMS = 200;
6
+
7
+ export function projectPaidPurchase(recap: PurchaseOrder,
8
+ suppressPurchase: (reason: PurchaseSuppressionReason) => null = () => null,
9
+ ): AnalyticsEventMap["purchase"] | null {
10
+ if (recap.status !== "paid") return suppressPurchase("order_not_paid");
11
+ if (recap.paymentStatus !== "succeeded") return suppressPurchase("payment_not_succeeded");
12
+ if (recap.checkoutKind !== "one_time" && recap.checkoutKind !== "subscription_initial"
13
+ && recap.checkoutKind !== "subscription_renewal") return suppressPurchase("checkout_kind_unsupported");
14
+ if (!recap.moneyReconciled) return suppressPurchase("money_unreconciled");
15
+ if (recap.items.length === 0) return suppressPurchase("items_empty");
16
+ let effectiveNet = 0;
17
+ const items: AnalyticsPurchaseItem[] = [];
18
+ for (const item of recap.items) {
19
+ if (!item.sku) return suppressPurchase("sku_missing");
20
+ if (!Number.isSafeInteger(item.quantity) || item.quantity <= 0) return suppressPurchase("quantity_invalid");
21
+ if (item.quantity > MAX_ITEMS) return suppressPurchase("item_limit_exceeded");
22
+ if (!isNonNegativeSafeInteger(item.catalogTotalGross) || !isNonNegativeSafeInteger(item.effectiveNet)
23
+ || !isNonNegativeSafeInteger(item.vatRateBps) || item.vatRateBps > 10_000) return suppressPurchase("line_money_invalid");
24
+ const catalogNet = netFromGross(item.catalogTotalGross, item.vatRateBps);
25
+ const netDiscount = catalogNet - item.effectiveNet;
26
+ if (netDiscount < 0) return suppressPurchase("net_discount_invalid");
27
+ const projected = allocateLineItems({ sku: item.sku, variantCode: item.variantCode,
28
+ quantity: item.quantity, effectiveNet: item.effectiveNet, netDiscount });
29
+ if (items.length + projected.length > MAX_ITEMS) return suppressPurchase("item_limit_exceeded");
30
+ items.push(...projected);
31
+ effectiveNet += item.effectiveNet;
32
+ }
33
+ const tax = recap.totals.tax.amountMinor;
34
+ const total = recap.totals.total.amountMinor;
35
+ const effectiveShippingNet = total - tax - effectiveNet;
36
+ if (!isNonNegativeSafeInteger(effectiveShippingNet)) return suppressPurchase("shipping_net_invalid");
37
+ return { transaction_id: recap.orderId, currency: recap.totals.total.currency, value: effectiveNet / 100,
38
+ shipping: effectiveShippingNet / 100, tax: tax / 100,
39
+ checkout_mode: recap.checkoutKind === "one_time" ? "one_time" : "subscription", items };
40
+ }
41
+ function allocateLineItems(input: { sku: string; variantCode: string | null; quantity: number;
42
+ effectiveNet: number; netDiscount: number }): AnalyticsPurchaseItem[] {
43
+ const priceBase = Math.floor(input.effectiveNet / input.quantity);
44
+ const priceRemainder = input.effectiveNet % input.quantity;
45
+ const discountBase = Math.floor(input.netDiscount / input.quantity);
46
+ const discountRemainder = input.netDiscount % input.quantity;
47
+ const grouped = new Map<string, { priceMinor: number; discountMinor: number; quantity: number }>();
48
+ for (let position = 0; position < input.quantity; position += 1) {
49
+ const priceMinor = priceBase + (position < priceRemainder ? 1 : 0);
50
+ const discountMinor = discountBase + (position < discountRemainder ? 1 : 0);
51
+ const key = `${priceMinor}:${discountMinor}`;
52
+ const current = grouped.get(key);
53
+ if (current) current.quantity += 1;
54
+ else grouped.set(key, { priceMinor, discountMinor, quantity: 1 });
55
+ }
56
+ return [...grouped.values()].map(group => ({ item_id: input.sku,
57
+ ...(input.variantCode ? { item_variant: input.variantCode } : {}), price: group.priceMinor / 100,
58
+ discount: group.discountMinor / 100, quantity: group.quantity }));
59
+ }
60
+ function netFromGross(grossMinor: number, vatRateBps: number): number {
61
+ return Math.round(grossMinor * 10_000 / (10_000 + vatRateBps));
62
+ }
63
+ function isNonNegativeSafeInteger(value: number): boolean { return Number.isSafeInteger(value) && value >= 0; }
@@ -0,0 +1,55 @@
1
+ import type { AnalyticsRoutePolicyInput } from "../contracts/analytics.js";
2
+ export type { AnalyticsRoutePolicyInput } from "../contracts/analytics.js";
3
+ const CAMPAIGN_KEYS = ["utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term"] as const;
4
+ const CAMPAIGN_VALUE = /^[A-Za-z0-9_-]{1,100}$/;
5
+ const NO_ADDITIONAL_PATHS: ReadonlySet<string> = new Set();
6
+
7
+ export function normalizeAnalyticsPathname(pathname: string): string {
8
+ const withLeadingSlash = pathname.startsWith("/") ? pathname : `/${pathname}`;
9
+ const withoutTrailingSlash = withLeadingSlash.length > 1
10
+ ? withLeadingSlash.replace(/\/+$/, "") : withLeadingSlash;
11
+ return withoutTrailingSlash.toLowerCase();
12
+ }
13
+
14
+ /** Route manifests and sensitive paths are explicit host policy. */
15
+ export function createAnalyticsRoutePolicy(input: AnalyticsRoutePolicyInput) {
16
+ const manifestPaths = new Set(input.publicPaths.map(normalizeAnalyticsPathname));
17
+ const redirectPaths = new Set(input.redirectSources.filter(source => !source.includes(":"))
18
+ .map(normalizeAnalyticsPathname));
19
+ function isSensitiveAnalyticsPath(pathname: string): boolean {
20
+ const capabilityPath = pathname.toLowerCase();
21
+ return input.privatePathPrefixes.some(prefix => pathname === prefix || pathname.startsWith(`${prefix}/`))
22
+ || input.privateAuthPaths.includes(pathname)
23
+ || input.capabilityPathPrefixes.some(prefix => capabilityPath === prefix || capabilityPath.startsWith(`${prefix}/`));
24
+ }
25
+ function isOptionalAnalyticsRouteEligible(pathname: string): boolean {
26
+ return !isSensitiveAnalyticsPath(pathname);
27
+ }
28
+ function isDeploymentAnalyticsRouteEligible(pathname: string,
29
+ additionalPublicPaths: ReadonlySet<string> = NO_ADDITIONAL_PATHS): boolean {
30
+ const path = normalizeAnalyticsPathname(pathname);
31
+ if (isSensitiveAnalyticsPath(path)) return false;
32
+ return manifestPaths.has(path) || redirectPaths.has(path) || additionalPublicPaths.has(path);
33
+ }
34
+ function sanitizeDeploymentAnalyticsUrl(rawUrl: string, currentOrigin: string,
35
+ additionalPublicPaths?: ReadonlySet<string>): string | null {
36
+ let base: URL;
37
+ let url: URL;
38
+ try { base = new URL(currentOrigin); url = new URL(rawUrl, base); } catch { return null; }
39
+ if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password
40
+ || url.origin !== base.origin) return null;
41
+ const pathname = normalizeAnalyticsPathname(url.pathname);
42
+ if (!isDeploymentAnalyticsRouteEligible(pathname, additionalPublicPaths)) return null;
43
+ const campaign = new URLSearchParams();
44
+ for (const key of CAMPAIGN_KEYS) {
45
+ const values = url.searchParams.getAll(key);
46
+ if (values.length === 1 && CAMPAIGN_VALUE.test(values[0]!)) campaign.set(key, values[0]!);
47
+ }
48
+ url.pathname = pathname;
49
+ url.search = campaign.toString();
50
+ url.hash = "";
51
+ return url.href;
52
+ }
53
+ return { isSensitiveAnalyticsPath, isOptionalAnalyticsRouteEligible,
54
+ isDeploymentAnalyticsRouteEligible, sanitizeDeploymentAnalyticsUrl };
55
+ }
@@ -0,0 +1,36 @@
1
+ import type { MarketingHistoricalConsentCommand, MarketingHistoricalConsentState, MarketingHistoricalConsentResult } from "./contracts/analytics.js";
2
+ export type { MarketingHistoricalConsentCommand, MarketingHistoricalConsentState, MarketingHistoricalConsentResult } from "./contracts/analytics.js";
3
+ import { z } from "./validation.js";
4
+
5
+ const historicalTimestamp = z.string().max(80).datetime({ offset: true });
6
+ const HISTORICAL_MIN_AGE_MS = 5 * 60 * 1000;
7
+
8
+ /** Transport chronology after authorization; never changes the local consent grant. */
9
+ export function selectMarketingHistoricalConsentTime(input: {
10
+ command: MarketingHistoricalConsentCommand; state: MarketingHistoricalConsentState; now: Date;
11
+ }): MarketingHistoricalConsentResult {
12
+ const { command, state, now } = input;
13
+ if (!Number.isFinite(now.getTime())) return { kind: "hold", reason: "consent_time_invalid" };
14
+ let selected = command.consentedAt;
15
+ if (state.consent === "SUBSCRIBED") {
16
+ const native = historicalTimestamp.safeParse(state.nativeConsentAt);
17
+ if (!native.success || !Number.isFinite(Date.parse(native.data)) || Date.parse(native.data) >= now.getTime()) {
18
+ return { kind: "hold", reason: "provider_state_unknown" };
19
+ }
20
+ const overriding = state.suppressions.some(item => item.reason === "UNSUBSCRIBE"
21
+ && (item.listId === null || item.listId === command.listId));
22
+ if (!overriding) selected = native.data;
23
+ else if (Date.parse(command.consentedAt ?? "") > Date.parse(native.data)) {
24
+ return { kind: "hold", reason: "provider_state_unknown" };
25
+ }
26
+ }
27
+ const parsed = historicalTimestamp.safeParse(selected);
28
+ if (!parsed.success || !Number.isFinite(Date.parse(parsed.data)) || Date.parse(parsed.data) >= now.getTime()) {
29
+ return { kind: "hold", reason: "consent_time_invalid" };
30
+ }
31
+ // Parsing truncates fractional milliseconds; round only the age boundary up.
32
+ const discarded = /\.\d{3}(\d+)(?:Z|[+-]\d{2}:\d{2})$/.exec(parsed.data)?.[1];
33
+ const matureAt = Date.parse(parsed.data) + (discarded && /[1-9]/.test(discarded) ? 1 : 0) + HISTORICAL_MIN_AGE_MS;
34
+ return now.getTime() < matureAt ? { kind: "defer", retryAt: new Date(matureAt).toISOString() }
35
+ : { kind: "ready", providerConsentedAt: parsed.data };
36
+ }
@@ -0,0 +1,22 @@
1
+ import type { ContactContent, LegalDocumentContent } from "./contracts/contactLegal.js";
2
+
3
+ export function contactContent(facts: ContactContent) {
4
+ return {
5
+ supportEmail: facts.supportEmail,
6
+ companyLines: [
7
+ facts.organization.legalName,
8
+ facts.addressLine,
9
+ facts.registrationIdentifiers.map(({ label, value }) => `${label} ${value}`).join(" · "),
10
+ ...(facts.registrationAuthority !== undefined ? [facts.registrationAuthority] : []),
11
+ ],
12
+ };
13
+ }
14
+
15
+ export function legalDocumentContent(content: LegalDocumentContent) {
16
+ return {
17
+ title: content.title,
18
+ intro: content.intro,
19
+ sections: content.sections.map(({ title, body }) => ({ title, body })),
20
+ ...(content.lastUpdated !== undefined ? { lastUpdated: content.lastUpdated } : {}),
21
+ };
22
+ }
@@ -0,0 +1,50 @@
1
+ export type AcquisitionOpaqueReference = string;
2
+ export type AcquisitionAddressReference = { source: string; reference: string; revision: string; provenance: string };
3
+ export type AcquisitionCaseState = "submitted" | "approved" | "active" | "rejected" | "withdrawn";
4
+ export type AcquisitionCaseProjection = {
5
+ contractVersion: "tester_application_v1";
6
+ caseRef: string;
7
+ contactRef: string;
8
+ sourceKind: "tester_application";
9
+ consent: { consentVersion: string; policyVersion: string; recordedAt: string; locale: "en" | "pl"; sourcePath: "/tester-application" };
10
+ addressReference: AcquisitionAddressReference | null;
11
+ state: AcquisitionCaseState;
12
+ version: number;
13
+ createdAt: string;
14
+ updatedAt: string;
15
+ };
16
+ export type AcquisitionCaseListRequest = { view: "acquisition_case_v1"; cursor?: string | undefined; limit: number };
17
+ export type AcquisitionCaseListResponse = { contractVersion: "tester_application_v1"; cases: AcquisitionCaseProjection[]; nextCursor: string | null };
18
+ export type AcquisitionCaseActiveCountResponse = { activeTesterCount: number };
19
+ export type AcquisitionIdempotencyKey = string;
20
+ export type AcquisitionSurveySubmit = { view: "acquisition_survey_v1"; caseReference: string; surveyType: "producer" | "consumer"; responseData: Record<string, unknown> };
21
+ export type AcquisitionSurveyEvidence = {
22
+ evidenceReference: string;
23
+ caseReference: string;
24
+ contactReference: string;
25
+ surveyType: "producer" | "consumer";
26
+ responseDigest: string;
27
+ answerCount: number;
28
+ recordedAt: string;
29
+ };
30
+ export type AcquisitionSurveySubmitResponse = { contractVersion: "acquisition_survey_v1"; evidence: AcquisitionSurveyEvidence; replayed: boolean };
31
+ export type AcquisitionSurveyListQuery = { view: "acquisition_survey_v1"; surveyType?: "producer" | "consumer" | undefined; limit: number };
32
+ export type AcquisitionSurveyListResponse = { contractVersion: "acquisition_survey_v1"; rows: AcquisitionSurveyEvidence[]; totalCount: number };
33
+ export type AcquisitionNewsletterConsentEvent = {
34
+ contractVersion: "acquisition_newsletter_consent_v1";
35
+ caseReference: string;
36
+ contactReference: string;
37
+ eventReference: string;
38
+ eventType: "subscribe" | "unsubscribe" | "suppress" | "complaint" | "update";
39
+ purpose: "marketing_launch_offer" | "marketing_newsletter";
40
+ explicitOptInEvidence: boolean;
41
+ occurredAt: string;
42
+ };
43
+ export type AcquisitionNewsletterConsentResponse = {
44
+ contractVersion: "acquisition_newsletter_consent_v1";
45
+ eventReference: string;
46
+ outcome: "applied" | "ignored" | "stale";
47
+ consentState: "granted" | "suppressed" | null;
48
+ auditReference: string;
49
+ replayed: boolean;
50
+ };