@icp-sdk/auth 8.0.2 → 9.0.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 (85) hide show
  1. package/dist/esm/client/app-delegation-source.d.ts +65 -0
  2. package/dist/esm/client/app-delegation-source.js +58 -0
  3. package/dist/esm/client/app-delegation-source.js.map +1 -0
  4. package/dist/esm/client/app-lock.d.ts +63 -0
  5. package/dist/esm/client/app-lock.js +92 -0
  6. package/dist/esm/client/app-lock.js.map +1 -0
  7. package/dist/esm/client/auth-client.d.ts +270 -53
  8. package/dist/esm/client/auth-client.js +1017 -414
  9. package/dist/esm/client/auth-client.js.map +1 -1
  10. package/dist/esm/client/base64.d.ts +8 -0
  11. package/dist/esm/client/base64.js +28 -0
  12. package/dist/esm/client/base64.js.map +1 -0
  13. package/dist/esm/client/cookie-state-storage.d.ts +59 -0
  14. package/dist/esm/client/cookie-state-storage.js +245 -0
  15. package/dist/esm/client/cookie-state-storage.js.map +1 -0
  16. package/dist/esm/client/credential-storage.d.ts +48 -0
  17. package/dist/esm/client/credential-storage.js +2 -0
  18. package/dist/esm/client/credential-storage.js.map +1 -0
  19. package/dist/esm/client/db.d.ts +6 -1
  20. package/dist/esm/client/db.js +56 -15
  21. package/dist/esm/client/db.js.map +1 -1
  22. package/dist/esm/client/foreground-refresh.d.ts +35 -0
  23. package/dist/esm/client/foreground-refresh.js +83 -0
  24. package/dist/esm/client/foreground-refresh.js.map +1 -0
  25. package/dist/esm/client/idb-credential-storage.d.ts +25 -0
  26. package/dist/esm/client/idb-credential-storage.js +69 -0
  27. package/dist/esm/client/idb-credential-storage.js.map +1 -0
  28. package/dist/esm/client/index.d.ts +14 -3
  29. package/dist/esm/client/index.js +13 -3
  30. package/dist/esm/client/index.js.map +1 -1
  31. package/dist/esm/client/local-credential-storage.d.ts +26 -0
  32. package/dist/esm/client/local-credential-storage.js +59 -0
  33. package/dist/esm/client/local-credential-storage.js.map +1 -0
  34. package/dist/esm/client/memory-credential-storage.d.ts +22 -0
  35. package/dist/esm/client/memory-credential-storage.js +35 -0
  36. package/dist/esm/client/memory-credential-storage.js.map +1 -0
  37. package/dist/esm/client/session-delegation.d.ts +40 -0
  38. package/dist/esm/client/session-delegation.js +100 -0
  39. package/dist/esm/client/session-delegation.js.map +1 -0
  40. package/dist/esm/client/session-identity.d.ts +97 -0
  41. package/dist/esm/client/session-identity.js +392 -0
  42. package/dist/esm/client/session-identity.js.map +1 -0
  43. package/dist/esm/client/session-minter.d.ts +94 -0
  44. package/dist/esm/client/session-minter.js +185 -0
  45. package/dist/esm/client/session-minter.js.map +1 -0
  46. package/dist/esm/client/shared-memory-credential-storage.d.ts +44 -0
  47. package/dist/esm/client/shared-memory-credential-storage.js +243 -0
  48. package/dist/esm/client/shared-memory-credential-storage.js.map +1 -0
  49. package/dist/esm/client/slots.d.ts +59 -0
  50. package/dist/esm/client/slots.js +67 -0
  51. package/dist/esm/client/slots.js.map +1 -0
  52. package/dist/esm/client/sso.d.ts +33 -0
  53. package/dist/esm/client/sso.js +120 -0
  54. package/dist/esm/client/sso.js.map +1 -0
  55. package/dist/esm/client/state-storage.d.ts +135 -0
  56. package/dist/esm/client/state-storage.js +178 -0
  57. package/dist/esm/client/state-storage.js.map +1 -0
  58. package/package.json +3 -2
  59. package/src/client/app-delegation-source.ts +86 -0
  60. package/src/client/app-lock.ts +120 -0
  61. package/src/client/auth-client.ts +1300 -487
  62. package/src/client/base64.ts +28 -0
  63. package/src/client/cookie-state-storage.ts +277 -0
  64. package/src/client/credential-storage.ts +56 -0
  65. package/src/client/db.ts +56 -12
  66. package/src/client/foreground-refresh.ts +92 -0
  67. package/src/client/idb-credential-storage.ts +82 -0
  68. package/src/client/index.ts +24 -8
  69. package/src/client/local-credential-storage.ts +68 -0
  70. package/src/client/memory-credential-storage.ts +41 -0
  71. package/src/client/session-delegation.ts +138 -0
  72. package/src/client/session-identity.ts +510 -0
  73. package/src/client/session-minter.ts +286 -0
  74. package/src/client/shared-memory-credential-storage.ts +303 -0
  75. package/src/client/slots.ts +78 -0
  76. package/src/client/sso.ts +122 -0
  77. package/src/client/state-storage.ts +284 -0
  78. package/dist/esm/client/idle-manager.d.ts +0 -83
  79. package/dist/esm/client/idle-manager.js +0 -100
  80. package/dist/esm/client/idle-manager.js.map +0 -1
  81. package/dist/esm/client/storage.d.ts +0 -51
  82. package/dist/esm/client/storage.js +0 -90
  83. package/dist/esm/client/storage.js.map +0 -1
  84. package/src/client/idle-manager.ts +0 -162
  85. package/src/client/storage.ts +0 -114
