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.
Files changed (50) 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 +64 -20
  6. package/package.json +1 -1
  7. package/skills/caracms-adopt/SKILL.md +5 -3
  8. package/skills/caracms-adopt/look-same.mjs +8 -4
  9. package/skills/caracms-blocks/SKILL.md +20 -2
  10. package/skills/caracms-tina/SKILL.md +14 -10
  11. package/src/components/Head.astro +3 -2
  12. package/src/content.ts +2 -0
  13. package/src/editor/client/editor.ts +24 -6
  14. package/src/editor/client/live/publish.tsx +20 -10
  15. package/src/editor/client/pane/parts.tsx +34 -2
  16. package/src/editor/client/state/keeper.ts +51 -6
  17. package/src/editor/client/state/live.ts +45 -6
  18. package/src/editor/middleware.ts +3 -2
  19. package/src/editor/routes/try-again.ts +2 -1
  20. package/src/editor/server/admin-props.ts +2 -1
  21. package/src/editor/server/mode.ts +13 -3
  22. package/src/editor/styles/levels/live.css +1 -1
  23. package/src/editor/types.ts +25 -6
  24. package/src/files-route.ts +9 -4
  25. package/src/forms/endpoint.ts +11 -16
  26. package/src/head.ts +10 -1
  27. package/src/index.ts +53 -7
  28. package/src/live/README.md +4 -2
  29. package/src/live/auth/auth.ts +4 -1
  30. package/src/live/auth/guard.ts +3 -2
  31. package/src/live/auth/index.ts +6 -7
  32. package/src/live/auth/login.ts +17 -8
  33. package/src/live/auth/middleware.ts +7 -3
  34. package/src/live/auth/request.ts +92 -11
  35. package/src/live/auth/views.ts +9 -4
  36. package/src/live/autosaves.ts +59 -16
  37. package/src/live/backend.ts +13 -1
  38. package/src/live/drafts-store.ts +4 -3
  39. package/src/live/editor.ts +92 -67
  40. package/src/live/git.ts +112 -34
  41. package/src/live/github.ts +128 -13
  42. package/src/live/locks.ts +92 -26
  43. package/src/live/mode.ts +131 -18
  44. package/src/live/notices.ts +100 -0
  45. package/src/live/redis.ts +161 -39
  46. package/src/live/runtime.ts +11 -3
  47. package/src/live/status-cache.ts +219 -0
  48. package/src/live/status.ts +14 -2
  49. package/src/live/timings.ts +24 -0
  50. package/src/media.ts +26 -1
@@ -29,7 +29,7 @@
29
29
  * `CARACMS_PRELAUNCH` in this same process), so a shared cache never keeps it.
30
30
  */
31
31
  import type { APIRoute } from 'astro';
32
- import { clientIp } from './live/auth/request.ts';
32
+ import { clientIp, NO_GATE_MESSAGE } from './live/auth/request.ts';
33
33
  import { fileAddress, fileKeyOf, fileTypeOf } from './files.ts';
34
34
  import { s3Bucket, type Bucket } from './editor/server/media/bucket.ts';
35
35
  import { readImagesConfig } from './editor/server/media/config.ts';
@@ -67,7 +67,7 @@ export interface FileRouteContext {
67
67
  /** Default `process.env`, read on each request. */
68
68
  env?: Env;
69
69
  production?: boolean;
70
- /** The socket's address (Astro's `clientAddress`), read after `X-Real-IP`. */
70
+ /** The socket's address (Astro's `clientAddress`), read after `X-Real-IP` under `astro dev`. */
71
71
  clientAddress?: () => string | undefined;
72
72
  /** Default the process's own (`filesLimiter`). */
73
73
  limiter?: RateLimiter;
@@ -147,10 +147,15 @@ export async function handleFileRequest(request: Request, ctx: FileRouteContext
147
147
  }
148
148
  }
149
149
 
150
+ // Runs-anywhere Decision 8: a production server not started by `caracms start` has no gate, so no visitor's IP for the limit.
151
+ const production = ctx.production ?? isProductionBuild();
152
+ const ip = clientIp(request, ctx.clientAddress ?? (() => undefined), production);
153
+ if (ip === null) return text(503, NO_GATE_MESSAGE, {}, head);
154
+
150
155
  // 4. The media variables.
151
156
  const env = ctx.env ?? process.env;
152
157
  const log = ctx.log ?? ((line: string) => console.error(line));
