@aooth/user 0.1.7 → 0.1.9

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,77 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_user_store = require("./user-store-BPZVAboN.cjs");
2
+ const require_federated_identity_store = require("./federated-identity-store-BEEEcoaP.cjs");
3
+ let node_crypto = require("node:crypto");
4
+ //#region src/atscript-db/federated-identity-store.ts
5
+ function isConflict$1(err) {
6
+ return typeof err === "object" && err !== null && err.code === "CONFLICT";
7
+ }
8
+ /**
9
+ * `@atscript/db`-backed {@link FederatedIdentityStore}. Pass the resolved table
10
+ * for the `AoothFederatedIdentity` model shipped at
11
+ * `@aooth/user/atscript-db/federated-model`. The compound-unique
12
+ * `(provider, subject)` index makes `find` an O(1) point read and turns a
13
+ * cross-user re-link attempt into a `CONFLICT` (→ `ALREADY_EXISTS`); `userId`
14
+ * is a plain indexed column, so `listForUser` / `deleteAllForUser` scan it
15
+ * natively.
16
+ */
17
+ var FederatedIdentityStoreAtscriptDb = class extends require_federated_identity_store.FederatedIdentityStore {
18
+ table;
19
+ clock;
20
+ constructor(opts) {
21
+ super();
22
+ this.table = opts.table;
23
+ this.clock = opts.clock ?? Date.now;
24
+ }
25
+ async find(provider, subject) {
26
+ return this.table.findOne({ filter: {
27
+ provider,
28
+ subject
29
+ } });
30
+ }
31
+ async listForUser(userId) {
32
+ return (await this.table.findMany({ filter: { userId } })).toSorted((a, b) => a.linkedAt - b.linkedAt);
33
+ }
34
+ async link(rec) {
35
+ const row = {
36
+ id: (0, node_crypto.randomUUID)(),
37
+ provider: rec.provider,
38
+ subject: rec.subject,
39
+ userId: rec.userId,
40
+ ...require_federated_identity_store.pickDefinedProfile(rec),
41
+ linkedAt: this.clock()
42
+ };
43
+ try {
44
+ await this.table.insertOne(row);
45
+ return row;
46
+ } catch (e) {
47
+ if (isConflict$1(e)) throw new require_federated_identity_store.UserAuthError("ALREADY_EXISTS", `Provider account "${rec.provider}:${rec.subject}" is already linked`);
48
+ throw e;
49
+ }
50
+ }
51
+ async unlink(provider, subject) {
52
+ return (await this.table.deleteMany({
53
+ provider,
54
+ subject
55
+ })).deletedCount > 0;
56
+ }
57
+ async touchLogin(provider, subject, profile) {
58
+ const row = await this.table.findOne({ filter: {
59
+ provider,
60
+ subject
61
+ } });
62
+ if (!row) return;
63
+ const next = {
64
+ ...row,
65
+ lastLoginAt: this.clock()
66
+ };
67
+ if (profile) Object.assign(next, require_federated_identity_store.pickDefinedProfile(profile));
68
+ await this.table.replaceOne(next);
69
+ }
70
+ async deleteAllForUser(userId) {
71
+ return (await this.table.deleteMany({ userId })).deletedCount;
72
+ }
73
+ };
74
+ //#endregion
3
75
  //#region src/atscript-db/index.ts
