@honua/app-platform 0.1.12 → 0.1.14
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/columnar/apache-arrow.d.ts +17 -0
- package/columnar/apache-arrow.d.ts.map +1 -0
- package/columnar/apache-arrow.js +1036 -0
- package/columnar/batch-cache-conformance.d.ts +66 -0
- package/columnar/batch-cache-conformance.d.ts.map +1 -0
- package/columnar/batch-cache-conformance.js +317 -0
- package/columnar/batch-cache-indexeddb.d.ts +31 -0
- package/columnar/batch-cache-indexeddb.d.ts.map +1 -0
- package/columnar/batch-cache-indexeddb.js +148 -0
- package/columnar/batch-cache.d.ts +298 -0
- package/columnar/batch-cache.d.ts.map +1 -0
- package/columnar/batch-cache.js +757 -0
- package/columnar/geoarrow-aggregate.d.ts +178 -0
- package/columnar/geoarrow-aggregate.d.ts.map +1 -0
- package/columnar/geoarrow-aggregate.js +976 -0
- package/columnar/geoarrow-patch.d.ts +382 -0
- package/columnar/geoarrow-patch.d.ts.map +1 -0
- package/columnar/geoarrow-patch.js +1656 -0
- package/columnar/geoarrow-reproject.d.ts +22 -0
- package/columnar/geoarrow-reproject.d.ts.map +1 -0
- package/columnar/geoarrow-reproject.js +119 -0
- package/columnar/geoarrow-serialization.d.ts +68 -0
- package/columnar/geoarrow-serialization.d.ts.map +1 -0
- package/columnar/geoarrow-serialization.js +331 -0
- package/columnar/geoarrow-types.d.ts +271 -0
- package/columnar/geoarrow-types.d.ts.map +1 -0
- package/columnar/geoarrow-types.js +15 -0
- package/columnar/geoarrow.d.ts +52 -0
- package/columnar/geoarrow.d.ts.map +1 -0
- package/columnar/geoarrow.js +1469 -0
- package/columnar/index.d.ts +43 -0
- package/columnar/index.d.ts.map +1 -0
- package/columnar/index.js +29 -0
- package/columnar/operators.d.ts +70 -0
- package/columnar/operators.d.ts.map +1 -0
- package/columnar/operators.js +307 -0
- package/columnar/result.d.ts +369 -0
- package/columnar/result.d.ts.map +1 -0
- package/columnar/result.js +815 -0
- package/columnar/telemetry.d.ts +152 -0
- package/columnar/telemetry.d.ts.map +1 -0
- package/columnar/telemetry.js +265 -0
- package/columnar/transfer.d.ts +28 -0
- package/columnar/transfer.d.ts.map +1 -0
- package/columnar/transfer.js +669 -0
- package/columnar/types.d.ts +175 -0
- package/columnar/types.d.ts.map +1 -0
- package/columnar/types.js +22 -0
- package/columnar/worker.d.ts +157 -0
- package/columnar/worker.d.ts.map +1 -0
- package/columnar/worker.js +1059 -0
- package/contract/pmtiles.d.ts +6 -5
- package/contract/pmtiles.d.ts.map +1 -1
- package/contract/pmtiles.js +30 -5
- package/contract/source.d.ts.map +1 -1
- package/contract/source.js +11 -8
- package/controls/catalog.d.ts.map +1 -1
- package/controls/catalog.js +110 -0
- package/controls/index.d.ts +1 -1
- package/controls/index.js +1 -1
- package/controls/types.d.ts +1 -1
- package/controls/types.js +1 -1
- package/core/client.d.ts.map +1 -1
- package/core/client.js +87 -19
- package/core/deprecation-notice.d.ts +28 -0
- package/core/deprecation-notice.d.ts.map +1 -0
- package/core/deprecation-notice.js +22 -0
- package/core/esri-geojson.d.ts.map +1 -1
- package/core/esri-geojson.js +14 -8
- package/core/optional-grpc-peer.d.ts +15 -0
- package/core/optional-grpc-peer.d.ts.map +1 -0
- package/core/optional-grpc-peer.js +60 -0
- package/esri-compat/basemap-gallery.d.ts +2 -0
- package/esri-compat/basemap-gallery.d.ts.map +1 -1
- package/esri-compat/basemap-gallery.js +12 -0
- package/esri-compat/basemap.d.ts +4 -0
- package/esri-compat/basemap.d.ts.map +1 -1
- package/esri-compat/basemap.js +70 -0
- package/esri-compat/bookmarks.d.ts +2 -0
- package/esri-compat/bookmarks.d.ts.map +1 -1
- package/esri-compat/bookmarks.js +12 -0
- package/esri-compat/controls.d.ts +18 -0
- package/esri-compat/controls.d.ts.map +1 -1
- package/esri-compat/controls.js +120 -57
- package/esri-compat/directions.d.ts +4 -0
- package/esri-compat/directions.d.ts.map +1 -1
- package/esri-compat/directions.js +30 -20
- package/esri-compat/editor.d.ts +4 -0
- package/esri-compat/editor.d.ts.map +1 -1
- package/esri-compat/editor.js +16 -0
- package/esri-compat/esri-config.d.ts +2 -1
- package/esri-compat/esri-config.d.ts.map +1 -1
- package/esri-compat/feature-effect.d.ts +23 -0
- package/esri-compat/feature-effect.d.ts.map +1 -0
- package/esri-compat/feature-effect.js +12 -0
- package/esri-compat/feature-form.d.ts +6 -0
- package/esri-compat/feature-form.d.ts.map +1 -1
- package/esri-compat/feature-form.js +63 -0
- package/esri-compat/feature-layer.d.ts +21 -2
- package/esri-compat/feature-layer.d.ts.map +1 -1
- package/esri-compat/feature-layer.js +172 -1
- package/esri-compat/feature-table.d.ts +5 -0
- package/esri-compat/feature-table.d.ts.map +1 -1
- package/esri-compat/feature-table.js +23 -0
- package/esri-compat/identity-manager.d.ts +1 -1
- package/esri-compat/identity-manager.js +1 -1
- package/esri-compat/legend.d.ts +2 -0
- package/esri-compat/legend.d.ts.map +1 -1
- package/esri-compat/legend.js +98 -0
- package/esri-compat/locator.d.ts +1 -1
- package/esri-compat/locator.js +1 -1
- package/esri-compat/map-view-mount.d.ts +1 -0
- package/esri-compat/map-view-mount.d.ts.map +1 -1
- package/esri-compat/map-view-mount.js +46 -25
- package/esri-compat/map-view.d.ts +13 -0
- package/esri-compat/map-view.d.ts.map +1 -1
- package/esri-compat/map-view.js +94 -8
- package/esri-compat/map.d.ts +5 -1
- package/esri-compat/map.d.ts.map +1 -1
- package/esri-compat/map.js +10 -0
- package/esri-compat/measurement.d.ts +2 -0
- package/esri-compat/measurement.d.ts.map +1 -1
- package/esri-compat/measurement.js +14 -0
- package/esri-compat/point.d.ts +7 -0
- package/esri-compat/point.d.ts.map +1 -1
- package/esri-compat/point.js +33 -3
- package/esri-compat/popup.d.ts +2 -0
- package/esri-compat/popup.d.ts.map +1 -1
- package/esri-compat/popup.js +12 -0
- package/esri-compat/portal.d.ts +1 -1
- package/esri-compat/portal.js +1 -1
- package/esri-compat/print.d.ts +3 -0
- package/esri-compat/print.d.ts.map +1 -1
- package/esri-compat/print.js +15 -0
- package/esri-compat/search.d.ts +4 -0
- package/esri-compat/search.d.ts.map +1 -1
- package/esri-compat/search.js +25 -28
- package/esri-compat/sketch.d.ts +2 -0
- package/esri-compat/sketch.d.ts.map +1 -1
- package/esri-compat/sketch.js +12 -0
- package/esri-compat/widget-host.d.ts +46 -17
- package/esri-compat/widget-host.d.ts.map +1 -1
- package/esri-compat/widget-host.js +84 -20
- package/esri-compat/widget-shell-hosts.d.ts +61 -0
- package/esri-compat/widget-shell-hosts.d.ts.map +1 -0
- package/esri-compat/widget-shell-hosts.js +76 -0
- package/kernel/renderer.d.ts +82 -0
- package/kernel/renderer.js +1 -0
- package/offline/conflict-resolution.d.ts +183 -0
- package/offline/conflict-resolution.d.ts.map +1 -0
- package/offline/conflict-resolution.js +286 -0
- package/offline/credential-screen.d.ts +34 -0
- package/offline/credential-screen.d.ts.map +1 -0
- package/offline/credential-screen.js +78 -0
- package/offline/digest.d.ts +15 -0
- package/offline/digest.d.ts.map +1 -0
- package/offline/digest.js +58 -0
- package/offline/edit-queue.d.ts +403 -0
- package/offline/edit-queue.d.ts.map +1 -0
- package/offline/edit-queue.js +1807 -0
- package/offline/edit-replay.d.ts +116 -0
- package/offline/edit-replay.d.ts.map +1 -0
- package/offline/edit-replay.js +392 -0
- package/offline/fetch-handler.d.ts +12 -0
- package/offline/fetch-handler.d.ts.map +1 -0
- package/offline/fetch-handler.js +65 -0
- package/offline/index.d.ts +52 -0
- package/offline/index.d.ts.map +1 -0
- package/offline/index.js +34 -0
- package/offline/indexeddb.d.ts +135 -0
- package/offline/indexeddb.d.ts.map +1 -0
- package/offline/indexeddb.js +888 -0
- package/offline/memory-store.d.ts +17 -0
- package/offline/memory-store.d.ts.map +1 -0
- package/offline/memory-store.js +183 -0
- package/offline/quota.d.ts +161 -0
- package/offline/quota.d.ts.map +1 -0
- package/offline/quota.js +370 -0
- package/offline/read-gate.d.ts +175 -0
- package/offline/read-gate.d.ts.map +1 -0
- package/offline/read-gate.js +252 -0
- package/offline/read-resource.d.ts +99 -0
- package/offline/read-resource.d.ts.map +1 -0
- package/offline/read-resource.js +156 -0
- package/offline/read.d.ts +50 -0
- package/offline/read.d.ts.map +1 -0
- package/offline/read.js +65 -0
- package/offline/region.d.ts +29 -0
- package/offline/region.d.ts.map +1 -0
- package/offline/region.js +1007 -0
- package/offline/replay-conflict.d.ts +183 -0
- package/offline/replay-conflict.d.ts.map +1 -0
- package/offline/replay-conflict.js +482 -0
- package/offline/selection.d.ts +140 -0
- package/offline/selection.d.ts.map +1 -0
- package/offline/selection.js +391 -0
- package/offline/snapshot.d.ts +132 -0
- package/offline/snapshot.d.ts.map +1 -0
- package/offline/snapshot.js +312 -0
- package/offline/status.d.ts +136 -0
- package/offline/status.d.ts.map +1 -0
- package/offline/status.js +479 -0
- package/offline/store-conformance.d.ts +48 -0
- package/offline/store-conformance.d.ts.map +1 -0
- package/offline/store-conformance.js +233 -0
- package/offline/tile-protocol.d.ts +81 -0
- package/offline/tile-protocol.d.ts.map +1 -0
- package/offline/tile-protocol.js +119 -0
- package/offline/types.d.ts +349 -0
- package/offline/types.d.ts.map +1 -0
- package/offline/types.js +75 -0
- package/package.json +14 -2
- package/realtime/odata-delta.d.ts +1 -1
- package/realtime/odata-delta.d.ts.map +1 -1
- package/realtime/odata-delta.js +38 -10
- package/scene-workspace/index.d.ts +2 -2
- package/scene-workspace/index.d.ts.map +1 -1
- package/scene-workspace/index.js +1 -1
- package/scene-workspace/scene-discovery.d.ts +111 -91
- package/scene-workspace/scene-discovery.d.ts.map +1 -1
- package/scene-workspace/scene-discovery.js +340 -92
- package/scene-workspace/scene-view.d.ts +6 -6
- package/scene-workspace/scene-view.d.ts.map +1 -1
- package/scene-workspace/scene-view.js +32 -6
- package/web-components/elements.d.ts +34 -0
- package/web-components/elements.d.ts.map +1 -1
- package/web-components/elements.js +82 -8
- package/web-components/index.d.ts +2 -0
- package/web-components/index.d.ts.map +1 -1
- package/web-components/index.js +3 -1
- package/web-components/map-chrome.d.ts +103 -0
- package/web-components/map-chrome.d.ts.map +1 -0
- package/web-components/map-chrome.js +325 -0
- package/web-components/widget-shell.d.ts +68 -0
- package/web-components/widget-shell.d.ts.map +1 -0
- package/web-components/widget-shell.js +224 -0
- package/widget-capabilities.d.ts +67 -0
- package/widget-capabilities.js +152 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replica conflict policy and reviewed conflict resolution for the durable
|
|
3
|
+
* offline edit queue.
|
|
4
|
+
*
|
|
5
|
+
* The queue can record that a replay attempt conflicted. What it could not do
|
|
6
|
+
* until now is say *what should happen next*. A replica is registered with a
|
|
7
|
+
* {@link ReplicaConflictPolicy}, and a reviewer closes an individual conflict
|
|
8
|
+
* with a {@link ConflictResolutionChoice}; both vocabularies ship in
|
|
9
|
+
* `@honua/sdk-js/replica-sync`, and neither reached the queue. This module is
|
|
10
|
+
* that reach, under two rules:
|
|
11
|
+
*
|
|
12
|
+
* - **Nothing is downgraded in silence.** A policy whose outcome the SDK cannot
|
|
13
|
+
* compute from queue-side state alone is refused by name
|
|
14
|
+
* ({@link HonuaOfflineConflictAdjudicationError}) rather than quietly
|
|
15
|
+
* behaving like the one policy the SDK can always honour. Parking a
|
|
16
|
+
* `client-wins` replica's conflicts for manual review *looks* like working
|
|
17
|
+
* software and is a different product than the one the application asked for.
|
|
18
|
+
* - **Nothing is claimed of a server.** A resolution recorded here is durable
|
|
19
|
+
* *local* state: it is stamped `unacknowledged-by-server`, and this build has
|
|
20
|
+
* no code path that produces any other value. Delivering a resolution to a
|
|
21
|
+
* server, and reading its answer back, is server work that has not landed.
|
|
22
|
+
*
|
|
23
|
+
* Like every other offline↔replica-sync seam, the coupling is type-only: the
|
|
24
|
+
* emitted JavaScript here imports `./edit-queue.js` and nothing from
|
|
25
|
+
* `src/replica-sync`.
|
|
26
|
+
*
|
|
27
|
+
* @experimental
|
|
28
|
+
* @module
|
|
29
|
+
*/
|
|
30
|
+
import { HonuaSdkError } from "../core/error-envelope.js";
|
|
31
|
+
import type { ConflictResolutionChoice, ReplicaConflictPolicy, SyncConflictId, SyncConflictResolution } from "../replica-sync/types.js";
|
|
32
|
+
import { type OfflineEditConflictResolutionAcknowledgement, type OfflineEditConflictResolutionChoice, type OfflineEditConflictResolutionDisposition, type OfflineEditQueue, type OfflineEditQueuePartition } from "./edit-queue.js";
|
|
33
|
+
/** Stable discriminator and version for offline conflict-resolution receipts. */
|
|
34
|
+
export declare const HONUA_OFFLINE_CONFLICT_RESOLUTION_KIND: "honua.offline-conflict-resolution";
|
|
35
|
+
export declare const HONUA_OFFLINE_CONFLICT_RESOLUTION_VERSION: "1.0";
|
|
36
|
+
/** Whether the SDK can carry out a policy without a server. */
|
|
37
|
+
export type OfflineReplayConflictPolicyDisposition = "locally-honoured" | "server-adjudicated";
|
|
38
|
+
/** What a replay pass does with a conflicted edit under a policy. */
|
|
39
|
+
export type OfflineReplayConflictPolicyAction =
|
|
40
|
+
/** Leave the edit `conflicted` for a reviewer; the SDK decides nothing. */
|
|
41
|
+
"retain-for-review"
|
|
42
|
+
/** Close the conflict against the local edit and abandon it. */
|
|
43
|
+
| "discard-local-edit"
|
|
44
|
+
/** Refuse the pass before it starts. */
|
|
45
|
+
| "refuse";
|
|
46
|
+
/**
|
|
47
|
+
* Why a policy falls where it does. Machine-readable on purpose: the prose
|
|
48
|
+
* lives in `docs/offline-regions.md`, which is drift-gated against this table.
|
|
49
|
+
*/
|
|
50
|
+
export type OfflineReplayConflictPolicyReason =
|
|
51
|
+
/** The complete effect on the queue follows from queue-side state alone. */
|
|
52
|
+
"queue-side-outcome"
|
|
53
|
+
/** The server already refused this edit; only the server can let it win. */
|
|
54
|
+
| "needs-server-override"
|
|
55
|
+
/** Choosing a winner needs the server's edit time, which the SDK never sees. */
|
|
56
|
+
| "needs-remote-edit-time";
|
|
57
|
+
export interface OfflineReplayConflictPolicyRuleV1 {
|
|
58
|
+
readonly policy: ReplicaConflictPolicy;
|
|
59
|
+
readonly disposition: OfflineReplayConflictPolicyDisposition;
|
|
60
|
+
readonly action: OfflineReplayConflictPolicyAction;
|
|
61
|
+
readonly reason: OfflineReplayConflictPolicyReason;
|
|
62
|
+
/** The resolution a pass records automatically, when the action records one. */
|
|
63
|
+
readonly choice?: OfflineEditConflictResolutionChoice;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The classification, keyed by policy so it is exhaustive at compile time: a
|
|
67
|
+
* new member of the shipped `ReplicaConflictPolicy` union fails the build here
|
|
68
|
+
* rather than falling through to a default the SDK never reasoned about.
|
|
69
|
+
*
|
|
70
|
+
* The rule that decides the two arms is one question — *can the complete effect
|
|
71
|
+
* on the durable edit queue be computed from queue-side state alone?*
|
|
72
|
+
*
|
|
73
|
+
* - `manual` retains the edit for a reviewer. That is the whole of the policy,
|
|
74
|
+
* and it needs nothing remote.
|
|
75
|
+
* - `server-wins` means the server's committed row stands, so the queued edit
|
|
76
|
+
* lost and is abandoned. Abandoning an edit needs no remote value. What the
|
|
77
|
+
* SDK does *not* do under this policy is adopt the server's content into
|
|
78
|
+
* local state — that needs the remote row, and the queue does not own local
|
|
79
|
+
* read state at all. The scope is stated rather than glossed.
|
|
80
|
+
* - `client-wins` means the local edit should win, but the server has already
|
|
81
|
+
* refused it. Making it win requires an override the mutation transport does
|
|
82
|
+
* not define. Requeuing it locally would be a retry wearing the policy's
|
|
83
|
+
* name, so the policy is refused instead.
|
|
84
|
+
* - `last-writer-wins` needs both writers' edit times. A conflicted
|
|
85
|
+
* acknowledgement carries an opaque `ServerGenerationCursor` — an ordering
|
|
86
|
+
* token, not a comparable clock — so the SDK cannot tell who wrote last.
|
|
87
|
+
*/
|
|
88
|
+
export declare const OFFLINE_REPLAY_CONFLICT_POLICIES: Readonly<Record<ReplicaConflictPolicy, OfflineReplayConflictPolicyRuleV1>>;
|
|
89
|
+
/** The rule for a policy, or `undefined` for a value this build does not know. */
|
|
90
|
+
export declare function classifyOfflineReplayConflictPolicy(policy: string): OfflineReplayConflictPolicyRuleV1 | undefined;
|
|
91
|
+
/** Why an adjudication was refused. */
|
|
92
|
+
export type OfflineConflictAdjudicationCode =
|
|
93
|
+
/** The replica's conflict policy needs a server to decide the outcome. */
|
|
94
|
+
"server-adjudicated-policy"
|
|
95
|
+
/** The reviewer's resolution needs a server to commit the outcome. */
|
|
96
|
+
| "server-adjudicated-resolution";
|
|
97
|
+
/**
|
|
98
|
+
* A conflict outcome the SDK will not decide locally.
|
|
99
|
+
*
|
|
100
|
+
* It names its subject — the {@link ReplicaConflictPolicy} or the
|
|
101
|
+
* {@link ConflictResolutionChoice} — so a caller can report exactly what was
|
|
102
|
+
* asked for and refused, rather than inferring it from a message.
|
|
103
|
+
*/
|
|
104
|
+
export declare class HonuaOfflineConflictAdjudicationError extends HonuaSdkError {
|
|
105
|
+
readonly name = "HonuaOfflineConflictAdjudicationError";
|
|
106
|
+
readonly code: OfflineConflictAdjudicationCode;
|
|
107
|
+
/** Present for `server-adjudicated-policy`. */
|
|
108
|
+
readonly policy?: ReplicaConflictPolicy;
|
|
109
|
+
/** Present for `server-adjudicated-resolution`. */
|
|
110
|
+
readonly choice?: ConflictResolutionChoice;
|
|
111
|
+
readonly path?: string;
|
|
112
|
+
constructor(code: OfflineConflictAdjudicationCode, message: string, options?: {
|
|
113
|
+
readonly policy?: ReplicaConflictPolicy;
|
|
114
|
+
readonly choice?: ConflictResolutionChoice;
|
|
115
|
+
readonly path?: string;
|
|
116
|
+
readonly cause?: unknown;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
export declare function isHonuaOfflineConflictAdjudicationError(error: unknown): error is HonuaOfflineConflictAdjudicationError;
|
|
120
|
+
/**
|
|
121
|
+
* Resolve a declared policy to the rule a replay pass will follow, refusing a
|
|
122
|
+
* policy the SDK cannot honour.
|
|
123
|
+
*
|
|
124
|
+
* The refusal happens before any edit is claimed, so a pass either honours the
|
|
125
|
+
* policy it was given for every conflict it meets or does nothing at all. A
|
|
126
|
+
* value outside the shipped union is a malformed caller argument, not a
|
|
127
|
+
* server-adjudicated policy, and is reported as one.
|
|
128
|
+
*
|
|
129
|
+
* @internal
|
|
130
|
+
*/
|
|
131
|
+
export declare function requireHonourableOfflineReplayConflictPolicy(policy: unknown, path: string): OfflineReplayConflictPolicyRuleV1;
|
|
132
|
+
export interface RecordOfflineConflictResolutionOptions extends OfflineEditQueuePartition {
|
|
133
|
+
/** The conflicted queued edit whose conflict is being closed. */
|
|
134
|
+
readonly editId: `sha256:${string}`;
|
|
135
|
+
/**
|
|
136
|
+
* The reviewer's decision, in the shipped contract's own shape — the value a
|
|
137
|
+
* conflict-review surface already produces. Nothing has to be re-typed
|
|
138
|
+
* between the two vocabularies.
|
|
139
|
+
*/
|
|
140
|
+
readonly resolution: SyncConflictResolution;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Versioned, payload-free account of one recorded resolution.
|
|
144
|
+
*
|
|
145
|
+
* `acknowledgement` is the load-bearing member: the resolution is durable local
|
|
146
|
+
* state, and the queue remains the source of truth for what has and has not
|
|
147
|
+
* been delivered.
|
|
148
|
+
*/
|
|
149
|
+
export interface OfflineConflictResolutionReceiptV1 {
|
|
150
|
+
readonly kind: typeof HONUA_OFFLINE_CONFLICT_RESOLUTION_KIND;
|
|
151
|
+
readonly version: typeof HONUA_OFFLINE_CONFLICT_RESOLUTION_VERSION;
|
|
152
|
+
readonly editId: `sha256:${string}`;
|
|
153
|
+
readonly conflictId: SyncConflictId;
|
|
154
|
+
readonly choice: OfflineEditConflictResolutionChoice;
|
|
155
|
+
readonly disposition: OfflineEditConflictResolutionDisposition;
|
|
156
|
+
readonly acknowledgement: OfflineEditConflictResolutionAcknowledgement;
|
|
157
|
+
readonly resolvedAt: string;
|
|
158
|
+
/** The queue state the edit now holds: `pending` if requeued, else `cancelled`. */
|
|
159
|
+
readonly state: "pending" | "cancelled";
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Record a reviewer's {@link SyncConflictResolution} against the durable queued
|
|
163
|
+
* edit that raised the conflict.
|
|
164
|
+
*
|
|
165
|
+
* The edit transitions per the choice — `accept-client` requeues it as
|
|
166
|
+
* `pending` for another delivery attempt, `accept-server` and `discard` abandon
|
|
167
|
+
* it as `cancelled` — the closure lands in the edit's audit history, and the
|
|
168
|
+
* conflict leaves the record, so no later replay pass and no later status
|
|
169
|
+
* snapshot re-surfaces it.
|
|
170
|
+
*
|
|
171
|
+
* Two inputs are refused rather than approximated:
|
|
172
|
+
*
|
|
173
|
+
* - **`merge`**, and any resolution carrying `mergedAttributes` or
|
|
174
|
+
* `mergedGeometry`. A merged value would have to replace the queued edit's
|
|
175
|
+
* payload, and the edit's id is a digest of that payload bound to an
|
|
176
|
+
* idempotency key the transport may already have seen. Rewriting it locally
|
|
177
|
+
* would forge a different write under an identity that was promised for
|
|
178
|
+
* another one.
|
|
179
|
+
* - **`resolvedBy` beyond its id.** A `SyncActor`'s display name and kind are
|
|
180
|
+
* presentation, and the queue persists identities, not profiles.
|
|
181
|
+
*/
|
|
182
|
+
export declare function recordOfflineConflictResolution(queue: OfflineEditQueue, options: RecordOfflineConflictResolutionOptions): Promise<OfflineConflictResolutionReceiptV1>;
|
|
183
|
+
//# sourceMappingURL=conflict-resolution.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"conflict-resolution.d.ts","sourceRoot":"","sources":["../../../src/offline/conflict-resolution.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replica conflict policy and reviewed conflict resolution for the durable
|
|
3
|
+
* offline edit queue.
|
|
4
|
+
*
|
|
5
|
+
* The queue can record that a replay attempt conflicted. What it could not do
|
|
6
|
+
* until now is say *what should happen next*. A replica is registered with a
|
|
7
|
+
* {@link ReplicaConflictPolicy}, and a reviewer closes an individual conflict
|
|
8
|
+
* with a {@link ConflictResolutionChoice}; both vocabularies ship in
|
|
9
|
+
* `@honua/sdk-js/replica-sync`, and neither reached the queue. This module is
|
|
10
|
+
* that reach, under two rules:
|
|
11
|
+
*
|
|
12
|
+
* - **Nothing is downgraded in silence.** A policy whose outcome the SDK cannot
|
|
13
|
+
* compute from queue-side state alone is refused by name
|
|
14
|
+
* ({@link HonuaOfflineConflictAdjudicationError}) rather than quietly
|
|
15
|
+
* behaving like the one policy the SDK can always honour. Parking a
|
|
16
|
+
* `client-wins` replica's conflicts for manual review *looks* like working
|
|
17
|
+
* software and is a different product than the one the application asked for.
|
|
18
|
+
* - **Nothing is claimed of a server.** A resolution recorded here is durable
|
|
19
|
+
* *local* state: it is stamped `unacknowledged-by-server`, and this build has
|
|
20
|
+
* no code path that produces any other value. Delivering a resolution to a
|
|
21
|
+
* server, and reading its answer back, is server work that has not landed.
|
|
22
|
+
*
|
|
23
|
+
* Like every other offline↔replica-sync seam, the coupling is type-only: the
|
|
24
|
+
* emitted JavaScript here imports `./edit-queue.js` and nothing from
|
|
25
|
+
* `src/replica-sync`.
|
|
26
|
+
*
|
|
27
|
+
* @experimental
|
|
28
|
+
* @module
|
|
29
|
+
*/
|
|
30
|
+
import { HonuaSdkError } from "../core/error-envelope.js";
|
|
31
|
+
import { HonuaOfflineEditQueueError, } from "./edit-queue.js";
|
|
32
|
+
/** Stable discriminator and version for offline conflict-resolution receipts. */
|
|
33
|
+
export const HONUA_OFFLINE_CONFLICT_RESOLUTION_KIND = "honua.offline-conflict-resolution";
|
|
34
|
+
export const HONUA_OFFLINE_CONFLICT_RESOLUTION_VERSION = "1.0";
|
|
35
|
+
/**
|
|
36
|
+
* Both refusals are capability statements — the SDK cannot adjudicate, rather
|
|
37
|
+
* than the caller having malformed its request — so they share the envelope
|
|
38
|
+
* code the replica-sync capability errors already use.
|
|
39
|
+
*/
|
|
40
|
+
const ADJUDICATION_ERROR_CODE = "offline.replica-sync.capability";
|
|
41
|
+
/**
|
|
42
|
+
* The classification, keyed by policy so it is exhaustive at compile time: a
|
|
43
|
+
* new member of the shipped `ReplicaConflictPolicy` union fails the build here
|
|
44
|
+
* rather than falling through to a default the SDK never reasoned about.
|
|
45
|
+
*
|
|
46
|
+
* The rule that decides the two arms is one question — *can the complete effect
|
|
47
|
+
* on the durable edit queue be computed from queue-side state alone?*
|
|
48
|
+
*
|
|
49
|
+
* - `manual` retains the edit for a reviewer. That is the whole of the policy,
|
|
50
|
+
* and it needs nothing remote.
|
|
51
|
+
* - `server-wins` means the server's committed row stands, so the queued edit
|
|
52
|
+
* lost and is abandoned. Abandoning an edit needs no remote value. What the
|
|
53
|
+
* SDK does *not* do under this policy is adopt the server's content into
|
|
54
|
+
* local state — that needs the remote row, and the queue does not own local
|
|
55
|
+
* read state at all. The scope is stated rather than glossed.
|
|
56
|
+
* - `client-wins` means the local edit should win, but the server has already
|
|
57
|
+
* refused it. Making it win requires an override the mutation transport does
|
|
58
|
+
* not define. Requeuing it locally would be a retry wearing the policy's
|
|
59
|
+
* name, so the policy is refused instead.
|
|
60
|
+
* - `last-writer-wins` needs both writers' edit times. A conflicted
|
|
61
|
+
* acknowledgement carries an opaque `ServerGenerationCursor` — an ordering
|
|
62
|
+
* token, not a comparable clock — so the SDK cannot tell who wrote last.
|
|
63
|
+
*/
|
|
64
|
+
export const OFFLINE_REPLAY_CONFLICT_POLICIES = deepFreeze({
|
|
65
|
+
manual: {
|
|
66
|
+
policy: "manual",
|
|
67
|
+
disposition: "locally-honoured",
|
|
68
|
+
action: "retain-for-review",
|
|
69
|
+
reason: "queue-side-outcome",
|
|
70
|
+
},
|
|
71
|
+
"server-wins": {
|
|
72
|
+
policy: "server-wins",
|
|
73
|
+
disposition: "locally-honoured",
|
|
74
|
+
action: "discard-local-edit",
|
|
75
|
+
reason: "queue-side-outcome",
|
|
76
|
+
choice: "accept-server",
|
|
77
|
+
},
|
|
78
|
+
"client-wins": {
|
|
79
|
+
policy: "client-wins",
|
|
80
|
+
disposition: "server-adjudicated",
|
|
81
|
+
action: "refuse",
|
|
82
|
+
reason: "needs-server-override",
|
|
83
|
+
},
|
|
84
|
+
"last-writer-wins": {
|
|
85
|
+
policy: "last-writer-wins",
|
|
86
|
+
disposition: "server-adjudicated",
|
|
87
|
+
action: "refuse",
|
|
88
|
+
reason: "needs-remote-edit-time",
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
/** The rule for a policy, or `undefined` for a value this build does not know. */
|
|
92
|
+
export function classifyOfflineReplayConflictPolicy(policy) {
|
|
93
|
+
return Object.hasOwn(OFFLINE_REPLAY_CONFLICT_POLICIES, policy)
|
|
94
|
+
? OFFLINE_REPLAY_CONFLICT_POLICIES[policy]
|
|
95
|
+
: undefined;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* A conflict outcome the SDK will not decide locally.
|
|
99
|
+
*
|
|
100
|
+
* It names its subject — the {@link ReplicaConflictPolicy} or the
|
|
101
|
+
* {@link ConflictResolutionChoice} — so a caller can report exactly what was
|
|
102
|
+
* asked for and refused, rather than inferring it from a message.
|
|
103
|
+
*/
|
|
104
|
+
export class HonuaOfflineConflictAdjudicationError extends HonuaSdkError {
|
|
105
|
+
name = "HonuaOfflineConflictAdjudicationError";
|
|
106
|
+
code;
|
|
107
|
+
/** Present for `server-adjudicated-policy`. */
|
|
108
|
+
policy;
|
|
109
|
+
/** Present for `server-adjudicated-resolution`. */
|
|
110
|
+
choice;
|
|
111
|
+
path;
|
|
112
|
+
constructor(code, message, options = {}) {
|
|
113
|
+
super(ADJUDICATION_ERROR_CODE, message, {
|
|
114
|
+
...(options.cause === undefined ? {} : { cause: options.cause }),
|
|
115
|
+
context: { reasonCode: code },
|
|
116
|
+
});
|
|
117
|
+
this.code = code;
|
|
118
|
+
this.policy = options.policy;
|
|
119
|
+
this.choice = options.choice;
|
|
120
|
+
this.path = options.path;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export function isHonuaOfflineConflictAdjudicationError(error) {
|
|
124
|
+
return error instanceof HonuaOfflineConflictAdjudicationError;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Resolve a declared policy to the rule a replay pass will follow, refusing a
|
|
128
|
+
* policy the SDK cannot honour.
|
|
129
|
+
*
|
|
130
|
+
* The refusal happens before any edit is claimed, so a pass either honours the
|
|
131
|
+
* policy it was given for every conflict it meets or does nothing at all. A
|
|
132
|
+
* value outside the shipped union is a malformed caller argument, not a
|
|
133
|
+
* server-adjudicated policy, and is reported as one.
|
|
134
|
+
*
|
|
135
|
+
* @internal
|
|
136
|
+
*/
|
|
137
|
+
export function requireHonourableOfflineReplayConflictPolicy(policy, path) {
|
|
138
|
+
if (typeof policy !== "string") {
|
|
139
|
+
throw new HonuaOfflineEditQueueError("invalid-edit", `${path} must be a ReplicaConflictPolicy.`, { path });
|
|
140
|
+
}
|
|
141
|
+
const rule = classifyOfflineReplayConflictPolicy(policy);
|
|
142
|
+
if (!rule) {
|
|
143
|
+
throw new HonuaOfflineEditQueueError("invalid-edit", `${path} is not a ReplicaConflictPolicy this build knows.`, {
|
|
144
|
+
path,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (rule.disposition === "server-adjudicated") {
|
|
148
|
+
throw new HonuaOfflineConflictAdjudicationError("server-adjudicated-policy", `The "${rule.policy}" replica conflict policy needs server adjudication, so an offline replay pass cannot honour it. Resolve conflicts through the replica-sync review surface instead.`, { policy: rule.policy, path });
|
|
149
|
+
}
|
|
150
|
+
return rule;
|
|
151
|
+
}
|
|
152
|
+
const RESOLUTION_KEYS = new Set(["conflictId", "choice", "mergedAttributes", "mergedGeometry", "note", "resolvedBy"]);
|
|
153
|
+
const OPTION_KEYS = new Set(["authorizationScopeDigest", "sourceId", "editId", "resolution"]);
|
|
154
|
+
const ACTOR_KEYS = new Set(["id", "displayName", "kind"]);
|
|
155
|
+
const LOCAL_CHOICES = new Set(["accept-client", "accept-server", "discard"]);
|
|
156
|
+
const CONTRACT_CHOICES = new Set([...LOCAL_CHOICES, "merge"]);
|
|
157
|
+
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
158
|
+
/**
|
|
159
|
+
* Record a reviewer's {@link SyncConflictResolution} against the durable queued
|
|
160
|
+
* edit that raised the conflict.
|
|
161
|
+
*
|
|
162
|
+
* The edit transitions per the choice — `accept-client` requeues it as
|
|
163
|
+
* `pending` for another delivery attempt, `accept-server` and `discard` abandon
|
|
164
|
+
* it as `cancelled` — the closure lands in the edit's audit history, and the
|
|
165
|
+
* conflict leaves the record, so no later replay pass and no later status
|
|
166
|
+
* snapshot re-surfaces it.
|
|
167
|
+
*
|
|
168
|
+
* Two inputs are refused rather than approximated:
|
|
169
|
+
*
|
|
170
|
+
* - **`merge`**, and any resolution carrying `mergedAttributes` or
|
|
171
|
+
* `mergedGeometry`. A merged value would have to replace the queued edit's
|
|
172
|
+
* payload, and the edit's id is a digest of that payload bound to an
|
|
173
|
+
* idempotency key the transport may already have seen. Rewriting it locally
|
|
174
|
+
* would forge a different write under an identity that was promised for
|
|
175
|
+
* another one.
|
|
176
|
+
* - **`resolvedBy` beyond its id.** A `SyncActor`'s display name and kind are
|
|
177
|
+
* presentation, and the queue persists identities, not profiles.
|
|
178
|
+
*/
|
|
179
|
+
export async function recordOfflineConflictResolution(queue, options) {
|
|
180
|
+
if (!queue || typeof queue.resolveConflict !== "function") {
|
|
181
|
+
invalid("queue must implement OfflineEditQueue.", "queue");
|
|
182
|
+
}
|
|
183
|
+
const record = argumentRecord(options, "options");
|
|
184
|
+
allowedArgumentKeys(record, OPTION_KEYS, "options");
|
|
185
|
+
const authorizationScopeDigest = argumentDigest(record.authorizationScopeDigest, "options.authorizationScopeDigest");
|
|
186
|
+
const sourceId = argumentString(record.sourceId, "options.sourceId");
|
|
187
|
+
const editId = argumentDigest(record.editId, "options.editId");
|
|
188
|
+
const resolution = captureResolution(record.resolution, "options.resolution");
|
|
189
|
+
const updated = await queue.resolveConflict(editId, { authorizationScopeDigest, sourceId }, {
|
|
190
|
+
conflictId: resolution.conflictId,
|
|
191
|
+
choice: resolution.choice,
|
|
192
|
+
...(resolution.resolvedBy === undefined ? {} : { resolvedBy: resolution.resolvedBy }),
|
|
193
|
+
...(resolution.note === undefined ? {} : { note: resolution.note }),
|
|
194
|
+
});
|
|
195
|
+
const recorded = updated.conflictResolution;
|
|
196
|
+
if (!recorded || (updated.state !== "pending" && updated.state !== "cancelled")) {
|
|
197
|
+
// A queue implementation that accepted the transition without recording it
|
|
198
|
+
// has not done what this receipt would claim, so nothing is claimed.
|
|
199
|
+
throw new HonuaOfflineEditQueueError("invalid-transition", "The queue did not record the resolution against the edit.", { editId });
|
|
200
|
+
}
|
|
201
|
+
return Object.freeze({
|
|
202
|
+
kind: HONUA_OFFLINE_CONFLICT_RESOLUTION_KIND,
|
|
203
|
+
version: HONUA_OFFLINE_CONFLICT_RESOLUTION_VERSION,
|
|
204
|
+
editId,
|
|
205
|
+
conflictId: recorded.conflictId,
|
|
206
|
+
choice: recorded.choice,
|
|
207
|
+
disposition: recorded.disposition,
|
|
208
|
+
acknowledgement: recorded.acknowledgement,
|
|
209
|
+
resolvedAt: recorded.resolvedAt,
|
|
210
|
+
state: updated.state,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
function captureResolution(value, path) {
|
|
214
|
+
const record = argumentRecord(value, path);
|
|
215
|
+
allowedArgumentKeys(record, RESOLUTION_KEYS, path);
|
|
216
|
+
const conflictId = argumentString(record.conflictId, `${path}.conflictId`);
|
|
217
|
+
const choice = record.choice;
|
|
218
|
+
if (typeof choice !== "string" || !CONTRACT_CHOICES.has(choice)) {
|
|
219
|
+
invalid(`${path}.choice must be a ConflictResolutionChoice.`, `${path}.choice`);
|
|
220
|
+
}
|
|
221
|
+
if (record.mergedAttributes !== undefined || record.mergedGeometry !== undefined || choice === "merge") {
|
|
222
|
+
throw new HonuaOfflineConflictAdjudicationError("server-adjudicated-resolution", 'A "merge" resolution needs server adjudication: merged content would replace the queued edit\'s payload, ' +
|
|
223
|
+
"and the edit's identity is a digest of that payload bound to an idempotency key. Submit the merge through " +
|
|
224
|
+
"the replica-sync review surface instead.", { choice: "merge", path: `${path}.choice` });
|
|
225
|
+
}
|
|
226
|
+
const resolvedBy = record.resolvedBy === undefined ? undefined : captureActorId(record.resolvedBy, `${path}.resolvedBy`);
|
|
227
|
+
const note = record.note === undefined ? undefined : argumentString(record.note, `${path}.note`);
|
|
228
|
+
return Object.freeze({
|
|
229
|
+
conflictId,
|
|
230
|
+
choice: choice,
|
|
231
|
+
...(resolvedBy === undefined ? {} : { resolvedBy }),
|
|
232
|
+
...(note === undefined ? {} : { note }),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
function captureActorId(value, path) {
|
|
236
|
+
const record = argumentRecord(value, path);
|
|
237
|
+
allowedArgumentKeys(record, ACTOR_KEYS, path);
|
|
238
|
+
return argumentString(record.id, `${path}.id`);
|
|
239
|
+
}
|
|
240
|
+
function argumentRecord(value, path) {
|
|
241
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
242
|
+
invalid(`${path} must be a plain data object.`, path);
|
|
243
|
+
}
|
|
244
|
+
const prototype = Object.getPrototypeOf(value);
|
|
245
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
246
|
+
invalid(`${path} must use a safe prototype.`, path);
|
|
247
|
+
if (Object.getOwnPropertySymbols(value).length > 0)
|
|
248
|
+
invalid(`${path} cannot contain symbol keys.`, path);
|
|
249
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
250
|
+
const record = Object.create(null);
|
|
251
|
+
for (const [key, descriptor] of Object.entries(descriptors)) {
|
|
252
|
+
if (!("value" in descriptor))
|
|
253
|
+
invalid(`${path}.${key} must be a data property.`, `${path}.${key}`);
|
|
254
|
+
record[key] = descriptor.value;
|
|
255
|
+
}
|
|
256
|
+
return record;
|
|
257
|
+
}
|
|
258
|
+
function allowedArgumentKeys(record, allowed, path) {
|
|
259
|
+
for (const key of Object.keys(record)) {
|
|
260
|
+
if (!allowed.has(key))
|
|
261
|
+
invalid(`${path}.${key} is not allowed.`, `${path}.${key}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function argumentString(value, path) {
|
|
265
|
+
if (typeof value !== "string" || value.length === 0 || value.trim() !== value || value.length > 1024) {
|
|
266
|
+
invalid(`${path} must be a normalized non-empty string of at most 1024 characters.`, path);
|
|
267
|
+
}
|
|
268
|
+
return value;
|
|
269
|
+
}
|
|
270
|
+
function argumentDigest(value, path) {
|
|
271
|
+
const result = argumentString(value, path);
|
|
272
|
+
if (!DIGEST_PATTERN.test(result))
|
|
273
|
+
invalid(`${path} must be a lowercase SHA-256 digest.`, path);
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
function invalid(message, path) {
|
|
277
|
+
throw new HonuaOfflineEditQueueError("invalid-edit", message, { path });
|
|
278
|
+
}
|
|
279
|
+
function deepFreeze(value) {
|
|
280
|
+
if (typeof value !== "object" || value === null || Object.isFrozen(value))
|
|
281
|
+
return value;
|
|
282
|
+
Object.freeze(value);
|
|
283
|
+
for (const child of Object.values(value))
|
|
284
|
+
deepFreeze(child);
|
|
285
|
+
return value;
|
|
286
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type CredentialScreenReason, type CredentialScreenStrictness, credentialScreenMessage, screenPersistedString } from "../connect-url-safety.js";
|
|
2
|
+
import { type OfflineRegionManifestV1 } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Defence-in-depth screening for the strings the offline stores persist verbatim.
|
|
5
|
+
*
|
|
6
|
+
* The documented contract is still that persisted identities are non-secret by
|
|
7
|
+
* construction: this boundary refuses credential-shaped values, it does not make
|
|
8
|
+
* accepting secrets safe. Rejection fails the whole descriptor closed, because
|
|
9
|
+
* silently rewriting an identity would change the deterministic region id and the
|
|
10
|
+
* resource primary key.
|
|
11
|
+
*
|
|
12
|
+
* The denylist and the per-string screen itself live in
|
|
13
|
+
* `src/connect-url-safety.ts` so endpoint normalization, plan persistence,
|
|
14
|
+
* offline persistence, and realtime resume checkpoints share one vocabulary and
|
|
15
|
+
* one implementation. This module keeps only the offline-manifest walk, which
|
|
16
|
+
* is the part that knows about `OfflineRegionManifestV1`.
|
|
17
|
+
*/
|
|
18
|
+
export type { CredentialScreenReason, CredentialScreenStrictness };
|
|
19
|
+
export { credentialScreenMessage, screenPersistedString };
|
|
20
|
+
/**
|
|
21
|
+
* Locate the first persisted manifest string that must not reach storage.
|
|
22
|
+
*
|
|
23
|
+
* Walks in a stable order so the reported path is deterministic. The
|
|
24
|
+
* authorization scope is already reduced to its digest before a manifest exists,
|
|
25
|
+
* so nothing here re-derives or inspects caller authorization material, and no
|
|
26
|
+
* cached bytes are read.
|
|
27
|
+
*/
|
|
28
|
+
export declare function findManifestCredentialLeak(manifest: OfflineRegionManifestV1): {
|
|
29
|
+
readonly path: string;
|
|
30
|
+
readonly reason: CredentialScreenReason;
|
|
31
|
+
} | undefined;
|
|
32
|
+
/** Throw the structured `invalid-manifest` rejection when a manifest is unsafe to persist. */
|
|
33
|
+
export declare function assertCredentialFreeManifest(manifest: OfflineRegionManifestV1): void;
|
|
34
|
+
//# sourceMappingURL=credential-screen.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"credential-screen.d.ts","sourceRoot":"","sources":["../../../src/offline/credential-screen.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,sBAAsB,EAC3B,KAAK,0BAA0B,EAC/B,uBAAuB,EACvB,qBAAqB,EACtB,MAAM,0BAA0B,CAAC;AAElC,OAAO,EAA2B,KAAK,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAEnF;;;;;;;;;;;;;;GAcG;AAEH,YAAY,EAAE,sBAAsB,EAAE,0BAA0B,EAAE,CAAC;AACnE,OAAO,EAAE,uBAAuB,EAAE,qBAAqB,EAAE,CAAC;AAE1D;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,uBAAuB,GAChC;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,sBAAsB,CAAA;CAAE,GAAG,SAAS,CAahF;AAED,8FAA8F;AAC9F,wBAAgB,4BAA4B,CAAC,QAAQ,EAAE,uBAAuB,GAAG,IAAI,CAMpF"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { credentialScreenMessage, screenPersistedString, } from "../connect-url-safety.js";
|
|
2
|
+
import { normalizeDiscoveryEndpoint } from "../contract/discovery.js";
|
|
3
|
+
import { HonuaOfflineRegionError } from "./types.js";
|
|
4
|
+
export { credentialScreenMessage, screenPersistedString };
|
|
5
|
+
/**
|
|
6
|
+
* Locate the first persisted manifest string that must not reach storage.
|
|
7
|
+
*
|
|
8
|
+
* Walks in a stable order so the reported path is deterministic. The
|
|
9
|
+
* authorization scope is already reduced to its digest before a manifest exists,
|
|
10
|
+
* so nothing here re-derives or inspects caller authorization material, and no
|
|
11
|
+
* cached bytes are read.
|
|
12
|
+
*/
|
|
13
|
+
export function findManifestCredentialLeak(manifest) {
|
|
14
|
+
const record = manifest;
|
|
15
|
+
const source = asRecord(record.source);
|
|
16
|
+
const endpoint = source?.endpoint;
|
|
17
|
+
if (!source || typeof endpoint !== "string" || !isNormalizedCredentialFreeEndpoint(endpoint)) {
|
|
18
|
+
return { path: "source.endpoint", reason: "endpoint-not-normalized" };
|
|
19
|
+
}
|
|
20
|
+
for (const [path, value, strictness] of screenedManifestStrings(record, source)) {
|
|
21
|
+
if (typeof value !== "string")
|
|
22
|
+
continue;
|
|
23
|
+
const reason = screenPersistedString(value, strictness);
|
|
24
|
+
if (reason)
|
|
25
|
+
return { path, reason };
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
/** Throw the structured `invalid-manifest` rejection when a manifest is unsafe to persist. */
|
|
30
|
+
export function assertCredentialFreeManifest(manifest) {
|
|
31
|
+
const leak = findManifestCredentialLeak(manifest);
|
|
32
|
+
if (!leak)
|
|
33
|
+
return;
|
|
34
|
+
throw new HonuaOfflineRegionError("invalid-manifest", credentialScreenMessage(leak.path, leak.reason), {
|
|
35
|
+
path: leak.path,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function* screenedManifestStrings(record, source) {
|
|
39
|
+
yield ["name", record.name, "label"];
|
|
40
|
+
yield ["source.id", source.id, "identity"];
|
|
41
|
+
yield ["source.sourceVersion", source.sourceVersion, "identity"];
|
|
42
|
+
yield ["source.schemaVersion", source.schemaVersion, "identity"];
|
|
43
|
+
yield ["source.planVersion", source.planVersion, "identity"];
|
|
44
|
+
const attribution = asRecord(record.attribution);
|
|
45
|
+
if (attribution) {
|
|
46
|
+
for (const key in attribution) {
|
|
47
|
+
if (!Object.hasOwn(attribution, key))
|
|
48
|
+
continue;
|
|
49
|
+
yield ["attribution id", key, "identity"];
|
|
50
|
+
yield [`attribution.${key}`, attribution[key], "label"];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const resources = Array.isArray(record.resources) ? record.resources : [];
|
|
54
|
+
for (let index = 0; index < resources.length; index += 1) {
|
|
55
|
+
const resource = asRecord(resources[index]);
|
|
56
|
+
if (!resource)
|
|
57
|
+
continue;
|
|
58
|
+
const path = `resources[${index}]`;
|
|
59
|
+
yield [`${path}.id`, resource.id, "identity"];
|
|
60
|
+
yield [`${path}.contentType`, resource.contentType, "identity"];
|
|
61
|
+
yield [`${path}.sourceVersion`, resource.sourceVersion, "identity"];
|
|
62
|
+
yield [`${path}.schemaVersion`, resource.schemaVersion, "identity"];
|
|
63
|
+
yield [`${path}.planVersion`, resource.planVersion, "identity"];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function asRecord(value) {
|
|
67
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
68
|
+
? value
|
|
69
|
+
: undefined;
|
|
70
|
+
}
|
|
71
|
+
function isNormalizedCredentialFreeEndpoint(endpoint) {
|
|
72
|
+
try {
|
|
73
|
+
return normalizeDiscoveryEndpoint(endpoint) === endpoint;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Code-unit ordering, the deterministic sort used by every persisted identity. */
|
|
2
|
+
export declare function compareCodeUnits(left: string, right: string): number;
|
|
3
|
+
/**
|
|
4
|
+
* Serialize `value` with sorted own keys and no `undefined` members.
|
|
5
|
+
*
|
|
6
|
+
* The output is the digest pre-image for region and resource identity, so it is
|
|
7
|
+
* deliberately strict: an `undefined` anywhere would silently disappear from the
|
|
8
|
+
* identity and let two different selections collide.
|
|
9
|
+
*/
|
|
10
|
+
export declare function canonicalJson(value: unknown): string;
|
|
11
|
+
/** Portable Web Crypto SHA-256 over UTF-8 text or raw bytes. */
|
|
12
|
+
export declare function sha256(value: string | Uint8Array): Promise<`sha256:${string}`>;
|
|
13
|
+
/** Domain-separated digest of a canonical identity object. */
|
|
14
|
+
export declare function identityDigest(namespace: string, value: unknown): Promise<`sha256:${string}`>;
|
|
15
|
+
//# sourceMappingURL=digest.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"digest.d.ts","sourceRoot":"","sources":["../../../src/offline/digest.ts"],"names":[],"mappings":"AAaA,mFAAmF;AACnF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAEpE;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAiBpD;AAED,gEAAgE;AAChE,wBAAsB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC,UAAU,MAAM,EAAE,CAAC,CAgBpF;AAED,8DAA8D;AAC9D,wBAAsB,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,UAAU,MAAM,EAAE,CAAC,CAEnG"}
|