@cloudware-de/gatekeeper-widget 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +17 -0
  2. package/dist/client/instance.d.ts +35 -0
  3. package/dist/client/instance.d.ts.map +1 -0
  4. package/dist/client/instance.js +93 -0
  5. package/dist/client/instance.js.map +1 -0
  6. package/dist/client/transport.d.ts +19 -0
  7. package/dist/client/transport.d.ts.map +1 -0
  8. package/dist/client/transport.js +41 -0
  9. package/dist/client/transport.js.map +1 -0
  10. package/dist/client/urls.d.ts +12 -0
  11. package/dist/client/urls.d.ts.map +1 -0
  12. package/dist/client/urls.js +27 -0
  13. package/dist/client/urls.js.map +1 -0
  14. package/dist/copy/load-notice.d.ts +8 -0
  15. package/dist/copy/load-notice.d.ts.map +1 -0
  16. package/dist/copy/load-notice.js +8 -0
  17. package/dist/copy/load-notice.js.map +1 -0
  18. package/dist/element/honeypot.d.ts +5 -0
  19. package/dist/element/honeypot.d.ts.map +1 -0
  20. package/dist/element/honeypot.js +5 -0
  21. package/dist/element/honeypot.js.map +1 -0
  22. package/dist/element/session.d.ts +32 -0
  23. package/dist/element/session.d.ts.map +1 -0
  24. package/dist/element/session.js +90 -0
  25. package/dist/element/session.js.map +1 -0
  26. package/dist/element/start.d.ts +25 -0
  27. package/dist/element/start.d.ts.map +1 -0
  28. package/dist/element/start.js +85 -0
  29. package/dist/element/start.js.map +1 -0
  30. package/dist/element/states.d.ts +26 -0
  31. package/dist/element/states.d.ts.map +1 -0
  32. package/dist/element/states.js +58 -0
  33. package/dist/element/states.js.map +1 -0
  34. package/dist/element/templates/host.d.ts +7 -0
  35. package/dist/element/templates/host.d.ts.map +1 -0
  36. package/dist/element/templates/host.js +11 -0
  37. package/dist/element/templates/host.js.map +1 -0
  38. package/dist/element/templates/names.d.ts +11 -0
  39. package/dist/element/templates/names.d.ts.map +1 -0
  40. package/dist/element/templates/names.js +20 -0
  41. package/dist/element/templates/names.js.map +1 -0
  42. package/dist/element/templates/sheets.d.ts +10 -0
  43. package/dist/element/templates/sheets.d.ts.map +1 -0
  44. package/dist/element/templates/sheets.js +26 -0
  45. package/dist/element/templates/sheets.js.map +1 -0
  46. package/dist/element/widget.d.ts +75 -0
  47. package/dist/element/widget.d.ts.map +1 -0
  48. package/dist/element/widget.js +852 -0
  49. package/dist/element/widget.js.map +1 -0
  50. package/dist/envelope/run.d.ts +20 -0
  51. package/dist/envelope/run.d.ts.map +1 -0
  52. package/dist/envelope/run.js +104 -0
  53. package/dist/envelope/run.js.map +1 -0
  54. package/dist/index.d.ts +12 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +18 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/pow/messages.d.ts +24 -0
  59. package/dist/pow/messages.d.ts.map +1 -0
  60. package/dist/pow/messages.js +2 -0
  61. package/dist/pow/messages.js.map +1 -0
  62. package/dist/pow/oom.d.ts +7 -0
  63. package/dist/pow/oom.d.ts.map +1 -0
  64. package/dist/pow/oom.js +19 -0
  65. package/dist/pow/oom.js.map +1 -0
  66. package/dist/pow/run.d.ts +13 -0
  67. package/dist/pow/run.d.ts.map +1 -0
  68. package/dist/pow/run.js +73 -0
  69. package/dist/pow/run.js.map +1 -0
  70. package/dist/pow/solve.d.ts +32 -0
  71. package/dist/pow/solve.d.ts.map +1 -0
  72. package/dist/pow/solve.js +114 -0
  73. package/dist/pow/solve.js.map +1 -0
  74. package/dist/pow/wasm-url.d.ts +13 -0
  75. package/dist/pow/wasm-url.d.ts.map +1 -0
  76. package/dist/pow/wasm-url.js +16 -0
  77. package/dist/pow/wasm-url.js.map +1 -0
  78. package/dist/pow/worker.d.ts +2 -0
  79. package/dist/pow/worker.d.ts.map +1 -0
  80. package/dist/pow/worker.js +43 -0
  81. package/dist/pow/worker.js.map +1 -0
  82. package/dist/runtime/workers.d.ts +11 -0
  83. package/dist/runtime/workers.d.ts.map +1 -0
  84. package/dist/runtime/workers.js +36 -0
  85. package/dist/runtime/workers.js.map +1 -0
  86. package/package.json +33 -3
  87. package/src/client/instance.ts +140 -0
  88. package/src/client/transport.ts +55 -0
  89. package/src/client/urls.ts +38 -0
  90. package/src/copy/load-notice.ts +8 -0
  91. package/src/element/honeypot.ts +4 -0
  92. package/src/element/session.ts +131 -0
  93. package/src/element/start.ts +99 -0
  94. package/src/element/states.ts +73 -0
  95. package/src/element/templates/compact.css +4 -0
  96. package/src/element/templates/contrast.css +5 -0
  97. package/src/element/templates/host.ts +11 -0
  98. package/src/element/templates/names.ts +23 -0
  99. package/src/element/templates/sheets.ts +28 -0
  100. package/src/element/templates/standard.css +5 -0
  101. package/src/element/widget.ts +982 -0
  102. package/src/envelope/run.ts +126 -0
  103. package/src/index.ts +77 -0
  104. package/src/pow/messages.ts +21 -0
  105. package/src/pow/oom.ts +20 -0
  106. package/src/pow/run.ts +78 -0
  107. package/src/pow/solve.ts +136 -0
  108. package/src/pow/wasm-url.ts +27 -0
  109. package/src/pow/worker.ts +46 -0
  110. package/src/runtime/workers.ts +40 -0
  111. package/README.md +0 -3
