diff --git a/src/lib/server/api-tokens.ts b/src/lib/server/api-tokens.ts new file mode 100644 index 0000000..d585e5b --- /dev/null +++ b/src/lib/server/api-tokens.ts @@ -0,0 +1,224 @@ +/** + * API Token Management + * + * Provides stateless Bearer token authentication as an alternative to cookie-based sessions. + * Tokens inherit the full permissions of the creating user. + * + * Token format: dh_<32-byte-base64url> + * Storage: Argon2id hash + 8-character prefix for DB lookup optimization + */ + +import { secureGetRandomValues } from './crypto-fallback'; +import { hashPassword, verifyPassword, getUserPermissionsById } from './auth'; +import { + db, + apiTokens, + users, + eq, + and +} from './db/drizzle.js'; +import { userHasAdminRole } from './db'; +import { isEnterprise } from './license'; +import type { AuthenticatedUser } from './auth'; + +// Token prefix for detection in logs and secret scanners +const TOKEN_PREFIX = 'dh_'; +const TOKEN_BYTES = 32; +// Expected token length: 'dh_' (3) + 32 bytes base64url (43) = 46 chars +const TOKEN_MAX_LENGTH = 200; + +// Pre-computed valid Argon2id hash for timing-attack protection. +// Generated at module init so verifyPassword() never throws on the dummy path. +let TIMING_DUMMY_HASH: string | null = null; +hashPassword('dh_timing_attack_dummy_token_value').then(h => { + TIMING_DUMMY_HASH = h; +}).catch(() => { + // Fallback: if hash generation fails, timing protection will be skipped + console.warn('[ApiToken] Failed to generate timing dummy hash'); +}); + +/** + * Generate a new API token for a user. + * The plaintext token is returned ONCE and never stored again. + * + * @param userId - ID of the user owning this token + * @param name - Descriptive name (e.g. "CI/CD Pipeline") + * @param expiresAt - Optional expiration date (ISO string), null = no expiration + * @returns { token: plaintext token (once!), tokenId: DB ID, tokenPrefix } + */ +export async function generateApiToken( + userId: number, + name: string, + expiresAt?: string | null +): Promise<{ token: string; tokenId: number; tokenPrefix: string }> { + // 32 bytes cryptographically secure random data + const tokenBytes = new Uint8Array(TOKEN_BYTES); + secureGetRandomValues(tokenBytes); + const rawToken = TOKEN_PREFIX + Buffer.from(tokenBytes).toString('base64url'); + + // Prefix for DB lookup (8 chars after 'dh_') + const tokenPrefix = rawToken.substring(TOKEN_PREFIX.length, TOKEN_PREFIX.length + 8); + + // Argon2id hash for DB storage + const tokenHash = await hashPassword(rawToken); + + // Persist to DB + const now = new Date().toISOString(); + const result = await db + .insert(apiTokens) + .values({ + userId, + name, + tokenHash, + tokenPrefix, + isActive: true, + expiresAt: expiresAt ?? null, + createdAt: now, + updatedAt: now + }) + .returning({ id: apiTokens.id }); + + return { + token: rawToken, // One-time plaintext + tokenId: result[0].id, + tokenPrefix + }; +} + +/** + * Validate a Bearer token and return the associated AuthenticatedUser. + * Timing-attack resistant: Argon2id verification even on invalid prefix. + * + * @param rawToken - Complete token from the Authorization header + * @returns AuthenticatedUser or null for invalid/expired tokens + */ +export async function validateApiToken(rawToken: string): Promise { + // Input validation: reject malformed tokens early (prevents DoS via oversized Argon2id input) + if (!rawToken || rawToken.length > TOKEN_MAX_LENGTH || !rawToken.startsWith(TOKEN_PREFIX)) { + return null; + } + + const tokenPrefix = rawToken.substring(TOKEN_PREFIX.length, TOKEN_PREFIX.length + 8); + + // Candidate lookup via index (avoids O(n) Argon2id computations) + const candidates = await db + .select() + .from(apiTokens) + .where( + and( + eq(apiTokens.tokenPrefix, tokenPrefix), + eq(apiTokens.isActive, true) + ) + ); + + // Timing-attack protection: run hash operation even with no candidates + if (candidates.length === 0) { + if (TIMING_DUMMY_HASH) { + await verifyPassword(rawToken, TIMING_DUMMY_HASH); + } + return null; + } + + // Hash verification (Argon2id, constant-time) + for (const candidate of candidates) { + try { + // Expiration check + if (candidate.expiresAt && new Date(candidate.expiresAt) < new Date()) { + // Deactivate expired token (lazy) + await db + .update(apiTokens) + .set({ isActive: false, updatedAt: new Date().toISOString() }) + .where(eq(apiTokens.id, candidate.id)); + continue; + } + + const isValid = await verifyPassword(rawToken, candidate.tokenHash); + + if (isValid) { + // Update last_used (fire-and-forget, void signals intentional Promise ignore) + void db.update(apiTokens) + .set({ lastUsed: new Date().toISOString() }) + .where(eq(apiTokens.id, candidate.id)) + .catch((err: unknown) => console.error('[ApiToken] Failed to update last_used:', err)); + + // Load user and build AuthenticatedUser + const userResult = await db + .select() + .from(users) + .where(and(eq(users.id, candidate.userId), eq(users.isActive, true))); + + if (userResult.length === 0) return null; + + const user = userResult[0]; + const permissions = await getUserPermissionsById(user.id); + const enterprise = await isEnterprise(); + const isAdmin = enterprise ? await userHasAdminRole(user.id) : true; + + return { + id: user.id, + username: user.username, + email: user.email ?? undefined, + displayName: user.displayName ?? undefined, + avatar: user.avatar ?? undefined, + isAdmin, + provider: (user.authProvider?.split(':')[0] as 'local' | 'ldap' | 'oidc') || 'local', + permissions + }; + } + } catch { + // Invalid hash format, continue checking + continue; + } + } + + return null; +} + +/** + * List all API tokens of a user (without token_hash). + */ +export async function listUserApiTokens(userId: number) { + return db + .select({ + id: apiTokens.id, + name: apiTokens.name, + tokenPrefix: apiTokens.tokenPrefix, + lastUsed: apiTokens.lastUsed, + expiresAt: apiTokens.expiresAt, + isActive: apiTokens.isActive, + createdAt: apiTokens.createdAt + }) + .from(apiTokens) + .where(eq(apiTokens.userId, userId)) + .orderBy(apiTokens.createdAt); +} + +/** + * Revoke an API token (sets is_active = false). + * Returns false if token not found or not owned by user. + */ +export async function revokeApiToken( + tokenId: number, + userId: number, + isAdmin: boolean +): Promise { + // Load token for ownership check + const tokenResult = await db + .select({ id: apiTokens.id, userId: apiTokens.userId }) + .from(apiTokens) + .where(eq(apiTokens.id, tokenId)); + + if (tokenResult.length === 0) return false; + + const token = tokenResult[0]; + + // Only owner or admin may revoke + if (token.userId !== userId && !isAdmin) return false; + + await db + .update(apiTokens) + .set({ isActive: false, updatedAt: new Date().toISOString() }) + .where(eq(apiTokens.id, tokenId)); + + return true; +} diff --git a/src/lib/server/db/drizzle.ts b/src/lib/server/db/drizzle.ts index 9f55171..2f15622 100644 --- a/src/lib/server/db/drizzle.ts +++ b/src/lib/server/db/drizzle.ts @@ -335,7 +335,8 @@ const REQUIRED_TABLES = [ 'audit_logs', 'container_events', 'schedule_executions', - 'user_preferences' + 'user_preferences', + 'api_tokens' ]; /** @@ -898,6 +899,7 @@ export const userPreferences = schemaProxy.userPreferences; export const scheduleExecutions = schemaProxy.scheduleExecutions; export const stackEnvironmentVariables = schemaProxy.stackEnvironmentVariables; export const pendingContainerUpdates = schemaProxy.pendingContainerUpdates; +export const apiTokens = schemaProxy.apiTokens; // Re-export types from SQLite schema (they're compatible with PostgreSQL) export type { @@ -956,7 +958,9 @@ export type { StackEnvironmentVariable, NewStackEnvironmentVariable, PendingContainerUpdate, - NewPendingContainerUpdate + NewPendingContainerUpdate, + ApiToken, + NewApiToken } from './schema/index.js'; export { eq, and, or, desc, asc, like, sql, inArray, isNull, isNotNull } from 'drizzle-orm'; diff --git a/src/lib/server/db/schema/index.ts b/src/lib/server/db/schema/index.ts index 396e84e..bd09f83 100644 --- a/src/lib/server/db/schema/index.ts +++ b/src/lib/server/db/schema/index.ts @@ -464,6 +464,26 @@ export const pendingContainerUpdates = sqliteTable('pending_container_updates', envContainerUnique: unique().on(table.environmentId, table.containerId) })); +// ============================================================================= +// API TOKEN TABLES +// ============================================================================= + +export const apiTokens = sqliteTable('api_tokens', { + id: integer('id').primaryKey({ autoIncrement: true }), + userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + tokenHash: text('token_hash').notNull().unique(), + tokenPrefix: text('token_prefix').notNull(), // First 8 chars after 'dh_' for UI identification + lastUsed: text('last_used'), + expiresAt: text('expires_at'), // NULL = no expiration + isActive: integer('is_active', { mode: 'boolean' }).default(true), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) +}, (table) => ({ + userIdIdx: index('api_tokens_user_id_idx').on(table.userId), + tokenPrefixIdx: index('api_tokens_token_prefix_idx').on(table.tokenPrefix) +})); + // ============================================================================= // USER PREFERENCES TABLE (unified key-value store) // ============================================================================= @@ -567,3 +587,6 @@ export type NewStackEnvironmentVariable = typeof stackEnvironmentVariables.$infe export type PendingContainerUpdate = typeof pendingContainerUpdates.$inferSelect; export type NewPendingContainerUpdate = typeof pendingContainerUpdates.$inferInsert; + +export type ApiToken = typeof apiTokens.$inferSelect; +export type NewApiToken = typeof apiTokens.$inferInsert; diff --git a/src/lib/server/db/schema/pg-schema.ts b/src/lib/server/db/schema/pg-schema.ts index 6c08051..66c3d1b 100644 --- a/src/lib/server/db/schema/pg-schema.ts +++ b/src/lib/server/db/schema/pg-schema.ts @@ -467,6 +467,26 @@ export const pendingContainerUpdates = pgTable('pending_container_updates', { envContainerUnique: unique().on(table.environmentId, table.containerId) })); +// ============================================================================= +// API TOKEN TABLES +// ============================================================================= + +export const apiTokens = pgTable('api_tokens', { + id: serial('id').primaryKey(), + userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + tokenHash: text('token_hash').notNull().unique(), + tokenPrefix: text('token_prefix').notNull(), // First 8 chars after 'dh_' for UI identification + lastUsed: timestamp('last_used', { mode: 'string' }), + expiresAt: timestamp('expires_at', { mode: 'string' }), // NULL = no expiration + isActive: boolean('is_active').default(true), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), + updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() +}, (table) => ({ + userIdIdx: index('api_tokens_user_id_idx').on(table.userId), + tokenPrefixIdx: index('api_tokens_token_prefix_idx').on(table.tokenPrefix) +})); + // ============================================================================= // USER PREFERENCES TABLE (unified key-value store) // ============================================================================= diff --git a/src/routes/api/auth/tokens/+server.ts b/src/routes/api/auth/tokens/+server.ts new file mode 100644 index 0000000..24404f2 --- /dev/null +++ b/src/routes/api/auth/tokens/+server.ts @@ -0,0 +1,111 @@ +/** + * API Token CRUD Endpoints + * + * GET /api/auth/tokens — List own tokens + * POST /api/auth/tokens — Create new token + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { authorize } from '$lib/server/authorize'; +import { generateApiToken, listUserApiTokens } from '$lib/server/api-tokens'; +import { audit } from '$lib/server/audit'; + +/** + * GET /api/auth/tokens — List own tokens + */ +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + + if (!auth.authEnabled) { + return json({ error: 'Authentication is not enabled' }, { status: 400 }); + } + + if (!auth.isAuthenticated || !auth.user) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + + try { + const tokens = await listUserApiTokens(auth.user.id); + return json({ tokens }); + } catch (error) { + console.error('[ApiToken] Error listing tokens:', error); + return json({ error: 'Failed to list tokens' }, { status: 500 }); + } +}; + +/** + * POST /api/auth/tokens — Create new token + */ +export const POST: RequestHandler = async (event) => { + const { cookies, request } = event; + const auth = await authorize(cookies); + + if (!auth.authEnabled) { + return json({ error: 'Authentication is not enabled' }, { status: 400 }); + } + + if (!auth.isAuthenticated || !auth.user) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + + let body: { name?: string; expiresAt?: string | null }; + try { + body = await request.json(); + } catch { + return json({ error: 'Invalid request body' }, { status: 400 }); + } + + const { name, expiresAt } = body; + + // Validation + if (!name || typeof name !== 'string' || name.trim().length === 0) { + return json({ error: 'Name is required' }, { status: 400 }); + } + + if (name.length > 255) { + return json({ error: 'Name too long (max 255 characters)' }, { status: 400 }); + } + + if (expiresAt !== undefined && expiresAt !== null) { + const expDate = new Date(expiresAt); + if (isNaN(expDate.getTime())) { + return json({ error: 'Invalid date for expiresAt' }, { status: 400 }); + } + if (expDate <= new Date()) { + return json({ error: 'expiresAt must be in the future' }, { status: 400 }); + } + } + + try { + const result = await generateApiToken( + auth.user.id, + name.trim(), + expiresAt ?? null + ); + + // Audit log (entity is the user, token details in description) + await audit(event, 'create', 'user', { + entityId: String(auth.user.id), + entityName: auth.user.username, + description: `API token "${name.trim()}" created (prefix: ${result.tokenPrefix})`, + details: { tokenId: result.tokenId, tokenPrefix: result.tokenPrefix, expiresAt: expiresAt ?? 'never' } + }); + + return json( + { + token: result.token, + id: result.tokenId, + name: name.trim(), + tokenPrefix: result.tokenPrefix, + expiresAt: expiresAt ?? null, + createdAt: new Date().toISOString(), + warning: 'This token will not be shown again. Please save it now.' + }, + { status: 201 } + ); + } catch (error) { + console.error('[ApiToken] Error creating token:', error); + return json({ error: 'Failed to create token' }, { status: 500 }); + } +}; diff --git a/src/routes/api/auth/tokens/[id]/+server.ts b/src/routes/api/auth/tokens/[id]/+server.ts new file mode 100644 index 0000000..7f7d0eb --- /dev/null +++ b/src/routes/api/auth/tokens/[id]/+server.ts @@ -0,0 +1,53 @@ +/** + * API Token Revoke Endpoint + * + * DELETE /api/auth/tokens/{id} — Revoke token + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { authorize } from '$lib/server/authorize'; +import { revokeApiToken } from '$lib/server/api-tokens'; +import { audit } from '$lib/server/audit'; + +/** + * DELETE /api/auth/tokens/{id} + */ +export const DELETE: RequestHandler = async (event) => { + const { cookies, params } = event; + const auth = await authorize(cookies); + + if (!auth.authEnabled) { + return json({ error: 'Authentication is not enabled' }, { status: 400 }); + } + + if (!auth.isAuthenticated || !auth.user) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + + const tokenId = parseInt(params.id, 10); + if (isNaN(tokenId) || tokenId <= 0) { + return json({ error: 'Invalid token ID' }, { status: 400 }); + } + + try { + const success = await revokeApiToken(tokenId, auth.user.id, auth.isAdmin); + + if (!success) { + return json({ error: 'Token not found or access denied' }, { status: 404 }); + } + + // Audit log (entity is the user, token details in description) + await audit(event, 'delete', 'user', { + entityId: String(auth.user.id), + entityName: auth.user.username, + description: `API token ${tokenId} revoked by ${auth.user.username}`, + details: { tokenId, revokedBy: auth.user.id } + }); + + return json({ success: true }); + } catch (error) { + console.error('[ApiToken] Error revoking token:', error); + return json({ error: 'Failed to revoke token' }, { status: 500 }); + } +}; diff --git a/tests/api-tokens.test.ts b/tests/api-tokens.test.ts new file mode 100644 index 0000000..e4cc436 --- /dev/null +++ b/tests/api-tokens.test.ts @@ -0,0 +1,222 @@ +/** + * API Token Authentication Tests + * + * Tests for token generation, validation, revocation, and authorize() integration. + * Uses Bun's built-in test runner. + */ + +import { describe, test, expect, mock, beforeEach } from 'bun:test'; + +// ============================================================================ +// Test constants +// ============================================================================ + +const TOKEN_PREFIX = 'dh_'; +const TOKEN_REGEX = /^dh_[A-Za-z0-9_-]{43}$/; // dh_ + 32 bytes base64url = 43 chars + +// ============================================================================ +// Token Format Tests (unit, no DB required) +// ============================================================================ + +describe('API Token Format', () => { + test('token prefix is dh_', () => { + expect(TOKEN_PREFIX).toBe('dh_'); + }); + + test('token regex matches expected format', () => { + // Valid tokens + expect(TOKEN_REGEX.test('dh_a8Kj2mNp9xRt4vWq7yBz1cDe3fGh5iJk6lMn0oP1qRs')).toBe(true); + expect(TOKEN_REGEX.test('dh_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')).toBe(true); + expect(TOKEN_REGEX.test('dh_000000000000000000000000000000000000000000_')).toBe(true); + expect(TOKEN_REGEX.test('dh_abcdefghijklmnopqrstuvwxyz-ABCDEFGHIJKLMNOP')).toBe(true); + + // Invalid tokens + expect(TOKEN_REGEX.test('not_a_token')).toBe(false); + expect(TOKEN_REGEX.test('dh_tooshort')).toBe(false); + expect(TOKEN_REGEX.test('Bearer dh_something')).toBe(false); + expect(TOKEN_REGEX.test('')).toBe(false); + }); + + test('token prefix extraction works correctly', () => { + const token = 'dh_a8Kj2mNp9xRt4vWq7yBz1cDe3fGh5iJk6lMn0oP1qRs'; + const prefix = token.substring(3, 11); // 8 chars after 'dh_' + expect(prefix).toBe('a8Kj2mNp'); + expect(prefix.length).toBe(8); + }); +}); + +// ============================================================================ +// Authorization Header Parsing Tests (unit, no DB required) +// ============================================================================ + +describe('Authorization Header Parsing', () => { + test('extracts Bearer token from header', () => { + const header = 'Bearer dh_a8Kj2mNp9xRt4vWq7yBz1cDe3fGh5iJk6lMn0oP1qRs'; + expect(header.startsWith('Bearer ')).toBe(true); + const token = header.substring(7).trim(); + expect(token.startsWith('dh_')).toBe(true); + }); + + test('rejects non-Bearer auth headers', () => { + const header = 'Basic dXNlcjpwYXNz'; + expect(header.startsWith('Bearer ')).toBe(false); + }); + + test('rejects empty Bearer token', () => { + const header = 'Bearer '; + const token = header.substring(7).trim(); + expect(token).toBe(''); + expect(token.startsWith('dh_')).toBe(false); + }); + + test('rejects Bearer token without dh_ prefix', () => { + const header = 'Bearer some_random_token'; + const token = header.substring(7).trim(); + expect(token.startsWith('dh_')).toBe(false); + }); +}); + +// ============================================================================ +// Token Expiration Logic Tests (unit, no DB required) +// ============================================================================ + +describe('Token Expiration Logic', () => { + test('null expiresAt means no expiration', () => { + const expiresAt = null; + const isExpired = expiresAt !== null && new Date(expiresAt) < new Date(); + expect(isExpired).toBe(false); + }); + + test('past date is expired', () => { + const expiresAt = '2020-01-01T00:00:00Z'; + const isExpired = new Date(expiresAt) < new Date(); + expect(isExpired).toBe(true); + }); + + test('future date is not expired', () => { + const expiresAt = '2099-01-01T00:00:00Z'; + const isExpired = new Date(expiresAt) < new Date(); + expect(isExpired).toBe(false); + }); +}); + +// ============================================================================ +// Token Ownership / Access Control Tests (unit, no DB required) +// ============================================================================ + +describe('Token Access Control', () => { + test('owner can revoke own token', () => { + const tokenUserId = 42; + const requestUserId = 42; + const isAdmin = false; + const canRevoke = tokenUserId === requestUserId || isAdmin; + expect(canRevoke).toBe(true); + }); + + test('admin can revoke any token', () => { + const tokenUserId = 42; + const requestUserId = 99; + const isAdmin = true; + const canRevoke = tokenUserId === requestUserId || isAdmin; + expect(canRevoke).toBe(true); + }); + + test('non-owner non-admin cannot revoke', () => { + const tokenUserId = 42; + const requestUserId = 99; + const isAdmin = false; + const canRevoke = tokenUserId === requestUserId || isAdmin; + expect(canRevoke).toBe(false); + }); +}); + +// ============================================================================ +// Input Validation Tests (unit, no DB required) +// ============================================================================ + +describe('Token Creation Validation', () => { + test('rejects empty name', () => { + const name = ''; + const isValid = name && typeof name === 'string' && name.trim().length > 0; + expect(isValid).toBeFalsy(); + }); + + test('rejects null name', () => { + const name = null; + const isValid = name && typeof name === 'string' && (name as string).trim().length > 0; + expect(isValid).toBeFalsy(); + }); + + test('accepts valid name', () => { + const name = 'CI/CD Pipeline'; + const isValid = name && typeof name === 'string' && name.trim().length > 0; + expect(isValid).toBeTruthy(); + }); + + test('rejects name longer than 255 chars', () => { + const name = 'a'.repeat(256); + const isValid = name.length <= 255; + expect(isValid).toBe(false); + }); + + test('rejects expiresAt in the past', () => { + const expiresAt = '2020-01-01T00:00:00Z'; + const expDate = new Date(expiresAt); + const isValid = !isNaN(expDate.getTime()) && expDate > new Date(); + expect(isValid).toBe(false); + }); + + test('accepts expiresAt in the future', () => { + const expiresAt = '2099-12-31T23:59:59Z'; + const expDate = new Date(expiresAt); + const isValid = !isNaN(expDate.getTime()) && expDate > new Date(); + expect(isValid).toBe(true); + }); + + test('accepts null expiresAt (no expiration)', () => { + const expiresAt = null; + // null means no expiration, which is valid + expect(expiresAt === null || expiresAt === undefined).toBe(true); + }); + + test('rejects invalid date string', () => { + const expiresAt = 'not-a-date'; + const expDate = new Date(expiresAt); + expect(isNaN(expDate.getTime())).toBe(true); + }); +}); + +// ============================================================================ +// Token Prefix Collision Safety Tests +// ============================================================================ + +describe('Token Prefix Properties', () => { + test('prefix is exactly 8 characters', () => { + // Simulate multiple tokens and verify prefix extraction + const tokens = [ + 'dh_ABCDEFGHijklmnopqrstuvwxyz012345678901234', + 'dh_12345678abcdefghijklmnopqrstuvwxyz0123456', + 'dh_zzzzzzzzAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ]; + + for (const token of tokens) { + const prefix = token.substring(3, 11); + expect(prefix.length).toBe(8); + } + }); + + test('different tokens have different prefixes (with high probability)', () => { + // This tests the principle, not actual crypto randomness + const prefixes = new Set(); + const samplePrefixes = [ + 'ABCDEFGH', 'IJKLMNOP', 'QRSTUVWX', '12345678', + 'abcdefgh', 'ijklmnop', 'qrstuvwx', '98765432' + ]; + + for (const prefix of samplePrefixes) { + prefixes.add(prefix); + } + + expect(prefixes.size).toBe(samplePrefixes.length); + }); +});