@better-forge/auth 0.1.0 → 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.
@@ -1,100 +1,100 @@
1
- import type { FastifyInstance, FastifyReply } from 'fastify'
2
- import bcrypt from 'bcryptjs'
3
- import { eq } from 'drizzle-orm'
4
- import type { SqliteDatabase } from '@better-forge/db'
5
- import { users } from './tables.js'
6
-
7
- export interface SetupRoutesOptions<Identity extends { sessionId?: string }> {
8
- db: SqliteDatabase
9
- /** Runs `work` so that all of it is written, or none of it. */
10
- withTransaction: <T>(work: () => Promise<T>) => Promise<T>
11
- isSetupComplete: () => boolean
12
- /** Records that setup is done. Called as the last step inside the transaction: if it throws, the administrator is not kept. */
13
- writeSetupComplete: () => void
14
- /** Takes the record back, when the transaction fails to commit after it was written. */
15
- removeSetupMarker: () => void
16
- /** Opens the database and brings its tables up to date; called once the request has been checked. */
17
- prepareDatabase: () => void
18
- /** Signs the new administrator in. */
19
- createSession: (app: FastifyInstance, reply: FastifyReply, user: typeof users.$inferSelect) => Promise<Identity>
20
- /** Rate limit of `POST /complete` (the config the rate limit plugin understands). */
21
- rateLimit: Record<string, unknown>
22
- /** Writes whatever else the application needs on first run, inside the same transaction as the administrator. */
23
- onSetup?: () => Promise<void>
24
- /** Starts the work that runs only once the application is set up. */
25
- startBackgroundServices?: () => void
26
- /** Lets setup run although accounts already exist, for an installation that is filled with accounts before its first run. Checked on each request. */
27
- allowExistingUsers?: () => boolean
28
- }
29
-
30
- /**
31
- * The first-run routes: whether the application still needs setup, and the one request that creates the administrator
32
- * and finishes it. Setup is refused once it is done, and only one attempt runs at a time.
33
- */
34
- export function createSetupRoutes<Identity extends { sessionId?: string }>(options: SetupRoutesOptions<Identity>) {
35
- const { db } = options
36
- let inProgress = false
37
-
38
- return async function setupRoutes(app: FastifyInstance, overrides: { startBackgroundServices?: () => void } = {}) {
39
- app.get('/status', async () => ({ needsSetup: !options.isSetupComplete() }))
40
-
41
- app.post<{ Body: { email: string; password: string } }>('/complete', {
42
- config: { rateLimit: options.rateLimit },
43
- }, async (req, reply) => {
44
- if (options.isSetupComplete()) {
45
- return reply.code(409).send({ error: 'Setup already completed' })
46
- }
47
-
48
- const { email, password } = req.body ?? {}
49
- if (typeof email !== 'string' || typeof password !== 'string' || !email || !password) {
50
- return reply.code(400).send({ error: 'Email and password are required' })
51
- }
52
- if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
53
- return reply.code(400).send({ error: 'A valid email address is required' })
54
- }
55
- if (password.length < 8 || password.length > 128) {
56
- return reply.code(400).send({ error: 'Password must be between 8 and 128 characters' })
57
- }
58
-
59
- if (inProgress) {
60
- return reply.code(409).send({ error: 'Setup is already in progress' })
61
- }
62
-
63
- inProgress = true
64
- try {
65
- // Initialize DB and run migrations now that we have a confirmed DB type
66
- options.prepareDatabase()
67
-
68
- const hash = await bcrypt.hash(password, 10)
69
- let setupMarkerWritten = false
70
- const user = await options.withTransaction(async () => {
71
- // The marker can be missing while the data is not (a lost volume, a restore). Accounts that already
72
- // exist mean setup was done: never create or overwrite an administrator over them.
73
- if (!options.allowExistingUsers?.() && (await db.select({ id: users.id }).from(users).limit(1)).length > 0) return null
74
-
75
- await db.insert(users).values({ email, passwordHash: hash, role: 'admin', createdAt: Date.now() })
76
-
77
- await options.onSetup?.()
78
-
79
- const admin = (await db.select().from(users).where(eq(users.email, email)))[0]!
80
- // Last step: if writing the marker fails the admin insert rolls back with it.
81
- options.writeSetupComplete()
82
- setupMarkerWritten = true
83
- return admin
84
- }).catch((error: unknown) => {
85
- // COMMIT itself failed after the marker was written — undo it so setup can be retried.
86
- if (setupMarkerWritten) options.removeSetupMarker()
87
- throw error
88
- })
89
- if (!user) return reply.code(409).send({ error: 'Setup already completed' })
90
- const startServices = overrides.startBackgroundServices ?? options.startBackgroundServices
91
- startServices?.()
92
- const identity = await options.createSession(app, reply, user)
93
- const { sessionId: _sessionId, ...safeIdentity } = identity
94
- return safeIdentity
95
- } finally {
96
- inProgress = false
97
- }
98
- })
99
- }
100
- }
1
+ import type { FastifyInstance, FastifyReply } from 'fastify'
2
+ import bcrypt from 'bcryptjs'
3
+ import { eq } from 'drizzle-orm'
4
+ import type { SqliteDatabase } from '@better-forge/db'
5
+ import { users } from './tables.js'
6
+
7
+ export interface SetupRoutesOptions<Identity extends { sessionId?: string }> {
8
+ db: SqliteDatabase
9
+ /** Runs `work` so that all of it is written, or none of it. */
10
+ withTransaction: <T>(work: () => Promise<T>) => Promise<T>
11
+ isSetupComplete: () => boolean
12
+ /** Records that setup is done. Called as the last step inside the transaction: if it throws, the administrator is not kept. */
13
+ writeSetupComplete: () => void
14
+ /** Takes the record back, when the transaction fails to commit after it was written. */
15
+ removeSetupMarker: () => void
16
+ /** Opens the database and brings its tables up to date; called once the request has been checked. */
17
+ prepareDatabase: () => void
18
+ /** Signs the new administrator in. */
19
+ createSession: (app: FastifyInstance, reply: FastifyReply, user: typeof users.$inferSelect) => Promise<Identity>
20
+ /** Rate limit of `POST /complete` (the config the rate limit plugin understands). */
21
+ rateLimit: Record<string, unknown>
22
+ /** Writes whatever else the application needs on first run, inside the same transaction as the administrator. */
23
+ onSetup?: () => Promise<void>
24
+ /** Starts the work that runs only once the application is set up. */
25
+ startBackgroundServices?: () => void
26
+ /** Lets setup run although accounts already exist, for an installation that is filled with accounts before its first run. Checked on each request. */
27
+ allowExistingUsers?: () => boolean
28
+ }
29
+
30
+ /**
31
+ * The first-run routes: whether the application still needs setup, and the one request that creates the administrator
32
+ * and finishes it. Setup is refused once it is done, and only one attempt runs at a time.
33
+ */
34
+ export function createSetupRoutes<Identity extends { sessionId?: string }>(options: SetupRoutesOptions<Identity>) {
35
+ const { db } = options
36
+ let inProgress = false
37
+
38
+ return async function setupRoutes(app: FastifyInstance, overrides: { startBackgroundServices?: () => void } = {}) {
39
+ app.get('/status', async () => ({ needsSetup: !options.isSetupComplete() }))
40
+
41
+ app.post<{ Body: { email: string; password: string } }>('/complete', {
42
+ config: { rateLimit: options.rateLimit },
43
+ }, async (req, reply) => {
44
+ if (options.isSetupComplete()) {
45
+ return reply.code(409).send({ error: 'Setup already completed' })
46
+ }
47
+
48
+ const { email, password } = req.body ?? {}
49
+ if (typeof email !== 'string' || typeof password !== 'string' || !email || !password) {
50
+ return reply.code(400).send({ error: 'Email and password are required' })
51
+ }
52
+ if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
53
+ return reply.code(400).send({ error: 'A valid email address is required' })
54
+ }
55
+ if (password.length < 8 || password.length > 128) {
56
+ return reply.code(400).send({ error: 'Password must be between 8 and 128 characters' })
57
+ }
58
+
59
+ if (inProgress) {
60
+ return reply.code(409).send({ error: 'Setup is already in progress' })
61
+ }
62
+
63
+ inProgress = true
64
+ try {
65
+ // Initialize DB and run migrations now that we have a confirmed DB type
66
+ options.prepareDatabase()
67
+
68
+ const hash = await bcrypt.hash(password, 10)
69
+ let setupMarkerWritten = false
70
+ const user = await options.withTransaction(async () => {
71
+ // The marker can be missing while the data is not (a lost volume, a restore). Accounts that already
72
+ // exist mean setup was done: never create or overwrite an administrator over them.
73
+ if (!options.allowExistingUsers?.() && (await db.select({ id: users.id }).from(users).limit(1)).length > 0) return null
74
+
75
+ await db.insert(users).values({ email, passwordHash: hash, role: 'admin', createdAt: Date.now() })
76
+
77
+ await options.onSetup?.()
78
+
79
+ const admin = (await db.select().from(users).where(eq(users.email, email)))[0]!
80
+ // Last step: if writing the marker fails the admin insert rolls back with it.
81
+ options.writeSetupComplete()
82
+ setupMarkerWritten = true
83
+ return admin
84
+ }).catch((error: unknown) => {
85
+ // COMMIT itself failed after the marker was written — undo it so setup can be retried.
86
+ if (setupMarkerWritten) options.removeSetupMarker()
87
+ throw error
88
+ })
89
+ if (!user) return reply.code(409).send({ error: 'Setup already completed' })
90
+ const startServices = overrides.startBackgroundServices ?? options.startBackgroundServices
91
+ startServices?.()
92
+ const identity = await options.createSession(app, reply, user)
93
+ const { sessionId: _sessionId, ...safeIdentity } = identity
94
+ return safeIdentity
95
+ } finally {
96
+ inProgress = false
97
+ }
98
+ })
99
+ }
100
+ }
@@ -1,43 +1,43 @@
1
- import type { AuthOptions } from './context.js'
2
-
3
- /** How the user tried to sign in, as recorded in `diff.method` of 'sign_in' audit entries. */
4
- export type SignInMethod = 'password' | 'sso'
5
-
6
- /** Why a password, or the authentication-code step of any sign-in, was refused. SSO refusals use the OIDC codes. */
7
- export type CredentialDenialCode =
8
- | 'password_login_disabled'
9
- | 'no_matching_account'
10
- | 'wrong_password'
11
- | 'two_factor_expired'
12
- | 'invalid_two_factor_code'
13
-
14
- export interface SignInDenial {
15
- code: string
16
- reason: string
17
- email?: string | undefined
18
- }
19
-
20
- export function createSignInAudit(ctx: AuthOptions) {
21
- const { audit } = ctx
22
-
23
- /** Records a successful sign-in; `twoFactor` says whether an authentication code was part of it. */
24
- async function auditSignIn(user: { id: number; email: string }, method: SignInMethod, twoFactor: boolean): Promise<void> {
25
- await audit.write({ userId: user.id, userEmail: user.email }, 'allow', 'sign_in', user.id, user.email, { method, twoFactor })
26
- }
27
-
28
- /**
29
- * Records a refused sign-in. Nobody is signed in, so the actor is user 0 with the email that was entered or that
30
- * the provider asserted, when there was one. The user only ever sees a generic message; this holds the reason.
31
- */
32
- async function auditSignInDenial(method: SignInMethod, denial: SignInDenial, extra: Record<string, unknown> = {}): Promise<void> {
33
- await audit.write(
34
- { userId: 0, userEmail: denial.email ?? `unknown (${method === 'sso' ? 'SSO' : 'password'})` },
35
- 'deny', 'sign_in', null, denial.email ?? (method === 'sso' ? 'SSO sign-in' : 'Password sign-in'),
36
- { method, code: denial.code, reason: denial.reason, ...extra },
37
- )
38
- }
39
-
40
- return { auditSignIn, auditSignInDenial }
41
- }
42
-
43
- export type SignInAudit = ReturnType<typeof createSignInAudit>
1
+ import type { AuthOptions } from './context.js'
2
+
3
+ /** How the user tried to sign in, as recorded in `diff.method` of 'sign_in' audit entries. */
4
+ export type SignInMethod = 'password' | 'sso'
5
+
6
+ /** Why a password, or the authentication-code step of any sign-in, was refused. SSO refusals use the OIDC codes. */
7
+ export type CredentialDenialCode =
8
+ | 'password_login_disabled'
9
+ | 'no_matching_account'
10
+ | 'wrong_password'
11
+ | 'two_factor_expired'
12
+ | 'invalid_two_factor_code'
13
+
14
+ export interface SignInDenial {
15
+ code: string
16
+ reason: string
17
+ email?: string | undefined
18
+ }
19
+
20
+ export function createSignInAudit(ctx: AuthOptions) {
21
+ const { audit } = ctx
22
+
23
+ /** Records a successful sign-in; `twoFactor` says whether an authentication code was part of it. */
24
+ async function auditSignIn(user: { id: number; email: string }, method: SignInMethod, twoFactor: boolean): Promise<void> {
25
+ await audit.write({ userId: user.id, userEmail: user.email }, 'allow', 'sign_in', user.id, user.email, { method, twoFactor })
26
+ }
27
+
28
+ /**
29
+ * Records a refused sign-in. Nobody is signed in, so the actor is user 0 with the email that was entered or that
30
+ * the provider asserted, when there was one. The user only ever sees a generic message; this holds the reason.
31
+ */
32
+ async function auditSignInDenial(method: SignInMethod, denial: SignInDenial, extra: Record<string, unknown> = {}): Promise<void> {
33
+ await audit.write(
34
+ { userId: 0, userEmail: denial.email ?? `unknown (${method === 'sso' ? 'SSO' : 'password'})` },
35
+ 'deny', 'sign_in', null, denial.email ?? (method === 'sso' ? 'SSO sign-in' : 'Password sign-in'),
36
+ { method, code: denial.code, reason: denial.reason, ...extra },
37
+ )
38
+ }
39
+
40
+ return { auditSignIn, auditSignInDenial }
41
+ }
42
+
43
+ export type SignInAudit = ReturnType<typeof createSignInAudit>
package/src/tables.ts CHANGED
@@ -1,59 +1,59 @@
1
- import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
2
-
3
- export const users = sqliteTable('users', {
4
- id: integer('id').primaryKey({ autoIncrement: true }),
5
- email: text('email').notNull().unique(),
6
- passwordHash: text('password_hash').notNull(),
7
- role: text('role').notNull().default('admin'),
8
- createdAt: integer('created_at').notNull(),
9
- // Additive columns (added via ALTER TABLE, must stay at end for sqlite-proxy position mapping)
10
- mustChangePassword: integer('must_change_password').notNull().default(0),
11
- totpSecret: text('totp_secret'),
12
- totpEnabled: integer('totp_enabled').notNull().default(0),
13
- totpRecoveryCodes: text('totp_recovery_codes'),
14
- // SSO identity bound on the first OIDC sign-in; later sign-ins match on it instead of the email.
15
- oidcIssuer: text('oidc_issuer'),
16
- oidcSubject: text('oidc_subject'),
17
- })
18
-
19
- export const authSessions = sqliteTable('auth_sessions', {
20
- id: text('id').primaryKey(),
21
- userId: integer('user_id').notNull(),
22
- csrfTokenHash: text('csrf_token_hash').notNull(),
23
- createdAt: integer('created_at').notNull(),
24
- lastSeenAt: integer('last_seen_at').notNull(),
25
- expiresAt: integer('expires_at').notNull(),
26
- // 'password' or 'oidc'. An SSO session never asks for the temporary password to be changed.
27
- authMethod: text('auth_method').notNull().default('password'),
28
- // When the user last proved who they are in this session: the sign-in, or a later SSO confirmation.
29
- verifiedAt: integer('verified_at'),
30
- })
31
-
32
- /** Long-lived bearer credentials for scripts and CI. Only the SHA-256 of the token is stored; revoking deletes the row. */
33
- export const apiTokens = sqliteTable('api_tokens', {
34
- id: integer('id').primaryKey({ autoIncrement: true }),
35
- name: text('name').notNull(),
36
- tokenHash: text('token_hash').notNull().unique(),
37
- /** Leading characters of the token, so it can be recognised in the list. */
38
- prefix: text('prefix').notNull(),
39
- createdAt: integer('created_at').notNull(),
40
- expiresAt: integer('expires_at'),
41
- lastUsedAt: integer('last_used_at'),
42
- /** JSON list of what the token may do, e.g. `["notes:write","tags:read"]` (the permissions are defined by the application). */
43
- scopes: text('scopes').notNull(),
44
- })
45
-
46
- export const oidcSettings = sqliteTable('oidc_settings', {
47
- id: integer('id').primaryKey(),
48
- enabled: integer('enabled').notNull().default(0),
49
- issuer: text('issuer').notNull().default(''),
50
- clientId: text('client_id').notNull().default(''),
51
- /** Stored encrypted by the application's secret box; empty when no secret is set. */
52
- clientSecret: text('client_secret').notNull().default(''),
53
- scopes: text('scopes').notNull().default(''),
54
- redirectUri: text('redirect_uri').notNull().default(''),
55
- buttonLabel: text('button_label').notNull().default(''),
56
- allowUnverifiedEmail: integer('allow_unverified_email').notNull().default(0),
57
- disablePasswordLogin: integer('disable_password_login').notNull().default(0),
58
- updatedAt: integer('updated_at').notNull(),
59
- })
1
+ import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
2
+
3
+ export const users = sqliteTable('users', {
4
+ id: integer('id').primaryKey({ autoIncrement: true }),
5
+ email: text('email').notNull().unique(),
6
+ passwordHash: text('password_hash').notNull(),
7
+ role: text('role').notNull().default('admin'),
8
+ createdAt: integer('created_at').notNull(),
9
+ // Additive columns (added via ALTER TABLE, must stay at end for sqlite-proxy position mapping)
10
+ mustChangePassword: integer('must_change_password').notNull().default(0),
11
+ totpSecret: text('totp_secret'),
12
+ totpEnabled: integer('totp_enabled').notNull().default(0),
13
+ totpRecoveryCodes: text('totp_recovery_codes'),
14
+ // SSO identity bound on the first OIDC sign-in; later sign-ins match on it instead of the email.
15
+ oidcIssuer: text('oidc_issuer'),
16
+ oidcSubject: text('oidc_subject'),
17
+ })
18
+
19
+ export const authSessions = sqliteTable('auth_sessions', {
20
+ id: text('id').primaryKey(),
21
+ userId: integer('user_id').notNull(),
22
+ csrfTokenHash: text('csrf_token_hash').notNull(),
23
+ createdAt: integer('created_at').notNull(),
24
+ lastSeenAt: integer('last_seen_at').notNull(),
25
+ expiresAt: integer('expires_at').notNull(),
26
+ // 'password' or 'oidc'. An SSO session never asks for the temporary password to be changed.
27
+ authMethod: text('auth_method').notNull().default('password'),
28
+ // When the user last proved who they are in this session: the sign-in, or a later SSO confirmation.
29
+ verifiedAt: integer('verified_at'),
30
+ })
31
+
32
+ /** Long-lived bearer credentials for scripts and CI. Only the SHA-256 of the token is stored; revoking deletes the row. */
33
+ export const apiTokens = sqliteTable('api_tokens', {
34
+ id: integer('id').primaryKey({ autoIncrement: true }),
35
+ name: text('name').notNull(),
36
+ tokenHash: text('token_hash').notNull().unique(),
37
+ /** Leading characters of the token, so it can be recognised in the list. */
38
+ prefix: text('prefix').notNull(),
39
+ createdAt: integer('created_at').notNull(),
40
+ expiresAt: integer('expires_at'),
41
+ lastUsedAt: integer('last_used_at'),
42
+ /** JSON list of what the token may do, e.g. `["notes:write","tags:read"]` (the permissions are defined by the application). */
43
+ scopes: text('scopes').notNull(),
44
+ })
45
+
46
+ export const oidcSettings = sqliteTable('oidc_settings', {
47
+ id: integer('id').primaryKey(),
48
+ enabled: integer('enabled').notNull().default(0),
49
+ issuer: text('issuer').notNull().default(''),
50
+ clientId: text('client_id').notNull().default(''),
51
+ /** Stored encrypted by the application's secret box; empty when no secret is set. */
52
+ clientSecret: text('client_secret').notNull().default(''),
53
+ scopes: text('scopes').notNull().default(''),
54
+ redirectUri: text('redirect_uri').notNull().default(''),
55
+ buttonLabel: text('button_label').notNull().default(''),
56
+ allowUnverifiedEmail: integer('allow_unverified_email').notNull().default(0),
57
+ disablePasswordLogin: integer('disable_password_login').notNull().default(0),
58
+ updatedAt: integer('updated_at').notNull(),
59
+ })
@@ -1,42 +1,42 @@
1
- import type { TokenPreset, TokenResource } from '@better-forge/contracts'
2
- import { VAULT_USE_SCOPE, normalizeScopes } from '@better-forge/contracts'
3
-
4
- export interface TokenCatalogue {
5
- /** Every permission a token can hold, in the order they are listed: `<part>:read`, `<part>:write`, then the extra ones. */
6
- readonly scopes: readonly string[]
7
- readonly resources: readonly TokenResource[]
8
- readonly presets: readonly TokenPreset[]
9
- /** A clean list of known permissions (reading added wherever writing is granted), or null if one is unknown or none is given. */
10
- normalize(input: unknown): string[] | null
11
- /** The permissions stored with a token. Anything that is not a known permission is dropped. */
12
- parse(stored: string): string[]
13
- }
14
-
15
- /**
16
- * The permissions an API token can be given. A part is a group of routes (`belongsTo(scope, part)` ties routes to
17
- * it); `extraScopes` are single permissions that belong to no part, such as the one to use vault secrets.
18
- */
19
- export function createTokenCatalogue(options: {
20
- resources: readonly TokenResource[]
21
- extraScopes?: readonly string[]
22
- presets?: (scopes: readonly string[]) => readonly TokenPreset[]
23
- }): TokenCatalogue {
24
- const scopes = [
25
- ...options.resources.flatMap((resource) => resource.levels.map((level) => `${resource.key}:${level}`)),
26
- ...(options.extraScopes ?? [VAULT_USE_SCOPE]),
27
- ]
28
- return {
29
- scopes,
30
- resources: options.resources,
31
- presets: options.presets?.(scopes) ?? [],
32
- normalize: (input) => normalizeScopes(input, scopes),
33
- parse(stored) {
34
- try {
35
- const parsed: unknown = JSON.parse(stored)
36
- return Array.isArray(parsed) ? parsed.filter((scope): scope is string => typeof scope === 'string' && scopes.includes(scope)) : []
37
- } catch {
38
- return []
39
- }
40
- },
41
- }
42
- }
1
+ import type { TokenPreset, TokenResource } from '@better-forge/contracts'
2
+ import { VAULT_USE_SCOPE, normalizeScopes } from '@better-forge/contracts'
3
+
4
+ export interface TokenCatalogue {
5
+ /** Every permission a token can hold, in the order they are listed: `<part>:read`, `<part>:write`, then the extra ones. */
6
+ readonly scopes: readonly string[]
7
+ readonly resources: readonly TokenResource[]
8
+ readonly presets: readonly TokenPreset[]
9
+ /** A clean list of known permissions (reading added wherever writing is granted), or null if one is unknown or none is given. */
10
+ normalize(input: unknown): string[] | null
11
+ /** The permissions stored with a token. Anything that is not a known permission is dropped. */
12
+ parse(stored: string): string[]
13
+ }
14
+
15
+ /**
16
+ * The permissions an API token can be given. A part is a group of routes (`belongsTo(scope, part)` ties routes to
17
+ * it); `extraScopes` are single permissions that belong to no part, such as the one to use vault secrets.
18
+ */
19
+ export function createTokenCatalogue(options: {
20
+ resources: readonly TokenResource[]
21
+ extraScopes?: readonly string[]
22
+ presets?: (scopes: readonly string[]) => readonly TokenPreset[]
23
+ }): TokenCatalogue {
24
+ const scopes = [
25
+ ...options.resources.flatMap((resource) => resource.levels.map((level) => `${resource.key}:${level}`)),
26
+ ...(options.extraScopes ?? [VAULT_USE_SCOPE]),
27
+ ]
28
+ return {
29
+ scopes,
30
+ resources: options.resources,
31
+ presets: options.presets?.(scopes) ?? [],
32
+ normalize: (input) => normalizeScopes(input, scopes),
33
+ parse(stored) {
34
+ try {
35
+ const parsed: unknown = JSON.parse(stored)
36
+ return Array.isArray(parsed) ? parsed.filter((scope): scope is string => typeof scope === 'string' && scopes.includes(scope)) : []
37
+ } catch {
38
+ return []
39
+ }
40
+ },
41
+ }
42
+ }