@oxyhq/core 11.0.1 → 12.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.
@@ -10,7 +10,7 @@
10
10
  * ```ts
11
11
  * import { OxyServices, oxyClient } from '@oxyhq/core';
12
12
  *
13
- * const user = await oxyClient.signIn(publicKey);
13
+ * const user = await oxyClient.getCurrentUser();
14
14
  * ```
15
15
  *
16
16
  * Every export below is NOMINAL — no `export *`, no barrels, no compat shims.
@@ -87,7 +87,7 @@ export { runColdBoot } from './utils/coldBoot';
87
87
  export type { ColdBootStep, ColdBootStepResult, ColdBootSession, ColdBootSkip, ColdBootOutcome, RunColdBootOptions, } from './utils/coldBoot';
88
88
  export { buildOAuthAuthorizeUrl, computeCodeChallenge, generateOAuthState, generatePkcePair, DEFAULT_OAUTH_SCOPE, OXY_AUTHORIZE_URL, OXY_OAUTH_STATE_STORAGE_KEY, OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY, OXY_SILENT_OAUTH_ATTEMPTED_KEY, OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY, normalizeOAuthRedirectUri, persistOAuthHandshake, readOAuthHandshake, clearOAuthHandshake, } from './utils/oauthPkce';
89
89
  export type { PkcePair, BuildOAuthAuthorizeUrlParams } from './utils/oauthPkce';
90
- export { buildIdpHubOrigin, buildHubSyncUrl, isIdpHubOrigin, isOfficialWebOrigin, isAllowedDeviceJoinOrigin, normalizeOfficialReturnOrigin, parseHubSyncReturnUrl, } from './utils/officialOrigins';
90
+ export { buildIdpHubOrigin, buildHubSyncUrl, isIdpHubOrigin, isLoopbackOrigin, isOfficialWebOrigin, isAllowedDeviceJoinOrigin, normalizeOfficialReturnOrigin, parseHubSyncReturnUrl, } from './utils/officialOrigins';
91
91
  export { syncHubAfterSignIn, redeemHubTicketOnHub, } from './session/hubSync';
92
92
  export type { SyncHubAfterSignInOptions } from './session/hubSync';
93
93
  export { SessionClient } from './session/SessionClient';
@@ -450,50 +450,6 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
450
450
  available: boolean;
451
451
  message: string;
452
452
  }>;
453
- /**
454
- * Register a new user with email/username and password
455
- */
456
- signUp(username: string, email: string, password: string, deviceName?: string, deviceFingerprint?: any): Promise<SessionLoginResponse>;
457
- /**
458
- * Sign in with email or username and password
459
- */
460
- signIn(identifier: string, password: string, deviceName?: string, deviceFingerprint?: any): Promise<SessionLoginResponse>;
461
- /**
462
- * Convenience helper for email sign-in
463
- */
464
- signInWithEmail(email: string, password: string, deviceName?: string, deviceFingerprint?: any): Promise<SessionLoginResponse>;
465
- /**
466
- * Device-first password sign-in. Unlike the legacy {@link signIn} (which
467
- * assumes a one-step session and is kept intact for existing callers until
468
- * the F4 cutover), this returns the FULL `POST /auth/login` contract — the
469
- * discriminated {@link LoginResult}: either a 2FA challenge
470
- * (`{ twoFactorRequired, loginToken }`) to complete via
471
- * {@link completeTwoFactorSignIn}, or a session arm.
472
- *
473
- * On the session arm, a returned access token is planted immediately
474
- * (mirroring {@link verifyChallenge}), so the caller has an authenticated
475
- * client without a second round-trip. The response's `deviceId` +
476
- * `deviceSecret` are the zero-cookie restore credential the caller persists.
477
- */
478
- passwordSignIn(identifier: string, password: string, options?: {
479
- deviceName?: string;
480
- deviceFingerprint?: string;
481
- deviceId?: string;
482
- }): Promise<LoginResult>;
483
- /**
484
- * Complete a 2FA-gated sign-in started by {@link passwordSignIn}. Presents
485
- * the short-lived `loginToken` with either a TOTP `token` or a `backupCode`
486
- * to `POST /security/2fa/verify-login`, which must resolve to the session
487
- * arm of {@link LoginResult} (a second 2FA challenge here is a protocol
488
- * error). A returned access token is planted immediately.
489
- */
490
- completeTwoFactorSignIn(params: {
491
- loginToken: string;
492
- token?: string;
493
- backupCode?: string;
494
- deviceName?: string;
495
- deviceId?: string;
496
- }): Promise<LoginSessionResult>;
497
453
  /**
498
454
  * Begin a WebAuthn / passkey REGISTRATION ceremony. Requests the
499
455
  * `PublicKeyCredentialCreationOptions` the browser's `navigator.credentials
@@ -543,9 +499,9 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
543
499
  * Finish a WebAuthn / passkey AUTHENTICATION ceremony. Forwards the opaque
544
500
  * browser `AuthenticationResponseJSON` (`response`) alongside the
545
501
  * device-session envelope. Resolves to the SAME {@link LoginResult} contract
546
- * as `POST /auth/verify`; on the session arm the access token is planted
547
- * immediately (mirroring {@link passwordSignIn}), and the response's
548
- * `deviceId` + `deviceSecret` are the zero-cookie restore credential.
502
+ * as `POST /auth/verify`; the access token is planted immediately, and the
503
+ * response's `deviceId` + `deviceSecret` are the zero-cookie restore
504
+ * credential.
549
505
  */
