@brika/auth 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.
Files changed (62) hide show
  1. package/README.md +207 -0
  2. package/package.json +50 -0
  3. package/src/__tests__/AuthClient.test.ts +736 -0
  4. package/src/__tests__/AuthService.test.ts +140 -0
  5. package/src/__tests__/ScopeService.test.ts +156 -0
  6. package/src/__tests__/SessionService.test.ts +311 -0
  7. package/src/__tests__/UserService-avatar.test.ts +277 -0
  8. package/src/__tests__/UserService.test.ts +223 -0
  9. package/src/__tests__/canAccess.test.ts +166 -0
  10. package/src/__tests__/disabledScopes.test.ts +101 -0
  11. package/src/__tests__/middleware.test.ts +190 -0
  12. package/src/__tests__/plugin.test.ts +78 -0
  13. package/src/__tests__/requireSession.test.ts +78 -0
  14. package/src/__tests__/routes-auth.test.ts +248 -0
  15. package/src/__tests__/routes-profile.test.ts +403 -0
  16. package/src/__tests__/routes-scopes.test.ts +64 -0
  17. package/src/__tests__/routes-sessions.test.ts +235 -0
  18. package/src/__tests__/routes-users.test.ts +477 -0
  19. package/src/__tests__/serveImage.test.ts +277 -0
  20. package/src/__tests__/setup.test.ts +270 -0
  21. package/src/__tests__/verifyToken.test.ts +219 -0
  22. package/src/client/AuthClient.ts +312 -0
  23. package/src/client/http-client.ts +84 -0
  24. package/src/client/index.ts +19 -0
  25. package/src/config.ts +82 -0
  26. package/src/constants.ts +10 -0
  27. package/src/index.ts +16 -0
  28. package/src/lib/define-roles.ts +35 -0
  29. package/src/lib/define-scopes.ts +48 -0
  30. package/src/middleware/canAccess.ts +126 -0
  31. package/src/middleware/index.ts +13 -0
  32. package/src/middleware/requireAuth.ts +35 -0
  33. package/src/middleware/requireScope.ts +46 -0
  34. package/src/middleware/verifyToken.ts +52 -0
  35. package/src/plugin.ts +86 -0
  36. package/src/react/AuthProvider.tsx +105 -0
  37. package/src/react/hooks.ts +128 -0
  38. package/src/react/index.ts +51 -0
  39. package/src/react/withScopeGuard.tsx +73 -0
  40. package/src/roles.ts +40 -0
  41. package/src/schemas.ts +112 -0
  42. package/src/scopes.ts +60 -0
  43. package/src/server/index.ts +44 -0
  44. package/src/server/requireSession.ts +44 -0
  45. package/src/server/routes/auth.ts +102 -0
  46. package/src/server/routes/cookie.ts +7 -0
  47. package/src/server/routes/index.ts +32 -0
  48. package/src/server/routes/profile.ts +162 -0
  49. package/src/server/routes/scopes.ts +22 -0
  50. package/src/server/routes/sessions.ts +68 -0
  51. package/src/server/routes/setup.ts +50 -0
  52. package/src/server/routes/users.ts +175 -0
  53. package/src/server/serveImage.ts +91 -0
  54. package/src/services/AuthService.ts +80 -0
  55. package/src/services/ScopeService.ts +94 -0
  56. package/src/services/SessionService.ts +245 -0
  57. package/src/services/UserService.ts +245 -0
  58. package/src/setup.ts +99 -0
  59. package/src/tanstack/index.ts +15 -0
  60. package/src/tanstack/routeBuilder.ts +311 -0
  61. package/src/types.ts +118 -0
  62. package/tsconfig.json +8 -0
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @brika/auth - ScopeService
3
+ * Handles scope validation and permission checks
4
+ */
5
+
6
+ import { injectable } from '@brika/di';
7
+ import { ROLE_SCOPES, SCOPES_REGISTRY } from '../constants';
8
+ import { Role, Scope } from '../types';
9
+
10
+ /**
11
+ * Service for managing scopes and permissions
12
+ */
13
+ @injectable()
14
+ export class ScopeService {
15
+ /**
16
+ * Check if scope is valid
17
+ */
18
+ isValidScope(scope: string): scope is Scope {
19
+ return Object.values(Scope).includes(scope as Scope);
20
+ }
21
+
22
+ /**
23
+ * Validate array of scopes
24
+ */
25
+ validateScopes(scopes: unknown[]): Scope[] {
26
+ if (!Array.isArray(scopes)) {
27
+ return [];
28
+ }
29
+
30
+ return scopes.filter((s) => this.isValidScope(s as string)) as Scope[];
31
+ }
32
+
33
+ /**
34
+ * Get scopes for a user based on role
35
+ */
36
+ getScopesForRole(role: Role): Scope[] {
37
+ return ROLE_SCOPES[role] || [];
38
+ }
39
+
40
+ /**
41
+ * Check if scopes include required scope
42
+ */
43
+ hasScope(scopes: Scope[], requiredScope: Scope): boolean {
44
+ if (requiredScope === Scope.ADMIN_ALL) {
45
+ return scopes.includes(Scope.ADMIN_ALL);
46
+ }
47
+
48
+ return scopes.includes(requiredScope) || scopes.includes(Scope.ADMIN_ALL);
49
+ }
50
+
51
+ /**
52
+ * Check if scopes include any in list
53
+ */
54
+ hasScopeAny(scopes: Scope[], required: Scope[]): boolean {
55
+ return required.some((scope) => this.hasScope(scopes, scope));
56
+ }
57
+
58
+ /**
59
+ * Check if scopes include all in list
60
+ */
61
+ hasScopeAll(scopes: Scope[], required: Scope[]): boolean {
62
+ return required.every((scope) => this.hasScope(scopes, scope));
63
+ }
64
+
65
+ /**
66
+ * Get all available scopes
67
+ */
68
+ getAllScopes(): Scope[] {
69
+ return Object.values(Scope) as Scope[];
70
+ }
71
+
72
+ /**
73
+ * Get scopes by category
74
+ */
75
+ getScopesByCategory(category: string): Scope[] {
76
+ return Object.entries(SCOPES_REGISTRY)
77
+ .filter(([, info]) => info.category === category)
78
+ .map(([scope]) => scope as Scope);
79
+ }
80
+
81
+ /**
82
+ * Get scope description
83
+ */
84
+ getScopeDescription(scope: Scope): string {
85
+ return SCOPES_REGISTRY[scope]?.description || 'Unknown scope';
86
+ }
87
+
88
+ /**
89
+ * Get all scopes with descriptions
90
+ */
91
+ getRegistry() {
92
+ return SCOPES_REGISTRY;
93
+ }
94
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * @brika/auth - SessionService
3
+ * Manages server-side sessions stored in SQLite.
4
+ * Replaces JWT-based TokenService with revocable, trackable sessions.
5
+ */
6
+
7
+ import type { Database } from 'bun:sqlite';
8
+ import { createHash, randomBytes } from 'node:crypto';
9
+ import { injectable } from '@brika/di';
10
+ import { getAuthConfig } from '../config';
11
+ import { ROLE_SCOPES } from '../roles';
12
+ import { Role, Scope, type Session, type SessionRecord } from '../types';
13
+
14
+ interface SessionRow {
15
+ id: string;
16
+ user_id: string;
17
+ token_hash: string;
18
+ ip: string | null;
19
+ user_agent: string | null;
20
+ created_at: number;
21
+ last_seen_at: number;
22
+ expires_at: number;
23
+ revoked_at: number | null;
24
+ }
25
+
26
+ interface SessionWithUserRow extends SessionRow {
27
+ email: string;
28
+ name: string;
29
+ role: string;
30
+ scopes: string | null;
31
+ }
32
+
33
+ function parseScopes(raw: string | null): Scope[] {
34
+ if (!raw) {
35
+ return [];
36
+ }
37
+ try {
38
+ const parsed = JSON.parse(raw);
39
+ if (!Array.isArray(parsed)) {
40
+ return [];
41
+ }
42
+ const valid = new Set<string>(Object.values(Scope));
43
+ return parsed.filter((s: string) => valid.has(s)) as Scope[];
44
+ } catch {
45
+ return [];
46
+ }
47
+ }
48
+
49
+ /** SHA-256 hash of a raw token */
50
+ function hashToken(token: string): string {
51
+ return createHash('sha256').update(token).digest('hex');
52
+ }
53
+
54
+ /** Generate a cryptographically random session token */
55
+ function generateToken(): string {
56
+ return randomBytes(32).toString('hex');
57
+ }
58
+
59
+ function generateId(): string {
60
+ return randomBytes(16).toString('hex');
61
+ }
62
+
63
+ function toSessionRecord(row: SessionRow): SessionRecord {
64
+ return {
65
+ id: row.id,
66
+ userId: row.user_id,
67
+ tokenHash: row.token_hash,
68
+ ip: row.ip,
69
+ userAgent: row.user_agent,
70
+ createdAt: row.created_at,
71
+ lastSeenAt: row.last_seen_at,
72
+ expiresAt: row.expires_at,
73
+ revokedAt: row.revoked_at,
74
+ };
75
+ }
76
+
77
+ @injectable()
78
+ export class SessionService {
79
+ private readonly sessionTTL: number;
80
+
81
+ constructor(
82
+ private readonly db: Database,
83
+ sessionTTL?: number
84
+ ) {
85
+ this.sessionTTL = sessionTTL ?? getAuthConfig().session.ttl;
86
+ }
87
+
88
+ /**
89
+ * Create a new session. Returns the raw token (only time it's available).
90
+ * Automatically revokes the oldest sessions if the per-user limit is exceeded.
91
+ */
92
+ createSession(userId: string, ip?: string, userAgent?: string): string {
93
+ const id = generateId();
94
+ const token = generateToken();
95
+ const tokenHash = hashToken(token);
96
+ const now = Date.now();
97
+ const expiresAt = now + this.sessionTTL * 1000;
98
+
99
+ this.db
100
+ .query(
101
+ `INSERT INTO sessions (id, user_id, token_hash, ip, user_agent, created_at, last_seen_at, expires_at)
102
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
103
+ )
104
+ .run(id, userId, tokenHash, ip ?? null, userAgent ?? null, now, now, expiresAt);
105
+
106
+ // Enforce per-user session limit — revoke oldest sessions beyond the cap
107
+ this.#enforceSessionLimit(userId, now);
108
+
109
+ return token;
110
+ }
111
+
112
+ #enforceSessionLimit(userId: string, now: number): void {
113
+ const { maxPerUser } = getAuthConfig().session;
114
+ const activeSessions = this.db
115
+ .query<
116
+ {
117
+ id: string;
118
+ },
119
+ [string, number]
120
+ >(
121
+ `SELECT id FROM sessions
122
+ WHERE user_id = ? AND revoked_at IS NULL AND expires_at > ?
123
+ ORDER BY last_seen_at DESC`
124
+ )
125
+ .all(userId, now);
126
+
127
+ if (activeSessions.length <= maxPerUser) {
128
+ return;
129
+ }
130
+
131
+ // Revoke all sessions beyond the limit (oldest first, they're at the end)
132
+ const toRevoke = activeSessions.slice(maxPerUser);
133
+ for (const session of toRevoke) {
134
+ this.db.query(`UPDATE sessions SET revoked_at = ? WHERE id = ?`).run(now, session.id);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Validate a session token.
140
+ * Returns the session if valid, null if expired/revoked/unknown.
141
+ * Updates last_seen_at and ip on each successful validation (sliding expiration).
142
+ */
143
+ validateSession(token: string, ip?: string): Session | null {
144
+ const tokenHash = hashToken(token);
145
+ const now = Date.now();
146
+
147
+ const row = this.db
148
+ .query<SessionWithUserRow, [string]>(
149
+ `SELECT s.*, u.email, u.name, u.role, u.scopes
150
+ FROM sessions s
151
+ JOIN users u ON u.id = s.user_id
152
+ WHERE s.token_hash = ? AND u.is_active = 1`
153
+ )
154
+ .get(tokenHash);
155
+
156
+ if (!row) {
157
+ return null;
158
+ }
159
+ if (row.revoked_at !== null) {
160
+ return null;
161
+ }
162
+ if (row.expires_at < now) {
163
+ return null;
164
+ }
165
+
166
+ // Sliding expiration: extend session + update last_seen_at & ip
167
+ const newExpiresAt = now + this.sessionTTL * 1000;
168
+ this.db
169
+ .query(
170
+ `UPDATE sessions SET last_seen_at = ?, expires_at = ?, ip = COALESCE(?, ip) WHERE id = ?`
171
+ )
172
+ .run(now, newExpiresAt, ip ?? null, row.id);
173
+
174
+ const role = (row.role as Role) ?? Role.USER;
175
+
176
+ // Admins always get full admin scopes; others use their explicit allow-list
177
+ const scopes: Scope[] = role === Role.ADMIN ? ROLE_SCOPES[Role.ADMIN] : parseScopes(row.scopes);
178
+
179
+ return {
180
+ id: row.id,
181
+ userId: row.user_id,
182
+ userEmail: row.email,
183
+ userName: row.name,
184
+ userRole: role,
185
+ scopes,
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Revoke a specific session by ID.
191
+ */
192
+ revokeSession(sessionId: string): void {
193
+ const now = Date.now();
194
+ this.db
195
+ .query(`UPDATE sessions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`)
196
+ .run(now, sessionId);
197
+ }
198
+
199
+ /**
200
+ * Revoke all sessions for a user.
201
+ */
202
+ revokeAllUserSessions(userId: string): void {
203
+ const now = Date.now();
204
+ this.db
205
+ .query(`UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL`)
206
+ .run(now, userId);
207
+ }
208
+
209
+ /**
210
+ * List active (non-revoked, non-expired) sessions for a user.
211
+ */
212
+ listUserSessions(userId: string): SessionRecord[] {
213
+ const now = Date.now();
214
+ const rows = this.db
215
+ .query<SessionRow, [string, number]>(
216
+ `SELECT * FROM sessions
217
+ WHERE user_id = ? AND revoked_at IS NULL AND expires_at > ?
218
+ ORDER BY last_seen_at DESC`
219
+ )
220
+ .all(userId, now);
221
+
222
+ return rows.map(toSessionRecord);
223
+ }
224
+
225
+ /**
226
+ * Clean up expired and revoked sessions older than 30 days.
227
+ */
228
+ cleanExpiredSessions(): number {
229
+ const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
230
+ const result = this.db
231
+ .query(
232
+ `DELETE FROM sessions WHERE (expires_at < ? OR revoked_at IS NOT NULL) AND created_at < ?`
233
+ )
234
+ .run(cutoff, cutoff);
235
+
236
+ return result.changes;
237
+ }
238
+
239
+ /**
240
+ * Get session TTL in seconds.
241
+ */
242
+ getSessionTTL(): number {
243
+ return this.sessionTTL;
244
+ }
245
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * @brika/auth - UserService
3
+ * Pure SQLite-backed user CRUD operations.
4
+ */
5
+
6
+ import type { Database } from 'bun:sqlite';
7
+ import { photon } from '@brika/photon';
8
+ import bcryptjs from 'bcryptjs';
9
+ import { ROLE_SCOPES } from '../constants';
10
+ import { validatePassword } from '../schemas';
11
+ import { Role, Scope, type User } from '../types';
12
+
13
+ const AVATAR_SIZE = 256;
14
+
15
+ interface UserRow {
16
+ id: string;
17
+ email: string;
18
+ name: string;
19
+ role: Role;
20
+ avatar_hash: string | null;
21
+ is_active: number;
22
+ scopes: string | null;
23
+ created_at: number;
24
+ updated_at: number;
25
+ }
26
+
27
+ function parseScopes(raw: string | null): Scope[] {
28
+ if (!raw) {
29
+ return [];
30
+ }
31
+ try {
32
+ const parsed = JSON.parse(raw);
33
+ if (!Array.isArray(parsed)) {
34
+ return [];
35
+ }
36
+ const valid = new Set<string>(Object.values(Scope));
37
+ return parsed.filter((s: string) => valid.has(s));
38
+ } catch {
39
+ return [];
40
+ }
41
+ }
42
+
43
+ function toUser(row: UserRow): User {
44
+ return {
45
+ id: row.id,
46
+ email: row.email,
47
+ name: row.name,
48
+ role: row.role,
49
+ avatarHash: row.avatar_hash,
50
+ isActive: row.is_active === 1,
51
+ scopes: parseScopes(row.scopes),
52
+ createdAt: new Date(row.created_at),
53
+ updatedAt: new Date(row.updated_at),
54
+ };
55
+ }
56
+
57
+ /** Center-crop to square, compress as webp. */
58
+ export function processAvatar(input: Buffer): Buffer {
59
+ return photon(input)
60
+ .resize(AVATAR_SIZE, AVATAR_SIZE, {
61
+ fit: 'cover',
62
+ })
63
+ .webp()
64
+ .toBuffer();
65
+ }
66
+
67
+ // ─── Service ─────────────────────────────────────────────────────────────────
68
+
69
+ export class UserService {
70
+ constructor(private readonly db: Database) {}
71
+
72
+ getUser(id: string): User | null {
73
+ const row = this.db.prepare('SELECT * FROM users WHERE id = ?').get(id) as UserRow | undefined;
74
+ return row ? toUser(row) : null;
75
+ }
76
+
77
+ getUserByEmail(email: string): User | null {
78
+ const row = this.db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase()) as
79
+ | UserRow
80
+ | undefined;
81
+ return row ? toUser(row) : null;
82
+ }
83
+
84
+ listUsers(): User[] {
85
+ const rows = this.db.prepare('SELECT * FROM users ORDER BY created_at DESC').all() as UserRow[];
86
+ return rows.map(toUser);
87
+ }
88
+
89
+ createUser(email: string, name: string, role: Role): User {
90
+ const id = crypto.randomUUID();
91
+ const now = Date.now();
92
+ const scopes = ROLE_SCOPES[role] ?? [];
93
+
94
+ this.db
95
+ .prepare(
96
+ `INSERT INTO users (id, email, name, role, scopes, is_active, created_at, updated_at)
97
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?)`
98
+ )
99
+ .run(id, email.toLowerCase(), name, role, JSON.stringify(scopes), now, now);
100
+
101
+ return {
102
+ id,
103
+ email: email.toLowerCase(),
104
+ name,
105
+ role,
106
+ avatarHash: null,
107
+ createdAt: new Date(now),
108
+ updatedAt: new Date(now),
109
+ isActive: true,
110
+ scopes,
111
+ };
112
+ }
113
+
114
+ updateUser(
115
+ id: string,
116
+ updates: {
117
+ name?: string;
118
+ role?: Role;
119
+ isActive?: boolean;
120
+ scopes?: Scope[];
121
+ }
122
+ ): User {
123
+ const user = this.getUser(id);
124
+ if (!user) {
125
+ throw new Error('User not found');
126
+ }
127
+
128
+ const now = Date.now();
129
+ const name = updates.name ?? user.name;
130
+
131
+ const sets: string[] = ['name = ?', 'updated_at = ?'];
132
+ const params: (string | number)[] = [name, now];
133
+
134
+ if (updates.role !== undefined) {
135
+ sets.push('role = ?');
136
+ params.push(updates.role);
137
+ }
138
+ let isActive: number | undefined;
139
+ if (updates.isActive !== undefined) {
140
+ isActive = updates.isActive ? 1 : 0;
141
+ }
142
+ if (isActive !== undefined) {
143
+ sets.push('is_active = ?');
144
+ params.push(isActive);
145
+ }
146
+ if (updates.scopes !== undefined) {
147
+ sets.push('scopes = ?');
148
+ params.push(JSON.stringify(updates.scopes));
149
+ }
150
+
151
+ params.push(id);
152
+ this.db.prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(...params);
153
+
154
+ const updated = this.getUser(id);
155
+ if (!updated) {
156
+ throw new Error('User not found after update');
157
+ }
158
+ return updated;
159
+ }
160
+
161
+ /** Set avatar from raw image data (processed to webp). Returns content hash for cache busting. */
162
+ setAvatar(userId: string, imageData: Buffer): string {
163
+ const processed = processAvatar(imageData);
164
+ const hash = Bun.hash(processed).toString(36).slice(0, 8);
165
+ this.db
166
+ .prepare(
167
+ 'UPDATE users SET avatar_data = ?, avatar_mime = ?, avatar_hash = ?, updated_at = ? WHERE id = ?'
168
+ )
169
+ .run(processed, 'image/webp', hash, Date.now(), userId);
170
+ return hash;
171
+ }
172
+
173
+ /** Remove avatar */
174
+ removeAvatar(userId: string): void {
175
+ this.db
176
+ .prepare(
177
+ 'UPDATE users SET avatar_data = NULL, avatar_mime = NULL, avatar_hash = NULL, updated_at = ? WHERE id = ?'
178
+ )
179
+ .run(Date.now(), userId);
180
+ }
181
+
182
+ /** Get raw avatar data for serving */
183
+ getAvatarData(userId: string): {
184
+ data: Buffer;
185
+ mimeType: string;
186
+ } | null {
187
+ const row = this.db
188
+ .prepare('SELECT avatar_data, avatar_mime FROM users WHERE id = ?')
189
+ .get(userId) as
190
+ | {
191
+ avatar_data: Buffer | null;
192
+ avatar_mime: string | null;
193
+ }
194
+ | undefined;
195
+ if (!row?.avatar_data || !row.avatar_mime) {
196
+ return null;
197
+ }
198
+ return {
199
+ data: row.avatar_data,
200
+ mimeType: row.avatar_mime,
201
+ };
202
+ }
203
+
204
+ deleteUser(email: string): void {
205
+ const user = this.getUserByEmail(email);
206
+ if (!user) {
207
+ throw new Error('User not found');
208
+ }
209
+ if (user.role === Role.ADMIN) {
210
+ throw new Error('Cannot delete admin user');
211
+ }
212
+ this.db.prepare('DELETE FROM users WHERE email = ?').run(email.toLowerCase());
213
+ }
214
+
215
+ async setPassword(userId: string, password: string): Promise<void> {
216
+ const user = this.getUser(userId);
217
+ if (!user) {
218
+ throw new Error('User not found');
219
+ }
220
+ const error = validatePassword(password);
221
+ if (error) {
222
+ throw new Error(error);
223
+ }
224
+ const hash = await bcryptjs.hash(password, 12);
225
+ this.db
226
+ .prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?')
227
+ .run(hash, Date.now(), userId);
228
+ }
229
+
230
+ async verifyPassword(userId: string, password: string): Promise<boolean> {
231
+ const row = this.db.prepare('SELECT password_hash FROM users WHERE id = ?').get(userId) as
232
+ | {
233
+ password_hash: string | null;
234
+ }
235
+ | undefined;
236
+ if (!row?.password_hash) {
237
+ return false;
238
+ }
239
+ return await bcryptjs.compare(password, row.password_hash);
240
+ }
241
+
242
+ hasAdmin(): boolean {
243
+ return this.db.prepare("SELECT 1 FROM users WHERE role = 'admin' LIMIT 1").get() !== null;
244
+ }
245
+ }
package/src/setup.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * @brika/auth - Setup
3
+ * Opens SQLite database, creates schema, registers services in DI.
4
+ */
5
+
6
+ import { Database } from 'bun:sqlite';
7
+ import { chmodSync, mkdirSync } from 'node:fs';
8
+ import { dirname } from 'node:path';
9
+ import { container } from '@brika/di';
10
+ import { type AuthConfig, initAuthConfig } from './config';
11
+ import { AuthService } from './services/AuthService';
12
+ import { ScopeService } from './services/ScopeService';
13
+ import { SessionService } from './services/SessionService';
14
+ import { UserService } from './services/UserService';
15
+
16
+ /**
17
+ * Open the SQLite database and create tables if needed.
18
+ */
19
+ export function openAuthDatabase(path: string): Database {
20
+ if (path !== ':memory:') {
21
+ mkdirSync(dirname(path), {
22
+ recursive: true,
23
+ mode: 0o700,
24
+ });
25
+ }
26
+
27
+ const db = new Database(path, {
28
+ strict: true,
29
+ });
30
+
31
+ // Restrict database file to owner-only access (contains password hashes and session data)
32
+ if (path !== ':memory:') {
33
+ try {
34
+ chmodSync(path, 0o600);
35
+ } catch {
36
+ /* may fail on some platforms */
37
+ }
38
+ }
39
+ db.run('PRAGMA journal_mode = WAL');
40
+ db.run('PRAGMA synchronous = NORMAL');
41
+
42
+ db.run(`
43
+ CREATE TABLE IF NOT EXISTS users (
44
+ id TEXT PRIMARY KEY,
45
+ email TEXT UNIQUE NOT NULL,
46
+ password_hash TEXT,
47
+ name TEXT NOT NULL,
48
+ role TEXT NOT NULL DEFAULT 'user',
49
+ is_active INTEGER DEFAULT 1,
50
+ avatar_data BLOB,
51
+ avatar_mime TEXT,
52
+ avatar_hash TEXT,
53
+ scopes TEXT DEFAULT '[]',
54
+ created_at INTEGER NOT NULL,
55
+ updated_at INTEGER NOT NULL
56
+ )
57
+ `);
58
+
59
+ db.run(`
60
+ CREATE TABLE IF NOT EXISTS sessions (
61
+ id TEXT PRIMARY KEY,
62
+ user_id TEXT NOT NULL,
63
+ token_hash TEXT UNIQUE NOT NULL,
64
+ ip TEXT,
65
+ user_agent TEXT,
66
+ created_at INTEGER NOT NULL,
67
+ last_seen_at INTEGER NOT NULL,
68
+ expires_at INTEGER NOT NULL,
69
+ revoked_at INTEGER,
70
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
71
+ )
72
+ `);
73
+
74
+ db.run('CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)');
75
+ db.run('CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash)');
76
+ db.run('CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)');
77
+
78
+ return db;
79
+ }
80
+
81
+ /**
82
+ * Register all auth services in the DI container.
83
+ * Must be called after `openAuthDatabase`.
84
+ */
85
+ export function setupAuthServices(db: Database, config?: AuthConfig): void {
86
+ const resolved = initAuthConfig(config);
87
+ container.register(SessionService, {
88
+ useValue: new SessionService(db, resolved.session.ttl),
89
+ });
90
+ container.register(UserService, {
91
+ useValue: new UserService(db),
92
+ });
93
+ container.register(ScopeService, {
94
+ useClass: ScopeService,
95
+ });
96
+ container.register(AuthService, {
97
+ useClass: AuthService,
98
+ });
99
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @brika/auth/tanstack
3
+ * TanStack Router integration for protected routes
4
+ */
5
+
6
+ export type {
7
+ ExtractParams,
8
+ NormalizeParam,
9
+ ParamsArg,
10
+ ProtectedRouteDefinition,
11
+ ProtectedRouteResult,
12
+ ProtectedRoutesOptions,
13
+ ProtectedRouteWithChildren,
14
+ } from './routeBuilder';
15
+ export { createProtectedRoute, createProtectedRoutes, resolvePath } from './routeBuilder';