mirror of
https://github.com/get-drexa/drive.git
synced 2025-11-30 21:41:39 +00:00
Compare commits
5 Commits
e58caa6b16
...
6eded27121
| Author | SHA1 | Date | |
|---|---|---|---|
|
6eded27121
|
|||
|
0307cbbf61
|
|||
|
d0893e13be
|
|||
|
a4544a3f09
|
|||
|
14e2ee1e28
|
60
apps/cli/README.md
Normal file
60
apps/cli/README.md
Normal 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`
|
||||||
71
apps/cli/commands/generate/apikey.ts
Normal file
71
apps/cli/commands/generate/apikey.ts
Normal 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")
|
||||||
|
})
|
||||||
6
apps/cli/commands/generate/index.ts
Normal file
6
apps/cli/commands/generate/index.ts
Normal 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
17
apps/cli/index.ts
Executable 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
23
apps/cli/package.json
Normal 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
106
apps/cli/prompts.ts
Normal 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
28
apps/cli/tsconfig.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
28
bun.lock
28
bun.lock
@@ -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=="],
|
||||||
|
|||||||
@@ -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
89
packages/auth/hasher.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
4
packages/convex/_generated/api.d.ts
vendored
4
packages/convex/_generated/api.d.ts
vendored
@@ -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
14
packages/convex/apikey.ts
Normal 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)
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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!
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
|
||||||
*
|
*
|
||||||
|
|||||||
30
packages/convex/model/apikey.ts
Normal file
30
packages/convex/model/apikey.ts
Normal 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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user