4
76
  function isConflict(err) {
5
77
  return typeof err === "object" && err !== null && err.code === "CONFLICT";
@@ -7,62 +79,88 @@ function isConflict(err) {
7
79
  /**
8
80
  * `@atscript/db`-backed `UserStore`. Pass the resolved table for the
9
81
  * `AoothUserCredentials` (or a `.as` interface extending it) shipped at the
10
- * `@aooth/user/atscript-db/model.as` subpath.
82
+ * `@aooth/user/atscript-db/model.as` subpath. Identity reads use the
83
+ * `@meta.id` PK (`id`) and the unique `username` index, plus any
84
+ * annotation-resolved `handleFields`; writes key on `id`.
11
85
  */
12
- var UsersStoreAtscriptDb = class extends require_user_store.UserStore {
86
+ var UsersStoreAtscriptDb = class extends require_federated_identity_store.UserStore {
13
87
  table;
88
+ /** Secondary handle fields tried (in order) by `findByHandle` after `username`. */
89
+ handleFields;
14
90
  constructor(opts) {
15
91
  super();
16
92
  this.table = opts.table;
93
+ this.handleFields = opts.handleFields ?? [];
17
94
  }
18
- async exists(username) {
19
- return await this.table.count({ filter: { username } }) > 0;
95
+ async exists(handle) {
96
+ return await this.table.count({ filter: { username: handle } }) > 0;
20
97
  }
21
- async findByUsername(username) {
22
- return await this.table.findOne({ filter: { username } }) ?? null;
98
+ async findById(id) {
99
+ return await this.table.findOne({ filter: { id } });
100
+ }
101
+ async findByHandle(handle) {
102
+ const byUsername = await this.table.findOne({ filter: { username: handle } });
103
+ if (byUsername) return byUsername;
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;
109
+ }
110
+ async findByIdentifier(value) {
111
+ const byId = await this.table.findOne({ filter: { id: value } });
112
+ if (byId) return byId;
113
+ return this.findByHandle(value);
23
114
  }
24
115
  async create(data) {
25
116
  try {
26
117
  await this.table.insertOne(data);
27
118
  } catch (e) {
28
- if (isConflict(e)) throw new require_user_store.UserAuthError("ALREADY_EXISTS", `User "${data.username}" already exists`);
119
+ if (isConflict(e)) throw new require_federated_identity_store.UserAuthError("ALREADY_EXISTS", `User "${data.username}" already exists`);
29
120
  throw e;
30
121
  }
31
122
  }
32
- async update(username, update) {
33
- const patch = { username };
123
+ async update(id, update) {
124
+ const patch = { id };
34
125
  if (update.set) Object.assign(patch, update.set);
35
- if (update.inc) for (const [path, amount] of Object.entries(update.inc)) require_user_store.setAtPath(patch, path, { $inc: amount });
126
+ if (update.inc) for (const [path, amount] of Object.entries(update.inc)) require_federated_identity_store.setAtPath(patch, path, { $inc: amount });
36
127
  if (Object.keys(patch).length <= 1) return true;
37
128
  if (update.expectedVersion !== void 0) patch.$cas = { version: update.expectedVersion };
38
- 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
+ }
39
135
  }
40
- async delete(username) {
41
- return (await this.table.deleteMany({ username })).deletedCount > 0;
136
+ async delete(id) {
137
+ return (await this.table.deleteMany({ id })).deletedCount > 0;
42
138
  }
43
139
  /**
44
140
  * Inline retry loop rather than delegating to @atscript/db's
45
141
  * `withOptimisticRetry`: that helper expects the mutator to always return a
46
142
  * patch object, but our contract lets the mutator return `null` (the
47
- * race-loser detects nothing to do — see `UserService.consumeBackupCode`).
48
- * Bridging would need a sentinel exception. The version-bump + $cas
49
- * atomicity still happen at the atscript-db table layer via the
50
- * `expectedVersion` we thread through `update()`.
143
+ * race-loser detects nothing to do — used by callers whose mutator opts
144
+ * out of a write after re-reading state). Bridging would need a sentinel
145
+ * exception. The version-bump + $cas atomicity still happen at the
146
+ * atscript-db table layer via the `expectedVersion` we thread through
147
+ * `update()`.
51
148
  */
52
- async withCas(username, mutator, opts) {
149
+ async withCas(id, mutator, opts) {
53
150
  const maxAttempts = opts?.maxAttempts ?? 2;
54
151
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
55
- const current = await this.findByUsername(username);
56
- if (!current) throw new require_user_store.UserAuthError("NOT_FOUND");
152
+ const current = await this.findById(id);
153
+ if (!current) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
57
154
  const patch = mutator(current);
58
155
  if (patch === null) return;
59
- if (await this.update(username, {
156
+ if (await this.update(id, {
60
157
  ...patch,
61
158
  expectedVersion: current.version ?? 0
62
159
  })) return;
63
160
  }
64
- throw new require_user_store.UserAuthError("CAS_EXHAUSTED");
161
+ throw new require_federated_identity_store.UserAuthError("CAS_EXHAUSTED");
65
162
  }
66
163
  };
67
164
  //#endregion
165
+ exports.FederatedIdentityStoreAtscriptDb = FederatedIdentityStoreAtscriptDb;
68
166
  exports.UsersStoreAtscriptDb = UsersStoreAtscriptDb;
@@ -1,5 +1,57 @@
1
- import { S as UserCredentials, n as WithCasOptions, t as UserStore, w as UserStoreUpdate } from "./user-store-BZsKtBHy.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
+ //#region src/atscript-db/federated-identity-store.d.ts
4
+ /**
5
+ * Structural surface of `AtscriptDbTable` covering exactly the methods this
6
+ * adapter calls — kept loose so `@aooth/user` doesn't pull `@atscript/db` types
7
+ * into its public surface. Consumers pass `db.getTable(AoothFederatedIdentity)`
8
+ * directly and TypeScript matches by-shape. Mirrors `AuthCredentialTable` from
9
+ * `@aooth/auth/atscript-db`.
10
+ */
11
+ interface FederatedIdentityTable {
12
+ insertOne(row: FederatedIdentity): Promise<{
13
+ insertedId: unknown;
14
+ }>;
15
+ findOne(query: {
16
+ filter: Record<string, unknown>;
17
+ }): Promise<FederatedIdentity | null>;
18
+ findMany(query: {
19
+ filter?: Record<string, unknown>;
20
+ }): Promise<FederatedIdentity[]>;
21
+ replaceOne(row: FederatedIdentity): Promise<{
22
+ matchedCount: number;
23
+ modifiedCount: number;
24
+ }>;
25
+ deleteMany(filter: Record<string, unknown>): Promise<{
26
+ deletedCount: number;
27
+ }>;
28
+ }
29
+ interface FederatedIdentityStoreAtscriptDbOptions {
30
+ table: FederatedIdentityTable;
31
+ /** Injectable clock for `linkedAt` / `lastLoginAt`. Defaults to `Date.now`. */
32
+ clock?: () => number;
33
+ }
34
+ /**
35
+ * `@atscript/db`-backed {@link FederatedIdentityStore}. Pass the resolved table
36
+ * for the `AoothFederatedIdentity` model shipped at
37
+ * `@aooth/user/atscript-db/federated-model`. The compound-unique
38
+ * `(provider, subject)` index makes `find` an O(1) point read and turns a
39
+ * cross-user re-link attempt into a `CONFLICT` (→ `ALREADY_EXISTS`); `userId`
40
+ * is a plain indexed column, so `listForUser` / `deleteAllForUser` scan it
41
+ * natively.
42
+ */
43
+ declare class FederatedIdentityStoreAtscriptDb extends FederatedIdentityStore {
44
+ private readonly table;
45
+ private readonly clock;
46
+ constructor(opts: FederatedIdentityStoreAtscriptDbOptions);
47
+ find(provider: string, subject: string): Promise<FederatedIdentity | null>;
48
+ listForUser(userId: string): Promise<FederatedIdentity[]>;
49
+ link(rec: NewFederatedIdentity): Promise<FederatedIdentity>;
50
+ unlink(provider: string, subject: string): Promise<boolean>;
51
+ touchLogin(provider: string, subject: string, profile?: FederatedProfileSnapshot): Promise<void>;
52
+ deleteAllForUser(userId: string): Promise<number>;
53
+ }
54
+ //#endregion
3
55
  //#region src/atscript-db/index.d.ts
4
56
  /**
5
57
  * Persisted row shape — `UserCredentials` plus the consumer's custom user
@@ -36,30 +88,47 @@ interface AuthUserTable<TUserCustom extends object = object> {
36
88
  }
37
89
  interface UsersStoreAtscriptDbOptions<TUserCustom extends object> {
38
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[];
39
101
  }
40
102
  /**
41
103
  * `@atscript/db`-backed `UserStore`. Pass the resolved table for the
42
104
  * `AoothUserCredentials` (or a `.as` interface extending it) shipped at the
43
- * `@aooth/user/atscript-db/model.as` subpath.
105
+ * `@aooth/user/atscript-db/model.as` subpath. Identity reads use the
106
+ * `@meta.id` PK (`id`) and the unique `username` index, plus any
107
+ * annotation-resolved `handleFields`; writes key on `id`.
44
108
  */
45
109
  declare class UsersStoreAtscriptDb<TUserCustom extends object = object> extends UserStore<TUserCustom> {
46
110
  protected table: AuthUserTable<TUserCustom>;
111
+ /** Secondary handle fields tried (in order) by `findByHandle` after `username`. */
112
+ protected readonly handleFields: string[];
47
113
  constructor(opts: UsersStoreAtscriptDbOptions<TUserCustom>);
48
- exists(username: string): Promise<boolean>;
49
- findByUsername(username: string): Promise<(UserCredentials & TUserCustom) | null>;
114
+ exists(handle: string): Promise<boolean>;
115
+ findById(id: string): Promise<(UserCredentials & TUserCustom) | null>;
116
+ findByHandle(handle: string): Promise<(UserCredentials & TUserCustom) | null>;
117
+ findByIdentifier(value: string): Promise<(UserCredentials & TUserCustom) | null>;
50
118
  create(data: UserCredentials & TUserCustom): Promise<void>;
51
- update(username: string, update: UserStoreUpdate): Promise<boolean>;
52
- delete(username: string): Promise<boolean>;
119
+ update(id: string, update: UserStoreUpdate): Promise<boolean>;
120
+ delete(id: string): Promise<boolean>;
53
121
  /**
54
122
  * Inline retry loop rather than delegating to @atscript/db's
55
123
  * `withOptimisticRetry`: that helper expects the mutator to always return a
56
124
  * patch object, but our contract lets the mutator return `null` (the
57
- * race-loser detects nothing to do — see `UserService.consumeBackupCode`).
58
- * Bridging would need a sentinel exception. The version-bump + $cas
59
- * atomicity still happen at the atscript-db table layer via the
60
- * `expectedVersion` we thread through `update()`.
125
+ * race-loser detects nothing to do — used by callers whose mutator opts
126
+ * out of a write after re-reading state). Bridging would need a sentinel
127
+ * exception. The version-bump + $cas atomicity still happen at the
128
+ * atscript-db table layer via the `expectedVersion` we thread through
129
+ * `update()`.
61
130
  */
62
- withCas(username: string, mutator: (current: UserCredentials & TUserCustom) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
131
+ withCas(id: string, mutator: (current: UserCredentials & TUserCustom) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
63
132
  }
64
133
  //#endregion
65
- export { AuthUserTable, UserCredentialsRow, UsersStoreAtscriptDb };
134
+ export { AuthUserTable, FederatedIdentityStoreAtscriptDb, type FederatedIdentityTable, UserCredentialsRow, UsersStoreAtscriptDb };
@@ -1,5 +1,57 @@
1
- import { S as UserCredentials, n as WithCasOptions, t as UserStore, w as UserStoreUpdate } from "./user-store-62LCSa8q.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
+ //#region src/atscript-db/federated-identity-store.d.ts
4
+ /**
5
+ * Structural surface of `AtscriptDbTable` covering exactly the methods this
6
+ * adapter calls — kept loose so `@aooth/user` doesn't pull `@atscript/db` types
7
+ * into its public surface. Consumers pass `db.getTable(AoothFederatedIdentity)`
8
+ * directly and TypeScript matches by-shape. Mirrors `AuthCredentialTable` from
9
+ * `@aooth/auth/atscript-db`.
10
+ */
11
+ interface FederatedIdentityTable {
12
+ insertOne(row: FederatedIdentity): Promise<{
13
+ insertedId: unknown;
14
+ }>;
15
+ findOne(query: {
16
+ filter: Record<string, unknown>;
17
+ }): Promise<FederatedIdentity | null>;
18
+ findMany(query: {
19
+ filter?: Record<string, unknown>;
20
+ }): Promise<FederatedIdentity[]>;
21
+ replaceOne(row: FederatedIdentity): Promise<{
22
+ matchedCount: number;
23
+ modifiedCount: number;
24
+ }>;
25
+ deleteMany(filter: Record<string, unknown>): Promise<{
26
+ deletedCount: number;
27
+ }>;
28
+ }
29
+ interface FederatedIdentityStoreAtscriptDbOptions {
30
+ table: FederatedIdentityTable;
31
+ /** Injectable clock for `linkedAt` / `lastLoginAt`. Defaults to `Date.now`. */
32
+ clock?: () => number;
33
+ }
34
+ /**
35
+ * `@atscript/db`-backed {@link FederatedIdentityStore}. Pass the resolved table
36
+ * for the `AoothFederatedIdentity` model shipped at
37
+ * `@aooth/user/atscript-db/federated-model`. The compound-unique
38
+ * `(provider, subject)` index makes `find` an O(1) point read and turns a
39
+ * cross-user re-link attempt into a `CONFLICT` (→ `ALREADY_EXISTS`); `userId`
40
+ * is a plain indexed column, so `listForUser` / `deleteAllForUser` scan it
41
+ * natively.
42
+ */
43
+ declare class FederatedIdentityStoreAtscriptDb extends FederatedIdentityStore {
44
+ private readonly table;
45
+ private readonly clock;
46
+ constructor(opts: FederatedIdentityStoreAtscriptDbOptions);
47
+ find(provider: string, subject: string): Promise<FederatedIdentity | null>;
48
+ listForUser(userId: string): Promise<FederatedIdentity[]>;
49
+ link(rec: NewFederatedIdentity): Promise<FederatedIdentity>;
50
+ unlink(provider: string, subject: string): Promise<boolean>;
51
+ touchLogin(provider: string, subject: string, profile?: FederatedProfileSnapshot): Promise<void>;
52
+ deleteAllForUser(userId: string): Promise<number>;
53
+ }
54
+ //#endregion
3
55
  //#region src/atscript-db/index.d.ts
4
56
  /**
5
57
  * Persisted row shape — `UserCredentials` plus the consumer's custom user
@@ -36,30 +88,47 @@ interface AuthUserTable<TUserCustom extends object = object> {
36
88
  }
37
89
  interface UsersStoreAtscriptDbOptions<TUserCustom extends object> {
38
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[];
39
101
  }
40
102
  /**
41
103
  * `@atscript/db`-backed `UserStore`. Pass the resolved table for the
42
104
  * `AoothUserCredentials` (or a `.as` interface extending it) shipped at the
43
- * `@aooth/user/atscript-db/model.as` subpath.
105
+ * `@aooth/user/atscript-db/model.as` subpath. Identity reads use the
106
+ * `@meta.id` PK (`id`) and the unique `username` index, plus any
107
+ * annotation-resolved `handleFields`; writes key on `id`.
44
108
  */
45
109
  declare class UsersStoreAtscriptDb<TUserCustom extends object = object> extends UserStore<TUserCustom> {
46
110
  protected table: AuthUserTable<TUserCustom>;
111
+ /** Secondary handle fields tried (in order) by `findByHandle` after `username`. */
112
+ protected readonly handleFields: string[];
47
113
  constructor(opts: UsersStoreAtscriptDbOptions<TUserCustom>);
48
- exists(username: string): Promise<boolean>;
49
- findByUsername(username: string): Promise<(UserCredentials & TUserCustom) | null>;
114
+ exists(handle: string): Promise<boolean>;
115
+ findById(id: string): Promise<(UserCredentials & TUserCustom) | null>;
116
+ findByHandle(handle: string): Promise<(UserCredentials & TUserCustom) | null>;
117
+ findByIdentifier(value: string): Promise<(UserCredentials & TUserCustom) | null>;
50
118
  create(data: UserCredentials & TUserCustom): Promise<void>;
51
- update(username: string, update: UserStoreUpdate): Promise<boolean>;
52
- delete(username: string): Promise<boolean>;
119
+ update(id: string, update: UserStoreUpdate): Promise<boolean>;
120
+ delete(id: string): Promise<boolean>;
53
121
  /**
54
122
  * Inline retry loop rather than delegating to @atscript/db's
55
123
  * `withOptimisticRetry`: that helper expects the mutator to always return a
56
124
  * patch object, but our contract lets the mutator return `null` (the
57
- * race-loser detects nothing to do — see `UserService.consumeBackupCode`).
58
- * Bridging would need a sentinel exception. The version-bump + $cas
59
- * atomicity still happen at the atscript-db table layer via the
60
- * `expectedVersion` we thread through `update()`.
125
+ * race-loser detects nothing to do — used by callers whose mutator opts
126
+ * out of a write after re-reading state). Bridging would need a sentinel
127
+ * exception. The version-bump + $cas atomicity still happen at the
128
+ * atscript-db table layer via the `expectedVersion` we thread through
129
+ * `update()`.
61
130
  */
62
- withCas(username: string, mutator: (current: UserCredentials & TUserCustom) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
131
+ withCas(id: string, mutator: (current: UserCredentials & TUserCustom) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
63
132
  }
64
133
  //#endregion
65
- export { AuthUserTable, UserCredentialsRow, UsersStoreAtscriptDb };
134
+ export { AuthUserTable, FederatedIdentityStoreAtscriptDb, type FederatedIdentityTable, UserCredentialsRow, UsersStoreAtscriptDb };
@@ -1,4 +1,76 @@
1
- import { c as setAtPath, l as UserAuthError, t as UserStore } from "./user-store-BaBmH13V.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
+ import { randomUUID } from "node:crypto";
3
+ //#region src/atscript-db/federated-identity-store.ts
4
+ function isConflict$1(err) {
5
+ return typeof err === "object" && err !== null && err.code === "CONFLICT";
6
+ }
7
+ /**
8
+ * `@atscript/db`-backed {@link FederatedIdentityStore}. Pass the resolved table
9
+ * for the `AoothFederatedIdentity` model shipped at
10
+ * `@aooth/user/atscript-db/federated-model`. The compound-unique
11
+ * `(provider, subject)` index makes `find` an O(1) point read and turns a
12
+ * cross-user re-link attempt into a `CONFLICT` (→ `ALREADY_EXISTS`); `userId`
13
+ * is a plain indexed column, so `listForUser` / `deleteAllForUser` scan it
14
+ * natively.
15
+ */
16
+ var FederatedIdentityStoreAtscriptDb = class extends FederatedIdentityStore {
17
+ table;
18
+ clock;
19
+ constructor(opts) {
20
+ super();
21
+ this.table = opts.table;
22
+ this.clock = opts.clock ?? Date.now;
23
+ }
24
+ async find(provider, subject) {
25
+ return this.table.findOne({ filter: {
26
+ provider,
27
+ subject
28
+ } });
29
+ }
30
+ async listForUser(userId) {
31
+ return (await this.table.findMany({ filter: { userId } })).toSorted((a, b) => a.linkedAt - b.linkedAt);
32
+ }
33
+ async link(rec) {
34
+ const row = {
35
+ id: randomUUID(),
36
+ provider: rec.provider,
37
+ subject: rec.subject,
38
+ userId: rec.userId,
39
+ ...pickDefinedProfile(rec),
40
+ linkedAt: this.clock()
41
+ };
42
+ try {
43
+ await this.table.insertOne(row);
44
+ return row;
45
+ } catch (e) {
46
+ if (isConflict$1(e)) throw new UserAuthError("ALREADY_EXISTS", `Provider account "${rec.provider}:${rec.subject}" is already linked`);
47
+ throw e;
48
+ }
49
+ }
50
+ async unlink(provider, subject) {
51
+ return (await this.table.deleteMany({
52
+ provider,
53
+ subject
54
+ })).deletedCount > 0;
55
+ }
56
+ async touchLogin(provider, subject, profile) {
57
+ const row = await this.table.findOne({ filter: {
58
+ provider,
59
+ subject
60
+ } });
61
+ if (!row) return;
62
+ const next = {
63
+ ...row,
64
+ lastLoginAt: this.clock()
65
+ };
66
+ if (profile) Object.assign(next, pickDefinedProfile(profile));
67
+ await this.table.replaceOne(next);
68
+ }
69
+ async deleteAllForUser(userId) {
70
+ return (await this.table.deleteMany({ userId })).deletedCount;
71
+ }
72
+ };
73
+ //#endregion
2
74
  //#region src/atscript-db/index.ts
3
75
  function isConflict(err) {
4
76
  return typeof err === "object" && err !== null && err.code === "CONFLICT";
@@ -6,19 +78,38 @@ function isConflict(err) {
6
78
  /**
7
79
  * `@atscript/db`-backed `UserStore`. Pass the resolved table for the
8
80
  * `AoothUserCredentials` (or a `.as` interface extending it) shipped at the
9
- * `@aooth/user/atscript-db/model.as` subpath.
81
+ * `@aooth/user/atscript-db/model.as` subpath. Identity reads use the
82
+ * `@meta.id` PK (`id`) and the unique `username` index, plus any
83
+ * annotation-resolved `handleFields`; writes key on `id`.
10
84
  */
11
85
  var UsersStoreAtscriptDb = class extends UserStore {
12
86
  table;
87
+ /** Secondary handle fields tried (in order) by `findByHandle` after `username`. */
88
+ handleFields;
13
89
  constructor(opts) {
14
90
  super();
15
91
  this.table = opts.table;
92
+ this.handleFields = opts.handleFields ?? [];
16
93
  }
17
- async exists(username) {
18
- return await this.table.count({ filter: { username } }) > 0;
94
+ async exists(handle) {
95
+ return await this.table.count({ filter: { username: handle } }) > 0;
19
96
  }
20
- async findByUsername(username) {
21
- return await this.table.findOne({ filter: { username } }) ?? null;
97
+ async findById(id) {
98
+ return await this.table.findOne({ filter: { id } });
99
+ }
100
+ async findByHandle(handle) {
101
+ const byUsername = await this.table.findOne({ filter: { username: handle } });
102
+ if (byUsername) return byUsername;
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;
108
+ }
109
+ async findByIdentifier(value) {
110
+ const byId = await this.table.findOne({ filter: { id: value } });
111
+ if (byId) return byId;
112
+ return this.findByHandle(value);
22
113
  }
23
114
  async create(data) {
24
115
  try {
@@ -28,34 +119,40 @@ var UsersStoreAtscriptDb = class extends UserStore {
28
119
  throw e;
29
120
  }
30
121
  }
31
- async update(username, update) {
32
- const patch = { username };
122
+ async update(id, update) {
123
+ const patch = { id };
33
124
  if (update.set) Object.assign(patch, update.set);
34
125
  if (update.inc) for (const [path, amount] of Object.entries(update.inc)) setAtPath(patch, path, { $inc: amount });
35
126
  if (Object.keys(patch).length <= 1) return true;
36
127
  if (update.expectedVersion !== void 0) patch.$cas = { version: update.expectedVersion };
37
- 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
+ }
38
134
  }
39
- async delete(username) {
40
- return (await this.table.deleteMany({ username })).deletedCount > 0;
135
+ async delete(id) {
136
+ return (await this.table.deleteMany({ id })).deletedCount > 0;
41
137
  }
42
138
  /**
43
139
  * Inline retry loop rather than delegating to @atscript/db's
44
140
  * `withOptimisticRetry`: that helper expects the mutator to always return a
45
141
  * patch object, but our contract lets the mutator return `null` (the
46
- * race-loser detects nothing to do — see `UserService.consumeBackupCode`).
47
- * Bridging would need a sentinel exception. The version-bump + $cas
48
- * atomicity still happen at the atscript-db table layer via the
49
- * `expectedVersion` we thread through `update()`.
142
+ * race-loser detects nothing to do — used by callers whose mutator opts
143
+ * out of a write after re-reading state). Bridging would need a sentinel
144
+ * exception. The version-bump + $cas atomicity still happen at the
145
+ * atscript-db table layer via the `expectedVersion` we thread through
146
+ * `update()`.
50
147
  */
51
- async withCas(username, mutator, opts) {
148
+ async withCas(id, mutator, opts) {
52
149
  const maxAttempts = opts?.maxAttempts ?? 2;
53
150
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
54
- const current = await this.findByUsername(username);
151
+ const current = await this.findById(id);
55
152
  if (!current) throw new UserAuthError("NOT_FOUND");
56
153
  const patch = mutator(current);
57
154
  if (patch === null) return;
58
- if (await this.update(username, {
155
+ if (await this.update(id, {
59
156
  ...patch,
60
157
  expectedVersion: current.version ?? 0
61
158
  })) return;
@@ -64,4 +161,4 @@ var UsersStoreAtscriptDb = class extends UserStore {
64
161
  }
65
162
  };
66
163
  //#endregion
67
- export { UsersStoreAtscriptDb };
164
+ export { FederatedIdentityStoreAtscriptDb, UsersStoreAtscriptDb };