@@ -0,0 +1,286 @@
1
+ import {
2
+ Actor,
3
+ type DerEncodedPublicKey,
4
+ HttpAgent,
5
+ type HttpAgentOptions,
6
+ type SignIdentity,
7
+ } from '@icp-sdk/core/agent';
8
+ import type { IDL } from '@icp-sdk/core/candid';
9
+ import { Delegation, DelegationChain, DelegationIdentity } from '@icp-sdk/core/identity';
10
+ import type { Principal } from '@icp-sdk/core/principal';
11
+ import { type AppDelegationSource, SessionGoneError } from './app-delegation-source.js';
12
+
13
+ /**
14
+ * Just the three methods an app calls with a session chain. Written out rather
15
+ * than generated because this package carries no Internet Identity declarations,
16
+ * and generating them for three methods would mean carrying the whole interface.
17
+ */
18
+ const idlFactory: IDL.InterfaceFactory = ({ IDL }) => {
19
+ const SessionKey = IDL.Vec(IDL.Nat8);
20
+ const Timestamp = IDL.Nat64;
21
+ const AppSessionError = IDL.Variant({
22
+ NoSuchSession: IDL.Null,
23
+ NoSuchDelegation: IDL.Null,
24
+ InternalCanisterError: IDL.Text,
25
+ });
26
+ const CandidDelegation = IDL.Record({
27
+ pubkey: SessionKey,
28
+ expiration: Timestamp,
29
+ targets: IDL.Opt(IDL.Vec(IDL.Principal)),
30
+ permissions: IDL.Opt(IDL.Text),
31
+ });
32
+ return IDL.Service({
33
+ app_prepare_delegation: IDL.Func(
34
+ [IDL.Record({ session_key: SessionKey })],
35
+ [
36
+ IDL.Variant({
37
+ Ok: IDL.Record({ user_key: SessionKey, expiration: Timestamp }),
38
+ Err: AppSessionError,
39
+ }),
40
+ ],
41
+ [],
42
+ ),
43
+ app_get_delegation: IDL.Func(
44
+ [IDL.Record({ session_key: SessionKey, expiration: Timestamp })],
45
+ [
46
+ IDL.Variant({
47
+ Ok: IDL.Record({ delegation: CandidDelegation, signature: SessionKey }),
48
+ Err: AppSessionError,
49
+ }),
50
+ ],
51
+ ['query'],
52
+ ),
53
+ app_revoke_session: IDL.Func([], [IDL.Variant({ Ok: IDL.Null, Err: AppSessionError })], []),
54
+ });
55
+ };
56
+
57
+ type AppSessionError =
58
+ | { NoSuchSession: null }
59
+ | { NoSuchDelegation: null }
60
+ | { InternalCanisterError: string };
61
+ type Result<T> = { Ok: T } | { Err: AppSessionError };
62
+
63
+ interface SessionService {
64
+ app_prepare_delegation(request: {
65
+ session_key: Uint8Array;
66
+ }): Promise<Result<{ user_key: Uint8Array; expiration: bigint }>>;
67
+ app_get_delegation(request: { session_key: Uint8Array; expiration: bigint }): Promise<
68
+ Result<{
69
+ delegation: {
70
+ pubkey: Uint8Array;
71
+ expiration: bigint;
72
+ targets: [] | [Principal[]];
73
+ permissions: [] | [string];
74
+ };
75
+ signature: Uint8Array;
76
+ }>
77
+ >;
78
+ app_revoke_session(): Promise<Result<null>>;
79
+ }
80
+
81
+ const isGone = (error: AppSessionError): boolean => 'NoSuchSession' in error;
82
+
83
+ /**
84
+ * Raised when the canister signed nothing for the key and expiration asked for,
85
+ * which means the expiration was not one `app_prepare_delegation` returned.
86
+ *
87
+ * Its own error because the remedy is its own: prepare again and use what comes
88
+ * back. Signing in afresh is not it — the session is live, or the call would have
89
+ * been refused for being no session at all.
90
+ */
91
+ class NoSuchDelegationError extends Error {
92
+ constructor() {
93
+ super('Internet Identity signed no delegation for this key and expiration');
94
+ this.name = 'NoSuchDelegationError';
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Unwraps a canister result, turning the one terminal error into
100
+ * {@link SessionGoneError}, the retryable one into
101
+ * {@link NoSuchDelegationError}, and everything else into a plain failure.
102
+ */
103
+ export const unwrapSessionResult = <T>(result: Result<T>): T => {
104
+ if ('Ok' in result) return result.Ok;
105
+ if (isGone(result.Err)) throw new SessionGoneError();
106
+ if ('NoSuchDelegation' in result.Err) throw new NoSuchDelegationError();
107
+ // A candid variant, so interpolating it directly renders "[object Object]" and
108
+ // throws away the canister's own explanation of what went wrong.
109
+ const detail = 'InternalCanisterError' in result.Err ? result.Err.InternalCanisterError : '';
110
+ throw new Error(
111
+ `Internet Identity could not mint a delegation${detail === '' ? '' : `: ${detail}`}`,
112
+ );
113
+ };
114
+
115
+ export interface SessionMinterOptions {
116
+ /** The key the session chain delegates to. Signs the calls made here. */
117
+ sessionKey: SignIdentity;
118
+
119
+ /** The chain proving those calls come from the session. Restricted to II. */
120
+ sessionChain: DelegationChain;
121
+
122
+ /** The canister these calls go to. */
123
+ canisterId: Principal;
124
+
125
+ /**
126
+ * Options for the agent making them. `identity` is not among them: the agent
127
+ * signs as the session, which is what a mint call rests on.
128
+ */
129
+ agentOptions?: Omit<HttpAgentOptions, 'identity'>;
130
+ }
131
+
132
+ /** Mints app delegations by asking the Internet Identity canister for one. */
133
+ export class SessionMinter implements AppDelegationSource {
134
+ readonly #service: SessionService;
135
+
136
+ private constructor(service: SessionService) {
137
+ this.#service = service;
138
+ }
139
+
140
+ static async create(options: SessionMinterOptions): Promise<SessionMinter> {
141
+ const { canisterId } = options;
142
+ assertChainReaches(options.sessionChain, canisterId);
143
+ const agent = await HttpAgent.create({
144
+ ...options.agentOptions,
145
+ // Last, so no agent option can replace the session these calls are made as.
146
+ identity: DelegationIdentity.fromDelegation(options.sessionKey, options.sessionChain),
147
+ });
148
+ return new SessionMinter(Actor.createActor<SessionService>(idlFactory, { agent, canisterId }));
149
+ }
150
+
151
+ /**
152
+ * Prepares and fetches an app delegation, once more if the fetch finds nothing
153
+ * signed for what the prepare handed out.
154
+ *
155
+ * The pair is two calls, and only the first of them is an update: an expiration
156
+ * the canister will not sign for is one this session no longer holds, and the
157
+ * answer is to prepare again rather than to sign in again. Attempted a second
158
+ * time and no further — a third would be pursuing something other than the race
159
+ * this covers.
160
+ */
161
+ async mint(appPublicKey: DerEncodedPublicKey): Promise<DelegationChain> {
162
+ try {
163
+ return await this.#mintOnce(appPublicKey);
164
+ } catch (error) {
165
+ if (!(error instanceof NoSuchDelegationError)) throw error;
166
+ return await this.#mintOnce(appPublicKey);
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Ends the session at the canister.
172
+ *
173
+ * Idempotent at the canister: a session that is already gone is `Ok`, because
174
+ * the caller wanted it gone and it is gone. What comes back as an error is the
175
+ * canister failing to write, and it is raised rather than dropped — an
176
+ * application that told a user it signed them out of their apps should be able
177
+ * to find out that it did not. The call can also fail the way any update call
178
+ * can, and that rejection is the caller's to decide about too.
179
+ */
180
+ async revoke(): Promise<void> {
181
+ unwrapSessionResult(await this.#service.app_revoke_session());
182
+ }
183
+
184
+ async #mintOnce(appPublicKey: DerEncodedPublicKey): Promise<DelegationChain> {
185
+ const prepared = unwrapSessionResult(
186
+ await this.#service.app_prepare_delegation({ session_key: appPublicKey }),
187
+ );
188
+
189
+ const signed = unwrapSessionResult(
190
+ await this.#service.app_get_delegation({
191
+ session_key: appPublicKey,
192
+ // Exactly what was prepared. A different value is a different signature.
193
+ expiration: prepared.expiration,
194
+ }),
195
+ );
196
+
197
+ return appDelegationChain(prepared.user_key, signed);
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Assembles the one-hop chain an app signs its calls with, rooted at the
203
+ * account's own key.
204
+ */
205
+ export function appDelegationChain(
206
+ userKey: Uint8Array,
207
+ signed: {
208
+ delegation: {
209
+ pubkey: Uint8Array;
210
+ expiration: bigint;
211
+ targets: [] | [Principal[]];
212
+ permissions: [] | [string];
213
+ };
214
+ signature: Uint8Array;
215
+ },
216
+ ): DelegationChain {
217
+ // The signature covers whatever fields the canister set, and a delegation
218
+ // carrying permissions cannot be represented by the chain type, so sending one
219
+ // without it would fail verification at the boundary node with nothing to point
220
+ // at. Read-only sessions are unsupported until the type carries the field.
221
+ if (signed.delegation.permissions.length > 0) {
222
+ throw new Error(
223
+ 'This session is read-only, which @icp-sdk/auth cannot act for yet: its delegations carry permissions that a delegation chain has no room for',
224
+ );
225
+ }
226
+
227
+ return DelegationChain.fromDelegations(
228
+ [
229
+ {
230
+ delegation: new Delegation(
231
+ signed.delegation.pubkey,
232
+ signed.delegation.expiration,
233
+ signed.delegation.targets[0],
234
+ ),
235
+ signature: signed.signature as DelegationChain['delegations'][number]['signature'],
236
+ },
237
+ ],
238
+ userKey as DerEncodedPublicKey,
239
+ );
240
+ }
241
+
242
+ /**
243
+ * Refuses a chain that is not a session for `canisterId`.
244
+ *
245
+ * Targets restrict what the chain's final key may call, and the effective
246
+ * restriction is the intersection of the hops that name any: a hop with no
247
+ * targets imposes nothing, so one restricting hop is enough to confine the key.
248
+ * Internet Identity names the canister on both of its hops — on the one the
249
+ * canister signs to the provider's own key, and again on the provider's
250
+ * extension to the application's key — and the intersection is what is checked,
251
+ * so a hop that stopped naming it would still leave the chain confined by the
252
+ * other.
253
+ *
254
+ * So what is refused is a chain no hop restricts, which the session key would
255
+ * otherwise be able to sign any call with, and one whose restrictions do not
256
+ * come out as this canister alone.
257
+ */
258
+ export function assertChainReaches(chain: DelegationChain, canisterId: Principal): void {
259
+ const hops = chain.delegations.map(({ delegation }) => delegation.targets);
260
+ const restricting = hops.filter(
261
+ (hopTargets): hopTargets is Principal[] => hopTargets !== undefined,
262
+ );
263
+
264
+ if (restricting.length === 0) {
265
+ throw new Error(
266
+ `A session chain must be restricted to ${canisterId.toText()}, but no hop of this one names any canister`,
267
+ );
268
+ }
269
+ // An empty list names nothing rather than naming this canister, and a chain
270
+ // that can call nothing is not a session either.
271
+ const namingNothing = restricting.some((hopTargets) => hopTargets.length === 0);
272
+ if (namingNothing) {
273
+ throw new Error(
274
+ `A session chain must be restricted to ${canisterId.toText()}, but a hop of this one names nothing`,
275
+ );
276
+ }
277
+ const elsewhere = restricting
278
+ .flat()
279
+ .filter((target) => target.compareTo(canisterId) !== 'eq')
280
+ .map((target) => target.toText());
281
+ if (elsewhere.length > 0) {
282
+ throw new Error(
283
+ `A session chain must be restricted to ${canisterId.toText()}, but this one also names ${[...new Set(elsewhere)].join(', ')}`,
284
+ );
285
+ }
286
+ }
@@ -0,0 +1,303 @@
1
+ import { DelegationChain, ECDSAKeyIdentity } from '@icp-sdk/core/identity';
2
+ import type { Credential, CredentialStorage } from './credential-storage.js';
3
+
4
+ /** The channel and lock prefix every instance agrees on by default. */
5
+ export const DEFAULT_SHARED_MEMORY_NAME = 'ic-credentials';
6
+
7
+ /** What one slot travels as: a key handle, and the chain as JSON. */
8
+ interface WireCredential {
9
+ slot: string;
10
+ keyPair: CryptoKeyPair;
11
+ chain?: string;
12
+ }
13
+
14
+ /** A write, replicated to every tab as it happens. */
15
+ interface Put extends WireCredential {
16
+ kind: 'put';
17
+ }
18
+
19
+ /** A removal, replicated the same way. */
20
+ interface Drop {
21
+ kind: 'drop';
22
+ slot: string;
23
+ }
24
+
25
+ /** A starting instance asking whoever is already running for what it holds. */
26
+ interface Ask {
27
+ kind: 'ask';
28
+ }
29
+
30
+ /** Everything one instance holds, in answer to an {@link Ask}. */
31
+ interface Offer {
32
+ kind: 'offer';
33
+ entries: WireCredential[];
34
+ }
35
+
36
+ type Message = Put | Drop | Ask | Offer;
37
+
38
+ // Narrowing, not protection. A channel reaches one origin, so a stranger on the
39
+ // name is our own code through a deploy or an application posting on it, and what
40
+ // keeps those harmless is elsewhere: the switch answers nothing it does not
41
+ // recognise, and rebuilding a record catches its own failures. This is what gives
42
+ // `data` a type, so the switch can read the fields it needs.
43
+ const isMessage = (value: unknown): value is Message => {
44
+ if (typeof value !== 'object' || value === null || !('kind' in value)) return false;
45
+ const message = value as { kind: unknown; slot?: unknown; entries?: unknown };
46
+ switch (message.kind) {
47
+ case 'ask':
48
+ return true;
49
+ case 'drop':
50
+ return typeof message.slot === 'string';
51
+ case 'put':
52
+ return typeof message.slot === 'string' && 'keyPair' in message;
53
+ case 'offer':
54
+ return Array.isArray(message.entries);
55
+ default:
56
+ return false;
57
+ }
58
+ };
59
+
60
+ /**
61
+ * Credentials in memory, replicated to the other tabs of this origin.
62
+ *
63
+ * For an environment with no durable medium — a browser with IndexedDB and
64
+ * `localStorage` both unavailable, or an application that has decided nothing may
65
+ * reach disk — where every tab minting for itself is still worth avoiding.
66
+ *
67
+ * Every write is broadcast and every instance applies what it receives, so a
68
+ * steady-state read is local and immediate: the medium is a `Map`, and the channel
69
+ * is what keeps the Maps agreeing. A `CryptoKeyPair` survives a structured clone
70
+ * as a handle that signs and cannot be exported, so what crosses is usable
71
+ * without key material having been sent.
72
+ *
73
+ * Nothing survives the document, which is what `durable` says. What that costs is
74
+ * the last tab of an origin closing: the credentials go with it, and the next load
75
+ * signs in again.
76
+ * @see implements {@link CredentialStorage}
77
+ */
78
+ export class SharedMemoryCredentialStorage implements CredentialStorage<ECDSAKeyIdentity> {
79
+ public readonly shared = true;
80
+ public readonly durable = false;
81
+
82
+ readonly #name: string;
83
+ readonly #channel: BroadcastChannel | undefined;
84
+
85
+ // On the instance and never on the module, so two clients on one page keep
86
+ // their own replicas and no state carries between tests in one process.
87
+ readonly #replica = new Map<string, Credential<ECDSAKeyIdentity>>();
88
+
89
+ // Held for this instance's life under a name only this instance uses, so a peer
90
+ // can wait for the grant that means this one is gone. The browser releases it
91
+ // when the document is torn down, which is the signal no message could carry.
92
+ readonly #id = crypto.randomUUID();
93
+ #releasePresence: (() => void) | undefined;
94
+
95
+ #announceOffer: (() => void) | undefined;
96
+
97
+ // Applying an incoming record is asynchronous, because a key handle and a chain
98
+ // have to be rebuilt from what crossed. Queued so they land in the order they
99
+ // arrived: a put applied after the drop that followed it would resurrect a slot
100
+ // its owner had already cleared.
101
+ #applying: Promise<void> = Promise.resolve();
102
+
103
+ /**
104
+ * Resolves once there is nothing left to wait for: a peer has answered, or
105
+ * every peer that existed when this instance started has gone.
106
+ *
107
+ * Reads await it, so the first one after a cold start sees what the other tabs
108
+ * hold rather than a miss. There is no deadline in it — waiting ends on a lock
109
+ * grant, and a lone instance waits not at all because there is no peer to hold
110
+ * one.
111
+ */
112
+ readonly #synced: Promise<void>;
113
+
114
+ /**
115
+ * @param options - The channel and lock prefix the tabs of this origin share.
116
+ */
117
+ constructor(options?: { name?: string }) {
118
+ this.#name = options?.name ?? DEFAULT_SHARED_MEMORY_NAME;
119
+ this.#channel =
120
+ typeof BroadcastChannel === 'undefined' ? undefined : new BroadcastChannel(this.#name);
121
+ this.#channel?.addEventListener('message', (event) => this.#receive(event.data));
122
+ this.#synced = this.#openReplica();
123
+ }
124
+
125
+ /**
126
+ * Stops replicating and lets go of the name a peer waits on.
127
+ *
128
+ * Only needed when you replace a store while the page lives; the document
129
+ * going away does the same thing.
130
+ */
131
+ public close(): void {
132
+ this.#channel?.close();
133
+ this.#releasePresence?.();
134
+ this.#releasePresence = undefined;
135
+ }
136
+
137
+ public create(): Promise<ECDSAKeyIdentity> {
138
+ return ECDSAKeyIdentity.generate({ extractable: false });
139
+ }
140
+
141
+ public async get(slot: string): Promise<Credential<ECDSAKeyIdentity> | null> {
142
+ await this.#synced;
143
+ // A read reflects everything received, not everything that has finished being
144
+ // rebuilt: applying is asynchronous, so a message already in hand would
145
+ // otherwise read as a miss and cost the caller a credential it had.
146
+ await this.#applying;
147
+ return this.#replica.get(slot) ?? null;
148
+ }
149
+
150
+ public async set(slot: string, credential: Credential<ECDSAKeyIdentity>): Promise<void> {
151
+ this.#replica.set(slot, credential);
152
+ this.#post({ kind: 'put', ...this.#toWire(slot, credential) });
153
+ }
154
+
155
+ public async remove(slot: string): Promise<void> {
156
+ this.#replica.delete(slot);
157
+ this.#post({ kind: 'drop', slot });
158
+ }
159
+
160
+ #presenceName(id: string): string {
161
+ return `${this.#name}:alive:${id}`;
162
+ }
163
+
164
+ /**
165
+ * Takes this instance's presence lock, then waits for the peers that already
166
+ * had one — asking them for what they hold.
167
+ */
168
+ async #openReplica(): Promise<void> {
169
+ const locks = globalThis.navigator?.locks;
170
+ if (locks === undefined || this.#channel === undefined) {
171
+ // Nothing can be enumerated and nothing can answer, so waiting could only
172
+ // cost a load. Every instance is alone, which is calls and not correctness.
173
+ return;
174
+ }
175
+
176
+ const peers = await this.#claimPresence(locks);
177
+ if (peers.length === 0) return;
178
+
179
+ const answered = new Promise<void>((resolve) => {
180
+ this.#announceOffer = resolve;
181
+ });
182
+
183
+ this.#post({ kind: 'ask' });
184
+
185
+ // A peer that answers ends the wait; a peer that dies without answering ends
186
+ // it too, because its lock is granted the moment the browser releases it. So
187
+ // the wait is bounded by the peers themselves rather than by a number.
188
+ const goneEverywhere = Promise.all(
189
+ peers.map((name) => locks.request(name, { mode: 'exclusive' }, () => undefined)),
190
+ );
191
+ await Promise.race([answered, goneEverywhere]);
192
+ }
193
+
194
+ /**
195
+ * Enumerates the peers holding a presence lock, then takes this instance's own.
196
+ *
197
+ * In that order: a snapshot taken after would include this instance, and it
198
+ * would then wait for a lock it holds itself and never be granted.
199
+ */
200
+ async #claimPresence(locks: LockManager): Promise<string[]> {
201
+ const mine = this.#presenceName(this.#id);
202
+ const prefix = `${this.#name}:alive:`;
203
+
204
+ let peers: string[] = [];
205
+ try {
206
+ const snapshot = await locks.query();
207
+ peers = (snapshot.held ?? [])
208
+ .map((lock) => lock.name)
209
+ .filter((name): name is string => name !== undefined && name !== mine)
210
+ .filter((name) => name.startsWith(prefix));
211
+ } catch {
212
+ // query() is not everywhere, and a caller cannot act on its absence. With
213
+ // no snapshot there is no peer to wait for, so this instance starts alone.
214
+ }
215
+
216
+ // Not awaited: the request resolves only when the lock is released, which is
217
+ // when this instance is done with it.
218
+ void locks
219
+ .request(mine, { mode: 'exclusive' }, () => {
220
+ return new Promise<void>((resolve) => {
221
+ this.#releasePresence = resolve;
222
+ });
223
+ })
224
+ .catch(() => undefined);
225
+
226
+ return peers;
227
+ }
228
+
229
+ #receive(data: unknown): void {
230
+ if (!isMessage(data)) return;
231
+
232
+ if (data.kind === 'ask') {
233
+ // Answered from what is queued as well as what has landed, so an instance
234
+ // asked while still applying an earlier message does not describe a state
235
+ // it is halfway out of.
236
+ this.#enqueue(() => {
237
+ const entries = [...this.#replica].map(([slot, credential]) =>
238
+ this.#toWire(slot, credential),
239
+ );
240
+ // Answered even when empty, because the asker is waiting on an answer and
241
+ // "I have nothing" is one. Staying silent would make it wait for this
242
+ // instance to close.
243
+ this.#post({ kind: 'offer', entries });
244
+ });
245
+ return;
246
+ }
247
+
248
+ this.#enqueue(async () => {
249
+ switch (data.kind) {
250
+ case 'offer':
251
+ for (const entry of data.entries) await this.#apply(entry, { overwrite: false });
252
+ // Last, so the answer is complete before anything waiting on it wakes.
253
+ this.#announceOffer?.();
254
+ this.#announceOffer = undefined;
255
+ return;
256
+ case 'put':
257
+ await this.#apply(data, { overwrite: true });
258
+ return;
259
+ case 'drop':
260
+ this.#replica.delete(data.slot);
261
+ return;
262
+ }
263
+ });
264
+ }
265
+
266
+ #enqueue(work: () => void | Promise<void>): void {
267
+ this.#applying = this.#applying.then(work).catch(() => undefined);
268
+ }
269
+
270
+ async #apply(entry: WireCredential, { overwrite }: { overwrite: boolean }): Promise<void> {
271
+ // An offer answers a question asked before anything local was written, so it
272
+ // fills gaps rather than replacing: a write that happened in the meantime is
273
+ // this instance's own and newer than the snapshot being described.
274
+ if (!overwrite && this.#replica.has(entry.slot)) return;
275
+
276
+ try {
277
+ const identity = await ECDSAKeyIdentity.fromKeyPair(entry.keyPair);
278
+ const chain = entry.chain === undefined ? undefined : DelegationChain.fromJSON(entry.chain);
279
+ this.#replica.set(entry.slot, { identity, chain });
280
+ } catch {
281
+ // A record from an incompatible version, or a handle this context cannot
282
+ // adopt. Holding nothing lets the reader acquire again.
283
+ }
284
+ }
285
+
286
+ #toWire(slot: string, credential: Credential<ECDSAKeyIdentity>): WireCredential {
287
+ return {
288
+ slot,
289
+ keyPair: credential.identity.getKeyPair(),
290
+ chain: credential.chain && JSON.stringify(credential.chain.toJSON()),
291
+ };
292
+ }
293
+
294
+ #post(message: Message): void {
295
+ try {
296
+ this.#channel?.postMessage(message);
297
+ } catch {
298
+ // A closed channel, or a payload this browser will not clone. The write has
299
+ // already landed in this instance's own replica, so the cost is a peer
300
+ // missing it and minting for itself.
301
+ }
302
+ }
303
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Every name one client writes under, and the one thing that knows the whole set.
3
+ *
4
+ * Its own module rather than living beside the credential store or the state
5
+ * store, because it belongs to neither: the credentials and the state of a
6
+ * sign-in are named from one namespace, and a list that sat in either file would
7
+ * have to reach across to the other.
8
+ */
9
+
10
+ /**
11
+ * The bare names, before a namespace is applied.
12
+ *
13
+ * One list, so a name cannot be added to some of the places that need it and
14
+ * missed in others. It was two lists and a hand-written mapping, and the state
15
+ * record was left out of the namespacing for exactly that reason.
16
+ */
17
+ const SLOT_NAMES = {
18
+ /** The session's key and the chain issued to it. */
19
+ session: 'session',
20
+
21
+ /**
22
+ * Where a sign-in ceremony keeps its key until it returns.
23
+ *
24
+ * Its own name rather than {@link SLOT_NAMES.session}, so a ceremony that is
25
+ * cancelled or never comes back cannot take a working session with it.
26
+ */
27
+ sessionPending: 'session-pending',
28
+
29
+ /** The key an application signs with, and its delegation. */
30
+ app: 'app',
31
+
32
+ /**
33
+ * Where a ceremony keeps the credential it minted until its session is stored.
34
+ *
35
+ * The store reaches every tab of the origin, so writing to the app name during
36
+ * a ceremony — or clearing it first — would change what those tabs act with
37
+ * before the sign-in has succeeded. This is promoted instead, once there is a
38
+ * session behind it.
39
+ */
40
+ appPending: 'app-pending',
41
+
42
+ /**
43
+ * The record of who is signed in here and until when.
44
+ *
45
+ * Not a credential slot, and named here anyway: a namespace that moved the
46
+ * credentials and left this fixed would give two clients their own keys and one
47
+ * shared answer to who is signed in.
48
+ */
49
+ state: 'ic-session-state',
50
+ } as const;
51
+
52
+ /**
53
+ * What separates a namespace from the name it prefixes.
54
+ *
55
+ * A cross-origin contract rather than a detail: a sibling subdomain reads the
56
+ * state record by name, so both sides have to agree on this byte.
57
+ */
58
+ const NAMESPACE_SEPARATOR = ':';
59
+
60
+ /** Every name a client writes under, with its namespace already applied. */
61
+ export type Slots = Record<keyof typeof SLOT_NAMES, string>;
62
+
63
+ /**
64
+ * The names one client writes under, prefixed where a namespace is set.
65
+ *
66
+ * Assigned in one place rather than defaulted by each store: implementations
67
+ * choosing their own is what produced three colliding slots in the arrangement
68
+ * this replaces, and one assigner cannot collide with itself. A namespace moves
69
+ * all of them at once, so an application running two clients under one origin
70
+ * separates them with one string rather than renaming some and missing others.
71
+ * @param namespace - Prefix for every name, or `undefined` for the bare ones.
72
+ */
73
+ export function slotsFor(namespace?: string): Slots {
74
+ const prefix = namespace === undefined ? '' : `${namespace}${NAMESPACE_SEPARATOR}`;
75
+ return Object.fromEntries(
76
+ Object.entries(SLOT_NAMES).map(([key, name]) => [key, `${prefix}${name}`]),
77
+ ) as Slots;
78
+ }