Compare commits

...

5 Commits

Author SHA1 Message Date
6eded27121 feat[cli]: add run script for cli 2025-10-20 00:18:33 +00:00
0307cbbf61 style[convex]: reorganize imports 2025-10-20 00:17:50 +00:00
d0893e13be feat[convex]: api key auth support 2025-10-20 00:17:41 +00:00
a4544a3f09 feat[cli]: new admin cli
new admin cli for general admin task. only supports api key generation
for now
2025-10-20 00:15:42 +00:00
14e2ee1e28 feat[auth]: custom hasher & api key validation
Co-authored-by: Ona <no-reply@ona.com>
2025-10-20 00:14:50 +00:00
20 changed files with 571 additions and 21 deletions

60
apps/cli/README.md Normal file
View File

@@ -0,0 +1,60 @@
# @drexa/cli
Admin CLI tool for managing Drexa resources.
## Usage
From the project root:
```bash
bun drexa <command> [subcommand] [options]
```
## Commands
### `generate apikey`
Generate a new API key for authentication.
```bash
bun drexa generate apikey
```
The command will interactively prompt you for (using Node.js readline):
- **Prefix**: A short identifier for the key (e.g., 'proxy', 'admin'). Cannot contain dashes.
- **Key byte length**: Length of the key in bytes (default: 32)
- **Description**: A description of what this key is for
- **Expiration date**: Optional expiration date in YYYY-MM-DD format
The command will output:
- **Unhashed key**: Save this securely - it won't be shown again
- **Hashed key**: Store this in your database
- **Description**: The description you provided
- **Expiration date**: When the key expires (if set)
## Development
Run the CLI directly:
```bash
bun run apps/cli/index.ts <command>
```
## Project Structure
```
apps/cli/
├── index.ts # Main entry point
├── prompts.ts # Interactive prompt utilities
└── commands/ # Command structure mirrors CLI structure
└── generate/
├── index.ts # Generate command group
└── apikey.ts # API key generation command
```
## Adding New Commands
1. Create a new directory under `commands/` for command groups
2. Create command files following the pattern in `commands/generate/apikey.ts`
3. Export commands from an `index.ts` in the command group directory
4. Register the command group in the main `index.ts`

View File

@@ -0,0 +1,71 @@
import { generateApiKey, newPrefix } from "@drexa/auth"
import chalk from "chalk"
import { Command } from "commander"
import {
promptNumber,
promptOptionalDate,
promptText,
} from "../../prompts.ts"
export const apikeyCommand = new Command("apikey")
.description("Generate a new API key")
.action(async () => {
console.log(chalk.bold.blue("\n🔑 Generate API Key\n"))
// Prompt for all required information
const prefixInput = await promptText(
"Enter API key prefix (e.g., 'proxy', 'admin'):",
)
const prefix = newPrefix(prefixInput)
if (!prefix) {
console.error(
chalk.red(
'✗ Invalid prefix: cannot contain "-" character. Please use alphanumeric characters only.',
),
)
process.exit(1)
}
const keyByteLength = await promptNumber("Enter key byte length:", 32)
const description = await promptText("Enter description:")
const expiresAt = await promptOptionalDate("Enter expiration date")
console.log(chalk.dim("\n⏳ Generating API key...\n"))
// Generate the API key
const result = await generateApiKey({
prefix,
keyByteLength,
description,
expiresAt,
})
// Display results
console.log(chalk.green.bold("✓ API Key Generated Successfully!\n"))
console.log(chalk.gray("─".repeat(60)))
console.log(
chalk.yellow.bold(
"\n⚠ IMPORTANT: Save the unhashed key now. It won't be shown again!\n",
),
)
console.log(chalk.bold("Unhashed Key ") + chalk.dim("(save this):"))
console.log(chalk.green(` ${result.unhashedKey}\n`))
console.log(chalk.gray("─".repeat(60)))
console.log(
chalk.bold("\nHashed Key ") + chalk.dim("(store this in your database):"),
)
console.log(chalk.dim(` ${result.hashedKey}\n`))
console.log(chalk.bold("Description:"))
console.log(chalk.white(` ${result.description}\n`))
if (result.expiresAt) {
console.log(chalk.bold("Expires At:"))
console.log(chalk.yellow(` ${result.expiresAt.toISOString()}\n`))
} else {
console.log(chalk.bold("Expires At:"))
console.log(chalk.dim(" Never\n"))
}
console.log(chalk.gray("─".repeat(60)) + "\n")
})

