@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,209 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AlertDecision,
|
|
3
|
+
AlertSeverity,
|
|
4
|
+
WatchdogSnapshot,
|
|
5
|
+
} from "../contracts/index.js";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_PAGING_MIN_SEVERITY,
|
|
8
|
+
shouldPage,
|
|
9
|
+
} from "../index.js";
|
|
10
|
+
import type {
|
|
11
|
+
AlertLedgerPort,
|
|
12
|
+
AlertSinkPort,
|
|
13
|
+
ObservabilityEvidencePort,
|
|
14
|
+
} from "../contracts/index.js";
|
|
15
|
+
import {
|
|
16
|
+
isActionabilitySuppressed,
|
|
17
|
+
isDeliveryBackoff,
|
|
18
|
+
isPagingSuppressed,
|
|
19
|
+
isSeverityEscalation,
|
|
20
|
+
} from "./platformWatchdogDeliveryPolicy.js";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
emptyWatchdogDecisionCounts,
|
|
24
|
+
isCriticalDecision,
|
|
25
|
+
processWatchdogDecision,
|
|
26
|
+
} from "./platformWatchdogDecision.js";
|
|
27
|
+
import { createSerialWatchdogSink, runWatchdogQueue } from "./platformWatchdogQueue.js";
|
|
28
|
+
|
|
29
|
+
export type PlatformWatchdogResult<Channel extends string = string> = {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
health: "healthy" | "firing";
|
|
32
|
+
checkOnly: boolean;
|
|
33
|
+
checkedAt: string;
|
|
34
|
+
alertCount: number;
|
|
35
|
+
firingCount: number;
|
|
36
|
+
/** P0/P1 incidents not covered by an active snooze and still requiring ownership. */
|
|
37
|
+
actionableCriticalCount: number;
|
|
38
|
+
/** P0/P1 incidents covered by an active snooze; acknowledged alerts stay actionable. */
|
|
39
|
+
suppressedCriticalCount: number;
|
|
40
|
+
/** Alerts that meet the current paging threshold and are not currently page-suppressed. */
|
|
41
|
+
actionablePageableCount: number;
|
|
42
|
+
maxSeverity: string | null;
|
|
43
|
+
decisions: AlertDecision<Channel>[];
|
|
44
|
+
notified: number;
|
|
45
|
+
notificationFailures: number;
|
|
46
|
+
/** Failed deliveries held until their persisted bounded retry time. */
|
|
47
|
+
deliveryBackoffCount: number;
|
|
48
|
+
throttledNotifications: number;
|
|
49
|
+
skippedNotifications: number;
|
|
50
|
+
belowThreshold: number;
|
|
51
|
+
suppressed: number;
|
|
52
|
+
muted: number;
|
|
53
|
+
resolved: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export async function runPlatformWatchdog<Snapshot extends WatchdogSnapshot, CatalogEntry, Channel extends string = string>({
|
|
57
|
+
evidencePort, evaluate,
|
|
58
|
+
ledgerPort,
|
|
59
|
+
sinkPort,
|
|
60
|
+
catalog,
|
|
61
|
+
now = new Date(),
|
|
62
|
+
checkOnly,
|
|
63
|
+
notificationThrottleSeconds,
|
|
64
|
+
pagingMinSeverity = DEFAULT_PAGING_MIN_SEVERITY,
|
|
65
|
+
mutedDedupePrefixes = [],
|
|
66
|
+
normalizeDecision,
|
|
67
|
+
}: {
|
|
68
|
+
evidencePort: ObservabilityEvidencePort<Snapshot>;
|
|
69
|
+
evaluate: (snapshot: Snapshot, catalog: readonly CatalogEntry[], now: Date) => AlertDecision<Channel>[];
|
|
70
|
+
ledgerPort: AlertLedgerPort<Channel>;
|
|
71
|
+
sinkPort: AlertSinkPort<Channel>;
|
|
72
|
+
catalog: readonly CatalogEntry[];
|
|
73
|
+
now?: Date;
|
|
74
|
+
checkOnly: boolean;
|
|
75
|
+
/** Flat override; when omitted, the throttle is scaled per alert severity. */
|
|
76
|
+
notificationThrottleSeconds?: number;
|
|
77
|
+
pagingMinSeverity?: AlertSeverity;
|
|
78
|
+
/**
|
|
79
|
+
* Dedupe-key prefixes fully muted for this environment (e.g. `job_missed` on a
|
|
80
|
+
* staging host with no real crons). Muted signals are not recorded, not paged,
|
|
81
|
+
* and existing open rows for them auto-resolve — keeping the ledger clean.
|
|
82
|
+
*/
|
|
83
|
+
mutedDedupePrefixes?: readonly string[];
|
|
84
|
+
normalizeDecision?: (decision: AlertDecision<Channel>) => AlertDecision<Channel>;
|
|
85
|
+
}): Promise<PlatformWatchdogResult<Channel>> {
|
|
86
|
+
const snapshot = await evidencePort.collectSnapshot(now);
|
|
87
|
+
const allDecisions = evaluate(snapshot, catalog, now);
|
|
88
|
+
const decisions = allDecisions
|
|
89
|
+
.filter((decision) => !isMuted(decision.dedupeKey, mutedDedupePrefixes))
|
|
90
|
+
.map((decision) => normalizeDecision ? normalizeDecision(decision) : decision);
|
|
91
|
+
const muted = allDecisions.length - decisions.length;
|
|
92
|
+
const firingDecisions = decisions.filter((decision) => decision.paging !== "never");
|
|
93
|
+
const firingCount = firingDecisions.length;
|
|
94
|
+
const maxSeverity = maxSeverityOf(firingDecisions);
|
|
95
|
+
const openAlerts = await ledgerPort.listOpenAlerts();
|
|
96
|
+
|
|
97
|
+
if (checkOnly) {
|
|
98
|
+
const alertsByDedupeKey = new Map(openAlerts.map((alert) => [alert.dedupeKey, alert]));
|
|
99
|
+
let actionableCriticalCount = 0;
|
|
100
|
+
let suppressedCriticalCount = 0;
|
|
101
|
+
let actionablePageableCount = 0;
|
|
102
|
+
let deliveryBackoffCount = 0;
|
|
103
|
+
let suppressed = 0;
|
|
104
|
+
for (const decision of decisions) {
|
|
105
|
+
const alert = alertsByDedupeKey.get(decision.dedupeKey);
|
|
106
|
+
// The mutating path clears acknowledgement, snooze and delivery cadence on
|
|
107
|
+
// escalation. Mirror that transition here so a read-only probe cannot
|
|
108
|
+
// report a stale suppression that the next real tick will remove.
|
|
109
|
+
const suppressionSurvives = alert ? !isSeverityEscalation(decision, alert) : false;
|
|
110
|
+
const actionableSuppressed = alert && suppressionSurvives
|
|
111
|
+
? isActionabilitySuppressed(alert, now)
|
|
112
|
+
: false;
|
|
113
|
+
const pagingSuppressed = alert && suppressionSurvives
|
|
114
|
+
? isPagingSuppressed(alert, now)
|
|
115
|
+
: false;
|
|
116
|
+
const critical = isCriticalDecision(decision);
|
|
117
|
+
const pageable = decision.paging !== "never" && shouldPage(decision.severity, pagingMinSeverity);
|
|
118
|
+
if (critical) {
|
|
119
|
+
if (actionableSuppressed) suppressedCriticalCount += 1;
|
|
120
|
+
else actionableCriticalCount += 1;
|
|
121
|
+
}
|
|
122
|
+
if (pageable && !pagingSuppressed) actionablePageableCount += 1;
|
|
123
|
+
if (alert && pageable && !pagingSuppressed && isDeliveryBackoff(alert, now)) {
|
|
124
|
+
deliveryBackoffCount += 1;
|
|
125
|
+
}
|
|
126
|
+
if (pagingSuppressed) suppressed += 1;
|
|
127
|
+
}
|
|
128
|
+
return result({ checkOnly, checkedAt: snapshot.checkedAt, decisions, firingCount, actionableCriticalCount, suppressedCriticalCount, actionablePageableCount, notified: 0, notificationFailures: 0, deliveryBackoffCount, throttledNotifications: 0, skippedNotifications: 0, belowThreshold: 0, suppressed, muted, resolved: 0, maxSeverity });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const firingKeys = new Set(decisions.map((decision) => decision.dedupeKey));
|
|
132
|
+
const counts = emptyWatchdogDecisionCounts();
|
|
133
|
+
const serialSink = createSerialWatchdogSink(sinkPort);
|
|
134
|
+
await runWatchdogQueue(decisions, async (index) => {
|
|
135
|
+
const contribution = await processWatchdogDecision(decisions[index], {
|
|
136
|
+
ledgerPort, sinkPort: serialSink, now, pagingMinSeverity, notificationThrottleSeconds,
|
|
137
|
+
});
|
|
138
|
+
for (const key of Object.keys(contribution) as (keyof typeof counts)[]) {
|
|
139
|
+
counts[key] += contribution[key];
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// A rejected decision queue drains before throwing, so it never enters resolution.
|
|
144
|
+
const staleAlerts = openAlerts.filter((alert) => !firingKeys.has(alert.dedupeKey));
|
|
145
|
+
await runWatchdogQueue(staleAlerts, async (index) => {
|
|
146
|
+
await ledgerPort.resolveAlert(staleAlerts[index].dedupeKey, now);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return result({ checkOnly, checkedAt: snapshot.checkedAt, decisions, firingCount, ...counts, muted, resolved: staleAlerts.length, maxSeverity });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isMuted(dedupeKey: string, mutedPrefixes: readonly string[]): boolean {
|
|
153
|
+
return mutedPrefixes.some((prefix) => prefix.length > 0 && dedupeKey.startsWith(prefix));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function severityRank(severity: AlertSeverity): number {
|
|
157
|
+
return { p0: 0, p1: 1, p2: 2, p3: 3 }[severity];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function maxSeverityOf(decisions: readonly AlertDecision[]): AlertSeverity | null {
|
|
161
|
+
return decisions.reduce<AlertSeverity | null>((current, decision) =>
|
|
162
|
+
current == null || severityRank(decision.severity) < severityRank(current) ? decision.severity : current
|
|
163
|
+
, null);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function result<Channel extends string>({
|
|
167
|
+
checkOnly,
|
|
168
|
+
checkedAt,
|
|
169
|
+
decisions,
|
|
170
|
+
firingCount,
|
|
171
|
+
actionableCriticalCount,
|
|
172
|
+
suppressedCriticalCount,
|
|
173
|
+
actionablePageableCount,
|
|
174
|
+
notified,
|
|
175
|
+
notificationFailures,
|
|
176
|
+
deliveryBackoffCount,
|
|
177
|
+
throttledNotifications,
|
|
178
|
+
skippedNotifications,
|
|
179
|
+
belowThreshold,
|
|
180
|
+
suppressed,
|
|
181
|
+
muted,
|
|
182
|
+
resolved,
|
|
183
|
+
maxSeverity,
|
|
184
|
+
}: Omit<PlatformWatchdogResult<Channel>, "ok" | "health" | "alertCount">): PlatformWatchdogResult<Channel> {
|
|
185
|
+
return {
|
|
186
|
+
// Evaluation success is separate from the incident state. HTTP binding may
|
|
187
|
+
// still fail the request for evidence, notification transport, or config.
|
|
188
|
+
ok: true,
|
|
189
|
+
health: firingCount === 0 ? "healthy" : "firing",
|
|
190
|
+
checkOnly,
|
|
191
|
+
checkedAt,
|
|
192
|
+
alertCount: decisions.length,
|
|
193
|
+
firingCount,
|
|
194
|
+
actionableCriticalCount,
|
|
195
|
+
suppressedCriticalCount,
|
|
196
|
+
actionablePageableCount,
|
|
197
|
+
maxSeverity,
|
|
198
|
+
decisions,
|
|
199
|
+
notified,
|
|
200
|
+
notificationFailures,
|
|
201
|
+
deliveryBackoffCount,
|
|
202
|
+
throttledNotifications,
|
|
203
|
+
skippedNotifications,
|
|
204
|
+
belowThreshold,
|
|
205
|
+
suppressed,
|
|
206
|
+
muted,
|
|
207
|
+
resolved,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, open, realpath, rename, rm, type FileHandle } from "node:fs/promises";
|
|
4
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
encodeTransferObject, normalizeObjectTransferError, ObjectTransferError, validateTransferObject,
|
|
7
|
+
type TransferObject, type TransferObjectRead, type TransferObjectStore,
|
|
8
|
+
} from "./objects.js";
|
|
9
|
+
|
|
10
|
+
// Covers bounded metadata JSON in UTF-8 plus JSON-escaped bounded identifiers.
|
|
11
|
+
const maximumDescriptorBytes = 262144;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Private local transfer store, exclusively controlled by its owner process/account.
|
|
15
|
+
* Metadata and bytes publish together through one directory rename. Concurrent use through
|
|
16
|
+
* this adapter is supported; hostile same-UID filesystem mutation is outside that guarantee.
|
|
17
|
+
*/
|
|
18
|
+
export async function createFilesystemTransferObjects(options: {
|
|
19
|
+
root: string;
|
|
20
|
+
maximumObjectBytes: number;
|
|
21
|
+
/** Host label for this filesystem owner's private-read policy; other ACLs refuse. */
|
|
22
|
+
access: string;
|
|
23
|
+
}): Promise<TransferObjectStore> {
|
|
24
|
+
if (!isAbsolute(options.root) || !Number.isSafeInteger(options.maximumObjectBytes) || options.maximumObjectBytes < 0
|
|
25
|
+
|| typeof options.access !== "string" || !options.access.trim()) {
|
|
26
|
+
throw new ObjectTransferError("invalid_input");
|
|
27
|
+
}
|
|
28
|
+
const root = resolve(options.root), maximum = options.maximumObjectBytes, access = options.access;
|
|
29
|
+
try {
|
|
30
|
+
if (await realpath(dirname(root)) !== dirname(root)) throw new ObjectTransferError("denied");
|
|
31
|
+
await mkdir(root, { mode: 0o700 }).catch(error => { if (errno(error) !== "EEXIST") throw error; });
|
|
32
|
+
await privateDirectory(root, true);
|
|
33
|
+
} catch (error) { throw filesystemFailure(error); }
|
|
34
|
+
const location = (key: string) => join(root, createHash("sha256").update(JSON.stringify(key)).digest("hex"));
|
|
35
|
+
return {
|
|
36
|
+
async read(key, expectedVersion): Promise<TransferObjectRead> {
|
|
37
|
+
let body: FileHandle | undefined;
|
|
38
|
+
let published = false;
|
|
39
|
+
try {
|
|
40
|
+
await privateDirectory(root, true);
|
|
41
|
+
const directory = location(key);
|
|
42
|
+
await privateDirectory(directory);
|
|
43
|
+
published = true;
|
|
44
|
+
const metadata = await openPrivateFile(join(directory, "metadata.json"));
|
|
45
|
+
let object: TransferObject;
|
|
46
|
+
try {
|
|
47
|
+
if ((await metadata.stat()).size > maximumDescriptorBytes) throw new ObjectTransferError("integrity_conflict");
|
|
48
|
+
try { object = JSON.parse(await metadata.readFile("utf8")) as TransferObject; }
|
|
49
|
+
catch { throw new ObjectTransferError("integrity_conflict"); }
|
|
50
|
+
} finally { await metadata.close(); }
|
|
51
|
+
validateTransferObject(object);
|
|
52
|
+
if (object.access !== access) throw new ObjectTransferError("denied");
|
|
53
|
+
if (object.key !== key || (expectedVersion !== undefined && object.version !== expectedVersion)) throw new ObjectTransferError("version_conflict");
|
|
54
|
+
if (object.bytes > maximum) throw new ObjectTransferError("invalid_input");
|
|
55
|
+
body = await openPrivateFile(join(directory, "bytes"));
|
|
56
|
+
if ((await body.stat()).size !== object.bytes) throw new ObjectTransferError("integrity_conflict");
|
|
57
|
+
await body.close();
|
|
58
|
+
body = undefined;
|
|
59
|
+
// Open lazily: descriptor refusal must not leave an unconsumed stream holding a file.
|
|
60
|
+
return { object, body: (async function* () {
|
|
61
|
+
let opened: FileHandle | undefined;
|
|
62
|
+
try {
|
|
63
|
+
await privateDirectory(root, true);
|
|
64
|
+
await privateDirectory(directory);
|
|
65
|
+
opened = await openPrivateFile(join(directory, "bytes"));
|
|
66
|
+
if ((await opened.stat()).size !== object.bytes) throw new ObjectTransferError("integrity_conflict");
|
|
67
|
+
for await (const chunk of opened.createReadStream({ autoClose: false })) yield Buffer.from(chunk);
|
|
68
|
+
}
|
|
69
|
+
catch (error) { throw errno(error) === "ENOENT" ? new ObjectTransferError("integrity_conflict") : filesystemFailure(error); }
|
|
70
|
+
finally { await opened?.close(); }
|
|
71
|
+
})() };
|
|
72
|
+
} catch (error) {
|
|
73
|
+
await body?.close();
|
|
74
|
+
if (published && errno(error) === "ENOENT") throw new ObjectTransferError("integrity_conflict");
|
|
75
|
+
throw filesystemFailure(error);
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
async createIfAbsent(object, bytes) {
|
|
79
|
+
let temporary: string | undefined;
|
|
80
|
+
try {
|
|
81
|
+
const encoded = Buffer.from(encodeTransferObject(object));
|
|
82
|
+
if (encoded.byteLength > maximumDescriptorBytes) throw new ObjectTransferError("invalid_input");
|
|
83
|
+
if (object.access !== access) throw new ObjectTransferError("denied");
|
|
84
|
+
const key = object.key;
|
|
85
|
+
if (object.bytes > maximum || bytes.byteLength !== object.bytes
|
|
86
|
+
|| createHash("sha256").update(bytes).digest("hex") !== object.sha256) throw new ObjectTransferError("integrity_conflict");
|
|
87
|
+
await privateDirectory(root, true);
|
|
88
|
+
temporary = join(root, `.partial-${randomUUID()}`);
|
|
89
|
+
await mkdir(temporary, { mode: 0o700 });
|
|
90
|
+
await writePrivateFile(join(temporary, "metadata.json"), encoded);
|
|
91
|
+
await writePrivateFile(join(temporary, "bytes"), bytes);
|
|
92
|
+
const directoryHandle = await open(temporary, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
93
|
+
try { await directoryHandle.sync(); } finally { await directoryHandle.close(); }
|
|
94
|
+
try {
|
|
95
|
+
// Existing published objects are nonempty directories, so rename cannot replace them.
|
|
96
|
+
// Check also refuses an empty directory or symlink occupying a target key.
|
|
97
|
+
await lstat(location(key));
|
|
98
|
+
throw new ObjectTransferError("version_conflict");
|
|
99
|
+
} catch (error) { if (errno(error) !== "ENOENT") throw error; }
|
|
100
|
+
await rename(temporary, location(key));
|
|
101
|
+
temporary = undefined;
|
|
102
|
+
const rootHandle = await open(root, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
103
|
+
try { await rootHandle.sync(); } finally { await rootHandle.close(); }
|
|
104
|
+
} catch (error) { throw filesystemFailure(error); }
|
|
105
|
+
finally { if (temporary) await rm(temporary, { recursive: true, force: true }); }
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function privateDirectory(path: string, storeRoot = false): Promise<void> {
|
|
111
|
+
const stat = await lstat(path).catch(error => {
|
|
112
|
+
if (storeRoot && errno(error) === "ENOENT") throw new ObjectTransferError("unavailable");
|
|
113
|
+
throw error;
|
|
114
|
+
});
|
|
115
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o777) !== 0o700 || stat.uid !== process.getuid?.()) {
|
|
116
|
+
throw new ObjectTransferError("denied");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async function openPrivateFile(path: string): Promise<FileHandle> {
|
|
120
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
121
|
+
try {
|
|
122
|
+
const stat = await handle.stat();
|
|
123
|
+
if (!stat.isFile() || stat.nlink !== 1 || (stat.mode & 0o777) !== 0o600 || stat.uid !== process.getuid?.()) throw new ObjectTransferError("denied");
|
|
124
|
+
return handle;
|
|
125
|
+
} catch (error) { await handle.close(); throw error; }
|
|
126
|
+
}
|
|
127
|
+
async function writePrivateFile(path: string, bytes: Uint8Array): Promise<void> {
|
|
128
|
+
const handle = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
129
|
+
try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); }
|
|
130
|
+
}
|
|
131
|
+
function errno(error: unknown): string | undefined { return (error as NodeJS.ErrnoException | undefined)?.code; }
|
|
132
|
+
function filesystemFailure(error: unknown): ObjectTransferError {
|
|
133
|
+
if (error instanceof ObjectTransferError) return error;
|
|
134
|
+
if (errno(error) === "ENOENT") return new ObjectTransferError("absent");
|
|
135
|
+
if (["EACCES", "EPERM", "ELOOP"].includes(errno(error) ?? "")) return new ObjectTransferError("denied");
|
|
136
|
+
if (["EEXIST", "ENOTEMPTY"].includes(errno(error) ?? "")) return new ObjectTransferError("version_conflict");
|
|
137
|
+
return normalizeObjectTransferError(error);
|
|
138
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Server-only, opt-in data movement. No executor, route or handover is activated.
|
|
2
|
+
export { copyTransferObjects, ObjectTransferError } from "./objects.js";
|
|
3
|
+
export type {
|
|
4
|
+
ObjectTransferFailure, TransferObject, TransferObjectRead, TransferObjectReader,
|
|
5
|
+
TransferObjectStore, TransferObjectInventory,
|
|
6
|
+
} from "./objects.js";
|
|
7
|
+
export { createFilesystemTransferObjects } from "./filesystemObjects.js";
|
|
8
|
+
export { captureCompatiblePostgresRows, restoreCompatiblePostgresRows, PostgresTransferError } from "./postgresRows.js";
|
|
9
|
+
export type {
|
|
10
|
+
CompatiblePostgresTable, TransferPostgresQuery, CompatiblePostgresSnapshot,
|
|
11
|
+
CompatiblePostgresTarget, CompatiblePostgresPacket, PostgresTransferFailure,
|
|
12
|
+
} from "./postgresRows.js";
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export type ObjectTransferFailure = "absent" | "denied" | "unavailable" | "version_conflict" | "integrity_conflict" | "invalid_input";
|
|
4
|
+
|
|
5
|
+
/** Adapters distinguish missing data from permission and availability failures. */
|
|
6
|
+
export class ObjectTransferError extends Error {
|
|
7
|
+
constructor(readonly code: ObjectTransferFailure) {
|
|
8
|
+
super(`object_transfer_${code}`);
|
|
9
|
+
this.name = "ObjectTransferError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TransferObject {
|
|
14
|
+
key: string;
|
|
15
|
+
/** Logical immutable source version; never a claim about a target provider's native version ID. */
|
|
16
|
+
version: string;
|
|
17
|
+
bytes: number;
|
|
18
|
+
sha256: string;
|
|
19
|
+
metadata: Readonly<Record<string, string>>;
|
|
20
|
+
/** An explicit access classification, never inferred from a public URL. */
|
|
21
|
+
access: string;
|
|
22
|
+
lineage?: { sourceId: string; snapshotId: string; sourceVersion: string };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TransferObjectRead {
|
|
26
|
+
object: TransferObject;
|
|
27
|
+
body: AsyncIterable<Uint8Array>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TransferObjectReader {
|
|
31
|
+
/** Must read the requested version or refuse, never silently read a newer one. */
|
|
32
|
+
read(key: string, expectedVersion?: string): Promise<TransferObjectRead>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TransferObjectStore extends TransferObjectReader {
|
|
36
|
+
/** Atomic publication; an existing key must refuse with version_conflict. */
|
|
37
|
+
createIfAbsent(object: TransferObject, bytes: Uint8Array): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TransferObjectInventory {
|
|
41
|
+
sourceId: string;
|
|
42
|
+
/** Host prerequisite: complete inventory and stable versioned reads, or a held write fence. */
|
|
43
|
+
snapshot: { kind: "immutable" | "quiesced"; id: string };
|
|
44
|
+
objects: readonly TransferObject[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Canonical descriptor encoding also preserves metadata independent of property order. */
|
|
48
|
+
export function encodeTransferObject(object: TransferObject): string {
|
|
49
|
+
validateTransferObject(object);
|
|
50
|
+
return JSON.stringify({
|
|
51
|
+
key: object.key, version: object.version, bytes: object.bytes, sha256: object.sha256,
|
|
52
|
+
metadata: Object.fromEntries(Object.entries(object.metadata).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)),
|
|
53
|
+
access: object.access,
|
|
54
|
+
...(object.lineage ? { lineage: { sourceId: object.lineage.sourceId, snapshotId: object.lineage.snapshotId, sourceVersion: object.lineage.sourceVersion } } : {}),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function validateTransferObject(object: TransferObject): void {
|
|
59
|
+
if (!object || !text(object.key, 4096) || !text(object.version, 1024)
|
|
60
|
+
|| !Number.isSafeInteger(object.bytes) || object.bytes < 0 || !/^[a-f0-9]{64}$/.test(object.sha256)
|
|
61
|
+
|| !text(object.access, 1024) || !object.metadata || typeof object.metadata !== "object" || Array.isArray(object.metadata)
|
|
62
|
+
|| Object.entries(object.metadata).some(([key, value]) => !text(key, 1024) || typeof value !== "string" || value.length > 8192)
|
|
63
|
+
|| JSON.stringify(object.metadata).length > 65536
|
|
64
|
+
|| (object.lineage !== undefined && (!object.lineage || !text(object.lineage.sourceId, 1024)
|
|
65
|
+
|| !text(object.lineage.snapshotId, 1024) || !text(object.lineage.sourceVersion, 1024)))) {
|
|
66
|
+
throw new ObjectTransferError("invalid_input");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Bounded in-memory object copy; ceilings are explicit and checked before any destination mutation. */
|
|
71
|
+
export async function copyTransferObjects(input: {
|
|
72
|
+
inventory: TransferObjectInventory;
|
|
73
|
+
source: TransferObjectReader;
|
|
74
|
+
destination: TransferObjectStore;
|
|
75
|
+
accessMapping: Readonly<Record<string, string>>;
|
|
76
|
+
maximumObjectBytes: number;
|
|
77
|
+
maximumTotalBytes: number;
|
|
78
|
+
/** Called only after destination descriptor and bytes have been read back successfully. */
|
|
79
|
+
checkpoint?: (object: TransferObject) => Promise<void>;
|
|
80
|
+
}): Promise<{ copied: number; replayed: number; verifiedKeys: string[] }> {
|
|
81
|
+
const { inventory } = input;
|
|
82
|
+
if (!inventory || !text(inventory.sourceId, 1024) || !inventory.snapshot
|
|
83
|
+
|| !["immutable", "quiesced"].includes(inventory.snapshot.kind) || !text(inventory.snapshot.id, 1024)
|
|
84
|
+
|| !Array.isArray(inventory.objects) || !input.accessMapping
|
|
85
|
+
|| !ceiling(input.maximumObjectBytes) || !ceiling(input.maximumTotalBytes)) throw new ObjectTransferError("invalid_input");
|
|
86
|
+
// Snapshot inputs before the first await; a caller cannot change the plan during a copy.
|
|
87
|
+
const sourceId = inventory.sourceId, snapshotId = inventory.snapshot.id;
|
|
88
|
+
const keys = new Set<string>();
|
|
89
|
+
let total = 0;
|
|
90
|
+
const plan = inventory.objects.map(object => {
|
|
91
|
+
const source = JSON.parse(encodeTransferObject(object)) as TransferObject;
|
|
92
|
+
const access = Object.hasOwn(input.accessMapping, source.access) ? input.accessMapping[source.access] : undefined;
|
|
93
|
+
total += source.bytes;
|
|
94
|
+
if (keys.has(source.key) || !text(access, 1024) || source.bytes > input.maximumObjectBytes
|
|
95
|
+
|| !Number.isSafeInteger(total) || total > input.maximumTotalBytes) throw new ObjectTransferError("invalid_input");
|
|
96
|
+
keys.add(source.key);
|
|
97
|
+
const target: TransferObject = { ...source, access, lineage: { sourceId, snapshotId, sourceVersion: source.version } };
|
|
98
|
+
return { source, target };
|
|
99
|
+
});
|
|
100
|
+
const result = { copied: 0, replayed: 0, verifiedKeys: [] as string[] };
|
|
101
|
+
for (const { source, target } of plan) {
|
|
102
|
+
let existing: TransferObjectRead | undefined;
|
|
103
|
+
try { existing = await input.destination.read(target.key); }
|
|
104
|
+
catch (error) { if (!failure(error, "absent")) throw normalizeObjectTransferError(error); }
|
|
105
|
+
if (existing) {
|
|
106
|
+
await verifyRead(existing, target);
|
|
107
|
+
result.replayed++;
|
|
108
|
+
} else {
|
|
109
|
+
let read: TransferObjectRead;
|
|
110
|
+
try { read = await input.source.read(source.key, source.version); }
|
|
111
|
+
catch (error) { throw normalizeObjectTransferError(error); }
|
|
112
|
+
const bytes = await verifyRead(read, source);
|
|
113
|
+
let writeFailure: ObjectTransferError | undefined;
|
|
114
|
+
try { await input.destination.createIfAbsent(target, bytes); }
|
|
115
|
+
catch (error) {
|
|
116
|
+
// A competing writer or lost write acknowledgement still requires exact readback.
|
|
117
|
+
if (!failure(error, "version_conflict") && !failure(error, "unavailable")) throw normalizeObjectTransferError(error);
|
|
118
|
+
writeFailure = normalizeObjectTransferError(error);
|
|
119
|
+
}
|
|
120
|
+
try { await verifyRead(await input.destination.read(target.key), target); }
|
|
121
|
+
catch (error) { throw failure(error, "absent") && writeFailure ? writeFailure : normalizeObjectTransferError(error); }
|
|
122
|
+
if (writeFailure) result.replayed++;
|
|
123
|
+
else result.copied++;
|
|
124
|
+
}
|
|
125
|
+
await input.checkpoint?.(JSON.parse(encodeTransferObject(target)) as TransferObject);
|
|
126
|
+
result.verifiedKeys.push(target.key);
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Read exactly the declared bytes and hash; never trust a successful HEAD alone. */
|
|
132
|
+
export async function verifyRead(read: TransferObjectRead, expected: TransferObject): Promise<Uint8Array> {
|
|
133
|
+
if (encodeTransferObject(read.object) !== encodeTransferObject(expected)) throw new ObjectTransferError("version_conflict");
|
|
134
|
+
const chunks: Buffer[] = [];
|
|
135
|
+
const hash = createHash("sha256");
|
|
136
|
+
let bytes = 0;
|
|
137
|
+
try {
|
|
138
|
+
for await (const chunk of read.body) {
|
|
139
|
+
if (!(chunk instanceof Uint8Array)) throw new ObjectTransferError("integrity_conflict");
|
|
140
|
+
bytes += chunk.byteLength;
|
|
141
|
+
if (bytes > expected.bytes) throw new ObjectTransferError("integrity_conflict");
|
|
142
|
+
const copy = Buffer.from(chunk);
|
|
143
|
+
chunks.push(copy);
|
|
144
|
+
hash.update(copy);
|
|
145
|
+
}
|
|
146
|
+
} catch (error) { throw normalizeObjectTransferError(error); }
|
|
147
|
+
if (bytes !== expected.bytes || hash.digest("hex") !== expected.sha256) throw new ObjectTransferError("integrity_conflict");
|
|
148
|
+
return Buffer.concat(chunks, bytes);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function normalizeObjectTransferError(error: unknown): ObjectTransferError {
|
|
152
|
+
return error instanceof ObjectTransferError ? error : new ObjectTransferError("unavailable");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function text(value: unknown, maximum: number): value is string {
|
|
156
|
+
return typeof value === "string" && value.length > 0 && value.length <= maximum
|
|
157
|
+
&& !Array.from(value).some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127);
|
|
158
|
+
}
|
|
159
|
+
function ceiling(value: number): boolean { return Number.isSafeInteger(value) && value >= 0; }
|
|
160
|
+
function failure(error: unknown, code: ObjectTransferFailure): boolean { return error instanceof ObjectTransferError && error.code === code; }
|