@@ -0,0 +1,852 @@
1
+ /**
2
+ * Custom Element: Checkbox, Token, Startzeitpunkt, Hard-Timeout.
3
+ * Spec: specs/002-widget/tasks.md T-204 T-205 T-209 T-210
4
+ * Spec: specs/002-widget/spec.md §Öffentliches Custom-Element-API
5
+ * Spec: specs/002-widget/spec.md Pflichtumfang Start / Timeout / Token
6
+ * Spec: specs/002-widget/spec.md §Formular-Kanten
7
+ * Spec: specs/002-widget/spec.md §Öffentliches Custom-Element-API (template)
8
+ * Verfassung: Artikel 5, 6, 10. ADR-008.
9
+ */
10
+ import { InstanceClientError } from "../client/instance.js";
11
+ import { LOAD_NOTICE } from "../copy/load-notice.js";
12
+ import { EnvelopeIsolationError } from "../envelope/run.js";
13
+ import { terminateTrackedWorkers } from "../runtime/workers.js";
14
+ import { HARD_TIMEOUT_MS, TOKEN_TTL_MS, isAbortError, expiresAtOf, isChallengeExpired, issuedAtOf, parseStartMode, readSubmitHold, } from "./start.js";
15
+ import { attestChallenge, createBoundClient, mergeRedeemBody, readBaseUrl, readSiteKey, readWasmUrl, } from "./session.js";
16
+ import { HONEYPOT_FIELD } from "./honeypot.js";
17
+ import { applyTemplateToHost } from "./templates/host.js";
18
+ import { combinedLookCss } from "./templates/sheets.js";
19
+ import { isWasmOom } from "../pow/oom.js";
20
+ import { challengeExpiredMessage, isWidgetUiState, labelForHost, missingTokenMessage, } from "./states.js";
21
+ export { HONEYPOT_FIELD } from "./honeypot.js";
22
+ const TAG = "gatekeep-widget";
23
+ const DEFAULT_FIELD_NAME = "gatekeep-token";
24
+ let nextBoxId = 0;
25
+ function nextId(prefix) {
26
+ nextBoxId += 1;
27
+ return `${prefix}-${nextBoxId}`;
28
+ }
29
+ export class WidgetRedeemError extends Error {
30
+ errorCodes;
31
+ constructor(message, errorCodes) {
32
+ super(message);
33
+ this.name = "WidgetRedeemError";
34
+ this.errorCodes = errorCodes;
35
+ }
36
+ }
37
+ function fieldNameOf(host) {
38
+ const name = host.getAttribute("name")?.trim();
39
+ if (name) {
40
+ return name;
41
+ }
42
+ const data = host.getAttribute("data-field-name")?.trim();
43
+ return data && data.length > 0 ? data : DEFAULT_FIELD_NAME;
44
+ }
45
+ function codesFromUnknown(err) {
46
+ if (err instanceof InstanceClientError && err.errorCodes.length > 0) {
47
+ return [...err.errorCodes];
48
+ }
49
+ if (err instanceof WidgetRedeemError && err.errorCodes.length > 0) {
50
+ return [...err.errorCodes];
51
+ }
52
+ if (err instanceof EnvelopeIsolationError) {
53
+ return [err.code];
54
+ }
55
+ if (err !== null && typeof err === "object" && "errorCodes" in err) {
56
+ const codes = err.errorCodes;
57
+ if (Array.isArray(codes) &&
58
+ codes.length > 0 &&
59
+ codes.every((item) => typeof item === "string")) {
60
+ return codes;
61
+ }
62
+ }
63
+ if (err instanceof Error && "code" in err && typeof err.code === "string" && err.code.length > 0) {
64
+ return [err.code];
65
+ }
66
+ return ["invalid_solution"];
67
+ }
68
+ function defaultDelay(callback, ms) {
69
+ const id = setTimeout(callback, ms);
70
+ return { cancel: () => clearTimeout(id) };
71
+ }
72
+ export class GatekeepWidget extends HTMLElement {
73
+ static formAssociated = true;
74
+ static get observedAttributes() {
75
+ return [
76
+ "label-unchecked",
77
+ "label-checked",
78
+ "missing-token",
79
+ "expired-challenge",
80
+ "submit-hold",
81
+ "template",
82
+ ];
83
+ }
84
+ #internals = null;
85
+ #state = "idle";
86
+ #checkbox = null;
87
+ #labelEl = null;
88
+ #hidden = null;
89
+ #honeypot = null;
90
+ #token = null;
91
+ #issuedAtMs = null;
92
+ #pendingSolution = undefined;
93
+ #redeemClient = null;
94
+ #prepareHooks = null;
95
+ #transport;
96
+ #attestation = null;
97
+ #inFlight = null;
98
+ #prepareFlight = null;
99
+ #nowMs = () => Date.now();
100
+ #delay = defaultDelay;
101
+ #timeoutHandle = null;
102
+ #ttlHandle = null;
103
+ #expiryHandle = null;
104
+ #challengeExpired = false;
105
+ #submitHeld = false;
106
+ #abort = null;
107
+ #challenge = undefined;
108
+ #issueContext = null;
109
+ #downgraded = false;
110
+ #sessionActive = false;
111
+ #form = null;
112
+ #boundFormFocus = (event) => {
113
+ this.#onFormInteraction(event);
114
+ };
115
+ #boundFormKeydown = (event) => {
116
+ this.#onFormInteraction(event);
117
+ };
118
+ #boundFormSubmit = (event) => {
119
+ this.#onFormSubmit(event);
120
+ };
121
+ #boundInvalid = () => {
122
+ if (this.#holdEnabled()) {
123
+ this.#submitHeld = true;
124
+ }
125
+ };
126
+ constructor() {
127
+ super();
128
+ try {
129
+ this.#internals = this.attachInternals();
130
+ }
131
+ catch {
132
+ this.#internals = null;
133
+ }
134
+ applyTemplateToHost(this);
135
+ const root = this.attachShadow({ mode: "open" });
136
+ root.innerHTML = `<style>
137
+ ${combinedLookCss()}</style>
138
+ <div class="gk-root">
139
+ <label class="gk">
140
+ <input class="gk-box" type="checkbox" />
141
+ <span class="gk-label"></span>
142
+ </label>
143
+ <p class="gk-load"></p>
144
+ </div>`;
145
+ this.#checkbox = root.querySelector("input[type=checkbox]");
146
+ this.#labelEl = root.querySelector("label.gk");
147
+ const notice = root.querySelector(".gk-load");
148
+ const id = nextId("gk-box");
149
+ const noticeId = nextId("gk-load");
150
+ if (notice instanceof HTMLElement) {
151
+ notice.id = noticeId;
152
+ notice.textContent = LOAD_NOTICE;
153
+ }
154
+ if (this.#checkbox) {
155
+ this.#checkbox.id = id;
156
+ this.#checkbox.setAttribute("aria-describedby", noticeId);
157
+ this.#checkbox.addEventListener("click", (event) => {
158
+ event.preventDefault();
159
+ void this.#onRedeemGesture();
160
+ });
161
+ this.#checkbox.addEventListener("keydown", (event) => {
162
+ if (event.key === " " || event.key === "Enter") {
163
+ event.preventDefault();
164
+ if (event.key === " ") {
165
+ void this.#onRedeemGesture();
166
+ }
167
+ }
168
+ });
169
+ }
170
+ if (this.#labelEl) {
171
+ this.#labelEl.htmlFor = id;
172
+ }
173
+ this.addEventListener("invalid", this.#boundInvalid);
174
+ this.#ensureHoneypot();
175
+ this.#writeFormValue(null);
176
+ this.#render();
177
+ }
178
+ connectedCallback() {
179
+ this.#bindForm();
180
+ if (this.#startMode() === "auto") {
181
+ queueMicrotask(() => {
182
+ void this.#beginPrepare();
183
+ });
184
+ }
185
+ }
186
+ disconnectedCallback() {
187
+ this.#unbindForm();
188
+ this.#clearTimeout();
189
+ this.#clearExpiry();
190
+ terminateTrackedWorkers();
191
+ this.#abort?.abort();
192
+ this.#sessionActive = false;
193
+ this.#prepareFlight = null;
194
+ }
195
+ get uiState() {
196
+ return this.#state;
197
+ }
198
+ /**
199
+ * T-204: UI-Zustand setzen, damit ARIA-Labels prüfbar sind.
200
+ */
201
+ applyUiState(state) {
202
+ if (!isWidgetUiState(state)) {
203
+ throw new TypeError(`unknown widget UI state: ${state}`);
204
+ }
205
+ this.#state = state;
206
+ this.#render();
207
+ }
208
+ get siteKey() {
209
+ return readSiteKey(this);
210
+ }
211
+ get baseUrl() {
212
+ return readBaseUrl(this);
213
+ }
214
+ get wasmUrl() {
215
+ return readWasmUrl(this);
216
+ }
217
+ setRedeemClient(client) {
218
+ this.#redeemClient = client;
219
+ }
220
+ setPrepareHooks(hooks) {
221
+ this.#prepareHooks = hooks;
222
+ }
223
+ setTransport(transport) {
224
+ this.#transport = transport;
225
+ }
226
+ setPendingSolution(solution) {
227
+ this.#pendingSolution = solution;
228
+ }
229
+ setNowMs(nowMs) {
230
+ this.#nowMs = nowMs;
231
+ }
232
+ setDelay(delay) {
233
+ this.#delay = delay;
234
+ }
235
+ getResponse() {
236
+ if (this.#honeypotFilled()) {
237
+ return null;
238
+ }
239
+ if (this.#token === null || this.isExpired()) {
240
+ return null;
241
+ }
242
+ return this.#token;
243
+ }
244
+ isExpired() {
245
+ if (this.#token === null || this.#issuedAtMs === null) {
246
+ return true;
247
+ }
248
+ return this.#nowMs() - this.#issuedAtMs >= TOKEN_TTL_MS;
249
+ }
250
+ /**
251
+ * T-210: fehlt die Lösung, zuerst Challenge/PoW.
252
+ * Checkbox-Klick löst ein, wenn die Lösung schon da ist.
253
+ */
254
+ async solve() {
255
+ await this.#beginPrepare();
256
+ return this.#redeemOnly();
257
+ }
258
+ reset() {
259
+ this.#clearTimeout();
260
+ this.#clearTtl();
261
+ this.#clearExpiry();
262
+ this.#challengeExpired = false;
263
+ this.#abort?.abort();
264
+ this.#abort = null;
265
+ this.#token = null;
266
+ this.#issuedAtMs = null;
267
+ this.#pendingSolution = undefined;
268
+ this.#inFlight = null;
269
+ this.#prepareFlight = null;
270
+ this.#challenge = undefined;
271
+ this.#issueContext = null;
272
+ this.#attestation = null;
273
+ this.#downgraded = false;
274
+ this.#sessionActive = false;
275
+ this.#submitHeld = false;
276
+ this.#writeFormValue(null);
277
+ this.applyUiState("idle");
278
+ this.dispatchEvent(new CustomEvent("gatekeep:reset", { bubbles: true, composed: true }));
279
+ }
280
+ attributeChangedCallback(name) {
281
+ if (name === "template") {
282
+ applyTemplateToHost(this);
283
+ }
284
+ this.#render();
285
+ }
286
+ #startMode() {
287
+ return parseStartMode(this.getAttribute("start-mode"));
288
+ }
289
+ #holdEnabled() {
290
+ return readSubmitHold(this);
291
+ }
292
+ #bindForm() {
293
+ this.#unbindForm();
294
+ const form = this.closest("form");
295
+ this.#form = form;
296
+ form?.addEventListener("focusin", this.#boundFormFocus);
297
+ form?.addEventListener("keydown", this.#boundFormKeydown);
298
+ form?.addEventListener("submit", this.#boundFormSubmit);
299
+ }
300
+ #unbindForm() {
301
+ this.#form?.removeEventListener("focusin", this.#boundFormFocus);
302
+ this.#form?.removeEventListener("keydown", this.#boundFormKeydown);
303
+ this.#form?.removeEventListener("submit", this.#boundFormSubmit);
304
+ this.#form = null;
305
+ }
306
+ #onFormSubmit(event) {
307
+ if (this.getResponse() !== null) {
308
+ return;
309
+ }
310
+ event.preventDefault();
311
+ if (this.#holdEnabled()) {
312
+ this.#submitHeld = true;
313
+ }
314
+ }
315
+ #onFormInteraction(event) {
316
+ if (this.#startMode() !== "interaction") {
317
+ return;
318
+ }
319
+ if (event.composedPath().includes(this)) {
320
+ return;
321
+ }
322
+ void this.#beginPrepare();
323
+ }
324
+ #fieldName() {
325
+ return fieldNameOf(this);
326
+ }
327
+ #ensureHoneypot() {
328
+ if (this.#honeypot) {
329
+ return;
330
+ }
331
+ const input = document.createElement("input");
332
+ input.type = "text";
333
+ input.name = HONEYPOT_FIELD;
334
+ input.autocomplete = "off";
335
+ input.tabIndex = -1;
336
+ input.setAttribute("aria-hidden", "true");
337
+ input.setAttribute("data-gatekeep-honeypot", "1");
338
+ input.style.position = "absolute";
339
+ input.style.left = "-9999px";
340
+ input.style.width = "1px";
341
+ input.style.height = "1px";
342
+ input.style.overflow = "hidden";
343
+ input.style.clipPath = "inset(50%)";
344
+ input.style.border = "0";
345
+ input.style.padding = "0";
346
+ input.style.margin = "0";
347
+ this.append(input);
348
+ this.#honeypot = input;
349
+ }
350
+ #honeypotFilled() {
351
+ this.#ensureHoneypot();
352
+ const root = this.#form ?? this;
353
+ const fields = root.querySelectorAll(`input[name="${HONEYPOT_FIELD}"]`);
354
+ for (let i = 0; i < fields.length; i += 1) {
355
+ const node = fields.item(i);
356
+ if (node instanceof HTMLInputElement && node.value.trim() !== "") {
357
+ return true;
358
+ }
359
+ }
360
+ return false;
361
+ }
362
+ #syncHidden(value) {
363
+ const fieldName = this.#fieldName();
364
+ if (!this.#hidden) {
365
+ const input = document.createElement("input");
366
+ input.type = "hidden";
367
+ this.append(input);
368
+ this.#hidden = input;
369
+ }
370
+ this.#hidden.value = value;
371
+ if (this.#internals) {
372
+ this.#hidden.removeAttribute("name");
373
+ this.#hidden.setAttribute("form", "");
374
+ }
375
+ else {
376
+ this.#hidden.name = fieldName;
377
+ this.#hidden.removeAttribute("form");
378
+ }
379
+ }
380
+ #writeFormValue(token) {
381
+ const fieldName = this.#fieldName();
382
+ if (this.#internals) {
383
+ if (!this.hasAttribute("name")) {
384
+ this.setAttribute("name", fieldName);
385
+ }
386
+ this.#internals.setFormValue(token);
387
+ }
388
+ this.#syncHidden(token ?? "");
389
+ this.#syncValidity();
390
+ }
391
+ #syncValidity() {
392
+ const token = this.getResponse();
393
+ const message = token === null
394
+ ? this.#challengeExpired
395
+ ? challengeExpiredMessage(this)
396
+ : missingTokenMessage(this)
397
+ : "";
398
+ if (this.#checkbox) {
399
+ this.#checkbox.setCustomValidity(message);
400
+ }
401
+ if (this.#hidden) {
402
+ this.#hidden.setCustomValidity(message);
403
+ }
404
+ if (!this.#internals) {
405
+ return;
406
+ }
407
+ try {
408
+ if (token === null) {
409
+ const box = this.#checkbox;
410
+ if (box) {
411
+ this.#internals.setValidity({ customError: true }, message, box);
412
+ }
413
+ else {
414
+ this.#internals.setValidity({ customError: true }, message);
415
+ }
416
+ }
417
+ else {
418
+ this.#internals.setValidity({});
419
+ }
420
+ }
421
+ catch {
422
+ // Internals-Validity nicht verfügbar; setCustomValidity bleibt.
423
+ }
424
+ }
425
+ #flushHeldSubmit() {
426
+ if (!this.#submitHeld) {
427
+ return;
428
+ }
429
+ this.#submitHeld = false;
430
+ if (!this.#holdEnabled()) {
431
+ return;
432
+ }
433
+ const form = this.#form ?? this.closest("form");
434
+ if (!form) {
435
+ return;
436
+ }
437
+ if (typeof form.requestSubmit === "function") {
438
+ form.requestSubmit();
439
+ }
440
+ else {
441
+ form.submit();
442
+ }
443
+ }
444
+ #emitError(errorCodes) {
445
+ this.dispatchEvent(new CustomEvent("gatekeep:error", {
446
+ bubbles: true,
447
+ composed: true,
448
+ detail: { errorCodes: [...errorCodes] },
449
+ }));
450
+ }
451
+ #emitStart() {
452
+ this.dispatchEvent(new CustomEvent("gatekeep:start", { bubbles: true, composed: true }));
453
+ }
454
+ #emitProgress(detail) {
455
+ this.dispatchEvent(new CustomEvent("gatekeep:progress", {
456
+ bubbles: true,
457
+ composed: true,
458
+ detail,
459
+ }));
460
+ }
461
+ #clearTimeout() {
462
+ this.#timeoutHandle?.cancel();
463
+ this.#timeoutHandle = null;
464
+ }
465
+ #clearTtl() {
466
+ this.#ttlHandle?.cancel();
467
+ this.#ttlHandle = null;
468
+ }
469
+ #clearExpiry() {
470
+ this.#expiryHandle?.cancel();
471
+ this.#expiryHandle = null;
472
+ }
473
+ #pendingExpired() {
474
+ return (isChallengeExpired(this.#challenge, this.#nowMs()) ||
475
+ isChallengeExpired(this.#pendingSolution, this.#nowMs()));
476
+ }
477
+ #armChallengeExpiry() {
478
+ this.#clearExpiry();
479
+ const expiresAt = expiresAtOf(this.#challenge);
480
+ if (expiresAt === null) {
481
+ return;
482
+ }
483
+ const delay = expiresAt - this.#nowMs();
484
+ if (delay <= 0) {
485
+ return;
486
+ }
487
+ this.#expiryHandle = this.#delay(() => {
488
+ this.#expiryHandle = null;
489
+ void this.#onChallengeTtl();
490
+ }, delay);
491
+ }
492
+ #clearSessionForRetry() {
493
+ this.#clearTimeout();
494
+ this.#clearExpiry();
495
+ this.#abort?.abort();
496
+ this.#abort = null;
497
+ this.#pendingSolution = undefined;
498
+ this.#challenge = undefined;
499
+ this.#issueContext = null;
500
+ this.#attestation = null;
501
+ this.#downgraded = false;
502
+ this.#sessionActive = false;
503
+ this.#prepareFlight = null;
504
+ }
505
+ async #reissueAfterExpiry() {
506
+ this.#challengeExpired = true;
507
+ this.applyUiState("timeout");
508
+ if (this.#pendingExpired() || this.#pendingSolution !== undefined) {
509
+ this.#emitError(["invalid_solution"]);
510
+ this.#clearSessionForRetry();
511
+ }
512
+ await this.#beginPrepare();
513
+ }
514
+ async #onChallengeTtl() {
515
+ if (this.getResponse() !== null) {
516
+ return;
517
+ }
518
+ if (this.#inFlight) {
519
+ return;
520
+ }
521
+ this.#challengeExpired = true;
522
+ this.applyUiState("timeout");
523
+ this.#emitError(["invalid_solution"]);
524
+ this.#clearSessionForRetry();
525
+ await this.#beginPrepare();
526
+ }
527
+ #armTimeout(issuedAtMs) {
528
+ this.#clearTimeout();
529
+ const delay = Math.max(0, issuedAtMs + HARD_TIMEOUT_MS - this.#nowMs());
530
+ this.#timeoutHandle = this.#delay(() => {
531
+ void this.#onHardTimeout();
532
+ }, delay);
533
+ }
534
+ #armTokenTtl() {
535
+ this.#clearTtl();
536
+ if (this.#issuedAtMs === null) {
537
+ return;
538
+ }
539
+ const delay = Math.max(0, this.#issuedAtMs + TOKEN_TTL_MS - this.#nowMs());
540
+ this.#ttlHandle = this.#delay(() => {
541
+ this.#ttlHandle = null;
542
+ this.reset();
543
+ }, delay);
544
+ }
545
+ async #beginPrepare() {
546
+ if (this.#honeypotFilled()) {
547
+ return;
548
+ }
549
+ if (this.#sessionActive) {
550
+ return this.#prepareFlight ?? Promise.resolve();
551
+ }
552
+ if (this.#prepareFlight) {
553
+ return this.#prepareFlight;
554
+ }
555
+ if (this.#pendingSolution !== undefined && this.#pendingSolution !== null) {
556
+ return;
557
+ }
558
+ if (!this.#effectiveHooks()) {
559
+ return;
560
+ }
561
+ this.#sessionActive = true;
562
+ const run = this.#prepareOnce();
563
+ this.#prepareFlight = run;
564
+ try {
565
+ await run;
566
+ }
567
+ finally {
568
+ if (this.#prepareFlight === run) {
569
+ this.#prepareFlight = null;
570
+ }
571
+ }
572
+ }
573
+ #boundClient() {
574
+ return createBoundClient(this, this.#transport);
575
+ }
576
+ #effectiveRedeemClient() {
577
+ return this.#redeemClient ?? this.#boundClient();
578
+ }
579
+ #defaultHooks() {
580
+ const client = this.#boundClient();
581
+ if (!client) {
582
+ return null;
583
+ }
584
+ const wasmUrl = this.wasmUrl;
585
+ return {
586
+ requestChallenge: (request) => client.requestChallenge(request),
587
+ solve: async (challenge, ctx) => {
588
+ const { runSolve } = await import("../pow/run.js");
589
+ return runSolve(challenge, {
590
+ onProgress: ctx.onProgress,
591
+ wasmUrl,
592
+ });
593
+ },
594
+ };
595
+ }
596
+ #effectiveHooks() {
597
+ return this.#prepareHooks ?? this.#defaultHooks();
598
+ }
599
+ async #prepareOnce() {
600
+ const hooks = this.#effectiveHooks();
601
+ if (!hooks) {
602
+ return;
603
+ }
604
+ this.#sessionActive = true;
605
+ this.#abort?.abort();
606
+ this.#abort = new AbortController();
607
+ const signal = this.#abort.signal;
608
+ const wasmUrl = this.wasmUrl;
609
+ this.#emitStart();
610
+ this.applyUiState("working");
611
+ try {
612
+ const issue = {
613
+ hostname: globalThis.location?.hostname || "localhost",
614
+ action: this.getAttribute("action") ?? "",
615
+ cdata: this.getAttribute("cdata") ?? "",
616
+ };
617
+ const challenge = await hooks.requestChallenge(issue);
618
+ if (signal.aborted) {
619
+ return;
620
+ }
621
+ this.#challenge = challenge;
622
+ this.#issueContext = { challenge, ...issue };
623
+ this.#attestation = await attestChallenge(challenge);
624
+ this.#downgraded = false;
625
+ this.#armTimeout(issuedAtOf(challenge, this.#nowMs()));
626
+ const solveStarted = this.#nowMs();
627
+ const solution = await hooks.solve(challenge, {
628
+ onProgress: (detail) => this.#emitProgress(detail),
629
+ signal,
630
+ wasmUrl,
631
+ });
632
+ if (signal.aborted) {
633
+ return;
634
+ }
635
+ this.#clearTimeout();
636
+ if (this.#issueContext === null) {
637
+ return;
638
+ }
639
+ const solveMs = Math.max(0, Math.round(this.#nowMs() - solveStarted));
640
+ this.#pendingSolution = mergeRedeemBody(solution, this.#attestation, {
641
+ ...this.#issueContext,
642
+ solveMs,
643
+ });
644
+ this.#challengeExpired = false;
645
+ this.#armChallengeExpiry();
646
+ if (this.#state === "working" || this.#state === "timeout") {
647
+ this.applyUiState("idle");
648
+ }
649
+ }
650
+ catch (err) {
651
+ if (isAbortError(err) || signal.aborted) {
652
+ return;
653
+ }
654
+ const codes = codesFromUnknown(err);
655
+ if ((isWasmOom(err) || codes.includes("challenge_timeout")) && !this.#downgraded) {
656
+ await this.#onHardTimeout();
657
+ return;
658
+ }
659
+ this.#clearTimeout();
660
+ this.applyUiState("error");
661
+ this.#emitError(codes);
662
+ }
663
+ }
664
+ async #onHardTimeout() {
665
+ this.#timeoutHandle = null;
666
+ if (this.#pendingSolution !== undefined && this.#pendingSolution !== null) {
667
+ return;
668
+ }
669
+ this.applyUiState("timeout");
670
+ if (this.#downgraded) {
671
+ this.#abort?.abort();
672
+ this.#emitError(["challenge_timeout"]);
673
+ return;
674
+ }
675
+ const hooks = this.#effectiveHooks();
676
+ const challenge = this.#challenge;
677
+ if (!hooks?.downgrade || challenge === undefined) {
678
+ this.#abort?.abort();
679
+ this.#downgraded = true;
680
+ this.#emitError(["challenge_timeout"]);
681
+ return;
682
+ }
683
+ this.#abort?.abort();
684
+ this.#abort = new AbortController();
685
+ const signal = this.#abort.signal;
686
+ this.#downgraded = true;
687
+ try {
688
+ const next = await hooks.downgrade(challenge, this.#nowMs());
689
+ if (signal.aborted) {
690
+ return;
691
+ }
692
+ this.#challenge = next;
693
+ if (this.#issueContext !== null) {
694
+ this.#issueContext = { ...this.#issueContext, challenge: next };
695
+ }
696
+ this.#armTimeout(issuedAtOf(next, this.#nowMs()));
697
+ this.#attestation = await attestChallenge(next);
698
+ const solveStarted = this.#nowMs();
699
+ const solution = await hooks.solve(next, {
700
+ onProgress: (detail) => this.#emitProgress(detail),
701
+ signal,
702
+ wasmUrl: this.wasmUrl,
703
+ });
704
+ if (signal.aborted) {
705
+ return;
706
+ }
707
+ this.#clearTimeout();
708
+ const context = this.#issueContext ?? {
709
+ challenge: next,
710
+ hostname: globalThis.location?.hostname || "localhost",
711
+ };
712
+ const solveMs = Math.max(0, Math.round(this.#nowMs() - solveStarted));
713
+ this.#pendingSolution = mergeRedeemBody(solution, this.#attestation, {
714
+ ...context,
715
+ solveMs,
716
+ });
717
+ this.#challengeExpired = false;
718
+ this.#armChallengeExpiry();
719
+ if (this.#state === "working" || this.#state === "timeout") {
720
+ this.applyUiState("idle");
721
+ }
722
+ }
723
+ catch (err) {
724
+ if (isAbortError(err) || signal.aborted) {
725
+ return;
726
+ }
727
+ this.#clearTimeout();
728
+ this.applyUiState("timeout");
729
+ this.#emitError(codesFromUnknown(err).includes("challenge_timeout")
730
+ ? ["challenge_timeout"]
731
+ : codesFromUnknown(err));
732
+ }
733
+ }
734
+ async #onRedeemGesture() {
735
+ try {
736
+ if (this.#pendingExpired() || this.#challengeExpired) {
737
+ await this.#reissueAfterExpiry();
738
+ }
739
+ await this.#beginPrepare();
740
+ if (this.#pendingSolution === undefined || this.#pendingSolution === null) {
741
+ return;
742
+ }
743
+ await this.#redeemOnly();
744
+ }
745
+ catch {
746
+ // Event trägt den Code; Gesture darf nicht als unhandled rejection enden.
747
+ }
748
+ }
749
+ async #redeemOnly() {
750
+ if (this.#inFlight) {
751
+ return this.#inFlight;
752
+ }
753
+ if (this.#token !== null && !this.isExpired()) {
754
+ return this.#token;
755
+ }
756
+ const run = this.#redeemOnce();
757
+ this.#inFlight = run;
758
+ try {
759
+ return await run;
760
+ }
761
+ finally {
762
+ if (this.#inFlight === run) {
763
+ this.#inFlight = null;
764
+ }
765
+ }
766
+ }
767
+ async #redeemOnce() {
768
+ if (this.#pendingExpired()) {
769
+ await this.#reissueAfterExpiry();
770
+ }
771
+ return this.#acceptRedeem(true);
772
+ }
773
+ async #acceptRedeem(allowExpiryRetry) {
774
+ if (this.#honeypotFilled()) {
775
+ this.#token = null;
776
+ this.#writeFormValue(null);
777
+ this.#emitError(["missing_token"]);
778
+ throw new WidgetRedeemError("missing_token", ["missing_token"]);
779
+ }
780
+ const client = this.#effectiveRedeemClient();
781
+ const solution = this.#pendingSolution;
782
+ if (!client || solution === undefined || solution === null) {
783
+ this.#emitError(["missing_token"]);
784
+ throw new WidgetRedeemError("missing_token", ["missing_token"]);
785
+ }
786
+ this.applyUiState("working");
787
+ try {
788
+ const result = await client.redeem(solution);
789
+ this.#challengeExpired = false;
790
+ this.#clearExpiry();
791
+ this.#token = result.token;
792
+ this.#issuedAtMs = this.#nowMs();
793
+ this.#writeFormValue(result.token);
794
+ this.applyUiState("success");
795
+ this.#armTokenTtl();
796
+ this.dispatchEvent(new CustomEvent("gatekeep:success", {
797
+ bubbles: true,
798
+ composed: true,
799
+ detail: { token: result.token },
800
+ }));
801
+ this.#flushHeldSubmit();
802
+ return result.token;
803
+ }
804
+ catch (err) {
805
+ if (err instanceof WidgetRedeemError) {
806
+ throw err;
807
+ }
808
+ const codes = codesFromUnknown(err);
809
+ if (allowExpiryRetry && codes.includes("invalid_solution") && this.#pendingExpired()) {
810
+ await this.#reissueAfterExpiry();
811
+ return this.#acceptRedeem(false);
812
+ }
813
+ this.#challengeExpired = false;
814
+ this.applyUiState("error");
815
+ this.#emitError(codes);
816
+ throw err instanceof Error
817
+ ? err
818
+ : new WidgetRedeemError("redeem failed", codes);
819
+ }
820
+ }
821
+ #captionText() {
822
+ if (this.#challengeExpired && (this.#state === "timeout" || this.#state === "error")) {
823
+ return challengeExpiredMessage(this);
824
+ }
825
+ return labelForHost(this, this.#state);
826
+ }
827
+ #render() {
828
+ const box = this.#checkbox;
829
+ const label = this.#labelEl;
830
+ if (!box || !label) {
831
+ return;
832
+ }
833
+ const text = this.#captionText();
834
+ box.setAttribute("aria-label", text);
835
+ box.setAttribute("aria-busy", this.#state === "working" ? "true" : "false");
836
+ box.checked = this.#state === "success";
837
+ const caption = label.querySelector(".gk-label");
838
+ if (caption) {
839
+ caption.textContent = text;
840
+ }
841
+ if (this.#internals) {
842
+ this.#internals.ariaLabel = text;
843
+ }
844
+ this.#syncValidity();
845
+ }
846
+ }
847
+ export function defineGatekeepWidget() {
848
+ if (!customElements.get(TAG)) {
849
+ customElements.define(TAG, GatekeepWidget);
850
+ }
851
+ }
852
+ //# sourceMappingURL=widget.js.map