@deepraxis/app-template 0.0.0-stage → 0.1.0-dev.61eb886

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.
Files changed (48) hide show
  1. package/LICENSE +67 -0
  2. package/README.md +6 -2
  3. package/SKILL.md +121 -0
  4. package/package.json +12 -4
  5. package/template/README.md +53 -0
  6. package/template/_gitignore +7 -0
  7. package/template/app/account/page.tsx +45 -0
  8. package/template/app/admin/page.tsx +49 -0
  9. package/template/app/api/auth/login/route.ts +30 -0
  10. package/template/app/api/auth/register/route.ts +24 -0
  11. package/template/app/api/health/route.ts +10 -0
  12. package/template/app/api/images/route.ts +15 -0
  13. package/template/app/globals.css +3 -0
  14. package/template/app/images/[...key]/route.ts +20 -0
  15. package/template/app/layout.tsx +35 -0
  16. package/template/app/login/page.tsx +26 -0
  17. package/template/app/logout/route.ts +9 -0
  18. package/template/app/page.tsx +21 -0
  19. package/template/app/register/page.tsx +25 -0
  20. package/template/components/ui/button.tsx +24 -0
  21. package/template/components/ui/card.tsx +17 -0
  22. package/template/components/ui/input.tsx +14 -0
  23. package/template/components/ui/label.tsx +6 -0
  24. package/template/env.d.ts +11 -0
  25. package/template/lib/auth.test.ts +61 -0
  26. package/template/lib/auth.ts +107 -0
  27. package/template/lib/db.ts +19 -0
  28. package/template/lib/guard.ts +37 -0
  29. package/template/lib/migrate.test.ts +50 -0
  30. package/template/lib/migrate.ts +54 -0
  31. package/template/lib/migrations.ts +5 -0
  32. package/template/lib/schema.ts +30 -0
  33. package/template/lib/session.ts +48 -0
  34. package/template/lib/storage.ts +32 -0
  35. package/template/lib/users.test.ts +51 -0
  36. package/template/lib/users.ts +46 -0
  37. package/template/lib/utils.ts +6 -0
  38. package/template/migrations/0001_init.sql +27 -0
  39. package/template/next.config.ts +4 -0
  40. package/template/package.json +39 -0
  41. package/template/postcss.config.cjs +1 -0
  42. package/template/praxis.jsonc +10 -0
  43. package/template/tailwind.config.ts +7 -0
  44. package/template/test/fake-d1.ts +38 -0
  45. package/template/tsconfig.json +17 -0
  46. package/template/vinext-plugins.ts +77 -0
  47. package/template/vite.config.ts +8 -0
  48. package/template/wrangler.jsonc +11 -0
