@aooth/user 0.2.2 → 0.2.3

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.
@@ -132,8 +132,10 @@ var UsersStoreAtscriptDb = class extends require_federated_identity_store.UserSt
132
132
  throw e;
133
133
  }
134
134
  }
135
- async delete(id) {
136
- return (await this.table.deleteMany({ id })).deletedCount > 0;
135
+ async delete(id, opts) {
136
+ const filter = { id };
137
+ if (opts?.expectedVersion !== void 0) filter.version = opts.expectedVersion;
138
+ return (await this.table.deleteMany(filter)).deletedCount > 0;
137
139
  }
138
140
  /**
139
141
  * Inline retry loop rather than delegating to @atscript/db's
@@ -1,4 +1,4 @@
1
- import { D as UserCredentials, i as NewFederatedIdentity, k as UserStoreUpdate, n as FederatedIdentityStore, o as UserStore, r as FederatedProfileSnapshot, s as WithCasOptions, t as FederatedIdentity } from "./federated-identity-store-D10-BT9l.cjs";
1
+ import { D as UserCredentials, i as NewFederatedIdentity, k as UserStoreUpdate, n as FederatedIdentityStore, o as UserStore, r as FederatedProfileSnapshot, s as WithCasOptions, t as FederatedIdentity } from "./federated-identity-store-Ds7Ye9qJ.cjs";
2
2
  /**
3
3
  * Structural surface of `AtscriptDbTable` covering exactly the methods this
4
4
  * adapter calls — kept loose so `@aooth/user` doesn't pull `@atscript/db` types
@@ -113,7 +113,9 @@ export declare class UsersStoreAtscriptDb<TUserCustom extends object = object> e
113
113
  findByIdentifier(value: string): Promise<(UserCredentials & TUserCustom) | null>;
114
114
  create(data: UserCredentials & TUserCustom): Promise<void>;
115
115
  update(id: string, update: UserStoreUpdate): Promise<boolean>;
116
- delete(id: string): Promise<boolean>;
116
+ delete(id: string, opts?: {
117
+ expectedVersion?: number;
118
+ }): Promise<boolean>;
117
119
  /**
118
120
  * Inline retry loop rather than delegating to @atscript/db's
119
121
  * `withOptimisticRetry`: that helper expects the mutator to always return a
@@ -1,4 +1,4 @@
1
- import { D as UserCredentials, i as NewFederatedIdentity, k as UserStoreUpdate, n as FederatedIdentityStore, o as UserStore, r as FederatedProfileSnapshot, s as WithCasOptions, t as FederatedIdentity } from "./federated-identity-store-D10-BT9l.mjs";
1
+ import { D as UserCredentials, i as NewFederatedIdentity, k as UserStoreUpdate, n as FederatedIdentityStore, o as UserStore, r as FederatedProfileSnapshot, s as WithCasOptions, t as FederatedIdentity } from "./federated-identity-store-Ds7Ye9qJ.mjs";
2
2
  /**
3
3
  * Structural surface of `AtscriptDbTable` covering exactly the methods this
4
4
  * adapter calls — kept loose so `@aooth/user` doesn't pull `@atscript/db` types
@@ -113,7 +113,9 @@ export declare class UsersStoreAtscriptDb<TUserCustom extends object = object> e
113
113
  findByIdentifier(value: string): Promise<(UserCredentials & TUserCustom) | null>;
114
114
  create(data: UserCredentials & TUserCustom): Promise<void>;
115
115
  update(id: string, update: UserStoreUpdate): Promise<boolean>;
116
- delete(id: string): Promise<boolean>;
116
+ delete(id: string, opts?: {
117
+ expectedVersion?: number;
118
+ }): Promise<boolean>;
117
119
  /**
118
120
  * Inline retry loop rather than delegating to @atscript/db's
119
121
  * `withOptimisticRetry`: that helper expects the mutator to always return a
@@ -131,8 +131,10 @@ var UsersStoreAtscriptDb = class extends UserStore {
131
131
  throw e;
132
132
  }
133
133
  }
134
- async delete(id) {
135
- return (await this.table.deleteMany({ id })).deletedCount > 0;
134
+ async delete(id, opts) {
135
+ const filter = { id };
136
+ if (opts?.expectedVersion !== void 0) filter.version = opts.expectedVersion;
137
+ return (await this.table.deleteMany(filter)).deletedCount > 0;
136
138
  }
137
139
  /**
138
140
  * Inline retry loop rather than delegating to @atscript/db's
@@ -58,10 +58,11 @@ interface AccountData {
58
58
  lastLogin: number;
59
59
  /**
60
60
  * True while the user record exists from an admin-issued invite but the
61
- * invitee has not yet accepted (set password + activate). Used by
62
- * `InviteWorkflow` to gate the accept tail, reject duplicate invites, and
63
- * power `auth/invite/resend` / `auth/invite/cancel`. Absent / `false` once
64
- * the invite has been accepted.
61
+ * invitee has not yet accepted (set password + activate). The invite
62
+ * workflow uses it to gate the accept tail and to detect re-invites
63
+ * (`'reuse'` refreshes the pending row and sends a new link);
64
+ * `UserService.revokeInvitation()` deletes only rows that still have it.
65
+ * Absent / `false` once the invite has been accepted.
65
66
  */
66
67
  pendingInvitation?: boolean;
67
68
  /** Id of the user who issued the pending invite (written by the invite flow). */
@@ -293,9 +294,14 @@ declare abstract class UserStore<T extends object = object> {
293
294
  abstract update(id: string, update: UserStoreUpdate): Promise<boolean>;
294
295
  /**
295
296
  * Hard-delete the row by `id`. Returns `true` when a row was removed, `false`
296
- * when the id was not found.
297
+ * when the id was not found. With `opts.expectedVersion`, delete only while
298
+ * the row's `version` still equals it (`false` otherwise) — a custom store
299
+ * that ignores it makes `UserService.revokeInvitation()` delete
300
+ * unconditionally after its re-check.
297
301
  */
298
- abstract delete(id: string): Promise<boolean>;
302
+ abstract delete(id: string, opts?: {
303
+ expectedVersion?: number;
304
+ }): Promise<boolean>;
299
305
  /**
300
306
  * Run a read-modify-write cycle under optimistic concurrency, keyed by `id`.
301
307
  * Each attempt re-reads (via `findById`), calls `mutator`, and applies the
@@ -58,10 +58,11 @@ interface AccountData {
58
58
  lastLogin: number;
59
59
  /**
60
60
  * True while the user record exists from an admin-issued invite but the
61
- * invitee has not yet accepted (set password + activate). Used by
62
- * `InviteWorkflow` to gate the accept tail, reject duplicate invites, and
63
- * power `auth/invite/resend` / `auth/invite/cancel`. Absent / `false` once
64
- * the invite has been accepted.
61
+ * invitee has not yet accepted (set password + activate). The invite
62
+ * workflow uses it to gate the accept tail and to detect re-invites
63
+ * (`'reuse'` refreshes the pending row and sends a new link);
64
+ * `UserService.revokeInvitation()` deletes only rows that still have it.
65
+ * Absent / `false` once the invite has been accepted.
65
66
  */
66
67
  pendingInvitation?: boolean;
67
68
  /** Id of the user who issued the pending invite (written by the invite flow). */
@@ -293,9 +294,14 @@ declare abstract class UserStore<T extends object = object> {
293
294
  abstract update(id: string, update: UserStoreUpdate): Promise<boolean>;
294
295
  /**
295
296
  * Hard-delete the row by `id`. Returns `true` when a row was removed, `false`
296
- * when the id was not found.
297
+ * when the id was not found. With `opts.expectedVersion`, delete only while
298
+ * the row's `version` still equals it (`false` otherwise) — a custom store
299
+ * that ignores it makes `UserService.revokeInvitation()` delete
300
+ * unconditionally after its re-check.
297
301
  */
298
- abstract delete(id: string): Promise<boolean>;
302
+ abstract delete(id: string, opts?: {
303
+ expectedVersion?: number;
304
+ }): Promise<boolean>;
299
305
  /**
300
306
  * Run a read-modify-write cycle under optimistic concurrency, keyed by `id`.
301
307
  * Each attempt re-reads (via `findById`), calls `mutator`, and applies the
package/dist/index.cjs CHANGED
@@ -410,8 +410,8 @@ var UserService = class {
410
410
  }
411
411
  /**
412
412
  * Creates a user with `account.active: false`. The invite workflow relies
413
- * on this default (see `InviteWorkflow.acceptInvite` — pending invitees stay
414
- * inactive until they accept). For setup scripts / seeders / tests that
413
+ * on this default (pending invitees stay inactive until they accept the
414
+ * invite). For setup scripts / seeders / tests that
415
415
  * don't go through invite, follow up with `activateAccount(id)` or `login()`
416
416
  * will throw `UserAuthError("INACTIVE")` — which the login workflow
417
417
  * deliberately re-maps to `"Invalid credentials"` to avoid account
@@ -540,8 +540,9 @@ var UserService = class {
540
540
  }
541
541
  /**
542
542
  * Hard-delete the user row by `id`. Returns nothing on success. Throws
543
- * `UserAuthError("NOT_FOUND")` when no row matches. Used by the invite
544
- * workflow's `auth/invite/cancel` to revoke a pending invitation.
543
+ * `UserAuthError("NOT_FOUND")` when no row matches. To revoke a pending
544
+ * invitation use {@link revokeInvitation}, which never deletes an account
545
+ * whose invite was accepted meanwhile.
545
546
  */
546
547
  async deleteUser(id) {
547
548
  if (!await this.store.delete(id)) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
@@ -550,7 +551,6 @@ var UserService = class {
550
551
  * Deep-merge `patch` into the user record (top-level fields are shallow-
551
552
  * merged; `account` / `mfa` / `password` are merged per their
552
553
  * `@db.patch.strategy 'merge'` declaration). Returns the patched record.
553
- * Used by the invite workflow's `applyProfile` default fallback.
554
554
  */
555
555
  async update(id, patch) {
556
556
  if (!await this.store.update(id, { set: patch })) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
@@ -577,6 +577,36 @@ var UserService = class {
577
577
  return accepted;
578
578
  }
579
579
  /**
580
+ * Revoke a pending invitation: delete the invited account, so its invite
581
+ * link stops working (the invite workflow answers it with 410
582
+ * `INVITE_CANCELLED`). Returns `false` and deletes nothing when the account
583
+ * is not pending — the invite was already accepted — or when the row
584
+ * changed while the revoke ran. Throws `UserAuthError("NOT_FOUND")` when no
585
+ * row matches `id`.
586
+ *
587
+ * Safe against a concurrent acceptance or re-invite: one compare-and-set
588
+ * write first rotates `account.inviteNonce` while the row is still pending
589
+ * (an accept racing it fails), and the delete is conditional on the row's
590
+ * `version` still being the one that write produced — any write in between
591
+ * (a re-invite, an acceptance) makes it delete nothing. The bundled stores
592
+ * honour the conditional delete; a custom store whose `delete()` ignores
593
+ * `expectedVersion` leaves a window of one round-trip between the re-check
594
+ * and the delete.
595
+ */
596
+ async revokeInvitation(id) {
597
+ const nonce = (0, node_crypto.randomBytes)(16).toString("base64url");
598
+ let pending = false;
599
+ await this.store.withCas(id, (user) => {
600
+ pending = user.account.pendingInvitation === true;
601
+ if (!pending) return null;
602
+ return { set: { account: { inviteNonce: nonce } } };
603
+ });
604
+ if (!pending) return false;
605
+ const claimed = await this.store.findById(id);
606
+ if (!claimed?.account.pendingInvitation || claimed.account.inviteNonce !== nonce) return false;
607
+ return this.store.delete(id, { expectedVersion: claimed.version ?? 0 });
608
+ }
609
+ /**
580
610
  * Flip the account active. `opts.verifiedEmail` lets callers that just
581
611
  * proved an inbox (invite magic link, signup OTP) record the correspondence
582
612
  * address in the same store write — see {@link setVerifiedEmail}.
@@ -1200,7 +1230,10 @@ var UserStoreMemory = class extends require_federated_identity_store.UserStore {
1200
1230
  user.version = (user.version ?? 0) + 1;
1201
1231
  return true;
1202
1232
  }
1203
- async delete(id) {
1233
+ async delete(id, opts) {
1234
+ const user = this.store.get(id);
1235
+ if (!user) return false;
1236
+ if (opts?.expectedVersion !== void 0 && (user.version ?? 0) !== opts.expectedVersion) return false;
1204
1237
  return this.store.delete(id);
1205
1238
  }
1206
1239
  async withCas(id, mutator, opts) {
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as TotpConfig, D as UserCredentials, E as UserAuthErrorType, O as UserServiceConfig, S as PolicyCheckResult, T as TrustedDeviceRecord, _ as PasswordData, a as pickDefinedProfile, b as PasswordPolicyEvalFn, c as AccountData, d as LockoutConfig, f as LoginResult, g as PasswordConfig, h as MfaMethodInfo, i as NewFederatedIdentity, k as UserStoreUpdate, l as DeepPartial, m as MfaMethod, n as FederatedIdentityStore, o as UserStore, p as MfaData, r as FederatedProfileSnapshot, s as WithCasOptions, t as FederatedIdentity, u as LockStatus, v as PasswordPolicyContext, w as TransferablePolicy, x as PasswordPolicyInstance, y as PasswordPolicyDef } from "./federated-identity-store-D10-BT9l.cjs";
1
+ import { C as TotpConfig, D as UserCredentials, E as UserAuthErrorType, O as UserServiceConfig, S as PolicyCheckResult, T as TrustedDeviceRecord, _ as PasswordData, a as pickDefinedProfile, b as PasswordPolicyEvalFn, c as AccountData, d as LockoutConfig, f as LoginResult, g as PasswordConfig, h as MfaMethodInfo, i as NewFederatedIdentity, k as UserStoreUpdate, l as DeepPartial, m as MfaMethod, n as FederatedIdentityStore, o as UserStore, p as MfaData, r as FederatedProfileSnapshot, s as WithCasOptions, t as FederatedIdentity, u as LockStatus, v as PasswordPolicyContext, w as TransferablePolicy, x as PasswordPolicyInstance, y as PasswordPolicyDef } from "./federated-identity-store-Ds7Ye9qJ.cjs";
2
2
  export declare class UserAuthError extends Error {
3
3
  readonly type: UserAuthErrorType;
4
4
  readonly details?: Record<string, unknown> | undefined;
@@ -106,8 +106,8 @@ export declare class UserService<T extends object = object> {
106
106
  getUserSnapshot(id: string, scope: object): Promise<UserCredentials & T>;
107
107
  /**
108
108
  * Creates a user with `account.active: false`. The invite workflow relies
109
- * on this default (see `InviteWorkflow.acceptInvite` — pending invitees stay
110
- * inactive until they accept). For setup scripts / seeders / tests that
109
+ * on this default (pending invitees stay inactive until they accept the
110
+ * invite). For setup scripts / seeders / tests that
111
111
  * don't go through invite, follow up with `activateAccount(id)` or `login()`
112
112
  * will throw `UserAuthError("INACTIVE")` — which the login workflow
113
113
  * deliberately re-maps to `"Invalid credentials"` to avoid account
@@ -160,15 +160,15 @@ export declare class UserService<T extends object = object> {
160
160
  setPassword(id: string, newPassword: string): Promise<void>;
161
161
  /**
162
162
  * Hard-delete the user row by `id`. Returns nothing on success. Throws
163
- * `UserAuthError("NOT_FOUND")` when no row matches. Used by the invite
164
- * workflow's `auth/invite/cancel` to revoke a pending invitation.
163
+ * `UserAuthError("NOT_FOUND")` when no row matches. To revoke a pending
164
+ * invitation use {@link revokeInvitation}, which never deletes an account
165
+ * whose invite was accepted meanwhile.
165
166
  */
166
167
  deleteUser(id: string): Promise<void>;
167
168
  /**
168
169
  * Deep-merge `patch` into the user record (top-level fields are shallow-
169
170
  * merged; `account` / `mfa` / `password` are merged per their
170
171
  * `@db.patch.strategy 'merge'` declaration). Returns the patched record.
171
- * Used by the invite workflow's `applyProfile` default fallback.
172
172
  */
173
173
  update(id: string, patch: Partial<UserCredentials & T>): Promise<UserCredentials & T>;
174
174
  /**
@@ -180,6 +180,24 @@ export declare class UserService<T extends object = object> {
180
180
  * the same account can never both complete.
181
181
  */
182
182
  acceptInvitation(id: string, nonce: string | undefined): Promise<boolean>;
183
+ /**
184
+ * Revoke a pending invitation: delete the invited account, so its invite
185
+ * link stops working (the invite workflow answers it with 410
186
+ * `INVITE_CANCELLED`). Returns `false` and deletes nothing when the account
187
+ * is not pending — the invite was already accepted — or when the row
188
+ * changed while the revoke ran. Throws `UserAuthError("NOT_FOUND")` when no
189
+ * row matches `id`.
190
+ *
191
+ * Safe against a concurrent acceptance or re-invite: one compare-and-set
192
+ * write first rotates `account.inviteNonce` while the row is still pending
193
+ * (an accept racing it fails), and the delete is conditional on the row's
194
+ * `version` still being the one that write produced — any write in between
195
+ * (a re-invite, an acceptance) makes it delete nothing. The bundled stores
196
+ * honour the conditional delete; a custom store whose `delete()` ignores
197
+ * `expectedVersion` leaves a window of one round-trip between the re-check
198
+ * and the delete.
199
+ */
200
+ revokeInvitation(id: string): Promise<boolean>;
183
201
  /**
184
202
  * Flip the account active. `opts.verifiedEmail` lets callers that just
185
203
  * proved an inbox (invite magic link, signup OTP) record the correspondence
@@ -415,7 +433,9 @@ export declare class UserStoreMemory<T extends object = object> extends UserStor
415
433
  */
416
434
  private assertHandlesUnique;
417
435
  update(id: string, update: UserStoreUpdate): Promise<boolean>;
418
- delete(id: string): Promise<boolean>;
436
+ delete(id: string, opts?: {
437
+ expectedVersion?: number;
438
+ }): Promise<boolean>;
419
439
  withCas(id: string, mutator: (current: UserCredentials & T) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
420
440
  }
421
441
  interface FederatedIdentityStoreMemoryOptions {
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as TotpConfig, D as UserCredentials, E as UserAuthErrorType, O as UserServiceConfig, S as PolicyCheckResult, T as TrustedDeviceRecord, _ as PasswordData, a as pickDefinedProfile, b as PasswordPolicyEvalFn, c as AccountData, d as LockoutConfig, f as LoginResult, g as PasswordConfig, h as MfaMethodInfo, i as NewFederatedIdentity, k as UserStoreUpdate, l as DeepPartial, m as MfaMethod, n as FederatedIdentityStore, o as UserStore, p as MfaData, r as FederatedProfileSnapshot, s as WithCasOptions, t as FederatedIdentity, u as LockStatus, v as PasswordPolicyContext, w as TransferablePolicy, x as PasswordPolicyInstance, y as PasswordPolicyDef } from "./federated-identity-store-D10-BT9l.mjs";
1
+ import { C as TotpConfig, D as UserCredentials, E as UserAuthErrorType, O as UserServiceConfig, S as PolicyCheckResult, T as TrustedDeviceRecord, _ as PasswordData, a as pickDefinedProfile, b as PasswordPolicyEvalFn, c as AccountData, d as LockoutConfig, f as LoginResult, g as PasswordConfig, h as MfaMethodInfo, i as NewFederatedIdentity, k as UserStoreUpdate, l as DeepPartial, m as MfaMethod, n as FederatedIdentityStore, o as UserStore, p as MfaData, r as FederatedProfileSnapshot, s as WithCasOptions, t as FederatedIdentity, u as LockStatus, v as PasswordPolicyContext, w as TransferablePolicy, x as PasswordPolicyInstance, y as PasswordPolicyDef } from "./federated-identity-store-Ds7Ye9qJ.mjs";
2
2
  export declare class UserAuthError extends Error {
3
3
  readonly type: UserAuthErrorType;
4
4
  readonly details?: Record<string, unknown> | undefined;
@@ -106,8 +106,8 @@ export declare class UserService<T extends object = object> {
106
106
  getUserSnapshot(id: string, scope: object): Promise<UserCredentials & T>;
107
107
  /**
108
108
  * Creates a user with `account.active: false`. The invite workflow relies
109
- * on this default (see `InviteWorkflow.acceptInvite` — pending invitees stay
110
- * inactive until they accept). For setup scripts / seeders / tests that
109
+ * on this default (pending invitees stay inactive until they accept the
110
+ * invite). For setup scripts / seeders / tests that
111
111
  * don't go through invite, follow up with `activateAccount(id)` or `login()`
112
112
  * will throw `UserAuthError("INACTIVE")` — which the login workflow
113
113
  * deliberately re-maps to `"Invalid credentials"` to avoid account
@@ -160,15 +160,15 @@ export declare class UserService<T extends object = object> {
160
160
  setPassword(id: string, newPassword: string): Promise<void>;
161
161
  /**
162
162
  * Hard-delete the user row by `id`. Returns nothing on success. Throws
163
- * `UserAuthError("NOT_FOUND")` when no row matches. Used by the invite
164
- * workflow's `auth/invite/cancel` to revoke a pending invitation.
163
+ * `UserAuthError("NOT_FOUND")` when no row matches. To revoke a pending
164
+ * invitation use {@link revokeInvitation}, which never deletes an account
165
+ * whose invite was accepted meanwhile.
165
166
  */
166
167
  deleteUser(id: string): Promise<void>;
167
168
  /**
168
169
  * Deep-merge `patch` into the user record (top-level fields are shallow-
169
170
  * merged; `account` / `mfa` / `password` are merged per their
170
171
  * `@db.patch.strategy 'merge'` declaration). Returns the patched record.
171
- * Used by the invite workflow's `applyProfile` default fallback.
172
172
  */
173
173
  update(id: string, patch: Partial<UserCredentials & T>): Promise<UserCredentials & T>;
174
174
  /**
@@ -180,6 +180,24 @@ export declare class UserService<T extends object = object> {
180
180
  * the same account can never both complete.
181
181
  */
182
182
  acceptInvitation(id: string, nonce: string | undefined): Promise<boolean>;
183
+ /**
184
+ * Revoke a pending invitation: delete the invited account, so its invite
185
+ * link stops working (the invite workflow answers it with 410
186
+ * `INVITE_CANCELLED`). Returns `false` and deletes nothing when the account
187
+ * is not pending — the invite was already accepted — or when the row
188
+ * changed while the revoke ran. Throws `UserAuthError("NOT_FOUND")` when no
189
+ * row matches `id`.
190
+ *
191
+ * Safe against a concurrent acceptance or re-invite: one compare-and-set
192
+ * write first rotates `account.inviteNonce` while the row is still pending
193
+ * (an accept racing it fails), and the delete is conditional on the row's
194
+ * `version` still being the one that write produced — any write in between
195
+ * (a re-invite, an acceptance) makes it delete nothing. The bundled stores
196
+ * honour the conditional delete; a custom store whose `delete()` ignores
197
+ * `expectedVersion` leaves a window of one round-trip between the re-check
198
+ * and the delete.
199
+ */
200
+ revokeInvitation(id: string): Promise<boolean>;
183
201
  /**
184
202
  * Flip the account active. `opts.verifiedEmail` lets callers that just
185
203
  * proved an inbox (invite magic link, signup OTP) record the correspondence
@@ -415,7 +433,9 @@ export declare class UserStoreMemory<T extends object = object> extends UserStor
415
433
  */
416
434
  private assertHandlesUnique;
417
435
  update(id: string, update: UserStoreUpdate): Promise<boolean>;
418
- delete(id: string): Promise<boolean>;
436
+ delete(id: string, opts?: {
437
+ expectedVersion?: number;
438
+ }): Promise<boolean>;
419
439
  withCas(id: string, mutator: (current: UserCredentials & T) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
420
440
  }
421
441
  interface FederatedIdentityStoreMemoryOptions {
package/dist/index.mjs CHANGED
@@ -409,8 +409,8 @@ var UserService = class {
409
409
  }
410
410
  /**
411
411
  * Creates a user with `account.active: false`. The invite workflow relies
412
- * on this default (see `InviteWorkflow.acceptInvite` — pending invitees stay
413
- * inactive until they accept). For setup scripts / seeders / tests that
412
+ * on this default (pending invitees stay inactive until they accept the
413
+ * invite). For setup scripts / seeders / tests that
414
414
  * don't go through invite, follow up with `activateAccount(id)` or `login()`
415
415
  * will throw `UserAuthError("INACTIVE")` — which the login workflow
416
416
  * deliberately re-maps to `"Invalid credentials"` to avoid account
@@ -539,8 +539,9 @@ var UserService = class {
539
539
  }
540
540
  /**
541
541
  * Hard-delete the user row by `id`. Returns nothing on success. Throws
542
- * `UserAuthError("NOT_FOUND")` when no row matches. Used by the invite
543
- * workflow's `auth/invite/cancel` to revoke a pending invitation.
542
+ * `UserAuthError("NOT_FOUND")` when no row matches. To revoke a pending
543
+ * invitation use {@link revokeInvitation}, which never deletes an account
544
+ * whose invite was accepted meanwhile.
544
545
  */
545
546
  async deleteUser(id) {
546
547
  if (!await this.store.delete(id)) throw new UserAuthError("NOT_FOUND");
@@ -549,7 +550,6 @@ var UserService = class {
549
550
  * Deep-merge `patch` into the user record (top-level fields are shallow-
550
551
  * merged; `account` / `mfa` / `password` are merged per their
551
552
  * `@db.patch.strategy 'merge'` declaration). Returns the patched record.
552
- * Used by the invite workflow's `applyProfile` default fallback.
553
553
  */
554
554
  async update(id, patch) {
555
555
  if (!await this.store.update(id, { set: patch })) throw new UserAuthError("NOT_FOUND");
@@ -576,6 +576,36 @@ var UserService = class {
576
576
  return accepted;
577
577
  }
578
578
  /**
579
+ * Revoke a pending invitation: delete the invited account, so its invite
580
+ * link stops working (the invite workflow answers it with 410
581
+ * `INVITE_CANCELLED`). Returns `false` and deletes nothing when the account
582
+ * is not pending — the invite was already accepted — or when the row
583
+ * changed while the revoke ran. Throws `UserAuthError("NOT_FOUND")` when no
584
+ * row matches `id`.
585
+ *
586
+ * Safe against a concurrent acceptance or re-invite: one compare-and-set
587
+ * write first rotates `account.inviteNonce` while the row is still pending
588
+ * (an accept racing it fails), and the delete is conditional on the row's
589
+ * `version` still being the one that write produced — any write in between
590
+ * (a re-invite, an acceptance) makes it delete nothing. The bundled stores
591
+ * honour the conditional delete; a custom store whose `delete()` ignores
592
+ * `expectedVersion` leaves a window of one round-trip between the re-check
593
+ * and the delete.
594
+ */
595
+ async revokeInvitation(id) {
596
+ const nonce = randomBytes(16).toString("base64url");
597
+ let pending = false;
598
+ await this.store.withCas(id, (user) => {
599
+ pending = user.account.pendingInvitation === true;
600
+ if (!pending) return null;
601
+ return { set: { account: { inviteNonce: nonce } } };
602
+ });
603
+ if (!pending) return false;
604
+ const claimed = await this.store.findById(id);
605
+ if (!claimed?.account.pendingInvitation || claimed.account.inviteNonce !== nonce) return false;
606
+ return this.store.delete(id, { expectedVersion: claimed.version ?? 0 });
607
+ }
608
+ /**
579
609
  * Flip the account active. `opts.verifiedEmail` lets callers that just
580
610
  * proved an inbox (invite magic link, signup OTP) record the correspondence
581
611
  * address in the same store write — see {@link setVerifiedEmail}.
@@ -1199,7 +1229,10 @@ var UserStoreMemory = class extends UserStore {
1199
1229
  user.version = (user.version ?? 0) + 1;
1200
1230
  return true;
1201
1231
  }
1202
- async delete(id) {
1232
+ async delete(id, opts) {
1233
+ const user = this.store.get(id);
1234
+ if (!user) return false;
1235
+ if (opts?.expectedVersion !== void 0 && (user.version ?? 0) !== opts.expectedVersion) return false;
1203
1236
  return this.store.delete(id);
1204
1237
  }
1205
1238
  async withCas(id, mutator, opts) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aooth/user",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "User credential primitives for aoothjs",
5
5
  "keywords": [
6
6
  "aoothjs",
@@ -60,18 +60,18 @@
60
60
  "access": "public"
61
61
  },
62
62
  "devDependencies": {
63
- "@atscript/core": "^0.2.3",
64
- "@atscript/db": "^0.1.167",
65
- "@atscript/db-sql-tools": "^0.1.167",
66
- "@atscript/db-sqlite": "^0.1.167",
67
- "@atscript/typescript": "^0.2.3",
63
+ "@atscript/core": "^0.2.4",
64
+ "@atscript/db": "^0.1.168",
65
+ "@atscript/db-sql-tools": "^0.1.168",
66
+ "@atscript/db-sqlite": "^0.1.168",
67
+ "@atscript/typescript": "^0.2.4",
68
68
  "@types/better-sqlite3": "^7.6.13",
69
- "@uniqu/core": "^0.1.15",
69
+ "@uniqu/core": "^0.2.0",
70
70
  "better-sqlite3": "^12.6.2",
71
- "unplugin-atscript": "^0.2.3"
71
+ "unplugin-atscript": "^0.2.4"
72
72
  },
73
73
  "peerDependencies": {
74
- "@atscript/db": "^0.1.167"
74
+ "@atscript/db": "^0.1.168"
75
75
  },
76
76
  "peerDependenciesMeta": {
77
77
  "@atscript/db": {