@bevel-software/platform-core-backend 0.1.1 → 0.2.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.
Files changed (86) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +13 -0
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts.map +1 -1
  5. package/dist/core/create-core-services.js +23 -2
  6. package/dist/core/create-core-services.js.map +1 -1
  7. package/dist/core-config.d.ts +36 -1
  8. package/dist/core-config.d.ts.map +1 -1
  9. package/dist/core-config.js +56 -2
  10. package/dist/core-config.js.map +1 -1
  11. package/dist/modules/admin/admin-access.service.d.ts +17 -1
  12. package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
  13. package/dist/modules/admin/admin-access.service.js +14 -1
  14. package/dist/modules/admin/admin-access.service.js.map +1 -1
  15. package/dist/modules/auth/account.routes.d.ts +17 -0
  16. package/dist/modules/auth/account.routes.d.ts.map +1 -0
  17. package/dist/modules/auth/account.routes.js +47 -0
  18. package/dist/modules/auth/account.routes.js.map +1 -0
  19. package/dist/modules/auth/auth.routes.d.ts +13 -7
  20. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  21. package/dist/modules/auth/auth.routes.js +51 -3
  22. package/dist/modules/auth/auth.routes.js.map +1 -1
  23. package/dist/modules/auth/auth.service.d.ts +44 -8
  24. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  25. package/dist/modules/auth/auth.service.js +129 -32
  26. package/dist/modules/auth/auth.service.js.map +1 -1
  27. package/dist/modules/auth/oidc-auth-provider.d.ts +51 -0
  28. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -0
  29. package/dist/modules/auth/oidc-auth-provider.js +189 -0
  30. package/dist/modules/auth/oidc-auth-provider.js.map +1 -0
  31. package/dist/modules/auth/password-hash.d.ts +12 -0
  32. package/dist/modules/auth/password-hash.d.ts.map +1 -0
  33. package/dist/modules/auth/password-hash.js +67 -0
  34. package/dist/modules/auth/password-hash.js.map +1 -0
  35. package/dist/modules/auth/rate-limit.d.ts +32 -0
  36. package/dist/modules/auth/rate-limit.d.ts.map +1 -0
  37. package/dist/modules/auth/rate-limit.js +49 -0
  38. package/dist/modules/auth/rate-limit.js.map +1 -0
  39. package/dist/modules/database/core-schema.d.ts +17 -0
  40. package/dist/modules/database/core-schema.d.ts.map +1 -1
  41. package/dist/modules/database/core-schema.js +6 -0
  42. package/dist/modules/database/core-schema.js.map +1 -1
  43. package/dist/modules/tool-auth/internal-token.service.d.ts +10 -0
  44. package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -1
  45. package/dist/modules/tool-auth/internal-token.service.js +1 -0
  46. package/dist/modules/tool-auth/internal-token.service.js.map +1 -1
  47. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +8 -0
  48. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  49. package/dist/modules/tool-auth/tool-auth.middleware.js +1 -1
  50. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  51. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  52. package/dist/modules/tool-helpers/tool-context.js +6 -0
  53. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  54. package/dist/modules/tool-helpers/tool.contract.d.ts +9 -0
  55. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  56. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  57. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  58. package/dist/modules/workspace/workspace.tools.js +46 -17
  59. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  60. package/migrations/0001_user_password_hash.sql +4 -0
  61. package/migrations/meta/_journal.json +20 -13
  62. package/package.json +2 -2
  63. package/src/core/create-core-server.ts +352 -338
  64. package/src/core/create-core-services.ts +542 -514
  65. package/src/core-config.ts +55 -2
  66. package/src/modules/admin/admin-access.service.ts +10 -0
  67. package/src/modules/auth/__tests__/account.routes.test.ts +98 -0
  68. package/src/modules/auth/__tests__/auth.routes.test.ts +91 -0
  69. package/src/modules/auth/__tests__/auth.service.test.ts +217 -0
  70. package/src/modules/auth/__tests__/oidc-auth-provider.test.ts +174 -0
  71. package/src/modules/auth/__tests__/password-hash.test.ts +53 -0
  72. package/src/modules/auth/__tests__/rate-limit.test.ts +36 -0
  73. package/src/modules/auth/account.routes.ts +59 -0
  74. package/src/modules/auth/auth.routes.ts +68 -10
  75. package/src/modules/auth/auth.service.ts +151 -32
  76. package/src/modules/auth/oidc-auth-provider.ts +229 -0
  77. package/src/modules/auth/password-hash.ts +80 -0
  78. package/src/modules/auth/rate-limit.ts +45 -0
  79. package/src/modules/database/core-schema.ts +437 -431
  80. package/src/modules/tool-auth/__tests__/internal-token.service.test.ts +16 -0
  81. package/src/modules/tool-auth/internal-token.service.ts +11 -0
  82. package/src/modules/tool-auth/tool-auth.middleware.ts +9 -1
  83. package/src/modules/tool-helpers/tool-context.ts +6 -0
  84. package/src/modules/tool-helpers/tool.contract.ts +9 -0
  85. package/src/modules/workspace/__tests__/workspace.tools.test.ts +79 -22
  86. package/src/modules/workspace/workspace.tools.ts +49 -17
