@wcstack/credential 1.20.0 → 1.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +5 -0
- package/README.md +6 -0
- package/dist/index.d.ts +133 -40
- package/dist/index.esm.js +566 -120
- package/dist/index.esm.js.map +1 -1
- package/dist/index.esm.min.js +1 -1
- package/dist/index.esm.min.js.map +1 -1
- package/package.json +1 -1
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
|
-
*
|
|
48
|
-
*
|
|
49
|
-
|
|
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
|
-
*
|
|
52
|
-
* (
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
-
*
|
|
62
|
-
* (
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
-
|
|
91
|
-
//
|
|
92
|
-
|
|
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()
|
|
116
|
-
//
|
|
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.
|
|
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
|
|
136
|
-
//
|
|
137
|
-
//
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
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
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
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
|
-
|
|
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
|
|
205
|
-
if (!
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
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
|
-
|
|
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
|
|
254
|
-
if (!
|
|
255
|
-
|
|
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
|
|
259
|
-
this.
|
|
260
|
-
|
|
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
|