h4ckath0n 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +189 -0
- package/lib/scaffold.js +144 -0
- package/package.json +38 -0
- package/templates/fullstack/README.md +56 -0
- package/templates/fullstack/backend/.python-version +1 -0
- package/templates/fullstack/backend/app/__init__.py +1 -0
- package/templates/fullstack/backend/app/cli.py +98 -0
- package/templates/fullstack/backend/app/main.py +7 -0
- package/templates/fullstack/backend/app/middleware.py +55 -0
- package/templates/fullstack/backend/pyproject.toml +19 -0
- package/templates/fullstack/web/eslint.config.js +22 -0
- package/templates/fullstack/web/index.html +13 -0
- package/templates/fullstack/web/package-lock.json +5133 -0
- package/templates/fullstack/web/package.json +42 -0
- package/templates/fullstack/web/public/vite.svg +4 -0
- package/templates/fullstack/web/src/App.tsx +45 -0
- package/templates/fullstack/web/src/auth/AuthContext.tsx +238 -0
- package/templates/fullstack/web/src/auth/__tests__/token.test.ts +22 -0
- package/templates/fullstack/web/src/auth/__tests__/webauthn.test.ts +44 -0
- package/templates/fullstack/web/src/auth/api.ts +63 -0
- package/templates/fullstack/web/src/auth/deviceKey.ts +71 -0
- package/templates/fullstack/web/src/auth/index.ts +26 -0
- package/templates/fullstack/web/src/auth/token.ts +59 -0
- package/templates/fullstack/web/src/auth/webauthn.ts +133 -0
- package/templates/fullstack/web/src/auth/ws.ts +40 -0
- package/templates/fullstack/web/src/components/Alert.tsx +35 -0
- package/templates/fullstack/web/src/components/Button.tsx +37 -0
- package/templates/fullstack/web/src/components/Card.tsx +22 -0
- package/templates/fullstack/web/src/components/Input.tsx +27 -0
- package/templates/fullstack/web/src/components/Layout.tsx +88 -0
- package/templates/fullstack/web/src/components/ProtectedRoute.tsx +34 -0
- package/templates/fullstack/web/src/components/index.ts +6 -0
- package/templates/fullstack/web/src/index.css +48 -0
- package/templates/fullstack/web/src/main.tsx +28 -0
- package/templates/fullstack/web/src/pages/Admin.tsx +43 -0
- package/templates/fullstack/web/src/pages/Dashboard.tsx +75 -0
- package/templates/fullstack/web/src/pages/Landing.tsx +73 -0
- package/templates/fullstack/web/src/pages/Login.tsx +66 -0
- package/templates/fullstack/web/src/pages/Register.tsx +80 -0
- package/templates/fullstack/web/src/pages/Settings.tsx +172 -0
- package/templates/fullstack/web/src/pages/index.ts +6 -0
- package/templates/fullstack/web/src/test/setup.ts +1 -0
- package/templates/fullstack/web/src/vite-env.d.ts +1 -0
- package/templates/fullstack/web/tsconfig.app.json +21 -0
- package/templates/fullstack/web/tsconfig.json +7 -0
- package/templates/fullstack/web/tsconfig.node.json +18 -0
- package/templates/fullstack/web/vite.config.ts +16 -0
- package/templates/fullstack/web/vitest.config.ts +9 -0
package/bin/cli.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
5
|
+
import { resolve, join, dirname } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
import { copyDir, dirExists, generateSecret, validateProjectName, writeEnvFiles } from "../lib/scaffold.js";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Helpers
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
15
|
+
const __dirname = dirname(__filename);
|
|
16
|
+
|
|
17
|
+
function printUsage() {
|
|
18
|
+
console.log(`
|
|
19
|
+
Usage: h4ckath0n <project-name> [options]
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
--no-install Skip dependency installation (uv sync / npm install)
|
|
23
|
+
--no-git Skip git init
|
|
24
|
+
--no-python Skip Python backend scaffolding
|
|
25
|
+
--no-node Skip Node/React frontend scaffolding
|
|
26
|
+
--db <type> Database type: postgres (default) or sqlite
|
|
27
|
+
-h, --help Show this help message
|
|
28
|
+
`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Arg parsing
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
function parseArgs(argv) {
|
|
36
|
+
const args = argv.slice(2);
|
|
37
|
+
const opts = {
|
|
38
|
+
name: null,
|
|
39
|
+
install: true,
|
|
40
|
+
git: true,
|
|
41
|
+
python: true,
|
|
42
|
+
node: true,
|
|
43
|
+
db: "postgres",
|
|
44
|
+
help: false,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < args.length; i++) {
|
|
48
|
+
const arg = args[i];
|
|
49
|
+
if (arg === "--no-install") {
|
|
50
|
+
opts.install = false;
|
|
51
|
+
} else if (arg === "--no-git") {
|
|
52
|
+
opts.git = false;
|
|
53
|
+
} else if (arg === "--no-python") {
|
|
54
|
+
opts.python = false;
|
|
55
|
+
} else if (arg === "--no-node") {
|
|
56
|
+
opts.node = false;
|
|
57
|
+
} else if (arg === "--db") {
|
|
58
|
+
i++;
|
|
59
|
+
const val = args[i];
|
|
60
|
+
if (val !== "postgres" && val !== "sqlite") {
|
|
61
|
+
console.error(`Error: --db must be "postgres" or "sqlite", got "${val}".`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
opts.db = val;
|
|
65
|
+
} else if (arg === "-h" || arg === "--help") {
|
|
66
|
+
opts.help = true;
|
|
67
|
+
} else if (arg.startsWith("-")) {
|
|
68
|
+
console.error(`Error: Unknown flag "${arg}". Use --help for usage.`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
} else if (!opts.name) {
|
|
71
|
+
opts.name = arg;
|
|
72
|
+
} else {
|
|
73
|
+
console.error(`Error: Unexpected argument "${arg}". Only one project name allowed.`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return opts;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Main
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
function main() {
|
|
86
|
+
const opts = parseArgs(process.argv);
|
|
87
|
+
|
|
88
|
+
if (opts.help) {
|
|
89
|
+
printUsage();
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!opts.name) {
|
|
94
|
+
console.error("Error: Please provide a project name.\n");
|
|
95
|
+
printUsage();
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const validation = validateProjectName(opts.name);
|
|
100
|
+
if (!validation.valid) {
|
|
101
|
+
console.error(`Error: ${validation.reason}`);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const projectDir = resolve(process.cwd(), opts.name);
|
|
106
|
+
|
|
107
|
+
if (dirExists(projectDir) && readdirSync(projectDir).length > 0) {
|
|
108
|
+
console.error(`Error: Directory "${opts.name}" already exists and is not empty.`);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Locate templates directory (bundled inside the npm package).
|
|
113
|
+
const templatesDir = join(__dirname, "..", "templates", "fullstack");
|
|
114
|
+
if (!existsSync(templatesDir)) {
|
|
115
|
+
console.error(
|
|
116
|
+
`Error: Templates directory not found at "${templatesDir}".\n` +
|
|
117
|
+
"This is a packaging issue — please report it.",
|
|
118
|
+
);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
console.log(`\n🚀 Creating project "${opts.name}" in ${projectDir}\n`);
|
|
123
|
+
|
|
124
|
+
// Placeholder replacements applied to every text file in the template.
|
|
125
|
+
const replacements = {
|
|
126
|
+
"{{PROJECT_NAME}}": opts.name,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
copyDir(templatesDir, projectDir, replacements);
|
|
130
|
+
|
|
131
|
+
// Write .env / .env.example
|
|
132
|
+
writeEnvFiles(projectDir, opts.db, opts.name);
|
|
133
|
+
|
|
134
|
+
console.log("✅ Project files created.");
|
|
135
|
+
|
|
136
|
+
// ---- Optional: git init ----
|
|
137
|
+
if (opts.git) {
|
|
138
|
+
try {
|
|
139
|
+
execSync("git init", { cwd: projectDir, stdio: "ignore" });
|
|
140
|
+
console.log("✅ Initialized git repository.");
|
|
141
|
+
} catch {
|
|
142
|
+
console.warn("⚠️ Could not run git init. Skipping.");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---- Optional: install deps ----
|
|
147
|
+
if (opts.install) {
|
|
148
|
+
if (opts.python && existsSync(join(projectDir, "backend"))) {
|
|
149
|
+
console.log("📦 Installing Python dependencies (uv sync)...");
|
|
150
|
+
try {
|
|
151
|
+
execSync("uv sync", { cwd: join(projectDir, "backend"), stdio: "inherit" });
|
|
152
|
+
console.log("✅ Python dependencies installed.");
|
|
153
|
+
} catch {
|
|
154
|
+
console.warn("⚠️ uv sync failed. You can run it manually later.");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (opts.node && existsSync(join(projectDir, "web"))) {
|
|
159
|
+
console.log("📦 Installing Node dependencies (npm install)...");
|
|
160
|
+
try {
|
|
161
|
+
execSync("npm install", { cwd: join(projectDir, "web"), stdio: "inherit" });
|
|
162
|
+
console.log("✅ Node dependencies installed.");
|
|
163
|
+
} catch {
|
|
164
|
+
console.warn("⚠️ npm install failed. You can run it manually later.");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---- Success ----
|
|
170
|
+
console.log(`
|
|
171
|
+
🎉 Done! Your project is ready.
|
|
172
|
+
|
|
173
|
+
Next steps:
|
|
174
|
+
|
|
175
|
+
cd ${opts.name}
|
|
176
|
+
|
|
177
|
+
# Start the backend
|
|
178
|
+
cd backend && uv run uvicorn app.main:app --reload
|
|
179
|
+
|
|
180
|
+
# Start the frontend (in another terminal)
|
|
181
|
+
cd web && npm run dev
|
|
182
|
+
|
|
183
|
+
# Open http://localhost:5173
|
|
184
|
+
|
|
185
|
+
Happy hacking! 🏴☠️
|
|
186
|
+
`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
main();
|
package/lib/scaffold.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Validate that a project name contains only alphanumeric chars, hyphens,
|
|
7
|
+
* and underscores, starts with a letter or underscore, and is 1-214 chars.
|
|
8
|
+
* @param {string} name
|
|
9
|
+
* @returns {{ valid: boolean, reason?: string }}
|
|
10
|
+
*/
|
|
11
|
+
export function validateProjectName(name) {
|
|
12
|
+
if (!name) {
|
|
13
|
+
return { valid: false, reason: "Project name is required." };
|
|
14
|
+
}
|
|
15
|
+
if (name.length > 214) {
|
|
16
|
+
return { valid: false, reason: "Project name must be 214 characters or fewer." };
|
|
17
|
+
}
|
|
18
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(name)) {
|
|
19
|
+
return {
|
|
20
|
+
valid: false,
|
|
21
|
+
reason:
|
|
22
|
+
"Project name must start with a letter or underscore and contain only alphanumeric characters, hyphens, and underscores.",
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return { valid: true };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Generate a cryptographically random hex secret.
|
|
30
|
+
* @param {number} [bytes=32]
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
export function generateSecret(bytes = 32) {
|
|
34
|
+
return randomBytes(bytes).toString("hex");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Binary file extensions that should be copied without placeholder replacement.
|
|
38
|
+
const BINARY_EXTENSIONS = new Set([
|
|
39
|
+
".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg",
|
|
40
|
+
".woff", ".woff2", ".ttf", ".eot",
|
|
41
|
+
".zip", ".gz", ".tar", ".bz2",
|
|
42
|
+
".pdf", ".mp3", ".mp4", ".webm",
|
|
43
|
+
".wasm", ".bin",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Recursively copy a directory, replacing placeholder strings in text files.
|
|
48
|
+
* @param {string} src - source directory
|
|
49
|
+
* @param {string} dest - destination directory
|
|
50
|
+
* @param {Record<string, string>} replacements - map of placeholder -> value
|
|
51
|
+
*/
|
|
52
|
+
export function copyDir(src, dest, replacements) {
|
|
53
|
+
mkdirSync(dest, { recursive: true });
|
|
54
|
+
|
|
55
|
+
for (const entry of readdirSync(src)) {
|
|
56
|
+
const srcPath = join(src, entry);
|
|
57
|
+
const destPath = join(dest, entry);
|
|
58
|
+
const stat = statSync(srcPath);
|
|
59
|
+
|
|
60
|
+
if (stat.isDirectory()) {
|
|
61
|
+
copyDir(srcPath, destPath, replacements);
|
|
62
|
+
} else {
|
|
63
|
+
const dotIdx = entry.lastIndexOf(".");
|
|
64
|
+
const ext = dotIdx > 0 ? entry.slice(dotIdx).toLowerCase() : "";
|
|
65
|
+
if (BINARY_EXTENSIONS.has(ext)) {
|
|
66
|
+
writeFileSync(destPath, readFileSync(srcPath));
|
|
67
|
+
} else {
|
|
68
|
+
let content = readFileSync(srcPath, "utf8");
|
|
69
|
+
for (const [placeholder, value] of Object.entries(replacements)) {
|
|
70
|
+
content = content.replaceAll(placeholder, value);
|
|
71
|
+
}
|
|
72
|
+
writeFileSync(destPath, content, "utf8");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build the contents of a .env file.
|
|
80
|
+
* @param {"postgres" | "sqlite"} dbType
|
|
81
|
+
* @param {string} projectName
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
function buildEnvContent(dbType, projectName) {
|
|
85
|
+
const signingKey = generateSecret();
|
|
86
|
+
const dbUrl =
|
|
87
|
+
dbType === "sqlite"
|
|
88
|
+
? `sqlite+aiosqlite:///./data/${projectName}.db`
|
|
89
|
+
: `postgresql+psycopg://postgres:postgres@localhost:5432/${projectName}`;
|
|
90
|
+
|
|
91
|
+
return [
|
|
92
|
+
"# h4ckath0n environment",
|
|
93
|
+
"H4CKATH0N_ENV=development",
|
|
94
|
+
`H4CKATH0N_DATABASE_URL=${dbUrl}`,
|
|
95
|
+
`H4CKATH0N_AUTH_SIGNING_KEY=${signingKey}`,
|
|
96
|
+
"H4CKATH0N_RP_ID=localhost",
|
|
97
|
+
"H4CKATH0N_ORIGIN=http://localhost:5173",
|
|
98
|
+
"VITE_API_BASE_URL=/api",
|
|
99
|
+
"",
|
|
100
|
+
].join("\n");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build the contents of a .env.example file.
|
|
105
|
+
* @param {"postgres" | "sqlite"} dbType
|
|
106
|
+
* @returns {string}
|
|
107
|
+
*/
|
|
108
|
+
function buildEnvExampleContent(dbType) {
|
|
109
|
+
const dbPlaceholder =
|
|
110
|
+
dbType === "sqlite"
|
|
111
|
+
? "sqlite+aiosqlite:///./data/myproject.db"
|
|
112
|
+
: "postgresql+psycopg://postgres:postgres@localhost:5432/myproject";
|
|
113
|
+
|
|
114
|
+
return [
|
|
115
|
+
"# h4ckath0n environment",
|
|
116
|
+
"H4CKATH0N_ENV=development",
|
|
117
|
+
`H4CKATH0N_DATABASE_URL=${dbPlaceholder}`,
|
|
118
|
+
"H4CKATH0N_AUTH_SIGNING_KEY=<random-hex-secret>",
|
|
119
|
+
"H4CKATH0N_RP_ID=localhost",
|
|
120
|
+
"H4CKATH0N_ORIGIN=http://localhost:5173",
|
|
121
|
+
"VITE_API_BASE_URL=/api",
|
|
122
|
+
"",
|
|
123
|
+
].join("\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Write .env and .env.example into the destination directory.
|
|
128
|
+
* @param {string} dest - project root directory
|
|
129
|
+
* @param {"postgres" | "sqlite"} dbType
|
|
130
|
+
* @param {string} projectName
|
|
131
|
+
*/
|
|
132
|
+
export function writeEnvFiles(dest, dbType, projectName) {
|
|
133
|
+
writeFileSync(join(dest, ".env"), buildEnvContent(dbType, projectName), "utf8");
|
|
134
|
+
writeFileSync(join(dest, ".env.example"), buildEnvExampleContent(dbType), "utf8");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Check whether a directory already exists and is non-empty.
|
|
139
|
+
* @param {string} dir
|
|
140
|
+
* @returns {boolean}
|
|
141
|
+
*/
|
|
142
|
+
export function dirExists(dir) {
|
|
143
|
+
return existsSync(dir) && statSync(dir).isDirectory();
|
|
144
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "h4ckath0n",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Create a full-stack hackathon project with passkeys and strong defaults",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"h4ckath0n": "bin/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"lib/",
|
|
13
|
+
"templates/"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/BTreeMap/h4ckath0n.git",
|
|
18
|
+
"directory": "packages/create-h4ckath0n"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/BTreeMap/h4ckath0n",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/BTreeMap/h4ckath0n/issues"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"hackathon",
|
|
26
|
+
"fullstack",
|
|
27
|
+
"passkeys",
|
|
28
|
+
"webauthn",
|
|
29
|
+
"react",
|
|
30
|
+
"fastapi"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# {{PROJECT_NAME}}
|
|
2
|
+
|
|
3
|
+
Full-stack hackathon project scaffolded with [h4ckath0n](https://github.com/BTreeMap/h4ckath0n).
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Start both backend and frontend
|
|
9
|
+
cd backend
|
|
10
|
+
uv run h4ckath0n dev
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Backend runs at http://localhost:8000, frontend at http://localhost:5173.
|
|
14
|
+
|
|
15
|
+
## Structure
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
{{PROJECT_NAME}}/
|
|
19
|
+
backend/ Python (FastAPI + h4ckath0n library)
|
|
20
|
+
web/ React + Vite + TypeScript + Tailwind v4
|
|
21
|
+
.env Environment variables (gitignored)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Auth model
|
|
25
|
+
|
|
26
|
+
- Passkeys (WebAuthn) for registration and login
|
|
27
|
+
- Each device gets a P-256 keypair (private key non-extractable, stored in IndexedDB)
|
|
28
|
+
- API requests use short-lived JWTs (15 min) signed by the device key
|
|
29
|
+
- Server verifies JWT signature and enforces RBAC from the database
|
|
30
|
+
- No privilege claims in the JWT; all roles/scopes are server-derived
|
|
31
|
+
|
|
32
|
+
## Development
|
|
33
|
+
|
|
34
|
+
### Backend
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
cd backend
|
|
38
|
+
uv sync
|
|
39
|
+
uv run uvicorn app.main:app --reload
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Frontend
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
cd web
|
|
46
|
+
npm install
|
|
47
|
+
npm run dev
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Environment
|
|
51
|
+
|
|
52
|
+
Copy `.env.example` to `.env` and set values as needed. See the h4ckath0n docs for all configuration options.
|
|
53
|
+
|
|
54
|
+
## License
|
|
55
|
+
|
|
56
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Backend application package."""
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""CLI for the scaffolded h4ckath0n project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
"""Entry point for the h4ckath0n CLI."""
|
|
12
|
+
if len(sys.argv) < 2:
|
|
13
|
+
_print_help()
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
command = sys.argv[1]
|
|
17
|
+
handlers = {
|
|
18
|
+
"dev": _cmd_dev,
|
|
19
|
+
"help": _print_help,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
handler = handlers.get(command)
|
|
23
|
+
if handler:
|
|
24
|
+
handler()
|
|
25
|
+
else:
|
|
26
|
+
print(f"Unknown command: {command}")
|
|
27
|
+
_print_help()
|
|
28
|
+
sys.exit(1)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _cmd_dev() -> None:
|
|
32
|
+
"""Run backend and frontend dev servers concurrently."""
|
|
33
|
+
project_root = _find_project_root()
|
|
34
|
+
backend_dir = os.path.join(project_root, "backend")
|
|
35
|
+
web_dir = os.path.join(project_root, "web")
|
|
36
|
+
|
|
37
|
+
print("Starting h4ckath0n dev servers...")
|
|
38
|
+
print(f" Backend: http://localhost:8000 (from {backend_dir})")
|
|
39
|
+
print(f" Frontend: http://localhost:5173 (from {web_dir})")
|
|
40
|
+
print()
|
|
41
|
+
|
|
42
|
+
processes = []
|
|
43
|
+
try:
|
|
44
|
+
# Start backend
|
|
45
|
+
backend_proc = subprocess.Popen(
|
|
46
|
+
[sys.executable, "-m", "uvicorn", "app.main:app", "--reload", "--port", "8000"],
|
|
47
|
+
cwd=backend_dir,
|
|
48
|
+
)
|
|
49
|
+
processes.append(backend_proc)
|
|
50
|
+
|
|
51
|
+
# Start frontend
|
|
52
|
+
npm_cmd = "npm.cmd" if sys.platform == "win32" else "npm"
|
|
53
|
+
frontend_proc = subprocess.Popen(
|
|
54
|
+
[npm_cmd, "run", "dev"],
|
|
55
|
+
cwd=web_dir,
|
|
56
|
+
)
|
|
57
|
+
processes.append(frontend_proc)
|
|
58
|
+
|
|
59
|
+
# Wait for any process to exit
|
|
60
|
+
for proc in processes:
|
|
61
|
+
proc.wait()
|
|
62
|
+
except KeyboardInterrupt:
|
|
63
|
+
print("\nShutting down...")
|
|
64
|
+
finally:
|
|
65
|
+
for proc in processes:
|
|
66
|
+
try:
|
|
67
|
+
proc.terminate()
|
|
68
|
+
proc.wait(timeout=5)
|
|
69
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
70
|
+
proc.kill()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _find_project_root() -> str:
|
|
74
|
+
"""Find the project root by looking for backend/ and web/ directories."""
|
|
75
|
+
# Start from the current working directory
|
|
76
|
+
cwd = os.getcwd()
|
|
77
|
+
if os.path.isdir(os.path.join(cwd, "backend")) and os.path.isdir(os.path.join(cwd, "web")):
|
|
78
|
+
return cwd
|
|
79
|
+
# Try parent of backend/
|
|
80
|
+
parent = os.path.dirname(cwd)
|
|
81
|
+
if os.path.isdir(os.path.join(parent, "backend")) and os.path.isdir(
|
|
82
|
+
os.path.join(parent, "web")
|
|
83
|
+
):
|
|
84
|
+
return parent
|
|
85
|
+
# Default to cwd
|
|
86
|
+
return cwd
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _print_help() -> None:
|
|
90
|
+
"""Print CLI help."""
|
|
91
|
+
print("h4ckath0n backend CLI")
|
|
92
|
+
print()
|
|
93
|
+
print("Usage: uv run h4ckath0n <command>")
|
|
94
|
+
print(" (run from the backend/ directory of your scaffolded project)")
|
|
95
|
+
print()
|
|
96
|
+
print("Commands:")
|
|
97
|
+
print(" dev Start backend and frontend dev servers")
|
|
98
|
+
print(" help Show this help message")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Security middleware: CSP headers and device JWT verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
9
|
+
from starlette.requests import Request
|
|
10
|
+
from starlette.responses import Response
|
|
11
|
+
|
|
12
|
+
ENV_VAR = "H4CKATH0N_ENV"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def add_csp_middleware(app: FastAPI) -> None:
|
|
16
|
+
"""Add Content-Security-Policy middleware to the FastAPI app."""
|
|
17
|
+
app.add_middleware(CSPMiddleware)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CSPMiddleware(BaseHTTPMiddleware):
|
|
21
|
+
"""Set Content-Security-Policy headers based on environment."""
|
|
22
|
+
|
|
23
|
+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
24
|
+
response = await call_next(request)
|
|
25
|
+
env = os.getenv(ENV_VAR, "development")
|
|
26
|
+
if env == "production":
|
|
27
|
+
csp = (
|
|
28
|
+
"default-src 'self'; "
|
|
29
|
+
"script-src 'self'; "
|
|
30
|
+
"style-src 'self'; "
|
|
31
|
+
"img-src 'self' data:; "
|
|
32
|
+
"font-src 'self'; "
|
|
33
|
+
"connect-src 'self'; "
|
|
34
|
+
"frame-ancestors 'none'; "
|
|
35
|
+
"base-uri 'self'; "
|
|
36
|
+
"form-action 'self'"
|
|
37
|
+
)
|
|
38
|
+
else:
|
|
39
|
+
# Development: allow Vite dev server
|
|
40
|
+
csp = (
|
|
41
|
+
"default-src 'self' http://localhost:*; "
|
|
42
|
+
"script-src 'self' http://localhost:*; "
|
|
43
|
+
"style-src 'self' 'unsafe-inline' http://localhost:*; "
|
|
44
|
+
"img-src 'self' data: http://localhost:*; "
|
|
45
|
+
"font-src 'self' http://localhost:*; "
|
|
46
|
+
"connect-src 'self' http://localhost:* ws://localhost:*; "
|
|
47
|
+
"frame-ancestors 'none'; "
|
|
48
|
+
"base-uri 'self'; "
|
|
49
|
+
"form-action 'self'"
|
|
50
|
+
)
|
|
51
|
+
response.headers["Content-Security-Policy"] = csp
|
|
52
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
53
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
54
|
+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
55
|
+
return response
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "{{PROJECT_NAME}}-backend"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Backend for {{PROJECT_NAME}}"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"h4ckath0n>=0.1.0",
|
|
8
|
+
"cryptography>=44.0",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
h4ckath0n = "app.cli:main"
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["hatchling"]
|
|
16
|
+
build-backend = "hatchling.build"
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["app"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import js from "@eslint/js";
|
|
2
|
+
import globals from "globals";
|
|
3
|
+
import reactHooks from "eslint-plugin-react-hooks";
|
|
4
|
+
import tseslint from "typescript-eslint";
|
|
5
|
+
|
|
6
|
+
export default tseslint.config(
|
|
7
|
+
{ ignores: ["dist"] },
|
|
8
|
+
{
|
|
9
|
+
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
|
10
|
+
files: ["**/*.{ts,tsx}"],
|
|
11
|
+
languageOptions: {
|
|
12
|
+
ecmaVersion: 2020,
|
|
13
|
+
globals: globals.browser,
|
|
14
|
+
},
|
|
15
|
+
plugins: {
|
|
16
|
+
"react-hooks": reactHooks,
|
|
17
|
+
},
|
|
18
|
+
rules: {
|
|
19
|
+
...reactHooks.configs.recommended.rules,
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>{{PROJECT_NAME}}</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|