@chipmobilesdk/rn-notification 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +306 -0
  3. package/package.json +73 -0
  4. package/src/channel/migrate.ts +211 -0
  5. package/src/channel/tone-map.ts +90 -0
  6. package/src/contract/channel.ts +89 -0
  7. package/src/contract/diagnostics.ts +102 -0
  8. package/src/contract/errors.ts +123 -0
  9. package/src/contract/index.ts +10 -0
  10. package/src/contract/interaction.ts +62 -0
  11. package/src/contract/notification.ts +118 -0
  12. package/src/contract/permission.ts +60 -0
  13. package/src/contract/reconcile.ts +88 -0
  14. package/src/contract/recurrence.ts +123 -0
  15. package/src/diagnostics/burst.ts +101 -0
  16. package/src/diagnostics/events.ts +72 -0
  17. package/src/diagnostics/redact.ts +72 -0
  18. package/src/diagnostics/snapshot.ts +64 -0
  19. package/src/engine/adapter.ts +79 -0
  20. package/src/engine/notify-kit-engine.ts +535 -0
  21. package/src/index.ts +173 -0
  22. package/src/initialize.ts +174 -0
  23. package/src/interaction/index.ts +146 -0
  24. package/src/maintenance/cycle.ts +147 -0
  25. package/src/maintenance/permission-refresh.ts +41 -0
  26. package/src/maintenance/reanchor.ts +95 -0
  27. package/src/maintenance/toggle.ts +60 -0
  28. package/src/maintenance/verify.ts +83 -0
  29. package/src/maintenance/window-refresh.ts +61 -0
  30. package/src/permission/index.ts +183 -0
  31. package/src/permission/state.ts +77 -0
  32. package/src/push/envelope.ts +165 -0
  33. package/src/push/fcm-provider.ts +146 -0
  34. package/src/push/gate.ts +190 -0
  35. package/src/push/index.ts +29 -0
  36. package/src/push/instructions.ts +109 -0
  37. package/src/push/provider-adapter.ts +54 -0
  38. package/src/push/signout.ts +65 -0
  39. package/src/reconcile/chunk.ts +53 -0
  40. package/src/reconcile/diff.ts +145 -0
  41. package/src/reconcile/duplicates.ts +73 -0
  42. package/src/reconcile/index.ts +175 -0
  43. package/src/reconcile/truncate.ts +56 -0
  44. package/src/recurrence/clock-guard.ts +71 -0
  45. package/src/recurrence/expand.ts +292 -0
  46. package/src/recurrence/index.ts +177 -0
  47. package/src/schedule/anchor.ts +267 -0
  48. package/src/schedule/index.ts +173 -0
  49. package/src/schedule/partition.ts +85 -0
  50. package/src/schedule/resolve-moment.ts +80 -0
  51. package/src/schedule/validate-content.ts +111 -0
  52. package/src/schedule/validate-identifier.ts +114 -0
  53. package/src/state/caps.ts +71 -0
  54. package/src/state/dedup-ledger.ts +39 -0
  55. package/src/state/interaction-buffer.ts +73 -0
  56. package/src/state/mmkv-store.ts +71 -0
  57. package/src/state/permission-history.ts +41 -0
  58. package/src/state/record.ts +246 -0
  59. package/src/state/reset.ts +41 -0
  60. package/src/state/series-ledger.ts +84 -0
  61. package/src/state/store.ts +40 -0
  62. package/src/testing/conformance/index.ts +194 -0
  63. package/src/testing/fake-engine.ts +284 -0
  64. package/src/testing/fake-provider.ts +129 -0
  65. package/src/testing/faults.ts +72 -0
  66. package/src/testing/index.ts +26 -0
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Pending-set verification.
3
+ *
4
+ * Engine boot restoration coverage varies by manufacturer, so after a restart
5
+ * the device may hold fewer notifications than the package expects. This step
6
+ * notices that.
7
+ *
8
+ * It **detects and reports only**. It never re-registers, cancels, or repairs
9
+ * anything — the package deliberately does not persist a desired set, so it
10
+ * cannot tell a restoration gap from an intentional consumer change. Guessing
11
+ * would mean either resurrecting notifications the user cancelled or deleting
12
+ * ones they still want. Repair is consumer-invoked reconciliation.
13
+ *
14
+ * Runs at order 20, after re-anchoring (order 10): re-anchoring rewrites firing
15
+ * instants, and verifying first would compare against instants that are about
16
+ * to change.
17
+ */
18
+
19
+ import type { MaintenanceStep, MaintenanceContext, StepOutcome } from './cycle';
20
+ import type { NotificationEngineAdapter } from '../engine/adapter';
21
+ import type { PendingDiscrepancy } from '../contract/reconcile';
22
+ import { DIAGNOSTIC_CODES } from '../contract/errors';
23
+
24
+ export const VERIFY_ORDER = 20;
25
+
26
+ export interface VerifyStepDependencies {
27
+ readonly engine: NotificationEngineAdapter;
28
+ /**
29
+ * Identifiers the package expects the device to hold — the union of series
30
+ * occurrences and the last reconciliation's result. Absent expectations mean
31
+ * nothing to verify, which is different from expecting nothing.
32
+ */
33
+ readonly expectedIds: () => ReadonlySet<string> | undefined;
34
+ readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
35
+ }
36
+
37
+ export function createVerifyStep(deps: VerifyStepDependencies): MaintenanceStep {
38
+ return {
39
+ name: 'verify',
40
+ order: VERIFY_ORDER,
41
+
42
+ async run(context: MaintenanceContext): Promise<StepOutcome> {
43
+ const expected = deps.expectedIds();
44
+
45
+ // No recorded expectation is not the same as expecting an empty device.
46
+ // Reporting every pending entry as "unrecognized" on a fresh install
47
+ // would be noise, not a finding.
48
+ if (expected === undefined) {
49
+ return { operationCount: 0 };
50
+ }
51
+
52
+ const pending = await deps.engine.getPending();
53
+ const actual = new Set(pending.map(entry => entry.id));
54
+
55
+ const missing = [...expected].filter(id => !actual.has(id)).sort();
56
+ const unrecognized = [...actual].filter(id => !expected.has(id)).sort();
57
+
58
+ if (missing.length === 0 && unrecognized.length === 0) {
59
+ return { operationCount: 0 };
60
+ }
61
+
62
+ const discrepancy: PendingDiscrepancy = {
63
+ missing,
64
+ unrecognized,
65
+ detectedAt: context.now,
66
+ };
67
+
68
+ deps.emit?.(
69
+ DIAGNOSTIC_CODES.PENDING_SET_DISCREPANCY,
70
+ `Pending set differs from expectation: ${missing.length} expected but absent, ${unrecognized.length} present but unrecognized. ` +
71
+ 'Reported only — run reconciliation to repair, since the package cannot distinguish a restoration gap from an intentional change.',
72
+ { missing: missing.length, unrecognized: unrecognized.length },
73
+ );
74
+
75
+ return {
76
+ operationCount: 0,
77
+ discrepancy,
78
+ // Verification repairs nothing, so it cancels nothing.
79
+ cancelledIds: [],
80
+ };
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The window-refresh maintenance step.
3
+ *
4
+ * Runs at order 15 — after re-anchoring (10) so it expands against corrected
5
+ * instants, before verification (20) so verification sees the refreshed set
6
+ * rather than reporting occurrences the refresh was about to add.
7
+ *
8
+ * This step is the reason automatic maintenance exists at all: only the package
9
+ * knows when a series' horizon has moved, and it can act on that without ever
10
+ * needing a desired set from the consumer.
11
+ */
12
+
13
+ import type { MaintenanceStep, MaintenanceContext, StepOutcome } from './cycle';
14
+ import type { RecurrenceRule } from '../contract/recurrence';
15
+ import type { Recurrence } from '../recurrence';
16
+
17
+ export const WINDOW_REFRESH_ORDER = 15;
18
+
19
+ export interface WindowRefreshDependencies {
20
+ readonly recurrence: Recurrence;
21
+ /**
22
+ * Rules the consumer has registered, supplied as a lookup rather than stored.
23
+ *
24
+ * The ledger records what the package expanded; the rule itself belongs to
25
+ * the consumer, because persisting a rule would mean persisting its content
26
+ * template — and FR-096a forbids the package holding notification content.
27
+ */
28
+ readonly registeredRules: () => readonly RecurrenceRule[];
29
+ readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
30
+ }
31
+
32
+ export function createWindowRefreshStep(deps: WindowRefreshDependencies): MaintenanceStep {
33
+ return {
34
+ name: 'window-refresh',
35
+ order: WINDOW_REFRESH_ORDER,
36
+
37
+ async run(_context: MaintenanceContext): Promise<StepOutcome> {
38
+ const rules = deps.registeredRules();
39
+ if (rules.length === 0) {
40
+ return { operationCount: 0, windowRefreshed: false };
41
+ }
42
+
43
+ let scheduled = 0;
44
+
45
+ for (const rule of rules) {
46
+ const result = await deps.recurrence.refreshSeries(rule);
47
+ scheduled += result.scheduled;
48
+ }
49
+
50
+ return {
51
+ operationCount: scheduled,
52
+ occurrencesScheduled: scheduled,
53
+ // A refresh that found nothing to add still ran; reporting it as
54
+ // refreshed distinguishes "nothing due" from "step never executed".
55
+ windowRefreshed: true,
56
+ // Refresh only ever adds occurrences within series the package owns.
57
+ cancelledIds: [],
58
+ };
59
+ },
60
+ };
61
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Permission coordination.
3
+ *
4
+ * Two rules shape everything here. Reading state never prompts, so the consumer
5
+ * decides when the user is asked — a prompt at the wrong moment is a permanent
6
+ * denial. And a refusal never fails the caller's business operation: the user's
7
+ * actual work completes, and the lost capability is reported separately.
8
+ */
9
+
10
+ import type {
11
+ PermissionKind,
12
+ PermissionReport,
13
+ PermissionState,
14
+ RestrictionReport,
15
+ } from '../contract/permission';
16
+ import type { NotificationEngineAdapter } from '../engine/adapter';
17
+ import type { StateManager } from '../state/record';
18
+ import { DIAGNOSTIC_CODES, NotificationError } from '../contract/errors';
19
+ import { capabilityFor, normalizeState, type PlatformInfo } from './state';
20
+ import { hasRequested, recordRequested } from '../state/permission-history';
21
+
22
+ export interface PermissionDependencies {
23
+ readonly engine: NotificationEngineAdapter;
24
+ readonly state: StateManager;
25
+ readonly platform: PlatformInfo;
26
+ readonly now: () => number;
27
+ readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
28
+ }
29
+
30
+ export type PermissionChangeHandler = (report: PermissionReport) => void;
31
+
32
+ export function createPermissions(deps: PermissionDependencies) {
33
+ const emit = deps.emit ?? (() => {});
34
+ let listeners: PermissionChangeHandler[] = [];
35
+ let lastReport: PermissionReport | undefined;
36
+
37
+ const read = async (): Promise<PermissionReport> => {
38
+ // Never prompts. This is what lets a consumer decide the moment to ask.
39
+ const [display, exactAlarm] = await Promise.all([
40
+ deps.engine.getPermission('display'),
41
+ deps.engine.getPermission('exactAlarm'),
42
+ ]);
43
+
44
+ return {
45
+ display: normalizeState('display', display, deps.platform),
46
+ exactAlarm: normalizeState('exactAlarm', exactAlarm, deps.platform),
47
+ readAt: deps.now(),
48
+ };
49
+ };
50
+
51
+ const notifyIfChanged = (report: PermissionReport): boolean => {
52
+ const changed =
53
+ lastReport === undefined ||
54
+ lastReport.display !== report.display ||
55
+ lastReport.exactAlarm !== report.exactAlarm;
56
+
57
+ lastReport = report;
58
+ if (!changed) return false;
59
+
60
+ for (const listener of listeners) {
61
+ try {
62
+ listener(report);
63
+ } catch {
64
+ // A consumer's handler must never break permission reading.
65
+ }
66
+ }
67
+ return true;
68
+ };
69
+
70
+ const request = async (kind: PermissionKind, ownerTag?: string): Promise<PermissionState> => {
71
+ const capability = capabilityFor(kind, deps.platform);
72
+
73
+ // Requesting something the platform version does not have is a no-op that
74
+ // shows no dialog, rather than a refusal.
75
+ if (!capability.supported) {
76
+ return 'notRequired';
77
+ }
78
+
79
+ recordRequested(deps.state, kind, deps.now(), ownerTag);
80
+
81
+ const raw = await deps.engine.requestPermission(kind);
82
+ const result = normalizeState(kind, raw, deps.platform);
83
+
84
+ if (result === 'denied' || result === 'permanentlyDenied') {
85
+ emit(
86
+ kind === 'display'
87
+ ? result === 'permanentlyDenied'
88
+ ? DIAGNOSTIC_CODES.DISPLAY_PERMISSION_PERMANENT
89
+ : DIAGNOSTIC_CODES.DISPLAY_PERMISSION_DENIED
90
+ : DIAGNOSTIC_CODES.EXACT_ALARM_UNAVAILABLE,
91
+ kind === 'display'
92
+ ? 'Display permission was refused. The caller\'s operation still succeeded; notifications will not be shown until this is granted.'
93
+ : 'Exact-alarm capability is unavailable. Notifications are still scheduled, in approximate mode, and may be delivered late.',
94
+ { kind, state: result },
95
+ );
96
+ }
97
+
98
+ notifyIfChanged(await read());
99
+ return result;
100
+ };
101
+
102
+ return {
103
+ /** Reads every permission state without triggering any dialog. */
104
+ async getPermissions(): Promise<PermissionReport> {
105
+ const report = await read();
106
+ notifyIfChanged(report);
107
+ return report;
108
+ },
109
+
110
+ /** Requested independently — an app may need one and not the other. */
111
+ requestDisplayPermission(ownerTag?: string): Promise<PermissionState> {
112
+ return request('display', ownerTag);
113
+ },
114
+
115
+ requestExactAlarmPermission(ownerTag?: string): Promise<PermissionState> {
116
+ return request('exactAlarm', ownerTag);
117
+ },
118
+
119
+ /** Whether the package has ever asked — the platform cannot answer this. */
120
+ hasEverRequested(kind: PermissionKind, ownerTag?: string): boolean {
121
+ return hasRequested(deps.state, kind, ownerTag);
122
+ },
123
+
124
+ /**
125
+ * Opens the settings screen for one specific permission.
126
+ *
127
+ * Telling a user to "find it in settings" is how you lose them, so a
128
+ * platform with no reachable screen is an error rather than a silent no-op.
129
+ */
130
+ async openSettings(kind: PermissionKind): Promise<void> {
131
+ if (!capabilityFor(kind, deps.platform).supported) {
132
+ throw new NotificationError(
133
+ DIAGNOSTIC_CODES.SETTINGS_UNAVAILABLE,
134
+ `No settings screen exists for ${kind} on this platform version.`,
135
+ { kind, os: deps.platform.os, apiLevel: deps.platform.apiLevel },
136
+ );
137
+ }
138
+ await deps.engine.openSettings(kind);
139
+ },
140
+
141
+ onPermissionChange(handler: PermissionChangeHandler): () => void {
142
+ listeners.push(handler);
143
+ return () => {
144
+ listeners = listeners.filter(l => l !== handler);
145
+ };
146
+ },
147
+
148
+ /**
149
+ * Manufacturer background restrictions, reported for information only.
150
+ *
151
+ * `unknown` is a first-class answer: some vendors stop apps in ways with no
152
+ * detection API, and guessing would be worse than admitting it. The package
153
+ * changes none of these settings on the user's behalf.
154
+ */
155
+ async getBackgroundRestrictions(): Promise<RestrictionReport> {
156
+ const report = await deps.engine.getRestrictions();
157
+
158
+ if (report.batteryOptimization === 'restricted' || report.autoStart === 'restricted') {
159
+ emit(
160
+ DIAGNOSTIC_CODES.BACKGROUND_RESTRICTED,
161
+ 'The device applies a manufacturer background restriction. Notifications may be delayed or suppressed.',
162
+ { battery: report.batteryOptimization, autoStart: report.autoStart },
163
+ );
164
+ } else if (report.batteryOptimization === 'unknown' || report.autoStart === 'unknown') {
165
+ emit(
166
+ DIAGNOSTIC_CODES.BACKGROUND_RESTRICTION_UNKNOWN,
167
+ 'Manufacturer background restriction could not be determined on this device. Reported as unknown rather than guessed.',
168
+ { battery: report.batteryOptimization, autoStart: report.autoStart },
169
+ );
170
+ }
171
+
172
+ return report;
173
+ },
174
+
175
+ /** Called by the permission-refresh maintenance step. */
176
+ async refresh(): Promise<{ readonly report: PermissionReport; readonly changed: boolean }> {
177
+ const report = await read();
178
+ return { report, changed: notifyIfChanged(report) };
179
+ },
180
+ };
181
+ }
182
+
183
+ export type Permissions = ReturnType<typeof createPermissions>;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The permission state model.
3
+ *
4
+ * Six states, never collapsed. The two that matter most are the ones a naive
5
+ * model loses: `notAsked` must stay distinguishable from `denied`, or reading
6
+ * state without prompting buys nothing; and `notRequired` must never be
7
+ * reported as `denied`, because a platform version with no such concept never
8
+ * refused anything.
9
+ */
10
+
11
+ import type { PermissionCapability, PermissionKind, PermissionState } from '../contract/permission';
12
+
13
+ /** Android runtime notification permission arrived in API 33. */
14
+ export const ANDROID_DISPLAY_PERMISSION_API = 33;
15
+ /** Android exact-alarm permission arrived in API 31. */
16
+ export const ANDROID_EXACT_ALARM_API = 31;
17
+
18
+ export interface PlatformInfo {
19
+ readonly os: 'ios' | 'android' | 'other';
20
+ /** Android API level. Ignored on iOS. */
21
+ readonly apiLevel?: number;
22
+ }
23
+
24
+ /**
25
+ * Whether a permission concept exists at all on this platform version.
26
+ *
27
+ * Returning `supported: false` is what lets `notRequired` be reported honestly
28
+ * rather than inventing a refusal.
29
+ */
30
+ export function capabilityFor(kind: PermissionKind, platform: PlatformInfo): PermissionCapability {
31
+ if (kind === 'display') {
32
+ if (platform.os === 'android' && (platform.apiLevel ?? 0) < ANDROID_DISPLAY_PERMISSION_API) {
33
+ return { kind, supported: false, settingsOnly: false };
34
+ }
35
+ return { kind, supported: true, settingsOnly: false };
36
+ }
37
+
38
+ // Exact alarms exist only on Android 12+, and are granted through system
39
+ // settings rather than an in-app dialog on the versions that gate them.
40
+ if (platform.os !== 'android' || (platform.apiLevel ?? 0) < ANDROID_EXACT_ALARM_API) {
41
+ return { kind, supported: false, settingsOnly: false };
42
+ }
43
+ return { kind, supported: true, settingsOnly: true };
44
+ }
45
+
46
+ /** Normalizes a platform reading into the six-state model. */
47
+ export function normalizeState(
48
+ kind: PermissionKind,
49
+ raw: PermissionState,
50
+ platform: PlatformInfo,
51
+ ): PermissionState {
52
+ if (!capabilityFor(kind, platform).supported) {
53
+ return 'notRequired';
54
+ }
55
+ return raw;
56
+ }
57
+
58
+ /**
59
+ * Whether a state means the package can still show notifications.
60
+ *
61
+ * `provisional` counts: iOS quiet delivery still reaches the user, just without
62
+ * interrupting them. Treating it as denied would suppress notifications the
63
+ * user is in fact receiving.
64
+ */
65
+ export function canDisplay(state: PermissionState): boolean {
66
+ return state === 'granted' || state === 'provisional' || state === 'notRequired';
67
+ }
68
+
69
+ /** Whether asking again could produce a dialog, or whether settings is the only route. */
70
+ export function isTerminal(state: PermissionState): boolean {
71
+ return state === 'permanentlyDenied' || state === 'notRequired';
72
+ }
73
+
74
+ /** Whether the package has ever asked, which the platform cannot answer after the fact. */
75
+ export function shouldOfferSettings(state: PermissionState): boolean {
76
+ return state === 'denied' || state === 'permanentlyDenied';
77
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Payload normalization.
3
+ *
4
+ * Every provider's payload is reduced to one envelope shape before it reaches
5
+ * the consumer, so changing provider costs consumer code nothing.
6
+ *
7
+ * Version handling is best-effort: a payload declaring a version this build
8
+ * does not implement is still processed, honouring the fields it understands
9
+ * and ignoring the rest. Servers deploy ahead of app adoption and old app
10
+ * versions live on devices for months, so gating every server-side change on
11
+ * app rollout is the more expensive failure.
12
+ *
13
+ * That is only safe under an append-only discipline — a released field's
14
+ * meaning must never be repurposed. The version-skew diagnostic exists so a
15
+ * misprocessing incident is traceable to the skew rather than investigated as a
16
+ * logic defect.
17
+ */
18
+
19
+ import type { PushEnvelope } from './provider-adapter';
20
+ import { DIAGNOSTIC_CODES, NotificationError } from '../contract/errors';
21
+
22
+ /** The contract version this build implements. */
23
+ export const IMPLEMENTED_PAYLOAD_VERSION = 1;
24
+
25
+ const KINDS = new Set(['display', 'data', 'schedule', 'cancel']);
26
+
27
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
28
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
29
+ ? (value as Record<string, unknown>)
30
+ : undefined;
31
+ }
32
+
33
+ /**
34
+ * Text, or nothing.
35
+ *
36
+ * Deliberately not `String(value)`: an object coerces to the literal string
37
+ * "[object Object]", which would be rendered to the user as a notification
38
+ * title. A field that is not text is treated as absent.
39
+ */
40
+ function asText(value: unknown): string | undefined {
41
+ if (typeof value === 'string') return value;
42
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
43
+ return undefined;
44
+ }
45
+
46
+ /** An epoch instant from either a number or an ISO-8601 string. */
47
+ function asInstant(value: unknown): number {
48
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
49
+ if (typeof value === 'string') return Date.parse(value);
50
+ return Number.NaN;
51
+ }
52
+
53
+ /** Extracts and validates the contract version, which every payload must declare. */
54
+ function readVersion(body: Record<string, unknown>): number {
55
+ const raw = body.v;
56
+ const version = typeof raw === 'number' ? raw : Number.parseInt(asText(raw) ?? '', 10);
57
+
58
+ if (!Number.isInteger(version) || version < 1) {
59
+ throw new NotificationError(
60
+ DIAGNOSTIC_CODES.PAYLOAD_MALFORMED,
61
+ 'Push payload carries no usable contract version. Every payload must declare `v` from the first release.',
62
+ { received: typeof raw },
63
+ );
64
+ }
65
+ return version;
66
+ }
67
+
68
+ function readKind(body: Record<string, unknown>): PushEnvelope['kind'] {
69
+ const kind = asText(body.kind);
70
+
71
+ if (kind === undefined || !KINDS.has(kind)) {
72
+ throw new NotificationError(
73
+ DIAGNOSTIC_CODES.PAYLOAD_MALFORMED,
74
+ `Push payload declares unknown kind "${kind ?? ''}".`,
75
+ { kind },
76
+ );
77
+ }
78
+ return kind as PushEnvelope['kind'];
79
+ }
80
+
81
+ function readId(body: Record<string, unknown>): string {
82
+ const id = asText(body.id);
83
+
84
+ if (id === undefined || id.length === 0) {
85
+ throw new NotificationError(
86
+ DIAGNOSTIC_CODES.PAYLOAD_MALFORMED,
87
+ 'Push payload carries no identifier.',
88
+ );
89
+ }
90
+ return id;
91
+ }
92
+
93
+ function readContent(body: Record<string, unknown>): PushEnvelope['content'] {
94
+ const content = asRecord(body.content);
95
+ if (content === undefined) return undefined;
96
+
97
+ return {
98
+ title: asText(content.title) ?? '',
99
+ body: asText(content.body) ?? '',
100
+ subtitle: asText(content.subtitle),
101
+ };
102
+ }
103
+
104
+ function readMoment(body: Record<string, unknown>): PushEnvelope['moment'] {
105
+ const moment = asRecord(body.moment);
106
+ if (moment === undefined) return undefined;
107
+
108
+ return {
109
+ at: asInstant(moment.at),
110
+ anchoring: moment.anchoring === 'absolute' ? 'absolute' : 'wallClock',
111
+ };
112
+ }
113
+
114
+ export interface NormalizeResult {
115
+ readonly envelope: PushEnvelope;
116
+ readonly versionSkew: boolean;
117
+ }
118
+
119
+ /**
120
+ * Normalizes a raw provider payload.
121
+ *
122
+ * Throws only for a **structurally unparseable** payload — a broken message.
123
+ * An unrecognized version is a healthy message from a newer contract and must
124
+ * never collapse into the same condition.
125
+ */
126
+ export function normalizeEnvelope(raw: unknown, receivedAt: number): NormalizeResult {
127
+ const record = asRecord(raw);
128
+ if (record === undefined) {
129
+ throw new NotificationError(
130
+ DIAGNOSTIC_CODES.PAYLOAD_MALFORMED,
131
+ 'Push payload is not an object and cannot be interpreted.',
132
+ { received: Array.isArray(raw) ? 'array' : typeof raw },
133
+ );
134
+ }
135
+
136
+ // Providers deliver the payload under a data bag; accept either shape so the
137
+ // adapter does not have to reshape before handing it over.
138
+ const body = asRecord(record.data) ?? record;
139
+
140
+ const version = readVersion(body);
141
+ const kind = readKind(body);
142
+ const id = readId(body);
143
+ const versionSkew = version !== IMPLEMENTED_PAYLOAD_VERSION;
144
+
145
+ return {
146
+ versionSkew,
147
+ envelope: {
148
+ version,
149
+ kind,
150
+ id,
151
+ ownerTag: asText(body.ownerTag),
152
+ content: readContent(body),
153
+ tone: asText(body.tone),
154
+ moment: readMoment(body),
155
+ routing: asRecord(body.routing),
156
+ // Falls back to the identifier, so a server that omits it still gets
157
+ // de-duplication rather than silently losing it.
158
+ dedupId: asText(body.dedupId) ?? id,
159
+ receivedAt,
160
+ versionSkew,
161
+ // Fields this build does not understand are simply not read — that is
162
+ // what best-effort processing means in practice.
163
+ },
164
+ };
165
+ }