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