@jskit-ai/auth-provider-local-db-core 0.1.1

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.
@@ -0,0 +1,109 @@
1
+ export default Object.freeze({
2
+ packageVersion: 1,
3
+ packageId: "@jskit-ai/auth-provider-local-db-core",
4
+ version: "0.1.1",
5
+ kind: "runtime",
6
+ description: "Database-backed local auth storage backend for JSKIT local auth.",
7
+ dependsOn: [
8
+ "@jskit-ai/auth-provider-local-core",
9
+ "@jskit-ai/database-runtime"
10
+ ],
11
+ capabilities: {
12
+ provides: [
13
+ "auth.local.backend.db"
14
+ ],
15
+ requires: [
16
+ "runtime.database"
17
+ ]
18
+ },
19
+ runtime: {
20
+ server: {
21
+ providers: [
22
+ {
23
+ entrypoint: "src/server/providers/AuthLocalDbBackendServiceProvider.js",
24
+ export: "AuthLocalDbBackendServiceProvider"
25
+ }
26
+ ]
27
+ },
28
+ client: {
29
+ providers: []
30
+ }
31
+ },
32
+ metadata: {
33
+ jskit: {
34
+ tableOwnership: {
35
+ tables: [
36
+ {
37
+ tableName: "auth_local_users",
38
+ provenance: "auth-provider-local-db-core",
39
+ ownerKind: "package"
40
+ },
41
+ {
42
+ tableName: "auth_local_sessions",
43
+ provenance: "auth-provider-local-db-core",
44
+ ownerKind: "package"
45
+ },
46
+ {
47
+ tableName: "auth_local_recovery",
48
+ provenance: "auth-provider-local-db-core",
49
+ ownerKind: "package"
50
+ }
51
+ ]
52
+ }
53
+ },
54
+ apiSummary: {
55
+ surfaces: [
56
+ {
57
+ subpath: "./server/providers/AuthLocalDbBackendServiceProvider",
58
+ summary: "Exports the service provider that registers auth.local.backend for AUTH_LOCAL_BACKEND=db."
59
+ },
60
+ {
61
+ subpath: "./server/lib/index",
62
+ summary: "Exports the DB local auth backend factory."
63
+ }
64
+ ],
65
+ containerTokens: {
66
+ server: [
67
+ "auth.local.backend"
68
+ ],
69
+ client: []
70
+ }
71
+ }
72
+ },
73
+ mutations: {
74
+ dependencies: {
75
+ runtime: {
76
+ "@jskit-ai/auth-provider-local-core": "0.1.9",
77
+ "@jskit-ai/database-runtime": "0.1.109",
78
+ "@jskit-ai/kernel": "0.1.110"
79
+ },
80
+ dev: {}
81
+ },
82
+ packageJson: {
83
+ scripts: {}
84
+ },
85
+ procfile: {},
86
+ files: [
87
+ {
88
+ op: "install-migration",
89
+ from: "templates/migrations/auth_local_db_initial.cjs",
90
+ toDir: "migrations",
91
+ extension: ".cjs",
92
+ reason: "Install DB-backed local auth credential, session, and recovery tables.",
93
+ category: "auth-local-db",
94
+ id: "auth-local-db-initial-schema"
95
+ }
96
+ ],
97
+ text: [
98
+ {
99
+ file: ".env",
100
+ op: "upsert-env",
101
+ key: "AUTH_LOCAL_BACKEND",
102
+ value: "db",
103
+ reason: "Use the database-backed local auth backend.",
104
+ category: "runtime-config",
105
+ id: "auth-local-backend-db"
106
+ }
107
+ ]
108
+ }
109
+ });
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@jskit-ai/auth-provider-local-db-core",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "scripts": {
6
+ "test": "node --test"
7
+ },
8
+ "exports": {
9
+ "./server/providers/AuthLocalDbBackendServiceProvider": "./src/server/providers/AuthLocalDbBackendServiceProvider.js",
10
+ "./server/lib/index": "./src/server/lib/index.js"
11
+ },
12
+ "dependencies": {
13
+ "@jskit-ai/auth-provider-local-core": "0.1.9",
14
+ "@jskit-ai/database-runtime": "0.1.109",
15
+ "@jskit-ai/kernel": "0.1.110"
16
+ }
17
+ }
@@ -0,0 +1,284 @@
1
+ import { isDuplicateEntryError } from "@jskit-ai/database-runtime/shared/duplicateEntry";
2
+
3
+ const TABLES = Object.freeze({
4
+ users: "auth_local_users",
5
+ sessions: "auth_local_sessions",
6
+ recovery: "auth_local_recovery"
7
+ });
8
+
9
+ function nowIso() {
10
+ return new Date().toISOString();
11
+ }
12
+
13
+ function toDate(value) {
14
+ const date = value instanceof Date ? value : new Date(value);
15
+ if (Number.isNaN(date.getTime())) {
16
+ throw new TypeError("Invalid local auth DB timestamp.");
17
+ }
18
+ return date;
19
+ }
20
+
21
+ function requiredDateTime(value, fallback = nowIso()) {
22
+ return toDate(value || fallback);
23
+ }
24
+
25
+ function optionalDateTime(value) {
26
+ return value ? toDate(value) : null;
27
+ }
28
+
29
+ function readDateTime(value, fallback = "") {
30
+ if (!value) {
31
+ return fallback;
32
+ }
33
+ return toDate(value).toISOString();
34
+ }
35
+
36
+ function readPassword(row) {
37
+ return {
38
+ algorithm: String(row.password_algorithm || ""),
39
+ version: String(row.password_version || ""),
40
+ salt: String(row.password_salt || ""),
41
+ hash: String(row.password_hash || "")
42
+ };
43
+ }
44
+
45
+ function writePassword(password = {}) {
46
+ return {
47
+ password_algorithm: String(password.algorithm || ""),
48
+ password_version: String(password.version || ""),
49
+ password_salt: String(password.salt || ""),
50
+ password_hash: String(password.hash || "")
51
+ };
52
+ }
53
+
54
+ function mapUser(row) {
55
+ if (!row) {
56
+ return null;
57
+ }
58
+ return {
59
+ id: String(row.id || ""),
60
+ email: String(row.email || ""),
61
+ displayName: String(row.display_name || ""),
62
+ password: readPassword(row),
63
+ createdAt: readDateTime(row.created_at),
64
+ updatedAt: readDateTime(row.updated_at),
65
+ disabled: row.disabled === true || row.disabled === 1 || row.disabled === "1"
66
+ };
67
+ }
68
+
69
+ function mapSession(row) {
70
+ if (!row) {
71
+ return null;
72
+ }
73
+ return {
74
+ id: String(row.id || ""),
75
+ userId: String(row.user_id || ""),
76
+ tokenHash: String(row.token_hash || ""),
77
+ purpose: String(row.purpose || "normal"),
78
+ createdAt: readDateTime(row.created_at),
79
+ expiresAt: readDateTime(row.expires_at),
80
+ revokedAt: readDateTime(row.revoked_at)
81
+ };
82
+ }
83
+
84
+ function mapRecovery(row) {
85
+ if (!row) {
86
+ return null;
87
+ }
88
+ return {
89
+ id: String(row.id || ""),
90
+ userId: String(row.user_id || ""),
91
+ tokenHash: String(row.token_hash || ""),
92
+ createdAt: readDateTime(row.created_at),
93
+ expiresAt: readDateTime(row.expires_at),
94
+ usedAt: readDateTime(row.used_at)
95
+ };
96
+ }
97
+
98
+ function userInsert(input = {}) {
99
+ const createdAt = requiredDateTime(input.createdAt);
100
+ return {
101
+ id: String(input.id || ""),
102
+ email: String(input.email || ""),
103
+ display_name: String(input.displayName || ""),
104
+ ...writePassword(input.password),
105
+ disabled: input.disabled === true,
106
+ created_at: createdAt,
107
+ updated_at: requiredDateTime(input.updatedAt, createdAt)
108
+ };
109
+ }
110
+
111
+ function sessionInsert(input = {}) {
112
+ const createdAt = requiredDateTime(input.createdAt);
113
+ return {
114
+ id: String(input.id || ""),
115
+ user_id: String(input.userId || ""),
116
+ token_hash: String(input.tokenHash || ""),
117
+ purpose: String(input.purpose || "normal"),
118
+ expires_at: requiredDateTime(input.expiresAt),
119
+ revoked_at: optionalDateTime(input.revokedAt),
120
+ created_at: createdAt,
121
+ updated_at: requiredDateTime(input.updatedAt, createdAt)
122
+ };
123
+ }
124
+
125
+ function recoveryInsert(input = {}) {
126
+ const createdAt = requiredDateTime(input.createdAt);
127
+ return {
128
+ id: String(input.id || ""),
129
+ user_id: String(input.userId || ""),
130
+ token_hash: String(input.tokenHash || ""),
131
+ expires_at: requiredDateTime(input.expiresAt),
132
+ used_at: optionalDateTime(input.usedAt),
133
+ created_at: createdAt,
134
+ updated_at: requiredDateTime(input.updatedAt, createdAt)
135
+ };
136
+ }
137
+
138
+ function assertBackendDependencies({ knex, transactionManager } = {}) {
139
+ if (typeof knex !== "function") {
140
+ throw new Error("createLocalDbBackend requires jskit.database.knex.");
141
+ }
142
+ if (!transactionManager || typeof transactionManager.inTransaction !== "function") {
143
+ throw new Error("createLocalDbBackend requires jskit.database.transactionManager.");
144
+ }
145
+ }
146
+
147
+ function createLocalDbBackend({ knex, transactionManager } = {}) {
148
+ assertBackendDependencies({ knex, transactionManager });
149
+
150
+ function table(client, tableName) {
151
+ return client(tableName);
152
+ }
153
+
154
+ function createTx(client) {
155
+ return Object.freeze({
156
+ users: Object.freeze({
157
+ async create(input) {
158
+ const row = userInsert(input);
159
+ try {
160
+ await table(client, TABLES.users).insert(row);
161
+ } catch (error) {
162
+ if (isDuplicateEntryError(error, { client: knex })) {
163
+ throw new Error("Local auth user already exists.");
164
+ }
165
+ throw error;
166
+ }
167
+ return mapUser(row);
168
+ },
169
+ async findById(userId) {
170
+ return mapUser(await table(client, TABLES.users).where({ id: String(userId || "") }).first());
171
+ },
172
+ async findByEmail(email) {
173
+ return mapUser(await table(client, TABLES.users).where({ email: String(email || "") }).first());
174
+ },
175
+ async updatePassword(userId, passwordRecord) {
176
+ const updatedAt = requiredDateTime();
177
+ await table(client, TABLES.users)
178
+ .where({ id: String(userId || "") })
179
+ .update({
180
+ ...writePassword(passwordRecord),
181
+ updated_at: updatedAt
182
+ });
183
+ return mapUser(await table(client, TABLES.users).where({ id: String(userId || "") }).first());
184
+ },
185
+ async updateProfile(userId, updates = {}) {
186
+ const patch = {
187
+ updated_at: requiredDateTime()
188
+ };
189
+ if (typeof updates.displayName === "string") {
190
+ patch.display_name = updates.displayName;
191
+ }
192
+ await table(client, TABLES.users).where({ id: String(userId || "") }).update(patch);
193
+ return mapUser(await table(client, TABLES.users).where({ id: String(userId || "") }).first());
194
+ }
195
+ }),
196
+ sessions: Object.freeze({
197
+ async create(input) {
198
+ const row = sessionInsert(input);
199
+ await table(client, TABLES.sessions).insert(row);
200
+ return mapSession(row);
201
+ },
202
+ async findById(sessionId) {
203
+ return mapSession(await table(client, TABLES.sessions).where({ id: String(sessionId || "") }).first());
204
+ },
205
+ async findByTokenHash(tokenHash) {
206
+ return mapSession(await table(client, TABLES.sessions).where({ token_hash: String(tokenHash || "") }).first());
207
+ },
208
+ async revoke(sessionId) {
209
+ const existing = await table(client, TABLES.sessions).where({ id: String(sessionId || "") }).first();
210
+ if (!existing || existing.revoked_at) {
211
+ return mapSession(existing);
212
+ }
213
+ const updatedAt = requiredDateTime();
214
+ await table(client, TABLES.sessions)
215
+ .where({ id: String(sessionId || "") })
216
+ .update({
217
+ revoked_at: updatedAt,
218
+ updated_at: updatedAt
219
+ });
220
+ return mapSession(await table(client, TABLES.sessions).where({ id: String(sessionId || "") }).first());
221
+ },
222
+ async revokeForUser(userId, options = {}) {
223
+ const query = table(client, TABLES.sessions)
224
+ .where({ user_id: String(userId || "") })
225
+ .whereNull("revoked_at");
226
+ const exceptSessionId = String(options.exceptSessionId || "");
227
+ if (exceptSessionId) {
228
+ query.whereNot("id", exceptSessionId);
229
+ }
230
+ const updatedAt = requiredDateTime();
231
+ const count = await query.update({
232
+ revoked_at: updatedAt,
233
+ updated_at: updatedAt
234
+ });
235
+ return Number(count || 0);
236
+ }
237
+ }),
238
+ recovery: Object.freeze({
239
+ async create(input) {
240
+ const row = recoveryInsert(input);
241
+ await table(client, TABLES.recovery).insert(row);
242
+ return mapRecovery(row);
243
+ },
244
+ async findByTokenHash(tokenHash) {
245
+ return mapRecovery(await table(client, TABLES.recovery).where({ token_hash: String(tokenHash || "") }).first());
246
+ },
247
+ async consume(tokenId) {
248
+ const existing = await table(client, TABLES.recovery).where({ id: String(tokenId || "") }).first();
249
+ if (!existing || existing.used_at) {
250
+ return mapRecovery(existing);
251
+ }
252
+ const updatedAt = requiredDateTime();
253
+ await table(client, TABLES.recovery)
254
+ .where({ id: String(tokenId || "") })
255
+ .update({
256
+ used_at: updatedAt,
257
+ updated_at: updatedAt
258
+ });
259
+ return mapRecovery(await table(client, TABLES.recovery).where({ id: String(tokenId || "") }).first());
260
+ },
261
+ async consumeForUser(userId) {
262
+ const updatedAt = requiredDateTime();
263
+ const count = await table(client, TABLES.recovery)
264
+ .where({ user_id: String(userId || "") })
265
+ .whereNull("used_at")
266
+ .update({
267
+ used_at: updatedAt,
268
+ updated_at: updatedAt
269
+ });
270
+ return Number(count || 0);
271
+ }
272
+ })
273
+ });
274
+ }
275
+
276
+ return Object.freeze({
277
+ tables: TABLES,
278
+ async withTransaction(callback) {
279
+ return transactionManager.inTransaction(async (trx) => callback(createTx(trx)));
280
+ }
281
+ });
282
+ }
283
+
284
+ export { TABLES as LOCAL_AUTH_DB_TABLES, createLocalDbBackend };
@@ -0,0 +1 @@
1
+ export { createLocalDbBackend, LOCAL_AUTH_DB_TABLES } from "./dbBackend.js";
@@ -0,0 +1,30 @@
1
+ import { resolveLocalBackendMode } from "@jskit-ai/auth-provider-local-core/server/providers/AuthLocalServiceProvider";
2
+ import { createLocalDbBackend } from "../lib/dbBackend.js";
3
+
4
+ class AuthLocalDbBackendServiceProvider {
5
+ static id = "auth.provider.local.db";
6
+
7
+ static dependsOn = ["runtime.database"];
8
+
9
+ register(app) {
10
+ if (!app || typeof app.singleton !== "function" || typeof app.has !== "function") {
11
+ throw new Error("AuthLocalDbBackendServiceProvider requires application singleton()/has().");
12
+ }
13
+
14
+ const backendMode = resolveLocalBackendMode(app);
15
+ if (backendMode !== "db") {
16
+ return;
17
+ }
18
+
19
+ if (app.has("auth.local.backend")) {
20
+ throw new Error('AUTH_LOCAL_BACKEND="db" requires auth-provider-local-db-core to own auth.local.backend.');
21
+ }
22
+
23
+ app.singleton("auth.local.backend", (scope) => createLocalDbBackend({
24
+ knex: scope.make("jskit.database.knex"),
25
+ transactionManager: scope.make("jskit.database.transactionManager")
26
+ }));
27
+ }
28
+ }
29
+
30
+ export { AuthLocalDbBackendServiceProvider };
@@ -0,0 +1,60 @@
1
+ const USERS_TABLE = "auth_local_users";
2
+ const SESSIONS_TABLE = "auth_local_sessions";
3
+ const RECOVERY_TABLE = "auth_local_recovery";
4
+
5
+ exports.up = async function up(knex) {
6
+ if (!(await knex.schema.hasTable(USERS_TABLE))) {
7
+ await knex.schema.createTable(USERS_TABLE, (table) => {
8
+ table.string("id", 80).primary();
9
+ table.string("email", 320).notNullable();
10
+ table.string("display_name", 160).notNullable();
11
+ table.string("password_algorithm", 64).notNullable();
12
+ table.string("password_version", 32).notNullable();
13
+ table.string("password_salt", 256).notNullable();
14
+ table.string("password_hash", 512).notNullable();
15
+ table.boolean("disabled").notNullable().defaultTo(false);
16
+ table.timestamp("created_at").notNullable();
17
+ table.timestamp("updated_at").notNullable();
18
+ table.unique(["email"], "uq_auth_local_users_email");
19
+ });
20
+ }
21
+
22
+ if (!(await knex.schema.hasTable(SESSIONS_TABLE))) {
23
+ await knex.schema.createTable(SESSIONS_TABLE, (table) => {
24
+ table.string("id", 80).primary();
25
+ table.string("user_id", 80).notNullable();
26
+ table.string("token_hash", 128).notNullable();
27
+ table.string("purpose", 32).notNullable();
28
+ table.timestamp("expires_at").notNullable();
29
+ table.timestamp("revoked_at").nullable();
30
+ table.timestamp("created_at").notNullable();
31
+ table.timestamp("updated_at").notNullable();
32
+ table.unique(["token_hash"], "uq_auth_local_sessions_token_hash");
33
+ table.index(["user_id"], "idx_auth_local_sessions_user");
34
+ table.index(["expires_at"], "idx_auth_local_sessions_expires");
35
+ table.foreign(["user_id"], "fk_auth_local_sessions_user").references(["id"]).inTable(USERS_TABLE).onUpdate("RESTRICT").onDelete("CASCADE");
36
+ });
37
+ }
38
+
39
+ if (!(await knex.schema.hasTable(RECOVERY_TABLE))) {
40
+ await knex.schema.createTable(RECOVERY_TABLE, (table) => {
41
+ table.string("id", 80).primary();
42
+ table.string("user_id", 80).notNullable();
43
+ table.string("token_hash", 128).notNullable();
44
+ table.timestamp("expires_at").notNullable();
45
+ table.timestamp("used_at").nullable();
46
+ table.timestamp("created_at").notNullable();
47
+ table.timestamp("updated_at").notNullable();
48
+ table.unique(["token_hash"], "uq_auth_local_recovery_token_hash");
49
+ table.index(["user_id"], "idx_auth_local_recovery_user");
50
+ table.index(["expires_at"], "idx_auth_local_recovery_expires");
51
+ table.foreign(["user_id"], "fk_auth_local_recovery_user").references(["id"]).inTable(USERS_TABLE).onUpdate("RESTRICT").onDelete("CASCADE");
52
+ });
53
+ }
54
+ };
55
+
56
+ exports.down = async function down(knex) {
57
+ await knex.schema.dropTableIfExists(RECOVERY_TABLE);
58
+ await knex.schema.dropTableIfExists(SESSIONS_TABLE);
59
+ await knex.schema.dropTableIfExists(USERS_TABLE);
60
+ };
@@ -0,0 +1,505 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { createApplication } from "@jskit-ai/kernel/_testable";
5
+ import { DatabaseRuntimeServiceProvider } from "@jskit-ai/database-runtime/server/providers/DatabaseRuntimeServiceProvider";
6
+ import { createLocalAuthService, hashPassword } from "@jskit-ai/auth-provider-local-core/server/lib/index";
7
+ import { AuthLocalServiceProvider } from "@jskit-ai/auth-provider-local-core/server/providers/AuthLocalServiceProvider";
8
+ import { createLocalDbBackend, LOCAL_AUTH_DB_TABLES } from "../src/server/lib/index.js";
9
+ import { AuthLocalDbBackendServiceProvider } from "../src/server/providers/AuthLocalDbBackendServiceProvider.js";
10
+ import descriptor from "../package.descriptor.mjs";
11
+
12
+ function clone(value) {
13
+ return value == null ? value : JSON.parse(JSON.stringify(value));
14
+ }
15
+
16
+ function createDuplicateError() {
17
+ const error = new Error("duplicate key value violates unique constraint");
18
+ error.code = "23505";
19
+ return error;
20
+ }
21
+
22
+ function createMemoryKnex() {
23
+ const tables = new Map(Object.values(LOCAL_AUTH_DB_TABLES).map((tableName) => [tableName, []]));
24
+
25
+ function rowsFor(tableName) {
26
+ if (!tables.has(tableName)) {
27
+ tables.set(tableName, []);
28
+ }
29
+ return tables.get(tableName);
30
+ }
31
+
32
+ function assertUnique(tableName, row) {
33
+ const rows = rowsFor(tableName);
34
+ const duplicate = rows.some((existing) => {
35
+ if (existing.id === row.id) {
36
+ return true;
37
+ }
38
+ if (tableName === LOCAL_AUTH_DB_TABLES.users) {
39
+ return existing.email === row.email;
40
+ }
41
+ return existing.token_hash === row.token_hash;
42
+ });
43
+ if (duplicate) {
44
+ throw createDuplicateError();
45
+ }
46
+ }
47
+
48
+ class Query {
49
+ constructor(tableName) {
50
+ this.tableName = tableName;
51
+ this.filters = [];
52
+ }
53
+
54
+ where(criteria, value) {
55
+ if (criteria && typeof criteria === "object") {
56
+ for (const [key, expected] of Object.entries(criteria)) {
57
+ this.filters.push((row) => row[key] === expected);
58
+ }
59
+ return this;
60
+ }
61
+ const key = String(criteria || "");
62
+ this.filters.push((row) => row[key] === value);
63
+ return this;
64
+ }
65
+
66
+ whereNot(key, value) {
67
+ this.filters.push((row) => row[String(key || "")] !== value);
68
+ return this;
69
+ }
70
+
71
+ whereNull(key) {
72
+ this.filters.push((row) => row[String(key || "")] == null);
73
+ return this;
74
+ }
75
+
76
+ matches(row) {
77
+ return this.filters.every((filter) => filter(row));
78
+ }
79
+
80
+ async first() {
81
+ return clone(rowsFor(this.tableName).find((row) => this.matches(row)) || null);
82
+ }
83
+
84
+ async insert(row) {
85
+ assertUnique(this.tableName, row);
86
+ rowsFor(this.tableName).push(clone(row));
87
+ return [row.id];
88
+ }
89
+
90
+ async update(patch) {
91
+ let count = 0;
92
+ for (const row of rowsFor(this.tableName)) {
93
+ if (this.matches(row)) {
94
+ Object.assign(row, clone(patch));
95
+ count += 1;
96
+ }
97
+ }
98
+ return count;
99
+ }
100
+ }
101
+
102
+ function knex(tableName) {
103
+ return new Query(tableName);
104
+ }
105
+
106
+ knex.__tables = tables;
107
+ knex.transaction = async (callback) => callback(knex);
108
+
109
+ return knex;
110
+ }
111
+
112
+ function createReplyFixture() {
113
+ const cookies = {};
114
+ return {
115
+ cookies,
116
+ setCookie(name, value) {
117
+ cookies[name] = value;
118
+ },
119
+ clearCookie(name) {
120
+ delete cookies[name];
121
+ }
122
+ };
123
+ }
124
+
125
+ function createDbBackendFixture() {
126
+ const knex = createMemoryKnex();
127
+ const transactionManager = {
128
+ transactions: 0,
129
+ async inTransaction(work) {
130
+ this.transactions += 1;
131
+ return knex.transaction(work);
132
+ }
133
+ };
134
+ return {
135
+ knex,
136
+ transactionManager,
137
+ backend: createLocalDbBackend({
138
+ knex,
139
+ transactionManager
140
+ })
141
+ };
142
+ }
143
+
144
+ function createService(backend, options = {}) {
145
+ return createLocalAuthService({
146
+ backend,
147
+ config: {
148
+ nodeEnv: "test",
149
+ sessionSecret: "test-secret",
150
+ appPublicUrl: "http://localhost:5173",
151
+ smtpConfigured: false,
152
+ recoveryDevOutput: "response"
153
+ },
154
+ ...options
155
+ });
156
+ }
157
+
158
+ test("DB local auth backend implements the storage repository contract", async () => {
159
+ const { backend, transactionManager } = createDbBackendFixture();
160
+ const password = await hashPassword("stored password value");
161
+
162
+ await backend.withTransaction(async (tx) => {
163
+ const user = await tx.users.create({
164
+ id: "usr_db_contract",
165
+ email: "contract@example.com",
166
+ displayName: "Contract User",
167
+ password
168
+ });
169
+ assert.equal(user.email, "contract@example.com");
170
+ assert.deepEqual(user.password, password);
171
+
172
+ assert.equal((await tx.users.findByEmail("contract@example.com")).id, "usr_db_contract");
173
+ assert.equal((await tx.users.findById("usr_db_contract")).displayName, "Contract User");
174
+
175
+ const updated = await tx.users.updateProfile("usr_db_contract", {
176
+ displayName: "Renamed User"
177
+ });
178
+ assert.equal(updated.displayName, "Renamed User");
179
+
180
+ const nextPassword = await hashPassword("next password value");
181
+ assert.deepEqual((await tx.users.updatePassword("usr_db_contract", nextPassword)).password, nextPassword);
182
+
183
+ const session = await tx.sessions.create({
184
+ id: "ses_db_contract",
185
+ userId: "usr_db_contract",
186
+ tokenHash: "session-token-hash",
187
+ purpose: "normal",
188
+ expiresAt: new Date(Date.now() + 60_000).toISOString()
189
+ });
190
+ assert.equal(session.revokedAt, "");
191
+ assert.equal((await tx.sessions.findByTokenHash("session-token-hash")).id, "ses_db_contract");
192
+ assert.notEqual((await tx.sessions.revoke("ses_db_contract")).revokedAt, "");
193
+ assert.equal(await tx.sessions.revokeForUser("usr_db_contract"), 0);
194
+
195
+ const recovery = await tx.recovery.create({
196
+ id: "rec_db_contract",
197
+ userId: "usr_db_contract",
198
+ tokenHash: "recovery-token-hash",
199
+ expiresAt: new Date(Date.now() + 60_000).toISOString()
200
+ });
201
+ assert.equal(recovery.usedAt, "");
202
+ assert.equal((await tx.recovery.findByTokenHash("recovery-token-hash")).id, "rec_db_contract");
203
+ assert.notEqual((await tx.recovery.consume("rec_db_contract")).usedAt, "");
204
+ assert.equal(await tx.recovery.consumeForUser("usr_db_contract"), 0);
205
+ });
206
+
207
+ assert.equal(transactionManager.transactions, 1);
208
+ });
209
+
210
+ test("DB local auth backend rejects duplicate users by canonical email", async () => {
211
+ const { backend } = createDbBackendFixture();
212
+ const password = await hashPassword("stored password value");
213
+
214
+ await backend.withTransaction(async (tx) => {
215
+ await tx.users.create({
216
+ id: "usr_duplicate_1",
217
+ email: "duplicate@example.com",
218
+ displayName: "Duplicate One",
219
+ password
220
+ });
221
+ await assert.rejects(
222
+ () =>
223
+ tx.users.create({
224
+ id: "usr_duplicate_2",
225
+ email: "duplicate@example.com",
226
+ displayName: "Duplicate Two",
227
+ password
228
+ }),
229
+ /Local auth user already exists/
230
+ );
231
+ });
232
+ });
233
+
234
+ test("local auth service works end to end with DB backend", async () => {
235
+ const { backend } = createDbBackendFixture();
236
+ const authService = createService(backend);
237
+
238
+ const registered = await authService.register({
239
+ email: "DBUser@example.com",
240
+ password: "correct horse battery staple",
241
+ displayName: "DB User"
242
+ });
243
+ assert.equal(registered.actor.email, "dbuser@example.com");
244
+
245
+ const login = await authService.login({
246
+ email: "dbuser@example.com",
247
+ password: "correct horse battery staple"
248
+ });
249
+ const reply = createReplyFixture();
250
+ authService.writeSessionCookies(reply, login.session);
251
+ assert.equal((await authService.authenticateRequest({ cookies: reply.cookies })).authenticated, true);
252
+
253
+ const recoveryRequest = await authService.requestPasswordReset({
254
+ email: "dbuser@example.com"
255
+ });
256
+ const recoveryToken = new URL(recoveryRequest.recoveryUrl).searchParams.get("token");
257
+ const recovery = await authService.completePasswordRecovery({
258
+ code: recoveryToken,
259
+ type: "recovery"
260
+ });
261
+ const recoveryReply = createReplyFixture();
262
+ authService.writeSessionCookies(recoveryReply, recovery.session);
263
+ await authService.resetPassword(
264
+ {
265
+ cookies: recoveryReply.cookies
266
+ },
267
+ {
268
+ password: "updated password value"
269
+ }
270
+ );
271
+
272
+ await assert.rejects(
273
+ () =>
274
+ authService.login({
275
+ email: "dbuser@example.com",
276
+ password: "correct horse battery staple"
277
+ }),
278
+ /Invalid email or password/
279
+ );
280
+ assert.equal(
281
+ (await authService.login({
282
+ email: "dbuser@example.com",
283
+ password: "updated password value"
284
+ })).actor.email,
285
+ "dbuser@example.com"
286
+ );
287
+
288
+ const firstNormalLogin = await authService.login({
289
+ email: "dbuser@example.com",
290
+ password: "updated password value"
291
+ });
292
+ const firstReply = createReplyFixture();
293
+ authService.writeSessionCookies(firstReply, firstNormalLogin.session);
294
+ const secondNormalLogin = await authService.login({
295
+ email: "dbuser@example.com",
296
+ password: "updated password value"
297
+ });
298
+ const secondReply = createReplyFixture();
299
+ authService.writeSessionCookies(secondReply, secondNormalLogin.session);
300
+
301
+ const signOutResult = await authService.signOutOtherSessions({ cookies: secondReply.cookies });
302
+ assert.equal(signOutResult.revokedSessions >= 1, true);
303
+ assert.equal((await authService.authenticateRequest({ cookies: firstReply.cookies })).authenticated, false);
304
+
305
+ await authService.changePassword(
306
+ {
307
+ cookies: secondReply.cookies
308
+ },
309
+ {
310
+ currentPassword: "updated password value",
311
+ newPassword: "changed password value"
312
+ }
313
+ );
314
+ await assert.rejects(
315
+ () =>
316
+ authService.login({
317
+ email: "dbuser@example.com",
318
+ password: "updated password value"
319
+ }),
320
+ /Invalid email or password/
321
+ );
322
+ assert.equal(
323
+ (await authService.login({
324
+ email: "dbuser@example.com",
325
+ password: "changed password value"
326
+ })).actor.email,
327
+ "dbuser@example.com"
328
+ );
329
+ });
330
+
331
+ test("local auth DB backend works with custom password strategy", async () => {
332
+ const { backend } = createDbBackendFixture();
333
+ const authService = createService(backend, {
334
+ passwordStrategy: {
335
+ async hashPassword(password) {
336
+ return {
337
+ algorithm: "test-strategy",
338
+ version: "v1",
339
+ salt: "",
340
+ hash: `stored-${password}`
341
+ };
342
+ },
343
+ async verifyPassword(password, record) {
344
+ return record?.algorithm === "test-strategy" && record.hash === `stored-${password}`;
345
+ }
346
+ }
347
+ });
348
+
349
+ await authService.register({
350
+ email: "strategy-db@example.com",
351
+ password: "strategy password value",
352
+ displayName: "Strategy DB"
353
+ });
354
+ const login = await authService.login({
355
+ email: "strategy-db@example.com",
356
+ password: "strategy password value"
357
+ });
358
+ assert.equal(login.actor.email, "strategy-db@example.com");
359
+ });
360
+
361
+ test("local auth service rejects disabled DB-backed users", async () => {
362
+ const { backend } = createDbBackendFixture();
363
+ const password = await hashPassword("disabled password value");
364
+ await backend.withTransaction((tx) =>
365
+ tx.users.create({
366
+ id: "usr_disabled",
367
+ email: "disabled@example.com",
368
+ displayName: "Disabled",
369
+ password,
370
+ disabled: true
371
+ })
372
+ );
373
+ const authService = createService(backend);
374
+
375
+ await assert.rejects(
376
+ () =>
377
+ authService.login({
378
+ email: "disabled@example.com",
379
+ password: "disabled password value"
380
+ }),
381
+ /Invalid email or password/
382
+ );
383
+ });
384
+
385
+ test("local auth DB backend still supports lazy profile projection", async () => {
386
+ const { backend } = createDbBackendFixture();
387
+ let projectionCalls = 0;
388
+ const authService = createService(backend, {
389
+ profileProjector: {
390
+ async syncIdentityProfile(profile) {
391
+ projectionCalls += 1;
392
+ return {
393
+ ...profile,
394
+ id: "projected-db-user",
395
+ profileSource: "users"
396
+ };
397
+ }
398
+ }
399
+ });
400
+
401
+ const registered = await authService.register({
402
+ email: "projected-db@example.com",
403
+ password: "projected password value",
404
+ displayName: "Projected DB"
405
+ });
406
+
407
+ assert.equal(projectionCalls, 1);
408
+ assert.equal(registered.actor.appUserId, "projected-db-user");
409
+ assert.equal(registered.actor.profileSource, "users");
410
+ });
411
+
412
+ test("package descriptor installs portable local auth DB migrations", () => {
413
+ const files = descriptor.mutations.files.map((file) => file.from);
414
+ assert.deepEqual(files, ["templates/migrations/auth_local_db_initial.cjs"]);
415
+ assert.deepEqual(
416
+ descriptor.metadata.jskit.tableOwnership.tables.map((table) => table.tableName),
417
+ [
418
+ LOCAL_AUTH_DB_TABLES.users,
419
+ LOCAL_AUTH_DB_TABLES.sessions,
420
+ LOCAL_AUTH_DB_TABLES.recovery
421
+ ]
422
+ );
423
+ });
424
+
425
+ test("provider registration wires DB backend for AUTH_LOCAL_BACKEND=db", async () => {
426
+ const knex = createMemoryKnex();
427
+ const app = createApplication();
428
+ app.instance("jskit.env", {
429
+ AUTH_PROVIDER: "local",
430
+ AUTH_LOCAL_BACKEND: "db",
431
+ AUTH_LOCAL_SESSION_SECRET: "test-secret",
432
+ AUTH_LOCAL_RECOVERY_DEV_OUTPUT: "response",
433
+ APP_PUBLIC_URL: "http://localhost:5173",
434
+ NODE_ENV: "test"
435
+ });
436
+ app.instance("jskit.database.knex", knex);
437
+
438
+ await app.start({
439
+ providers: [
440
+ AuthLocalServiceProvider,
441
+ AuthLocalDbBackendServiceProvider,
442
+ DatabaseRuntimeServiceProvider
443
+ ]
444
+ });
445
+
446
+ assert.equal(app.has("auth.local.backend"), true);
447
+ const diagnostics = app.getDiagnostics();
448
+ assert.ok(diagnostics.registeredOrder.indexOf("auth.provider.local.db") > -1);
449
+
450
+ const authService = app.make("authService");
451
+ const registered = await authService.register({
452
+ email: "provider-db@example.com",
453
+ password: "provider password value",
454
+ displayName: "Provider DB"
455
+ });
456
+ assert.equal(registered.actor.email, "provider-db@example.com");
457
+ });
458
+
459
+ test("local auth DB mode fails clearly when no DB backend provider is installed", async () => {
460
+ const app = createApplication();
461
+ app.instance("jskit.env", {
462
+ AUTH_PROVIDER: "local",
463
+ AUTH_LOCAL_BACKEND: "db",
464
+ AUTH_LOCAL_SESSION_SECRET: "test-secret",
465
+ NODE_ENV: "test"
466
+ });
467
+
468
+ await assert.rejects(
469
+ () =>
470
+ app.start({
471
+ providers: [AuthLocalServiceProvider]
472
+ }),
473
+ (error) =>
474
+ /AUTH_LOCAL_BACKEND="db" requires a package or app provider that registers auth\.local\.backend/.test(
475
+ String(error.details?.cause?.message || error.message || "")
476
+ )
477
+ );
478
+ });
479
+
480
+ test("local auth DB provider rejects ambiguous backend ownership in DB mode", async () => {
481
+ const app = createApplication();
482
+ app.instance("jskit.env", {
483
+ AUTH_PROVIDER: "local",
484
+ AUTH_LOCAL_BACKEND: "db",
485
+ NODE_ENV: "test"
486
+ });
487
+ app.instance("auth.local.backend", {
488
+ async withTransaction() {}
489
+ });
490
+ app.instance("jskit.database.knex", createMemoryKnex());
491
+
492
+ await assert.rejects(
493
+ () =>
494
+ app.start({
495
+ providers: [
496
+ DatabaseRuntimeServiceProvider,
497
+ AuthLocalDbBackendServiceProvider
498
+ ]
499
+ }),
500
+ (error) =>
501
+ /auth-provider-local-db-core to own auth\.local\.backend/.test(
502
+ String(error.details?.cause?.message || error.message || "")
503
+ )
504
+ );
505
+ });