@@ -43,7 +43,32 @@ export class CoreConfig {
43
43
  */
44
44
  readonly spillRoot: string;
45
45
  readonly jwtSecret: string;
46
- readonly testPassword: string;
46
+ /**
47
+ * Bootstrap admin credential, checked directly against the environment at
48
+ * login time (never stored). Lets a fresh deployment sign in before any
49
+ * account exists; that admin (or any user with the Admin role in
50
+ * `roles.yaml`) then creates per-user accounts from Roles & Members.
51
+ * Remove EITHER variable and the credential stops working immediately —
52
+ * accounts created in the database are unaffected. The email is also
53
+ * always recognized as an admin (see AdminAccessService), so a fresh KB
54
+ * whose `roles.yaml` doesn't list it yet is still administrable.
55
+ */
56
+ readonly adminEmail: string;
57
+ readonly adminPassword: string;
58
+ /**
59
+ * Generic OIDC single sign-on (any spec-compliant provider: Entra, Okta,
60
+ * Auth0, Keycloak, Google, …). Enabled when issuer URL + client id + client
61
+ * secret are ALL set; the issuer's `/.well-known/openid-configuration` is
62
+ * discovered at first use. The redirect URI to register with the provider is
63
+ * `<PUBLIC_BACKEND_URL>/api/auth/oidc/callback`.
64
+ */
65
+ readonly oidcIssuerUrl: string;
66
+ readonly oidcClientId: string;
67
+ readonly oidcClientSecret: string;
68
+ /** Space-separated scopes requested (default `openid profile email`). */
69
+ readonly oidcScopes: string;
70
+ /** Login-button label (default "Single sign-on"). */
71
+ readonly oidcProviderLabel: string;
47
72
  /** Clone/push URL for the KB repo. Any https git host (GitHub, GitLab, Bitbucket, Azure DevOps, self-hosted). */
48
73
  readonly kbRepoUrl: string;
49
74
  /** Directory name the KB lives under inside each user's workspace dir. */
@@ -115,6 +140,16 @@ export class CoreConfig {
115
140
  * independently of user sessions.
116
141
  */
117
142
  readonly internalTokenSecret: string;
143
+ /**
144
+ * Express `trust proxy` setting, from `TRUST_PROXY`: the number of reverse
145
+ * proxy hops in front of this backend (e.g. `1`), or an address/CIDR list
146
+ * (`loopback`, `10.0.0.0/8`). Unset (default) → forwarded headers are
147
+ * IGNORED and `req.ip` is the socket peer — correct for direct exposure,
148
+ * but behind a proxy it makes every client share the proxy's IP (so the
149
+ * per-IP login rate limit would pool all users). Set it to the actual hop
150
+ * count — never a blanket trust — so clients can't spoof X-Forwarded-For.
151
+ */
152
+ readonly trustProxy: string;
118
153
  /**
119
154
  * Public base URL of THIS backend, used to build OAuth redirect URIs.
120
155
  * Must match a redirect URI registered with the OAuth provider(s).
@@ -137,7 +172,24 @@ export class CoreConfig {
137
172
  this.backupsRoot = process.env.BACKUPS_ROOT || path.resolve(this.workspacesRoot, '..', 'backups');
138
173
  this.spillRoot = process.env.SPILL_ROOT || path.resolve(this.workspacesRoot, '..', 'tool-chain-spills');
139
174
  this.jwtSecret = process.env.JWT_SECRET || '';
140
- this.testPassword = process.env.TEST_PASSWORD || '';
175
+ this.adminEmail = (process.env.ADMIN_EMAIL || '').trim().toLowerCase();
176
+ this.adminPassword = process.env.ADMIN_PASSWORD || '';
177
+ if (this.adminEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.adminEmail)) {
178
+ throw new Error(`ADMIN_EMAIL is not a valid email: "${this.adminEmail}"`);
179
+ }
180
+ this.oidcIssuerUrl = (process.env.OIDC_ISSUER_URL || '').trim().replace(/\/+$/, '');
181
+ this.oidcClientId = (process.env.OIDC_CLIENT_ID || '').trim();
182
+ this.oidcClientSecret = (process.env.OIDC_CLIENT_SECRET || '').trim();
183
+ this.oidcScopes = (process.env.OIDC_SCOPES || 'openid profile email').trim();
184
+ this.oidcProviderLabel = (process.env.OIDC_PROVIDER_LABEL || 'Single sign-on').trim();
185
+ if (this.oidcIssuerUrl) {
186
+ // Parse-validate so a typo'd issuer fails at boot, not at first login.
187
+ try {
188
+ new URL(this.oidcIssuerUrl);
189
+ } catch {
190
+ throw new Error(`OIDC_ISSUER_URL is not a valid URL: "${this.oidcIssuerUrl}"`);
191
+ }
192
+ }
141
193
  // No default: the KB repo is deployment-specific, and a wrong fallback
142
194
  // would surface much later as a confusing clone failure. Fail at boot.
143
195
  this.kbRepoUrl = (process.env.KB_REPO_URL || '').trim();
@@ -185,6 +237,7 @@ export class CoreConfig {
185
237
  ''
186
238
  ).trim();
187
239
  this.internalTokenSecret = (process.env.INTERNAL_TOKEN_SECRET || '').trim();
240
+ this.trustProxy = (process.env.TRUST_PROXY || '').trim();
188
241
  this.publicBackendUrl = (process.env.PUBLIC_BACKEND_URL || `http://localhost:${this.port}`)
189
242
  .trim()
190
243
  .replace(/\/+$/, '');
@@ -18,10 +18,20 @@ export class AdminAccessService implements IAdminAccessService {
18
18
  private readonly workspaceService: WorkspaceService,
19
19
  /** Branch whose `roles.yaml` defines admins (the authoritative default branch). */
20
20
  private readonly branch: string,
21
+ /**
22
+ * Emails treated as admin WITHOUT consulting `roles.yaml` — the env
23
+ * bootstrap admin (`ADMIN_EMAIL`). A fresh deployment's KB may not list
24
+ * that email yet, and the bootstrap credential must be able to administer
25
+ * accounts/roles regardless; membership here tracks the env var, so
26
+ * removing the var removes the privilege.
27
+ */
28
+ private readonly alwaysAdminEmails: string[] = [],
21
29
  ) {}
22
30
 
23
31
  async isAdmin(email: string | undefined): Promise<boolean> {
24
32
  if (!email) return false;
33
+ const normalized = email.trim().toLowerCase();
34
+ if (this.alwaysAdminEmails.includes(normalized)) return true;
25
35
  try {
26
36
  // Ensure the authoritative clone is on disk, then resolve Admin-role
27
37
  // membership via the access model. Any failure (missing/invalid
@@ -0,0 +1,98 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import express from 'express';
3
+ import type { Server } from 'node:http';
4
+ import { createAccountRoutes } from '../account.routes.js';
5
+ import type { AuthService } from '../auth.service.js';
6
+ import type { IAdminAccessService } from '../../admin/admin.interface.js';
7
+
8
+ const authService = {
9
+ listAccounts: vi.fn(async () => [
10
+ { id: 'u1', email: 'a@example.com', name: 'A', hasPassword: true, createdAt: new Date() },
11
+ ]),
12
+ createAccount: vi.fn(async (email: string) => ({ id: 'u2', email, name: 'B' })),
13
+ } as unknown as AuthService;
14
+
15
+ function makeApp(opts: { admin: boolean; email?: string }) {
16
+ const adminAccess: IAdminAccessService = {
17
+ isAdmin: vi.fn(async () => opts.admin),
18
+ };
19
+ const app = express();
20
+ app.use(express.json());
21
+ // Stand-in auth middleware: stamps the caller identity the way the real JWT
22
+ // middleware does.
23
+ app.use((req, _res, next) => {
24
+ req.userId = 'u1';
25
+ req.userEmail = opts.email ?? 'caller@example.com';
26
+ next();
27
+ });
28
+ app.use('/api', createAccountRoutes(authService, adminAccess));
29
+ return app;
30
+ }
31
+
32
+ let server: Server;
33
+ afterEach(() => {
34
+ server?.close();
35
+ vi.mocked(authService.createAccount).mockClear();
36
+ });
37
+
38
+ async function listen(app: express.Express): Promise<string> {
39
+ await new Promise<void>((resolve) => {
40
+ server = app.listen(0, resolve);
41
+ });
42
+ const address = server.address();
43
+ if (typeof address === 'string' || !address) throw new Error('no port');
44
+ return `http://127.0.0.1:${address.port}`;
45
+ }
46
+
47
+ describe('account routes — admin gate', () => {
48
+ it('refuses non-admins on both endpoints', async () => {
49
+ const base = await listen(makeApp({ admin: false }));
50
+ const list = await fetch(`${base}/api/admin/accounts`);
51
+ expect(list.status).toBe(403);
52
+ const create = await fetch(`${base}/api/admin/accounts`, {
53
+ method: 'POST',
54
+ headers: { 'Content-Type': 'application/json' },
55
+ body: JSON.stringify({ email: 'x@example.com', password: 'long-enough-pw' }),
56
+ });
57
+ expect(create.status).toBe(403);
58
+ expect(authService.createAccount).not.toHaveBeenCalled();
59
+ });
60
+
61
+ it('serves admins: list + create', async () => {
62
+ const base = await listen(makeApp({ admin: true }));
63
+ const list = await fetch(`${base}/api/admin/accounts`);
64
+ expect(list.status).toBe(200);
65
+ const listBody = (await list.json()) as { accounts: Array<{ email: string }> };
66
+ expect(listBody.accounts[0].email).toBe('a@example.com');
67
+
68
+ const create = await fetch(`${base}/api/admin/accounts`, {
69
+ method: 'POST',
70
+ headers: { 'Content-Type': 'application/json' },
71
+ body: JSON.stringify({ email: 'b@example.com', name: 'B', password: 'long-enough-pw' }),
72
+ });
73
+ expect(create.status).toBe(201);
74
+ expect(authService.createAccount).toHaveBeenCalledWith('b@example.com', 'B', 'long-enough-pw');
75
+ });
76
+
77
+ it('400s on missing fields and surfaces service validation errors', async () => {
78
+ const base = await listen(makeApp({ admin: true }));
79
+ const missing = await fetch(`${base}/api/admin/accounts`, {
80
+ method: 'POST',
81
+ headers: { 'Content-Type': 'application/json' },
82
+ body: JSON.stringify({ email: 'b@example.com' }),
83
+ });
84
+ expect(missing.status).toBe(400);
85
+
86
+ vi.mocked(authService.createAccount).mockRejectedValueOnce(
87
+ new Error('Password must be at least 8 characters'),
88
+ );
89
+ const tooShort = await fetch(`${base}/api/admin/accounts`, {
90
+ method: 'POST',
91
+ headers: { 'Content-Type': 'application/json' },
92
+ body: JSON.stringify({ email: 'b@example.com', password: 'short' }),
93
+ });
94
+ expect(tooShort.status).toBe(400);
95
+ const body = (await tooShort.json()) as { error: string };
96
+ expect(body.error).toContain('at least 8');
97
+ });
98
+ });
@@ -0,0 +1,91 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import express from 'express';
3
+ import type { Server } from 'node:http';
4
+ import { createAuthRoutes } from '../auth.routes.js';
5
+ import type { AuthService } from '../auth.service.js';
6
+
7
+ const authService = {
8
+ loginWithPassword: vi.fn(async () => {
9
+ throw new Error('Invalid credentials');
10
+ }),
11
+ changePassword: vi.fn(async () => {
12
+ throw new Error('Current password is incorrect');
13
+ }),
14
+ getUserById: vi.fn(async () => null),
15
+ } as unknown as AuthService;
16
+
17
+ const fakeAuthMiddleware: express.RequestHandler = (req, _res, next) => {
18
+ req.userId = 'user-1';
19
+ req.userEmail = 'alice@example.com';
20
+ next();
21
+ };
22
+
23
+ let server: Server;
24
+ beforeEach(() => {
25
+ vi.mocked(authService.loginWithPassword).mockClear();
26
+ });
27
+ afterEach(() => {
28
+ server?.close();
29
+ });
30
+
31
+ async function listen(): Promise<string> {
32
+ const app = express();
33
+ app.use(express.json());
34
+ app.use('/api', createAuthRoutes(authService, fakeAuthMiddleware));
35
+ await new Promise<void>((resolve) => {
36
+ server = app.listen(0, resolve);
37
+ });
38
+ const address = server.address();
39
+ if (typeof address === 'string' || !address) throw new Error('no port');
40
+ return `http://127.0.0.1:${address.port}`;
41
+ }
42
+
43
+ function postLogin(base: string, email: string, password = 'wrong') {
44
+ return fetch(`${base}/api/auth/login`, {
45
+ method: 'POST',
46
+ headers: { 'Content-Type': 'application/json' },
47
+ body: JSON.stringify({ email, password }),
48
+ });
49
+ }
50
+
51
+ describe('auth routes — rate limiting', () => {
52
+ it('429s login for an (ip, email) pair after repeated failures; other emails unaffected', async () => {
53
+ const base = await listen();
54
+ for (let i = 0; i < 10; i++) {
55
+ expect((await postLogin(base, 'alice@example.com')).status).toBe(401);
56
+ }
57
+ const limited = await postLogin(base, 'alice@example.com');
58
+ expect(limited.status).toBe(429);
59
+ // The service is not even consulted once the limit is hit.
60
+ expect(authService.loginWithPassword).toHaveBeenCalledTimes(10);
61
+ // A different email from the same address still has pair budget (the
62
+ // wider per-ip budget is 50).
63
+ expect((await postLogin(base, 'bob@example.com')).status).toBe(401);
64
+ });
65
+
66
+ it('a successful login resets the pair budget', async () => {
67
+ const base = await listen();
68
+ for (let i = 0; i < 9; i++) await postLogin(base, 'alice@example.com');
69
+ vi.mocked(authService.loginWithPassword).mockResolvedValueOnce({
70
+ token: 'jwt',
71
+ user: { id: 'user-1', email: 'alice@example.com', name: 'Alice' },
72
+ });
73
+ expect((await postLogin(base, 'alice@example.com', 'right')).status).toBe(200);
74
+ // Budget is fresh again — failures start counting from zero.
75
+ expect((await postLogin(base, 'alice@example.com')).status).toBe(401);
76
+ });
77
+
78
+ it('429s change-password per account after repeated failures', async () => {
79
+ const base = await listen();
80
+ const post = () =>
81
+ fetch(`${base}/api/auth/change-password`, {
82
+ method: 'POST',
83
+ headers: { 'Content-Type': 'application/json' },
84
+ body: JSON.stringify({ currentPassword: 'wrong', newPassword: 'new-password-1' }),
85
+ });
86
+ for (let i = 0; i < 10; i++) {
87
+ expect((await post()).status).toBe(400);
88
+ }
89
+ expect((await post()).status).toBe(429);
90
+ });
91
+ });
@@ -0,0 +1,217 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { Database } from '../../database/connection.js';
3
+ import type { CoreConfig } from '../../../core-config.js';
4
+ import { AuthService } from '../auth.service.js';
5
+ import { hashPassword } from '../password-hash.js';
6
+
7
+ /**
8
+ * Minimal drizzle chain stub (same idiom as the secrets-vault tests): each
9
+ * db.select()/insert()/update() consumes the next queued result; awaiting any
10
+ * point of the chain resolves it.
11
+ */
12
+ interface FakeChain extends PromiseLike<unknown> {
13
+ values: (...args: unknown[]) => FakeChain;
14
+ set: (...args: unknown[]) => FakeChain;
15
+ where: (...args: unknown[]) => FakeChain;
16
+ limit: (...args: unknown[]) => FakeChain;
17
+ from: (...args: unknown[]) => FakeChain;
18
+ orderBy: (...args: unknown[]) => FakeChain;
19
+ returning: (...args: unknown[]) => FakeChain;
20
+ onConflictDoUpdate: (...args: unknown[]) => FakeChain;
21
+ }
22
+
23
+ function makeFakeDb(queue: unknown[]) {
24
+ const captured: { values: unknown[]; set: unknown[]; conflict: unknown[] } = {
25
+ values: [],
26
+ set: [],
27
+ conflict: [],
28
+ };
29
+ function nextChain(): FakeChain {
30
+ const result = queue.shift();
31
+ const passthrough = (recorder?: (args: unknown[]) => void) =>
32
+ vi.fn((...args: unknown[]) => {
33
+ recorder?.(args);
34
+ return chain;
35
+ });
36
+ const chain: FakeChain = {
37
+ values: passthrough((a) => captured.values.push(a[0])),
38
+ set: passthrough((a) => captured.set.push(a[0])),
39
+ where: passthrough(),
40
+ limit: passthrough(),
41
+ from: passthrough(),
42
+ orderBy: passthrough(),
43
+ returning: passthrough(),
44
+ onConflictDoUpdate: passthrough((a) => captured.conflict.push(a[0])),
45
+ then: (onFulfilled, onRejected) => Promise.resolve(result).then(onFulfilled, onRejected),
46
+ };
47
+ return chain;
48
+ }
49
+ const db = {
50
+ insert: vi.fn(() => nextChain()),
51
+ select: vi.fn(() => nextChain()),
52
+ update: vi.fn(() => nextChain()),
53
+ } as unknown as Database;
54
+ return { db, captured };
55
+ }
56
+
57
+ function makeConfig(over: Partial<CoreConfig> = {}): CoreConfig {
58
+ return {
59
+ jwtSecret: 'test-jwt-secret',
60
+ adminEmail: '',
61
+ adminPassword: '',
62
+ allowedEmailDomains: [],
63
+ ...over,
64
+ } as CoreConfig;
65
+ }
66
+
67
+ const ROW = {
68
+ id: 'user-1',
69
+ email: 'alice@example.com',
70
+ name: 'Alice',
71
+ avatarUrl: null,
72
+ };
73
+
74
+ describe('AuthService.loginWithPassword — env bootstrap admin', () => {
75
+ const config = makeConfig({ adminEmail: 'root@example.com', adminPassword: 'sup3r-secret' });
76
+
77
+ it('signs in the env admin and upserts their user row', async () => {
78
+ const { db } = makeFakeDb([[{ ...ROW, email: 'root@example.com', name: 'root' }]]);
79
+ const svc = new AuthService(db, config);
80
+ const result = await svc.loginWithPassword('Root@Example.com', 'sup3r-secret');
81
+ expect(result.user.email).toBe('root@example.com');
82
+ expect(result.token.length).toBeGreaterThan(20);
83
+ });
84
+
85
+ it('rejects the admin email with a wrong password (and does not fall back oddly)', async () => {
86
+ // Wrong env password → falls through to the DB path; no row → refused.
87
+ const { db } = makeFakeDb([[]]);
88
+ const svc = new AuthService(db, config);
89
+ await expect(svc.loginWithPassword('root@example.com', 'wrong')).rejects.toThrow(
90
+ 'Invalid credentials',
91
+ );
92
+ });
93
+
94
+ it('is disabled entirely when either env var is empty', async () => {
95
+ for (const cfg of [
96
+ makeConfig({ adminEmail: 'root@example.com', adminPassword: '' }),
97
+ makeConfig({ adminEmail: '', adminPassword: 'sup3r-secret' }),
98
+ ]) {
99
+ const { db } = makeFakeDb([[]]);
100
+ const svc = new AuthService(db, cfg);
101
+ await expect(svc.loginWithPassword('root@example.com', 'sup3r-secret')).rejects.toThrow(
102
+ 'Invalid credentials',
103
+ );
104
+ }
105
+ });
106
+ });
107
+
108
+ describe('AuthService.loginWithPassword — per-user accounts', () => {
109
+ it('accepts a user whose stored hash matches', async () => {
110
+ const passwordHash = await hashPassword('alices-password');
111
+ const { db } = makeFakeDb([[{ ...ROW, passwordHash }]]);
112
+ const svc = new AuthService(db, makeConfig());
113
+ const result = await svc.loginWithPassword('alice@example.com', 'alices-password');
114
+ expect(result.user.id).toBe('user-1');
115
+ });
116
+
117
+ it('rejects a wrong password and an account with no password set', async () => {
118
+ const passwordHash = await hashPassword('alices-password');
119
+ const withHash = makeFakeDb([[{ ...ROW, passwordHash }]]);
120
+ await expect(
121
+ new AuthService(withHash.db, makeConfig()).loginWithPassword('alice@example.com', 'nope'),
122
+ ).rejects.toThrow('Invalid credentials');
123
+
124
+ // SSO-only account: row exists but has no hash.
125
+ const noHash = makeFakeDb([[{ ...ROW, passwordHash: null }]]);
126
+ await expect(
127
+ new AuthService(noHash.db, makeConfig()).loginWithPassword('alice@example.com', 'anything'),
128
+ ).rejects.toThrow('Invalid credentials');
129
+ });
130
+
131
+ it('never leaks whether the email exists — same error for unknown emails', async () => {
132
+ const { db } = makeFakeDb([[]]);
133
+ await expect(
134
+ new AuthService(db, makeConfig()).loginWithPassword('ghost@example.com', 'whatever'),
135
+ ).rejects.toThrow('Invalid credentials');
136
+ });
137
+ });
138
+
139
+ describe('AuthService.createAccount / changePassword', () => {
140
+ it('createAccount upserts the user in ONE query and stores a verifying hash', async () => {
141
+ const { db, captured } = makeFakeDb([[ROW]]);
142
+ const svc = new AuthService(db, makeConfig());
143
+ const user = await svc.createAccount('alice@example.com', 'Alice', 'brand-new-pass');
144
+ expect(user.email).toBe('alice@example.com');
145
+ // Insert values carry the hash; the conflict branch re-sets it too.
146
+ const values = captured.values[0] as { passwordHash: string; name: string };
147
+ expect(values.passwordHash.startsWith('scrypt:')).toBe(true);
148
+ expect(values.name).toBe('Alice');
149
+ const conflict = captured.conflict[0] as { set: { passwordHash: string } };
150
+ expect(conflict.set.passwordHash.startsWith('scrypt:')).toBe(true);
151
+ });
152
+
153
+ it('createAccount persists an explicitly supplied name on re-provisioning', async () => {
154
+ // Existing row conflicts; admin re-provisions with a new name.
155
+ const { db, captured } = makeFakeDb([[{ ...ROW, name: 'Alice Lidell' }]]);
156
+ const svc = new AuthService(db, makeConfig());
157
+ const user = await svc.createAccount('alice@example.com', 'Alice Lidell', 'brand-new-pass');
158
+ const conflict = captured.conflict[0] as { set: { name?: string } };
159
+ expect(conflict.set.name).toBe('Alice Lidell');
160
+ expect(user.name).toBe('Alice Lidell');
161
+ });
162
+
163
+ it('createAccount without a name keeps the existing display name on conflict', async () => {
164
+ const { db, captured } = makeFakeDb([[ROW]]);
165
+ const svc = new AuthService(db, makeConfig());
166
+ const user = await svc.createAccount('alice@example.com', undefined, 'brand-new-pass');
167
+ const conflict = captured.conflict[0] as { set: { name?: string } };
168
+ expect(conflict.set.name).toBeUndefined();
169
+ expect(user.name).toBe('Alice');
170
+ });
171
+
172
+ it('createAccount enforces the password policy', async () => {
173
+ const { db } = makeFakeDb([]);
174
+ const svc = new AuthService(db, makeConfig());
175
+ await expect(svc.createAccount('alice@example.com', 'Alice', 'short')).rejects.toThrow(
176
+ /at least/,
177
+ );
178
+ });
179
+
180
+ it('changePassword requires the current password once one is set', async () => {
181
+ const passwordHash = await hashPassword('old-password');
182
+ const wrong = makeFakeDb([[{ ...ROW, passwordHash }]]);
183
+ await expect(
184
+ new AuthService(wrong.db, makeConfig()).changePassword('user-1', 'not-it', 'new-password-1'),
185
+ ).rejects.toThrow('Current password is incorrect');
186
+
187
+ const right = makeFakeDb([[{ ...ROW, passwordHash }], undefined]);
188
+ await expect(
189
+ new AuthService(right.db, makeConfig()).changePassword('user-1', 'old-password', 'new-password-1'),
190
+ ).resolves.toBeUndefined();
191
+ });
192
+
193
+ it('changePassword lets an SSO-only account set its first password without one', async () => {
194
+ const { db, captured } = makeFakeDb([[{ ...ROW, passwordHash: null }], undefined]);
195
+ await new AuthService(db, makeConfig()).changePassword('user-1', undefined, 'first-password');
196
+ const set = captured.set[0] as { passwordHash: string };
197
+ expect(set.passwordHash.startsWith('scrypt:')).toBe(true);
198
+ });
199
+ });
200
+
201
+ describe('AuthService.listAccounts', () => {
202
+ it('reports hasPassword without ever exposing the hash', async () => {
203
+ const passwordHash = await hashPassword('pw-longer-than-8');
204
+ const { db } = makeFakeDb([
205
+ [
206
+ { ...ROW, passwordHash, createdAt: new Date() },
207
+ { ...ROW, id: 'user-2', email: 'bob@example.com', name: 'Bob', passwordHash: null, createdAt: new Date() },
208
+ ],
209
+ ]);
210
+ const accounts = await new AuthService(db, makeConfig()).listAccounts();
211
+ expect(accounts.map((a) => [a.email, a.hasPassword])).toEqual([
212
+ ['alice@example.com', true],
213
+ ['bob@example.com', false],
214
+ ]);
215
+ expect(JSON.stringify(accounts)).not.toContain('scrypt:');
216
+ });
217
+ });