@deepraxis/app-template 0.0.0-stage → 0.1.0-dev.378725e
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/LICENSE +67 -0
- package/README.md +6 -2
- package/SKILL.md +143 -0
- package/package.json +12 -4
- package/template/README.md +53 -0
- package/template/_gitignore +7 -0
- package/template/app/account/page.tsx +45 -0
- package/template/app/admin/page.tsx +49 -0
- package/template/app/api/auth/login/route.ts +30 -0
- package/template/app/api/auth/register/route.ts +24 -0
- package/template/app/api/health/route.ts +10 -0
- package/template/app/api/images/route.ts +15 -0
- package/template/app/globals.css +3 -0
- package/template/app/images/[...key]/route.ts +20 -0
- package/template/app/layout.tsx +35 -0
- package/template/app/login/page.tsx +26 -0
- package/template/app/logout/route.ts +9 -0
- package/template/app/page.tsx +21 -0
- package/template/app/register/page.tsx +25 -0
- package/template/components/ui/button.tsx +24 -0
- package/template/components/ui/card.tsx +17 -0
- package/template/components/ui/input.tsx +14 -0
- package/template/components/ui/label.tsx +6 -0
- package/template/env.d.ts +11 -0
- package/template/lib/auth.test.ts +61 -0
- package/template/lib/auth.ts +107 -0
- package/template/lib/db.ts +19 -0
- package/template/lib/guard.ts +37 -0
- package/template/lib/migrate.test.ts +50 -0
- package/template/lib/migrate.ts +54 -0
- package/template/lib/migrations.ts +5 -0
- package/template/lib/schema.ts +30 -0
- package/template/lib/session.ts +48 -0
- package/template/lib/storage.ts +32 -0
- package/template/lib/users.test.ts +51 -0
- package/template/lib/users.ts +46 -0
- package/template/lib/utils.ts +6 -0
- package/template/migrations/0001_init.sql +27 -0
- package/template/next.config.ts +4 -0
- package/template/package.json +39 -0
- package/template/postcss.config.cjs +1 -0
- package/template/praxis.jsonc +10 -0
- package/template/tailwind.config.ts +7 -0
- package/template/test/fake-d1.ts +38 -0
- package/template/tsconfig.json +17 -0
- package/template/vinext-plugins.ts +77 -0
- package/template/vite.config.ts +8 -0
- 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
|
-
#
|
|
1
|
+
# @deepraxis/app-template
|
|
2
2
|
|
|
3
|
-
|
|
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,143 @@
|
|
|
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. Its environment follows the install: `@deepraxis/cli` talks to production, `@deepraxis/cli@dev` to develop; `PRAXIS_ENV=develop|production` overrides it. Use develop only when the user asks for it.
|
|
31
|
+
|
|
32
|
+
## Steps
|
|
33
|
+
|
|
34
|
+
### 1. Decide who you are working with, then gather requirements to match
|
|
35
|
+
Read the request once and pick one of three paths. When in doubt, take **Quick build** and say so in one sentence ("I'll build it with sensible defaults; tell me anytime if you want something specific").
|
|
36
|
+
|
|
37
|
+
| Path | Signals in the request | How much to ask before building |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| **Quick build** | Describes a business outcome only ("make me a booking site for my pet shop"); no technical terms | Ask at most two questions, and only those whose answer changes the data model (for example "can one customer book several pets in one visit?"). Ask them in one go with the `ask_user_question` tool, offering 2–4 concrete options each. Choose every other detail yourself (names, prices, time slots, colors) and list those defaults in the final report so the user can correct them. |
|
|
40
|
+
| **Build with a developer** | Mentions a stack, tables or fields, an API, a repository or existing code, or asks to see a plan or design first | Before writing code, send a short plan and wait for the user to confirm or edit it. The plan covers: pages; tables with their columns; roles and who may do what; bindings and secrets; anything that departs from the template; and what you will not build. After confirmation, build exactly that, and check back before changing the plan. |
|
|
41
|
+
| **Change an existing app** | There is already a `praxis.jsonc` in the working directory, or the user names an app they have | Skip `praxis app create`. Read the project's `README.md` and `praxis.jsonc`, make the change, then continue at step 4 below. |
|
|
42
|
+
|
|
43
|
+
Whatever the path, write down for yourself:
|
|
44
|
+
- the pages;
|
|
45
|
+
- the data, which becomes tables and columns;
|
|
46
|
+
- who may do what, as roles (`user` and `admin` already exist);
|
|
47
|
+
- what is uploaded.
|
|
48
|
+
|
|
49
|
+
A developer who wants a different framework or brings an existing project: say plainly what the platform runs (Workers with D1, KV, R2, vars, secrets and static files; see Limits). Also say that `praxis app deploy` today ships projects with the template's build layout: a vinext build that writes `dist/server/wrangler.json`, `dist/server/*.js` and `dist/client`, plus a `praxis.jsonc`. Offer to move their pages and logic onto the template. Do not claim another layout will deploy.
|
|
50
|
+
|
|
51
|
+
### 2. Create the project
|
|
52
|
+
```bash
|
|
53
|
+
praxis app create <name>
|
|
54
|
+
cd <name>
|
|
55
|
+
```
|
|
56
|
+
`<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.
|
|
57
|
+
|
|
58
|
+
Read `README.md` in the new project once. It has the folder map and the recipes below in more detail.
|
|
59
|
+
|
|
60
|
+
### 3. Develop
|
|
61
|
+
- **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.
|
|
62
|
+
- **Rules** (prices, availability, who owns what) go in `lib/`. Pages and route handlers only call them.
|
|
63
|
+
- **Pages**: `app/<path>/page.tsx`. Protect them with `await requireUser("/<path>")` or `await requireAdmin("/<path>")` from `lib/guard.ts`.
|
|
64
|
+
- **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.
|
|
65
|
+
- **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. For an upload that belongs to a record (a pet photo on a booking, a cover on a book), read the file from the same form, call `checkImage` and `saveImage`, and store the returned `key` in a column of your row. Images are served by `/images/<key>` to their owner and admins only. If something must be public (a gallery, product photos), add a separate route that serves only keys you have marked public in your table, and leave `/images` private.
|
|
66
|
+
- **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"`.
|
|
67
|
+
- **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.
|
|
68
|
+
- **UI**: use the components in `components/ui/` and Tailwind classes. Change the site name, home page, navigation links and colors freely; keep the sign-in, sign-up, account and admin pages working.
|
|
69
|
+
|
|
70
|
+
### 4. Check before deploying
|
|
71
|
+
```bash
|
|
72
|
+
bun run typecheck && bun test && bun run build
|
|
73
|
+
```
|
|
74
|
+
`bun test` runs every test in the project. Fix every error before you deploy. `bun run dev` serves the app locally if you want to look at it first.
|
|
75
|
+
|
|
76
|
+
### 5. Deploy
|
|
77
|
+
```bash
|
|
78
|
+
praxis app deploy
|
|
79
|
+
```
|
|
80
|
+
It builds, uploads only the changed files, creates a version, deploys it, keeps or creates the secrets and calls the `healthcheck` path. The build log comes first; the result comes last and contains the URL. With `--json` the last line is a JSON object with `url`, `worker_id`, `version_id` and `secrets_created`. A deploy takes about one to two minutes.
|
|
81
|
+
|
|
82
|
+
### 6. Verify it like a user
|
|
83
|
+
- 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.
|
|
84
|
+
- Without a browser, check over HTTP. Forms post `application/x-www-form-urlencoded` (uploads: `multipart/form-data`) and answer `303` with a `Location`; protected pages answer a redirect to `/login` when signed out.
|
|
85
|
+
```bash
|
|
86
|
+
U=<url>; J=$(mktemp)
|
|
87
|
+
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' -c $J -d 'username=admin&password=<password>' $U/api/auth/register
|
|
88
|
+
curl -s -b $J $U/admin | grep -c '<something only admins see>'
|
|
89
|
+
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' $U/admin # signed out: redirect to /login
|
|
90
|
+
```
|
|
91
|
+
Match on plain words, not on numbers next to markup: React may split "Books (2)" into several text nodes in the HTML.
|
|
92
|
+
- **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.
|
|
93
|
+
|
|
94
|
+
### 7. Report to the user
|
|
95
|
+
Give:
|
|
96
|
+
- the URL;
|
|
97
|
+
- the admin username and password;
|
|
98
|
+
- what the app does, in a few lines;
|
|
99
|
+
- how to ask for changes.
|
|
100
|
+
|
|
101
|
+
Keep technical words out of the report (no "worker", "D1" or "migration").
|
|
102
|
+
|
|
103
|
+
## Changing the app later
|
|
104
|
+
Edit the code, then run step 4 and step 5 again. The data in D1 and R2 stays across deploys.
|
|
105
|
+
|
|
106
|
+
Rollback switches the code, not the data: rows written by the newer version stay in D1 and migrations are not undone. So write migrations that the previous version still works with (add tables and columns; do not rename or drop them in the same release).
|
|
107
|
+
|
|
108
|
+
If a deploy breaks something, go back to the previous version at once and then fix forward:
|
|
109
|
+
```bash
|
|
110
|
+
praxis app rollback # the version before the current one
|
|
111
|
+
praxis app rollback --to <version_id>
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Operating the app
|
|
115
|
+
| Question | CLI | Codemode |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| Which versions exist? | `praxis api application worker version list --worker <worker_id>` | `praxis.application.workers.versions.list({ path: { worker_id } })` |
|
|
118
|
+
| What was deployed, and when? | `praxis api application worker deployment list --worker <worker_id>` | `praxis.application.workers.deployments.list({ path: { worker_id } })` |
|
|
119
|
+
| What happened (deploys, failures)? | `praxis api application worker event list --worker <worker_id>` | `praxis.application.workers.events.list({ path: { worker_id } })` |
|
|
120
|
+
| Which secrets are set? | `praxis api application worker secret list --worker <worker_id>` | `praxis.application.workers.secrets.list({ path: { worker_id } })` |
|
|
121
|
+
| Find the worker id | `praxis api application worker list` | `praxis.application.workers.list()` |
|
|
122
|
+
| Delete the app | `praxis api application worker delete <worker_id> --yes` | `praxis.application.workers.delete({ path: { worker_id } })` |
|
|
123
|
+
|
|
124
|
+
Deleting takes the URL offline at once. The app, its database and its files are kept for 7 days and can be brought back with `praxis api application worker restore <worker_id>`; after that they are purged. A deleted app still appears in `worker list` with `praxis.status` `deleted` until it is purged. Only delete when the user asks for it.
|
|
125
|
+
|
|
126
|
+
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.
|
|
127
|
+
|
|
128
|
+
## Limits
|
|
129
|
+
- **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.
|
|
130
|
+
- **Rollout**: there is no gradual rollout. A deploy replaces the live version.
|
|
131
|
+
- **Files**: each file can be at most 25 MiB. The template limits image uploads to 5 MB.
|
|
132
|
+
- **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".
|
|
133
|
+
|
|
134
|
+
## When something fails
|
|
135
|
+
| Symptom | Cause | Fix |
|
|
136
|
+
|---|---|---|
|
|
137
|
+
| `praxis: command not found` | The CLI is not installed | `npm install -g @deepraxis/cli` |
|
|
138
|
+
| `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. |
|
|
139
|
+
| `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 |
|
|
140
|
+
| 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 |
|
|
141
|
+
| Sign-in answers 500 "SESSION_SECRET is not configured" | The secret was removed from `praxis.jsonc` | Put `"SESSION_SECRET": "generate"` back and deploy |
|
|
142
|
+
| A build error about modules or `import.meta.url` | `vinext-plugins.ts` was changed or removed | Restore it from the template |
|
|
143
|
+
| Deploy fails with `Completion token has already been consumed` | A rare platform retry issue | Run `praxis app deploy` again |
|
package/package.json
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepraxis/app-template",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-dev.378725e",
|
|
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,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,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,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
|
+
}
|