@sekyro/web 0.0.0-stage → 0.1.0-rc.1
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 +7 -0
- package/LICENSE +20 -0
- package/README.md +92 -2
- package/dist/capture-constraints-BujkZcfb.d.cts +21 -0
- package/dist/capture-constraints-BujkZcfb.d.ts +21 -0
- package/dist/capture-session-v3-BJ8ciaWl.d.ts +457 -0
- package/dist/capture-session-v3-CAoWM8iT.d.cts +457 -0
- package/dist/chunk-LOTAJDRE.js +1094 -0
- package/dist/chunk-LOTAJDRE.js.map +1 -0
- package/dist/chunk-MWNFSNMP.js +52 -0
- package/dist/chunk-MWNFSNMP.js.map +1 -0
- package/dist/chunk-NILNKLK6.js +50 -0
- package/dist/chunk-NILNKLK6.js.map +1 -0
- package/dist/chunk-NU4YOLCN.js +796 -0
- package/dist/chunk-NU4YOLCN.js.map +1 -0
- package/dist/chunk-NWF46H7K.js +359 -0
- package/dist/chunk-NWF46H7K.js.map +1 -0
- package/dist/chunk-WY6U7AIK.js +193 -0
- package/dist/chunk-WY6U7AIK.js.map +1 -0
- package/dist/chunk-YVFF4Q4G.js +327 -0
- package/dist/chunk-YVFF4Q4G.js.map +1 -0
- package/dist/client/index.cjs +357 -0
- package/dist/client/index.cjs.map +1 -0
- package/dist/client/index.d.cts +39 -0
- package/dist/client/index.d.ts +39 -0
- package/dist/client/index.js +15 -0
- package/dist/client/index.js.map +1 -0
- package/dist/core/index.cjs +1605 -0
- package/dist/core/index.cjs.map +1 -0
- package/dist/core/index.d.cts +87 -0
- package/dist/core/index.d.ts +87 -0
- package/dist/core/index.js +139 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/v3/detect-worker.cjs +392 -0
- package/dist/core/v3/detect-worker.cjs.map +1 -0
- package/dist/core/v3/detect-worker.d.cts +1 -0
- package/dist/core/v3/detect-worker.d.ts +1 -0
- package/dist/core/v3/detect-worker.js +13 -0
- package/dist/core/v3/detect-worker.js.map +1 -0
- package/dist/create-verification--ldwakXV.d.cts +341 -0
- package/dist/create-verification-BlAPWeW5.d.ts +341 -0
- package/dist/detect-worker-CKXHlXx9.d.cts +105 -0
- package/dist/detect-worker-CKXHlXx9.d.ts +105 -0
- package/dist/detector-scanic-4C6IEW2K.js +787 -0
- package/dist/detector-scanic-4C6IEW2K.js.map +1 -0
- package/dist/hosted-client-BpvBqu9f.d.cts +408 -0
- package/dist/hosted-client-BpvBqu9f.d.ts +408 -0
- package/dist/index.cjs +2580 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/index.cjs +4110 -0
- package/dist/internal/index.cjs.map +1 -0
- package/dist/internal/index.d.cts +431 -0
- package/dist/internal/index.d.ts +431 -0
- package/dist/internal/index.js +647 -0
- package/dist/internal/index.js.map +1 -0
- package/dist/popup/index.cjs +76 -0
- package/dist/popup/index.cjs.map +1 -0
- package/dist/popup/index.d.cts +66 -0
- package/dist/popup/index.d.ts +66 -0
- package/dist/popup/index.js +11 -0
- package/dist/popup/index.js.map +1 -0
- package/dist/scanic-ml-stub-S2OZ6HEF.js +10 -0
- package/dist/scanic-ml-stub-S2OZ6HEF.js.map +1 -0
- package/package.json +118 -4
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EXPOSURE_BAND
|
|
3
|
+
} from "./chunk-MWNFSNMP.js";
|
|
4
|
+
|
|
5
|
+
// src/core/doc/doc-catalog.ts
|
|
6
|
+
var ASPECT_BANDS = {
|
|
7
|
+
"ID-1": { center: 1.586, tolerance: 0.15 },
|
|
8
|
+
"ID-3": { center: 1.42, tolerance: 0.2 }
|
|
9
|
+
};
|
|
10
|
+
function aspectBand(format) {
|
|
11
|
+
return ASPECT_BANDS[format];
|
|
12
|
+
}
|
|
13
|
+
var MR_DOC_CATALOG = [
|
|
14
|
+
{
|
|
15
|
+
docType: "carte_identite",
|
|
16
|
+
format: "ID-1",
|
|
17
|
+
sides: ["recto", "verso"],
|
|
18
|
+
mrz: "td1",
|
|
19
|
+
// verso; carries the NNI with ICAO check digits (findings §6)
|
|
20
|
+
portraitSide: "recto",
|
|
21
|
+
aspectRatio: 1.586,
|
|
22
|
+
identifierSource: "mrz"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
docType: "passport",
|
|
26
|
+
format: "ID-3",
|
|
27
|
+
sides: ["single"],
|
|
28
|
+
mrz: "td3",
|
|
29
|
+
portraitSide: "single",
|
|
30
|
+
// data page
|
|
31
|
+
aspectRatio: 1.42,
|
|
32
|
+
identifierSource: "mrz"
|
|
33
|
+
}
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// src/core/doc/doc-gate-v1.ts
|
|
37
|
+
var DOC_DETECT_WIDTH = 480;
|
|
38
|
+
var DOC_GATE_V1_DEFAULTS = Object.freeze({
|
|
39
|
+
MIN_AREA_FRAC: 0.15,
|
|
40
|
+
EDGE_CLIP_MARGIN_PX: 4,
|
|
41
|
+
STABLE_TICKS: 3,
|
|
42
|
+
IOU_STABLE_MIN: 0.85,
|
|
43
|
+
RISE_PER_S: 0.9,
|
|
44
|
+
FALL_PER_S: 0.25,
|
|
45
|
+
GUIDE_EXPOSURE: 0.6,
|
|
46
|
+
GUIDE_SHARPNESS: 0.35,
|
|
47
|
+
ID3_BAND_MIN_WIDTH_FRAC: 0.55,
|
|
48
|
+
ID3_BAND_ASPECT_MIN: 4,
|
|
49
|
+
ID3_BAND_ASPECT_MAX: 40
|
|
50
|
+
});
|
|
51
|
+
var clamp01 = (value) => Math.min(1, Math.max(0, value));
|
|
52
|
+
function quadAreaFrac(quad, frameWidth, frameHeight) {
|
|
53
|
+
const pts = [quad.topLeft, quad.topRight, quad.bottomRight, quad.bottomLeft];
|
|
54
|
+
let doubled = 0;
|
|
55
|
+
for (let i = 0; i < 4; i++) {
|
|
56
|
+
const a = pts[i];
|
|
57
|
+
const b = pts[(i + 1) % 4];
|
|
58
|
+
doubled += a.x * b.y - b.x * a.y;
|
|
59
|
+
}
|
|
60
|
+
const frameArea = frameWidth * frameHeight;
|
|
61
|
+
return frameArea > 0 ? Math.abs(doubled / 2) / frameArea : 0;
|
|
62
|
+
}
|
|
63
|
+
function quadIou(a, b) {
|
|
64
|
+
const boxA = bbox(a);
|
|
65
|
+
const boxB = bbox(b);
|
|
66
|
+
const x1 = Math.max(boxA.x1, boxB.x1);
|
|
67
|
+
const y1 = Math.max(boxA.y1, boxB.y1);
|
|
68
|
+
const x2 = Math.min(boxA.x2, boxB.x2);
|
|
69
|
+
const y2 = Math.min(boxA.y2, boxB.y2);
|
|
70
|
+
const inter = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
|
|
71
|
+
const areaA = (boxA.x2 - boxA.x1) * (boxA.y2 - boxA.y1);
|
|
72
|
+
const areaB = (boxB.x2 - boxB.x1) * (boxB.y2 - boxB.y1);
|
|
73
|
+
const union = areaA + areaB - inter;
|
|
74
|
+
return union > 0 ? inter / union : 0;
|
|
75
|
+
}
|
|
76
|
+
function quadBounds(quad) {
|
|
77
|
+
const box = bbox(quad);
|
|
78
|
+
return { width: box.x2 - box.x1, height: box.y2 - box.y1 };
|
|
79
|
+
}
|
|
80
|
+
function bbox(quad) {
|
|
81
|
+
const pts = [quad.topLeft, quad.topRight, quad.bottomRight, quad.bottomLeft];
|
|
82
|
+
return {
|
|
83
|
+
x1: Math.min(...pts.map((p) => p.x)),
|
|
84
|
+
y1: Math.min(...pts.map((p) => p.y)),
|
|
85
|
+
x2: Math.max(...pts.map((p) => p.x)),
|
|
86
|
+
y2: Math.max(...pts.map((p) => p.y))
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function quadAspect(quad) {
|
|
90
|
+
const top = Math.hypot(quad.topRight.x - quad.topLeft.x, quad.topRight.y - quad.topLeft.y);
|
|
91
|
+
const bottom = Math.hypot(
|
|
92
|
+
quad.bottomRight.x - quad.bottomLeft.x,
|
|
93
|
+
quad.bottomRight.y - quad.bottomLeft.y
|
|
94
|
+
);
|
|
95
|
+
const left = Math.hypot(quad.bottomLeft.x - quad.topLeft.x, quad.bottomLeft.y - quad.topLeft.y);
|
|
96
|
+
const right = Math.hypot(
|
|
97
|
+
quad.bottomRight.x - quad.topRight.x,
|
|
98
|
+
quad.bottomRight.y - quad.topRight.y
|
|
99
|
+
);
|
|
100
|
+
const horizontal = (top + bottom) / 2;
|
|
101
|
+
const vertical = (left + right) / 2;
|
|
102
|
+
if (horizontal <= 0 || vertical <= 0) return 0;
|
|
103
|
+
return Math.max(horizontal, vertical) / Math.min(horizontal, vertical);
|
|
104
|
+
}
|
|
105
|
+
var DocGateV1 = class {
|
|
106
|
+
constructor(format, overrides = {}) {
|
|
107
|
+
this.p = 0;
|
|
108
|
+
this.lastTickAt = null;
|
|
109
|
+
this.lastQuad = null;
|
|
110
|
+
this.streak = 0;
|
|
111
|
+
this.format = format;
|
|
112
|
+
this.t = { ...DOC_GATE_V1_DEFAULTS, ...overrides };
|
|
113
|
+
}
|
|
114
|
+
reset() {
|
|
115
|
+
this.p = 0;
|
|
116
|
+
this.lastTickAt = null;
|
|
117
|
+
this.lastQuad = null;
|
|
118
|
+
this.streak = 0;
|
|
119
|
+
}
|
|
120
|
+
tick(input) {
|
|
121
|
+
const { quad, quality, timestampMs } = input;
|
|
122
|
+
const dtSeconds = this.lastTickAt === null ? 0 : Math.max(0, (timestampMs - this.lastTickAt) / 1e3);
|
|
123
|
+
this.lastTickAt = timestampMs;
|
|
124
|
+
let geometryKey = null;
|
|
125
|
+
if (quad === null) {
|
|
126
|
+
geometryKey = "dcv1.no_doc";
|
|
127
|
+
} else if (this.format === "ID-3") {
|
|
128
|
+
const box = quadBounds(quad);
|
|
129
|
+
const aspect = box.height > 0 ? box.width / box.height : 0;
|
|
130
|
+
if (box.width < this.t.ID3_BAND_MIN_WIDTH_FRAC * input.frameWidth) {
|
|
131
|
+
geometryKey = "dcv1.come_closer";
|
|
132
|
+
} else if (this.edgeClipped(quad, input.frameWidth, input.frameHeight)) {
|
|
133
|
+
geometryKey = "dcv1.fit_document";
|
|
134
|
+
} else if (aspect < this.t.ID3_BAND_ASPECT_MIN || aspect > this.t.ID3_BAND_ASPECT_MAX) {
|
|
135
|
+
geometryKey = "dcv1.wrong_shape";
|
|
136
|
+
}
|
|
137
|
+
} else if (quadAreaFrac(quad, input.frameWidth, input.frameHeight) < this.t.MIN_AREA_FRAC) {
|
|
138
|
+
geometryKey = "dcv1.come_closer";
|
|
139
|
+
} else if (this.edgeClipped(quad, input.frameWidth, input.frameHeight)) {
|
|
140
|
+
geometryKey = "dcv1.fit_document";
|
|
141
|
+
} else {
|
|
142
|
+
const band = aspectBand(this.format);
|
|
143
|
+
const aspect = quadAspect(quad);
|
|
144
|
+
if (Math.abs(aspect - band.center) > band.tolerance) geometryKey = "dcv1.wrong_shape";
|
|
145
|
+
}
|
|
146
|
+
const plausible = geometryKey === null;
|
|
147
|
+
const exposureOk = clamp01(quality.exposure) >= this.t.GUIDE_EXPOSURE;
|
|
148
|
+
const sharpnessOk = clamp01(quality.sharpness) >= this.t.GUIDE_SHARPNESS;
|
|
149
|
+
const qualityOk = exposureOk && sharpnessOk;
|
|
150
|
+
if (plausible && qualityOk && quad !== null) {
|
|
151
|
+
const held = this.lastQuad === null || quadIou(quad, this.lastQuad) >= this.t.IOU_STABLE_MIN;
|
|
152
|
+
this.streak = held ? this.streak + 1 : 1;
|
|
153
|
+
} else {
|
|
154
|
+
this.streak = 0;
|
|
155
|
+
}
|
|
156
|
+
this.lastQuad = quad;
|
|
157
|
+
const good = plausible && qualityOk;
|
|
158
|
+
this.p = clamp01(this.p + (good ? this.t.RISE_PER_S : -this.t.FALL_PER_S) * dtSeconds);
|
|
159
|
+
const ready = good && this.streak >= this.t.STABLE_TICKS;
|
|
160
|
+
let guidanceKey;
|
|
161
|
+
if (geometryKey !== null) {
|
|
162
|
+
guidanceKey = geometryKey;
|
|
163
|
+
} else if (!exposureOk) {
|
|
164
|
+
const bandMid = (EXPOSURE_BAND[0] + EXPOSURE_BAND[1]) / 2;
|
|
165
|
+
guidanceKey = quality.meanLuma < bandMid ? "dcv1.more_light" : "dcv1.too_bright";
|
|
166
|
+
} else if (!sharpnessOk) {
|
|
167
|
+
guidanceKey = "dcv1.steady";
|
|
168
|
+
} else {
|
|
169
|
+
guidanceKey = "dcv1.hold_still";
|
|
170
|
+
}
|
|
171
|
+
return { at: timestampMs, quad, plausible, p: this.p, guidanceKey, ready, stableTicks: this.streak };
|
|
172
|
+
}
|
|
173
|
+
edgeClipped(quad, frameWidth, frameHeight) {
|
|
174
|
+
const margin = this.t.EDGE_CLIP_MARGIN_PX;
|
|
175
|
+
const pts = [quad.topLeft, quad.topRight, quad.bottomRight, quad.bottomLeft];
|
|
176
|
+
return pts.some(
|
|
177
|
+
(p) => p.x <= margin || p.y <= margin || p.x >= frameWidth - margin || p.y >= frameHeight - margin
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export {
|
|
183
|
+
ASPECT_BANDS,
|
|
184
|
+
aspectBand,
|
|
185
|
+
MR_DOC_CATALOG,
|
|
186
|
+
DOC_DETECT_WIDTH,
|
|
187
|
+
DOC_GATE_V1_DEFAULTS,
|
|
188
|
+
quadAreaFrac,
|
|
189
|
+
quadIou,
|
|
190
|
+
quadBounds,
|
|
191
|
+
DocGateV1
|
|
192
|
+
};
|
|
193
|
+
//# sourceMappingURL=chunk-WY6U7AIK.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core/doc/doc-catalog.ts","../src/core/doc/doc-gate-v1.ts"],"sourcesContent":["// @sekyro/web/core — MR document catalog (Task 33 / D1 docTypes\n// facet, findings §4 verbatim). Pure DATA the hosted guided scanner reads\n// (which sides to prompt, where the MRZ band sits, portrait side, physical\n// aspect) and Phase 7's capability matrix joins. `docType` strings stay\n// VERBATIM with kyc-docs-v2's `document_type` — the doc executor branches on\n// them, no rename layer. `carte_residence` is intentionally ABSENT (DOC-6:\n// residence permits are out of MVP; the executor fails them `unreadable`).\n\n/** Physical geometry the scanner frames (ISO/IEC 7810 / ICAO 9303). */\nexport type DocFormat = \"ID-1\" | \"ID-3\";\n\nexport type DocSide = \"recto\" | \"verso\" | \"single\";\n\nexport interface DocCatalogEntry {\n /** kyc-docs-v2 `document_type`, verbatim. */\n docType: \"carte_identite\" | \"passport\";\n format: DocFormat;\n /** Capture sequence the hosted doc step walks, in order. */\n sides: readonly DocSide[];\n /** MRZ geometry: td1 = 3×30 (ID-1 verso), td3 = 2×44 (passport data page). */\n mrz: \"none\" | \"td1\" | \"td3\";\n /** Which side carries the holder photo (drives portrait expectations). */\n portraitSide: DocSide;\n /** Physical width/height ratio of the framed page. */\n aspectRatio: number;\n /** Where the subject identifier (NNI) is parsed from, when the doc carries one. */\n identifierSource?: \"mrz\";\n}\n\n/**\n * Plausibility aspect bands per format (Task 34 gate input): ID-1 cards are\n * 85.6×53.98mm → 1.586; an ID-3 passport DATA PAGE frames at ≈125×88mm → 1.42\n * (wider tolerance — page curl and hinge shadow skew the detected quad).\n */\nexport const ASPECT_BANDS: Record<DocFormat, { center: number; tolerance: number }> = {\n \"ID-1\": { center: 1.586, tolerance: 0.15 },\n \"ID-3\": { center: 1.42, tolerance: 0.2 },\n};\n\n/** Aspect band for a format — helper the DocGateV1 plausibility check calls. */\nexport function aspectBand(format: DocFormat): { center: number; tolerance: number } {\n return ASPECT_BANDS[format];\n}\n\n/** The MR docTypes facet (findings §4). Order = the hosted picker's display order. */\nexport const MR_DOC_CATALOG: readonly DocCatalogEntry[] = [\n {\n docType: \"carte_identite\",\n format: \"ID-1\",\n sides: [\"recto\", \"verso\"],\n mrz: \"td1\", // verso; carries the NNI with ICAO check digits (findings §6)\n portraitSide: \"recto\",\n aspectRatio: 1.586,\n identifierSource: \"mrz\",\n },\n {\n docType: \"passport\",\n format: \"ID-3\",\n sides: [\"single\"],\n mrz: \"td3\",\n portraitSide: \"single\", // data page\n aspectRatio: 1.42,\n identifierSource: \"mrz\",\n },\n];\n","// DocGateV1 (Task 34) — the document twin of FaceGateV3: plausibility gate\n// (min-area / aspect band / edge-clip) + quality (sharpness/exposure on the\n// quad crop, same frame-quality measures) + an IoU stability streak. The gate\n// NEVER fires on the Task-20 spike's failure modes (too-far/degenerate quad,\n// low-contrast lock, chip-lock, edge-crop) — each yields its taxonomy guidance\n// key (`dcv1.*`) instead, mirroring the face gate's no-luck-firing posture.\n//\n// 100% deterministic: the clock is a tick INPUT (timestampMs); nothing here\n// reads Date.now or performance.now, and detection cost lives OUTSIDE (the\n// session schedules scanic at the device profile's cadence).\nimport { EXPOSURE_BAND } from \"../v3/frame-quality\";\nimport { aspectBand, type DocFormat } from \"./doc-catalog\";\n\n/** Detect-frame width the session downscales to (findings §1: 25ms median).\n * Lives HERE (not detector-scanic.ts) so the session can import it without\n * statically pulling the scanic chunk into host bundles. */\nexport const DOC_DETECT_WIDTH = 480;\n\nexport interface DocQuadPoint {\n x: number;\n y: number;\n}\n\n/** Detected document quad, image-space pixels (scanic CornerPoints shape). */\nexport interface DocQuad {\n topLeft: DocQuadPoint;\n topRight: DocQuadPoint;\n bottomRight: DocQuadPoint;\n bottomLeft: DocQuadPoint;\n}\n\nexport interface DocGateThresholds {\n /** Quad area / frame area below which the doc is too far. */\n MIN_AREA_FRAC: number;\n /** A corner this close to (or past) a frame edge counts as clipped. */\n EDGE_CLIP_MARGIN_PX: number;\n /** Consecutive stable plausible+quality ticks required to fire. */\n STABLE_TICKS: number;\n /** Quad-bbox IoU with the previous tick at/above which the streak holds. */\n IOU_STABLE_MIN: number;\n /** Integrator slopes, per second — mirrors FaceGateV3's gentle drain. */\n RISE_PER_S: number;\n FALL_PER_S: number;\n /** Quality guidance trigger levels (same semantics as the face gate). */\n GUIDE_EXPOSURE: number;\n GUIDE_SHARPNESS: number;\n /** ID-3 (MRZ-band mode): band width / frame width floor. */\n ID3_BAND_MIN_WIDTH_FRAC: number;\n /** ID-3 (MRZ-band mode): band aspect (w/h) sanity bounds. */\n ID3_BAND_ASPECT_MIN: number;\n ID3_BAND_ASPECT_MAX: number;\n}\n\n/** Frozen defaults (plan Task 34 — do not retune without a golden update). */\nexport const DOC_GATE_V1_DEFAULTS: Readonly<DocGateThresholds> = Object.freeze({\n MIN_AREA_FRAC: 0.15,\n EDGE_CLIP_MARGIN_PX: 4,\n STABLE_TICKS: 3,\n IOU_STABLE_MIN: 0.85,\n RISE_PER_S: 0.9,\n FALL_PER_S: 0.25,\n GUIDE_EXPOSURE: 0.6,\n GUIDE_SHARPNESS: 0.35,\n ID3_BAND_MIN_WIDTH_FRAC: 0.55,\n ID3_BAND_ASPECT_MIN: 4,\n ID3_BAND_ASPECT_MAX: 40,\n});\n\nexport interface DocGateQuality {\n /** frame-quality.sharpness on the quad crop. */\n sharpness: number;\n /** frame-quality.exposure on the quad crop. */\n exposure: number;\n /** Mean crop luma — picks more_light vs too_bright. */\n meanLuma: number;\n}\n\nexport interface DocGateTickInput {\n timestampMs: number;\n quad: DocQuad | null;\n quality: DocGateQuality;\n frameWidth: number;\n frameHeight: number;\n}\n\n/** Per-tick output — the scanner chrome contract. */\nexport interface DocGateTick {\n at: number;\n quad: DocQuad | null;\n /** Geometry passed: present × big enough × in frame × right shape. */\n plausible: boolean;\n /** Integrated progress 0..1 (the overlay ring). */\n p: number;\n /** dcv1.* message key (host translates; never prose). */\n guidanceKey: string;\n /** Fire signal: plausible × quality × STABLE_TICKS consecutive high-IoU ticks. */\n ready: boolean;\n /** Current stability streak length (diagnostics/telemetry). */\n stableTicks: number;\n}\n\nconst clamp01 = (value: number) => Math.min(1, Math.max(0, value));\n\n/** Shoelace area of the quad as a fraction of the frame area. */\nexport function quadAreaFrac(quad: DocQuad, frameWidth: number, frameHeight: number): number {\n const pts = [quad.topLeft, quad.topRight, quad.bottomRight, quad.bottomLeft];\n let doubled = 0;\n for (let i = 0; i < 4; i++) {\n const a = pts[i] as DocQuadPoint;\n const b = pts[(i + 1) % 4] as DocQuadPoint;\n doubled += a.x * b.y - b.x * a.y;\n }\n const frameArea = frameWidth * frameHeight;\n return frameArea > 0 ? Math.abs(doubled / 2) / frameArea : 0;\n}\n\n/** IoU of the two quads' axis-aligned bounding boxes (stability metric). */\nexport function quadIou(a: DocQuad, b: DocQuad): number {\n const boxA = bbox(a);\n const boxB = bbox(b);\n const x1 = Math.max(boxA.x1, boxB.x1);\n const y1 = Math.max(boxA.y1, boxB.y1);\n const x2 = Math.min(boxA.x2, boxB.x2);\n const y2 = Math.min(boxA.y2, boxB.y2);\n const inter = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);\n const areaA = (boxA.x2 - boxA.x1) * (boxA.y2 - boxA.y1);\n const areaB = (boxB.x2 - boxB.x1) * (boxB.y2 - boxB.y1);\n const union = areaA + areaB - inter;\n return union > 0 ? inter / union : 0;\n}\n\n/** Axis-aligned bounds (width/height) of a quad — the ID-3 band-mode geometry. */\nexport function quadBounds(quad: DocQuad): { width: number; height: number } {\n const box = bbox(quad);\n return { width: box.x2 - box.x1, height: box.y2 - box.y1 };\n}\n\nfunction bbox(quad: DocQuad): { x1: number; y1: number; x2: number; y2: number } {\n const pts = [quad.topLeft, quad.topRight, quad.bottomRight, quad.bottomLeft];\n return {\n x1: Math.min(...pts.map((p) => p.x)),\n y1: Math.min(...pts.map((p) => p.y)),\n x2: Math.max(...pts.map((p) => p.x)),\n y2: Math.max(...pts.map((p) => p.y)),\n };\n}\n\n/** Orientation-agnostic aspect ratio (longer side pair / shorter side pair). */\nfunction quadAspect(quad: DocQuad): number {\n const top = Math.hypot(quad.topRight.x - quad.topLeft.x, quad.topRight.y - quad.topLeft.y);\n const bottom = Math.hypot(\n quad.bottomRight.x - quad.bottomLeft.x,\n quad.bottomRight.y - quad.bottomLeft.y,\n );\n const left = Math.hypot(quad.bottomLeft.x - quad.topLeft.x, quad.bottomLeft.y - quad.topLeft.y);\n const right = Math.hypot(\n quad.bottomRight.x - quad.topRight.x,\n quad.bottomRight.y - quad.topRight.y,\n );\n const horizontal = (top + bottom) / 2;\n const vertical = (left + right) / 2;\n if (horizontal <= 0 || vertical <= 0) return 0;\n return Math.max(horizontal, vertical) / Math.min(horizontal, vertical);\n}\n\nexport class DocGateV1 {\n private readonly t: DocGateThresholds;\n private readonly format: DocFormat;\n private p = 0;\n private lastTickAt: number | null = null;\n private lastQuad: DocQuad | null = null;\n private streak = 0;\n\n constructor(format: DocFormat, overrides: Partial<DocGateThresholds> = {}) {\n this.format = format;\n this.t = { ...DOC_GATE_V1_DEFAULTS, ...overrides };\n }\n\n reset(): void {\n this.p = 0;\n this.lastTickAt = null;\n this.lastQuad = null;\n this.streak = 0;\n }\n\n tick(input: DocGateTickInput): DocGateTick {\n const { quad, quality, timestampMs } = input;\n const dtSeconds =\n this.lastTickAt === null ? 0 : Math.max(0, (timestampMs - this.lastTickAt) / 1000);\n this.lastTickAt = timestampMs;\n\n // ── plausibility (geometry), taxonomy priority order ─────────────────────\n // ID-3 runs in MRZ-BAND mode (round 3): the \"quad\" is the detected MRZ\n // stroke band's rect — page-edge quads are unreliable on open booklets,\n // so the band IS the passport's detection signal. Geometry checks swap to\n // band semantics (width fraction instead of area; wide-aspect sanity).\n let geometryKey: string | null = null;\n if (quad === null) {\n geometryKey = \"dcv1.no_doc\";\n } else if (this.format === \"ID-3\") {\n const box = quadBounds(quad);\n const aspect = box.height > 0 ? box.width / box.height : 0;\n if (box.width < this.t.ID3_BAND_MIN_WIDTH_FRAC * input.frameWidth) {\n geometryKey = \"dcv1.come_closer\";\n } else if (this.edgeClipped(quad, input.frameWidth, input.frameHeight)) {\n geometryKey = \"dcv1.fit_document\";\n } else if (aspect < this.t.ID3_BAND_ASPECT_MIN || aspect > this.t.ID3_BAND_ASPECT_MAX) {\n geometryKey = \"dcv1.wrong_shape\";\n }\n } else if (quadAreaFrac(quad, input.frameWidth, input.frameHeight) < this.t.MIN_AREA_FRAC) {\n geometryKey = \"dcv1.come_closer\";\n } else if (this.edgeClipped(quad, input.frameWidth, input.frameHeight)) {\n geometryKey = \"dcv1.fit_document\";\n } else {\n const band = aspectBand(this.format);\n const aspect = quadAspect(quad);\n if (Math.abs(aspect - band.center) > band.tolerance) geometryKey = \"dcv1.wrong_shape\";\n }\n const plausible = geometryKey === null;\n\n // ── quality on the quad crop (same measures as the face gate) ────────────\n const exposureOk = clamp01(quality.exposure) >= this.t.GUIDE_EXPOSURE;\n const sharpnessOk = clamp01(quality.sharpness) >= this.t.GUIDE_SHARPNESS;\n const qualityOk = exposureOk && sharpnessOk;\n\n // ── stability streak: consecutive plausible+quality ticks with high IoU ──\n if (plausible && qualityOk && quad !== null) {\n const held = this.lastQuad === null || quadIou(quad, this.lastQuad) >= this.t.IOU_STABLE_MIN;\n this.streak = held ? this.streak + 1 : 1;\n } else {\n this.streak = 0;\n }\n this.lastQuad = quad;\n\n // ── integrator (gentle drain, FaceGateV3 shape) ───────────────────────────\n const good = plausible && qualityOk;\n this.p = clamp01(this.p + (good ? this.t.RISE_PER_S : -this.t.FALL_PER_S) * dtSeconds);\n\n const ready = good && this.streak >= this.t.STABLE_TICKS;\n\n // Guidance: geometry first (taxonomy priority), then light, then blur,\n // then \"hold still\" while the stability streak builds / holds.\n let guidanceKey: string;\n if (geometryKey !== null) {\n guidanceKey = geometryKey;\n } else if (!exposureOk) {\n const bandMid = (EXPOSURE_BAND[0] + EXPOSURE_BAND[1]) / 2;\n guidanceKey = quality.meanLuma < bandMid ? \"dcv1.more_light\" : \"dcv1.too_bright\";\n } else if (!sharpnessOk) {\n guidanceKey = \"dcv1.steady\";\n } else {\n guidanceKey = \"dcv1.hold_still\";\n }\n\n return { at: timestampMs, quad, plausible, p: this.p, guidanceKey, ready, stableTicks: this.streak };\n }\n\n private edgeClipped(quad: DocQuad, frameWidth: number, frameHeight: number): boolean {\n const margin = this.t.EDGE_CLIP_MARGIN_PX;\n const pts = [quad.topLeft, quad.topRight, quad.bottomRight, quad.bottomLeft];\n return pts.some(\n (p) =>\n p.x <= margin ||\n p.y <= margin ||\n p.x >= frameWidth - margin ||\n p.y >= frameHeight - margin,\n );\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// src/client/errors.ts
|
|
2
|
+
var KNOWN_CODES = [
|
|
3
|
+
// auth / addressing (session-token.guard, controllers' own-session guards)
|
|
4
|
+
"session_token_invalid",
|
|
5
|
+
// 401 — missing/invalid/expired/revoked token
|
|
6
|
+
"session_token_required",
|
|
7
|
+
// 403 — non-session-token principal on a hosted route (defensive)
|
|
8
|
+
"session_token_session_mismatch",
|
|
9
|
+
// 403 — route :id differs from the token's bound session
|
|
10
|
+
"session_not_found",
|
|
11
|
+
// 404 — token's session missing/foreign (findOwned)
|
|
12
|
+
// rate limiting (session-token.guard — per-token + per-IP buckets)
|
|
13
|
+
"rate_limit_exceeded",
|
|
14
|
+
// 429 + Retry-After header (seconds)
|
|
15
|
+
// consent gate (Task 0 — submit AND media presign/complete)
|
|
16
|
+
"consent_required",
|
|
17
|
+
// 409 — record consent before uploading/submitting
|
|
18
|
+
// submit flow (sessions.service.submitCheck)
|
|
19
|
+
"session_not_active",
|
|
20
|
+
// 409 — session is terminal/review; no further submissions
|
|
21
|
+
"check_not_found",
|
|
22
|
+
// 404 — session has no such checkType
|
|
23
|
+
"check_already_finalized",
|
|
24
|
+
// 409 — re-submit of a terminal/review check
|
|
25
|
+
"check_out_of_order",
|
|
26
|
+
// 409 — only the frontier check accepts a submission
|
|
27
|
+
"check_type_unsupported",
|
|
28
|
+
// 400 — no registered executor for the checkType
|
|
29
|
+
"force_outcome_forbidden",
|
|
30
|
+
// 403 — forceOutcome on a live session (plan D-C)
|
|
31
|
+
"attestation_invalid",
|
|
32
|
+
// 400 (Task 1) — Turnstile/attestation failed; reload and retry
|
|
33
|
+
"nni_locked",
|
|
34
|
+
// 409 (Plan A Task 2) — session is bound to a server-provided NNI; a different client NNI was sent
|
|
35
|
+
// media (media.service)
|
|
36
|
+
"media_content_type_not_allowed",
|
|
37
|
+
// 400 — outside the per-kind MIME allowlist
|
|
38
|
+
"media_too_large",
|
|
39
|
+
// 413 — over the per-kind byte cap
|
|
40
|
+
"media_key_invalid",
|
|
41
|
+
// 400 — not a well-formed pending key
|
|
42
|
+
"media_key_forbidden",
|
|
43
|
+
// 403 — key names another session's pending space
|
|
44
|
+
"media_not_found",
|
|
45
|
+
// 404 — no uploaded object behind the key
|
|
46
|
+
"media_unreadable",
|
|
47
|
+
// 422 — magic-byte sniff rejected the object
|
|
48
|
+
"media_session_invalid",
|
|
49
|
+
// 400 — sessionId unusable as a key segment (never in practice)
|
|
50
|
+
// idempotency (idempotency.interceptor — Task 2)
|
|
51
|
+
"idempotency_key_collision",
|
|
52
|
+
// 409 — same key, different body hash
|
|
53
|
+
"idempotency_in_progress",
|
|
54
|
+
// 409 — concurrent identical request mid-flight (client auto-retries)
|
|
55
|
+
"idempotency_key_required",
|
|
56
|
+
// 400 — only on @Idempotent({required:true}) routes (none hosted yet)
|
|
57
|
+
// validation + fallback
|
|
58
|
+
"validation_error",
|
|
59
|
+
// 400 — ValibotPipe rejection (camelToSnake of 'ValidationError')
|
|
60
|
+
"internal_error",
|
|
61
|
+
// 500 — unhandled server error
|
|
62
|
+
// client-synthesized (never sent by the backend)
|
|
63
|
+
"unknown_error",
|
|
64
|
+
// unparseable (non-JSON) response body — error OR 2xx (defensive)
|
|
65
|
+
"upload_failed",
|
|
66
|
+
// the bare Spaces PUT answered non-2xx
|
|
67
|
+
"network_error",
|
|
68
|
+
// fetch rejected: connection drop / DNS / CORS / mid-transfer (status 0)
|
|
69
|
+
"timeout"
|
|
70
|
+
// AbortSignal.timeout fired — API calls 30 s default, Spaces PUT 15 min (status 0)
|
|
71
|
+
];
|
|
72
|
+
function isKnownCode(code) {
|
|
73
|
+
return KNOWN_CODES.includes(code);
|
|
74
|
+
}
|
|
75
|
+
var HostedApiError = class extends Error {
|
|
76
|
+
constructor(args) {
|
|
77
|
+
super(args.message);
|
|
78
|
+
this.name = "HostedApiError";
|
|
79
|
+
this.status = args.status;
|
|
80
|
+
this.code = args.code;
|
|
81
|
+
if (args.retryAfterSeconds !== void 0) this.retryAfterSeconds = args.retryAfterSeconds;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// src/client/hosted-client.ts
|
|
86
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
|
|
87
|
+
var SPACES_PUT_TIMEOUT_MS = 9e5;
|
|
88
|
+
var UPLOAD_ERROR_SNIPPET_CHARS = 200;
|
|
89
|
+
var IN_PROGRESS_RETRY_ATTEMPTS = 5;
|
|
90
|
+
var IN_PROGRESS_RETRY_DELAY_MS = 400;
|
|
91
|
+
function stripMimeParams(contentType) {
|
|
92
|
+
return (contentType.split(";")[0] ?? "").trim();
|
|
93
|
+
}
|
|
94
|
+
var MEDIA_INPUT_FIELD = {
|
|
95
|
+
liveness: "clipKey",
|
|
96
|
+
face_match: "imageKey",
|
|
97
|
+
doc_ocr: "imageKey"
|
|
98
|
+
};
|
|
99
|
+
var HostedClient = class {
|
|
100
|
+
constructor(opts) {
|
|
101
|
+
this.sessionId = null;
|
|
102
|
+
this.bootstrap = null;
|
|
103
|
+
this.apiOrigin = opts.apiOrigin.replace(/\/+$/, "");
|
|
104
|
+
this.sessionToken = opts.sessionToken;
|
|
105
|
+
const impl = opts.fetch;
|
|
106
|
+
this.fetchImpl = (input, init) => (impl ?? globalThis.fetch).call(globalThis, input, init);
|
|
107
|
+
this.requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
108
|
+
this.retryAttempts = opts.inProgressRetry?.attempts ?? IN_PROGRESS_RETRY_ATTEMPTS;
|
|
109
|
+
this.retryDelayMs = opts.inProgressRetry?.delayMs ?? IN_PROGRESS_RETRY_DELAY_MS;
|
|
110
|
+
}
|
|
111
|
+
/** Token-addressed bootstrap — the hosted page's first call. Caches the sessionId. */
|
|
112
|
+
async getSession() {
|
|
113
|
+
const view = await this.api("GET", "/hosted/session");
|
|
114
|
+
this.sessionId = view.sessionId;
|
|
115
|
+
return view;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Record consent once (201; an idempotent replay answers 200 with
|
|
119
|
+
* `alreadyRecorded: true`). Until recorded, media presign/complete AND check
|
|
120
|
+
* submission answer 409 `consent_required`.
|
|
121
|
+
*/
|
|
122
|
+
async recordConsent(locale) {
|
|
123
|
+
return this.api("POST", "/hosted/consent", { body: { locale } });
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Presign a Spaces PUT for a new pending object. `contentType` is stripped of
|
|
127
|
+
* MIME parameters defensively (the allowlist holds bare types; the engine's
|
|
128
|
+
* `clipMimeType` carries codec params) — PUT with the SAME bare type, or the
|
|
129
|
+
* signed-header check rejects the upload.
|
|
130
|
+
*/
|
|
131
|
+
async presign(kind, contentType, contentLength) {
|
|
132
|
+
const id = await this.ensureSessionId();
|
|
133
|
+
const body = { kind, contentType: stripMimeParams(contentType), contentLength };
|
|
134
|
+
return this.api("POST", `/sessions/${id}/media`, { body });
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Bare PUT to the presigned Spaces URL: EXACTLY the signed headers — the
|
|
138
|
+
* declared Content-Type and the blob body. NO `x-session-token` (the
|
|
139
|
+
* signature IS the credential; an extra header breaks nothing but leaks the
|
|
140
|
+
* token to a third-party host) and no JSON envelope.
|
|
141
|
+
*
|
|
142
|
+
* `contentType` is param-stripped here too (idempotent for bare types): the
|
|
143
|
+
* presign strips internally, so a caller composing manually with the engine's
|
|
144
|
+
* parameterful `clipMimeType` would otherwise sign `video/webm` but PUT
|
|
145
|
+
* `video/webm;codecs=…` — a signed-header mismatch S3 rejects at runtime.
|
|
146
|
+
*
|
|
147
|
+
* Timeout: 15 min (`AbortSignal.timeout(900_000)`, matching the presign TTL)
|
|
148
|
+
* rather than the API calls' 30 s — a 15 MB clip on a slow link legitimately
|
|
149
|
+
* exceeds 30 s, but a stalled in-flight socket must still be bounded (the TTL
|
|
150
|
+
* alone only bounds when a PUT can START).
|
|
151
|
+
*
|
|
152
|
+
* Failures are ALWAYS typed: non-2xx throws {@link HostedApiError} with the
|
|
153
|
+
* client-synthesized `upload_failed` (message carries a truncated S3 body
|
|
154
|
+
* snippet — e.g. SignatureDoesNotMatch); a mid-transfer drop → `network_error`;
|
|
155
|
+
* the timeout → `timeout`.
|
|
156
|
+
*/
|
|
157
|
+
async uploadToSpaces(uploadUrl, blob, contentType) {
|
|
158
|
+
let res;
|
|
159
|
+
try {
|
|
160
|
+
res = await this.fetchImpl(uploadUrl, {
|
|
161
|
+
method: "PUT",
|
|
162
|
+
headers: { "content-type": stripMimeParams(contentType) },
|
|
163
|
+
body: blob,
|
|
164
|
+
signal: AbortSignal.timeout(SPACES_PUT_TIMEOUT_MS)
|
|
165
|
+
});
|
|
166
|
+
} catch (err) {
|
|
167
|
+
throw asTransportError(err);
|
|
168
|
+
}
|
|
169
|
+
if (!res.ok) {
|
|
170
|
+
const snippet = (await res.text().catch(() => "")).slice(0, UPLOAD_ERROR_SNIPPET_CHARS);
|
|
171
|
+
throw new HostedApiError({
|
|
172
|
+
status: res.status,
|
|
173
|
+
code: "upload_failed",
|
|
174
|
+
message: `media upload failed (HTTP ${res.status})${snippet ? `: ${snippet}` : ""}`
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** Finalize an upload: the backend sniffs + promotes; returns the PROMOTED key to submit. */
|
|
179
|
+
async completeMedia(key) {
|
|
180
|
+
const id = await this.ensureSessionId();
|
|
181
|
+
return this.api("POST", `/sessions/${id}/media/complete`, {
|
|
182
|
+
body: { key }
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Submit one check. Sends `{input, forceOutcome?}` (SubmitCheckSchema) and the
|
|
187
|
+
* optional `x-idempotency-key` header. Returns the whitelisted SessionView.
|
|
188
|
+
*/
|
|
189
|
+
async submitCheck(checkType, input, opts = {}) {
|
|
190
|
+
const id = await this.ensureSessionId();
|
|
191
|
+
const body = {
|
|
192
|
+
input,
|
|
193
|
+
...opts.forceOutcome !== void 0 ? { forceOutcome: opts.forceOutcome } : {}
|
|
194
|
+
};
|
|
195
|
+
return this.api("POST", `/sessions/${id}/checks/${checkType}`, {
|
|
196
|
+
body,
|
|
197
|
+
...opts.idempotencyKey !== void 0 ? { idempotencyKey: opts.idempotencyKey } : {}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Convenience composition: presign → PUT → complete → submit, passing the
|
|
202
|
+
* PROMOTED key (from complete's response — never the pending presign key)
|
|
203
|
+
* into the check's media input field (`clipKey` for liveness, `imageKey` for
|
|
204
|
+
* face_match/doc_ocr). `extraInput` carries the remaining input fields
|
|
205
|
+
* (liveness `attestation`, doc_ocr `side`). The media content type is
|
|
206
|
+
* param-stripped ONCE and used for both presign and PUT, so the signed
|
|
207
|
+
* header always matches.
|
|
208
|
+
*/
|
|
209
|
+
async uploadAndSubmit(checkType, media, extraInput, opts = {}) {
|
|
210
|
+
const bareType = stripMimeParams(media.contentType);
|
|
211
|
+
const presigned = await this.presign(media.kind, bareType, media.blob.size);
|
|
212
|
+
await this.uploadToSpaces(presigned.uploadUrl, media.blob, bareType);
|
|
213
|
+
const completed = await this.completeMedia(presigned.key);
|
|
214
|
+
const input = {
|
|
215
|
+
...extraInput,
|
|
216
|
+
[MEDIA_INPUT_FIELD[checkType]]: completed.key
|
|
217
|
+
};
|
|
218
|
+
return this.submitCheck(checkType, input, opts);
|
|
219
|
+
}
|
|
220
|
+
// ── transport ──────────────────────────────────────────────────────────────
|
|
221
|
+
/** The token's own sessionId — cached from getSession(), lazily bootstrapped. */
|
|
222
|
+
async ensureSessionId() {
|
|
223
|
+
if (this.sessionId !== null) return this.sessionId;
|
|
224
|
+
this.bootstrap ?? (this.bootstrap = this.getSession().finally(() => {
|
|
225
|
+
this.bootstrap = null;
|
|
226
|
+
}));
|
|
227
|
+
return (await this.bootstrap).sessionId;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* One API request: `${apiOrigin}/api/v2${path}`, `x-session-token` always,
|
|
231
|
+
* AbortSignal timeout (30 s default), `{success, data}` unwrap on 2xx, flat
|
|
232
|
+
* `{code, message}` → {@link HostedApiError} on non-2xx. EVERY failure is
|
|
233
|
+
* typed: a rejected fetch → `network_error`, the timeout → `timeout`, an
|
|
234
|
+
* unparseable 2xx body → `unknown_error` — the page never sees a raw
|
|
235
|
+
* TypeError/DOMException. The ONLY automatic retry is 409
|
|
236
|
+
* `idempotency_in_progress` (bounded, short delay).
|
|
237
|
+
*/
|
|
238
|
+
async api(method, path, opts = {}) {
|
|
239
|
+
const url = `${this.apiOrigin}/api/v2${path}`;
|
|
240
|
+
const headers = { "x-session-token": this.sessionToken };
|
|
241
|
+
if (opts.body !== void 0) headers["content-type"] = "application/json";
|
|
242
|
+
if (opts.idempotencyKey !== void 0) headers["x-idempotency-key"] = opts.idempotencyKey;
|
|
243
|
+
for (let attempt = 0; ; attempt++) {
|
|
244
|
+
let res;
|
|
245
|
+
try {
|
|
246
|
+
res = await this.fetchImpl(url, {
|
|
247
|
+
method,
|
|
248
|
+
headers,
|
|
249
|
+
...opts.body !== void 0 ? { body: JSON.stringify(opts.body) } : {},
|
|
250
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs)
|
|
251
|
+
});
|
|
252
|
+
} catch (err2) {
|
|
253
|
+
throw asTransportError(err2);
|
|
254
|
+
}
|
|
255
|
+
if (res.ok) {
|
|
256
|
+
let raw;
|
|
257
|
+
try {
|
|
258
|
+
raw = await res.json();
|
|
259
|
+
} catch {
|
|
260
|
+
throw new HostedApiError({
|
|
261
|
+
status: res.status,
|
|
262
|
+
code: "unknown_error",
|
|
263
|
+
message: `response body was not valid JSON (HTTP ${res.status})`
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
return unwrap(raw);
|
|
267
|
+
}
|
|
268
|
+
const err = await parseErrorResponse(res);
|
|
269
|
+
if (err.code === "idempotency_in_progress" && attempt < this.retryAttempts) {
|
|
270
|
+
if (this.retryDelayMs > 0) await sleep(this.retryDelayMs);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
throw err;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
function asTransportError(err) {
|
|
278
|
+
const name = typeof err === "object" && err !== null ? err.name : void 0;
|
|
279
|
+
if (name === "TimeoutError") {
|
|
280
|
+
return new HostedApiError({ status: 0, code: "timeout", message: "request timed out" });
|
|
281
|
+
}
|
|
282
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
283
|
+
return new HostedApiError({
|
|
284
|
+
status: 0,
|
|
285
|
+
code: "network_error",
|
|
286
|
+
message: `network request failed: ${detail}`
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
function unwrap(raw) {
|
|
290
|
+
if (raw !== null && typeof raw === "object" && "success" in raw && "data" in raw) {
|
|
291
|
+
return raw.data;
|
|
292
|
+
}
|
|
293
|
+
return raw;
|
|
294
|
+
}
|
|
295
|
+
async function parseErrorResponse(res) {
|
|
296
|
+
let code = "unknown_error";
|
|
297
|
+
let message = `request failed (HTTP ${res.status})`;
|
|
298
|
+
try {
|
|
299
|
+
const body = await res.json();
|
|
300
|
+
if (body !== null && typeof body === "object") {
|
|
301
|
+
const env = body;
|
|
302
|
+
if (typeof env.code === "string" && env.code.length > 0) code = env.code;
|
|
303
|
+
if (typeof env.message === "string" && env.message.length > 0) message = env.message;
|
|
304
|
+
}
|
|
305
|
+
} catch {
|
|
306
|
+
}
|
|
307
|
+
const retryAfterRaw = res.headers.get("retry-after");
|
|
308
|
+
const retryAfter = retryAfterRaw === null ? Number.NaN : Number.parseInt(retryAfterRaw, 10);
|
|
309
|
+
return new HostedApiError({
|
|
310
|
+
status: res.status,
|
|
311
|
+
code,
|
|
312
|
+
message,
|
|
313
|
+
...Number.isFinite(retryAfter) && retryAfter >= 0 ? { retryAfterSeconds: retryAfter } : {}
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
function sleep(ms) {
|
|
317
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export {
|
|
321
|
+
KNOWN_CODES,
|
|
322
|
+
isKnownCode,
|
|
323
|
+
HostedApiError,
|
|
324
|
+
stripMimeParams,
|
|
325
|
+
HostedClient
|
|
326
|
+
};
|
|
327
|
+
//# sourceMappingURL=chunk-YVFF4Q4G.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/client/errors.ts","../src/client/hosted-client.ts"],"sourcesContent":["// Public error map for the session-token surface (Plan 6 Task 5).\n//\n// The backend's AllExceptionsFilter (audience 'public') emits the FLAT envelope\n// `{code, message}` — no details, no request_id, no traceId. `code` is the\n// snake_cased `error` key of the thrown HttpException body (extractError →\n// camelToSnake). 429 responses additionally carry a `Retry-After` header in\n// SECONDS (SessionTokenGuard / RateLimitGuard).\n//\n// KNOWN_CODES below is the VERIFIED set reachable through the five hosted\n// endpoints (grep of `error: '…'` sites in identity-backend @ Tasks 0+1+2 —\n// hosted.controller, checks.controller, media.{controller,service},\n// sessions.service submit flow, session-token.guard, valibot.pipe,\n// check-executor registry, idempotency.interceptor, attestation mapping),\n// plus four CLIENT-SYNTHESIZED codes that never come from the backend:\n// - 'unknown_error' — a response body that was not parseable JSON (proxy HTML\n// on an error, or a malformed 2xx body)\n// - 'upload_failed' — the bare Spaces PUT answered non-2xx (S3 XML, no envelope)\n// - 'network_error' — fetch itself rejected: connection drop / DNS / CORS /\n// mid-transfer failure (status: 0 — no response arrived)\n// - 'timeout' — the request's AbortSignal.timeout fired (status: 0)\n// Task 7's error-copy map must carry entries for all four from day one.\n//\n// Deliberately EXCLUDED (verified unreachable on this surface today):\n// - payment_required / account_suspended — BillingInterceptor gates only\n// handlers decorated @BillableCheck, and no handler is decorated yet.\n// - not_found — the generic unmatched-route 404; a correctly configured client\n// never produces it. Unknown codes route through `isKnownCode() === false`.\n\n/**\n * Every error code the hosted page can receive from the session-token surface.\n * A closed string-literal union — switch over {@link KnownCode} with a `never`\n * default arm for compile-checked exhaustiveness.\n */\nexport const KNOWN_CODES = [\n // auth / addressing (session-token.guard, controllers' own-session guards)\n \"session_token_invalid\", // 401 — missing/invalid/expired/revoked token\n \"session_token_required\", // 403 — non-session-token principal on a hosted route (defensive)\n \"session_token_session_mismatch\", // 403 — route :id differs from the token's bound session\n \"session_not_found\", // 404 — token's session missing/foreign (findOwned)\n // rate limiting (session-token.guard — per-token + per-IP buckets)\n \"rate_limit_exceeded\", // 429 + Retry-After header (seconds)\n // consent gate (Task 0 — submit AND media presign/complete)\n \"consent_required\", // 409 — record consent before uploading/submitting\n // submit flow (sessions.service.submitCheck)\n \"session_not_active\", // 409 — session is terminal/review; no further submissions\n \"check_not_found\", // 404 — session has no such checkType\n \"check_already_finalized\", // 409 — re-submit of a terminal/review check\n \"check_out_of_order\", // 409 — only the frontier check accepts a submission\n \"check_type_unsupported\", // 400 — no registered executor for the checkType\n \"force_outcome_forbidden\", // 403 — forceOutcome on a live session (plan D-C)\n \"attestation_invalid\", // 400 (Task 1) — Turnstile/attestation failed; reload and retry\n \"nni_locked\", // 409 (Plan A Task 2) — session is bound to a server-provided NNI; a different client NNI was sent\n // media (media.service)\n \"media_content_type_not_allowed\", // 400 — outside the per-kind MIME allowlist\n \"media_too_large\", // 413 — over the per-kind byte cap\n \"media_key_invalid\", // 400 — not a well-formed pending key\n \"media_key_forbidden\", // 403 — key names another session's pending space\n \"media_not_found\", // 404 — no uploaded object behind the key\n \"media_unreadable\", // 422 — magic-byte sniff rejected the object\n \"media_session_invalid\", // 400 — sessionId unusable as a key segment (never in practice)\n // idempotency (idempotency.interceptor — Task 2)\n \"idempotency_key_collision\", // 409 — same key, different body hash\n \"idempotency_in_progress\", // 409 — concurrent identical request mid-flight (client auto-retries)\n \"idempotency_key_required\", // 400 — only on @Idempotent({required:true}) routes (none hosted yet)\n // validation + fallback\n \"validation_error\", // 400 — ValibotPipe rejection (camelToSnake of 'ValidationError')\n \"internal_error\", // 500 — unhandled server error\n // client-synthesized (never sent by the backend)\n \"unknown_error\", // unparseable (non-JSON) response body — error OR 2xx (defensive)\n \"upload_failed\", // the bare Spaces PUT answered non-2xx\n \"network_error\", // fetch rejected: connection drop / DNS / CORS / mid-transfer (status 0)\n \"timeout\", // AbortSignal.timeout fired — API calls 30 s default, Spaces PUT 15 min (status 0)\n] as const;\n\n/** Closed union of {@link KNOWN_CODES} — exhaustively switchable. */\nexport type KnownCode = (typeof KNOWN_CODES)[number];\n\n/** Narrow an arbitrary wire code to the known union (unknown codes stay `string`). */\nexport function isKnownCode(code: string): code is KnownCode {\n return (KNOWN_CODES as readonly string[]).includes(code);\n}\n\n/**\n * Typed transport error: EVERY failure surfaces as one of these — non-2xx\n * responses, rejected fetches, timeouts, and unparseable bodies alike. The\n * hosted page never sees a raw TypeError/DOMException from the client.\n *\n * - `code` — the public envelope's `code`, or a client-synthesized fallback\n * ('unknown_error' / 'upload_failed' / 'network_error' / 'timeout'). Typed\n * `string` because the wire may grow codes; narrow with {@link isKnownCode}\n * before an exhaustive switch.\n * - `status` — the HTTP status, or 0 when no response arrived at all\n * ('network_error' / 'timeout').\n * - `retryAfterSeconds` — parsed from the `Retry-After` header when present\n * (rate-limit 429s always set it).\n */\nexport class HostedApiError extends Error {\n readonly status: number;\n readonly code: string;\n readonly retryAfterSeconds?: number;\n\n constructor(args: { status: number; code: string; message: string; retryAfterSeconds?: number }) {\n super(args.message);\n this.name = \"HostedApiError\";\n this.status = args.status;\n this.code = args.code;\n if (args.retryAfterSeconds !== undefined) this.retryAfterSeconds = args.retryAfterSeconds;\n }\n}\n","// Typed API client for the hosted session-token surface (Plan 6 Task 5).\n// Framework-free browser code — fetch + AbortSignal only, same zero-React rule\n// as core/. Hand-authored against the CURRENT identity-backend (Tasks 0+1+2);\n// see types.ts / errors.ts for the verified wire shapes.\nimport { HostedApiError } from \"./errors\";\nimport type {\n CheckInputByType,\n CheckType,\n CompleteMediaResult,\n ConsentLocale,\n HostedSessionView,\n MediaKind,\n PresignRequest,\n PresignUploadResult,\n RecordConsentResult,\n SessionView,\n SubmitCheckBody,\n} from \"./types\";\n\n/** Default per-request timeout on API calls (override via `requestTimeoutMs`). */\nconst DEFAULT_REQUEST_TIMEOUT_MS = 30_000;\n\n/**\n * Timeout on the Spaces PUT — matches the presign TTL (900 s): the signature is\n * unusable after that anyway, and a 15 MB clip at 20 KB/s still finishes in\n * ~13 min. Bounds a stalled in-flight socket (the TTL alone only bounds when a\n * PUT can START, not how long a hung transfer may sit).\n */\nconst SPACES_PUT_TIMEOUT_MS = 900_000;\n\n/** Max chars of an S3 error body quoted into an upload_failed message. */\nconst UPLOAD_ERROR_SNIPPET_CHARS = 200;\n\n/** Bounded auto-retry defaults for 409 `idempotency_in_progress` (retries AFTER the first try). */\nconst IN_PROGRESS_RETRY_ATTEMPTS = 5;\nconst IN_PROGRESS_RETRY_DELAY_MS = 400;\n\n/**\n * Strip MIME parameters: `video/webm;codecs=vp8,opus` → `video/webm`.\n *\n * The capture engine (Task 4) emits `clipMimeType` WITH codec parameters, but\n * the presign allowlist holds BARE types only (['video/webm','video/mp4'] /\n * ['image/jpeg','image/png']) and the presigned PUT signs the exact declared\n * Content-Type. Exported so pages can normalize once and stay byte-consistent\n * between presign and PUT.\n */\nexport function stripMimeParams(contentType: string): string {\n return (contentType.split(\";\")[0] ?? \"\").trim();\n}\n\n/** Media-bearing check types → the input field the promoted key goes into. */\nconst MEDIA_INPUT_FIELD = {\n liveness: \"clipKey\",\n face_match: \"imageKey\",\n doc_ocr: \"imageKey\",\n} as const;\n\n/** Check types that take an uploaded media key (population_registry does not). */\nexport type MediaCheckType = keyof typeof MEDIA_INPUT_FIELD;\n\nexport interface SubmitOptions {\n /**\n * Sent as `x-idempotency-key` (the header IdempotencyInterceptor reads).\n * One key per LOGICAL submit — a flaky-network retry with the same key\n * replays the first response without burning another attempt.\n */\n idempotencyKey?: string;\n /** SANDBOX-ONLY deterministic override (plan D-C); a live session 403s it. */\n forceOutcome?: \"pass\" | \"fail\";\n}\n\nexport interface UploadMedia {\n kind: MediaKind;\n blob: Blob;\n /** May carry codec params (engine `clipMimeType`) — stripped before presign AND PUT. */\n contentType: string;\n}\n\nexport interface HostedClientOptions {\n /** API origin, e.g. `https://api.sekyro.ai` — paths are `${apiOrigin}/api/v2/...`. */\n apiOrigin: string;\n /** The raw session token from the hosted URL (short code; legacy `cst_` during its TTL) — sent as `x-session-token` on every API call. */\n sessionToken: string;\n /** Injection seam (tests / non-window contexts). Defaults to `globalThis.fetch`. */\n fetch?: typeof globalThis.fetch;\n /** Per-request timeout for API calls (NOT the Spaces PUT). Default 30 000 ms. */\n requestTimeoutMs?: number;\n /**\n * Tuning for the ONLY automatic retry the client performs: 409\n * `idempotency_in_progress` (a concurrent identical request is mid-flight\n * server-side; the real response lands once it finishes). `attempts` counts\n * retries AFTER the first try. Defaults: 5 × 400 ms.\n */\n inProgressRetry?: { attempts?: number; delayMs?: number };\n}\n\n/**\n * Hand-authored typed client for the five session-token endpoints:\n *\n * GET /api/v2/hosted/session → {@link getSession}\n * POST /api/v2/hosted/consent → {@link recordConsent}\n * POST /api/v2/sessions/:id/media → {@link presign}\n * POST /api/v2/sessions/:id/media/complete → {@link completeMedia}\n * POST /api/v2/sessions/:id/checks/:checkType → {@link submitCheck}\n *\n * plus the bare presigned Spaces PUT ({@link uploadToSpaces}) and the\n * presign→PUT→complete→submit composition ({@link uploadAndSubmit}).\n *\n * Envelopes (verified against sessions-docs.e2e-spec.ts):\n * - success: `{success: true, data}` — unwrapped here, methods return the payload;\n * - error: flat `{code, message}` → thrown as {@link HostedApiError}.\n *\n * The media/checks routes are `:id`-addressed while the token itself is the\n * only credential the page starts with — the client learns its sessionId from\n * `GET /hosted/session` (token-addressed) and lazily bootstraps it when a\n * `:id` route is called first.\n *\n * No automatic retry EXCEPT bounded short-delay retry on 409\n * `idempotency_in_progress`. Everything else — including 429 with its parsed\n * `retryAfterSeconds` — is surfaced to the page to handle.\n */\nexport class HostedClient {\n private readonly apiOrigin: string;\n private readonly sessionToken: string;\n private readonly fetchImpl: typeof globalThis.fetch;\n private readonly requestTimeoutMs: number;\n private readonly retryAttempts: number;\n private readonly retryDelayMs: number;\n\n private sessionId: string | null = null;\n private bootstrap: Promise<HostedSessionView> | null = null;\n\n constructor(opts: HostedClientOptions) {\n this.apiOrigin = opts.apiOrigin.replace(/\\/+$/, \"\");\n this.sessionToken = opts.sessionToken;\n // Always wrap and `.call(globalThis, …)`: browsers throw \"Illegal\n // invocation\" when fetch runs with a `this` that isn't the global — both\n // for the default AND for an injected `window.fetch`.\n const impl = opts.fetch;\n this.fetchImpl = (input, init) => (impl ?? globalThis.fetch).call(globalThis, input, init);\n this.requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;\n this.retryAttempts = opts.inProgressRetry?.attempts ?? IN_PROGRESS_RETRY_ATTEMPTS;\n this.retryDelayMs = opts.inProgressRetry?.delayMs ?? IN_PROGRESS_RETRY_DELAY_MS;\n }\n\n /** Token-addressed bootstrap — the hosted page's first call. Caches the sessionId. */\n async getSession(): Promise<HostedSessionView> {\n const view = await this.api<HostedSessionView>(\"GET\", \"/hosted/session\");\n this.sessionId = view.sessionId;\n return view;\n }\n\n /**\n * Record consent once (201; an idempotent replay answers 200 with\n * `alreadyRecorded: true`). Until recorded, media presign/complete AND check\n * submission answer 409 `consent_required`.\n */\n async recordConsent(locale: ConsentLocale): Promise<RecordConsentResult> {\n return this.api<RecordConsentResult>(\"POST\", \"/hosted/consent\", { body: { locale } });\n }\n\n /**\n * Presign a Spaces PUT for a new pending object. `contentType` is stripped of\n * MIME parameters defensively (the allowlist holds bare types; the engine's\n * `clipMimeType` carries codec params) — PUT with the SAME bare type, or the\n * signed-header check rejects the upload.\n */\n async presign(\n kind: MediaKind,\n contentType: string,\n contentLength: number,\n ): Promise<PresignUploadResult> {\n const id = await this.ensureSessionId();\n const body: PresignRequest = { kind, contentType: stripMimeParams(contentType), contentLength };\n return this.api<PresignUploadResult>(\"POST\", `/sessions/${id}/media`, { body });\n }\n\n /**\n * Bare PUT to the presigned Spaces URL: EXACTLY the signed headers — the\n * declared Content-Type and the blob body. NO `x-session-token` (the\n * signature IS the credential; an extra header breaks nothing but leaks the\n * token to a third-party host) and no JSON envelope.\n *\n * `contentType` is param-stripped here too (idempotent for bare types): the\n * presign strips internally, so a caller composing manually with the engine's\n * parameterful `clipMimeType` would otherwise sign `video/webm` but PUT\n * `video/webm;codecs=…` — a signed-header mismatch S3 rejects at runtime.\n *\n * Timeout: 15 min (`AbortSignal.timeout(900_000)`, matching the presign TTL)\n * rather than the API calls' 30 s — a 15 MB clip on a slow link legitimately\n * exceeds 30 s, but a stalled in-flight socket must still be bounded (the TTL\n * alone only bounds when a PUT can START).\n *\n * Failures are ALWAYS typed: non-2xx throws {@link HostedApiError} with the\n * client-synthesized `upload_failed` (message carries a truncated S3 body\n * snippet — e.g. SignatureDoesNotMatch); a mid-transfer drop → `network_error`;\n * the timeout → `timeout`.\n */\n async uploadToSpaces(uploadUrl: string, blob: Blob, contentType: string): Promise<void> {\n let res: Response;\n try {\n res = await this.fetchImpl(uploadUrl, {\n method: \"PUT\",\n headers: { \"content-type\": stripMimeParams(contentType) },\n body: blob,\n signal: AbortSignal.timeout(SPACES_PUT_TIMEOUT_MS),\n });\n } catch (err) {\n throw asTransportError(err);\n }\n if (!res.ok) {\n const snippet = (await res.text().catch(() => \"\")).slice(0, UPLOAD_ERROR_SNIPPET_CHARS);\n throw new HostedApiError({\n status: res.status,\n code: \"upload_failed\",\n message: `media upload failed (HTTP ${res.status})${snippet ? `: ${snippet}` : \"\"}`,\n });\n }\n }\n\n /** Finalize an upload: the backend sniffs + promotes; returns the PROMOTED key to submit. */\n async completeMedia(key: string): Promise<CompleteMediaResult> {\n const id = await this.ensureSessionId();\n return this.api<CompleteMediaResult>(\"POST\", `/sessions/${id}/media/complete`, {\n body: { key },\n });\n }\n\n /**\n * Submit one check. Sends `{input, forceOutcome?}` (SubmitCheckSchema) and the\n * optional `x-idempotency-key` header. Returns the whitelisted SessionView.\n */\n async submitCheck<T extends CheckType>(\n checkType: T,\n input: CheckInputByType[T],\n opts: SubmitOptions = {},\n ): Promise<SessionView> {\n const id = await this.ensureSessionId();\n const body: SubmitCheckBody = {\n input: input as unknown as Record<string, unknown>,\n ...(opts.forceOutcome !== undefined ? { forceOutcome: opts.forceOutcome } : {}),\n };\n return this.api<SessionView>(\"POST\", `/sessions/${id}/checks/${checkType}`, {\n body,\n ...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}),\n });\n }\n\n /**\n * Convenience composition: presign → PUT → complete → submit, passing the\n * PROMOTED key (from complete's response — never the pending presign key)\n * into the check's media input field (`clipKey` for liveness, `imageKey` for\n * face_match/doc_ocr). `extraInput` carries the remaining input fields\n * (liveness `attestation`, doc_ocr `side`). The media content type is\n * param-stripped ONCE and used for both presign and PUT, so the signed\n * header always matches.\n */\n async uploadAndSubmit<T extends MediaCheckType>(\n checkType: T,\n media: UploadMedia,\n extraInput: Omit<CheckInputByType[T], (typeof MEDIA_INPUT_FIELD)[T]>,\n opts: SubmitOptions = {},\n ): Promise<SessionView> {\n const bareType = stripMimeParams(media.contentType);\n const presigned = await this.presign(media.kind, bareType, media.blob.size);\n await this.uploadToSpaces(presigned.uploadUrl, media.blob, bareType);\n const completed = await this.completeMedia(presigned.key);\n const input = {\n ...extraInput,\n [MEDIA_INPUT_FIELD[checkType]]: completed.key,\n } as unknown as CheckInputByType[T];\n return this.submitCheck(checkType, input, opts);\n }\n\n // ── transport ──────────────────────────────────────────────────────────────\n\n /** The token's own sessionId — cached from getSession(), lazily bootstrapped. */\n private async ensureSessionId(): Promise<string> {\n if (this.sessionId !== null) return this.sessionId;\n // Dedupe concurrent bootstraps; reset on settle so a failure can be retried.\n this.bootstrap ??= this.getSession().finally(() => {\n this.bootstrap = null;\n });\n return (await this.bootstrap).sessionId;\n }\n\n /**\n * One API request: `${apiOrigin}/api/v2${path}`, `x-session-token` always,\n * AbortSignal timeout (30 s default), `{success, data}` unwrap on 2xx, flat\n * `{code, message}` → {@link HostedApiError} on non-2xx. EVERY failure is\n * typed: a rejected fetch → `network_error`, the timeout → `timeout`, an\n * unparseable 2xx body → `unknown_error` — the page never sees a raw\n * TypeError/DOMException. The ONLY automatic retry is 409\n * `idempotency_in_progress` (bounded, short delay).\n */\n private async api<T>(\n method: \"GET\" | \"POST\",\n path: string,\n opts: { body?: unknown; idempotencyKey?: string } = {},\n ): Promise<T> {\n const url = `${this.apiOrigin}/api/v2${path}`;\n const headers: Record<string, string> = { \"x-session-token\": this.sessionToken };\n if (opts.body !== undefined) headers[\"content-type\"] = \"application/json\";\n if (opts.idempotencyKey !== undefined) headers[\"x-idempotency-key\"] = opts.idempotencyKey;\n\n for (let attempt = 0; ; attempt++) {\n let res: Response;\n try {\n res = await this.fetchImpl(url, {\n method,\n headers,\n ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),\n signal: AbortSignal.timeout(this.requestTimeoutMs),\n });\n } catch (err) {\n throw asTransportError(err);\n }\n\n if (res.ok) {\n let raw: unknown;\n try {\n raw = await res.json();\n } catch {\n throw new HostedApiError({\n status: res.status,\n code: \"unknown_error\",\n message: `response body was not valid JSON (HTTP ${res.status})`,\n });\n }\n return unwrap<T>(raw);\n }\n\n const err = await parseErrorResponse(res);\n if (err.code === \"idempotency_in_progress\" && attempt < this.retryAttempts) {\n if (this.retryDelayMs > 0) await sleep(this.retryDelayMs);\n continue;\n }\n throw err;\n }\n }\n}\n\n/**\n * Normalize a rejected fetch into the typed contract (status 0 — no response\n * arrived): AbortSignal.timeout aborts with a DOMException named 'TimeoutError'\n * → `timeout`; everything else (TypeError on connection drop / DNS / CORS /\n * mid-transfer failure) → `network_error`. Duck-typed on `.name` because\n * DOMException does not inherit Error in every environment (e.g. jsdom).\n */\nfunction asTransportError(err: unknown): HostedApiError {\n const name = typeof err === \"object\" && err !== null ? (err as { name?: unknown }).name : undefined;\n if (name === \"TimeoutError\") {\n return new HostedApiError({ status: 0, code: \"timeout\", message: \"request timed out\" });\n }\n const detail = err instanceof Error ? err.message : String(err);\n return new HostedApiError({\n status: 0,\n code: \"network_error\",\n message: `network request failed: ${detail}`,\n });\n}\n\n/**\n * Unwrap the global success envelope `{success: true, data}` (ResponseTransform-\n * Interceptor wraps every object body that lacks a `success` key). Defensive:\n * a body without the envelope is returned as-is.\n */\nfunction unwrap<T>(raw: unknown): T {\n if (raw !== null && typeof raw === \"object\" && \"success\" in raw && \"data\" in raw) {\n return (raw as { data: T }).data;\n }\n return raw as T;\n}\n\n/**\n * Parse a non-2xx response into a {@link HostedApiError}: the public envelope\n * is flat `{code, message}`; a non-JSON body (proxy HTML, etc.) degrades to the\n * client-synthesized `unknown_error`. `Retry-After` (seconds) is parsed when\n * present — rate-limit 429s always set it.\n */\nasync function parseErrorResponse(res: Response): Promise<HostedApiError> {\n let code = \"unknown_error\";\n let message = `request failed (HTTP ${res.status})`;\n try {\n const body: unknown = await res.json();\n if (body !== null && typeof body === \"object\") {\n const env = body as { code?: unknown; message?: unknown };\n if (typeof env.code === \"string\" && env.code.length > 0) code = env.code;\n if (typeof env.message === \"string\" && env.message.length > 0) message = env.message;\n }\n } catch {\n // Not JSON — keep the defensive fallback.\n }\n const retryAfterRaw = res.headers.get(\"retry-after\");\n const retryAfter = retryAfterRaw === null ? Number.NaN : Number.parseInt(retryAfterRaw, 10);\n return new HostedApiError({\n status: res.status,\n code,\n message,\n ...(Number.isFinite(retryAfter) && retryAfter >= 0 ? { retryAfterSeconds: retryAfter } : {}),\n });\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n"],"mappings":"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","names":["err"]}
|