Merge branch 'cli-improvements-1' with @reviq/utils password hashing

- Use executeBootstrap helper from @reviq/db for CLI bootstrap
- Update @reviq/db to use @reviq/utils for PBKDF2-SHA256 password hashing
  (Cloudflare Workers compatible)
- Keep @scure/base for base58 token encoding
- Remove redundant password.ts from @reviq/db (import directly from @reviq/utils)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
RevIQ
2026-01-09 18:17:45 +08:00
19 changed files with 785 additions and 154 deletions

View File

@@ -21,6 +21,7 @@
"@reviq/db": "workspace:*",
"@reviq/db-schema": "workspace:*",
"@reviq/utils": "workspace:*",
"@scure/base": "^2.0.0",
"@simplewebauthn/server": "^13.2.2",
"@simplewebauthn/types": "^12.0.0",
"kysely": "^0.28.2",

View File

@@ -61,6 +61,7 @@ function createAuthenticatedContext(
userId: number,
email: string,
): AuthenticatedContext {
const now = new Date();
return {
...createAPIContext(),
user: {
@@ -73,7 +74,13 @@ function createAuthenticatedContext(
session: {
id: "1",
trustedMode: false,
createdAt: new Date(),
createdAt: now,
},
auth: {
method: "session",
sessionId: "1",
expiresAt: new Date(now.getTime() + 24 * 60 * 60 * 1000),
createdAt: now,
},
};
}

View File

@@ -44,6 +44,33 @@ export interface Session {
createdAt: Date;
}
/**
* API token authentication info
*/
export interface ApiTokenAuth {
method: "api_token";
tokenId: string;
tokenName: string;
expiresAt: Date;
lastUsedAt: Date | null;
createdAt: Date;
}
/**
* Session authentication info
*/
export interface SessionAuth {
method: "session";
sessionId: string;
expiresAt: Date;
createdAt: Date;
}
/**
* Union type for authentication method info
*/
export type AuthInfo = ApiTokenAuth | SessionAuth;
/**
* Authenticated API context for protected handlers
*/
@@ -52,6 +79,8 @@ export interface AuthenticatedContext extends APIContext {
user: SessionUser;
/** Current session */
session: Session;
/** Authentication method and details */
auth: AuthInfo;
}
/**

View File

@@ -9,6 +9,7 @@
import type {
APIContext,
AuthenticatedContext,
AuthInfo,
Session,
SessionUser,
} from "../context.js";
@@ -111,24 +112,49 @@ export const createAuthMiddleware = () => {
isSuperuser: user.is_superuser,
};
const sessionInfo: Session = session
? {
id: session.id,
trustedMode: session.trusted_mode,
createdAt: session.created_at,
}
: {
// For API token auth, create a synthetic session object
// We know apiToken exists because userId came from it
id: "0",
trustedMode: true,
createdAt: apiToken?.created_at ?? new Date(),
};
// Build session and auth info based on authentication method
let sessionInfo: Session;
let authInfo: AuthInfo;
if (session) {
sessionInfo = {
id: session.id,
trustedMode: session.trusted_mode,
createdAt: session.created_at,
};
authInfo = {
method: "session",
sessionId: session.id,
expiresAt: session.expires_at,
createdAt: session.created_at,
};
} else if (apiToken) {
sessionInfo = {
// For API token auth, create a synthetic session object
id: "0",
trustedMode: true,
createdAt: apiToken.created_at,
};
authInfo = {
method: "api_token",
tokenId: apiToken.id,
tokenName: apiToken.name,
expiresAt: apiToken.expires_at,
lastUsedAt: apiToken.last_used_at,
createdAt: apiToken.created_at,
};
} else {
// This should never happen since we checked userId above
throw new ORPCError("UNAUTHORIZED", {
message: "Invalid authentication state",
});
}
return next({
context: {
user: sessionUser,
session: sessionInfo,
auth: authInfo,
},
});
};

View File

@@ -8,6 +8,7 @@
import type {
APIContext,
AuthenticatedContext,
AuthInfo,
LoginRequestContext,
Session,
SessionUser,
@@ -109,23 +110,49 @@ export const authMiddleware = os.middleware(async ({ context, next }) => {
isSuperuser: user.is_superuser,
};
const sessionInfo: Session = session
? {
id: session.id,
trustedMode: session.trusted_mode,
createdAt: session.created_at,
}
: {
// For API token auth, create a synthetic session object
id: "0",
trustedMode: true,
createdAt: apiToken?.created_at ?? new Date(),
};
// Build session and auth info based on authentication method
let sessionInfo: Session;
let authInfo: AuthInfo;
if (session) {
sessionInfo = {
id: session.id,
trustedMode: session.trusted_mode,
createdAt: session.created_at,
};
authInfo = {
method: "session",
sessionId: session.id,
expiresAt: session.expires_at,
createdAt: session.created_at,
};
} else if (apiToken) {
sessionInfo = {
// For API token auth, create a synthetic session object
id: "0",
trustedMode: true,
createdAt: apiToken.created_at,
};
authInfo = {
method: "api_token",
tokenId: apiToken.id,
tokenName: apiToken.name,
expiresAt: apiToken.expires_at,
lastUsedAt: apiToken.last_used_at,
createdAt: apiToken.created_at,
};
} else {
// This should never happen since we checked userId above
throw new ORPCError("UNAUTHORIZED", {
message: "Invalid authentication state",
});
}
return next({
context: {
user: sessionUser,
session: sessionInfo,
auth: authInfo,
},
});
});

View File

@@ -187,6 +187,40 @@ const meGet = os.me.get.use(authMiddleware).handler(async ({ context }) => {
};
});
const meAuthStatus = os.me.authStatus
.use(authMiddleware)
.handler(async ({ context }) => {
const user = await context.db
.selectFrom("users")
.select([
"id",
"email",
"display_name",
"full_name",
"phone_number",
"avatar_url",
"email_verified_at",
"is_superuser",
])
.where("id", "=", context.user.id)
.executeTakeFirstOrThrow();
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
fullName: user.full_name,
phoneNumber: user.phone_number,
avatarUrl: user.avatar_url,
emailVerified: user.email_verified_at !== null,
needsSetup: user.display_name === null,
isSuperuser: user.is_superuser,
},
auth: context.auth,
};
});
const setupProfile = os.me.setupProfile
.use(authMiddleware)
.handler(async ({ input, context }) => {
@@ -238,6 +272,7 @@ export const router = os.router({
},
me: {
get: meGet,
authStatus: meAuthStatus,
setupProfile,
updateProfile,
delete: meDelete,

View File

@@ -1,3 +1,10 @@
import { base58 } from "@scure/base";
/**
* Token prefix for all RevIQ API tokens
*/
export const TOKEN_PREFIX = "reviq_";
/**
* Hash a token with SHA-256 for storage in database
* Never store raw tokens - always hash first
@@ -13,6 +20,34 @@ export const hashToken = async (token: string): Promise<string> => {
.join("");
};
/**
* Validate that a token has the correct format
* Returns the raw bytes if valid, null if invalid
*/
export const parseToken = (token: string): Uint8Array | null => {
if (!token.startsWith(TOKEN_PREFIX)) {
return null;
}
const encoded = token.slice(TOKEN_PREFIX.length);
try {
const bytes = base58.decode(encoded);
// Expect 32 bytes of entropy
if (bytes.length !== 32) {
return null;
}
return bytes;
} catch {
return null;
}
};
/**
* Check if a token has the valid reviq_ prefix format
*/
export const isValidTokenFormat = (token: string): boolean => {
return parseToken(token) !== null;
};
/**
* Generate a session token (UUID v4)
*/