caracms 0.1.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.
Files changed (93) hide show
  1. package/README.md +247 -3
  2. package/bin/caracms.mjs +32 -16
  3. package/bin/cloudflare-ips.mjs +38 -0
  4. package/bin/gate.mjs +277 -4
  5. package/bin/init.mjs +65 -20
  6. package/bin/templates/catch-all.astro.txt +3 -2
  7. package/package.json +1 -1
  8. package/skills/caracms-adopt/SKILL.md +17 -3
  9. package/skills/caracms-adopt/look-same.mjs +24 -11
  10. package/skills/caracms-blocks/SKILL.md +69 -4
  11. package/skills/caracms-content/SKILL.md +2 -1
  12. package/skills/caracms-tina/SKILL.md +431 -0
  13. package/skills/caracms-tina/field-kinds.md +88 -0
  14. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  15. package/src/block-runs.ts +84 -0
  16. package/src/cli/validate.ts +10 -5
  17. package/src/components/BlockRunTree.astro +45 -0
  18. package/src/components/Blocks.astro +20 -1
  19. package/src/components/Form.astro +76 -10
  20. package/src/components/Head.astro +4 -2
  21. package/src/components/Media.astro +27 -1
  22. package/src/components/RichText.astro +12 -4
  23. package/src/components/Text.astro +32 -9
  24. package/src/components/form-client.ts +7 -4
  25. package/src/components/sitemap.xml.ts +2 -1
  26. package/src/config.ts +32 -1
  27. package/src/content-schema.ts +7 -4
  28. package/src/content.ts +28 -0
  29. package/src/document.ts +15 -2
  30. package/src/editor/client/block-list/summary.ts +7 -1
  31. package/src/editor/client/editor.ts +24 -6
  32. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  33. package/src/editor/client/fields/LinkField.tsx +17 -12
  34. package/src/editor/client/fields/ReferenceField.tsx +6 -0
  35. package/src/editor/client/fields/RichTextField.tsx +38 -32
  36. package/src/editor/client/fields/host.tsx +5 -0
  37. package/src/editor/client/fields/parts.tsx +2 -1
  38. package/src/editor/client/fields/rich-text-doc.ts +28 -2
  39. package/src/editor/client/fields/rich-text-schema.ts +41 -2
  40. package/src/editor/client/live/publish.tsx +20 -10
  41. package/src/editor/client/nav/DocRow.tsx +6 -0
  42. package/src/editor/client/overlays/rich-text-bar.ts +6 -6
  43. package/src/editor/client/pane/levels/DocumentLevel.tsx +3 -0
  44. package/src/editor/client/pane/parts.tsx +34 -2
  45. package/src/editor/client/preview/driver.ts +5 -5
  46. package/src/editor/client/preview/field-text.ts +40 -2
  47. package/src/editor/client/preview/page-edit.ts +2 -2
  48. package/src/editor/client/preview/page-editor-registry.ts +4 -3
  49. package/src/editor/client/preview/page-editor.ts +3 -3
  50. package/src/editor/client/state/document-store.ts +20 -2
  51. package/src/editor/client/state/keeper.ts +51 -6
  52. package/src/editor/client/state/live.ts +45 -6
  53. package/src/editor/middleware.ts +3 -2
  54. package/src/editor/routes/try-again.ts +2 -1
  55. package/src/editor/server/admin-props.ts +2 -1
  56. package/src/editor/server/mode.ts +13 -3
  57. package/src/editor/styles/editor.css +2 -0
  58. package/src/editor/styles/levels/live.css +1 -1
  59. package/src/editor/types.ts +27 -6
  60. package/src/fields.ts +63 -15
  61. package/src/files-route.ts +9 -4
  62. package/src/forms/endpoint.ts +11 -16
  63. package/src/forms/rules.ts +9 -1
  64. package/src/head.ts +17 -4
  65. package/src/index.ts +53 -7
  66. package/src/links.ts +35 -0
  67. package/src/live/README.md +5 -2
  68. package/src/live/auth/auth.ts +4 -1
  69. package/src/live/auth/guard.ts +3 -2
  70. package/src/live/auth/index.ts +6 -7
  71. package/src/live/auth/login.ts +17 -8
  72. package/src/live/auth/middleware.ts +7 -3
  73. package/src/live/auth/request.ts +92 -11
  74. package/src/live/auth/views.ts +9 -4
  75. package/src/live/autosaves.ts +59 -16
  76. package/src/live/backend.ts +54 -24
  77. package/src/live/branches.ts +19 -0
  78. package/src/live/drafts-store.ts +4 -3
  79. package/src/live/editor.ts +92 -67
  80. package/src/live/git.ts +112 -34
  81. package/src/live/github.ts +128 -13
  82. package/src/live/locks.ts +92 -26
  83. package/src/live/mode.ts +177 -18
  84. package/src/live/notices.ts +100 -0
  85. package/src/live/redis.ts +161 -39
  86. package/src/live/runtime.ts +15 -4
  87. package/src/live/status-cache.ts +219 -0
  88. package/src/live/status.ts +14 -2
  89. package/src/live/timings.ts +24 -0
  90. package/src/media.ts +40 -7
  91. package/src/read.ts +2 -1
  92. package/src/rich-text.ts +30 -5
  93. package/src/serialize.ts +10 -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 site's skills,
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), SITE_URL, CARACMS_PRELAUNCH
20
- // (`user:password`), and content/site-settings.json → brand.name.
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] Add caracms to an existing Astro site. Shows the change; --write applies it.
31
- schemas Write schemas/ from caracms.config.ts, and add $schema to content files.
32
- validate [--fix] [--json] Check content against caracms.config.ts. --fix runs \`schemas\` first,
33
- then lays content files out as the editor saves them;
34
- --json prints { errors, warnings }.
35
- media import <files…> Put local images into the site's media bucket; prints JSON.
36
- start Serve the built site (dist/server/entry.mjs).
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 siteUrl = process.env.SITE_URL;
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 ? `indexable on ${site.host} only` : 'never indexable (SITE_URL is unset, or not a custom domain)';
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(`caracms start: listening on http://${shown}\n indexing: ${indexing}\n pre-launch password: ${password}`);
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
+ ];