153
- const config = readImagesConfig(env, ctx.production ?? isProductionBuild());
158
+ const config = readImagesConfig(env, production);
154
159
  if (config.kind === 'unset') {
155
160
  logOnce(log, `[caracms] files: /files/ answers 503: ${config.message}${config.missing.length ? ` (missing: ${config.missing.join(', ')})` : ''}`);
156
161
  return text(503, UNAVAILABLE_TEXT, {}, head);
@@ -158,7 +163,7 @@ export async function handleFileRequest(request: Request, ctx: FileRouteContext
158
163
 
159
164
  // 5. The visitor's allowance.
160
165
  const limiter = ctx.limiter ?? filesLimiter(env);
161
- if (await limiter.hit(clientIp(request, ctx.clientAddress ?? (() => undefined)))) {
166
+ if (await limiter.hit(ip)) {
162
167
  return text(429, RATE_LIMITED_TEXT, { 'Retry-After': String(Math.ceil(FILES_LIMIT.windowMs / 1000)) }, head);
163
168
  }
164
169
 
@@ -37,8 +37,9 @@
37
37
  */
38
38
  import type { ResolvedSite } from '../config.ts';
39
39
  import { publicOrigin } from '../live/auth/index.ts';
40
- import { clientIp } from '../live/auth/request.ts';
40
+ import { clientIp, NO_GATE_MESSAGE } from '../live/auth/request.ts';
41
41
  import { readCapped } from '../editor/server/media/upload.ts';
42
+ import { isProductionBuild } from '../media.ts';
42
43
  import { readFormSettings, sendNotification, type FormSettings } from './email.ts';
43
44
  import { formLimiter, type FormRateLimiter } from './rate-limit.ts';
44
45
  import { cleanReturn, FILE_KINDS, FILE_TYPES, formsAcceptFiles, HONEYPOT, looksLikeEmail, MAX_FILE_BYTES, MAX_FILES, type ResolvedForm } from './rules.ts';
@@ -65,24 +66,14 @@ export const BAD_EMAIL_MESSAGE = "That email address doesn't look right.";
65
66
  export const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
66
67
  const TURNSTILE_TIMEOUT_MS = 5_000;
67
68
 
68
- /** `SITE_URL` as an address (an http(s) origin), or undefined when it's unset or isn't one. */
69
- function siteUrlFromEnv(env: Env): URL | undefined {
70
- const raw = env['SITE_URL']?.trim();
71
- if (!raw) return undefined;
72
- try {
73
- const url = new URL(raw);
74
- return url.protocol === 'https:' || url.protocol === 'http:' ? url : undefined;
75
- } catch {
76
- return undefined;
77
- }
78
- }
79
-
80
69
  export { cleanReturn };
81
70
 
82
71
  export interface FormContext {
83
72
  site: Pick<ResolvedSite, 'root' | 'forms' | 'timeZone'>;
84
- /** The socket's address (Astro's `clientAddress`), read after `X-Real-IP`. */
73
+ /** The socket's address (Astro's `clientAddress`), read after `X-Real-IP` under `astro dev`. */
85
74
  clientAddress?: () => string | undefined;
75
+ /** A production server: the IP comes from `caracms start`'s gate only (runs-anywhere Decision 8). Default: the build's define. */
76
+ production?: boolean;
86
77
  env?: Env;
87
78
  /** Astro's `site` (the route passes `context.site`): the public origin for the email's editor link. */
88
79
  astroSite?: URL;
@@ -244,7 +235,11 @@ export async function handleForm(request: Request, ctx: FormContext): Promise<Re
244
235
  return reply(405, { error: 'This address takes a form post.' }, { Allow: 'POST' });
245
236
  }
246
237
 
247
- const ip = clientIp(request, ctx.clientAddress ?? (() => undefined));
238
+ const ip = clientIp(request, ctx.clientAddress ?? (() => undefined), ctx.production ?? isProductionBuild());
239
+ if (ip === null) {
240
+ await request.body?.cancel().catch(() => {});
241
+ return reply(503, { error: NO_GATE_MESSAGE });
242
+ }
248
243
  const takesFiles = formsAcceptFiles(ctx.site.forms);
249
244
  const declared = Number(request.headers.get('content-length') ?? NaN);
250
245
  const multipart = (request.headers.get('content-type') ?? '').toLowerCase().includes('multipart/form-data');
@@ -332,7 +327,7 @@ export async function handleForm(request: Request, ctx: FormContext): Promise<Re
332
327
  let email;
333
328
  try {
334
329
  const settings = await (ctx.readSettings ?? (() => readFormSettings(ctx.site.root)))();
335
- const siteUrl = publicOrigin(ctx.astroSite ?? siteUrlFromEnv(env), env);
330
+ const siteUrl = publicOrigin(ctx.astroSite, env);
336
331
  email = await sendNotification({ submission, form, settings, siteUrl, timeZone: ctx.site.timeZone, env, fetch: ctx.fetch, now, log });
337
332
  } catch (error) {
338
333
  email = { status: 'failed' as const, at: now().toISOString(), to: [], error: `Could not read Site Settings: ${error instanceof Error ? error.message : String(error)}` };
package/src/head.ts CHANGED
@@ -104,12 +104,20 @@ export function googleTagSnippet(tag: NonNullable<Head['googleTag']>): string {
104
104
  return `<script async src="https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(tag.id)}"></script><script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config',${id});</script>`;
105
105
  }
106
106
 
107
+ /**
108
+ * An address served from a `build.format: 'file'` prerender (`/about.html`, Home at
109
+ * `/index.html`) as the page's own address (`/about`, `/`): a document's address never ends in
110
+ * `.html` (Urban Return port). Others are kept as they are.
111
+ */
112
+ export const withoutHtml = (path: string): string => path.replace(/(?:\/index)?\.html$/, '') || '/';
113
+
107
114
  /**
108
115
  * PageProps' `path` (MEC trial item 2): the document's address, the same in a public render
109
116
  * and in its preview. A preview's is `previewUrl`, its document's URL (content.ts →
110
117
  * documentUrl; null when it has no page). A public render's is the address it's served at,
111
118
  * decoded, with no trailing slash, and a paged listing's `/page/N` taken off (`listPage` > 1):
112
- * `/journal/page/2` is `/journal`'s page 2.
119
+ * `/journal/page/2` is `/journal`'s page 2. A `build.format: 'file'` prerender's `.html` comes
120
+ * off too (`withoutHtml`).
113
121
  */
114
122
  export function documentPath(input: { preview: boolean; previewUrl: string | null; pathname: string; listPage: number }): string | null {
115
123
  if (input.preview) return input.previewUrl === null ? null : cleanPath(input.previewUrl) || '/';
@@ -120,6 +128,7 @@ export function documentPath(input: { preview: boolean; previewUrl: string | nul
120
128
  // Not valid percent-encoding: kept as it came.
121
129
  }
122
130
  path = cleanPath(path) || '/';
131
+ path = withoutHtml(path);
123
132
  if (input.listPage > 1) path = path.replace(new RegExp(`/page/${input.listPage}$`), '') || '/';
124
133
  return path;
125
134
  }
package/src/index.ts CHANGED
@@ -48,7 +48,8 @@
48
48
  */
49
49
  import type { AstroIntegration } from 'astro';
50
50
  import { fontProviders } from 'astro/config';
51
- import { readFileSync } from 'node:fs';
51
+ import { execFileSync } from 'node:child_process';
52
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
52
53
  import { createRequire } from 'node:module';
53
54
  import { isAbsolute, join, sep } from 'node:path';
54
55
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -59,6 +60,7 @@ import { writeSchemaFiles } from './cli/schemas.ts';
59
60
  import { configFile } from './cli/site.ts';
60
61
  import { readCodeRoutes } from './code-routes-disk.ts';
61
62
  import { GUARD_MIDDLEWARE, LOGIN_ROUTES } from './live/auth/routes.ts';
63
+ import { HOST_COMMIT_VARIABLES } from './live/mode.ts';
62
64
 
63
65
 
64
66
  export type { SiteConfig, ResolvedSite } from './config.ts';
@@ -169,6 +171,25 @@ export function siteAddress(astroSite: string | undefined, env: Record<string, s
169
171
  return { url: `${url.origin}${url.pathname}`.replace(/\/+$/, ''), problem: null };
170
172
  }
171
173
 
174
+ /**
175
+ * Runs-anywhere Decision 9: the commit a build is made from, baked into the server.
176
+ * CARACMS_RUNNING_COMMIT or a host's commit variable at build, else `git rev-parse HEAD`
177
+ * when the build has a `.git` folder (Railway's and most Docker builds don't); null
178
+ * when neither. A value at run time wins over it (live/mode.ts → `runningCommitFrom`).
179
+ */
180
+ export function buildCommit(root: string, env: Record<string, string | undefined> = process.env): string | null {
181
+ for (const name of ['CARACMS_RUNNING_COMMIT', ...HOST_COMMIT_VARIABLES]) {
182
+ const v = env[name]?.trim();
183
+ if (v) return v;
184
+ }
185
+ try {
186
+ const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).toString().trim();
187
+ return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+
172
193
  /** Pre-M7 item 8: Site Settings' redirects, served as 301s on the public site. */
173
194
  export const REDIRECT_MIDDLEWARE = 'redirect-middleware.ts';
174
195
 
@@ -198,10 +219,11 @@ export function contentWatchIgnores(site: Pick<ResolvedSite, 'collections' | 'ro
198
219
  /**
199
220
  * The hosts Astro may take a request's address from (`security.allowedDomains`).
200
221
  * Without them, Astro ignores the Host header and every request reads as
201
- * `localhost:<port>`. Behind Railway's edge that breaks the same-origin checks on the
222
+ * `localhost:<port>`. Behind a host's proxy that breaks the same-origin checks on the
202
223
  * sign-in form and the editor's writes, and the addresses the pages show. Taken from
203
- * Astro's `site`, then Railway's public domain: the same sources as emailed links
204
- * (live/auth/index.ts → `publicOrigin`). A site's own `allowedDomains` wins.
224
+ * Astro's `site` (SITE_URL when the config sets none: `siteAddress`), then Railway's
225
+ * public domain: the same chain as emailed links (live/mode.ts → `addressFrom`;
226
+ * runs-anywhere Decision 7). A site's own `allowedDomains` wins.
205
227
  * Read at build time: Astro puts the list in the server's manifest.
206
228
  */
207
229
  export function trustedDomains(
@@ -301,8 +323,23 @@ async function loadSiteEnv(mode: string, root: string): Promise<Record<string, s
301
323
  return loadEnv(mode, root, '');
302
324
  }
303
325
 
326
+ /**
327
+ * Runs-anywhere Decisions 7 and 9: what a build baked in, also written beside the server's
328
+ * entry (`dist/server/caracms-build.json`) for `caracms start`'s gate, which can't import
329
+ * the site: `{ "site": "https://fernhill.ie" | null, "commit": "<sha>" | null }`. `site` is
330
+ * Astro's `site` as built (the config's, else SITE_URL's: `siteAddress`); `commit` is
331
+ * `buildCommit()`'s.
332
+ */
333
+ export const BUILD_INFO_FILE = 'caracms-build.json';
334
+ export interface BuildInfo {
335
+ site: string | null;
336
+ commit: string | null;
337
+ }
338
+
304
339
  export default function caracms(config: SiteConfig): AstroIntegration {
305
340
  let site: ResolvedSite;
341
+ let buildInfo: BuildInfo | null = null;
342
+ let serverDir: URL | null = null;
306
343
  return {
307
344
  name: 'caracms',
308
345
  hooks: {
@@ -369,8 +406,10 @@ export default function caracms(config: SiteConfig): AstroIntegration {
369
406
  const domains = trustedDomains(astroConfig.site ?? address.url ?? undefined, astroConfig.security?.allowedDomains);
370
407
  if (command === 'build' && !astroConfig.security?.allowedDomains?.length) {
371
408
  if (domains.length) logger.info(`trusting the Host header for ${domains.map((d) => d.hostname).join(', ')}`);
372
- else logger.warn("the site's public address isn't known (set Astro's `site`, or build on Railway): behind a proxy, sign-in and the editor's saves will be refused as cross-origin.");
409
+ else logger.warn("the site's public address isn't known (set SITE_URL, at build and at run time): behind a proxy, sign-in and the editor's saves will be refused as cross-origin.");
373
410
  }
411
+ const commit = dev ? null : buildCommit(site.root);
412
+ if (command === 'build') buildInfo = { site: address.url, commit };
374
413
 
375
414
  updateConfig({
376
415
  ...(!astroConfig.site && address.url ? { site: address.url } : {}),
@@ -382,6 +421,9 @@ export default function caracms(config: SiteConfig): AstroIntegration {
382
421
  define: {
383
422
  __CARACMS_HARFBUZZ__: JSON.stringify(harfbuzz),
384
423
  __CARACMS_PRODUCTION__: JSON.stringify(!dev),
424
+ // Runs-anywhere Decisions 7 and 9: the address and the commit the build was made with (editor/server/mode.ts → bakedAtBuild).
425
+ __CARACMS_SITE__: JSON.stringify(astroConfig.site ?? address.url ?? null),
426
+ __CARACMS_BUILD_COMMIT__: JSON.stringify(commit),
385
427
  __CARACMS_TOKENS_CSS__: JSON.stringify(readFileSync(new URL('./editor/styles/tokens.css', import.meta.url), 'utf8')),
386
428
  },
387
429
  // Installed from npm, the package ships TypeScript and .astro source.
@@ -425,8 +467,12 @@ export default function caracms(config: SiteConfig): AstroIntegration {
425
467
  },
426
468
  // Posts and tags, Decision 15: a production build reads each document once (read.ts). Never in dev.
427
469
  'astro:build:start': () => shareBuildReads(true),
428
- 'astro:build:done': () => shareBuildReads(false),
429
- 'astro:config:done': ({ injectTypes }) => {
470
+ 'astro:build:done': () => {
471
+ shareBuildReads(false);
472
+ if (buildInfo && serverDir && existsSync(serverDir)) writeFileSync(new URL(BUILD_INFO_FILE, serverDir), `${JSON.stringify(buildInfo)}\n`);
473
+ },
474
+ 'astro:config:done': ({ injectTypes, config: done }) => {
475
+ serverDir = done.build.server;
430
476
  injectTypes({ filename: 'caracms.d.ts', content: '/// <reference types="caracms/virtual" />\n' });
431
477
  },
432
478
  },
@@ -13,13 +13,15 @@ live drafts store, sweep, locks and status route, and Phase 3's sign-in, sit on
13
13
  | `merge.ts` | `mergeFile({ base, draft, other })`: the per-key three-way merge, pure and byte-preserving |
14
14
  | `messages.ts` | The commit-message contract (`draftMessage`, `mergeMessage`, `publishMessage`, `parseMessage`), `Person`, `displayName` |
15
15
  | `status.ts` | Pure: `waitingFromCommits()`, `publishState()`, `PublishRecord` |
16
- | `redis.ts` | `RedisClient`; `connectRedis(url)` (ioredis with M1 → R2's retry and idle close) and `memoryRedis()` (the stand-in, with `calls` and `flush()`) |
16
+ | `redis.ts` | `RedisClient`; `connectRedis(url)` (ioredis with M1 → R2's retry and idle close) and `memoryRedis()` (the stand-in, with `calls` and `flush()`). Redis diet: `batch()`, a pipeline sent again whole only when every command in it is safe to send twice |
17
+ | `status-cache.ts` | Redis diet. `StatusCache` (one per process, `git.statusCache`): what the status poll reads, by Redis key, re-read once 30 s old, refreshes single-flight; the modules that write Redis tell it what landed (`string`, `hash`, `add`, `drop`, `landed`) |
18
+ | `notices.ts` | Redis diet. `Notices`: "published as it stood" notices and dismissed "draft kept" ones, written through the status cache |
17
19
  | `backend.ts` | `GitBackend`; `GitHubBackend`, `MemoryBackend` (test helpers: `seed`, `push`, `file`, `deployments`, `beforeRequest`); `StaleHeadError` |
18
20
  | `branches.ts` | `Branch` (a role: `main` or `drafts`), `BranchNames` (the refs behind them), `DEFAULT_BRANCHES`. `CARACMS_MAIN_BRANCH` and `CARACMS_DRAFTS_BRANCH` (read by `mode.ts`, M9 Decision 6) name the branch Publish commits to and the one autosaves land on; unset, `main` and `drafts`. They're set together or not at all: one alone is a problem, so the mode is unconfigured (`/admin` answers 503 saying both must be set; the public site still serves). Only the backends turn a role into a ref name, so with both set a branch called `main` is never read or moved. `MemoryBackend.refs` is keyed by name |
19
21
  | `github.ts` | The App's JWT, the installation token (one repo, refreshed early), `Tracker` (request counts) |
20
22
  | `webhook.ts` | `handleGithubWebhook()`, behind `POST /_caracms/github` (`../editor/routes/github.ts`) |
21
23
  | `autosaves.ts` | Phase 2. Autosaves in Redis (`caracms:autosave:<file>`, a week; stored compare-and-set, ordered per tab by `X-Caracms-Seq`), the session's last commit (the merge's ancestor) and who last changed a document (Decision 11; no expiry, and `known: false` when neither Redis nor git can say). M5a: a new document's autosave carries `created: { over }` (its ancestor until committed), a deletion is a "gone" autosave (`text` and `document` null, `gone: { title }`) |
22
- | `locks.ts` | Phase 2. `Locks`: taken by the first write, heartbeat on the poll, lapse only once committed, releasing, Edit here; presence |
24
+ | `locks.ts` | Phase 2. `Locks`: taken by the first write, heartbeat on the poll (Redis diet: only when `needsBeat`), lapse only once committed, releasing, Edit here; presence, each record holding for twice its tab's poll interval plus 3 s |
23
25
  | `sweep.ts` | Phase 2. `Sweep`: one commit per tick for everything ready; ticks only while something is pending (no Redis call otherwise); `commitAll` fills `git.commitPending` |
24
26
  | `drafts-store.ts` | Phase 2. The live `DraftsStore`: the copy with autosaves on top (a gone one leaves the file out); `write` takes the lock and stores the autosave. M5a Phase 3: `create` (the lock, then the new document as an autosave) and `delete` (the lock, then a gone autosave, the lock released to drop once committed), with `editor/server/create.ts`'s rules. M5a: any kind of document; Site Settings' autosave is marked `sweepAtOnce` (the sweep commits it on its next tick, no idle wait); `source()` is the copy with every autosave on top (a preview's reads) |
25
27
  | `editor.ts` | Phase 2. `createLiveEditor(runtime)` / `liveEditor(site, settings)`: the store, sweep and locks, and the handlers for write, status, lock, publish, history, try-again, notice |
@@ -65,6 +65,8 @@ export interface AuthOptions {
65
65
 
66
66
  export interface Auth {
67
67
  readonly secure: boolean;
68
+ /** The site's public origin (`AuthOptions.siteOrigin`): the mismatch sentences name it (runs-anywhere Decision 7). */
69
+ readonly siteOrigin: string | null;
68
70
  readonly cookieName: string;
69
71
  readonly whitelist: Whitelist;
70
72
  readonly links: LinkStore;
@@ -97,6 +99,7 @@ export function createAuth(options: AuthOptions): Auth {
97
99
 
98
100
  return {
99
101
  secure: options.secure,
102
+ siteOrigin: options.siteOrigin,
100
103
  cookieName,
101
104
  whitelist,
102
105
  links,
@@ -136,7 +139,7 @@ export function createAuth(options: AuthOptions): Auth {
136
139
  const emailing = options.env['NODE_ENV'] === 'production';
137
140
  const origin = options.siteOrigin ?? (emailing ? null : requestOrigin);
138
141
  if (!origin) {
139
- log("no sign-in link sent: the site's public address isn't known (set Astro's `site`, or run on Railway, which sets RAILWAY_PUBLIC_DOMAIN).");
142
+ log("no sign-in link sent: the site's public address isn't known (set SITE_URL, when the site is built and when it runs).");
140
143
  return 'not-sent';
141
144
  }
142
145
  const token = await links.issue(email, next);
@@ -22,7 +22,7 @@
22
22
  */
23
23
  import type { Person } from '../messages.ts';
24
24
  import type { Auth, SignedOutReason } from './auth.ts';
25
- import { isRead, isSameOrigin } from './request.ts';
25
+ import { addressMismatch, isRead, isSameOrigin } from './request.ts';
26
26
  import { cleanNext, LOGIN_PATH, loginHref } from './session.ts';
27
27
 
28
28
  /** Whether the guard looks at this path at all. */
@@ -93,7 +93,8 @@ export function guard(request: Request, auth: Auth): GuardResult {
93
93
  const api = isApi(raw);
94
94
 
95
95
  if (!isRead(request.method) && !isSameOrigin(request)) {
96
- const error = 'This only takes requests from the editor on this site.';
96
+ // Runs-anywhere Decision 7: the editor's own write, refused because the site's address isn't the one in use.
97
+ const error = addressMismatch(request, auth.siteOrigin) ?? 'This only takes requests from the editor on this site.';
97
98
  if (api) return { kind: 'respond', response: json(403, { error, code: 'cross-origin' } satisfies ForbiddenBody) };
98
99
  return { kind: 'respond', response: new Response(error, { status: 403, headers: { 'Content-Type': 'text/plain; charset=utf-8', ...NO_STORE } }) };
99
100
  }
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import { SITE_SETTINGS_FILE, type ResolvedSite } from '../../config.ts';
21
21
  import { brandFrom } from '../../editor/brand.ts';
22
- import type { LiveSettings } from '../mode.ts';
22
+ import { addressFrom, type LiveSettings } from '../mode.ts';
23
23
  import { liveRuntime } from '../runtime.ts';
24
24
  import { createAuth, type Auth } from './auth.ts';
25
25
 
@@ -27,13 +27,12 @@ const SHARED = Symbol.for('caracms.auth');
27
27
  type Shared = { [SHARED]?: Promise<Auth> };
28
28
 
29
29
  /**
30
- * The site's public origin for emailed links: Astro's `site`, else Railway's public
31
- * domain. Never a request's Host header (auth.ts → `siteOrigin`).
30
+ * The site's public origin for emailed links: Astro's `site`, then SITE_URL, then
31
+ * Railway's public domain (runs-anywhere Decision 7: the chain is mode.ts → `addressFrom`).
32
+ * Never a request's Host header (auth.ts → `siteOrigin`).
32
33
  */
33
34
  export function publicOrigin(astroSite: URL | undefined, env: Record<string, string | undefined> = process.env): string | null {
34
- if (astroSite) return astroSite.origin;
35
- const domain = env['RAILWAY_PUBLIC_DOMAIN']?.trim();
36
- return domain ? `https://${domain}` : null;
35
+ return addressFrom(env, astroSite?.href);
37
36
  }
38
37
 
39
38
  /** The process's Auth, built on first use over the shared live runtime. */
@@ -50,7 +49,7 @@ export function authFor(site: ResolvedSite, settings: LiveSettings, astroSite?:
50
49
  readSettings: () => rt.git.read(SITE_SETTINGS_FILE),
51
50
  // M5a Decision 10: Site Settings' brand name as committed in `drafts`.
52
51
  siteName: () => brandFrom(rt.git.read(SITE_SETTINGS_FILE), site).name,
53
- siteOrigin: publicOrigin(astroSite),
52
+ siteOrigin: settings.address ?? publicOrigin(astroSite),
54
53
  env: process.env,
55
54
  now: rt.now,
56
55
  }),
@@ -37,7 +37,7 @@
37
37
  */
38
38
  import type { Auth } from './auth.ts';
39
39
  import { normaliseEmail } from './allow.ts';
40
- import { clientIp, isRead, isSameOrigin } from './request.ts';
40
+ import { addressMismatch, addressNotice, clientIp, isRead, isSameOrigin, NO_GATE_MESSAGE } from './request.ts';
41
41
  import { backupPerson, cleanNext, DEFAULT_NEXT, LOGIN_PATH, loginHref, safeEqual } from './session.ts';
42
42
  import {
43
43
  backupView,
@@ -115,8 +115,17 @@ export async function handleLogin(request: Request, ctx: LoginContext): Promise<
115
115
  extra.Allow = allow;
116
116
  return page(405, 'Not allowed', messageView('Not allowed', 'Use the form on the sign-in page.'));
117
117
  };
118
- const crossOrigin = () => page(403, 'Not allowed', messageView('Not allowed', 'Send the form from the sign-in page.'));
118
+ // Runs-anywhere Decision 7: refused because the site's address isn't the one in use, the sentence says so, naming SITE_URL.
119
+ const mismatch = () => addressMismatch(request, auth.siteOrigin);
120
+ const crossOrigin = () => page(403, 'Not allowed', messageView('Not allowed', mismatch() ?? 'Send the form from the sign-in page.'));
119
121
  const wantsJson = (request.headers.get('accept') ?? '').includes('application/json');
122
+ // Runs-anywhere Decision 8: on a production server (`auth.secure` is production), the IP
123
+ // comes from `caracms start`'s gate alone; without it, no sign-in at all (acceptance check 17).
124
+ const ip = clientIp(request, ctx.clientAddress, auth.secure);
125
+ if (ip === null) {
126
+ if (pathname === SIGNIN_PATH || wantsJson) return json(503, { error: NO_GATE_MESSAGE, reason: 'failed' });
127
+ return page(503, 'Sign-in is off', messageView('Sign-in is off', NO_GATE_MESSAGE));
128
+ }
120
129
 
121
130
  try {
122
131
  // ────────────────────────────────────────────────────────────── confirm
@@ -138,7 +147,7 @@ export async function handleLogin(request: Request, ctx: LoginContext): Promise<
138
147
  // ─────────────────────────────────────────────────────────────── signin
139
148
  if (pathname === SIGNIN_PATH) {
140
149
  if (method !== 'POST') return notAllowed('POST');
141
- if (!isSameOrigin(request)) return json(403, { error: 'Sign in from the sign-in page.', reason: 'failed' });
150
+ if (!isSameOrigin(request)) return json(403, { error: mismatch() ?? 'Sign in from the sign-in page.', reason: 'failed' });
142
151
  let body: Record<string, unknown>;
143
152
  try {
144
153
  const parsed: unknown = JSON.parse(await readBody(request));
@@ -161,7 +170,7 @@ export async function handleLogin(request: Request, ctx: LoginContext): Promise<
161
170
  // Every attempt counts, the right password included, so the limit can't confirm a guess.
162
171
  let over = true;
163
172
  try {
164
- over = await auth.limits.backupAttemptOver(clientIp(request, ctx.clientAddress));
173
+ over = await auth.limits.backupAttemptOver(ip);
165
174
  } catch (error) {
166
175
  auth.log(`could not count a backup sign-in attempt, so it was refused: ${error instanceof Error ? error.message : String(error)}`);
167
176
  }
@@ -179,7 +188,7 @@ export async function handleLogin(request: Request, ctx: LoginContext): Promise<
179
188
  // ────────────────────────────────────────────────────────────── signout
180
189
  if (pathname === SIGNOUT_PATH) {
181
190
  if (method !== 'POST') return notAllowed('POST');
182
- if (!isSameOrigin(request)) return wantsJson ? json(403, { error: 'Sign out from the editor.', reason: 'cross-origin' }) : crossOrigin();
191
+ if (!isSameOrigin(request)) return wantsJson ? json(403, { error: mismatch() ?? 'Sign out from the editor.', reason: 'cross-origin' }) : crossOrigin();
183
192
  const location = loginHref({ signedout: '1' });
184
193
  return wantsJson ? json(200, { ok: true, location }, auth.clearCookie()) : redirect(location, auth.clearCookie());
185
194
  }
@@ -199,8 +208,9 @@ export async function handleLogin(request: Request, ctx: LoginContext): Promise<
199
208
  const showBackup = query.get('backup') === '1' && auth.backup.enabled;
200
209
  if (isRead(method)) {
201
210
  const next = cleanNext(query.get('next'));
202
- if (showBackup) return page(200, 'Backup account', backupView({ next }), { script: true });
203
- return page(200, 'Sign in', emailView({ next, signedOut: query.get('signedout') === '1', backupEnabled: auth.backup.enabled }));
211
+ const notice = addressNotice(request, auth.siteOrigin);
212
+ if (showBackup) return page(200, 'Backup account', backupView({ next, notice }), { script: true });
213
+ return page(200, 'Sign in', emailView({ next, signedOut: query.get('signedout') === '1', backupEnabled: auth.backup.enabled, notice }));
204
214
  }
205
215
  if (method !== 'POST') return notAllowed('GET, HEAD, POST');
206
216
  if (!isSameOrigin(request)) return crossOrigin();
@@ -221,7 +231,6 @@ export async function handleLogin(request: Request, ctx: LoginContext): Promise<
221
231
  }
222
232
 
223
233
  // Every address counts against the limits, allowed or not, so the time taken is the same.
224
- const ip = clientIp(request, ctx.clientAddress);
225
234
  let over = true;
226
235
  try {
227
236
  over = await auth.limits.linkRequestOver(email, ip);
@@ -8,7 +8,11 @@
8
8
  * even building the runtime, so the public site never waits on GitHub or Redis.
9
9
  * A guarded path that passes records the person (`setPerson`) for the route.
10
10
  * - **Unconfigured** never reaches here in a correct setup (Phase 2's middleware
11
- * answers first); if it does, a guarded path gets a 503 rather than through.
11
+ * answers first); if it does, a guarded path gets a 503 rather than through. The
12
+ * public form endpoint's POST is the exception: Phase 2 lets it through, and so does this, since
13
+ * a site's forms work without the editor (M6 Decision 5; the Urban Return port found it
14
+ * answering 503 here until the GitHub App's variables were set). A GET to it is as
15
+ * unavailable as any other guarded path, as live mode's let-through is POST-only too.
12
16
  *
13
17
  * The mode is Phase 2's `editorMode()`, so the two agree on live and local.
14
18
  */
@@ -16,7 +20,7 @@ import type { MiddlewareHandler } from 'astro';
16
20
  import { site } from 'virtual:caracms/config';
17
21
  import { editorMode } from '../../editor/server/mode.ts';
18
22
  import { setPerson } from '../locals.ts';
19
- import { guard, isGuardedPath } from './guard.ts';
23
+ import { guard, isFormEndpoint, isGuardedPath } from './guard.ts';
20
24
  import { authFor } from './index.ts';
21
25
 
22
26
  const unavailable = (text: string) => new Response(text, { status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'private, no-store' } });
@@ -25,7 +29,7 @@ export const onRequest: MiddlewareHandler = async (context, next) => {
25
29
  if (!isGuardedPath(context.url.pathname)) return next();
26
30
  const mode = editorMode();
27
31
  if (mode.kind === 'local') return next();
28
- if (mode.kind !== 'live') return unavailable("The editor isn't set up on this server yet.");
32
+ if (mode.kind !== 'live') return isFormEndpoint(context.url.pathname) && context.request.method === 'POST' ? next() : unavailable("The editor isn't set up on this server yet.");
29
33
 
30
34
  let auth;
31
35
  try {
@@ -6,20 +6,53 @@
6
6
  const first = (value: string | null) => (value ?? '').split(',')[0]!.trim();
7
7
 
8
8
  /**
9
- * The client's IP, which keys the per-IP rate limits (the template's `clientIp()`,
10
- * `docs/admin-login.md` → Rate limits and the client IP). In order:
9
+ * The header `caracms start`'s gate sets on every request, replacing any copy the request
10
+ * brought (runs-anywhere Decision 8, the spec's Contracts): the visitor's IP, decided from
11
+ * the one source `CARACMS_CLIENT_IP_HEADER` names.
12
+ */
13
+ export const CLIENT_IP_HEADER = 'x-caracms-client-ip';
14
+
15
+ /**
16
+ * The mark `caracms start` puts on its own process before importing the built site, which
17
+ * runs in that same process (`bin/gate.mjs` → `GATED_MARK`, `markProcessGated()`):
18
+ * `globalThis[Symbol.for('caracms.gated')] === true`. It's what makes the header above
19
+ * trustworthy: with the mark there, every request reached the site through the gate, which
20
+ * replaced any copy the visitor sent. Without it (`node dist/server/entry.mjs`), nothing
21
+ * replaced it, so the header is the visitor's to choose and is never read.
22
+ */
23
+ export const GATED_MARK = Symbol.for('caracms.gated');
24
+
25
+ /** Whether this process was started by `caracms start` (the mark above). */
26
+ export const isGatedProcess = (): boolean => (globalThis as Record<symbol, unknown>)[GATED_MARK] === true;
27
+
28
+ /** The 503 on sign-in, forms and downloads of a production server with no gate (Decision 8; acceptance check 17). */
29
+ export const NO_GATE_MESSAGE =
30
+ "This site wasn't started with `caracms start`, so it can't tell its visitors apart for the limits on signing in, forms and downloads. A developer can start it with `caracms start` (the site's start script).";
31
+
32
+ /**
33
+ * The client's IP, which keys the per-IP rate limits (sign-in, forms, downloads).
11
34
  *
12
- * 1. **`X-Real-IP`.** Railway's edge overwrites it with the address that connected, so
13
- * behind Railway it's the one value the client can't choose.
14
- * 2. The socket's address as the adapter gives it (`clientAddress`).
15
- * 3. The first `X-Forwarded-For` hop, only when there's nothing else. Never ahead of
16
- * the socket: the client writes the leftmost hop, so keyed on it an attacker could
17
- * rotate past every per-IP limit.
35
+ * **In production, only `x-caracms-client-ip`**, which `caracms start`'s gate sets on
36
+ * every request from the one trusted source, and only in a process the gate marked
37
+ * (`GATED_MARK`). Null when the mark is missing, whatever the request carries: the server
38
+ * wasn't started by `caracms start`, so a header there is one the visitor sent. Null too
39
+ * when the header is absent. Either way the caller answers 503 with `NO_GATE_MESSAGE`.
40
+ * Nothing else on the request is trusted there: Astro's Node adapter reports the first
41
+ * `X-Forwarded-For` entry as `clientAddress` whenever the Host is trusted, which caracms
42
+ * always arranges for its own domain, so the adapter's address is the visitor's to choose
43
+ * (`node_modules/astro/dist/core/app/node.js`), and `X-Real-IP` is trustworthy only behind
44
+ * a proxy that overwrites it.
18
45
  *
19
- * Off Railway (dev, tests) nothing proxies, so a spoofed `X-Real-IP` only rate-limits
20
- * the spoofer's own requests: accepted, as in the template.
46
+ * **Under `astro dev`** (no gate, and nothing to protect), as before: `X-Real-IP`, then
47
+ * the socket's address as the adapter gives it, then the first `X-Forwarded-For` hop.
48
+ * A spoofed one only rate-limits the spoofer's own requests.
21
49
  */
22
- export function clientIp(request: Request, clientAddress: () => string | undefined): string {
50
+ export function clientIp(request: Request, clientAddress: () => string | undefined, production = false): string | null {
51
+ if (production) {
52
+ if (!isGatedProcess()) return null;
53
+ const gated = request.headers.get(CLIENT_IP_HEADER)?.trim();
54
+ return gated ? gated.slice(0, 64) : null;
55
+ }
23
56
  const real = first(request.headers.get('x-real-ip'));
24
57
  if (real) return real.slice(0, 64);
25
58
  let socket: string | undefined;
@@ -53,4 +86,52 @@ export function isSameOrigin(request: Request): boolean {
53
86
  }
54
87
  }
55
88
 
89
+ /**
90
+ * Runs-anywhere Decision 7: the sentence for a site reached at an address that isn't its
91
+ * own, naming SITE_URL. The likeliest setup mistake: SITE_URL at www while people use the
92
+ * apex (or the other way round), or set differently at build and at run time.
93
+ */
94
+ export function addressMismatchMessage(reached: string, address: string | null): string {
95
+ return address
96
+ ? `This site's address is set to ${address} (SITE_URL), but it was opened at ${reached}, so signing in and saving are refused here. Open it at ${address}, or set SITE_URL to ${reached} where the site is built and where it runs.`
97
+ : `This site's address isn't set, so signing in and saving are refused. Set SITE_URL to ${reached} where the site is built and where it runs.`;
98
+ }
99
+
100
+ /** The address the visitor typed (the proxy's forwarded host, else Host), when it isn't the one Astro read the request as. */
101
+ export function reachedElsewhere(request: Request): string | null {
102
+ const host = first(request.headers.get('x-forwarded-host')) || first(request.headers.get('host'));
103
+ if (!host) return null;
104
+ const url = new URL(request.url);
105
+ if (host === url.host) return null;
106
+ const proto = first(request.headers.get('x-forwarded-proto')) || url.protocol.replace(':', '');
107
+ return `${proto}://${host}`;
108
+ }
109
+
110
+ /**
111
+ * For a write refused by `isSameOrigin`: the mismatch sentence when the browser was on
112
+ * this site all along (it says `Sec-Fetch-Site: same-origin`, or its `Origin` is the
113
+ * request's own Host) but Astro, trusting only the site's address, read the request as
114
+ * another host. Null for a real cross-site request.
115
+ */
116
+ export function addressMismatch(request: Request, address: string | null): string | null {
117
+ let origin: URL;
118
+ try {
119
+ origin = new URL(request.headers.get('origin') ?? '');
120
+ } catch {
121
+ return null;
122
+ }
123
+ if (origin.host === new URL(request.url).host) return null;
124
+ const fetchSite = request.headers.get('sec-fetch-site');
125
+ const host = first(request.headers.get('x-forwarded-host')) || first(request.headers.get('host'));
126
+ if (fetchSite !== 'same-origin' && !(!fetchSite && origin.host === host)) return null;
127
+ return addressMismatchMessage(origin.origin, address);
128
+ }
129
+
130
+ /** On a page with no `Origin` (the sign-in form's GET): the sentence when the site is opened at an address that isn't its own. */
131
+ export function addressNotice(request: Request, address: string | null): string | null {
132
+ const reached = reachedElsewhere(request);
133
+ if (!reached || (address && new URL(address).host === new URL(reached).host)) return null;
134
+ return addressMismatchMessage(reached, address);
135
+ }
136
+
56
137
  export const isRead = (method: string) => method === 'GET' || method === 'HEAD';
@@ -301,9 +301,9 @@ const FINE = html`<p class="fine">Not you? Close this tab.</p>`;
301
301
  const backupLink = (next: string) => html`<p class="si-alt"><a class="linkbtn" href="${loginHref({ backup: '1' }, next)}">Use the backup account</a></p>`;
302
302
 
303
303
  /** The email form: plain, signed out, or with the malformed-address error. */
304
- export function emailView(input: { next: string; value?: string; invalid?: string; signedOut?: boolean; backupEnabled: boolean }): SafeHtml {
304
+ export function emailView(input: { next: string; value?: string; invalid?: string; signedOut?: boolean; backupEnabled: boolean; notice?: string | null }): SafeHtml {
305
305
  const bad = !!input.invalid;
306
- return html`<section class="si-card">${input.signedOut ? html`<p class="notice info" role="status">${ic('check', 'i-sm')}You're signed out.</p>` : ''}<h1>Sign in</h1><p class="sub">We'll email you a sign-in link.</p><form method="post" action="${LOGIN_PATH}" novalidate><input type="hidden" name="next" value="${input.next}"><div class="field"><label class="field-label" for="si-email">Email address</label><input class="input" id="si-email" name="email" type="email" autocomplete="email" inputmode="email" spellcheck="false" autofocus value="${input.value ?? ''}"${new SafeHtml(bad ? ' aria-invalid="true" aria-describedby="si-err"' : '')}>${
306
+ return html`<section class="si-card">${addressNoticeHtml(input.notice)}${input.signedOut ? html`<p class="notice info" role="status">${ic('check', 'i-sm')}You're signed out.</p>` : ''}<h1>Sign in</h1><p class="sub">We'll email you a sign-in link.</p><form method="post" action="${LOGIN_PATH}" novalidate><input type="hidden" name="next" value="${input.next}"><div class="field"><label class="field-label" for="si-email">Email address</label><input class="input" id="si-email" name="email" type="email" autocomplete="email" inputmode="email" spellcheck="false" autofocus value="${input.value ?? ''}"${new SafeHtml(bad ? ' aria-invalid="true" aria-describedby="si-err"' : '')}>${
307
307
  bad ? html`<p class="field-err" id="si-err">${ic('warn', 'i-sm')}<span>${input.invalid}</span></p>` : ''
308
308
  }</div><button class="btn accent block" type="submit">Send sign-in link</button></form></section>${input.backupEnabled ? backupLink(input.next) : ''}`;
309
309
  }
@@ -317,9 +317,14 @@ export function sentView(input: { email: string; next: string; again: boolean; b
317
317
  return html`<section class="si-card">${input.again ? html`<p class="notice ok" role="status">${ic('check', 'i-sm')}Sent again.</p>` : ''}<h1>Check your email</h1><p>If <strong>${input.email}</strong> can sign in, a link is on its way. It expires in 15 minutes.</p><p class="fine">Not there? Check spam, or send again.</p><div class="row-acts"><form method="post" action="${LOGIN_PATH}"><input type="hidden" name="email" value="${input.email}"><input type="hidden" name="next" value="${input.next}"><input type="hidden" name="again" value="1"><button class="btn" type="submit">Send again</button></form><a class="linkbtn" href="${loginHref({}, input.next)}">Use a different address</a></div></section>${input.backupEnabled ? backupLink(input.next) : ''}`;
318
318
  }
319
319
 
320
+ /** Runs-anywhere Decision 7: the site was opened at an address that isn't its own (request.ts → `addressNotice`). */
321
+ function addressNoticeHtml(notice: string | null | undefined): SafeHtml | string {
322
+ return notice ? html`<p class="notice fail" role="alert" data-notice="address">${ic('warn', 'i-sm')}${notice}</p>` : '';
323
+ }
324
+
320
325
  /** The backup form. Posting it without script only shows it again. */
321
- export function backupView(input: { next: string }): SafeHtml {
322
- return html`<section class="si-card"><p class="notice fail" id="signin-error" role="alert" hidden></p><h1>Backup account</h1><p class="sub">Details are in the shared password manager.</p>${NOSCRIPT}<form method="post" action="${loginHref({ backup: '1' }, input.next)}" data-signin="backup" data-next="${input.next}"><div class="field"><label class="field-label" for="si-user">Username</label><input class="input" id="si-user" name="username" autocomplete="username" autocapitalize="off" spellcheck="false" autofocus required></div><div class="field"><label class="field-label" for="si-pass">Password</label><input class="input" id="si-pass" name="password" type="password" autocomplete="current-password" required></div><button class="btn accent block" type="submit">Sign in</button></form></section><p class="si-alt"><a class="linkbtn" href="${loginHref({}, input.next)}">Use email sign-in instead</a></p>`;
326
+ export function backupView(input: { next: string; notice?: string | null }): SafeHtml {
327
+ return html`<section class="si-card">${addressNoticeHtml(input.notice)}<p class="notice fail" id="signin-error" role="alert" hidden></p><h1>Backup account</h1><p class="sub">Details are in the shared password manager.</p>${NOSCRIPT}<form method="post" action="${loginHref({ backup: '1' }, input.next)}" data-signin="backup" data-next="${input.next}"><div class="field"><label class="field-label" for="si-user">Username</label><input class="input" id="si-user" name="username" autocomplete="username" autocapitalize="off" spellcheck="false" autofocus required></div><div class="field"><label class="field-label" for="si-pass">Password</label><input class="input" id="si-pass" name="password" type="password" autocomplete="current-password" required></div><button class="btn accent block" type="submit">Sign in</button></form></section><p class="si-alt"><a class="linkbtn" href="${loginHref({}, input.next)}">Use email sign-in instead</a></p>`;
323
328
  }
324
329
 
325
330
  /**