package/LICENSE ADDED
@@ -0,0 +1,67 @@
1
+ Deepractice Commercial Proprietary License
2
+
3
+ Copyright (c) 2026 南京深度实践人工智能科技有限公司.
4
+ Unified Social Credit Code: 91430104MAEDATJ09G.
5
+ All rights reserved.
6
+
7
+ IMPORTANT: THIS IS NOT AN OPEN SOURCE LICENSE.
8
+
9
+ This repository and its source code, documentation, architecture, tests,
10
+ scripts, configuration, prompts, agent designs, protocols, workflows, and other
11
+ accompanying materials are proprietary commercial assets owned by 南京深度实践
12
+ 人工智能科技有限公司 (the "Copyright Holder").
13
+ The Copyright Holder's Unified Social Credit Code is 91430104MAEDATJ09G.
14
+
15
+ The purpose of this license is to bind this repository and the project code to
16
+ the Copyright Holder as closed-source commercial intellectual property.
17
+
18
+ No rights are granted by default. Access to this repository, possession of a
19
+ copy of the source code, visibility into commit history, participation in
20
+ business discussions, employment, consulting, shareholder status, investment,
21
+ or cooperation with any related entity does not grant any ownership interest,
22
+ copyright license, patent license, trademark license, trade secret license, or
23
+ other intellectual property right.
24
+
25
+ Without a separate written commercial agreement signed by the Copyright Holder,
26
+ no person or entity may, directly or indirectly:
27
+
28
+ 1. use, run, deploy, host, or provide the software as a service;
29
+ 2. copy, modify, merge, adapt, translate, or create derivative works;
30
+ 3. publish, distribute, sublicense, lease, sell, transfer, or disclose the
31
+ software or any substantial part of it;
32
+ 4. reverse engineer, decompile, disassemble, or otherwise attempt to derive
33
+ source, structure, architecture, protocols, prompts, trade secrets, or
34
+ implementation details;
35
+ 5. use the software, code, documentation, architecture, protocols, prompts, or
36
+ repository history to train, fine-tune, evaluate, or improve any AI model,
37
+ coding agent, software-generation system, or competing product;
38
+ 6. remove, obscure, or alter copyright, license, ownership, confidentiality, or
39
+ attribution notices.
40
+
41
+ All commercial use, internal business use, customer delivery, private
42
+ deployment, SaaS operation, resale, redistribution, integration into another
43
+ product, or access by third parties requires prior written authorization from
44
+ the Copyright Holder.
45
+
46
+ Contributions are accepted only on the basis that the contributor has the right
47
+ to contribute them and assigns, or agrees to assign, all applicable rights in
48
+ those contributions to the Copyright Holder unless a separate written agreement
49
+ signed by the Copyright Holder states otherwise.
50
+
51
+ This license applies to this repository as a whole. No part of this repository
52
+ may be treated as public domain, community property, jointly owned project
53
+ property, or an implied asset of any company, shareholder, collaborator,
54
+ customer, investor, or business partner other than the Copyright Holder without
55
+ a separate written agreement signed by the Copyright Holder.
56
+
57
+ The Chinese legal name "南京深度实践人工智能科技有限公司" is the authoritative
58
+ identity of the Copyright Holder. Any English rendering of that name is for
59
+ identification convenience only and does not create a separate legal entity or
60
+ alter ownership.
61
+
62
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
63
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
64
+ FOR A PARTICULAR PURPOSE, TITLE, AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
65
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY ARISING
66
+ FROM OR IN CONNECTION WITH THE SOFTWARE OR THE USE OF OTHER DEALINGS IN THE
67
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,7 @@
1
- # Temporary Holding Version
1
+ # @deepraxis/app-template
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The standard Praxis application template: a vinext (Next.js App Router on Workers) app with
4
+ end-user sign-up and sign-in, D1 via drizzle, R2 image upload and an admin area.
5
+ `praxis app create` copies `template/` into a new project and replaces the `<app-name>` placeholder
6
+ in `praxis.jsonc`; an AI agent or a developer then adapts it. See `template/README.md` for the stack.
7
+ It has no runtime code and no private dependency; `spec/` holds the develop proof and is not published.
package/SKILL.md ADDED
@@ -0,0 +1,121 @@
1
+ ---
2
+ name: build-application
3
+ description: "Required before building, changing, deploying or operating a web application on Praxis: a website, booking or ordering site, store, member area, internal tool, or any app with pages, sign-in, a database or uploads. Uses the standard Praxis stack (Next.js App Router on Workers via vinext, D1, R2) with `praxis app create` and `praxis app deploy`. Never deploy with wrangler and never pick another framework."
4
+ ---
5
+
6
+ # Build an application on Praxis
7
+
8
+ Use this skill when the user wants a working web application: something with pages people open in a browser, and usually sign-in, stored data or uploads. The result is a live URL on Praxis that the user can open and share.
9
+
10
+ The stack and the architecture are fixed. Do not compare frameworks, do not ask the user to choose one, and do not deploy any other way. Every app starts from the standard template and is shipped with `praxis app deploy`.
11
+
12
+ ## The standard stack
13
+
14
+ | Part | What it is | Where it lives in the project |
15
+ |---|---|---|
16
+ | Pages and API | Next.js App Router (v15), run on Cloudflare Workers by vinext | `app/` |
17
+ | Business rules | Plain TypeScript, server-only | `lib/` |
18
+ | Database | D1 (SQLite) with drizzle-orm; SQL migrations applied automatically on the first request | `migrations/`, `lib/migrations.ts`, `lib/schema.ts` |
19
+ | Files | R2 bucket `IMAGES` | `lib/storage.ts` |
20
+ | End-user accounts | Username and password; signed HttpOnly session cookie; the first account to register becomes `admin` | `lib/auth.ts`, `lib/session.ts`, `lib/users.ts`, `lib/guard.ts` |
21
+ | UI | Tailwind CSS with Button, Input, Label and Card in `components/ui/` | `components/ui/` |
22
+ | Validation | zod | in each route handler |
23
+ | Project file | `praxis.jsonc`: name, secrets, health check | project root |
24
+
25
+ The template already contains sign-up, sign-in, sign-out, an account page with image upload, an admin page and `/api/health`. Build the user's app by changing and extending it, not by starting over.
26
+
27
+ ## Where to run commands
28
+
29
+ - **You are a Praxis platform agent.** Run `praxis` in your machine's terminal (the Bash tool). The CLI is preinstalled and already signed in through the `PRAXIS_API_TOKEN` environment variable, so never run `praxis login`. For questions about deployed apps (versions, deployments, events, secrets) you can also call the SDK from codemode: `praxis.application.workers.list()`, `praxis.application.workers.deployments.list({ path: { worker_id } })` and so on.
30
+ - **You run elsewhere (Claude Code, Cursor, CI).** Install the CLI with `npm install -g @deepraxis/cli`. Sign in with `praxis login`, or set `PRAXIS_API_KEY`. Use the CLI for everything.
31
+
32
+ ## Steps
33
+
34
+ ### 1. Turn the request into a short plan
35
+ Write down, for yourself:
36
+ - the pages;
37
+ - the data, which becomes tables and columns;
38
+ - who may do what, as roles (`user` and `admin` already exist);
39
+ - what is uploaded.
40
+
41
+ Ask the user only when the answer would change the data model, for example "can a customer book more than one slot?". Otherwise choose a sensible default, say what you chose, and keep going.
42
+
43
+ ### 2. Create the project
44
+ ```bash
45
+ praxis app create <name>
46
+ cd <name>
47
+ ```
48
+ `<name>` becomes part of the public URL: 3–40 lowercase letters, digits or single hyphens. `www`, `api`, `admin`, `app`, `docs`, `console` and `status` are reserved. The command copies the template and installs the dependencies.
49
+
50
+ Read `README.md` in the new project once. It has the folder map and the recipes below in more detail.
51
+
52
+ ### 3. Develop
53
+ - **Tables**: add `migrations/0002_<what>.sql` using `CREATE TABLE IF NOT EXISTS …`. Import it with `?raw` and append it to the list in `lib/migrations.ts`, then describe the table in `lib/schema.ts`. Migrations run once, in order, on the first request after a deploy. **Never edit a migration that has already been deployed**; add a new one instead.
54
+ - **Rules** (prices, availability, who owns what) go in `lib/`. Pages and route handlers only call them.
55
+ - **Pages**: `app/<path>/page.tsx`. Protect them with `await requireUser("/<path>")` or `await requireAdmin("/<path>")` from `lib/guard.ts`.
56
+ - **Forms**: post to a route handler in `app/api/…/route.ts`. Validate with zod and answer with a 303 redirect (`redirectResponse` in `lib/guard.ts`), the way the sign-in form does.
57
+ - **Uploads**: follow `app/api/images/route.ts` and `lib/storage.ts`. That code checks type and size, stores the file under the owner and records it in D1.
58
+ - **New bindings**: declare them in `wrangler.jsonc` (`d1_databases`, `kv_namespaces`, `r2_buckets`, `vars`), add the type to `env.d.ts`, and read them with `import { env } from "cloudflare:workers"`.
59
+ - **Secrets**: add them to `praxis.jsonc` `secrets`. `"generate"` creates a random value on the first deploy and keeps it on every later deploy. Never write a secret value into a file.
60
+ - **UI**: use the components in `components/ui/` and Tailwind classes. Keep the existing layout and navigation.
61
+
62
+ ### 4. Check before deploying
63
+ ```bash
64
+ bun run typecheck && bun test && bun run build
65
+ ```
66
+ Fix every error before you deploy. `bun run dev` serves the app locally if you want to look at it first.
67
+
68
+ ### 5. Deploy
69
+ ```bash
70
+ praxis app deploy
71
+ ```
72
+ It builds, uploads only the changed files, creates a version, deploys it, keeps or creates the secrets and calls the `healthcheck` path. It prints the URL. The first deploy takes about one to two minutes.
73
+
74
+ ### 6. Verify it like a user
75
+ - Open the URL. If the `control-browser` skill is available, use it, and walk through the main flow: register, sign in, do the main task, and check the admin page.
76
+ - **The first account registered on a fresh app becomes the admin.** Register that account yourself with the username the user asked for (or `admin`) and a strong random password, then hand both to the user. Any test accounts you create after that are ordinary users; mention them so the user can ignore them.
77
+
78
+ ### 7. Report to the user
79
+ Give:
80
+ - the URL;
81
+ - the admin username and password;
82
+ - what the app does, in a few lines;
83
+ - how to ask for changes.
84
+
85
+ Keep technical words out of the report (no "worker", "D1" or "migration").
86
+
87
+ ## Changing the app later
88
+ Edit the code, then run step 4 and step 5 again. The data in D1 and R2 stays across deploys.
89
+
90
+ If a deploy breaks something, go back to the previous version at once and then fix forward:
91
+ ```bash
92
+ praxis app rollback # the version before the current one
93
+ praxis app rollback --to <version_id>
94
+ ```
95
+
96
+ ## Operating the app
97
+ | Question | CLI | Codemode |
98
+ |---|---|---|
99
+ | Which versions exist? | `praxis api application worker version list --worker <worker_id>` | `praxis.application.workers.versions.list({ path: { worker_id } })` |
100
+ | What was deployed, and when? | `praxis api application worker deployment list --worker <worker_id>` | `praxis.application.workers.deployments.list({ path: { worker_id } })` |
101
+ | What happened (deploys, failures)? | `praxis api application worker event list --worker <worker_id>` | `praxis.application.workers.events.list({ path: { worker_id } })` |
102
+ | Which secrets are set? | `praxis api application worker secret list --worker <worker_id>` | `praxis.application.workers.secrets.list({ path: { worker_id } })` |
103
+ | Find the worker id | `praxis api application worker list` | `praxis.application.workers.list()` |
104
+
105
+ Runtime logs are not available yet. To find out why a page fails, reproduce the failure, read the error page or the API response, and add clear error messages in the route handler.
106
+
107
+ ## Limits
108
+ - **Not supported**: service bindings, queues, Durable Objects, Workers AI, Analytics Engine. A deploy that declares one fails with `UNSUPPORTED` and names it. Use D1, KV, R2, vars, secrets and static files instead.
109
+ - **Rollout**: there is no gradual rollout. A deploy replaces the live version.
110
+ - **Files**: each file can be at most 25 MiB. The template limits image uploads to 5 MB.
111
+ - **Template parts to leave alone**: `vinext-plugins.ts` and `vite.config.ts`. They make the build deployable; removing them breaks deploys with "No such module".
112
+
113
+ ## When something fails
114
+ | Symptom | Cause | Fix |
115
+ |---|---|---|
116
+ | `praxis: command not found` | The CLI is not installed | `npm install -g @deepraxis/cli` |
117
+ | `NOT_LOGGED_IN`, or 401 on a platform agent's machine | The machine credentials are missing | Run the command again. If it persists, tell the user the machine has no platform credentials. Do not run `praxis login` there. |
118
+ | `UNSUPPORTED … services` (or queues and so on) | A binding the platform does not offer | Remove it from `wrangler.jsonc` and use D1, KV or R2 |
119
+ | Health check failed after the deploy | The app throws on `/api/health`, usually a broken migration | Open the URL to read the error, fix the migration with a NEW migration file, deploy again; `praxis app rollback` meanwhile |
120
+ | Sign-in answers 500 "SESSION_SECRET is not configured" | The secret was removed from `praxis.jsonc` | Put `"SESSION_SECRET": "generate"` back and deploy |
121
+ | A build error about modules or `import.meta.url` | `vinext-plugins.ts` was changed or removed | Restore it from the template |
package/package.json CHANGED
@@ -1,6 +1,14 @@
1
1
  {
2
2
  "name": "@deepraxis/app-template",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-dev.61eb886",
4
+ "description": "The standard Praxis application template: vinext + D1 + R2 + end-user auth + admin area.",
5
+ "license": "Apache-2.0",
6
+ "files": [
7
+ "template",
8
+ "README.md",
9
+ "SKILL.md"
10
+ ],
11
+ "publishConfig": {
12
+ "access": "public"
13
+ }
14
+ }
@@ -0,0 +1,53 @@
1
+ # Praxis app
2
+
3
+ A Next.js App Router app (run by vinext on Cloudflare Workers) with end-user sign-up and sign-in,
4
+ a D1 database, R2 image upload and an admin area. It deploys through the Praxis Application API only.
5
+
6
+ ## Stack
7
+
8
+ - **vinext + Next.js 15 App Router**: ordinary `app/` pages and route handlers, built to a Worker.
9
+ - **D1 + drizzle-orm**: queries are typed; schema is plain SQL migrations applied at runtime.
10
+ - **R2**: image storage (binding `IMAGES`).
11
+ - **Auth**: username + password (PBKDF2-SHA256 via WebCrypto), HttpOnly signed session cookie, sessions stored hashed in D1. The first user to register becomes `admin`.
12
+ - **Tailwind CSS 3 + shadcn/ui-style primitives** in `components/ui/`.
13
+ - **zod** for input validation.
14
+
15
+ ## Folder map
16
+
17
+ ```
18
+ praxis.jsonc project file: kind, name, secrets, healthcheck (read by `praxis app deploy`)
19
+ wrangler.jsonc bindings the app needs (read at build time; never used to deploy)
20
+ app/ pages and route handlers (/, /login, /register, /account, /admin, /api/*, /images/*)
21
+ lib/ server-only code and business rules
22
+ db.ts getDb(): migrates once per isolate, returns the drizzle client
23
+ migrate.ts runtime migrator migrations.ts bundled migration list schema.ts drizzle tables
24
+ auth.ts password hashing, cookie signing, validation (no framework imports)
25
+ session.ts session rows + cookie strings users.ts user rules (first user = admin)
26
+ guard.ts getCurrentUser / requireUser / requireAdmin
27
+ storage.ts image rules and R2/D1 access
28
+ components/ui/ Button, Input, Label, Card
29
+ migrations/ numbered SQL files
30
+ ```
31
+
32
+ Keep business rules in `lib/`; pages and route handlers only call them.
33
+
34
+ ## Common changes
35
+
36
+ - **Add a page**: create `app/<name>/page.tsx`. Call `await requireUser("/<name>")` or `requireAdmin` from `lib/guard.ts` to protect it.
37
+ - **Add a table**: write `migrations/0002_<name>.sql` (idempotent: `CREATE TABLE IF NOT EXISTS`), import it with `?raw` and append it in `lib/migrations.ts`, then add the table to `lib/schema.ts`. It is applied on the first request after deploy.
38
+ - **Add a binding**: declare it in `wrangler.jsonc` (supported: `d1_databases`, `kv_namespaces`, `r2_buckets`, `vars`), add its type to `env.d.ts`, read it with `import { env } from "cloudflare:workers"`. The platform creates worker-owned resources and ignores ids.
39
+ - **Add a secret**: add it to `Cloudflare.Env` in `env.d.ts`, read `env.NAME`, and declare it in `praxis.jsonc` `secrets` (`"generate"` makes `praxis app deploy` create a random value once and keep it). To set your own value: `praxis api application worker secret put`. Secrets are never written in files. `SESSION_SECRET` is required: without it sign-in and sign-up return a 500 naming it.
40
+
41
+ ## Run
42
+
43
+ - `bun install` then `bun run dev` for local development.
44
+ - `bun run typecheck`, `bun test` (auth and migrator unit tests), `bun run build` (output in `dist/`).
45
+ - `praxis app deploy` builds, uploads and deploys, then checks `healthcheck`. `praxis app rollback` returns to the previous version. Nothing here calls wrangler to deploy.
46
+
47
+ ## Not supported on the platform
48
+
49
+ Service bindings, queues, Durable Objects, Workers AI, Analytics Engine. Use D1, KV, R2, plain/json/secret values and static assets.
50
+
51
+ ## Not included yet
52
+
53
+ Rate limiting (comments in `app/api/auth/*/route.ts` mark where it goes), password reset, email verification.
@@ -0,0 +1,7 @@
1
+ node_modules
2
+ dist
3
+ .wrangler
4
+ .dev.vars
5
+ .next
6
+ next-env.d.ts
7
+ bun.lock
@@ -0,0 +1,45 @@
1
+ import { Button } from "@/components/ui/button";
2
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
3
+ import { Input } from "@/components/ui/input";
4
+ import { getDb } from "@/lib/db";
5
+ import { requireUser } from "@/lib/guard";
6
+ import { listImages } from "@/lib/storage";
7
+
8
+ export const dynamic = "force-dynamic";
9
+
10
+ export default async function Account({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
11
+ const user = await requireUser("/account");
12
+ const { error } = await searchParams;
13
+ const mine = await listImages(await getDb(), user.id);
14
+ return (
15
+ <div className="space-y-6">
16
+ <Card>
17
+ <CardHeader><CardTitle>{user.username}</CardTitle></CardHeader>
18
+ <CardContent className="text-sm">Role: {user.role}</CardContent>
19
+ </Card>
20
+ <Card>
21
+ <CardHeader><CardTitle>Upload an image</CardTitle></CardHeader>
22
+ <CardContent>
23
+ <form action="/api/images" method="post" encType="multipart/form-data" className="flex items-center gap-3">
24
+ <Input name="image" type="file" accept="image/jpeg,image/png,image/webp" required />
25
+ <Button type="submit">Upload</Button>
26
+ </form>
27
+ {error && <p role="alert" className="mt-2 text-sm text-red-600">{error}</p>}
28
+ </CardContent>
29
+ </Card>
30
+ <Card>
31
+ <CardHeader><CardTitle>Your images ({mine.length})</CardTitle></CardHeader>
32
+ <CardContent>
33
+ <ul className="grid grid-cols-2 gap-3 sm:grid-cols-3">
34
+ {mine.map(img => (
35
+ <li key={img.id} className="text-xs">
36
+ <a href={`/images/${img.key}`}><img src={`/images/${img.key}`} alt="" className="h-32 w-full rounded-md border object-cover" /></a>
37
+ <span className="text-slate-500">{img.contentType} · {img.size} bytes</span>
38
+ </li>
39
+ ))}
40
+ </ul>
41
+ </CardContent>
42
+ </Card>
43
+ </div>
44
+ );
45
+ }
@@ -0,0 +1,49 @@
1
+ import { count, desc } from "drizzle-orm";
2
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
3
+ import { getDb } from "@/lib/db";
4
+ import { requireAdmin } from "@/lib/guard";
5
+ import { images, users } from "@/lib/schema";
6
+
7
+ export const dynamic = "force-dynamic";
8
+
9
+ export default async function Admin() {
10
+ await requireAdmin("/admin");
11
+ const db = await getDb();
12
+ const [allUsers, allImages, [userCount], [imageCount]] = await Promise.all([
13
+ db.select({ id: users.id, username: users.username, role: users.role, createdAt: users.createdAt }).from(users).orderBy(users.createdAt),
14
+ db.select().from(images).orderBy(desc(images.createdAt)),
15
+ db.select({ n: count() }).from(users),
16
+ db.select({ n: count() }).from(images),
17
+ ]);
18
+ const nameOf = new Map(allUsers.map(u => [u.id, u.username]));
19
+ return (
20
+ <div className="space-y-6">
21
+ <Card>
22
+ <CardHeader><CardTitle>Users ({userCount!.n})</CardTitle></CardHeader>
23
+ <CardContent>
24
+ <table className="w-full text-left text-sm">
25
+ <thead><tr><th>Username</th><th>Role</th><th>Created</th></tr></thead>
26
+ <tbody>
27
+ {allUsers.map(u => (
28
+ <tr key={u.id}><td>{u.username}</td><td>{u.role}</td><td>{new Date(u.createdAt).toISOString()}</td></tr>
29
+ ))}
30
+ </tbody>
31
+ </table>
32
+ </CardContent>
33
+ </Card>
34
+ <Card>
35
+ <CardHeader><CardTitle>Images ({imageCount!.n})</CardTitle></CardHeader>
36
+ <CardContent>
37
+ <table className="w-full text-left text-sm">
38
+ <thead><tr><th>Owner</th><th>Key</th><th>Type</th><th>Size</th></tr></thead>
39
+ <tbody>
40
+ {allImages.map(i => (
41
+ <tr key={i.id}><td>{nameOf.get(i.ownerId) ?? i.ownerId}</td><td><a className="underline" href={`/images/${i.key}`}>{i.key}</a></td><td>{i.contentType}</td><td>{i.size}</td></tr>
42
+ ))}
43
+ </tbody>
44
+ </table>
45
+ </CardContent>
46
+ </Card>
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,30 @@
1
+ import { env } from "cloudflare:workers";
2
+ import { credentialsSchema, hashPassword, MissingSecretError, requireSecret, verifyPassword } from "@/lib/auth";
3
+ import { getDb } from "@/lib/db";
4
+ import { redirectResponse, safeNext } from "@/lib/guard";
5
+ import { createSession, sessionCookie } from "@/lib/session";
6
+ import { findUserByUsername } from "@/lib/users";
7
+
8
+ // Rate limiting for sign-in (per IP and per username) would go here, before the password check.
9
+ export async function POST(req: Request) {
10
+ try {
11
+ requireSecret(env.SESSION_SECRET);
12
+ const form = await req.formData();
13
+ const next = String(form.get("next") ?? "");
14
+ const fail = () => redirectResponse(`/login?error=${encodeURIComponent("Wrong username or password")}${next ? `&next=${encodeURIComponent(next)}` : ""}`);
15
+ const parsed = credentialsSchema.safeParse({ username: form.get("username"), password: form.get("password") });
16
+ if (!parsed.success) return fail();
17
+ const db = await getDb();
18
+ const user = await findUserByUsername(db, parsed.data.username);
19
+ if (!user) {
20
+ await hashPassword(parsed.data.password); // spend the same time as a real check
21
+ return fail();
22
+ }
23
+ if (!(await verifyPassword(parsed.data.password, user.passwordHash))) return fail();
24
+ const session = await createSession(db, user.id, env.SESSION_SECRET);
25
+ return redirectResponse(safeNext(next), sessionCookie(session.value, session.expiresAt));
26
+ } catch (e) {
27
+ if (e instanceof MissingSecretError) return new Response(e.message, { status: 500 });
28
+ throw e;
29
+ }
30
+ }
@@ -0,0 +1,24 @@
1
+ import { env } from "cloudflare:workers";
2
+ import { credentialsSchema, hashPassword, MissingSecretError, requireSecret } from "@/lib/auth";
3
+ import { getDb } from "@/lib/db";
4
+ import { redirectResponse } from "@/lib/guard";
5
+ import { createSession, sessionCookie } from "@/lib/session";
6
+ import { createUser } from "@/lib/users";
7
+
8
+ // Rate limiting for sign-up (per IP) would go here, before any hashing or database work.
9
+ export async function POST(req: Request) {
10
+ try {
11
+ requireSecret(env.SESSION_SECRET); // fail before creating a user that could never sign in
12
+ const form = await req.formData();
13
+ const parsed = credentialsSchema.safeParse({ username: form.get("username"), password: form.get("password") });
14
+ if (!parsed.success) return redirectResponse(`/register?error=${encodeURIComponent(parsed.error.issues[0]!.message)}`);
15
+ const db = await getDb();
16
+ const created = await createUser(db, { username: parsed.data.username, passwordHash: await hashPassword(parsed.data.password) });
17
+ if (!created.ok) return redirectResponse(`/register?error=${encodeURIComponent("That username is taken")}`);
18
+ const session = await createSession(db, created.user.id, env.SESSION_SECRET);
19
+ return redirectResponse("/account", sessionCookie(session.value, session.expiresAt));
20
+ } catch (e) {
21
+ if (e instanceof MissingSecretError) return new Response(e.message, { status: 500 });
22
+ throw e;
23
+ }
24
+ }
@@ -0,0 +1,10 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { getDb } from "@/lib/db";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function GET() {
7
+ const db = await getDb();
8
+ await db.run(sql`SELECT 1`);
9
+ return Response.json({ ok: true });
10
+ }
@@ -0,0 +1,15 @@
1
+ import { env } from "cloudflare:workers";
2
+ import { getDb } from "@/lib/db";
3
+ import { getCurrentUser, redirectResponse } from "@/lib/guard";
4
+ import { checkImage, saveImage } from "@/lib/storage";
5
+
6
+ export async function POST(req: Request) {
7
+ const user = await getCurrentUser(req.headers.get("cookie"));
8
+ if (!user) return redirectResponse("/login?next=%2Faccount");
9
+ const file = (await req.formData()).get("image");
10
+ if (!(file instanceof File)) return redirectResponse(`/account?error=${encodeURIComponent("Choose an image to upload")}`);
11
+ const problem = checkImage(file);
12
+ if (problem) return redirectResponse(`/account?error=${encodeURIComponent(problem)}`);
13
+ await saveImage(await getDb(), env.IMAGES, user.id, file);
14
+ return redirectResponse("/account");
15
+ }
@@ -0,0 +1,3 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
@@ -0,0 +1,20 @@
1
+ import { env } from "cloudflare:workers";
2
+ import { getDb } from "@/lib/db";
3
+ import { getCurrentUser } from "@/lib/guard";
4
+ import { findImage } from "@/lib/storage";
5
+
6
+ export const dynamic = "force-dynamic";
7
+
8
+ // Images are private: only the owner and admins can read them.
9
+ export async function GET(req: Request, { params }: { params: Promise<{ key: string[] }> }) {
10
+ const user = await getCurrentUser(req.headers.get("cookie"));
11
+ if (!user) return new Response("Sign in first", { status: 401 });
12
+ const key = (await params).key.join("/");
13
+ const image = await findImage(await getDb(), key);
14
+ if (!image || (image.ownerId !== user.id && user.role !== "admin")) return new Response("Not found", { status: 404 });
15
+ const object = await env.IMAGES.get(key);
16
+ if (!object) return new Response("Not found", { status: 404 });
17
+ return new Response(object.body, {
18
+ headers: { "Content-Type": image.contentType, "Content-Length": String(image.size), "Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff" },
19
+ });
20
+ }
@@ -0,0 +1,35 @@
1
+ import "./globals.css";
2
+ import Link from "next/link";
3
+ import { getCurrentUser } from "@/lib/guard";
4
+
5
+ export const metadata = { title: "Praxis App", description: "A Praxis application" };
6
+ export const dynamic = "force-dynamic";
7
+
8
+ export default async function RootLayout({ children }: { children: React.ReactNode }) {
9
+ const user = await getCurrentUser();
10
+ return (
11
+ <html lang="en">
12
+ <body className="min-h-screen bg-slate-50 text-slate-900 antialiased">
13
+ <header className="border-b border-slate-200 bg-white">
14
+ <nav className="mx-auto flex max-w-4xl items-center gap-4 px-4 py-3 text-sm">
15
+ <Link href="/" className="font-semibold">Praxis App</Link>
16
+ <span className="flex-1" />
17
+ {user ? (
18
+ <>
19
+ <Link href="/account">Account</Link>
20
+ {user.role === "admin" && <Link href="/admin">Admin</Link>}
21
+ <form action="/logout" method="post"><button className="text-slate-600 hover:underline">Sign out ({user.username})</button></form>
22
+ </>
23
+ ) : (
24
+ <>
25
+ <Link href="/login">Sign in</Link>
26
+ <Link href="/register">Sign up</Link>
27
+ </>
28
+ )}
29
+ </nav>
30
+ </header>
31
+ <main className="mx-auto max-w-4xl px-4 py-8">{children}</main>
32
+ </body>
33
+ </html>
34
+ );
35
+ }
@@ -0,0 +1,26 @@
1
+ import Link from "next/link";
2
+ import { Button } from "@/components/ui/button";
3
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
4
+ import { Input } from "@/components/ui/input";
5
+ import { Label } from "@/components/ui/label";
6
+
7
+ export const dynamic = "force-dynamic";
8
+
9
+ export default async function Login({ searchParams }: { searchParams: Promise<{ error?: string; next?: string }> }) {
10
+ const { error, next } = await searchParams;
11
+ return (
12
+ <Card className="mx-auto max-w-sm">
13
+ <CardHeader><CardTitle>Sign in</CardTitle></CardHeader>
14
+ <CardContent>
15
+ <form action="/api/auth/login" method="post" className="space-y-4">
16
+ <input type="hidden" name="next" value={next ?? ""} />
17
+ <div className="space-y-1.5"><Label htmlFor="username">Username</Label><Input id="username" name="username" autoComplete="username" required /></div>
18
+ <div className="space-y-1.5"><Label htmlFor="password">Password</Label><Input id="password" name="password" type="password" autoComplete="current-password" required /></div>
19
+ {error && <p role="alert" className="text-sm text-red-600">{error}</p>}
20
+ <Button type="submit" className="w-full">Sign in</Button>
21
+ </form>
22
+ <p className="mt-4 text-sm"><Link className="underline" href="/register">Create an account</Link></p>
23
+ </CardContent>
24
+ </Card>
25
+ );
26
+ }
@@ -0,0 +1,9 @@
1
+ import { env } from "cloudflare:workers";
2
+ import { getDb } from "@/lib/db";
3
+ import { redirectResponse } from "@/lib/guard";
4
+ import { clearedSessionCookie, destroySession } from "@/lib/session";
5
+
6
+ export async function POST(req: Request) {
7
+ await destroySession(await getDb(), req.headers.get("cookie"), env.SESSION_SECRET);
8
+ return redirectResponse("/", clearedSessionCookie);
9
+ }
@@ -0,0 +1,21 @@
1
+ import Link from "next/link";
2
+ import { getCurrentUser } from "@/lib/guard";
3
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
4
+
5
+ export const dynamic = "force-dynamic";
6
+
7
+ export default async function Home() {
8
+ const user = await getCurrentUser();
9
+ return (
10
+ <Card>
11
+ <CardHeader><CardTitle>Welcome</CardTitle></CardHeader>
12
+ <CardContent className="space-y-3 text-sm">
13
+ {user ? (
14
+ <p>Signed in as <strong>{user.username}</strong> ({user.role}). <Link className="underline" href="/account">Go to your account</Link>.</p>
15
+ ) : (
16
+ <p>You are not signed in. <Link className="underline" href="/login">Sign in</Link> or <Link className="underline" href="/register">create an account</Link>.</p>
17
+ )}
18
+ </CardContent>
19
+ </Card>
20
+ );
21
+ }
@@ -0,0 +1,25 @@
1
+ import Link from "next/link";
2
+ import { Button } from "@/components/ui/button";
3
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
4
+ import { Input } from "@/components/ui/input";
5
+ import { Label } from "@/components/ui/label";
6
+
7
+ export const dynamic = "force-dynamic";
8
+
9
+ export default async function Register({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
10
+ const { error } = await searchParams;
11
+ return (
12
+ <Card className="mx-auto max-w-sm">
13
+ <CardHeader><CardTitle>Create account</CardTitle></CardHeader>
14
+ <CardContent>
15
+ <form action="/api/auth/register" method="post" className="space-y-4">
16
+ <div className="space-y-1.5"><Label htmlFor="username">Username</Label><Input id="username" name="username" autoComplete="username" required /></div>
17
+ <div className="space-y-1.5"><Label htmlFor="password">Password</Label><Input id="password" name="password" type="password" autoComplete="new-password" required /></div>
18
+ {error && <p role="alert" className="text-sm text-red-600">{error}</p>}
19
+ <Button type="submit" className="w-full">Create account</Button>
20
+ </form>
21
+ <p className="mt-4 text-sm"><Link className="underline" href="/login">Already have an account?</Link></p>
22
+ </CardContent>
23
+ </Card>
24
+ );
25
+ }
@@ -0,0 +1,24 @@
1
+ import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+ import { cn } from "@/lib/utils";
4
+
5
+ const buttonVariants = cva(
6
+ "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 disabled:pointer-events-none disabled:opacity-50",
7
+ {
8
+ variants: {
9
+ variant: {
10
+ default: "bg-slate-900 text-white hover:bg-slate-800",
11
+ outline: "border border-slate-300 bg-white hover:bg-slate-50",
12
+ ghost: "hover:bg-slate-100",
13
+ },
14
+ size: { default: "h-9 px-4 py-2", sm: "h-8 px-3" },
15
+ },
16
+ defaultVariants: { variant: "default", size: "default" },
17
+ },
18
+ );
19
+
20
+ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {}
21
+
22
+ export function Button({ className, variant, size, ...props }: ButtonProps) {
23
+ return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
24
+ }