550
506
  webauthnLoginVerify(response: unknown, envelope?: {
551
507
  deviceName?: string;
@@ -554,7 +510,7 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
554
510
  }): Promise<LoginResult>;
555
511
  /**
556
512
  * Exchange an OAuth authorization code (returned to the RP redirect URI
557
- * after password sign-in at auth.oxy.so) for a device-first session.
513
+ * after sign-in at auth.oxy.so) for a device-first session.
558
514
  * Public first-party clients use PKCE (`codeVerifier`); the access token is
559
515
  * planted immediately on success.
560
516
  */
@@ -33,7 +33,7 @@ import type { OxyServicesBase } from '../OxyServices.base';
33
33
  */
34
34
  export type IdentityRecordType = OxySignedRecordType;
35
35
  /** Auth-method types that can be unlinked via {@link OxyServicesIdentityMixin}. */
36
- export type UnlinkableAuthMethodType = 'identity' | 'password' | 'google' | 'apple' | 'github';
36
+ export type UnlinkableAuthMethodType = 'identity' | 'webauthn';
37
37
  /**
38
38
  * Result of a link/unlink auth-method mutation (`POST /auth/link`,
39
39
  * `DELETE /auth/link/:type`).
@@ -106,14 +106,6 @@ export declare function OxyServicesIdentityMixin<T extends typeof OxyServicesBas
106
106
  * exact bytes the server reconstructs and verifies.
107
107
  */
108
108
  linkIdentityKey(): Promise<LinkAuthMethodResult>;
109
- /**
110
- * Link password authentication to the current account. Adds a `password`
111
- * auth method (does not remove existing methods).
112
- *
113
- * @param email - The email to associate with password auth.
114
- * @param password - The new password (server enforces strength rules).
115
- */
116
- linkPassword(email: string, password: string): Promise<LinkAuthMethodResult>;
117
109
  /**
118
110
  * Unlink an authentication method from the current account. The server
119
111
  * refuses to remove the last remaining method (the account would become
@@ -122,6 +114,18 @@ export declare function OxyServicesIdentityMixin<T extends typeof OxyServicesBas
122
114
  * @param type - The auth-method type to remove.
123
115
  */
124
116
  unlinkAuthMethod(type: UnlinkableAuthMethodType): Promise<LinkAuthMethodResult>;
117
+ /**
118
+ * Remove ONE passkey (WebAuthn credential) from the current account.
119
+ *
120
+ * Passkeys are per-credential, so unlike {@link unlinkAuthMethod} (which
121
+ * removes an auth method by type) this targets a specific credential id.
122
+ * The server refuses to remove the last remaining auth method (the account
123
+ * would become inaccessible) and deletes the stored `WebauthnCredential`.
124
+ *
125
+ * @param credentialId - The passkey's public credential id
126
+ * (`AuthMethodEntry.credentialId`).
127
+ */
128
+ removePasskey(credentialId: string): Promise<LinkAuthMethodResult>;
125
129
  /**
126
130
  * Sign a record with the on-device identity key, WITHOUT publishing it.
127
131
  * The subject is the current user's DID. NATIVE-ONLY (requires a stored
@@ -21,9 +21,8 @@
21
21
  * `oxyServices.signInWithSharedIdentity`, else the cross-device QR handoff
22
22
  * via `startCommonsSignIn` → poll → `claimSessionByToken`).
23
23
  *
24
- * It deliberately owns NO password/2FA logic those live at the IdP
25
- * (auth.oxy.so). {@link AccountDialogController.openPasswordAtOxyAuth} only
26
- * builds the hand-off URL; device-first convergence syncs the session back.
24
+ * Sign-in is passkey (WebAuthn) or the Commons QR / shared-keychain handoff
25
+ * password, social login, and 2FA were removed ecosystem-wide.
27
26
  */
