@openlup/platform 0.28.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +369 -156
- package/README.md +220 -4
- package/dist/composition/index.d.ts +1 -0
- package/dist/composition/index.js +1 -0
- package/dist/composition/marketingContribution.d.ts +21 -0
- package/dist/composition/marketingContribution.js +29 -0
- package/dist/composition/nodeHost.d.ts +1 -1
- package/dist/composition/nodeHost.js +3 -2
- package/dist/lifecycle/appUpgrade.d.ts +52 -0
- package/dist/lifecycle/appUpgrade.js +180 -0
- package/dist/lifecycle/index.d.ts +2 -0
- package/dist/lifecycle/index.js +2 -0
- package/dist/lifecycle/postgresInstallQualification.d.ts +51 -0
- package/dist/lifecycle/postgresInstallQualification.js +74 -0
- package/dist/marketing/acquisitionCaseSchemas.d.ts +103 -0
- package/dist/marketing/acquisitionCaseSchemas.js +53 -0
- package/dist/marketing/acquisitionEvidenceSchemas.d.ts +105 -0
- package/dist/marketing/acquisitionEvidenceSchemas.js +55 -0
- package/dist/marketing/analytics/dataLayer.d.ts +18 -0
- package/dist/marketing/analytics/dataLayer.js +111 -0
- package/dist/marketing/analytics/noop.d.ts +9 -0
- package/dist/marketing/analytics/noop.js +8 -0
- package/dist/marketing/analytics/purchaseFacts.d.ts +4 -0
- package/dist/marketing/analytics/purchaseFacts.js +69 -0
- package/dist/marketing/analytics/routePolicy.d.ts +10 -0
- package/dist/marketing/analytics/routePolicy.js +59 -0
- package/dist/marketing/consentEvidence.d.ts +8 -0
- package/dist/marketing/consentEvidence.js +32 -0
- package/dist/marketing/contactLegal.d.ts +14 -0
- package/dist/marketing/contactLegal.js +19 -0
- package/dist/marketing/contracts/acquisition.d.ts +88 -0
- package/dist/marketing/contracts/acquisition.js +1 -0
- package/dist/marketing/contracts/acquisitionPorts.d.ts +49 -0
- package/dist/marketing/contracts/acquisitionPorts.js +1 -0
- package/dist/marketing/contracts/analytics.d.ts +144 -0
- package/dist/marketing/contracts/analytics.js +1 -0
- package/dist/marketing/contracts/contactLegal.d.ts +53 -0
- package/dist/marketing/contracts/contactLegal.js +1 -0
- package/dist/marketing/contracts/index.d.ts +6 -0
- package/dist/marketing/contracts/index.js +1 -0
- package/dist/marketing/contracts/prelaunch.d.ts +39 -0
- package/dist/marketing/contracts/prelaunch.js +1 -0
- package/dist/marketing/index.d.ts +16 -0
- package/dist/marketing/index.js +14 -0
- package/dist/marketing/prelaunchAcquisitionSchemas.d.ts +116 -0
- package/dist/marketing/prelaunchAcquisitionSchemas.js +51 -0
- package/dist/marketing/seo/jsonLd.d.ts +157 -0
- package/dist/marketing/seo/jsonLd.js +127 -0
- package/dist/marketing/seo/metadata.d.ts +41 -0
- package/dist/marketing/seo/metadata.js +36 -0
- package/dist/marketing/seo/serializeJsonLd.d.ts +1 -0
- package/dist/marketing/seo/serializeJsonLd.js +6 -0
- package/dist/marketing/seo/urls.d.ts +14 -0
- package/dist/marketing/seo/urls.js +23 -0
- package/dist/marketing/server/acquisitionCaseDirect.d.ts +4 -0
- package/dist/marketing/server/acquisitionCaseDirect.js +88 -0
- package/dist/marketing/server/acquisitionCaseHandlers.d.ts +28 -0
- package/dist/marketing/server/acquisitionCaseHandlers.js +40 -0
- package/dist/marketing/server/acquisitionCasePorts.d.ts +42 -0
- package/dist/marketing/server/acquisitionCasePorts.js +1 -0
- package/dist/marketing/server/acquisitionEvidenceDirect.d.ts +5 -0
- package/dist/marketing/server/acquisitionEvidenceDirect.js +100 -0
- package/dist/marketing/server/analyticsContext.d.ts +3 -0
- package/dist/marketing/server/analyticsContext.js +65 -0
- package/dist/marketing/server/analyticsContextPorts.d.ts +31 -0
- package/dist/marketing/server/analyticsContextPorts.js +1 -0
- package/dist/marketing/server/bindings.d.ts +14 -0
- package/dist/marketing/server/bindings.js +18 -0
- package/dist/marketing/server/directPorts.d.ts +26 -0
- package/dist/marketing/server/directPorts.js +1 -0
- package/dist/marketing/server/index.d.ts +14 -0
- package/dist/marketing/server/index.js +11 -0
- package/dist/marketing/server/postgres/acquisitionCaseRoutines.d.ts +10 -0
- package/dist/marketing/server/postgres/acquisitionCaseRoutines.js +204 -0
- package/dist/marketing/server/postgres/acquisitionEvidenceRoutines.d.ts +10 -0
- package/dist/marketing/server/postgres/acquisitionEvidenceRoutines.js +107 -0
- package/dist/marketing/server/postgres/index.d.ts +3 -0
- package/dist/marketing/server/postgres/index.js +3 -0
- package/dist/marketing/server/prelaunchAcquisitionDirect.d.ts +4 -0
- package/dist/marketing/server/prelaunchAcquisitionDirect.js +122 -0
- package/dist/marketing/server/privacyContributor.d.ts +66 -0
- package/dist/marketing/server/privacyContributor.js +87 -0
- package/dist/marketing/server/research/adminSurveyResponsesHandler.d.ts +9 -0
- package/dist/marketing/server/research/adminSurveyResponsesHandler.js +53 -0
- package/dist/marketing/server/research/managedSurveyResponseSubmit.d.ts +39 -0
- package/dist/marketing/server/research/managedSurveyResponseSubmit.js +102 -0
- package/dist/marketing/server/research/surveyResponseSubmitHandler.d.ts +8 -0
- package/dist/marketing/server/research/surveyResponseSubmitHandler.js +41 -0
- package/dist/marketing/validation.d.ts +2 -0
- package/dist/marketing/validation.js +3 -0
- package/dist/migrations/postgresRunner.d.ts +9 -0
- package/dist/migrations/postgresRunner.js +17 -10
- package/dist/operations/adminAlertsSchemas.d.ts +153 -0
- package/dist/operations/adminAlertsSchemas.js +69 -0
- package/dist/operations/adminAlertsView.d.ts +73 -0
- package/dist/operations/adminAlertsView.js +184 -0
- package/dist/operations/alertPagingPolicy.d.ts +15 -0
- package/dist/operations/alertPagingPolicy.js +43 -0
- package/dist/operations/alertThrottlePolicy.d.ts +3 -0
- package/dist/operations/alertThrottlePolicy.js +18 -0
- package/dist/operations/clients.d.ts +17 -0
- package/dist/operations/clients.js +32 -0
- package/dist/operations/contracts/admin.d.ts +93 -0
- package/dist/operations/contracts/admin.js +1 -0
- package/dist/operations/contracts/adminAlertsContracts.d.ts +40 -0
- package/dist/operations/contracts/adminAlertsContracts.js +1 -0
- package/dist/operations/contracts/alertHumanContext.d.ts +7 -0
- package/dist/operations/contracts/alertHumanContext.js +1 -0
- package/dist/operations/contracts/index.d.ts +6 -0
- package/dist/operations/contracts/index.js +6 -0
- package/dist/operations/contracts/observabilityContracts.d.ts +41 -0
- package/dist/operations/contracts/observabilityContracts.js +1 -0
- package/dist/operations/contracts/observabilityPorts.d.ts +18 -0
- package/dist/operations/contracts/observabilityPorts.js +1 -0
- package/dist/operations/contracts/ports.d.ts +19 -0
- package/dist/operations/contracts/ports.js +1 -0
- package/dist/operations/errors.d.ts +6 -0
- package/dist/operations/errors.js +6 -0
- package/dist/operations/index.d.ts +9 -0
- package/dist/operations/index.js +9 -0
- package/dist/operations/schemas.d.ts +138 -0
- package/dist/operations/schemas.js +104 -0
- package/dist/operations/server/adminAlertsHandlers.d.ts +16 -0
- package/dist/operations/server/adminAlertsHandlers.js +35 -0
- package/dist/operations/server/adminPipelineHandlers.d.ts +12 -0
- package/dist/operations/server/adminPipelineHandlers.js +48 -0
- package/dist/operations/server/adminSettingsHandlers.d.ts +14 -0
- package/dist/operations/server/adminSettingsHandlers.js +82 -0
- package/dist/operations/server/controlPlaneHandler.d.ts +7 -0
- package/dist/operations/server/controlPlaneHandler.js +54 -0
- package/dist/operations/server/controlPlaneStore.d.ts +12 -0
- package/dist/operations/server/controlPlaneStore.js +138 -0
- package/dist/operations/server/controlPlaneWatchdog.d.ts +7 -0
- package/dist/operations/server/controlPlaneWatchdog.js +20 -0
- package/dist/operations/server/index.d.ts +12 -0
- package/dist/operations/server/index.js +12 -0
- package/dist/operations/server/migrations.d.ts +14 -0
- package/dist/operations/server/migrations.js +7 -0
- package/dist/operations/server/platformWatchdogDecision.d.ts +34 -0
- package/dist/operations/server/platformWatchdogDecision.js +71 -0
- package/dist/operations/server/platformWatchdogDeliveryPolicy.d.ts +10 -0
- package/dist/operations/server/platformWatchdogDeliveryPolicy.js +53 -0
- package/dist/operations/server/platformWatchdogQueue.d.ts +11 -0
- package/dist/operations/server/platformWatchdogQueue.js +94 -0
- package/dist/operations/server/platformWatchdogService.d.ts +47 -0
- package/dist/operations/server/platformWatchdogService.js +105 -0
- package/dist/operations/validation.d.ts +2 -0
- package/dist/operations/validation.js +3 -0
- package/dist/portability/filesystemObjects.d.ts +12 -0
- package/dist/portability/filesystemObjects.js +191 -0
- package/dist/portability/index.d.ts +5 -0
- package/dist/portability/index.js +4 -0
- package/dist/portability/objects.d.ts +63 -0
- package/dist/portability/objects.js +138 -0
- package/dist/portability/postgresRows.d.ts +64 -0
- package/dist/portability/postgresRows.js +182 -0
- package/dist/scheduler/advisoryLock.js +20 -2
- package/dist/scheduler/nodeScheduler.d.ts +2 -2
- package/dist/scheduler/nodeScheduler.js +35 -6
- package/package.json +47 -2
- package/release-gates.json +143 -9
- package/sql/20260814010000_platform_control_plane.sql +286 -0
- package/src/composition/index.ts +1 -0
- package/src/composition/marketingContribution.ts +37 -0
- package/src/composition/nodeHost.ts +4 -3
- package/src/lifecycle/appUpgrade.ts +186 -0
- package/src/lifecycle/index.ts +19 -0
- package/src/lifecycle/postgresInstallQualification.ts +125 -0
- package/src/marketing/acquisitionCaseSchemas.ts +75 -0
- package/src/marketing/acquisitionEvidenceSchemas.ts +72 -0
- package/src/marketing/analytics/dataLayer.ts +100 -0
- package/src/marketing/analytics/noop.ts +14 -0
- package/src/marketing/analytics/purchaseFacts.ts +63 -0
- package/src/marketing/analytics/routePolicy.ts +55 -0
- package/src/marketing/consentEvidence.ts +36 -0
- package/src/marketing/contactLegal.ts +22 -0
- package/src/marketing/contracts/acquisition.ts +50 -0
- package/src/marketing/contracts/acquisitionPorts.ts +48 -0
- package/src/marketing/contracts/analytics.ts +88 -0
- package/src/marketing/contracts/contactLegal.ts +41 -0
- package/src/marketing/contracts/index.ts +17 -0
- package/src/marketing/contracts/prelaunch.ts +15 -0
- package/src/marketing/index.ts +16 -0
- package/src/marketing/prelaunchAcquisitionSchemas.ts +64 -0
- package/src/marketing/seo/jsonLd.ts +153 -0
- package/src/marketing/seo/metadata.ts +62 -0
- package/src/marketing/seo/serializeJsonLd.ts +6 -0
- package/src/marketing/seo/urls.ts +34 -0
- package/src/marketing/server/acquisitionCaseDirect.ts +67 -0
- package/src/marketing/server/acquisitionCaseHandlers.ts +57 -0
- package/src/marketing/server/acquisitionCasePorts.ts +42 -0
- package/src/marketing/server/acquisitionEvidenceDirect.ts +76 -0
- package/src/marketing/server/analyticsContext.ts +55 -0
- package/src/marketing/server/analyticsContextPorts.ts +28 -0
- package/src/marketing/server/bindings.ts +27 -0
- package/src/marketing/server/directPorts.ts +27 -0
- package/src/marketing/server/index.ts +14 -0
- package/src/marketing/server/postgres/acquisitionCaseRoutines.ts +218 -0
- package/src/marketing/server/postgres/acquisitionEvidenceRoutines.ts +103 -0
- package/src/marketing/server/postgres/index.ts +3 -0
- package/src/marketing/server/prelaunchAcquisitionDirect.ts +121 -0
- package/src/marketing/server/privacyContributor.ts +123 -0
- package/src/marketing/server/research/adminSurveyResponsesHandler.ts +78 -0
- package/src/marketing/server/research/managedSurveyResponseSubmit.ts +158 -0
- package/src/marketing/server/research/surveyResponseSubmitHandler.ts +57 -0
- package/src/marketing/validation.ts +3 -0
- package/src/migrations/postgresRunner.ts +24 -9
- package/src/operations/adminAlertsSchemas.ts +78 -0
- package/src/operations/adminAlertsView.ts +238 -0
- package/src/operations/alertPagingPolicy.ts +50 -0
- package/src/operations/alertThrottlePolicy.ts +22 -0
- package/src/operations/clients.ts +44 -0
- package/src/operations/contracts/admin.ts +24 -0
- package/src/operations/contracts/adminAlertsContracts.ts +13 -0
- package/src/operations/contracts/alertHumanContext.ts +17 -0
- package/src/operations/contracts/index.ts +6 -0
- package/src/operations/contracts/observabilityContracts.ts +40 -0
- package/src/operations/contracts/observabilityPorts.ts +30 -0
- package/src/operations/contracts/ports.ts +22 -0
- package/src/operations/errors.ts +6 -0
- package/src/operations/index.ts +9 -0
- package/src/operations/schemas.ts +122 -0
- package/src/operations/server/adminAlertsHandlers.ts +61 -0
- package/src/operations/server/adminPipelineHandlers.ts +73 -0
- package/src/operations/server/adminSettingsHandlers.ts +114 -0
- package/src/operations/server/controlPlaneHandler.ts +56 -0
- package/src/operations/server/controlPlaneStore.ts +150 -0
- package/src/operations/server/controlPlaneWatchdog.ts +28 -0
- package/src/operations/server/index.ts +12 -0
- package/src/operations/server/migrations.ts +7 -0
- package/src/operations/server/platformWatchdogDecision.ts +93 -0
- package/src/operations/server/platformWatchdogDeliveryPolicy.ts +92 -0
- package/src/operations/server/platformWatchdogQueue.ts +96 -0
- package/src/operations/server/platformWatchdogService.ts +209 -0
- package/src/operations/validation.ts +3 -0
- package/src/portability/filesystemObjects.ts +138 -0
- package/src/portability/index.ts +12 -0
- package/src/portability/objects.ts +160 -0
- package/src/portability/postgresRows.ts +169 -0
- package/src/scheduler/advisoryLock.ts +20 -5
- package/src/scheduler/nodeScheduler.ts +28 -8
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Exact UTF-8 scaffold files; every unlisted path remains application-owned. */
|
|
6
|
+
export interface AppVersionSnapshot {
|
|
7
|
+
version: string;
|
|
8
|
+
schema: { min: number; max: number };
|
|
9
|
+
files: Readonly<Record<string, string>>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AppUpgradeInput {
|
|
13
|
+
/** Absolute canonical path (as returned by realpath) of an existing app directory. */
|
|
14
|
+
root: string;
|
|
15
|
+
from: AppVersionSnapshot;
|
|
16
|
+
to: AppVersionSnapshot;
|
|
17
|
+
/** Observed by the caller; planning and applying execute no database work. */
|
|
18
|
+
schemaVersion: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type AppUpgradeAction = "create" | "replace" | "remove" | "unchanged" | "preserved" | "already-applied";
|
|
22
|
+
export interface AppUpgradeConflict { path: string; reason: string }
|
|
23
|
+
export interface AppUpgradePlan {
|
|
24
|
+
fromVersion: string;
|
|
25
|
+
toVersion: string;
|
|
26
|
+
schemaVersion: number;
|
|
27
|
+
files: { path: string; action: AppUpgradeAction }[];
|
|
28
|
+
conflicts: AppUpgradeConflict[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class AppUpgradeConflictError extends Error {
|
|
32
|
+
constructor(public readonly conflicts: readonly AppUpgradeConflict[]) {
|
|
33
|
+
super(`Application upgrade refused: ${conflicts.map(({ path, reason }) => `${path || "schema"}: ${reason}`).join("; ")}`);
|
|
34
|
+
this.name = "AppUpgradeConflictError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Read-only comparison of explicit predecessor and candidate snapshots. */
|
|
39
|
+
export async function planAppUpgrade(input: AppUpgradeInput): Promise<AppUpgradePlan> {
|
|
40
|
+
return (await inspectUpgrade(input)).plan;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The caller must hold an exclusive maintenance window over the application tree.
|
|
45
|
+
* This is not a lock against concurrent editors or hostile filesystem mutation.
|
|
46
|
+
* All conflicts are checked before any writes. A later I/O failure can leave a
|
|
47
|
+
* partial upgrade: rerun the same snapshots to recognize exact completed bytes.
|
|
48
|
+
* Each replacement is atomic on a local filesystem; the complete upgrade is not.
|
|
49
|
+
* An abrupt process termination can leave a uniquely named temporary file; it is
|
|
50
|
+
* never treated as a managed app file or resumed operation.
|
|
51
|
+
* No SQL, dependency install, version marker, deployment or user hook is executed.
|
|
52
|
+
*/
|
|
53
|
+
export async function applyAppUpgrade(input: AppUpgradeInput): Promise<{ plan: AppUpgradePlan; applied: string[] }> {
|
|
54
|
+
const { root, plan, target } = await inspectUpgrade(input);
|
|
55
|
+
if (plan.conflicts.length) throw new AppUpgradeConflictError(plan.conflicts);
|
|
56
|
+
const applied: string[] = [];
|
|
57
|
+
for (const file of plan.files) {
|
|
58
|
+
const destination = join(root, file.path);
|
|
59
|
+
if (file.action === "remove") {
|
|
60
|
+
await fs.unlink(destination);
|
|
61
|
+
} else if (file.action === "create" || file.action === "replace") {
|
|
62
|
+
await fs.mkdir(dirname(destination), { recursive: true });
|
|
63
|
+
const temporary = join(dirname(destination), `.openlup-${randomUUID()}.tmp`);
|
|
64
|
+
// Rename replaces this directory entry rather than modifying a possible hardlink.
|
|
65
|
+
const mode = file.action === "replace" ? (await fs.stat(destination)).mode & 0o777 : 0o600;
|
|
66
|
+
let handle;
|
|
67
|
+
try {
|
|
68
|
+
handle = await fs.open(temporary, "wx", mode);
|
|
69
|
+
if (file.action === "replace") await handle.chmod(mode);
|
|
70
|
+
await handle.writeFile(target.get(file.path)!, "utf8");
|
|
71
|
+
await handle.close();
|
|
72
|
+
await fs.rename(temporary, destination);
|
|
73
|
+
} finally {
|
|
74
|
+
if (handle) {
|
|
75
|
+
await handle.close();
|
|
76
|
+
await fs.rm(temporary, { force: true });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} else continue;
|
|
80
|
+
applied.push(file.path);
|
|
81
|
+
}
|
|
82
|
+
return { plan, applied };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function inspectUpgrade(input: AppUpgradeInput): Promise<{
|
|
86
|
+
root: string; plan: AppUpgradePlan; target: Map<string, string>;
|
|
87
|
+
}> {
|
|
88
|
+
const before = snapshotFiles(input.from), target = snapshotFiles(input.to);
|
|
89
|
+
const paths = [...new Set([...before.keys(), ...target.keys()])].sort();
|
|
90
|
+
const aliases = new Set<string>();
|
|
91
|
+
for (const path of paths) {
|
|
92
|
+
const alias = path.normalize("NFC").toLowerCase().toUpperCase();
|
|
93
|
+
if (aliases.has(alias)) throw new Error(`Application upgrade path alias: ${path}`);
|
|
94
|
+
aliases.add(alias);
|
|
95
|
+
}
|
|
96
|
+
const directorySpellings = new Map<string, string>();
|
|
97
|
+
for (const path of paths) {
|
|
98
|
+
const components = path.split("/");
|
|
99
|
+
while (components.length > 1) {
|
|
100
|
+
components.pop();
|
|
101
|
+
const directory = components.join("/"), alias = directory.normalize("NFC").toLowerCase().toUpperCase();
|
|
102
|
+
if (aliases.has(alias)) throw new Error(`Application upgrade file-parent collision: ${path}`);
|
|
103
|
+
// Parents may not exist yet; both snapshots must still agree on their spelling.
|
|
104
|
+
const spelling = directorySpellings.get(alias);
|
|
105
|
+
if (spelling !== undefined && spelling !== directory) throw new Error(`Application upgrade path alias: ${path}`);
|
|
106
|
+
directorySpellings.set(alias, directory);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!Number.isSafeInteger(input.schemaVersion) || input.schemaVersion < 0) throw new Error("Application schema version must be a nonnegative safe integer");
|
|
110
|
+
const plan: AppUpgradePlan = {
|
|
111
|
+
fromVersion: input.from.version, toVersion: input.to.version,
|
|
112
|
+
schemaVersion: input.schemaVersion, files: [], conflicts: [],
|
|
113
|
+
};
|
|
114
|
+
for (const snapshot of [input.from, input.to]) {
|
|
115
|
+
if (input.schemaVersion < snapshot.schema.min || input.schemaVersion > snapshot.schema.max) {
|
|
116
|
+
plan.conflicts.push({ path: "", reason: `schema ${input.schemaVersion} is outside ${snapshot.version} range ${snapshot.schema.min}..${snapshot.schema.max}` });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const requestedRoot = resolve(input.root), rootStat = await fs.lstat(requestedRoot);
|
|
120
|
+
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) throw new Error("Application upgrade root must be an ordinary directory");
|
|
121
|
+
const root = await fs.realpath(requestedRoot);
|
|
122
|
+
if (input.root !== root) throw new Error("Application upgrade root must be an absolute canonical path");
|
|
123
|
+
for (const path of paths) {
|
|
124
|
+
const observed = await readFile(root, path);
|
|
125
|
+
if (typeof observed === "string") {
|
|
126
|
+
plan.conflicts.push({ path, reason: observed });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const oldBytes = before.has(path) ? Buffer.from(before.get(path)!, "utf8") : null;
|
|
130
|
+
const newBytes = target.has(path) ? Buffer.from(target.get(path)!, "utf8") : null;
|
|
131
|
+
const equal = (bytes: Buffer | null) => observed === null ? bytes === null : bytes !== null && observed.equals(bytes);
|
|
132
|
+
let action: AppUpgradeAction;
|
|
133
|
+
if (oldBytes !== null && newBytes !== null && oldBytes.equals(newBytes)) {
|
|
134
|
+
// An upstream-unchanged file carries no write authority over local customization.
|
|
135
|
+
action = equal(oldBytes) ? "unchanged" : "preserved";
|
|
136
|
+
} else if (equal(newBytes)) action = "already-applied";
|
|
137
|
+
else if (equal(oldBytes)) action = newBytes === null ? "remove" : oldBytes === null ? "create" : "replace";
|
|
138
|
+
else {
|
|
139
|
+
plan.conflicts.push({ path, reason: "current bytes match neither predecessor nor candidate" });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
plan.files.push({ path, action });
|
|
143
|
+
}
|
|
144
|
+
return { root, plan, target };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function snapshotFiles(snapshot: AppVersionSnapshot): Map<string, string> {
|
|
148
|
+
if (!snapshot || typeof snapshot.version !== "string" || !snapshot.version.trim()) throw new Error("Application snapshot needs an explicit version");
|
|
149
|
+
const range = snapshot.schema;
|
|
150
|
+
if (!range || !Number.isSafeInteger(range.min) || !Number.isSafeInteger(range.max) || range.min < 0 || range.max < range.min) throw new Error("Application snapshot needs an ordered nonnegative schema range");
|
|
151
|
+
if (!snapshot.files || typeof snapshot.files !== "object" || Array.isArray(snapshot.files)) throw new Error("Application snapshot files must be a record");
|
|
152
|
+
const result = new Map<string, string>();
|
|
153
|
+
for (const [path, content] of Object.entries(snapshot.files)) {
|
|
154
|
+
const segments = path.split("/");
|
|
155
|
+
if (!path || Buffer.from(path, "utf8").toString("utf8") !== path || /[\\:]/u.test(path) || [...path].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127) || segments.some(segment => !segment || segment === "." || segment === ".." || /[. ]$/u.test(segment)) || segments.some(segment => [".git", "node_modules"].includes(segment.toLowerCase()))) {
|
|
156
|
+
throw new Error(`Unsafe application upgrade path: ${path}`);
|
|
157
|
+
}
|
|
158
|
+
if (typeof content !== "string" || Buffer.from(content, "utf8").toString("utf8") !== content) throw new Error(`Application upgrade content must be valid UTF-8: ${path}`);
|
|
159
|
+
result.set(path, content);
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A string is a structural refusal; null means an absent path. Never follow links. */
|
|
165
|
+
async function readFile(root: string, path: string): Promise<Buffer | null | string> {
|
|
166
|
+
const segments = path.split("/");
|
|
167
|
+
let current = root;
|
|
168
|
+
for (const [index, segment] of segments.entries()) {
|
|
169
|
+
const parent = current;
|
|
170
|
+
current = join(current, segment);
|
|
171
|
+
let stat;
|
|
172
|
+
try { stat = await fs.lstat(current); }
|
|
173
|
+
catch (error) {
|
|
174
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
// A filesystem alias is not ownership of the differently spelled entry.
|
|
178
|
+
// Check only after lstat: ENOENT on a case-sensitive store stays absence.
|
|
179
|
+
if (!(await fs.readdir(parent)).includes(segment)) return "managed path spelling differs from directory entry";
|
|
180
|
+
if (stat.isSymbolicLink()) return "symlink in managed path";
|
|
181
|
+
const last = index === segments.length - 1;
|
|
182
|
+
if (last) return stat.isFile() ? fs.readFile(current) : "managed path is not a regular file";
|
|
183
|
+
if (!stat.isDirectory()) return "managed parent is not a directory";
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export {
|
|
2
|
+
planAppUpgrade,
|
|
3
|
+
applyAppUpgrade,
|
|
4
|
+
AppUpgradeConflictError,
|
|
5
|
+
type AppVersionSnapshot,
|
|
6
|
+
type AppUpgradeInput,
|
|
7
|
+
type AppUpgradeAction,
|
|
8
|
+
type AppUpgradeConflict,
|
|
9
|
+
type AppUpgradePlan,
|
|
10
|
+
} from "./appUpgrade.js";
|
|
11
|
+
export {
|
|
12
|
+
inspectLegacyPostgresInstallRoles,
|
|
13
|
+
type PostgresInstallQuery,
|
|
14
|
+
type LegacyPostgresInstallRequest,
|
|
15
|
+
type PostgresInstallRoleObservation,
|
|
16
|
+
type PostgresInstallMembershipObservation,
|
|
17
|
+
type LegacyPostgresInstallBlocker,
|
|
18
|
+
type LegacyPostgresInstallRoleReport,
|
|
19
|
+
} from "./postgresInstallQualification.js";
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/** Read-only prerequisites of the immutable legacy fresh-install bootstrap, not install readiness. */
|
|
2
|
+
export interface PostgresInstallQuery {
|
|
3
|
+
query(sql: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[] }>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface LegacyPostgresInstallRequest {
|
|
7
|
+
/** Expected actual login: both current_user and session_user must match. */
|
|
8
|
+
migrationLogin: string;
|
|
9
|
+
/** Legacy bootstrap requires this to be the same login as the migrator. */
|
|
10
|
+
runtimeLogin: string;
|
|
11
|
+
roleMode: "installer-managed" | "operator-provisioned";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PostgresInstallRoleObservation {
|
|
15
|
+
name: string;
|
|
16
|
+
login: boolean;
|
|
17
|
+
superuser: boolean;
|
|
18
|
+
inherit: boolean;
|
|
19
|
+
createRole: boolean;
|
|
20
|
+
createDatabase: boolean;
|
|
21
|
+
replication: boolean;
|
|
22
|
+
bypassRls: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PostgresInstallMembershipObservation {
|
|
26
|
+
role: string;
|
|
27
|
+
member: string;
|
|
28
|
+
grantor: string;
|
|
29
|
+
admin: boolean;
|
|
30
|
+
inherit: boolean;
|
|
31
|
+
set: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type LegacyPostgresInstallBlocker =
|
|
35
|
+
| "migration_login_mismatch"
|
|
36
|
+
| "legacy_bootstrap_requires_superuser"
|
|
37
|
+
| "legacy_bootstrap_requires_shared_runtime_login"
|
|
38
|
+
| "runtime_login_missing_or_not_login"
|
|
39
|
+
| "legacy_bootstrap_requires_installer_managed_roles"
|
|
40
|
+
| "legacy_bootstrap_role_already_exists";
|
|
41
|
+
|
|
42
|
+
export interface LegacyPostgresInstallRoleReport {
|
|
43
|
+
scope: "legacy-fresh-bootstrap-role-prerequisites";
|
|
44
|
+
database: string;
|
|
45
|
+
currentUser: string;
|
|
46
|
+
sessionUser: string;
|
|
47
|
+
roles: PostgresInstallRoleObservation[];
|
|
48
|
+
memberships: PostgresInstallMembershipObservation[];
|
|
49
|
+
blockers: { code: LegacyPostgresInstallBlocker; role?: string }[];
|
|
50
|
+
rolePrerequisitesMet: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const BOOTSTRAP_ROLES = ["anon", "authenticated", "platform_acquisition_owner", "platform_acquisition_runtime"];
|
|
54
|
+
// One statement observes identities and the relevant role graph in the same statement snapshot.
|
|
55
|
+
// No SQL, identifiers, credentials or catalog expectations come from the caller.
|
|
56
|
+
// Bind functions, cast types and operators independently of the caller's search_path.
|
|
57
|
+
const OBSERVE = `SELECT pg_catalog.current_database() AS database, current_user::pg_catalog.text AS "currentUser",
|
|
58
|
+
session_user::pg_catalog.text AS "sessionUser",
|
|
59
|
+
(SELECT COALESCE(pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object('name', rolname, 'login', rolcanlogin,
|
|
60
|
+
'superuser', rolsuper, 'inherit', rolinherit, 'createRole', rolcreaterole,
|
|
61
|
+
'createDatabase', rolcreatedb, 'replication', rolreplication, 'bypassRls', rolbypassrls)
|
|
62
|
+
ORDER BY rolname), '[]'::pg_catalog.jsonb) FROM pg_catalog.pg_roles
|
|
63
|
+
WHERE rolname OPERATOR(pg_catalog.=) ANY($1::pg_catalog.text[]) OR rolname OPERATOR(pg_catalog.=) current_user) AS roles,
|
|
64
|
+
(SELECT COALESCE(pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object('role', granted.rolname, 'member', member.rolname,
|
|
65
|
+
'grantor', grantor.rolname, 'admin', m.admin_option, 'inherit', m.inherit_option, 'set', m.set_option)
|
|
66
|
+
ORDER BY granted.rolname, member.rolname, grantor.rolname), '[]'::pg_catalog.jsonb)
|
|
67
|
+
FROM pg_catalog.pg_auth_members m
|
|
68
|
+
JOIN pg_catalog.pg_roles granted ON granted.oid OPERATOR(pg_catalog.=) m.roleid
|
|
69
|
+
JOIN pg_catalog.pg_roles member ON member.oid OPERATOR(pg_catalog.=) m.member
|
|
70
|
+
JOIN pg_catalog.pg_roles grantor ON grantor.oid OPERATOR(pg_catalog.=) m.grantor
|
|
71
|
+
WHERE granted.rolname OPERATOR(pg_catalog.=) ANY($1::pg_catalog.text[])
|
|
72
|
+
OR member.rolname OPERATOR(pg_catalog.=) ANY($1::pg_catalog.text[])) AS memberships`;
|
|
73
|
+
|
|
74
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
75
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function validRows<T>(value: unknown, texts: string[], flags: string[]): value is T[] {
|
|
79
|
+
return Array.isArray(value) && value.every(row => isRecord(row)
|
|
80
|
+
&& texts.every(key => typeof row[key] === "string" && row[key].length > 0)
|
|
81
|
+
&& flags.every(key => typeof row[key] === "boolean"));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Observes PostgreSQL 16+ legacy role prerequisites without changing the database.
|
|
86
|
+
* A true result says nothing about schema inventory, object ACLs, pooling, import safety or hosting.
|
|
87
|
+
* Query/driver errors are redacted because they can contain connection credentials.
|
|
88
|
+
*/
|
|
89
|
+
export async function inspectLegacyPostgresInstallRoles(
|
|
90
|
+
db: PostgresInstallQuery,
|
|
91
|
+
request: LegacyPostgresInstallRequest,
|
|
92
|
+
): Promise<LegacyPostgresInstallRoleReport> {
|
|
93
|
+
if (![request.migrationLogin, request.runtimeLogin].every(name => typeof name === "string" && name.trim().length > 0)
|
|
94
|
+
|| !["installer-managed", "operator-provisioned"].includes(request.roleMode)) {
|
|
95
|
+
throw new Error("postgres_install_request_invalid");
|
|
96
|
+
}
|
|
97
|
+
let rows: Record<string, unknown>[];
|
|
98
|
+
try {
|
|
99
|
+
({ rows } = await db.query(OBSERVE, [[...new Set([...BOOTSTRAP_ROLES, request.migrationLogin, request.runtimeLogin])]]));
|
|
100
|
+
} catch {
|
|
101
|
+
throw new Error("postgres_install_observation_failed");
|
|
102
|
+
}
|
|
103
|
+
const row = rows[0];
|
|
104
|
+
if (rows.length !== 1 || !row
|
|
105
|
+
|| ![row.database, row.currentUser, row.sessionUser].every(value => typeof value === "string" && value.length > 0)
|
|
106
|
+
|| !validRows<PostgresInstallRoleObservation>(row.roles, ["name"], ["login", "superuser", "inherit", "createRole", "createDatabase", "replication", "bypassRls"])
|
|
107
|
+
|| !validRows<PostgresInstallMembershipObservation>(row.memberships, ["role", "member", "grantor"], ["admin", "inherit", "set"])) {
|
|
108
|
+
throw new Error("postgres_install_observation_invalid");
|
|
109
|
+
}
|
|
110
|
+
const roles = row.roles;
|
|
111
|
+
const blockers: LegacyPostgresInstallRoleReport["blockers"] = [];
|
|
112
|
+
if (row.currentUser !== request.migrationLogin || row.sessionUser !== request.migrationLogin) blockers.push({ code: "migration_login_mismatch" });
|
|
113
|
+
if (!roles.some(role => role.name === row.currentUser && role.superuser)) blockers.push({ code: "legacy_bootstrap_requires_superuser" });
|
|
114
|
+
if (row.currentUser !== request.runtimeLogin) blockers.push({ code: "legacy_bootstrap_requires_shared_runtime_login" });
|
|
115
|
+
if (!roles.some(role => role.name === request.runtimeLogin && role.login)) blockers.push({ code: "runtime_login_missing_or_not_login" });
|
|
116
|
+
if (request.roleMode !== "installer-managed") blockers.push({ code: "legacy_bootstrap_requires_installer_managed_roles" });
|
|
117
|
+
for (const name of BOOTSTRAP_ROLES) {
|
|
118
|
+
if (roles.some(role => role.name === name)) blockers.push({ code: "legacy_bootstrap_role_already_exists", role: name });
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
scope: "legacy-fresh-bootstrap-role-prerequisites",
|
|
122
|
+
database: row.database as string, currentUser: row.currentUser as string, sessionUser: row.sessionUser as string,
|
|
123
|
+
roles, memberships: row.memberships, blockers, rolePrerequisitesMet: blockers.length === 0,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -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; }
|