caracms 0.2.0 → 0.3.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/README.md +247 -3
- package/bin/caracms.mjs +32 -16
- package/bin/cloudflare-ips.mjs +38 -0
- package/bin/gate.mjs +277 -4
- package/bin/init.mjs +64 -20
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +5 -3
- package/skills/caracms-adopt/look-same.mjs +8 -4
- package/skills/caracms-blocks/SKILL.md +20 -2
- package/skills/caracms-tina/SKILL.md +14 -10
- package/src/components/Head.astro +3 -2
- package/src/content.ts +2 -0
- package/src/editor/client/editor.ts +24 -6
- package/src/editor/client/live/publish.tsx +20 -10
- package/src/editor/client/pane/parts.tsx +34 -2
- package/src/editor/client/state/keeper.ts +51 -6
- package/src/editor/client/state/live.ts +45 -6
- package/src/editor/middleware.ts +3 -2
- package/src/editor/routes/try-again.ts +2 -1
- package/src/editor/server/admin-props.ts +2 -1
- package/src/editor/server/mode.ts +13 -3
- package/src/editor/styles/levels/live.css +1 -1
- package/src/editor/types.ts +25 -6
- package/src/files-route.ts +9 -4
- package/src/forms/endpoint.ts +11 -16
- package/src/head.ts +10 -1
- package/src/index.ts +53 -7
- package/src/live/README.md +4 -2
- package/src/live/auth/auth.ts +4 -1
- package/src/live/auth/guard.ts +3 -2
- package/src/live/auth/index.ts +6 -7
- package/src/live/auth/login.ts +17 -8
- package/src/live/auth/middleware.ts +7 -3
- package/src/live/auth/request.ts +92 -11
- package/src/live/auth/views.ts +9 -4
- package/src/live/autosaves.ts +59 -16
- package/src/live/backend.ts +13 -1
- package/src/live/drafts-store.ts +4 -3
- package/src/live/editor.ts +92 -67
- package/src/live/git.ts +112 -34
- package/src/live/github.ts +128 -13
- package/src/live/locks.ts +92 -26
- package/src/live/mode.ts +131 -18
- package/src/live/notices.ts +100 -0
- package/src/live/redis.ts +161 -39
- package/src/live/runtime.ts +11 -3
- package/src/live/status-cache.ts +219 -0
- package/src/live/status.ts +14 -2
- package/src/live/timings.ts +24 -0
- package/src/media.ts +26 -1
package/README.md
CHANGED
|
@@ -44,15 +44,259 @@ job of the `caracms-adopt` skill, one page at a time.
|
|
|
44
44
|
|
|
45
45
|
Run from the site's root:
|
|
46
46
|
|
|
47
|
-
- `caracms init [--write]`: add caracms to an Astro site, or bring a set-up
|
|
48
|
-
schemas and CLAUDE.md block up to date.
|
|
47
|
+
- `caracms init [--write] [--railway]`: add caracms to an Astro site, or bring a set-up
|
|
48
|
+
site's skills, schemas and CLAUDE.md block up to date. `--railway` also writes a
|
|
49
|
+
Railway setup; without it, `init` prints the variables any host needs (Hosting, below).
|
|
49
50
|
- `caracms validate [--fix] [--json]`: check the content against `caracms.config.ts`.
|
|
50
51
|
Exits 1 when there are errors. `--fix` runs `caracms schemas` first, then lays each
|
|
51
52
|
content file out as the editor saves it (two-space JSON; only whitespace changes).
|
|
52
53
|
- `caracms schemas`: write `schemas/` from the config, and give each content file its
|
|
53
54
|
`$schema`.
|
|
54
55
|
- `caracms media import <files…>`: put local images into the site's media bucket.
|
|
55
|
-
- `caracms start`: serve the built site.
|
|
56
|
+
- `caracms start`: serve the built site (Hosting, below).
|
|
56
57
|
|
|
57
58
|
The skills (`caracms-content`, `caracms-blocks` and `caracms-adopt`, in `skills/`) say how
|
|
58
59
|
the rest works, matching the installed version.
|
|
60
|
+
|
|
61
|
+
## Hosting
|
|
62
|
+
|
|
63
|
+
caracms runs on any host that keeps one Node server running behind HTTPS: Railway,
|
|
64
|
+
Render, Fly, or a VPS. What every host needs:
|
|
65
|
+
|
|
66
|
+
- **One instance.** The site runs as one server process (two for a moment during a
|
|
67
|
+
deploy). The drafts and several caches live in its memory. On Fly that means one
|
|
68
|
+
machine; on Render, no autoscaling.
|
|
69
|
+
- **HTTPS.** Editors' sessions only travel over https, so signing in needs it.
|
|
70
|
+
`http://` works for `localhost` only, and then in a Chromium browser: Safari won't
|
|
71
|
+
keep the session cookie on `http://localhost`.
|
|
72
|
+
- **A deploy whenever `main` changes.** A Publish commits to `main`; caracms never
|
|
73
|
+
deploys anything itself. Railway, Render and Coolify deploy on a push. On Fly or a
|
|
74
|
+
plain VPS, add a GitHub Action (or Coolify, or similar) that deploys on a push to
|
|
75
|
+
`main`.
|
|
76
|
+
- **`SITE_URL`, the same value at build and at run time.** Set it where the site is
|
|
77
|
+
built and where it runs. A www address at one and the bare domain at the other is
|
|
78
|
+
the likeliest setup mistake.
|
|
79
|
+
- **Started with `caracms start`** (the site's `npm start`). It decides each visitor's
|
|
80
|
+
IP for the limits on signing in, forms and downloads. A site started any other way
|
|
81
|
+
answers those routes with a 503:
|
|
82
|
+
|
|
83
|
+
> This site wasn't started with `caracms start`, so it can't tell its visitors apart
|
|
84
|
+
> for the limits on signing in, forms and downloads. A developer can start it with
|
|
85
|
+
> `caracms start` (the site's start script).
|
|
86
|
+
|
|
87
|
+
### The variables
|
|
88
|
+
|
|
89
|
+
These go on the site's server. `caracms init` prints the same list.
|
|
90
|
+
|
|
91
|
+
| Name | Needed | What it's for |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| `GITHUB_REPO` | required | The site's repository, as owner/name. |
|
|
94
|
+
| `CARACMS_GITHUB_TOKEN` | required, or the App | A fine-grained token (`github_pat_…`) on that one repo: Contents read and write, Metadata read (Deployments read is optional). Never `GITHUB_TOKEN`. |
|
|
95
|
+
| `GITHUB_APP_ID` | instead of the token | The GitHub App's ID, for anyone setting up many sites. Set the token or the App, not both. |
|
|
96
|
+
| `GITHUB_APP_PRIVATE_KEY` | with `GITHUB_APP_ID` | The App's private key. |
|
|
97
|
+
| `SITE_URL` | required | The site's public address, https://. The same value at build and at run time. |
|
|
98
|
+
| `REDIS_URL` | required | The editor's drafts and sessions: `redis://` or `rediss://` (Upstash works, in the site's region). |
|
|
99
|
+
| `CARACMS_SECRET` | required | Signs the editors' sessions. A long random string. |
|
|
100
|
+
| `CARACMS_CLIENT_IP_HEADER` | required off Railway, Fly and Render | Where the visitor's IP comes from: `x-real-ip` for nginx with `proxy_set_header X-Real-IP $remote_addr`, `x-forwarded-for` for Caddy, or `socket` if nothing sits in front of this server. `caracms start` won't start without it. |
|
|
101
|
+
| `CARACMS_ALLOW` | for email sign-in | Who may sign in, comma-separated: addresses (`aoife@fernhill.ie`) or whole domains (`@fernhill.ie`). The client adds their own people in Site Settings. |
|
|
102
|
+
| `RESEND_API_KEY` | for email sign-in | Sends the sign-in links (and form notifications) through Resend. |
|
|
103
|
+
| `FORMS_FROM` | for email sign-in | The address those emails come from, on a domain verified in Resend. |
|
|
104
|
+
| `CARACMS_BACKUP_USER` | for the backup sign-in | The backup sign-in, for when email sign-in links can't arrive. Both or neither. |
|
|
105
|
+
| `CARACMS_BACKUP_PASSWORD` | for the backup sign-in | The backup sign-in's password. |
|
|
106
|
+
| `CARACMS_RUNNING_COMMIT` | optional | The commit this server runs, where the host doesn't say (Railway and Render do). |
|
|
107
|
+
| `CARACMS_DEPLOY_HOOK_URL` | optional | Try again on a publish POSTs here to redeploy the site (the host's deploy hook). |
|
|
108
|
+
| `GITHUB_WEBHOOK_SECRET` | optional | For a webhook added in the repo's settings. Without it, a check once a minute does the same job. |
|
|
109
|
+
|
|
110
|
+
Set email sign-in, the backup sign-in or both, or nobody can sign in.
|
|
111
|
+
|
|
112
|
+
A site that uses images, video or forms needs these as well. The image variables and
|
|
113
|
+
`TURNSTILE_SITE_KEY` are read at build too, so set them in both places.
|
|
114
|
+
|
|
115
|
+
| Feature | Variables |
|
|
116
|
+
|---|---|
|
|
117
|
+
| Images (the media bucket and imgproxy) | `CARACMS_MEDIA_URL` (imgproxy's address), `IMGPROXY_KEY`, `IMGPROXY_SALT`, `S3_BUCKET`, `S3_ENDPOINT`, `S3_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` |
|
|
118
|
+
| Video | `MUX_TOKEN_ID`, `MUX_TOKEN_SECRET` |
|
|
119
|
+
| Form submissions (a second bucket, at the media bucket's endpoint and region) | `SUBMISSIONS_S3_BUCKET`, `SUBMISSIONS_S3_ACCESS_KEY_ID`, `SUBMISSIONS_S3_SECRET_ACCESS_KEY` |
|
|
120
|
+
| Form emails | `RESEND_API_KEY`, `FORMS_FROM` (as above) |
|
|
121
|
+
| Spam checks on forms | `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY` |
|
|
122
|
+
| A password on the whole site before launch | `CARACMS_PRELAUNCH` |
|
|
123
|
+
| Try again through Railway's API | `CARACMS_RAILWAY_TOKEN` (Railway sets the rest) |
|
|
124
|
+
|
|
125
|
+
When something required is missing or wrong, the public site still serves and `/admin`
|
|
126
|
+
answers 503 with a sentence saying what to fix. For example:
|
|
127
|
+
|
|
128
|
+
> Both a GitHub App (GITHUB_APP_ID) and a token (CARACMS_GITHUB_TOKEN) are set: set one
|
|
129
|
+
> of them, not both.
|
|
130
|
+
|
|
131
|
+
> The site's public address isn't known: set SITE_URL to it, like https://fernhill.ie,
|
|
132
|
+
> both when the site is built and when it runs. Without it, sign-in links can't be sent
|
|
133
|
+
> and the editor's saves are refused.
|
|
134
|
+
|
|
135
|
+
> Both CARACMS_DEPLOY_HOOK_URL and CARACMS_RAILWAY_TOKEN are set: Try again redeploys
|
|
136
|
+
> through one of them, so set only one.
|
|
137
|
+
|
|
138
|
+
If the site is opened at an address other than `SITE_URL` (say `https://fernhill.ie`
|
|
139
|
+
when `SITE_URL` is `https://www.fernhill.ie`), signing in and saving are refused with:
|
|
140
|
+
|
|
141
|
+
> This site's address is set to https://www.fernhill.ie (SITE_URL), but it was opened at
|
|
142
|
+
> https://fernhill.ie, so signing in and saving are refused here. Open it at
|
|
143
|
+
> https://www.fernhill.ie, or set SITE_URL to https://fernhill.ie where the site is built
|
|
144
|
+
> and where it runs.
|
|
145
|
+
|
|
146
|
+
### The visitor's IP
|
|
147
|
+
|
|
148
|
+
`caracms start` takes each visitor's IP from one source, set by
|
|
149
|
+
`CARACMS_CLIENT_IP_HEADER`, one of five values: a header the nearest proxy sets and
|
|
150
|
+
overwrites (`x-real-ip`, `fly-client-ip` or `cf-connecting-ip`), `x-forwarded-for` (its
|
|
151
|
+
last entry, the one the nearest proxy added), or `socket` for a server visitors reach
|
|
152
|
+
directly. Any other value is refused. On Railway, Fly and Render it has a
|
|
153
|
+
default, and a set value always wins. Anywhere else it won't start without it:
|
|
154
|
+
|
|
155
|
+
> Set CARACMS_CLIENT_IP_HEADER to the header your proxy sets: x-real-ip for nginx with
|
|
156
|
+
> `proxy_set_header X-Real-IP $remote_addr`, x-forwarded-for for Caddy, or socket if
|
|
157
|
+
> nothing sits in front of this server.
|
|
158
|
+
|
|
159
|
+
It prints where the IP comes from when it starts.
|
|
160
|
+
|
|
161
|
+
### Railway
|
|
162
|
+
|
|
163
|
+
- IP: `x-real-ip`, by default.
|
|
164
|
+
- Commit: `RAILWAY_GIT_COMMIT_SHA`, which Railway sets.
|
|
165
|
+
- `caracms init --railway` writes `.railway/railway.ts`, which describes the whole
|
|
166
|
+
project (the site, Redis, the buckets, imgproxy). Railway's own variables cover the
|
|
167
|
+
repo, so `GITHUB_REPO` isn't needed.
|
|
168
|
+
- Try again: `CARACMS_RAILWAY_TOKEN`, a project token. Railway has no deploy hook.
|
|
169
|
+
|
|
170
|
+
### Render
|
|
171
|
+
|
|
172
|
+
- IP: `x-forwarded-for`'s last entry, by default. Render documents no header it
|
|
173
|
+
overwrites, and this default hasn't been checked against a running Render service
|
|
174
|
+
yet. Check it before relying on the sign-in limits. `*.onrender.com` sits behind
|
|
175
|
+
Cloudflare: if the last entry there is a Cloudflare address, every visitor to the
|
|
176
|
+
`onrender.com` address shares one bucket (caracms never reads `CF-Connecting-IP` on a
|
|
177
|
+
host's own address). On the site's own domain it's read as usual.
|
|
178
|
+
- Commit: `RENDER_GIT_COMMIT`, which Render sets.
|
|
179
|
+
- One instance: don't turn on autoscaling.
|
|
180
|
+
- A web service with build command `npm ci && npm run build` and start command
|
|
181
|
+
`npm start`. Render's environment variables are there at build and at run time.
|
|
182
|
+
- Try again: set `CARACMS_DEPLOY_HOOK_URL` to the service's deploy hook. Render's hook
|
|
183
|
+
takes a bare POST.
|
|
184
|
+
|
|
185
|
+
### Fly
|
|
186
|
+
|
|
187
|
+
- IP: `fly-client-ip`, by default. Fly's proxy sets it and replaces a forged one.
|
|
188
|
+
- Commit: Fly sets none. Pass it as a build argument, and declare it in the Dockerfile
|
|
189
|
+
before the build step (`ARG CARACMS_RUNNING_COMMIT`), so the build bakes it in:
|
|
190
|
+
|
|
191
|
+
```sh
|
|
192
|
+
fly deploy --build-arg CARACMS_RUNNING_COMMIT=$(git rev-parse HEAD)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
In a GitHub Action, use `${{ github.sha }}`.
|
|
196
|
+
- `SITE_URL` at build: the variables in `fly.toml`'s `[env]` and Fly's secrets are
|
|
197
|
+
for run time only. Pass `SITE_URL` (and the image variables, if the site has images)
|
|
198
|
+
as build arguments too, declared with `ARG`.
|
|
199
|
+
- One machine: `fly launch --ha=false` (or `fly scale count 1` later). Fly starts two
|
|
200
|
+
by default.
|
|
201
|
+
- Deploys: Fly doesn't deploy on a push. Add a GitHub Action that runs `fly deploy` on
|
|
202
|
+
a push to `main`.
|
|
203
|
+
|
|
204
|
+
### A VPS, behind Caddy or nginx
|
|
205
|
+
|
|
206
|
+
- IP: Caddy adds the visitor's address to `X-Forwarded-For`, so set
|
|
207
|
+
`CARACMS_CLIENT_IP_HEADER=x-forwarded-for`. With nginx, set
|
|
208
|
+
`CARACMS_CLIENT_IP_HEADER=x-real-ip` and pass the headers on:
|
|
209
|
+
|
|
210
|
+
```nginx
|
|
211
|
+
location / {
|
|
212
|
+
proxy_pass http://127.0.0.1:8080;
|
|
213
|
+
proxy_set_header Host $host;
|
|
214
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
215
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Caddy passes the host and the scheme on by itself: `reverse_proxy 127.0.0.1:8080`.
|
|
220
|
+
- Commit: a build in a git checkout bakes in `git rev-parse HEAD`. Otherwise set
|
|
221
|
+
`CARACMS_RUNNING_COMMIT`. Coolify sets `SOURCE_COMMIT`, which caracms reads.
|
|
222
|
+
- Keep the server running with systemd or similar. `caracms start` listens on `PORT`
|
|
223
|
+
(default 8080).
|
|
224
|
+
- Deploys: Coolify, or a GitHub Action that pulls, builds and restarts on a push to
|
|
225
|
+
`main`.
|
|
226
|
+
- If the running commit isn't one in the repo (a server built from a commit that was
|
|
227
|
+
never pushed), no publish can ever show as live on it: each one reads as "taking
|
|
228
|
+
longer than usual".
|
|
229
|
+
|
|
230
|
+
### Redis
|
|
231
|
+
|
|
232
|
+
Any Redis works by URL: Railway's, Render Key Value, a local one, or Upstash. For
|
|
233
|
+
Upstash, use its `rediss://` URL as `REDIS_URL`, with the database in the same region
|
|
234
|
+
as the site.
|
|
235
|
+
|
|
236
|
+
### GitHub: a token or an App
|
|
237
|
+
|
|
238
|
+
caracms writes to the site's repo with one of two credentials. Set one, not both.
|
|
239
|
+
|
|
240
|
+
**A fine-grained token** is the easy one:
|
|
241
|
+
|
|
242
|
+
1. On GitHub: Settings → Developer settings → Personal access tokens → Fine-grained
|
|
243
|
+
tokens → Generate new token.
|
|
244
|
+
2. Resource owner: whoever owns the site's repo. Repository access: only that one repo.
|
|
245
|
+
3. Permissions: Contents, read and write. Metadata, read (GitHub adds it). Deployments,
|
|
246
|
+
read, if the host writes GitHub Deployments (Railway does): it lets the editor show
|
|
247
|
+
a failed deploy.
|
|
248
|
+
4. Pick an expiry (a year at most; an organisation may set less) and set the token as
|
|
249
|
+
`CARACMS_GITHUB_TOKEN`.
|
|
250
|
+
|
|
251
|
+
caracms never reads `GITHUB_TOKEN` (the `gh` CLI and GitHub Actions set it), and refuses
|
|
252
|
+
a classic token:
|
|
253
|
+
|
|
254
|
+
> CARACMS_GITHUB_TOKEN is a classic token, which reaches every repo its owner can: use a
|
|
255
|
+
> fine-grained token (it starts with github_pat_) with access to this site's repo only.
|
|
256
|
+
|
|
257
|
+
What a token costs:
|
|
258
|
+
|
|
259
|
+
- Its rate limits belong to the person, shared by every site on their tokens: 5,000
|
|
260
|
+
requests an hour, and 80 commits a minute and 500 an hour. A few sites edited hard at
|
|
261
|
+
the same moment can reach the hourly cap. Past a handful of sites on one person's
|
|
262
|
+
tokens, use the App.
|
|
263
|
+
- Commits show as the token's owner, not the App. Commit messages still carry the
|
|
264
|
+
editor's name.
|
|
265
|
+
- Publish's commits on `main`, and merges from `main`, are unsigned (GitHub signs only
|
|
266
|
+
the drafts' commits). A "require signed commits" rule on `main` needs the App.
|
|
267
|
+
- No webhook comes with it. Add one by hand in the repo's settings (`POST
|
|
268
|
+
/_caracms/github`, with `GITHUB_WEBHOOK_SECRET`), or leave it: a check once a minute
|
|
269
|
+
does the same job.
|
|
270
|
+
- It expires, and it's tied to a person's account. The editor warns 14 days before it
|
|
271
|
+
expires, and says so if GitHub refuses it or a permission is missing.
|
|
272
|
+
|
|
273
|
+
**A GitHub App** (`GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`) has its own rate limit per
|
|
274
|
+
site, signs every commit, and isn't tied to a person. Use it for anyone running more than
|
|
275
|
+
a handful of sites, or a repo that requires signed commits. Install it on the site's repo
|
|
276
|
+
only, with the same permissions as the token.
|
|
277
|
+
|
|
278
|
+
### Behind Cloudflare
|
|
279
|
+
|
|
280
|
+
caracms sees through Cloudflare's proxy (the orange cloud) by itself: when the address
|
|
281
|
+
from the host's header is one of Cloudflare's and the request came in on the site's own
|
|
282
|
+
domain, it uses `CF-Connecting-IP` instead. On a host's own address (`*.up.railway.app`,
|
|
283
|
+
`*.onrender.com`, `*.fly.dev`), localhost or a bare IP it never does. Watch for:
|
|
284
|
+
|
|
285
|
+
- **`CF-Connecting-IP` can be forged around Cloudflare.** Render and Fly give the site its
|
|
286
|
+
own address too (`*.onrender.com`, `*.fly.dev`), and anyone calling it directly can set
|
|
287
|
+
the header. caracms only trusts it on requests that came from Cloudflare's addresses,
|
|
288
|
+
so leave `CARACMS_CLIENT_IP_HEADER` alone: never set it to `cf-connecting-ip` unless
|
|
289
|
+
the server accepts Cloudflare's traffic alone.
|
|
290
|
+
- **Cloudflare Workers.** A Worker on any Cloudflare account can call a server that isn't
|
|
291
|
+
itself on Cloudflare, from Cloudflare's addresses, and set `CF-Connecting-IP` to
|
|
292
|
+
anything. So put the site's own domain on Cloudflare's proxy: a Worker fetching a
|
|
293
|
+
proxied domain goes through your zone, where Cloudflare sets the header itself, and
|
|
294
|
+
one fetching the host's own address is ignored (above). A domain that isn't proxied
|
|
295
|
+
(the grey cloud) can be reached this way. On a VPS, also accept only your own zone's
|
|
296
|
+
traffic: Authenticated Origin Pulls with your zone's own certificate, or a Cloudflare
|
|
297
|
+
Tunnel.
|
|
298
|
+
- **No "Cache Everything" rule over `/admin` or `/_caracms/*`.**
|
|
299
|
+
- **Bot Fight Mode** (the free plan's) can't exempt a path, so it may challenge GitHub's
|
|
300
|
+
webhook. The once-a-minute check covers it.
|
|
301
|
+
- **Cloudflare gives up on an answer after 100 seconds.** Publish answers within 60
|
|
302
|
+
seconds and carries on if it needs longer; the editor follows it.
|
package/bin/caracms.mjs
CHANGED
|
@@ -16,24 +16,26 @@
|
|
|
16
16
|
//
|
|
17
17
|
// It runs from the site's root (the directory with `dist/` and `content/`) and reads,
|
|
18
18
|
// once at start:
|
|
19
|
-
// PORT (default 8080), HOST (default 0.0.0.0),
|
|
20
|
-
//
|
|
19
|
+
// PORT (default 8080), HOST (default 0.0.0.0), the site's address (Astro's `site` from
|
|
20
|
+
// dist/server/caracms-build.json, else SITE_URL, else RAILWAY_PUBLIC_DOMAIN),
|
|
21
|
+
// CARACMS_PRELAUNCH (`user:password`), CARACMS_CLIENT_IP_HEADER (else the detected
|
|
22
|
+
// host's default; with neither, it exits with the fix), and content/site-settings.json → brand.name.
|
|
21
23
|
|
|
22
24
|
import { existsSync, readFileSync } from 'node:fs';
|
|
23
25
|
import { createServer } from 'node:http';
|
|
24
26
|
import { join } from 'node:path';
|
|
25
27
|
import { pathToFileURL } from 'node:url';
|
|
26
|
-
import { createGate, parsePrelaunch, parseSiteUrl } from './gate.mjs';
|
|
28
|
+
import { createGate, describeClientIpSource, markProcessGated, parsePrelaunch, parseSiteUrl, publicAddress, readBuildInfo, readClientIpSource } from './gate.mjs';
|
|
27
29
|
|
|
28
30
|
const USAGE = `Usage: caracms <command>, from the site's root
|
|
29
31
|
|
|
30
|
-
init [--write]
|
|
31
|
-
schemas
|
|
32
|
-
validate [--fix] [--json]
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
media import <files…>
|
|
36
|
-
start
|
|
32
|
+
init [--write] [--railway] Add caracms to an existing Astro site. Shows the change; --write applies it.
|
|
33
|
+
schemas Write schemas/ from caracms.config.ts, and add $schema to content files.
|
|
34
|
+
validate [--fix] [--json] Check content against caracms.config.ts. --fix runs \`schemas\` first,
|
|
35
|
+
then lays content files out as the editor saves them;
|
|
36
|
+
--json prints { errors, warnings }.
|
|
37
|
+
media import <files…> Put local images into the site's media bucket; prints JSON.
|
|
38
|
+
start Serve the built site (dist/server/entry.mjs).
|
|
37
39
|
`;
|
|
38
40
|
|
|
39
41
|
/** The command running, for `fatal`'s sentence: set by `main`. */
|
|
@@ -64,18 +66,28 @@ async function start() {
|
|
|
64
66
|
if (!Number.isInteger(port) || port < 0 || port > 65535) fatal(`PORT must be a port number, not "${portText}".`);
|
|
65
67
|
const host = process.env.HOST || '0.0.0.0';
|
|
66
68
|
|
|
69
|
+
// Decision 8: the one setting that stops the public site, on purpose. Read before the site loads.
|
|
70
|
+
const ipSource = readClientIpSource(process.env);
|
|
71
|
+
if (ipSource.problem) fatal(ipSource.problem);
|
|
72
|
+
|
|
67
73
|
// A built site is a production server: the submissions store and the email sender read NODE_ENV (M6 Decision 7),
|
|
68
74
|
// and this command is the one thing that starts the built site. An explicit NODE_ENV is left as it is.
|
|
69
75
|
process.env.NODE_ENV ??= 'production';
|
|
70
76
|
// The adapter starts its own server on import unless told not to.
|
|
71
77
|
process.env.ASTRO_NODE_AUTOSTART = 'disabled';
|
|
78
|
+
// Decision 8: the built site runs in this process (the import below), and the only
|
|
79
|
+
// server it gets is `createServer(gate)`, so every request it sees has had
|
|
80
|
+
// x-caracms-client-ip replaced by the gate. The mark tells `clientIp()` so; a server
|
|
81
|
+
// started any other way lacks it and answers 503, a forged header or not.
|
|
82
|
+
markProcessGated();
|
|
72
83
|
const built = await import(pathToFileURL(entry).href);
|
|
73
84
|
if (typeof built.handler !== 'function') fatal(`${entry} exports no handler. Is the site built with @astrojs/node?`);
|
|
74
85
|
if (built.options && built.options.mode !== 'standalone') fatal(`the site's Node adapter must be in standalone mode, not "${built.options.mode}".`);
|
|
75
86
|
|
|
76
|
-
const
|
|
87
|
+
const publicSite = publicAddress(process.env, readBuildInfo(root));
|
|
88
|
+
const siteUrl = publicSite?.value;
|
|
77
89
|
const prelaunch = process.env.CARACMS_PRELAUNCH;
|
|
78
|
-
const gate = createGate({ handler: built.handler, siteUrl, prelaunch, brandName: readBrandName(root) });
|
|
90
|
+
const gate = createGate({ handler: built.handler, clientIpSource: ipSource.source, siteUrl, prelaunch, brandName: readBrandName(root) });
|
|
79
91
|
|
|
80
92
|
const server = createServer(gate);
|
|
81
93
|
server.on('error', (err) => fatal(err.message));
|
|
@@ -83,9 +95,13 @@ async function start() {
|
|
|
83
95
|
const address = server.address();
|
|
84
96
|
const shown = typeof address === 'object' && address ? `${address.address.includes(':') ? `[${address.address}]` : address.address}:${address.port}` : `${host}:${port}`;
|
|
85
97
|
const site = parseSiteUrl(siteUrl);
|
|
86
|
-
const indexing = site?.indexable
|
|
98
|
+
const indexing = site?.indexable
|
|
99
|
+
? `indexable on ${site.host} only (${publicSite.from})`
|
|
100
|
+
: `never indexable (${!publicSite ? 'no address: SITE_URL is unset' : !site ? `${publicSite.from} isn't an http(s) address` : `the address, from ${publicSite.from}, is localhost, a host's own address or a bare IP`})`;
|
|
87
101
|
const password = parsePrelaunch(prelaunch) ? 'on (CARACMS_PRELAUNCH)' : 'off';
|
|
88
|
-
console.log(
|
|
102
|
+
console.log(
|
|
103
|
+
`caracms start: listening on http://${shown}\n client IP: ${describeClientIpSource(ipSource)}\n indexing: ${indexing}\n pre-launch password: ${password}`,
|
|
104
|
+
);
|
|
89
105
|
});
|
|
90
106
|
|
|
91
107
|
const stop = () => {
|
|
@@ -137,10 +153,10 @@ async function main(argv) {
|
|
|
137
153
|
if (rest.length) return usage(`start takes no arguments.`);
|
|
138
154
|
return start();
|
|
139
155
|
case 'init': {
|
|
140
|
-
const f = flags('init', rest, ['--write']);
|
|
156
|
+
const f = flags('init', rest, ['--write', '--railway']);
|
|
141
157
|
if (f.error) return usage(f.error);
|
|
142
158
|
const { run } = await import('./init.mjs');
|
|
143
|
-
process.exitCode = await run({ root: process.cwd(), write: f.set.has('--write') });
|
|
159
|
+
process.exitCode = await run({ root: process.cwd(), write: f.set.has('--write'), railway: f.set.has('--railway') });
|
|
144
160
|
return;
|
|
145
161
|
}
|
|
146
162
|
case 'schemas':
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Cloudflare's published IP ranges, from https://www.cloudflare.com/ips-v4/ and
|
|
2
|
+
// https://www.cloudflare.com/ips-v6/ (listed at cloudflare.com/ips), fetched 2026-10-24.
|
|
3
|
+
//
|
|
4
|
+
// The gate (gate.mjs) uses them to tell a request that came through Cloudflare's proxy:
|
|
5
|
+
// when the address its IP source gives is in one of these, it takes CF-Connecting-IP
|
|
6
|
+
// instead (runs-anywhere spec, Decision 8), but only on the site's own domain: a Worker
|
|
7
|
+
// on anyone's account calls from these ranges and can set CF-Connecting-IP to a host's
|
|
8
|
+
// own address, so the gate's Host rule (isPlatformHost) is what keeps that out, not this
|
|
9
|
+
// list. Cloudflare changes them rarely; a stale list means one shared bucket for a new
|
|
10
|
+
// range. To refresh: copy both lists here and change the date above.
|
|
11
|
+
|
|
12
|
+
export const CLOUDFLARE_IPV4 = [
|
|
13
|
+
'173.245.48.0/20',
|
|
14
|
+
'103.21.244.0/22',
|
|
15
|
+
'103.22.200.0/22',
|
|
16
|
+
'103.31.4.0/22',
|
|
17
|
+
'141.101.64.0/18',
|
|
18
|
+
'108.162.192.0/18',
|
|
19
|
+
'190.93.240.0/20',
|
|
20
|
+
'188.114.96.0/20',
|
|
21
|
+
'197.234.240.0/22',
|
|
22
|
+
'198.41.128.0/17',
|
|
23
|
+
'162.158.0.0/15',
|
|
24
|
+
'104.16.0.0/13',
|
|
25
|
+
'104.24.0.0/14',
|
|
26
|
+
'172.64.0.0/13',
|
|
27
|
+
'131.0.72.0/22',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export const CLOUDFLARE_IPV6 = [
|
|
31
|
+
'2400:cb00::/32',
|
|
32
|
+
'2606:4700::/32',
|
|
33
|
+
'2803:f800::/32',
|
|
34
|
+
'2405:b500::/32',
|
|
35
|
+
'2405:8100::/32',
|
|
36
|
+
'2a06:98c0::/29',
|
|
37
|
+
'2c0f:f248::/32',
|
|
38
|
+
];
|