View File

@@ -0,0 +1,6 @@
import { Command } from "commander"
import { apikeyCommand } from "./apikey.ts"
export const generateCommand = new Command("generate")
.description("Generate various resources")
.addCommand(apikeyCommand)

17
apps/cli/index.ts Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bun
import { Command } from "commander"
import { generateCommand } from "./commands/generate/index.ts"
const program = new Command()
program
.name("drexa")
.description("Drexa CLI - Admin tools for managing Drexa resources")
.version("0.1.0")
// Register command groups
program.addCommand(generateCommand)
// Parse command line arguments
program.parse()

23
apps/cli/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "@drexa/cli",
"version": "0.1.0",
"private": true,
"type": "module",
"bin": {
"drexa": "./index.ts"
},
"scripts": {
"cli": "bun run index.ts"
},
"dependencies": {
"@drexa/auth": "workspace:*",
"chalk": "^5.3.0",
"commander": "^12.1.0"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
}
}

106
apps/cli/prompts.ts Normal file
View File

@@ -0,0 +1,106 @@
import * as readline from "node:readline/promises"
import chalk from "chalk"
function createReadlineInterface() {
return readline.createInterface({
input: process.stdin,
output: process.stdout,
})
}
export async function promptText(message: string): Promise<string> {
const rl = createReadlineInterface()
try {
const input = await rl.question(chalk.cyan(`${message} `))
if (!input || input.trim() === "") {
console.error(chalk.red("✗ Input is required"))
process.exit(1)
}
return input.trim()
} finally {
rl.close()
}
}
export async function promptNumber(
message: string,
defaultValue?: number,
): Promise<number> {
const rl = createReadlineInterface()
try {
const defaultStr = defaultValue ? chalk.dim(` (default: ${defaultValue})`) : ""
const input = await rl.question(chalk.cyan(`${message}${defaultStr} `))
if ((!input || input.trim() === "") && defaultValue !== undefined) {
return defaultValue
}
if (!input || input.trim() === "") {
console.error(chalk.red("✗ Input is required"))
process.exit(1)
}
const num = Number.parseInt(input.trim(), 10)
if (Number.isNaN(num) || num <= 0) {
console.error(chalk.red("✗ Please enter a valid positive number"))
process.exit(1)
}
return num
} finally {
rl.close()
}
}
export async function promptOptionalDate(
message: string,
): Promise<Date | undefined> {
const rl = createReadlineInterface()
try {
const input = await rl.question(
chalk.cyan(`${message} `) + chalk.dim("(optional, format: YYYY-MM-DD) "),
)
if (!input || input.trim() === "") {
return undefined
}
const date = new Date(input.trim())
if (Number.isNaN(date.getTime())) {
console.error(chalk.red("✗ Invalid date format. Please use YYYY-MM-DD"))
process.exit(1)
}
if (date < new Date()) {
console.error(chalk.red("✗ Expiration date must be in the future"))
process.exit(1)
}
return date
} finally {
rl.close()
}
}
export async function promptConfirm(
message: string,
defaultValue = false,
): Promise<boolean> {
const rl = createReadlineInterface()
try {
const defaultStr = defaultValue
? chalk.dim(" (Y/n)")
: chalk.dim(" (y/N)")
const input = await rl.question(chalk.cyan(`${message}${defaultStr} `))
if (!input || input.trim() === "") {
return defaultValue
}
const normalized = input.toLowerCase().trim()
return normalized === "y" || normalized === "yes"
} finally {
rl.close()
}
}

28
apps/cli/tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

View File

