caracms 0.2.0 → 0.3.1

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 (52) 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/documents/actions.ts +6 -4
  14. package/src/editor/client/documents/dialogs.tsx +2 -1
  15. package/src/editor/client/editor.ts +24 -6
  16. package/src/editor/client/live/publish.tsx +20 -10
  17. package/src/editor/client/pane/parts.tsx +34 -2
  18. package/src/editor/client/state/keeper.ts +51 -6
  19. package/src/editor/client/state/live.ts +45 -6
  20. package/src/editor/middleware.ts +3 -2
  21. package/src/editor/routes/try-again.ts +2 -1
  22. package/src/editor/server/admin-props.ts +2 -1
  23. package/src/editor/server/mode.ts +13 -3
  24. package/src/editor/styles/levels/live.css +1 -1
  25. package/src/editor/types.ts +25 -6
  26. package/src/files-route.ts +9 -4
  27. package/src/forms/endpoint.ts +11 -16
  28. package/src/head.ts +10 -1
  29. package/src/index.ts +53 -7
  30. package/src/live/README.md +4 -2
  31. package/src/live/auth/auth.ts +4 -1
  32. package/src/live/auth/guard.ts +3 -2
  33. package/src/live/auth/index.ts +6 -7
  34. package/src/live/auth/login.ts +17 -8
  35. package/src/live/auth/middleware.ts +7 -3
  36. package/src/live/auth/request.ts +92 -11
  37. package/src/live/auth/views.ts +9 -4
  38. package/src/live/autosaves.ts +59 -16
  39. package/src/live/backend.ts +13 -1
  40. package/src/live/drafts-store.ts +4 -3
  41. package/src/live/editor.ts +92 -67
  42. package/src/live/git.ts +112 -34
  43. package/src/live/github.ts +128 -13
  44. package/src/live/locks.ts +92 -26
  45. package/src/live/mode.ts +131 -18
  46. package/src/live/notices.ts +100 -0
  47. package/src/live/redis.ts +161 -39
  48. package/src/live/runtime.ts +11 -3
  49. package/src/live/status-cache.ts +219 -0
  50. package/src/live/status.ts +14 -2
  51. package/src/live/timings.ts +24 -0
  52. package/src/media.ts +26 -1
@@ -205,6 +205,14 @@ export interface LiveProps {
205
205
  status: StatusResponse | null;
206
206
  /** How often the tab polls (live timings' `pollMs`: POLL_MS, shorter only in tests). */
207
207
  pollMs?: number;
208
+ /** Redis diet Decision 8: a visible tab with no input this long polls at `slowPollMs` (live timings'; absent: 2 minutes). */
209
+ idleSlowAfterMs?: number;
210
+ /** Redis diet Decision 8: an idle tab's poll interval (absent: 30 s). */
211
+ slowPollMs?: number;
212
+ /** Redis diet Decision 10: once the tab holds the document, a keep goes this long after the last change (absent: 3 s). */
213
+ keepDelayMs?: number;
214
+ /** Redis diet Decision 10: …and never later than this after the first change not yet sent (absent: 15 s). */
215
+ keepCapMs?: number;
208
216
  }
209
217
 
210
218
  /** A block in the editor: a DocumentBlock plus a key that stays with it through moves (never written). */
@@ -431,17 +439,21 @@ export interface PublishRecordView {
431
439
  }
432
440
 
433
441
  export interface PublishView {
434
- /** `none`: nothing published since the record began. */
435
- state: 'none' | 'publishing' | 'live' | 'failed';
442
+ /**
443
+ * `none`: nothing published since the record began. `slow` (runs-anywhere Decision 10):
444
+ * still not live 15 minutes after it was made or last tried again, with no failure
445
+ * known: "taking longer than usual. It may have failed."
446
+ */
447
+ state: 'none' | 'publishing' | 'live' | 'failed' | 'slow';
436
448
  /** A Publish is running now (committing, or moving the refs), before any record. */
437
449
  running: boolean;
438
450
  record: PublishRecordView | null;
439
451
  /** Live only because there's no deploy pipeline (Decision 12). */
440
452
  assumed: boolean;
441
- /** Failed, and Try again can redeploy. */
453
+ /** Failed or slow, and Try again can redeploy. */
442
454
  canTryAgain: boolean;
443
- /** CARACMS_RAILWAY_TOKEN is set. Failed with no token: "a developer can redeploy from Railway". */
444
- hasToken: boolean;
455
+ /** A redeployer is set (a deploy hook, or Railway's token). Failed with none: "A developer can redeploy it" (runs-anywhere Decision 11). */
456
+ hasRedeployer: boolean;
445
457
  }
446
458
 
447
459
  /** GET /_caracms/status?file=<open document's file> (Decision 13). */
@@ -470,6 +482,13 @@ export interface StatusResponse {
470
482
  presence: PresenceView[];
471
483
  notices: NoticeView[];
472
484
  publish: PublishView;
485
+ /**
486
+ * Runs-anywhere Decision 5: what's wrong with the site's GitHub access, for every
487
+ * person, while it holds (not dismissable, unlike `notices`): a refused credential, a
488
+ * missing permission, or a token expiring within 14 days. `message` names the fix,
489
+ * for the developer it's forwarded to. Null when nothing is wrong.
490
+ */
491
+ githubAccess: { kind: 'refused' | 'permission' | 'expiring'; title: string; message: string } | null;
473
492
  /**
474
493
  * The brand as committed in Site Settings (M5a Decision 10: read from `drafts`, never
475
494
  * an autosave), so the chrome picks up a change on the next poll with no reload.
@@ -500,7 +519,7 @@ export interface HistoryEntryView {
500
519
  at: string;
501
520
  titles: string[];
502
521
  files: string[];
503
- state: 'publishing' | 'live' | 'failed';
522
+ state: 'publishing' | 'live' | 'failed' | 'slow';
504
523
  }
505
524
  /** GET /_caracms/history. */
506
525
  export interface HistoryResponse {
@@ -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 {