@wcstack/credential 1.19.1 → 1.21.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.
package/dist/index.esm.js CHANGED
@@ -43,29 +43,415 @@ function setConfig(partialConfig) {
43
43
  frozenConfig = null;
44
44
  }
45
45
 
46
+ // ===========================================================================
47
+ // AUTO-GENERATED FILE - DO NOT EDIT.
48
+ // Generated from /io-core/operation-lane.ts by scripts/sync-io-core.mjs.
49
+ // Run `node scripts/sync-io-core.mjs` after editing the source.
50
+ // ===========================================================================
46
51
  /**
47
- * Headless Credential Management primitive. A thin, framework-agnostic
48
- * wrapper around `navigator.credentials.get()`/`.store()` exposed through the
49
- * wc-bindable protocol.
52
+ * 1 レーン = 独立した排他単位。Core 1 つ以上所有する (module singleton にしない —
53
+ * 複数 <wcs-fetch> 間で漏れるため)
54
+ */
55
+ class OperationLane {
56
+ laneKey;
57
+ policy;
58
+ _ownerGeneration = 0;
59
+ _latestEpoch = 0;
60
+ _nextOperationId = 1;
61
+ // latest / queue / exhaust の単一 active。queue は head を指す。
62
+ _activeOperationId = undefined;
63
+ // overlap の active set (§5: 内部 bookkeeping のみ・observable 公開はしない)。
64
+ _activeOperationIds = new Set();
65
+ // queue policy の FIFO。
66
+ _queue = [];
67
+ _inFlightCount = 0;
68
+ // opId → 終端状態 (absence = pending)。
69
+ _terminal = new Map();
70
+ // claimTerminal で確定した outcome (finalize が 'committing' を最終値へ移す)。
71
+ _claimedOutcome = new Map();
72
+ // opId → AbortController (identity は opId が保証。cross-op clobber は構造上起きない)。
73
+ _controllers = new Map();
74
+ // opId → attempt 数。
75
+ _attempts = new Map();
76
+ _withSignal;
77
+ _trace;
78
+ constructor(laneKey, policy, options = {}) {
79
+ this.laneKey = laneKey;
80
+ this.policy = policy;
81
+ this._withSignal = options.withSignal ?? false;
82
+ this._trace = options.trace;
83
+ }
84
+ get ownerGeneration() {
85
+ return this._ownerGeneration;
86
+ }
87
+ get inFlightCount() {
88
+ return this._inFlightCount;
89
+ }
90
+ get latestEpoch() {
91
+ return this._latestEpoch;
92
+ }
93
+ get activeOperationId() {
94
+ return this._activeOperationId;
95
+ }
96
+ /**
97
+ * 新しい要求の到着。arrival policy を適用し ticket + 最初の attempt を発行する。
98
+ * exhaust で実行中の場合だけ null を返す (新要求を ticket 化せず拒否 = 冪等 no-op)。
99
+ */
100
+ begin() {
101
+ let supersedeEpoch;
102
+ switch (this.policy) {
103
+ case "latest": {
104
+ // latestEpoch を進め、旧 active を abort (可能なら)。旧 ticket は settle 時に
105
+ // eligibility 不一致で stale となる。
106
+ supersedeEpoch = ++this._latestEpoch;
107
+ if (this._activeOperationId !== undefined) {
108
+ this._abortController(this._activeOperationId);
109
+ }
110
+ break;
111
+ }
112
+ case "exhaust": {
113
+ // 実行中なら新要求を拒否 (呼び出し側は既存結果へ合流)。
114
+ if (this._activeOperationId !== undefined) {
115
+ return null;
116
+ }
117
+ break;
118
+ }
119
+ }
120
+ const operationId = this._nextOperationId++;
121
+ const ticket = {
122
+ operationId,
123
+ ownerGeneration: this._ownerGeneration,
124
+ laneKey: this.laneKey,
125
+ policy: this.policy,
126
+ supersedeEpoch,
127
+ };
128
+ switch (this.policy) {
129
+ case "latest":
130
+ case "exhaust":
131
+ this._activeOperationId = operationId;
132
+ break;
133
+ case "queue":
134
+ this._queue.push(ticket);
135
+ // 先頭だけを active にする (先行が完了するまで待つ)。
136
+ if (this._activeOperationId === undefined) {
137
+ this._activeOperationId = operationId;
138
+ }
139
+ break;
140
+ case "overlap":
141
+ this._activeOperationIds.add(operationId);
142
+ break;
143
+ }
144
+ this._inFlightCount += 1;
145
+ this._attempts.set(operationId, 1);
146
+ const attempt = this._makeAttempt(operationId, 1);
147
+ if (this._trace !== undefined) {
148
+ this._trace({ type: "io:operation-started", operationId, laneKey: this.laneKey, policy: this.policy });
149
+ }
150
+ return { ticket, attempt };
151
+ }
152
+ /**
153
+ * retry: 同じ operationId に新しい attempt を作る。attempt number と resource signal
154
+ * だけを更新する (§5)。既に終端した operation には作れない (null)。
155
+ */
156
+ retry(ticket) {
157
+ if (ticket.ownerGeneration !== this._ownerGeneration)
158
+ return null;
159
+ if (this._terminal.has(ticket.operationId))
160
+ return null;
161
+ const previous = this._attempts.get(ticket.operationId);
162
+ if (previous === undefined)
163
+ return null;
164
+ const attemptNo = previous + 1;
165
+ this._attempts.set(ticket.operationId, attemptNo);
166
+ // 前の attempt の signal は破棄し、新しい controller を張る。
167
+ this._releaseController(ticket.operationId);
168
+ const attempt = this._makeAttempt(ticket.operationId, attemptNo);
169
+ if (this._trace !== undefined) {
170
+ this._trace({ type: "io:operation-retried", operationId: ticket.operationId, laneKey: this.laneKey, attempt: attemptNo });
171
+ }
172
+ return attempt;
173
+ }
174
+ /**
175
+ * CommitGuard (§5.1)。外部可視の setter / event dispatch の直前に呼ぶ。
176
+ * (1) owner lifecycle generation 一致 (2) terminal settle 前 (3) policy eligibility。
177
+ */
178
+ canCommit(ticket) {
179
+ if (ticket.ownerGeneration !== this._ownerGeneration)
180
+ return false;
181
+ const status = this._terminal.get(ticket.operationId);
182
+ // absence = pending / 'committing' = multi-setter commit 中。どちらも settle 前。
183
+ if (status !== undefined && status !== "committing")
184
+ return false;
185
+ return this._isEligible(ticket);
186
+ }
187
+ /**
188
+ * terminal CAS (§5.1): pending → committing を claim する。勝者だけが true。
189
+ * eligibility / owner gen を満たさない場合も false。claim 後は commit 中となり、
190
+ * canCommit は各 setter の直前で再検査する (setter が同期 supersede しても取りこぼさない)。
191
+ */
192
+ claimTerminal(ticket, outcome) {
193
+ if (ticket.ownerGeneration !== this._ownerGeneration)
194
+ return false;
195
+ if (this._terminal.has(ticket.operationId))
196
+ return false; // 既に committing / 終端
197
+ if (!this._isEligible(ticket))
198
+ return false;
199
+ this._terminal.set(ticket.operationId, "committing");
200
+ this._claimedOutcome.set(ticket.operationId, outcome);
201
+ return true;
202
+ }
203
+ /** claim 済み outcome (timer が claim → catch が読む等)。未 claim なら undefined。 */
204
+ claimedOutcome(ticket) {
205
+ return this._claimedOutcome.get(ticket.operationId);
206
+ }
207
+ /**
208
+ * operation の後始末。claim 済みなら outcome を確定し、未 claim なら stale-drop。
209
+ * controller を解放し in-flight を減らし、policy の bookkeeping を進める。冪等。
210
+ */
211
+ finalize(ticket) {
212
+ const operationId = ticket.operationId;
213
+ const status = this._terminal.get(operationId);
214
+ if (status !== undefined && status !== "committing") {
215
+ // 既に確定済み。冪等に return。
216
+ return;
217
+ }
218
+ let outcome;
219
+ if (status === "committing") {
220
+ outcome = this._claimedOutcome.get(operationId) ?? "stale";
221
+ }
222
+ else {
223
+ // 一度も claim されなかった (supersede / dispose で eligibility を失った)。
224
+ outcome = "stale";
225
+ }
226
+ this._terminal.set(operationId, outcome);
227
+ this._claimedOutcome.delete(operationId);
228
+ this._releaseController(operationId);
229
+ this._attempts.delete(operationId);
230
+ if (this._inFlightCount > 0)
231
+ this._inFlightCount -= 1;
232
+ this._advanceBookkeeping(operationId);
233
+ if (this._trace !== undefined) {
234
+ if (outcome === "stale") {
235
+ this._trace({ type: "io:stale-dropped", operationId, laneKey: this.laneKey });
236
+ }
237
+ else {
238
+ this._trace({ type: "io:operation-settled", operationId, laneKey: this.laneKey, outcome });
239
+ }
240
+ }
241
+ }
242
+ /** operation の signal (resource 解放用)。withSignal でなければ undefined。 */
243
+ signalOf(ticket) {
244
+ return this._controllers.get(ticket.operationId)?.signal;
245
+ }
246
+ /** best-effort な resource 中断。正しさは owner gen / eligibility / terminal CAS が担う。 */
247
+ abort(ticket) {
248
+ this._abortController(ticket.operationId);
249
+ }
250
+ /**
251
+ * 現在 active な operation を中断する (利用者による明示キャンセル)。epoch は進めない —
252
+ * 中断された operation は eligibility を保ったまま 'aborted' を claim できる
253
+ * (loading をクリアしつつ in-flight 状態を残す)。
254
+ */
255
+ abortActive() {
256
+ if (this._activeOperationId !== undefined) {
257
+ this._abortController(this._activeOperationId);
258
+ }
259
+ for (const operationId of this._activeOperationIds) {
260
+ this._abortController(operationId);
261
+ }
262
+ }
263
+ /**
264
+ * dispose (§4.1 world generation)。owner generation を bump して全 ticket を無効化し、
265
+ * 生きている controller を全て abort する。dispose 後に settle した operation は
266
+ * owner gen 不一致で外部 commit しない。retention gate (§10.3) のため live な
267
+ * 全 operation を即時に stale として finalize し、controller / attempt を解放する。
268
+ */
269
+ disposeOwner() {
270
+ this._ownerGeneration += 1;
271
+ for (const operationId of Array.from(this._controllers.keys())) {
272
+ this._abortController(operationId);
273
+ // finalize は dispose 後 (terminal='stale') に early-return するため controller を
274
+ // 解放しない。retention gate (§10.3) を満たすためここで明示的に解放する。
275
+ this._releaseController(operationId);
276
+ }
277
+ for (const operationId of Array.from(this._attempts.keys())) {
278
+ if (!this._terminal.has(operationId)) {
279
+ this._terminal.set(operationId, "stale");
280
+ }
281
+ this._claimedOutcome.delete(operationId);
282
+ this._attempts.delete(operationId);
283
+ if (this._trace !== undefined) {
284
+ this._trace({ type: "io:stale-dropped", operationId, laneKey: this.laneKey });
285
+ }
286
+ }
287
+ this._activeOperationId = undefined;
288
+ this._activeOperationIds.clear();
289
+ this._queue.length = 0;
290
+ this._inFlightCount = 0;
291
+ }
292
+ // --- internal ---
293
+ _makeAttempt(operationId, attemptNo) {
294
+ let signal;
295
+ // AbortController 不在環境(古い runtime / 一部 SSR)では degraded: signal なしで進む。
296
+ // 正しさは owner generation / eligibility / terminal CAS が担うため、native 中断が
297
+ // 無くても supersede / dispose は機能する(best-effort resource 中断が省かれるだけ)。
298
+ if (this._withSignal && typeof AbortController === "function") {
299
+ const controller = new AbortController();
300
+ this._controllers.set(operationId, controller);
301
+ signal = controller.signal;
302
+ }
303
+ return { operationId, attempt: attemptNo, signal };
304
+ }
305
+ _isEligible(ticket) {
306
+ switch (this.policy) {
307
+ case "latest":
308
+ return ticket.supersedeEpoch === this._latestEpoch;
309
+ case "queue":
310
+ case "exhaust":
311
+ return this._activeOperationId === ticket.operationId;
312
+ case "overlap":
313
+ return this._activeOperationIds.has(ticket.operationId);
314
+ }
315
+ }
316
+ _advanceBookkeeping(operationId) {
317
+ switch (this.policy) {
318
+ case "latest":
319
+ case "exhaust":
320
+ if (this._activeOperationId === operationId) {
321
+ this._activeOperationId = undefined;
322
+ }
323
+ break;
324
+ case "queue": {
325
+ // 完了した ticket を FIFO から取り除き、次の先頭を active にする。filter で
326
+ // 「先頭 / 非先頭 / 不在」を一様に扱う (finalize は冪等ガードを通った op のみ到達)。
327
+ const remaining = this._queue.filter((t) => t.operationId !== operationId);
328
+ this._queue.length = 0;
329
+ this._queue.push(...remaining);
330
+ this._activeOperationId = this._queue.length > 0 ? this._queue[0].operationId : undefined;
331
+ break;
332
+ }
333
+ case "overlap":
334
+ this._activeOperationIds.delete(operationId);
335
+ break;
336
+ }
337
+ }
338
+ _abortController(operationId) {
339
+ const controller = this._controllers.get(operationId);
340
+ if (controller !== undefined && !controller.signal.aborted) {
341
+ controller.abort();
342
+ }
343
+ }
344
+ _releaseController(operationId) {
345
+ this._controllers.delete(operationId);
346
+ }
347
+ }
348
+
349
+ // ===========================================================================
350
+ // AUTO-GENERATED FILE - DO NOT EDIT.
351
+ // Generated from /io-core/platform-capability.ts by scripts/sync-io-core.mjs.
352
+ // Run `node scripts/sync-io-core.mjs` after editing the source.
353
+ // ===========================================================================
354
+ function isSecureContext() {
355
+ return globalThis.isSecureContext === true;
356
+ }
357
+ /**
358
+ * capability を利用直前に評価して PlatformAssessment を作る。
359
+ * required が 1 つでも欠ければ readiness は "idle"(開始不可)、
360
+ * required 揃い + optional 欠けは "degraded"、全揃いは "ready"。
361
+ */
362
+ function assessCapabilities(registry, options) {
363
+ const availability = new Map();
364
+ const evaluate = (id) => {
365
+ const spec = registry.get(id);
366
+ if (spec === undefined)
367
+ return "unknown";
368
+ return spec.probe() ? "available" : "missing";
369
+ };
370
+ let requiredAllAvailable = true;
371
+ for (const id of options.required) {
372
+ const a = evaluate(id);
373
+ availability.set(id, a);
374
+ if (a !== "available")
375
+ requiredAllAvailable = false;
376
+ }
377
+ let optionalAllAvailable = true;
378
+ for (const id of options.optional ?? []) {
379
+ const a = evaluate(id);
380
+ availability.set(id, a);
381
+ if (a !== "available")
382
+ optionalAllAvailable = false;
383
+ }
384
+ const readiness = !requiredAllAvailable ? "idle" : (optionalAllAvailable ? "ready" : "degraded");
385
+ // preconditions: 対象 capability のいずれかが要求する場合だけ評価する。
386
+ const allIds = [...options.required, ...(options.optional ?? [])];
387
+ const needsSecure = allIds.some((id) => registry.get(id)?.requiresSecureContext === true);
388
+ const needsActivation = allIds.some((id) => registry.get(id)?.requiresUserActivation === true);
389
+ const secureContext = needsSecure ? (isSecureContext() ? "satisfied" : "required") : "not-applicable";
390
+ const userActivation = needsActivation ? "required" : "not-applicable";
391
+ return {
392
+ availability,
393
+ permission: options.permission ?? "not-applicable",
394
+ readiness,
395
+ activity: options.activity ?? "inactive",
396
+ preconditions: { secureContext, userActivation },
397
+ epoch: options.epoch ?? 0,
398
+ lastError: options.lastError,
399
+ };
400
+ }
401
+ /** availability から「required がすべて available か」を判定するヘルパ(supported の最低条件)。 */
402
+ function requiredCapabilitiesAvailable(assessment, required) {
403
+ return required.every((id) => assessment.availability.get(id) === "available");
404
+ }
405
+
406
+ /**
407
+ * credentialCapabilities.ts
50
408
  *
51
- * Reuses batch3's "thin command" archetype established by `@wcstack/share`
52
- * (docs/credential-tag-design.md): single `_gen` generation guard,
53
- * same-value-guarded private setters, never-throw try/catch, no
54
- * `AbortController`/`abort()` command.
409
+ * Credential Management node 固有の capability registry error code。汎用の assess
410
+ * 機構・型は `./platformCapability.js`(/io-core/ から copy-distribution される生成
411
+ * ファイル)から import する。node 固有の宣言はこのハンドライトファイルに置き、生成
412
+ * コピーとは分離する。
413
+ */
414
+ /** 安定した credential error code(taxonomy)。値は公開キーとして固定。 */
415
+ const WCS_CREDENTIAL_ERROR_CODE = {
416
+ CapabilityMissing: "capability-missing",
417
+ /** WebAuthn(publicKey) は v1 スコープ外 — get()/store() 双方で拒否する。 */
418
+ OutOfScope: "out-of-scope",
419
+ /** get()/store() の真のプラットフォーム失敗(NotAllowedError=cancelled は除く)。 */
420
+ CredentialFailed: "credential-failed",
421
+ };
422
+ /**
423
+ * credential node の capability registry。`navigator.credentials`(CredentialsContainer)
424
+ * の presence を probe する。文字列 ID を global property path として eval しない。
425
+ */
426
+ const CREDENTIAL_CAPABILITIES = new Map([
427
+ ["web.credentials", { probe: () => globalThis.navigator?.credentials != null, compatKey: "api.CredentialsContainer" }],
428
+ ]);
429
+
430
+ /**
431
+ * Headless Credential Management primitive. A thin, framework-agnostic wrapper
432
+ * around `navigator.credentials.get()`/`.store()` exposed through the wc-bindable
433
+ * protocol.
55
434
  *
56
- * **v1 scope excludes WebAuthn (`publicKey`)** see docs/credential-tag-design.md
57
- * §0. `get()` validates and strips a `publicKey` option rather than silently
58
- * forwarding it, surfacing the attempt as a scope-violation `error` instead of
59
- * accidentally supporting WebAuthn through a side door.
435
+ * Concurrency is owned by the shared `OperationLane` (io-core) with the `latest`
436
+ * policy — **`get()` and `store()` share one lane**. A later call supersedes the
437
+ * earlier one (the earlier completion fails the terminal CAS), preserving the v1
438
+ * "single generation" behavior (docs/multi-promise-io-node-design.md): these two
439
+ * operations are used sequentially in real auth flows (store after login, get
440
+ * before one), not naturally concurrently on the same instance. If both ARE
441
+ * invoked concurrently, the later call's result wins; use two separate
442
+ * `<wcs-credential>` instances if that bites. The lane runs with
443
+ * `withSignal: false` — the Credential Management API takes no `AbortSignal`;
444
+ * dispose() invalidates any in-flight call via the owner generation.
60
445
  *
61
- * **`get()`/`store()` share one `_gen`** an accepted v1 simplification
62
- * (docs/multi-promise-io-node-design.md): these two operations are used
63
- * sequentially in real auth flows (store after a successful login, get before
64
- * attempting one), not naturally concurrently on the same instance. If both
65
- * ARE invoked concurrently on the same `<wcs-credential>`, the later call's
66
- * generation bump silently drops the earlier call's completion write. If this
67
- * limitation actually bites, use two separate `<wcs-credential>` instances
68
- * (one for get, one for store) rather than reworking the Core.
446
+ * **v1 scope excludes WebAuthn (`publicKey`)** (docs/credential-tag-design.md §0):
447
+ * `get()` validates+strips a `publicKey` option and `store()` rejects a
448
+ * `PublicKeyCredential`, surfacing the attempt as a scope-violation `error`
449
+ * (`errorInfo.code === "out-of-scope"`) rather than a WebAuthn backdoor.
450
+ *
451
+ * Note the cancellation signal is **`NotAllowedError`, NOT `AbortError`**: unlike
452
+ * Web Share / Contact Picker, `credentials.get()/store()` reject with
453
+ * `NotAllowedError` when the user dismisses the native chooser. That maps to
454
+ * `cancelled`; every other name flows to `error`/`errorInfo`.
69
455
  */