@@ -12,6 +12,24 @@
"convex": "^1.27.0", "convex": "^1.27.0",
}, },
}, },
"apps/cli": {
"name": "@drexa/cli",
"version": "0.1.0",
"bin": {
"drexa": "./index.ts",
},
"dependencies": {
"@drexa/auth": "workspace:*",
"chalk": "^5.3.0",
"commander": "^12.1.0",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
"apps/drive-web": { "apps/drive-web": {
"name": "@fileone/web", "name": "@fileone/web",
"version": "0.1.0", "version": "0.1.0",
@@ -87,7 +105,9 @@
"packages/convex": { "packages/convex": {
"name": "@fileone/convex", "name": "@fileone/convex",
"dependencies": { "dependencies": {
"@drexa/auth": "workspace:*",
"@fileone/path": "workspace:*", "@fileone/path": "workspace:*",
"hash-wasm": "^4.12.0",
}, },
"peerDependencies": { "peerDependencies": {
"@convex-dev/better-auth": "^0.8.9", "@convex-dev/better-auth": "^0.8.9",
@@ -194,6 +214,8 @@
"@drexa/auth": ["@drexa/auth@workspace:packages/auth"], "@drexa/auth": ["@drexa/auth@workspace:packages/auth"],
"@drexa/cli": ["@drexa/cli@workspace:apps/cli"],
"@drexa/file-proxy": ["@drexa/file-proxy@workspace:apps/file-proxy"], "@drexa/file-proxy": ["@drexa/file-proxy@workspace:apps/file-proxy"],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
@@ -542,6 +564,8 @@
"caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="], "caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
@@ -556,6 +580,8 @@
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
"common-tags": ["common-tags@1.8.2", "", {}, "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA=="], "common-tags": ["common-tags@1.8.2", "", {}, "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
@@ -612,6 +638,8 @@
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"hash-wasm": ["hash-wasm@4.12.0", "", {}, "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ=="],
"hono": ["hono@4.10.1", "", {}, "sha512-rpGNOfacO4WEPClfkEt1yfl8cbu10uB1lNpiI33AKoiAHwOS8lV748JiLx4b5ozO/u4qLjIvfpFsPXdY5Qjkmg=="], "hono": ["hono@4.10.1", "", {}, "sha512-rpGNOfacO4WEPClfkEt1yfl8cbu10uB1lNpiI33AKoiAHwOS8lV748JiLx4b5ozO/u4qLjIvfpFsPXdY5Qjkmg=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],

View File

@@ -10,7 +10,8 @@
"scripts": { "scripts": {
"dev": "bun run --filter=@fileone/web dev", "dev": "bun run --filter=@fileone/web dev",
"build": "bun run --filter=@fileone/web build", "build": "bun run --filter=@fileone/web build",
"preview": "bun run --filter=@fileone/web preview" "preview": "bun run --filter=@fileone/web preview",
"drexa": "bun run apps/cli/index.ts"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.4", "@biomejs/biome": "2.2.4",

89
packages/auth/hasher.ts Normal file
View File

@@ -0,0 +1,89 @@
export interface PassswordHasher {
hash(password: string): Promise<string>
verify(password: string, hash: string): Promise<boolean>
}
export class BunSha256Hasher implements PassswordHasher {
async hash(password: string): Promise<string> {
const hasher = new Bun.CryptoHasher("sha256")
hasher.update(password)
return hasher.digest("base64url")
}
async verify(password: string, hash: string): Promise<boolean> {
const hasher = new Bun.CryptoHasher("sha256")
hasher.update(password)
const passwordHash = hasher.digest()
const hashBytes = Buffer.from(hash, "base64url")
if (passwordHash.byteLength !== hashBytes.byteLength) {
return false
}
return crypto.timingSafeEqual(passwordHash, hashBytes)
}
}
export class WebCryptoSha256Hasher implements PassswordHasher {
async hash(password: string): Promise<string> {
const hash = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(password),
)
return this.arrayBufferToBase64url(hash)
}
async verify(password: string, hash: string): Promise<boolean> {
const passwordHash = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(password),
)
const hashBytes = this.base64urlToArrayBuffer(hash)
if (passwordHash.byteLength !== hashBytes.byteLength) {
return false
}
// Timing-safe comparison
const passwordHashBytes = new Uint8Array(passwordHash)
let result = 0
for (let i = 0; i < passwordHashBytes.length; i++) {
result |= passwordHashBytes[i]! ^ hashBytes[i]!
}
return result === 0
}
private arrayBufferToBase64url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ""
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]!)
}
return btoa(binary).replace(/[+/=]/g, (char) => {
switch (char) {
case "+": return "-"
case "/": return "_"
case "=": return ""
default: return char
}
})
}
private base64urlToArrayBuffer(base64url: string): Uint8Array {
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/")
const padded = base64.padEnd(
base64.length + ((4 - (base64.length % 4)) % 4),
"=",
)
const binary = atob(padded)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
}

View File

@@ -1,3 +1,7 @@
import { BunSha256Hasher, type PassswordHasher } from "./hasher"
export { WebCryptoSha256Hasher } from "./hasher"
/** /**
* An unhashed api key. * An unhashed api key.
* Always starts with sk, then the prefix specified at time of generation, * Always starts with sk, then the prefix specified at time of generation,
@@ -7,6 +11,11 @@ export type UnhashedApiKey = `sk-${ApiKeyPrefix}-${string}`
export type ApiKeyPrefix = string & { __brand: "ApiKeyPrefix" } export type ApiKeyPrefix = string & { __brand: "ApiKeyPrefix" }
export type ParsedApiKey = {
prefix: ApiKeyPrefix
unhashedKey: UnhashedApiKey
}
export type GenerateApiKeyOptions = { export type GenerateApiKeyOptions = {
/** /**
* How long the key should be (excluding prefix) in bytes. * How long the key should be (excluding prefix) in bytes.
@@ -21,6 +30,8 @@ export type GenerateApiKeyOptions = {
expiresAt?: Date expiresAt?: Date
description: string description: string
hasher?: PassswordHasher
} }
export type GenerateApiKeyResult = { export type GenerateApiKeyResult = {
@@ -30,11 +41,21 @@ export type GenerateApiKeyResult = {
description: string description: string
} }
export type VerifyApiKeyOptions = {
keyToBeVerified: UnhashedApiKey
hashedKey: string
hasher?: PassswordHasher
}
function validatePrefix(prefix: string): prefix is ApiKeyPrefix {
return !prefix.includes("-")
}
export function newPrefix(prefix: string): ApiKeyPrefix | null { export function newPrefix(prefix: string): ApiKeyPrefix | null {
if (prefix.includes("-")) { if (!validatePrefix(prefix)) {
return null return null
} }
return prefix as ApiKeyPrefix return prefix
} }
export async function generateApiKey({ export async function generateApiKey({
@@ -42,6 +63,7 @@ export async function generateApiKey({
prefix, prefix,
expiresAt, expiresAt,
description, description,
hasher = new BunSha256Hasher(),
}: GenerateApiKeyOptions): Promise<GenerateApiKeyResult> { }: GenerateApiKeyOptions): Promise<GenerateApiKeyResult> {
const keyContent = new Uint8Array(keyByteLength) const keyContent = new Uint8Array(keyByteLength)
crypto.getRandomValues(keyContent) crypto.getRandomValues(keyContent)
@@ -49,11 +71,7 @@ export async function generateApiKey({
const base64KeyContent = Buffer.from(keyContent).toString("base64url") const base64KeyContent = Buffer.from(keyContent).toString("base64url")
const unhashedKey: UnhashedApiKey = `sk-${prefix}-${base64KeyContent}` const unhashedKey: UnhashedApiKey = `sk-${prefix}-${base64KeyContent}`
const hashedKey = await Bun.password.hash(unhashedKey, { const hashedKey = await hasher.hash(unhashedKey)
algorithm: "argon2id",
memoryCost: 4, // memory usage in kibibytes
timeCost: 3, // the number of iterations
})
return { return {
unhashedKey, unhashedKey,
@@ -62,3 +80,29 @@ export async function generateApiKey({
description, description,
} }
} }
export function parseApiKey(key: string): ParsedApiKey | null {
if (!key.startsWith("sk-")) {
return null
}
const parts = key.split("-")
if (parts.length !== 3) {
return null
}
const prefix = parts[1]
if (!prefix || !validatePrefix(prefix)) {
return null
}
return {
prefix,
unhashedKey: key as UnhashedApiKey,
}
}
export async function verifyApiKey({
keyToBeVerified,
hashedKey,
hasher = new BunSha256Hasher(),
}: VerifyApiKeyOptions): Promise<boolean> {
return await hasher.verify(keyToBeVerified, hashedKey)
}

View File

@@ -8,6 +8,7 @@
* @module * @module
*/ */
import type * as apikey from "../apikey.js";
import type * as auth from "../auth.js"; import type * as auth from "../auth.js";
import type * as betterauth__generated_api from "../betterauth/_generated/api.js"; import type * as betterauth__generated_api from "../betterauth/_generated/api.js";
import type * as betterauth__generated_server from "../betterauth/_generated/server.js"; import type * as betterauth__generated_server from "../betterauth/_generated/server.js";
@@ -19,6 +20,7 @@ import type * as files from "../files.js";
import type * as filesystem from "../filesystem.js"; import type * as filesystem from "../filesystem.js";
import type * as functions from "../functions.js"; import type * as functions from "../functions.js";
import type * as http from "../http.js"; import type * as http from "../http.js";
import type * as model_apikey from "../model/apikey.js";
import type * as model_directories from "../model/directories.js"; import type * as model_directories from "../model/directories.js";
import type * as model_files from "../model/files.js"; import type * as model_files from "../model/files.js";
import type * as model_filesystem from "../model/filesystem.js"; import type * as model_filesystem from "../model/filesystem.js";
@@ -43,6 +45,7 @@ import type {
* ``` * ```
*/ */
declare const fullApi: ApiFromModules<{ declare const fullApi: ApiFromModules<{
apikey: typeof apikey;
auth: typeof auth; auth: typeof auth;
"betterauth/_generated/api": typeof betterauth__generated_api; "betterauth/_generated/api": typeof betterauth__generated_api;
"betterauth/_generated/server": typeof betterauth__generated_server; "betterauth/_generated/server": typeof betterauth__generated_server;
@@ -54,6 +57,7 @@ declare const fullApi: ApiFromModules<{
filesystem: typeof filesystem; filesystem: typeof filesystem;
functions: typeof functions; functions: typeof functions;
http: typeof http; http: typeof http;
"model/apikey": typeof model_apikey;
"model/directories": typeof model_directories; "model/directories": typeof model_directories;
"model/files": typeof model_files; "model/files": typeof model_files;
"model/filesystem": typeof model_filesystem; "model/filesystem": typeof model_filesystem;

14
packages/convex/apikey.ts Normal file
View File

@@ -0,0 +1,14 @@
import { verifyApiKey as _verifyApiKey, parseApiKey } from "@drexa/auth"
import { WebCryptoSha256Hasher } from "@drexa/auth/hasher"
import { v } from "convex/values"
import { query } from "./_generated/server"
import * as ApiKey from "./model/apikey"
export const verifyApiKey = query({
args: {
unhashedKey: v.string(),
},
handler: async (ctx, args) => {
return await ApiKey.verifyApiKey(ctx, args.unhashedKey)
},
})

View File

@@ -1,8 +1,8 @@
import { createClient, type GenericCtx } from "@convex-dev/better-auth" import { createClient, type GenericCtx } from "@convex-dev/better-auth"
import { convex, crossDomain } from "@convex-dev/better-auth/plugins" import { convex, crossDomain } from "@convex-dev/better-auth/plugins"
import { betterAuth } from "better-auth"
import { components } from "@fileone/convex/api" import { components } from "@fileone/convex/api"
import type { DataModel } from "@fileone/convex/dataModel" import type { DataModel } from "@fileone/convex/dataModel"
import { betterAuth } from "better-auth"
import authSchema from "./betterauth/schema" import authSchema from "./betterauth/schema"
const siteUrl = process.env.SITE_URL! const siteUrl = process.env.SITE_URL!

View File

@@ -1,9 +1,12 @@
import { v } from "convex/values"
import type { Id } from "@fileone/convex/dataModel" import type { Id } from "@fileone/convex/dataModel"
import { authenticatedMutation, authenticatedQuery, authorizedGet } from "./functions" import { v } from "convex/values"
import {
authenticatedMutation,
authenticatedQuery,
authorizedGet,
} from "./functions"
import * as Directories from "./model/directories" import * as Directories from "./model/directories"
import * as Files from "./model/files" import * as Files from "./model/files"
import type { FileSystemItem } from "./shared/filesystem"
export const generateUploadUrl = authenticatedMutation({ export const generateUploadUrl = authenticatedMutation({
handler: async (ctx) => { handler: async (ctx) => {
@@ -54,7 +57,7 @@ export const createDirectory = authenticatedMutation({
if (!parentDirectory) { if (!parentDirectory) {
throw new Error("Parent directory not found") throw new Error("Parent directory not found")
} }
return await Directories.create(ctx, { return await Directories.create(ctx, {
name, name,
parentId: directoryId, parentId: directoryId,
@@ -75,7 +78,7 @@ export const saveFile = authenticatedMutation({
if (!directory) { if (!directory) {
throw new Error("Directory not found") throw new Error("Directory not found")
} }
const now = Date.now() const now = Date.now()
await ctx.db.insert("files", { await ctx.db.insert("files", {
@@ -102,7 +105,7 @@ export const renameFile = authenticatedMutation({
if (!file) { if (!file) {
throw new Error("File not found") throw new Error("File not found")
} }
await Files.renameFile(ctx, { directoryId, itemId, newName }) await Files.renameFile(ctx, { directoryId, itemId, newName })
}, },
}) })

View File

@@ -1,18 +1,20 @@
import type { DataModel } from "@fileone/convex/dataModel"
import type { MutationCtx, QueryCtx } from "@fileone/convex/server"
import { mutation, query } from "@fileone/convex/server"
import type { import type {
DocumentByName, DocumentByName,
TableNamesInDataModel, TableNamesInDataModel,
UserIdentity, UserIdentity,
} from "convex/server" } from "convex/server"
import type { GenericId } from "convex/values" import { type GenericId, v } from "convex/values"
import { import {
customCtx, customCtx,
customMutation, customMutation,
customQuery, customQuery,
} from "convex-helpers/server/customFunctions" } from "convex-helpers/server/customFunctions"
import type { DataModel } from "@fileone/convex/dataModel" import * as ApiKey from "./model/apikey"
import type { MutationCtx, QueryCtx } from "@fileone/convex/server"
import { mutation, query } from "@fileone/convex/server"
import { type AuthUser, userIdentityOrThrow, userOrThrow } from "./model/user" import { type AuthUser, userIdentityOrThrow, userOrThrow } from "./model/user"
import * as Err from "./shared/error"
export type AuthenticatedQueryCtx = QueryCtx & { export type AuthenticatedQueryCtx = QueryCtx & {
user: AuthUser user: AuthUser
@@ -50,6 +52,21 @@ export const authenticatedMutation = customMutation(
}), }),
) )
/**
* Custom mutation that requires api key authentication for a mutation.
*/
export const apiKeyAuthenticatedMutation = customMutation(mutation, {
args: {
apiKey: v.string(),
},
input: async (ctx, args) => {
if (!(await ApiKey.verifyApiKey(ctx, args.apiKey))) {
throw Err.create(Err.Code.Unauthenticated, "Invalid API key")
}
return { ctx, args }
},
})
/** /**
* Gets a document by its id and checks if the user is authorized to access it * Gets a document by its id and checks if the user is authorized to access it
* *

View File

@@ -0,0 +1,30 @@
import {
verifyApiKey as _verifyApiKey,
parseApiKey,
WebCryptoSha256Hasher,
} from "@drexa/auth"
import type { MutationCtx, QueryCtx } from "../_generated/server"
export async function verifyApiKey(
ctx: MutationCtx | QueryCtx,
unhashedKey: string,
): Promise<boolean> {
const parsedKey = parseApiKey(unhashedKey)
if (!parsedKey) {
return false
}
const apiKey = await ctx.db
.query("apiKeys")
.withIndex("byPublicId", (q) => q.eq("publicId", parsedKey.prefix))
.first()
if (!apiKey) {
return false
}
return await _verifyApiKey({
keyToBeVerified: parsedKey.unhashedKey,
hashedKey: apiKey.hashedKey,
hasher: new WebCryptoSha256Hasher(),
})
}

View File

@@ -1,5 +1,5 @@
import { v } from "convex/values" import { v } from "convex/values"
import type { Doc, Id } from "@fileone/convex/dataModel" import type { Doc, Id } from "../_generated/dataModel"
import { import {
type AuthenticatedMutationCtx, type AuthenticatedMutationCtx,
type AuthenticatedQueryCtx, type AuthenticatedQueryCtx,

View File

@@ -14,7 +14,9 @@
"./shared/*": "./shared/*" "./shared/*": "./shared/*"
}, },
"dependencies": { "dependencies": {
"@fileone/path": "workspace:*" "@drexa/auth": "workspace:*",
"@fileone/path": "workspace:*",
"hash-wasm": "^4.12.0"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5", "typescript": "^5",

View File

@@ -40,6 +40,13 @@ const schema = defineSchema({
"name", "name",
"deletedAt", "deletedAt",
]), ]),
apiKeys: defineTable({
publicId: v.string(),
hashedKey: v.string(),
createdAt: v.number(),
updatedAt: v.number(),
expiresAt: v.optional(v.number()),
}).index("byPublicId", ["publicId"]),
}) })
export default schema export default schema