@aooth/user 0.1.8 → 0.1.10

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.
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_federated_identity_store = require("./federated-identity-store-oRjhnR5l.cjs");
2
+ const require_federated_identity_store = require("./federated-identity-store-BEEEcoaP.cjs");
3
3
  let node_crypto = require("node:crypto");
4
4
  //#region src/atscript-db/federated-identity-store.ts
5
5
  function isConflict$1(err) {
@@ -80,14 +80,17 @@ function isConflict(err) {
80
80
  * `@atscript/db`-backed `UserStore`. Pass the resolved table for the
81
81
  * `AoothUserCredentials` (or a `.as` interface extending it) shipped at the
82
82
  * `@aooth/user/atscript-db/model.as` subpath. Identity reads use the
83
- * `@meta.id` PK (`id`) plus the unique `username` / `email` indexes; writes key
84
- * on `id`.
83
+ * `@meta.id` PK (`id`) and the unique `username` index, plus any
84
+ * annotation-resolved `handleFields`; writes key on `id`.
85
85
  */
86
86
  var UsersStoreAtscriptDb = class extends require_federated_identity_store.UserStore {
87
87
  table;
88
+ /** Secondary handle fields tried (in order) by `findByHandle` after `username`. */
89
+ handleFields;
88
90
  constructor(opts) {
89
91
  super();
90
92
  this.table = opts.table;
93
+ this.handleFields = opts.handleFields ?? [];
91
94
  }
92
95
  async exists(handle) {
93
96
  return await this.table.count({ filter: { username: handle } }) > 0;
@@ -98,7 +101,11 @@ var UsersStoreAtscriptDb = class extends require_federated_identity_store.UserSt
98
101
  async findByHandle(handle) {
99
102
  const byUsername = await this.table.findOne({ filter: { username: handle } });
100
103
  if (byUsername) return byUsername;
101
- return await this.table.findOne({ filter: { email: handle } });
104
+ for (const field of this.handleFields) {
105
+ const byHandle = await this.table.findOne({ filter: { [field]: handle } });
106
+ if (byHandle) return byHandle;
107
+ }
108
+ return null;
102
109
  }
103
110
  async findByIdentifier(value) {
104
111
  const byId = await this.table.findOne({ filter: { id: value } });
@@ -119,7 +126,12 @@ var UsersStoreAtscriptDb = class extends require_federated_identity_store.UserSt
119
126
  if (update.inc) for (const [path, amount] of Object.entries(update.inc)) require_federated_identity_store.setAtPath(patch, path, { $inc: amount });
120
127
  if (Object.keys(patch).length <= 1) return true;
121
128
  if (update.expectedVersion !== void 0) patch.$cas = { version: update.expectedVersion };
122
- return (await this.table.updateOne(patch)).matchedCount > 0;
129
+ try {
130
+ return (await this.table.updateOne(patch)).matchedCount > 0;
131
+ } catch (e) {
132
+ if (isConflict(e)) throw new require_federated_identity_store.UserAuthError("ALREADY_EXISTS", "Update conflicts with an existing unique value");
133
+ throw e;
134
+ }
123
135
  }
124
136
  async delete(id) {
125
137
  return (await this.table.deleteMany({ id })).deletedCount > 0;
@@ -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-DEEed8lA.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-CI7Vgllp.cjs";
2
2
 
3
3
  //#region src/atscript-db/federated-identity-store.d.ts
4
4
  /**
@@ -88,16 +88,28 @@ interface AuthUserTable<TUserCustom extends object = object> {
88
88
  }
89
89
  interface UsersStoreAtscriptDbOptions<TUserCustom extends object> {
90
90
  table: AuthUserTable<TUserCustom>;
91
+ /**
92
+ * Ordered login-handle field names (e.g. email then phone), resolved from the
93
+ * model's `@aooth.user.*` annotations by the wiring layer (`handleFields` of
94
+ * `getAoothUserHandleSpec`). `findByHandle` falls back to a `{ [field]: handle }`
95
+ * lookup for each, in order, after `username`. Omit/empty to disable handle
96
+ * resolution (login by anything but `username` unavailable). The base credential
97
+ * model no longer hardcodes `email` — the field names are whatever the
98
+ * consumer's `.as` model annotates.
99
+ */
100
+ handleFields?: string[];
91
101
  }
92
102
  /**
93
103
  * `@atscript/db`-backed `UserStore`. Pass the resolved table for the
94
104
  * `AoothUserCredentials` (or a `.as` interface extending it) shipped at the
95
105
  * `@aooth/user/atscript-db/model.as` subpath. Identity reads use the
96
- * `@meta.id` PK (`id`) plus the unique `username` / `email` indexes; writes key
97
- * on `id`.
106
+ * `@meta.id` PK (`id`) and the unique `username` index, plus any
107
+ * annotation-resolved `handleFields`; writes key on `id`.
98
108
  */
99
109
  declare class UsersStoreAtscriptDb<TUserCustom extends object = object> extends UserStore<TUserCustom> {
100
110
  protected table: AuthUserTable<TUserCustom>;
111
+ /** Secondary handle fields tried (in order) by `findByHandle` after `username`. */
112
+ protected readonly handleFields: string[];
101
113
  constructor(opts: UsersStoreAtscriptDbOptions<TUserCustom>);
102
114
  exists(handle: string): Promise<boolean>;
103
115
  findById(id: string): Promise<(UserCredentials & TUserCustom) | null>;
@@ -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-DEEed8lA.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-CI7Vgllp.mjs";
2
2
 
3
3
  //#region src/atscript-db/federated-identity-store.d.ts
4
4
  /**
@@ -88,16 +88,28 @@ interface AuthUserTable<TUserCustom extends object = object> {
88
88
  }
89
89
  interface UsersStoreAtscriptDbOptions<TUserCustom extends object> {
90
90
  table: AuthUserTable<TUserCustom>;
91
+ /**
92
+ * Ordered login-handle field names (e.g. email then phone), resolved from the
93
+ * model's `@aooth.user.*` annotations by the wiring layer (`handleFields` of
94
+ * `getAoothUserHandleSpec`). `findByHandle` falls back to a `{ [field]: handle }`
95
+ * lookup for each, in order, after `username`. Omit/empty to disable handle
96
+ * resolution (login by anything but `username` unavailable). The base credential
97
+ * model no longer hardcodes `email` — the field names are whatever the
98
+ * consumer's `.as` model annotates.
99
+ */
100
+ handleFields?: string[];
91
101
  }
92
102
  /**
93
103
  * `@atscript/db`-backed `UserStore`. Pass the resolved table for the
94
104
  * `AoothUserCredentials` (or a `.as` interface extending it) shipped at the
95
105
  * `@aooth/user/atscript-db/model.as` subpath. Identity reads use the
96
- * `@meta.id` PK (`id`) plus the unique `username` / `email` indexes; writes key
97
- * on `id`.
106
+ * `@meta.id` PK (`id`) and the unique `username` index, plus any
107
+ * annotation-resolved `handleFields`; writes key on `id`.
98
108
  */
99
109
  declare class UsersStoreAtscriptDb<TUserCustom extends object = object> extends UserStore<TUserCustom> {
100
110
  protected table: AuthUserTable<TUserCustom>;
111
+ /** Secondary handle fields tried (in order) by `findByHandle` after `username`. */
112
+ protected readonly handleFields: string[];
101
113
  constructor(opts: UsersStoreAtscriptDbOptions<TUserCustom>);
102
114
  exists(handle: string): Promise<boolean>;
103
115
  findById(id: string): Promise<(UserCredentials & TUserCustom) | null>;
@@ -1,4 +1,4 @@
1
- import { d as UserAuthError, n as pickDefinedProfile, r as UserStore, t as FederatedIdentityStore, u as setAtPath } from "./federated-identity-store-Cmc7jBrw.mjs";
1
+ import { d as UserAuthError, n as pickDefinedProfile, r as UserStore, t as FederatedIdentityStore, u as setAtPath } from "./federated-identity-store-CHW1xtMp.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  //#region src/atscript-db/federated-identity-store.ts
4
4
  function isConflict$1(err) {
@@ -79,14 +79,17 @@ function isConflict(err) {
79
79
  * `@atscript/db`-backed `UserStore`. Pass the resolved table for the
80
80
  * `AoothUserCredentials` (or a `.as` interface extending it) shipped at the
81
81
  * `@aooth/user/atscript-db/model.as` subpath. Identity reads use the
82
- * `@meta.id` PK (`id`) plus the unique `username` / `email` indexes; writes key
83
- * on `id`.
82
+ * `@meta.id` PK (`id`) and the unique `username` index, plus any
83
+ * annotation-resolved `handleFields`; writes key on `id`.
84
84
  */
85
85
  var UsersStoreAtscriptDb = class extends UserStore {
86
86
  table;
87
+ /** Secondary handle fields tried (in order) by `findByHandle` after `username`. */
88
+ handleFields;
87
89
  constructor(opts) {
88
90
  super();
89
91
  this.table = opts.table;
92
+ this.handleFields = opts.handleFields ?? [];
90
93
  }
91
94
  async exists(handle) {
92
95
  return await this.table.count({ filter: { username: handle } }) > 0;
@@ -97,7 +100,11 @@ var UsersStoreAtscriptDb = class extends UserStore {
97
100
  async findByHandle(handle) {
98
101
  const byUsername = await this.table.findOne({ filter: { username: handle } });
99
102
  if (byUsername) return byUsername;
100
- return await this.table.findOne({ filter: { email: handle } });
103
+ for (const field of this.handleFields) {
104
+ const byHandle = await this.table.findOne({ filter: { [field]: handle } });
105
+ if (byHandle) return byHandle;
106
+ }
107
+ return null;
101
108
  }
102
109
  async findByIdentifier(value) {
103
110
  const byId = await this.table.findOne({ filter: { id: value } });
@@ -118,7 +125,12 @@ var UsersStoreAtscriptDb = class extends UserStore {
118
125
  if (update.inc) for (const [path, amount] of Object.entries(update.inc)) setAtPath(patch, path, { $inc: amount });
119
126
  if (Object.keys(patch).length <= 1) return true;
120
127
  if (update.expectedVersion !== void 0) patch.$cas = { version: update.expectedVersion };
121
- return (await this.table.updateOne(patch)).matchedCount > 0;
128
+ try {
129
+ return (await this.table.updateOne(patch)).matchedCount > 0;
130
+ } catch (e) {
131
+ if (isConflict(e)) throw new UserAuthError("ALREADY_EXISTS", "Update conflicts with an existing unique value");
132
+ throw e;
133
+ }
122
134
  }
123
135
  async delete(id) {
124
136
  return (await this.table.deleteMany({ id })).deletedCount > 0;
@@ -100,9 +100,10 @@ function incrementAtPath(obj, path, amount) {
100
100
  *
101
101
  * - `findById` — strict, by the surrogate id; the canonical identity read used
102
102
  * by authenticated flows that resolve the session subject (`getUserId()`).
103
- * - `findByHandle` — deterministic LOGIN resolver (`username` then `email`).
103
+ * - `findByHandle` — deterministic LOGIN resolver (`username`, then the
104
+ * annotation-resolved handle fields — email, then phone — in order).
104
105
  * - `findByIdentifier` — permissive internal/admin/recovery lookup (`id`, then
105
- * `username`, then `email`).
106
+ * the `findByHandle` chain).
106
107
  *
107
108
  * Writes (`update`/`delete`/`withCas`) all key on the surrogate `id`.
108
109
  */
@@ -100,9 +100,10 @@ function incrementAtPath(obj, path, amount) {
100
100
  *
101
101
  * - `findById` — strict, by the surrogate id; the canonical identity read used
102
102
  * by authenticated flows that resolve the session subject (`getUserId()`).
103
- * - `findByHandle` — deterministic LOGIN resolver (`username` then `email`).
103
+ * - `findByHandle` — deterministic LOGIN resolver (`username`, then the
104
+ * annotation-resolved handle fields — email, then phone — in order).
104
105
  * - `findByIdentifier` — permissive internal/admin/recovery lookup (`id`, then
105
- * `username`, then `email`).
106
+ * the `findByHandle` chain).
106
107
  *
107
108
  * Writes (`update`/`delete`/`withCas`) all key on the surrogate `id`.
108
109
  */
@@ -2,14 +2,6 @@
2
2
  interface UserCredentials {
3
3
  id: string;
4
4
  username: string;
5
- /**
6
- * Unique login/contact handle. Indexed `@db.index.unique 'email_idx'` on the
7
- * `AoothUserCredentials` model so it is independently unique from `username`
8
- * and resolvable via `UserStore.findByHandle` / `findByIdentifier`. Optional
9
- * because not every deployment populates it (and the invite flow sets
10
- * `username := email`).
11
- */
12
- email?: string;
13
5
  /**
14
6
  * Server-managed optimistic-concurrency counter. Bumped by `UserStore.update`
15
7
  * on every successful write; checked against `UserStoreUpdate.expectedVersion`
@@ -233,9 +225,10 @@ interface WithCasOptions {
233
225
  *
234
226
  * - `findById` — strict, by the surrogate id; the canonical identity read used
235
227
  * by authenticated flows that resolve the session subject (`getUserId()`).
236
- * - `findByHandle` — deterministic LOGIN resolver (`username` then `email`).
228
+ * - `findByHandle` — deterministic LOGIN resolver (`username`, then the
229
+ * annotation-resolved handle fields — email, then phone — in order).
237
230
  * - `findByIdentifier` — permissive internal/admin/recovery lookup (`id`, then
238
- * `username`, then `email`).
231
+ * the `findByHandle` chain).
239
232
  *
240
233
  * Writes (`update`/`delete`/`withCas`) all key on the surrogate `id`.
241
234
  */
@@ -248,17 +241,19 @@ declare abstract class UserStore<T extends object = object> {
248
241
  */
249
242
  abstract findById(id: string): Promise<(UserCredentials & T) | null>;
250
243
  /**
251
- * Deterministic LOGIN resolver: matches `username` exactly, then `email`
252
- * exactly (in that order). Intentionally NOT a permissive `$or` — `id`,
253
- * `username`, and `email` are all strings, so a permissive match could
254
- * silently resolve a value that is one user's username and another's email
255
- * to an arbitrary account.
244
+ * Deterministic LOGIN resolver: matches `username` exactly, then each
245
+ * annotation-resolved handle field (email, then phone) exactly, in that
246
+ * order. Intentionally NOT a permissive `$or` handle values are all
247
+ * strings, so a permissive match could silently resolve a value that is one
248
+ * user's username and another's email to an arbitrary account. Each handle
249
+ * field is unique-when-present (the `@aooth.user.*` boot contract), so a
250
+ * present value resolves to at most one row.
256
251
  */
257
252
  abstract findByHandle(handle: string): Promise<(UserCredentials & T) | null>;
258
253
  /**
259
- * Permissive lookup for internal / admin / recovery callers: `id`, then
260
- * `username`, then `email` (ordered, first match). NOT for the login path —
261
- * use `findByHandle` there.
254
+ * Permissive lookup for internal / admin / recovery callers: `id`, then the
255
+ * `findByHandle` chain (`username`, then the resolved handle fields). NOT for
256
+ * the login path — use `findByHandle` there.
262
257
  */
263
258
  abstract findByIdentifier(value: string): Promise<(UserCredentials & T) | null>;
264
259
  abstract create(data: UserCredentials & T): Promise<void>;
@@ -2,14 +2,6 @@
2
2
  interface UserCredentials {
3
3
  id: string;
4
4
  username: string;
5
- /**
6
- * Unique login/contact handle. Indexed `@db.index.unique 'email_idx'` on the
7
- * `AoothUserCredentials` model so it is independently unique from `username`
8
- * and resolvable via `UserStore.findByHandle` / `findByIdentifier`. Optional
9
- * because not every deployment populates it (and the invite flow sets
10
- * `username := email`).
11
- */
12
- email?: string;
13
5
  /**
14
6
  * Server-managed optimistic-concurrency counter. Bumped by `UserStore.update`
15
7
  * on every successful write; checked against `UserStoreUpdate.expectedVersion`
@@ -233,9 +225,10 @@ interface WithCasOptions {
233
225
  *
234
226
  * - `findById` — strict, by the surrogate id; the canonical identity read used
235
227
  * by authenticated flows that resolve the session subject (`getUserId()`).
236
- * - `findByHandle` — deterministic LOGIN resolver (`username` then `email`).
228
+ * - `findByHandle` — deterministic LOGIN resolver (`username`, then the
229
+ * annotation-resolved handle fields — email, then phone — in order).
237
230
  * - `findByIdentifier` — permissive internal/admin/recovery lookup (`id`, then
238
- * `username`, then `email`).
231
+ * the `findByHandle` chain).
239
232
  *
240
233
  * Writes (`update`/`delete`/`withCas`) all key on the surrogate `id`.
241
234
  */
@@ -248,17 +241,19 @@ declare abstract class UserStore<T extends object = object> {
248
241
  */
249
242
  abstract findById(id: string): Promise<(UserCredentials & T) | null>;
250
243
  /**
251
- * Deterministic LOGIN resolver: matches `username` exactly, then `email`
252
- * exactly (in that order). Intentionally NOT a permissive `$or` — `id`,
253
- * `username`, and `email` are all strings, so a permissive match could
254
- * silently resolve a value that is one user's username and another's email
255
- * to an arbitrary account.
244
+ * Deterministic LOGIN resolver: matches `username` exactly, then each
245
+ * annotation-resolved handle field (email, then phone) exactly, in that
246
+ * order. Intentionally NOT a permissive `$or` handle values are all
247
+ * strings, so a permissive match could silently resolve a value that is one
248
+ * user's username and another's email to an arbitrary account. Each handle
249
+ * field is unique-when-present (the `@aooth.user.*` boot contract), so a
250
+ * present value resolves to at most one row.
256
251
  */
257
252
  abstract findByHandle(handle: string): Promise<(UserCredentials & T) | null>;
258
253
  /**
259
- * Permissive lookup for internal / admin / recovery callers: `id`, then
260
- * `username`, then `email` (ordered, first match). NOT for the login path —
261
- * use `findByHandle` there.
254
+ * Permissive lookup for internal / admin / recovery callers: `id`, then the
255
+ * `findByHandle` chain (`username`, then the resolved handle fields). NOT for
256
+ * the login path — use `findByHandle` there.
262
257
  */
263
258
  abstract findByIdentifier(value: string): Promise<(UserCredentials & T) | null>;
264
259
  abstract create(data: UserCredentials & T): Promise<void>;
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_federated_identity_store = require("./federated-identity-store-oRjhnR5l.cjs");
2
+ const require_federated_identity_store = require("./federated-identity-store-BEEEcoaP.cjs");
3
3
  let node_crypto = require("node:crypto");
4
4
  //#region src/base-x/base32.ts
5
5
  /**
@@ -780,12 +780,24 @@ var UserStoreMemory = class extends require_federated_identity_store.UserStore {
780
780
  /** Keyed by the stable surrogate `id` (the token subject). */
781
781
  store = /* @__PURE__ */ new Map();
782
782
  /**
783
+ * Ordered secondary handle fields (e.g. email then phone) resolved from the
784
+ * model's `@aooth.user.*` annotations by the wiring layer (`handleFields` of
785
+ * `getAoothUserHandleSpec`). Tried by `findByHandle` after `username`, and
786
+ * enforced unique by `create`. Empty when no handles are configured (login by
787
+ * email/phone unavailable).
788
+ */
789
+ handleFields;
790
+ /**
783
791
  * Optional seed. The map is keyed by each record's `id`; a record missing an
784
792
  * `id` gets one minted (mirrors the DB `@db.default.uuid`). The seed object's
785
793
  * keys are ignored — identity is the record's `id`.
794
+ *
795
+ * `opts.handleFields` names the ordered secondary login handles (mirroring
796
+ * `UsersStoreAtscriptDb`); omit it for username-only resolution.
786
797
  */
787
- constructor(seed) {
798
+ constructor(seed, opts) {
788
799
  super();
800
+ this.handleFields = opts?.handleFields ?? [];
789
801
  if (seed) for (const value of Object.values(seed)) {
790
802
  const cloned = structuredClone(value);
791
803
  if (!cloned.id) cloned.id = (0, node_crypto.randomUUID)();
@@ -801,12 +813,21 @@ var UserStoreMemory = class extends require_federated_identity_store.UserStore {
801
813
  return user ? structuredClone(user) : null;
802
814
  }
803
815
  async findByHandle(handle) {
804
- let byEmail = null;
816
+ let byHandle = null;
805
817
  for (const u of this.store.values()) {
806
818
  if (u.username === handle) return structuredClone(u);
807
- if (byEmail === null && u.email !== void 0 && u.email === handle) byEmail = u;
819
+ if (byHandle === null) {
820
+ const rec = u;
821
+ for (const field of this.handleFields) {
822
+ const value = rec[field];
823
+ if (value !== void 0 && value === handle) {
824
+ byHandle = u;
825
+ break;
826
+ }
827
+ }
828
+ }
808
829
  }
809
- return byEmail ? structuredClone(byEmail) : null;
830
+ return byHandle ? structuredClone(byHandle) : null;
810
831
  }
811
832
  async findByIdentifier(value) {
812
833
  const byId = this.store.get(value);
@@ -816,18 +837,36 @@ var UserStoreMemory = class extends require_federated_identity_store.UserStore {
816
837
  async create(data) {
817
838
  const cloned = structuredClone(data);
818
839
  if (!cloned.id) cloned.id = (0, node_crypto.randomUUID)();
819
- for (const u of this.store.values()) {
820
- if (u.username === cloned.username) throw new require_federated_identity_store.UserAuthError("ALREADY_EXISTS", `User "${cloned.username}" already exists`);
821
- if (cloned.email !== void 0 && u.email === cloned.email) throw new require_federated_identity_store.UserAuthError("ALREADY_EXISTS", `Email "${cloned.email}" already exists`);
822
- }
840
+ for (const u of this.store.values()) if (u.username === cloned.username) throw new require_federated_identity_store.UserAuthError("ALREADY_EXISTS", `User "${cloned.username}" already exists`);
841
+ this.assertHandlesUnique(cloned);
823
842
  cloned.version = 0;
824
843
  this.store.set(cloned.id, cloned);
825
844
  }
845
+ /**
846
+ * Throw `ALREADY_EXISTS` if any record other than `excludeId` already holds
847
+ * one of `rec`'s handle-column values — the in-memory mirror of the
848
+ * atscript-db store's unique index, so `create` and `update` enforce the same
849
+ * collision contract (which `promote-to-handle` swallows best-effort). Handle
850
+ * values are string columns; a non-string is never a collision.
851
+ */
852
+ assertHandlesUnique(rec, excludeId) {
853
+ for (const field of this.handleFields) {
854
+ const value = rec[field];
855
+ if (typeof value !== "string") continue;
856
+ for (const [otherId, other] of this.store) {
857
+ if (otherId === excludeId) continue;
858
+ if (other[field] === value) throw new require_federated_identity_store.UserAuthError("ALREADY_EXISTS", `${field} "${value}" already exists`);
859
+ }
860
+ }
861
+ }
826
862
  async update(id, update) {
827
863
  const user = this.store.get(id);
828
864
  if (!user) return false;
829
865
  if (update.expectedVersion !== void 0 && (user.version ?? 0) !== update.expectedVersion) return false;
830
- if (update.set) require_federated_identity_store.deepMerge(user, update.set);
866
+ if (update.set) {
867
+ this.assertHandlesUnique(update.set, id);
868
+ require_federated_identity_store.deepMerge(user, update.set);
869
+ }
831
870
  if (update.inc) for (const [path, amount] of Object.entries(update.inc)) require_federated_identity_store.incrementAtPath(user, path, amount);
832
871
  user.version = (user.version ?? 0) + 1;
833
872
  return true;
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-DEEed8lA.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-CI7Vgllp.cjs";
2
2
 
3
3
  //#region src/errors.d.ts
4
4
  declare class UserAuthError extends Error {
@@ -238,17 +238,38 @@ declare class UserService<T extends object = object> {
238
238
  declare class UserStoreMemory<T extends object = object> extends UserStore<T> {
239
239
  /** Keyed by the stable surrogate `id` (the token subject). */
240
240
  private store;
241
+ /**
242
+ * Ordered secondary handle fields (e.g. email then phone) resolved from the
243
+ * model's `@aooth.user.*` annotations by the wiring layer (`handleFields` of
244
+ * `getAoothUserHandleSpec`). Tried by `findByHandle` after `username`, and
245
+ * enforced unique by `create`. Empty when no handles are configured (login by
246
+ * email/phone unavailable).
247
+ */
248
+ private readonly handleFields;
241
249
  /**
242
250
  * Optional seed. The map is keyed by each record's `id`; a record missing an
243
251
  * `id` gets one minted (mirrors the DB `@db.default.uuid`). The seed object's
244
252
  * keys are ignored — identity is the record's `id`.
253
+ *
254
+ * `opts.handleFields` names the ordered secondary login handles (mirroring
255
+ * `UsersStoreAtscriptDb`); omit it for username-only resolution.
245
256
  */
246
- constructor(seed?: Record<string, UserCredentials & T>);
257
+ constructor(seed?: Record<string, UserCredentials & T>, opts?: {
258
+ handleFields?: string[];
259
+ });
247
260
  exists(handle: string): Promise<boolean>;
248
261
  findById(id: string): Promise<(UserCredentials & T) | null>;
249
262
  findByHandle(handle: string): Promise<(UserCredentials & T) | null>;
250
263
  findByIdentifier(value: string): Promise<(UserCredentials & T) | null>;
251
264
  create(data: UserCredentials & T): Promise<void>;
265
+ /**
266
+ * Throw `ALREADY_EXISTS` if any record other than `excludeId` already holds
267
+ * one of `rec`'s handle-column values — the in-memory mirror of the
268
+ * atscript-db store's unique index, so `create` and `update` enforce the same
269
+ * collision contract (which `promote-to-handle` swallows best-effort). Handle
270
+ * values are string columns; a non-string is never a collision.
271
+ */
272
+ private assertHandlesUnique;
252
273
  update(id: string, update: UserStoreUpdate): Promise<boolean>;
253
274
  delete(id: string): Promise<boolean>;
254
275
  withCas(id: string, mutator: (current: UserCredentials & T) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
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-DEEed8lA.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-CI7Vgllp.mjs";
2
2
 
3
3
  //#region src/errors.d.ts
4
4
  declare class UserAuthError extends Error {
@@ -238,17 +238,38 @@ declare class UserService<T extends object = object> {
238
238
  declare class UserStoreMemory<T extends object = object> extends UserStore<T> {
239
239
  /** Keyed by the stable surrogate `id` (the token subject). */
240
240
  private store;
241
+ /**
242
+ * Ordered secondary handle fields (e.g. email then phone) resolved from the
243
+ * model's `@aooth.user.*` annotations by the wiring layer (`handleFields` of
244
+ * `getAoothUserHandleSpec`). Tried by `findByHandle` after `username`, and
245
+ * enforced unique by `create`. Empty when no handles are configured (login by
246
+ * email/phone unavailable).
247
+ */
248
+ private readonly handleFields;
241
249
  /**
242
250
  * Optional seed. The map is keyed by each record's `id`; a record missing an
243
251
  * `id` gets one minted (mirrors the DB `@db.default.uuid`). The seed object's
244
252
  * keys are ignored — identity is the record's `id`.
253
+ *
254
+ * `opts.handleFields` names the ordered secondary login handles (mirroring
255
+ * `UsersStoreAtscriptDb`); omit it for username-only resolution.
245
256
  */
246
- constructor(seed?: Record<string, UserCredentials & T>);
257
+ constructor(seed?: Record<string, UserCredentials & T>, opts?: {
258
+ handleFields?: string[];
259
+ });
247
260
  exists(handle: string): Promise<boolean>;
248
261
  findById(id: string): Promise<(UserCredentials & T) | null>;
249
262
  findByHandle(handle: string): Promise<(UserCredentials & T) | null>;
250
263
  findByIdentifier(value: string): Promise<(UserCredentials & T) | null>;
251
264
  create(data: UserCredentials & T): Promise<void>;
265
+ /**
266
+ * Throw `ALREADY_EXISTS` if any record other than `excludeId` already holds
267
+ * one of `rec`'s handle-column values — the in-memory mirror of the
268
+ * atscript-db store's unique index, so `create` and `update` enforce the same
269
+ * collision contract (which `promote-to-handle` swallows best-effort). Handle
270
+ * values are string columns; a non-string is never a collision.
271
+ */
272
+ private assertHandlesUnique;
252
273
  update(id: string, update: UserStoreUpdate): Promise<boolean>;
253
274
  delete(id: string): Promise<boolean>;
254
275
  withCas(id: string, mutator: (current: UserCredentials & T) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { a as generateSecureRandom, c as maskMfaValue, d as UserAuthError, i as deepMerge, l as maskPhone, n as pickDefinedProfile, o as incrementAtPath, r as UserStore, s as maskEmail, t as FederatedIdentityStore, u as setAtPath } from "./federated-identity-store-Cmc7jBrw.mjs";
1
+ import { a as generateSecureRandom, c as maskMfaValue, d as UserAuthError, i as deepMerge, l as maskPhone, n as pickDefinedProfile, o as incrementAtPath, r as UserStore, s as maskEmail, t as FederatedIdentityStore, u as setAtPath } from "./federated-identity-store-CHW1xtMp.mjs";
2
2
  import { createHash, createHmac, randomBytes, randomUUID, scrypt, timingSafeEqual } from "node:crypto";
3
3
  //#region src/base-x/base32.ts
4
4
  /**
@@ -779,12 +779,24 @@ var UserStoreMemory = class extends UserStore {
779
779
  /** Keyed by the stable surrogate `id` (the token subject). */
780
780
  store = /* @__PURE__ */ new Map();
781
781
  /**
782
+ * Ordered secondary handle fields (e.g. email then phone) resolved from the
783
+ * model's `@aooth.user.*` annotations by the wiring layer (`handleFields` of
784
+ * `getAoothUserHandleSpec`). Tried by `findByHandle` after `username`, and
785
+ * enforced unique by `create`. Empty when no handles are configured (login by
786
+ * email/phone unavailable).
787
+ */
788
+ handleFields;
789
+ /**
782
790
  * Optional seed. The map is keyed by each record's `id`; a record missing an
783
791
  * `id` gets one minted (mirrors the DB `@db.default.uuid`). The seed object's
784
792
  * keys are ignored — identity is the record's `id`.
793
+ *
794
+ * `opts.handleFields` names the ordered secondary login handles (mirroring
795
+ * `UsersStoreAtscriptDb`); omit it for username-only resolution.
785
796
  */
786
- constructor(seed) {
797
+ constructor(seed, opts) {
787
798
  super();
799
+ this.handleFields = opts?.handleFields ?? [];
788
800
  if (seed) for (const value of Object.values(seed)) {
789
801
  const cloned = structuredClone(value);
790
802
  if (!cloned.id) cloned.id = randomUUID();
@@ -800,12 +812,21 @@ var UserStoreMemory = class extends UserStore {
800
812
  return user ? structuredClone(user) : null;
801
813
  }
802
814
  async findByHandle(handle) {
803
- let byEmail = null;
815
+ let byHandle = null;
804
816
  for (const u of this.store.values()) {
805
817
  if (u.username === handle) return structuredClone(u);
806
- if (byEmail === null && u.email !== void 0 && u.email === handle) byEmail = u;
818
+ if (byHandle === null) {
819
+ const rec = u;
820
+ for (const field of this.handleFields) {
821
+ const value = rec[field];
822
+ if (value !== void 0 && value === handle) {
823
+ byHandle = u;
824
+ break;
825
+ }
826
+ }
827
+ }
807
828
  }
808
- return byEmail ? structuredClone(byEmail) : null;
829
+ return byHandle ? structuredClone(byHandle) : null;
809
830
  }
810
831
  async findByIdentifier(value) {
811
832
  const byId = this.store.get(value);
@@ -815,18 +836,36 @@ var UserStoreMemory = class extends UserStore {
815
836
  async create(data) {
816
837
  const cloned = structuredClone(data);
817
838
  if (!cloned.id) cloned.id = randomUUID();
818
- for (const u of this.store.values()) {
819
- if (u.username === cloned.username) throw new UserAuthError("ALREADY_EXISTS", `User "${cloned.username}" already exists`);
820
- if (cloned.email !== void 0 && u.email === cloned.email) throw new UserAuthError("ALREADY_EXISTS", `Email "${cloned.email}" already exists`);
821
- }
839
+ for (const u of this.store.values()) if (u.username === cloned.username) throw new UserAuthError("ALREADY_EXISTS", `User "${cloned.username}" already exists`);
840
+ this.assertHandlesUnique(cloned);
822
841
  cloned.version = 0;
823
842
  this.store.set(cloned.id, cloned);
824
843
  }
844
+ /**
845
+ * Throw `ALREADY_EXISTS` if any record other than `excludeId` already holds
846
+ * one of `rec`'s handle-column values — the in-memory mirror of the
847
+ * atscript-db store's unique index, so `create` and `update` enforce the same
848
+ * collision contract (which `promote-to-handle` swallows best-effort). Handle
849
+ * values are string columns; a non-string is never a collision.
850
+ */
851
+ assertHandlesUnique(rec, excludeId) {
852
+ for (const field of this.handleFields) {
853
+ const value = rec[field];
854
+ if (typeof value !== "string") continue;
855
+ for (const [otherId, other] of this.store) {
856
+ if (otherId === excludeId) continue;
857
+ if (other[field] === value) throw new UserAuthError("ALREADY_EXISTS", `${field} "${value}" already exists`);
858
+ }
859
+ }
860
+ }
825
861
  async update(id, update) {
826
862
  const user = this.store.get(id);
827
863
  if (!user) return false;
828
864
  if (update.expectedVersion !== void 0 && (user.version ?? 0) !== update.expectedVersion) return false;
829
- if (update.set) deepMerge(user, update.set);
865
+ if (update.set) {
866
+ this.assertHandlesUnique(update.set, id);
867
+ deepMerge(user, update.set);
868
+ }
830
869
  if (update.inc) for (const [path, amount] of Object.entries(update.inc)) incrementAtPath(user, path, amount);
831
870
  user.version = (user.version ?? 0) + 1;
832
871
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aooth/user",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "User credential primitives for aoothjs",
5
5
  "keywords": [
6
6
  "aoothjs",
@@ -60,14 +60,14 @@
60
60
  "access": "public"
61
61
  },
62
62
  "devDependencies": {
63
- "@atscript/core": "^0.1.69",
64
- "@atscript/db": "^0.1.96",
65
- "@atscript/db-sql-tools": "^0.1.96",
66
- "@atscript/db-sqlite": "^0.1.96",
67
- "@atscript/typescript": "^0.1.69",
63
+ "@atscript/core": "^0.1.71",
64
+ "@atscript/db": "^0.1.98",
65
+ "@atscript/db-sql-tools": "^0.1.98",
66
+ "@atscript/db-sqlite": "^0.1.98",
67
+ "@atscript/typescript": "^0.1.71",
68
68
  "@types/better-sqlite3": "^7.6.13",
69
69
  "better-sqlite3": "^12.6.2",
70
- "unplugin-atscript": "^0.1.69"
70
+ "unplugin-atscript": "^0.1.71"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "@atscript/db": ">=0.1.79"
@@ -6,9 +6,6 @@ export interface AoothUserCredentials {
6
6
  @db.index.unique 'username_idx'
7
7
  username: string
8
8
 
9
- @db.index.unique 'email_idx'
10
- email?: string
11
-
12
9
  @db.column.version
13
10
  version: number.int
14
11
 
@@ -16,7 +16,6 @@ import type { TAtscriptTypeObject, TAtscriptTypeComplex, TAtscriptTypeFinal, TAt
16
16
  export declare class AoothUserCredentials {
17
17
  id: string
18
18
  username: string
19
- email?: string
20
19
  version: number /* int */
21
20
  password: {
22
21
  hash: string