70
456
  class CredentialCore extends EventTarget {
71
457
  static wcBindable = {
@@ -76,20 +462,28 @@ class CredentialCore extends EventTarget {
76
462
  { name: "loading", event: "wcs-credential:loading-changed" },
77
463
  { name: "error", event: "wcs-credential:error" },
78
464
  { name: "cancelled", event: "wcs-credential:cancelled-changed" },
465
+ // Serializable failure taxonomy (stable code / phase / recoverable), or null.
466
+ // Additive bindable output; the existing `error` property/event are unchanged.
467
+ // Fires its own `wcs-credential:error-info-changed` event; no getter, so the
468
+ // bound value is the event detail (mirrors `error` / `loading` / `cancelled`).
469
+ { name: "errorInfo", event: "wcs-credential:error-info-changed" },
79
470
  ],
80
471
  commands: [
81
472
  { name: "get", async: true },
82
473
  { name: "store", async: true },
83
474
  ],
84
475
  };
476
+ // Required capability (probed at call time, never at module eval).
477
+ static REQUIRED_CAPABILITIES = ["web.credentials"];
85
478
  _target;
86
479
  _value = null;
87
480
  _loading = false;
88
481
  _error = null;
89
482
  _cancelled = false;
90
- // Generation guard (§3.4): shared by get() and store() (see class docs on
91
- // the accepted concurrency limitation this implies).
92
- _gen = 0;
483
+ _errorInfo = null;
484
+ // Concurrency lane (io-core), shared by get() and store(). `latest`: a later
485
+ // call supersedes the earlier. `withSignal: false`: the API takes no AbortSignal.
486
+ _lane = new OperationLane("credential", "latest", { withSignal: false });
93
487
  // SSR (§3.8): no asynchronous probe to await, so readiness is immediate.
94
488
  _ready = Promise.resolve();
95
489
  constructor(target) {
@@ -111,14 +505,51 @@ class CredentialCore extends EventTarget {
111
505
  get cancelled() {
112
506
  return this._cancelled;
113
507
  }
508
+ /**
509
+ * The last failure's serializable `WcsIoErrorInfo` (stable `code` / `phase` /
510
+ * `recoverable` / `capabilityId`), or null. Exposed as an additive wc-bindable
511
+ * property (event `wcs-credential:error-info-changed`); the existing `error`
512
+ * property/event are unchanged. A `NotAllowedError` user cancellation is
513
+ * `cancelled`, not `errorInfo`.
514
+ */
515
+ get errorInfo() {
516
+ return this._errorInfo;
517
+ }
518
+ /**
519
+ * Whether the required platform capability (`web.credentials`) is available right
520
+ * now — decided by call-time feature detection, not User-Agent. Core-only,
521
+ * additive.
522
+ */
523
+ get supported() {
524
+ return requiredCapabilitiesAvailable(this.platformAssessment, CredentialCore.REQUIRED_CAPABILITIES);
525
+ }
526
+ /**
527
+ * Full platform assessment (availability / readiness / preconditions), probed at
528
+ * call time. Core-only opt-in dev / sidecar view.
529
+ */
530
+ get platformAssessment() {
531
+ return assessCapabilities(CREDENTIAL_CAPABILITIES, {
532
+ required: CredentialCore.REQUIRED_CAPABILITIES,
533
+ activity: this._loading ? "active" : "inactive",
534
+ lastError: this._errorInfo ?? undefined,
535
+ });
536
+ }
114
537
  // Lifecycle (§3.5). Command-driven with no subscription to establish, so
115
- // observe() is an idempotent no-op that resolves once ready; dispose() only
116
- // invalidates any in-flight get()/store() (there is nothing to unsubscribe).
538
+ // observe() is an idempotent no-op that resolves once ready; dispose() bumps the
539
+ // lane's owner generation, invalidating any in-flight get()/store().
117
540
  observe() {
118
541
  return this._ready;
119
542
  }
120
543
  dispose() {
121
- this._gen++;
544
+ this._lane.disposeOwner();
545
+ }
546
+ // CommitGuard (§5.1): external setters / event dispatch only run if the ticket
547
+ // still holds owner generation, is pre-terminal, and is the lane's latest epoch
548
+ // (a superseding get()/store() can invalidate a ticket mid-commit).
549
+ _commitStep(ticket, step) {
550
+ if (this._lane.canCommit(ticket)) {
551
+ step();
552
+ }
122
553
  }
123
554
  _setLoading(loading) {
124
555
  if (this._loading === loading)
@@ -132,12 +563,9 @@ class CredentialCore extends EventTarget {
132
563
  // Deliberately NO same-value guard (unlike error/loading/cancelled below).
133
564
  // `value` is a success-completion signal, not idempotent state: it is written
134
565
  // only on a successful get()/store(), and wcs-credential:complete is the *sole*
135
- // success notification. store() echoes the caller's credential argument, so two
136
- // consecutive successful store() calls with the same object reference are two
137
- // distinct completions and must each re-fire wcs-credential:complete so an
138
- // `$on`/eventToken consumer (and a `value:` binding) sees every success. This
139
- // matches clipboard `_setRead` / broadcast `_setMessage`, which carve
140
- // result/event values out of the §3.3 guard for the same reason.
566
+ // success notification (store() echoes the caller's credential, so two successful
567
+ // store() calls with the same object reference are two distinct completions). This
568
+ // matches ShareCore `_setValue` / clipboard `_setRead`.
141
569
  _setValue(value) {
142
570
  this._value = value;
143
571
  this._target.dispatchEvent(new CustomEvent("wcs-credential:complete", {
@@ -163,9 +591,21 @@ class CredentialCore extends EventTarget {
163
591
  bubbles: true,
164
592
  }));
165
593
  }
166
- _api() {
167
- const nav = globalThis.navigator;
168
- return nav?.credentials;
594
+ // Single mutation point for `errorInfo`, mirroring `_setError`'s same-value guard
595
+ // and event dispatch so the additive `errorInfo` wc-bindable property stays in
596
+ // sync with `error`. Each failure builds a fresh object (reference guard passes);
597
+ // the clear path passes null (suppresses a redundant null→null per call start).
598
+ _setErrorInfo(code, phase, recoverable, message, capabilityId) {
599
+ this._commitErrorInfo({ code, phase, recoverable, message, ...(capabilityId === undefined ? {} : { capabilityId }) });
600
+ }
601
+ _commitErrorInfo(info) {
602
+ if (this._errorInfo === info)
603
+ return;
604
+ this._errorInfo = info;
605
+ this._target.dispatchEvent(new CustomEvent("wcs-credential:error-info-changed", {
606
+ detail: info,
607
+ bubbles: true,
608
+ }));
169
609
  }
170
610
  // Normalizes a rejection reason to a consistent { name, message } shape,
171
611
  // mirroring WorkerCore._normalizeError (packages/worker/src/core/WorkerCore.ts).
@@ -175,112 +615,115 @@ class CredentialCore extends EventTarget {
175
615
  }
176
616
  return { name: "Error", message: String(e) };
177
617
  }
178
- // Classifies a get()/store() rejection as a user cancellation vs a real
179
- // failure (docs/credential-tag-design.md §2/§5). For the Credential
180
- // Management API the browser rejects with `NotAllowedError` when the user
181
- // dismisses/declines the native account-chooser UI this is a routine "the
182
- // user did not pick" outcome, not a platform failure, so it maps to
183
- // `cancelled` and is kept out of `error`. Note this is `NotAllowedError`,
184
- // NOT `AbortError`: unlike Web Share/Contact Picker (whose APIs reject with
185
- // `AbortError` on dismissal), credentials.get()/store() signal user refusal
186
- // via `NotAllowedError`. Every other name (SecurityError, NetworkError, a
187
- // programmatic signal abort, etc.) flows to `error`.
618
+ // Classifies a get()/store() rejection as a user cancellation vs a real failure.
619
+ // The Credential Management API rejects with `NotAllowedError` when the user
620
+ // dismisses/declines the native chooser a routine "the user did not pick"
621
+ // outcome, mapped to `cancelled` and kept out of `error`/`errorInfo`. This is
622
+ // `NotAllowedError`, NOT `AbortError` (unlike Web Share / Contact Picker). Every
623
+ // other name (SecurityError, NetworkError, etc.) flows to `error`.
188
624
  _isCancellation(e) {
189
625
  return e?.name === "NotAllowedError";
190
626
  }
627
+ // Shared lane flow for get()/store() (both `latest` on the same lane). `op`
628
+ // performs the platform call and returns the value to publish on success.
629
+ async _run(op) {
630
+ // `latest`: advance the epoch (supersede any in-flight get()/store()).
631
+ const started = this._lane.begin();
632
+ const { ticket } = started;
633
+ this._commitStep(ticket, () => this._setLoading(true));
634
+ // Reset the previous outcome before starting so a stale cancelled/error/
635
+ // errorInfo does not linger into this call's result.
636
+ this._commitStep(ticket, () => {
637
+ this._commitErrorInfo(null);
638
+ this._setError(null);
639
+ this._setCancelled(false);
640
+ });
641
+ try {
642
+ const value = await op();
643
+ // Terminal CAS: a stale (superseded / dispose-invalidated) completion loses
644
+ // the claim and is dropped without writing state.
645
+ if (!this._lane.claimTerminal(ticket, "success")) {
646
+ return null;
647
+ }
648
+ // Separate commit steps (like FetchCore): if `_setValue`'s event listener
649
+ // synchronously supersedes this op, the following `_setLoading(false)` is
650
+ // stopped by the commit guard rather than clobbering the newer op.
651
+ this._commitStep(ticket, () => this._setValue(value));
652
+ this._commitStep(ticket, () => this._setLoading(false));
653
+ this._lane.finalize(ticket);
654
+ return value;
655
+ }
656
+ catch (e) {
657
+ const cancelled = this._isCancellation(e);
658
+ if (!this._lane.claimTerminal(ticket, cancelled ? "aborted" : "error")) {
659
+ return null;
660
+ }
661
+ this._commitStep(ticket, () => {
662
+ if (cancelled) {
663
+ this._setCancelled(true);
664
+ }
665
+ else {
666
+ const norm = this._normalizeError(e);
667
+ this._setErrorInfo(WCS_CREDENTIAL_ERROR_CODE.CredentialFailed, "execute", true, norm.message);
668
+ this._setError(norm);
669
+ }
670
+ });
671
+ this._commitStep(ticket, () => this._setLoading(false));
672
+ this._lane.finalize(ticket);
673
+ return null;
674
+ }
675
+ }
191
676
  /**
192
- * `get(options)` — v1 scope excludes `publicKey` (WebAuthn). If present, it
193
- * is stripped and the call surfaces a scope-violation `error` instead of
194
- * forwarding it to the platform API (which would accidentally support
195
- * WebAuthn through a side door). `navigator.credentials.get()` does not
196
- * require a user gesture (unlike Web Share/Fullscreen), so this can be
197
- * invoked automatically on page load for a "silent sign-in" flow.
677
+ * `get(options)` — v1 scope excludes `publicKey` (WebAuthn). If present, it is
678
+ * stripped and the call surfaces a scope-violation `error` instead of forwarding
679
+ * it to the platform API. `navigator.credentials.get()` does not require a user
680
+ * gesture, so this can be invoked automatically on page load for silent sign-in.
198
681
  */
199
682
  async get(options = {}) {
200
683
  if ("publicKey" in options) {
201
- this._setError({ name: "NotSupportedError", message: "WebAuthn (publicKey) is out of scope for @wcstack/credential v1. Use a dedicated WebAuthn node instead." });
684
+ const message = "WebAuthn (publicKey) is out of scope for @wcstack/credential v1. Use a dedicated WebAuthn node instead.";
685
+ this._setErrorInfo(WCS_CREDENTIAL_ERROR_CODE.OutOfScope, "start", false, message);
686
+ this._setError({ name: "NotSupportedError", message });
202
687
  return null;
203
688
  }
204
- const api = this._api();
205
- if (!api) {
206
- this._setError({ message: "Credential Management API is not supported in this browser." });
207
- return null;
208
- }
209
- const gen = ++this._gen;
210
- this._setLoading(true);
211
- // Reset the previous outcome before starting a new get so a stale
212
- // cancelled/error does not linger into this call's result.
213
- this._setError(null);
214
- this._setCancelled(false);
215
- try {
216
- const credential = await api.get(options);
217
- if (gen !== this._gen)
218
- return null; // stale (dispose() ran while awaiting)
219
- this._setValue(credential);
220
- this._setLoading(false);
221
- return credential;
222
- }
223
- catch (e) {
224
- if (gen !== this._gen)
225
- return null;
226
- if (this._isCancellation(e)) {
227
- this._setCancelled(true);
228
- }
229
- else {
230
- this._setError(this._normalizeError(e));
231
- }
232
- this._setLoading(false);
689
+ const assessment = this.platformAssessment;
690
+ if (!requiredCapabilitiesAvailable(assessment, CredentialCore.REQUIRED_CAPABILITIES)) {
691
+ const missing = CredentialCore.REQUIRED_CAPABILITIES.find((id) => assessment.availability.get(id) !== "available");
692
+ const message = "Credential Management API is not supported in this browser.";
693
+ this._setErrorInfo(WCS_CREDENTIAL_ERROR_CODE.CapabilityMissing, "start", false, message, missing);
694
+ this._setError({ message });
233
695
  return null;
234
696
  }
697
+ const nav = globalThis.navigator;
698
+ return this._run(() => nav.credentials.get(options));
235
699
  }
236
700
  /**
237
- * `store(credential)` — shares the same single `_gen` as `get()` (see class
238
- * docs). `navigator.credentials.store()` resolves `Promise<void>` (per
239
- * `lib.dom.d.ts`) there is no payload to read off the API, so `value` is
240
- * synthesized as an echo of the caller's `credential`, mirroring
241
- * `ShareCore.share()`'s same accommodation for `navigator.share()`.
242
- *
243
- * A `PublicKeyCredential` (`type === "public-key"`, WebAuthn) is rejected as a
244
- * scope violation before touching the platform API — the same v1 boundary
245
- * `get()` enforces on the `publicKey` option (docs/credential-tag-design.md
246
- * §3.2), so this node never becomes a WebAuthn store backdoor.
701
+ * `store(credential)` — shares the same single lane as `get()`.
702
+ * `navigator.credentials.store()` resolves `Promise<void>`, so `value` is
703
+ * synthesized as an echo of the caller's `credential`. A `PublicKeyCredential`
704
+ * (`type === "public-key"`, WebAuthn) is rejected as a scope violation before
705
+ * touching the platform API.
247
706
  */
248
707
  async store(credential) {
249
708
  if (credential?.type === "public-key") {
250
- this._setError({ name: "NotSupportedError", message: "WebAuthn (publicKey) credentials are out of scope for @wcstack/credential v1. Use a dedicated WebAuthn node instead." });
709
+ const message = "WebAuthn (publicKey) credentials are out of scope for @wcstack/credential v1. Use a dedicated WebAuthn node instead.";
710
+ this._setErrorInfo(WCS_CREDENTIAL_ERROR_CODE.OutOfScope, "start", false, message);
711
+ this._setError({ name: "NotSupportedError", message });
251
712
  return null;
252
713
  }
253
- const api = this._api();
254
- if (!api) {
255
- this._setError({ message: "Credential Management API is not supported in this browser." });
714
+ const assessment = this.platformAssessment;
715
+ if (!requiredCapabilitiesAvailable(assessment, CredentialCore.REQUIRED_CAPABILITIES)) {
716
+ const missing = CredentialCore.REQUIRED_CAPABILITIES.find((id) => assessment.availability.get(id) !== "available");
717
+ const message = "Credential Management API is not supported in this browser.";
718
+ this._setErrorInfo(WCS_CREDENTIAL_ERROR_CODE.CapabilityMissing, "start", false, message, missing);
719
+ this._setError({ message });
256
720
  return null;
257
721
  }
258
- const gen = ++this._gen;
259
- this._setLoading(true);
260
- // Reset the previous outcome before starting a new store so a stale
261
- // cancelled/error does not linger into this call's result.
262
- this._setError(null);
263
- this._setCancelled(false);
264
- try {
265
- await api.store(credential);
266
- if (gen !== this._gen)
267
- return null;
268
- this._setValue(credential);
269
- this._setLoading(false);
722
+ const nav = globalThis.navigator;
723
+ return this._run(async () => {
724
+ await nav.credentials.store(credential);
270
725
  return credential;
271
- }
272
- catch (e) {
273
- if (gen !== this._gen)
274
- return null;
275
- if (this._isCancellation(e)) {
276
- this._setCancelled(true);
277
- }
278
- else {
279
- this._setError(this._normalizeError(e));
280
- }
281
- this._setLoading(false);
282
- return null;
283
- }
726
+ });
284
727
  }
285
728
  }
286
729
 
@@ -374,6 +817,9 @@ class WcsCredential extends HTMLElement {
374
817
  get cancelled() {
375
818
  return this._core.cancelled;
376
819
  }
820
+ get errorInfo() {
821
+ return this._core.errorInfo;
822
+ }
377
823
  get connectedCallbackPromise() {
378
824
  return this._connectedCallbackPromise;
379
825
  }
@@ -407,5 +853,5 @@ function bootstrapCredential(userConfig) {
407
853
  registerComponents();
408
854
  }
409
855
 
410
- export { CredentialCore, WcsCredential, bootstrapCredential, getConfig };
856
+ export { CredentialCore, WCS_CREDENTIAL_ERROR_CODE, WcsCredential, bootstrapCredential, getConfig };
411
857
  //# sourceMappingURL=index.esm.js.map