@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.
- package/dist/atscript-db.cjs +121 -23
- package/dist/atscript-db.d.cts +81 -12
- package/dist/atscript-db.d.mts +81 -12
- package/dist/atscript-db.mjs +116 -19
- package/dist/{user-store-BPZVAboN.cjs → federated-identity-store-BEEEcoaP.cjs} +52 -0
- package/dist/{user-store-BaBmH13V.mjs → federated-identity-store-CHW1xtMp.mjs} +41 -1
- package/dist/{user-store-62LCSa8q.d.mts → federated-identity-store-CI7Vgllp.d.cts} +143 -24
- package/dist/{user-store-BZsKtBHy.d.cts → federated-identity-store-CI7Vgllp.d.mts} +143 -24
- package/dist/index.cjs +321 -220
- package/dist/index.d.cts +123 -73
- package/dist/index.d.mts +123 -73
- package/dist/index.mjs +289 -190
- package/package.json +23 -9
- package/src/atscript-db/federated-identity.as +44 -0
- package/src/atscript-db/federated-identity.as.d.ts +62 -0
- package/src/atscript-db/user-credentials.as +4 -2
- package/src/atscript-db/user-credentials.as.d.ts +61 -0
package/dist/atscript-db.cjs
CHANGED
|
@@ -1,5 +1,77 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const
|
|
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
|
|
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(
|
|
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
|
|
22
|
-
return await this.table.findOne({ filter: {
|
|
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
|
|
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(
|
|
33
|
-
const patch = {
|
|
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))
|
|
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
|
-
|
|
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(
|
|
41
|
-
return (await this.table.deleteMany({
|
|
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 —
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* `expectedVersion` we thread through
|
|
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(
|
|
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.
|
|
56
|
-
if (!current) throw new
|
|
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(
|
|
156
|
+
if (await this.update(id, {
|
|
60
157
|
...patch,
|
|
61
158
|
expectedVersion: current.version ?? 0
|
|
62
159
|
})) return;
|
|
63
160
|
}
|
|
64
|
-
throw new
|
|
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;
|
package/dist/atscript-db.d.cts
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
|
-
import {
|
|
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(
|
|
49
|
-
|
|
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(
|
|
52
|
-
delete(
|
|
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 —
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* `expectedVersion` we thread through
|
|
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(
|
|
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 };
|
package/dist/atscript-db.d.mts
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
|
-
import {
|
|
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(
|
|
49
|
-
|
|
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(
|
|
52
|
-
delete(
|
|
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 —
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* `expectedVersion` we thread through
|
|
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(
|
|
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 };
|
package/dist/atscript-db.mjs
CHANGED
|
@@ -1,4 +1,76 @@
|
|
|
1
|
-
import {
|
|
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(
|
|
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
|
|
21
|
-
return await this.table.findOne({ filter: {
|
|
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(
|
|
32
|
-
const patch = {
|
|
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
|
-
|
|
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(
|
|
40
|
-
return (await this.table.deleteMany({
|
|
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 —
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* `expectedVersion` we thread through
|
|
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(
|
|
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.
|
|
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(
|
|
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 };
|