28
27
  import type { OxyServices } from '../OxyServices';
29
28
  import type { SessionLoginResponse, MinimalUserData } from '../models/session';
@@ -96,14 +95,6 @@ export interface AccountDialogControllerOptions {
96
95
  commitSession?: (session: SessionLoginResponse) => Promise<void>;
97
96
  /** Notified after a completed sign-in (bearer planted + session committed). */
98
97
  onSignedIn?: (user: MinimalUserData) => void;
99
- /** Central IdP apex for `openPasswordAtOxyAuth` (defaults to `CENTRAL_IDP_APEX`). */
100
- idpApex?: string;
101
- /**
102
- * Registered OAuth redirect URI for this RP (exact match against
103
- * `Application.redirectUris`). When set, wins over `returnUrl` /
104
- * `location.origin` normalization in {@link openPasswordAtOxyAuth}.
105
- */
106
- authRedirectUri?: string | null;
107
98
  /**
108
99
  * QR device-flow FALLBACK poll interval in ms (default 12000). The primary
109
100
  * approval signal is the `/auth-session` socket's `auth_update` event (instant);
@@ -119,8 +110,8 @@ export interface AccountDialogControllerOptions {
119
110
  */
120
111
  socketFactory?: SocketIOFactory;
121
112
  /**
122
- * Optional URL opener. When provided, `openPasswordAtOxyAuth` invokes it with
123
- * the built URL in addition to returning it (web: `location.assign`; native:
113
+ * Optional URL opener. When provided, the controller invokes it to deep-link
114
+ * the Commons app for the QR handoff (web: `location.assign`; native:
124
115
  * `Linking.openURL`). Headless core never touches `window`/`Linking` itself.
125
116
  */
126
117
  openUrl?: (url: string) => void;
@@ -143,8 +134,6 @@ export declare class AccountDialogController {
143
134
  private readonly locale?;
144
135
  private readonly commitSession?;
145
136
  private readonly onSignedIn?;
146
- private readonly idpApex;
147
- private readonly authRedirectUri;
148
137
  private readonly pollIntervalMs;
149
138
  private readonly openUrl?;
150
139
  private readonly canOpenApp?;
@@ -277,25 +266,6 @@ export declare class AccountDialogController {
277
266
  private maybeOpenCommons;
278
267
  /** Tear down the active sign-in device flow (timers + socket + token) and reset to idle. */
279
268
  cancelSignIn(): void;
280
- /**
281
- * Build (and, when an `openUrl` handler was supplied, open) the auth.oxy.so
282
- * password sign-in URL. Password + 2FA are NOT in the SDK — they live at the
283
- * IdP; this only hands off. Device-first: after login at the IdP the device
284
- * session converges and the caller is woken via the device socket /
285
- * `BroadcastChannel`, so the URL only needs to point at the IdP sign-in with
286
- * the right return.
287
- *
288
- * @param params.returnUrl - Where the IdP returns after login. Defaults to the
289
- * current document URL on web (`globalThis.location.href`); pass explicitly
290
- * on native (no `location`).
291
- * @param params.state - Optional opaque state echoed back on return.
292
- * @returns The absolute auth.oxy.so sign-in URL.
293
- */
294
- openPasswordAtOxyAuth(params?: {
295
- returnUrl?: string;
296
- state?: string;
297
- redirectUri?: string;
298
- }): Promise<string>;
299
269
  private scheduleNextPoll;
300
270
  /**
301
271
  * Run one status check + (on approval) claim. Triggered by the fallback timer
@@ -5,6 +5,12 @@
5
5
  export declare function buildIdpHubOrigin(): string;
6
6
  /** Whether the current web origin is the central IdP hub (`auth.oxy.so`). */
7
7
  export declare function isIdpHubOrigin(): boolean;
8
+ /**
9
+ * Whether an origin is a loopback / local-dev origin (`localhost`, `127.0.0.1`,
10
+ * or `[::1]` on any port, http or https). Local dev must never be bounced to a
11
+ * hosted IdP for cross-origin session restore.
12
+ */
13
+ export declare function isLoopbackOrigin(origin: string): boolean;
8
14
  /** Whether an origin belongs to the official Oxy web ecosystem. */
9
15
  export declare function isOfficialWebOrigin(origin: string): boolean;
10
16
  /** Normalize and validate a return URL against official origins. Returns origin only. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "11.0.1",
3
+ "version": "12.0.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -109,7 +109,7 @@
109
109
  }
110
110
  },
111
111
  "dependencies": {
112
- "@oxyhq/contracts": "^0.14.2",
112
+ "@oxyhq/contracts": "^0.15.0",
113
113
  "@oxyhq/protocol": "^0.1.5",
114
114
  "bip39": "^3.1.0",
115
115
  "buffer": "^6.0.3",
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * ```ts
11
11
  * import { OxyServices, oxyClient } from '@oxyhq/core';
12
12
  *
13
- * const user = await oxyClient.signIn(publicKey);
13
+ * const user = await oxyClient.getCurrentUser();
14
14
  * ```
15
15
  *
16
16
  * Every export below is NOMINAL — no `export *`, no barrels, no compat shims.
@@ -558,6 +558,7 @@ export {
558
558
  buildIdpHubOrigin,
559
559
  buildHubSyncUrl,
560
560
  isIdpHubOrigin,
561
+ isLoopbackOrigin,
561
562
  isOfficialWebOrigin,
562
563
  isAllowedDeviceJoinOrigin,
563
564
  normalizeOfficialReturnOrigin,
@@ -610,9 +611,10 @@ export type {
610
611
 
611
612
  // Headless controller for the unified account dialog. Framework-agnostic
612
613
  // state machine + subscribe/getSnapshot store (bind via `useSyncExternalStore`)
613
- // — no password/2FA logic (that lives at the IdP; `openPasswordAtOxyAuth` only
614
- // hands off). Reuses `SessionClient.switchAccount` / `oxyServices.switchToAccount`
615
- // for the uniform switch and the existing device-flow methods for sign-in.
614
+ // — sign-in is passkey (WebAuthn) or the Commons QR / shared-keychain handoff;
615
+ // password, social login, and 2FA were removed ecosystem-wide. Reuses
616
+ // `SessionClient.switchAccount` / `oxyServices.switchToAccount` for the uniform
617
+ // switch and the existing device-flow methods for sign-in.
616
618
  export {
617
619
  AccountDialogController,
618
620
  createAccountDialogController,
@@ -1058,144 +1058,6 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1058
1058
  }
1059
1059
  }
1060
1060
 
1061
- /**
1062
- * Register a new user with email/username and password
1063
- */
1064
- async signUp(
1065
- username: string,
1066
- email: string,
1067
- password: string,
1068
- deviceName?: string,
1069
- deviceFingerprint?: any
1070
- ): Promise<SessionLoginResponse> {
1071
- try {
1072
- const session = await this.makeRequest<SessionLoginResponse>('POST', '/auth/signup', {
1073
- username,
1074
- email,
1075
- password,
1076
- deviceName,
1077
- deviceFingerprint,
1078
- }, { cache: false });
1079
- return {
1080
- ...session,
1081
- user: normalizeUserIdentity(session.user),
1082
- };
1083
- } catch (error) {
1084
- throw this.handleError(error);
1085
- }
1086
- }
1087
-
1088
- /**
1089
- * Sign in with email or username and password
1090
- */
1091
- async signIn(
1092
- identifier: string,
1093
- password: string,
1094
- deviceName?: string,
1095
- deviceFingerprint?: any
1096
- ): Promise<SessionLoginResponse> {
1097
- try {
1098
- const session = await this.makeRequest<SessionLoginResponse>('POST', '/auth/login', {
1099
- identifier,
1100
- password,
1101
- deviceName,
1102
- deviceFingerprint,
1103
- }, { cache: false });
1104
- return {
1105
- ...session,
1106
- user: normalizeUserIdentity(session.user),
1107
- };
1108
- } catch (error) {
1109
- throw this.handleError(error);
1110
- }
1111
- }
1112
-
1113
- /**
1114
- * Convenience helper for email sign-in
1115
- */
1116
- async signInWithEmail(
1117
- email: string,
1118
- password: string,
1119
- deviceName?: string,
1120
- deviceFingerprint?: any
1121
- ): Promise<SessionLoginResponse> {
1122
- return this.signIn(email, password, deviceName, deviceFingerprint);
1123
- }
1124
-
1125
- /**
1126
- * Device-first password sign-in. Unlike the legacy {@link signIn} (which
1127
- * assumes a one-step session and is kept intact for existing callers until
1128
- * the F4 cutover), this returns the FULL `POST /auth/login` contract — the
1129
- * discriminated {@link LoginResult}: either a 2FA challenge
1130
- * (`{ twoFactorRequired, loginToken }`) to complete via
1131
- * {@link completeTwoFactorSignIn}, or a session arm.
1132
- *
1133
- * On the session arm, a returned access token is planted immediately
1134
- * (mirroring {@link verifyChallenge}), so the caller has an authenticated
1135
- * client without a second round-trip. The response's `deviceId` +
1136
- * `deviceSecret` are the zero-cookie restore credential the caller persists.
1137
- */
1138
- async passwordSignIn(
1139
- identifier: string,
1140
- password: string,
1141
- options: { deviceName?: string; deviceFingerprint?: string; deviceId?: string } = {},
1142
- ): Promise<LoginResult> {
1143
- try {
1144
- const res = await this.makeRequest<unknown>('POST', '/auth/login', {
1145
- identifier,
1146
- password,
1147
- deviceName: options.deviceName,
1148
- deviceFingerprint: options.deviceFingerprint,
1149
- ...(options.deviceId ? { deviceId: options.deviceId } : {}),
1150
- }, { cache: false });
1151
- const parsed = safeParseContract(loginResultSchema, res);
1152
- if (!parsed) {
1153
- throw new Error('auth/login returned an unexpected response shape');
1154
- }
1155
- if (!('twoFactorRequired' in parsed) && parsed.accessToken) {
1156
- this.setTokens(parsed.accessToken);
1157
- }
1158
- return parsed;
1159
- } catch (error) {
1160
- throw this.handleError(error);
1161
- }
1162
- }
1163
-
1164
- /**
1165
- * Complete a 2FA-gated sign-in started by {@link passwordSignIn}. Presents
1166
- * the short-lived `loginToken` with either a TOTP `token` or a `backupCode`
1167
- * to `POST /security/2fa/verify-login`, which must resolve to the session
1168
- * arm of {@link LoginResult} (a second 2FA challenge here is a protocol
1169
- * error). A returned access token is planted immediately.
1170
- */
1171
- async completeTwoFactorSignIn(params: {
1172
- loginToken: string;
1173
- token?: string;
1174
- backupCode?: string;
1175
- deviceName?: string;
1176
- deviceId?: string;
1177
- }): Promise<LoginSessionResult> {
1178
- try {
1179
- const res = await this.makeRequest<unknown>('POST', '/security/2fa/verify-login', {
1180
- loginToken: params.loginToken,
1181
- token: params.token,
1182
- backupCode: params.backupCode,
1183
- deviceName: params.deviceName,
1184
- ...(params.deviceId ? { deviceId: params.deviceId } : {}),
1185
- }, { cache: false });
1186
- const parsed = safeParseContract(loginResultSchema, res);
1187
- if (!parsed || 'twoFactorRequired' in parsed) {
1188
- throw new Error('security/2fa/verify-login returned an unexpected response shape');
1189
- }
1190
- if (parsed.accessToken) {
1191
- this.setTokens(parsed.accessToken);
1192
- }
1193
- return parsed;
1194
- } catch (error) {
1195
- throw this.handleError(error);
1196
- }
1197
- }
1198
-
1199
1061
  /**
1200
1062
  * Begin a WebAuthn / passkey REGISTRATION ceremony. Requests the
1201
1063
  * `PublicKeyCredentialCreationOptions` the browser's `navigator.credentials
@@ -1258,7 +1120,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1258
1120
  if (!parsed) {
1259
1121
  throw new Error('auth/webauthn/register/verify returned an unexpected response shape');
1260
1122
  }
1261
- if (!('twoFactorRequired' in parsed) && parsed.accessToken) {
1123
+ if (parsed.accessToken) {
1262
1124
  this.setTokens(parsed.accessToken);
1263
1125
  }
1264
1126
  return parsed;
@@ -1301,9 +1163,9 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1301
1163
  * Finish a WebAuthn / passkey AUTHENTICATION ceremony. Forwards the opaque
1302
1164
  * browser `AuthenticationResponseJSON` (`response`) alongside the
1303
1165
  * device-session envelope. Resolves to the SAME {@link LoginResult} contract
1304
- * as `POST /auth/verify`; on the session arm the access token is planted
1305
- * immediately (mirroring {@link passwordSignIn}), and the response's
1306
- * `deviceId` + `deviceSecret` are the zero-cookie restore credential.
1166
+ * as `POST /auth/verify`; the access token is planted immediately, and the
1167
+ * response's `deviceId` + `deviceSecret` are the zero-cookie restore
1168
+ * credential.
1307
1169
  */
1308
1170
  async webauthnLoginVerify(
1309
1171
  response: unknown,
@@ -1320,7 +1182,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1320
1182
  if (!parsed) {
1321
1183
  throw new Error('auth/webauthn/login/verify returned an unexpected response shape');
1322
1184
  }
1323
- if (!('twoFactorRequired' in parsed) && parsed.accessToken) {
1185
+ if (parsed.accessToken) {
1324
1186
  this.setTokens(parsed.accessToken);
1325
1187
  }
1326
1188
  return parsed;
@@ -1331,7 +1193,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1331
1193
 
1332
1194
  /**
1333
1195
  * Exchange an OAuth authorization code (returned to the RP redirect URI
1334
- * after password sign-in at auth.oxy.so) for a device-first session.
1196
+ * after sign-in at auth.oxy.so) for a device-first session.
1335
1197
  * Public first-party clients use PKCE (`codeVerifier`); the access token is
1336
1198
  * planted immediately on success.
1337
1199
  */
@@ -53,7 +53,7 @@ const OXY_IDENTITY_APEX = 'oxy.so';
53
53
  export type IdentityRecordType = OxySignedRecordType;
54
54
 
55
55
  /** Auth-method types that can be unlinked via {@link OxyServicesIdentityMixin}. */
56
- export type UnlinkableAuthMethodType = 'identity' | 'password' | 'google' | 'apple' | 'github';
56
+ export type UnlinkableAuthMethodType = 'identity' | 'webauthn';
57
57
 
58
58
  /**
59
59
  * Result of a link/unlink auth-method mutation (`POST /auth/link`,
@@ -207,18 +207,18 @@ export function OxyServicesIdentityMixin<T extends typeof OxyServicesBase>(Base:
207
207
  }
208
208
 
209
209
  /**
210
- * Link password authentication to the current account. Adds a `password`
211
- * auth method (does not remove existing methods).
210
+ * Unlink an authentication method from the current account. The server
211
+ * refuses to remove the last remaining method (the account would become
212
+ * inaccessible). Unlinking `identity` downgrades the account to custodial.
212
213
  *
213
- * @param email - The email to associate with password auth.
214
- * @param password - The new password (server enforces strength rules).
214
+ * @param type - The auth-method type to remove.
215
215
  */
216
- async linkPassword(email: string, password: string): Promise<LinkAuthMethodResult> {
216
+ async unlinkAuthMethod(type: UnlinkableAuthMethodType): Promise<LinkAuthMethodResult> {
217
217
  try {
218
218
  const result = await this.makeRequest<LinkAuthMethodResult>(
219
- 'POST',
220
- '/auth/link',
221
- { type: 'password', email, password },
219
+ 'DELETE',
220
+ `/auth/link/${encodeURIComponent(type)}`,
221
+ undefined,
222
222
  { cache: false },
223
223
  );
224
224
  this._invalidateIdentityCaches(this.getCurrentUserId());
@@ -229,17 +229,21 @@ export function OxyServicesIdentityMixin<T extends typeof OxyServicesBase>(Base:
229
229
  }
230
230
 
231
231
  /**
232
- * Unlink an authentication method from the current account. The server
233
- * refuses to remove the last remaining method (the account would become
234
- * inaccessible). Unlinking `identity` downgrades the account to custodial.
232
+ * Remove ONE passkey (WebAuthn credential) from the current account.
235
233
  *
236
- * @param type - The auth-method type to remove.
234
+ * Passkeys are per-credential, so unlike {@link unlinkAuthMethod} (which
235
+ * removes an auth method by type) this targets a specific credential id.
236
+ * The server refuses to remove the last remaining auth method (the account
237
+ * would become inaccessible) and deletes the stored `WebauthnCredential`.
238
+ *
239
+ * @param credentialId - The passkey's public credential id
240
+ * (`AuthMethodEntry.credentialId`).
237
241
  */
238
- async unlinkAuthMethod(type: UnlinkableAuthMethodType): Promise<LinkAuthMethodResult> {
242
+ async removePasskey(credentialId: string): Promise<LinkAuthMethodResult> {
239
243
  try {
240
244
  const result = await this.makeRequest<LinkAuthMethodResult>(
241
245
  'DELETE',
242
- `/auth/link/${encodeURIComponent(type)}`,
246
+ `/auth/link/webauthn/${encodeURIComponent(credentialId)}`,
243
247
  undefined,
244
248
  { cache: false },
245
249
  );
@@ -186,37 +186,49 @@ describe('OxyServices.identity', () => {
186
186
  });
187
187
  });
188
188
 
189
- describe('linkPassword', () => {
190
- it('POSTs /auth/link with the password method and sweeps cache', async () => {
191
- makeRequestSpy.mockResolvedValue({ success: true, message: 'Password auth linked successfully' });
189
+ describe('unlinkAuthMethod', () => {
190
+ it('DELETEs /auth/link/:type and sweeps cache', async () => {
191
+ makeRequestSpy.mockResolvedValue({ success: true, message: 'identity auth unlinked successfully' });
192
192
 
193
- await oxy.linkPassword('nate@oxy.so', 'sup3r-secret');
193
+ await oxy.unlinkAuthMethod('identity');
194
194
 
195
195
  expect(makeRequestSpy).toHaveBeenCalledWith(
196
- 'POST',
197
- '/auth/link',
198
- { type: 'password', email: 'nate@oxy.so', password: 'sup3r-secret' },
196
+ 'DELETE',
197
+ '/auth/link/identity',
198
+ undefined,
199
199
  expect.objectContaining({ cache: false }),
200
200
  );
201
- expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/me');
202
- expect(clearEntrySpy).toHaveBeenCalledWith('GET:/auth/methods');
201
+ expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
203
202
  });
204
203
  });
205
204
 
206
- describe('unlinkAuthMethod', () => {
207
- it('DELETEs /auth/link/:type and sweeps cache', async () => {
208
- makeRequestSpy.mockResolvedValue({ success: true, message: 'identity auth unlinked successfully' });
205
+ describe('removePasskey', () => {
206
+ it('DELETEs /auth/link/webauthn/:credentialID and sweeps cache', async () => {
207
+ makeRequestSpy.mockResolvedValue({ success: true, message: 'Passkey unlinked successfully' });
209
208
 
210
- await oxy.unlinkAuthMethod('identity');
209
+ await oxy.removePasskey('cred-abc');
211
210
 
212
211
  expect(makeRequestSpy).toHaveBeenCalledWith(
213
212
  'DELETE',
214
- '/auth/link/identity',
213
+ '/auth/link/webauthn/cred-abc',
215
214
  undefined,
216
215
  expect.objectContaining({ cache: false }),
217
216
  );
218
217
  expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
219
218
  });
219
+
220
+ it('URL-encodes the credential id', async () => {
221
+ makeRequestSpy.mockResolvedValue({ success: true, message: 'Passkey unlinked successfully' });
222
+
223
+ await oxy.removePasskey('a/b+c=');
224
+
225
+ expect(makeRequestSpy).toHaveBeenCalledWith(
226
+ 'DELETE',
227
+ '/auth/link/webauthn/a%2Fb%2Bc%3D',
228
+ undefined,
229
+ expect.objectContaining({ cache: false }),
230
+ );
231
+ });
220
232
  });
221
233
 
222
234
  describe('signRecord (client-only)', () => {
@@ -3,7 +3,7 @@
3
3
  * (`webauthnRegisterOptions` / `webauthnRegisterVerify` /
4
4
  * `webauthnLoginOptions` / `webauthnLoginVerify`).
5
5
  *
6
- * Stubs `makeRequest` (HTTP-mock style, like `passwordSignIn.test.ts`) and
6
+ * Stubs `makeRequest` (HTTP-mock style) and
7
7
  * asserts: each method hits the right endpoint with the right body; the opaque
8
8
  * ceremony `response` is forwarded verbatim; the login/verify + register/verify
9
9
  * (signup) paths parse the login contract and plant the access token on the