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
package/src/live/locks.ts CHANGED
@@ -28,11 +28,16 @@
28
28
  *
29
29
  * **Presence** (`caracms:presence`, a hash by tab): each poll records the person and the
30
30
  * document their tab has open; someone is "on" a document while a tab of theirs
31
- * polled with it in the last `presenceMs`.
31
+ * polled with it within that record's hold: twice the tab's poll interval plus 3 s
32
+ * (Redis diet Decision 9), so a slow tab stays on and a closed fast one goes quickly.
33
+ *
34
+ * **The status cache** (status-cache.ts, Redis diet Decision 2): every write here tells
35
+ * it what landed, so the status poll can read locks and presence from memory.
32
36
  */
33
37
  import type { LockState, LockView, PresenceView } from '../editor/types.ts';
34
38
  import type { Person } from './messages.ts';
35
39
  import type { RedisClient } from './redis.ts';
40
+ import { landed, type StatusCache } from './status-cache.ts';
36
41
  import type { LiveTimings } from './timings.ts';
37
42
 
38
43
  export interface LockRecord {
@@ -48,7 +53,7 @@ export interface LockRecord {
48
53
  deleted?: true;
49
54
  }
50
55
 
51
- interface ReadLock {
56
+ export interface ReadLock {
52
57
  raw: string;
53
58
  lock: LockRecord;
54
59
  }
@@ -57,11 +62,16 @@ interface PresenceRecord {
57
62
  person: Person;
58
63
  file: string | null;
59
64
  at: number;
65
+ /** ms the record holds after `at` (Redis diet Decision 9). Absent (written before it was added): `presenceMs`. */
66
+ hold?: number;
60
67
  }
61
68
 
62
69
  /** How long a releasing lock with no autosave yet waits for the tab's last write. */
63
70
  export const RELEASE_GRACE_MS = 5_000;
64
71
 
72
+ /** Redis diet Decision 9: a presence record holds for twice the tab's poll interval plus this. */
73
+ export const PRESENCE_SLACK_MS = 3_000;
74
+
65
75
  const sameEmail = (a: Person, b: Person) => a.email.toLowerCase() === b.email.toLowerCase();
66
76
 
67
77
  export type AcquireResult = { ok: true; lock: LockRecord } | { ok: false; lock: LockRecord };
@@ -73,17 +83,38 @@ export class Locks {
73
83
  readonly prefix: string;
74
84
  /** Whether the document has an autosave not yet committed. */
75
85
  private readonly pending: (file: string) => Promise<boolean>;
86
+ private readonly cache: StatusCache | null;
76
87
 
77
- constructor(redis: RedisClient, timings: LiveTimings, now: () => number, pending: (file: string) => Promise<boolean>, prefix = 'caracms:') {
88
+ constructor(
89
+ redis: RedisClient,
90
+ timings: LiveTimings,
91
+ now: () => number,
92
+ pending: (file: string) => Promise<boolean>,
93
+ prefix = 'caracms:',
94
+ cache: StatusCache | null = null,
95
+ ) {
78
96
  this.redis = redis;
79
97
  this.timings = timings;
80
98
  this.now = now;
81
99
  this.pending = pending;
82
100
  this.prefix = prefix;
101
+ this.cache = cache;
83
102
  }
84
103
 
85
104
  key = (file: string) => `${this.prefix}lock:${file}`;
86
- private presenceKey = () => `${this.prefix}presence`;
105
+ /** What every lock key starts with (the status cache scans it). */
106
+ get keyPrefix(): string {
107
+ return this.key('');
108
+ }
109
+ readonly presenceKey = () => `${this.prefix}presence`;
110
+
111
+ /** A compare-and-swap on a lock key, and the status cache told what came of it. */
112
+ private async swap(file: string, expected: string | null, raw: string): Promise<boolean> {
113
+ const ok = await landed(this.cache, this.key(file), () => this.redis.compareAndSet(this.key(file), expected, raw, { px: this.timings.autosaveTtlMs }));
114
+ if (ok) this.cache?.string(this.key(file), raw);
115
+ else this.cache?.drop(this.key(file));
116
+ return ok;
117
+ }
87
118
 
88
119
  private parse(raw: string | null): ReadLock | null {
89
120
  if (raw === null) return null;
@@ -101,14 +132,19 @@ export class Locks {
101
132
 
102
133
  /** Every lock key: one SCAN and one MGET. */
103
134
  async all(): Promise<Map<string, ReadLock>> {
104
- const keys = await this.redis.scan(`${this.prefix}lock:*`);
105
- const out = new Map<string, ReadLock>();
106
- if (keys.length === 0) return out;
135
+ const keys = await this.redis.scan(`${this.keyPrefix}*`);
136
+ if (keys.length === 0) return new Map();
107
137
  const raws = await this.redis.mget(keys);
108
- keys.forEach((k, i) => {
109
- const read = this.parse(raws[i] ?? null);
110
- if (read) out.set(k.slice(this.key('').length), read);
111
- });
138
+ return this.allFrom(keys.map((k, i) => [k, raws[i] ?? null]));
139
+ }
140
+
141
+ /** The locks among these keys and values (a scan of `keyPrefix`), by file. */
142
+ allFrom(entries: Iterable<readonly [string, string | null]>): Map<string, ReadLock> {
143
+ const out = new Map<string, ReadLock>();
144
+ for (const [k, raw] of entries) {
145
+ const read = this.parse(raw);
146
+ if (read) out.set(k.slice(this.keyPrefix.length), read);
147
+ }
112
148
  return out;
113
149
  }
114
150
 
@@ -125,7 +161,7 @@ export class Locks {
125
161
  }
126
162
 
127
163
  private put(file: string, expected: string | null, lock: LockRecord): Promise<boolean> {
128
- return this.redis.compareAndSet(this.key(file), expected, JSON.stringify(lock), { px: this.timings.autosaveTtlMs });
164
+ return this.swap(file, expected, JSON.stringify(lock));
129
165
  }
130
166
 
131
167
  private static mine(lock: LockRecord, tab: string, person: Person) {
@@ -161,18 +197,32 @@ export class Locks {
161
197
  * is still here), except for a lock the tab's own delete released. A lapsed lock that still names this tab is re-taken (a tab back
162
198
  * from hidden). Nothing is taken that the tab never held.
163
199
  */
164
- async beat(file: string, tab: string, person: Person): Promise<ReadLock | null> {
200
+ async beat(file: string, tab: string, person: Person, known?: ReadLock | null): Promise<ReadLock | null> {
165
201
  for (let attempt = 0; attempt < 3; attempt++) {
166
- const cur = await this.read(file);
202
+ // `known`: the lock as the status cache has it, the first try's expected value (Redis diet Decision 6).
203
+ const cur = attempt === 0 && known !== undefined ? known : await this.read(file);
167
204
  if (!cur || !Locks.mine(cur.lock, tab, person)) return cur;
168
205
  // A poll in flight when the tab deleted the document must not un-release its lock (M5a).
169
206
  const next = { ...cur.lock, beat: this.now(), releasing: cur.lock.deleted ? cur.lock.releasing : null };
170
207
  const raw = JSON.stringify(next);
171
- if (await this.redis.compareAndSet(this.key(file), cur.raw, raw, { px: this.timings.autosaveTtlMs })) return { raw, lock: next };
208
+ if (await this.swap(file, cur.raw, raw)) return { raw, lock: next };
172
209
  }
173
210
  return this.read(file);
174
211
  }
175
212
 
213
+ /**
214
+ * Redis diet Decision 6: does the status poll need to beat for this tab? Only when the
215
+ * lock (as the cache has it) is the tab's own and its last beat is `beatEveryMs` old
216
+ * (a lapsed one is older still: it's taken back), or it's marked releasing and not
217
+ * deleted (a tab back from `pagehide`: the beat clears it). A lock that isn't the
218
+ * tab's, or none, needs no beat: the beat takes nothing the tab never held.
219
+ */
220
+ needsBeat(cached: ReadLock | null, tab: string, person: Person): boolean {
221
+ if (!cached || !Locks.mine(cached.lock, tab, person)) return false;
222
+ if (cached.lock.releasing !== null && !cached.lock.deleted) return true;
223
+ return this.now() - cached.lock.beat >= this.timings.beatEveryMs;
224
+ }
225
+
176
226
  /** Edit here: move your own lock to this tab at once, or take a free one. Someone else's refuses. */
177
227
  async take(file: string, tab: string, person: Person): Promise<AcquireResult> {
178
228
  for (let attempt = 0; attempt < 5; attempt++) {
@@ -218,7 +268,10 @@ export class Locks {
218
268
  /** Delete a lock that no longer holds (the sweep, after a commit). */
219
269
  async dropIfFree(file: string): Promise<void> {
220
270
  const cur = await this.read(file);
221
- if (cur && !(await this.live(cur, file))) await this.redis.compareAndDelete(this.key(file), cur.raw);
271
+ if (cur && !(await this.live(cur, file))) {
272
+ if (await landed(this.cache, this.key(file), () => this.redis.compareAndDelete(this.key(file), cur.raw))) this.cache?.string(this.key(file), null);
273
+ else this.cache?.drop(this.key(file));
274
+ }
222
275
  }
223
276
 
224
277
  view(file: string, lock: LockRecord, tab: string | null, person: Person): LockView {
@@ -238,19 +291,28 @@ export class Locks {
238
291
 
239
292
  // ── Presence ────────────────────────────────────────────────────────────────
240
293
 
241
- /** Record a poll: this tab, this person, this document (or none). */
242
- async present(tab: string, person: Person, file: string | null): Promise<void> {
243
- const record: PresenceRecord = { person, file, at: this.now() };
244
- await this.redis.hset(this.presenceKey(), { [tab]: JSON.stringify(record) });
294
+ /**
295
+ * Record a poll: this tab, this person, this document (or none). `intervalMs`: how long
296
+ * until the tab's next poll; the record holds for twice that plus 3 s (Decision 9).
297
+ */
298
+ async present(tab: string, person: Person, file: string | null, intervalMs = this.timings.pollMs): Promise<void> {
299
+ const record: PresenceRecord = { person, file, at: this.now(), hold: 2 * intervalMs + PRESENCE_SLACK_MS };
300
+ const fields = { [tab]: JSON.stringify(record) };
301
+ await landed(this.cache, this.presenceKey(), () => this.redis.hset(this.presenceKey(), fields));
302
+ this.cache?.hash(this.presenceKey(), fields);
245
303
  }
246
304
 
247
- /** Who is on which document now (polled with it in the last `presenceMs`). Long-gone tabs are pruned. */
305
+ /** Who is on which document now (polled with it within the record's hold). Long-gone tabs are pruned. */
248
306
  async presence(): Promise<PresenceView[]> {
249
- const all = await this.redis.hgetall(this.presenceKey());
307
+ return this.presenceFrom(Object.entries(await this.redis.hgetall(this.presenceKey())));
308
+ }
309
+
310
+ /** `presence()` over the hash's fields as read (the status cache's, on a poll). */
311
+ async presenceFrom(fields: Iterable<readonly [string, string]>): Promise<PresenceView[]> {
250
312
  const now = this.now();
251
313
  const byFile = new Map<string, Person[]>();
252
314
  const gone: string[] = [];
253
- for (const [tab, raw] of Object.entries(all)) {
315
+ for (const [tab, raw] of fields) {
254
316
  let r: PresenceRecord;
255
317
  try {
256
318
  r = JSON.parse(raw) as PresenceRecord;
@@ -258,8 +320,9 @@ export class Locks {
258
320
  gone.push(tab);
259
321
  continue;
260
322
  }
261
- if (now - r.at > this.timings.presenceMs) {
262
- if (now - r.at > this.timings.lockLapseMs) gone.push(tab);
323
+ const hold = typeof r.hold === 'number' ? r.hold : this.timings.presenceMs;
324
+ if (now - r.at > hold) {
325
+ if (now - r.at > Math.max(hold, this.timings.lockLapseMs)) gone.push(tab);
263
326
  continue;
264
327
  }
265
328
  if (!r.file) continue;
@@ -267,7 +330,10 @@ export class Locks {
267
330
  if (!people.some((p) => sameEmail(p, r.person))) people.push(r.person);
268
331
  byFile.set(r.file, people);
269
332
  }
270
- if (gone.length) await this.redis.hdel(this.presenceKey(), ...gone);
333
+ if (gone.length) {
334
+ await landed(this.cache, this.presenceKey(), () => this.redis.hdel(this.presenceKey(), ...gone));
335
+ this.cache?.hash(this.presenceKey(), {}, gone);
336
+ }
271
337
  return [...byFile].map(([file, people]) => ({ file, people }));
272
338
  }
273
339
  }
package/src/live/mode.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * Which mode the editor runs in, read from the environment (M4 Decision 1).
3
3
  *
4
- * - **Local mode**: `astro dev` with no `GITHUB_APP_ID`. M3's editor, files on disk.
5
- * - **Live mode**: whenever the App is configured, and **always on a production
6
- * server**. It needs `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, the repo
7
- * (`RAILWAY_GIT_REPO_OWNER` and `RAILWAY_GIT_REPO_NAME`, or `GITHUB_REPO=owner/name`;
8
- * Decision 3), `CARACMS_SECRET`, and `REDIS_URL` in production (dev may use the
9
- * stand-in). A production server missing any of them still serves the public site;
10
- * `/admin` answers 503 naming them (Phase 2 builds that from `missing`).
4
+ * - **Local mode**: `astro dev` with no GitHub credentials (`GITHUB_APP_ID` or
5
+ * `CARACMS_GITHUB_TOKEN`). M3's editor, files on disk.
6
+ * - **Live mode**: whenever a credential is configured, and **always on a production
7
+ * server**. It needs one GitHub credential (a fine-grained token, or the App's
8
+ * `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`; runs-anywhere Decisions 3–4), the repo
9
+ * (`GITHUB_REPO=owner/name`, or Railway's `RAILWAY_GIT_REPO_OWNER` and
10
+ * `RAILWAY_GIT_REPO_NAME`; Decision 6), `CARACMS_SECRET`, `REDIS_URL` and the site's
11
+ * `https://` address in production (dev may use the stand-in Redis). A production
12
+ * server missing any of them still serves the public site; `/admin` answers 503
13
+ * naming them (Phase 2 builds that from `missing`).
11
14
  * - **The in-memory GitHub** (Decision 2): `CARACMS_GITHUB=memory`, for tests and local
12
15
  * browser runs, never against the site's own repo. It's live mode with no App and
13
16
  * no repo, and it's **refused in production**.
@@ -19,9 +22,10 @@ import { DEFAULT_BRANCHES, type BranchNames } from './branches.ts';
19
22
  import { DEFAULT_TIMINGS, type LiveTimings } from './timings.ts';
20
23
 
21
24
  export interface LiveSettings {
22
- /** `memory`: the in-memory GitHub backend (Decision 2). */
25
+ /** `memory`: the in-memory GitHub backend (Decision 2). `token`: CARACMS_GITHUB_TOKEN (runs-anywhere Decision 3). */
23
26
  github:
24
27
  | { kind: 'app'; appId: string; privateKey: string; owner: string; repo: string }
28
+ | { kind: 'token'; token: string; owner: string; repo: string }
25
29
  | { kind: 'memory' };
26
30
  /** Null in dev without REDIS_URL: the in-memory stand-in. */
27
31
  redisUrl: string | null;
@@ -31,12 +35,22 @@ export interface LiveSettings {
31
35
  allow: string[];
32
36
  /** GITHUB_WEBHOOK_SECRET; null means the webhook route answers 404 (Decision 14). */
33
37
  webhookSecret: string | null;
34
- /** Try again (Decision 12): all three, or null. */
38
+ /** Try again through Railway's API (Decision 12): all three, or null. */
35
39
  railway: { token: string; serviceId: string; environmentId: string } | null;
40
+ /** Try again through a deploy hook (runs-anywhere Decision 11): CARACMS_DEPLOY_HOOK_URL. A secret: never logged or sent to the browser. */
41
+ deployHook?: string | null;
42
+ /**
43
+ * The site's public origin (runs-anywhere Decision 7): Astro's `site` (baked at build,
44
+ * SITE_URL at build included), then SITE_URL, then RAILWAY_PUBLIC_DOMAIN. Never null
45
+ * in production live mode; missing (a test's settings) means unknown.
46
+ */
47
+ address?: string | null;
36
48
  /** CARACMS_BACKUP_USER and CARACMS_BACKUP_PASSWORD, both or neither (Phase 3 checks the length). */
37
49
  backup: { user: string; password: string } | null;
38
- /** CARACMS_RUNNING_COMMIT, else RAILWAY_GIT_COMMIT_SHA; null with no deploy pipeline (Decision 12). */
50
+ /** The running commit at start (runs-anywhere Decision 9, `runningCommitFrom`); null with none known. */
39
51
  runningCommit: string | null;
52
+ /** The commit baked in at build (`Baked.commit`); status reads the running commit afresh from it and the environment. */
53
+ bakedCommit?: string | null;
40
54
  /** Dev only: CARACMS_TIMINGS, a JSON object of LiveTimings to shorten (Decision 2). */
41
55
  timings: LiveTimings;
42
56
  /**
@@ -56,17 +70,90 @@ export type Mode =
56
70
 
57
71
  type Env = Record<string, string | undefined>;
58
72
 
73
+ /** What the build baked in (the integration's defines; editor/server/mode.ts passes them). */
74
+ export interface Baked {
75
+ /** Astro's `site` at build: the config's, or SITE_URL's (M6 Decision 3). */
76
+ site?: string | null;
77
+ /** The commit the build was made from (runs-anywhere Decision 9). */
78
+ commit?: string | null;
79
+ }
80
+
81
+ // The sentences the configuration problems print, kept here so the docs can quote them.
82
+ export const BOTH_CREDENTIALS_PROBLEM =
83
+ 'Both a GitHub App (GITHUB_APP_ID) and a token (CARACMS_GITHUB_TOKEN) are set: set one of them, not both.';
84
+ export const CLASSIC_TOKEN_PROBLEM =
85
+ 'CARACMS_GITHUB_TOKEN is a classic token, which reaches every repo its owner can: use a fine-grained token (it starts with github_pat_) with access to this site\'s repo only.';
86
+ export const NOT_FINE_GRAINED_PROBLEM = 'CARACMS_GITHUB_TOKEN must be a fine-grained token: it starts with github_pat_.';
87
+ export const NO_ADDRESS_PROBLEM =
88
+ "The site's public address isn't known: set SITE_URL to it, like https://fernhill.ie, both when the site is built and when it runs. Without it, sign-in links can't be sent and the editor's saves are refused.";
89
+ export const HTTP_ADDRESS_PROBLEM = (address: string) =>
90
+ `The site's address (${address}) is http://, but signing in needs https://: the session cookie is only ever sent over https. Set SITE_URL to the https:// address (http:// works for localhost only).`;
91
+ export const BOTH_REDEPLOYERS_PROBLEM =
92
+ 'Both CARACMS_DEPLOY_HOOK_URL and CARACMS_RAILWAY_TOKEN are set: Try again redeploys through one of them, so set only one.';
93
+
94
+ /** The prefixes of GitHub's classic tokens (`ghp_` and the OAuth, user-to-server, server-to-server and refresh ones). */
95
+ const CLASSIC_TOKEN = /^(ghp|gho|ghu|ghs|ghr)_/;
96
+
97
+ /** On Railway: it sets these on every service (the gate detects the host the same way). */
98
+ const onRailway = (env: Env) => Boolean(env.RAILWAY_ENVIRONMENT_ID?.trim() || env.RAILWAY_GIT_REPO_OWNER?.trim() || env.RAILWAY_GIT_REPO_NAME?.trim());
99
+
100
+ /**
101
+ * The hosts' commit variables, in order (runs-anywhere Decision 9): Railway's, Render's,
102
+ * Coolify's. The build bakes the first one set (or `git rev-parse HEAD`).
103
+ */
104
+ export const HOST_COMMIT_VARIABLES = ['RAILWAY_GIT_COMMIT_SHA', 'RENDER_GIT_COMMIT', 'SOURCE_COMMIT'] as const;
105
+
106
+ /**
107
+ * The running commit (runs-anywhere Decision 9): CARACMS_RUNNING_COMMIT, then a host's
108
+ * variable, then the commit baked in at build. A value at run time wins over the baked
109
+ * one; the in-memory GitHub ignores the baked one (it's this repo's, which the in-memory
110
+ * repo never has). Null with none known.
111
+ */
112
+ export function runningCommitFrom(env: Env, baked: string | null | undefined): string | null {
113
+ for (const name of ['CARACMS_RUNNING_COMMIT', ...HOST_COMMIT_VARIABLES]) {
114
+ const v = env[name]?.trim();
115
+ if (v) return v;
116
+ }
117
+ if (env.CARACMS_GITHUB?.trim() === 'memory') return null;
118
+ return baked?.trim() || null;
119
+ }
120
+
121
+ /**
122
+ * The site's public origin (runs-anywhere Decision 7): Astro's `site` as built, then
123
+ * SITE_URL, then Railway's public domain. Null when none is set or SITE_URL doesn't parse
124
+ * (the build already warned).
125
+ */
126
+ export function addressFrom(env: Env, bakedSite: string | null | undefined): string | null {
127
+ for (const raw of [bakedSite, env.SITE_URL]) {
128
+ const v = raw?.trim();
129
+ if (!v) continue;
130
+ try {
131
+ const url = new URL(v);
132
+ if (url.protocol === 'https:' || url.protocol === 'http:') return url.origin;
133
+ } catch {
134
+ /* the next one */
135
+ }
136
+ }
137
+ const railway = env.RAILWAY_PUBLIC_DOMAIN?.trim();
138
+ return railway ? `https://${railway}` : null;
139
+ }
140
+
141
+ const isLocalhost = (host: string) => host === 'localhost' || host.endsWith('.localhost') || host === '127.0.0.1' || host === '[::1]';
142
+
59
143
  /**
60
144
  * Read the mode. `production` is whether this is a production server (the
61
145
  * integration knows: not `astro dev`); it defaults to `NODE_ENV === 'production'`.
146
+ * `baked` is what the build baked in (the address and the commit).
62
147
  */
63
- export function readMode(env: Env = process.env, production = env.NODE_ENV === 'production'): Mode {
148
+ export function readMode(env: Env = process.env, production = env.NODE_ENV === 'production', baked: Baked = {}): Mode {
64
149
  const get = (name: string) => {
65
150
  const v = env[name]?.trim();
66
151
  return v ? v : null;
67
152
  };
68
153
  const memory = get('CARACMS_GITHUB') === 'memory';
69
- if (!production && !memory && !get('GITHUB_APP_ID')) return { kind: 'local' };
154
+ // Never GITHUB_TOKEN (Decision 4): the gh CLI and GitHub Actions export it.
155
+ const ghToken = get('CARACMS_GITHUB_TOKEN');
156
+ if (!production && !memory && !get('GITHUB_APP_ID') && !ghToken) return { kind: 'local' };
70
157
 
71
158
  const missing: string[] = [];
72
159
  const problems: string[] = [];
@@ -82,15 +169,38 @@ export function readMode(env: Env = process.env, production = env.NODE_ENV === '
82
169
 
83
170
  let github: LiveSettings['github'] = { kind: 'memory' };
84
171
  if (!memory || production) {
85
- const appId = need('GITHUB_APP_ID');
86
- const privateKey = need('GITHUB_APP_PRIVATE_KEY');
172
+ const appId = get('GITHUB_APP_ID');
87
173
  const repo = readRepo(env, problems);
88
- if (!repo) missing.push('RAILWAY_GIT_REPO_OWNER and RAILWAY_GIT_REPO_NAME (or GITHUB_REPO)');
89
- github = { kind: 'app', appId, privateKey, owner: repo?.owner ?? '', repo: repo?.repo ?? '' };
174
+ const owner = repo?.owner ?? '';
175
+ const name = repo?.repo ?? '';
176
+ if (appId && ghToken) problems.push(BOTH_CREDENTIALS_PROBLEM);
177
+ if (ghToken) {
178
+ if (CLASSIC_TOKEN.test(ghToken)) problems.push(CLASSIC_TOKEN_PROBLEM);
179
+ else if (!ghToken.startsWith('github_pat_')) problems.push(NOT_FINE_GRAINED_PROBLEM);
180
+ github = { kind: 'token', token: ghToken, owner, repo: name };
181
+ } else if (appId) {
182
+ github = { kind: 'app', appId, privateKey: need('GITHUB_APP_PRIVATE_KEY'), owner, repo: name };
183
+ } else {
184
+ missing.push('CARACMS_GITHUB_TOKEN (or GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY)');
185
+ }
186
+ if (!repo && !problems.some((p) => p.startsWith('GITHUB_REPO'))) {
187
+ missing.push(onRailway(env) ? 'RAILWAY_GIT_REPO_OWNER and RAILWAY_GIT_REPO_NAME (or GITHUB_REPO)' : 'GITHUB_REPO');
188
+ }
90
189
  }
91
190
  const secret = need('CARACMS_SECRET');
92
191
  const redisUrl = production ? need('REDIS_URL') : get('REDIS_URL');
93
192
 
193
+ // The address (Decision 7): known, and https:// (but for localhost), in production.
194
+ const address = addressFrom(env, baked.site);
195
+ if (production && !address) problems.push(NO_ADDRESS_PROBLEM);
196
+ if (production && address && address.startsWith('http:') && !isLocalhost(new URL(address).hostname)) problems.push(HTTP_ADDRESS_PROBLEM(address));
197
+
198
+ const deployHook = get('CARACMS_DEPLOY_HOOK_URL');
199
+ if (deployHook) {
200
+ if (get('CARACMS_RAILWAY_TOKEN')) problems.push(BOTH_REDEPLOYERS_PROBLEM);
201
+ if (!/^https?:\/\//i.test(deployHook)) problems.push('CARACMS_DEPLOY_HOOK_URL must be the hook\'s https:// address.');
202
+ }
203
+
94
204
  const token = get('CARACMS_RAILWAY_TOKEN');
95
205
  const serviceId = get('RAILWAY_SERVICE_ID');
96
206
  const environmentId = get('RAILWAY_ENVIRONMENT_ID');
@@ -119,8 +229,11 @@ export function readMode(env: Env = process.env, production = env.NODE_ENV === '
119
229
  .filter(Boolean),
120
230
  webhookSecret: get('GITHUB_WEBHOOK_SECRET'),
121
231
  railway: token && serviceId && environmentId ? { token, serviceId, environmentId } : null,
232
+ deployHook,
233
+ address,
122
234
  backup: user && password ? { user, password } : null,
123
- runningCommit: get('CARACMS_RUNNING_COMMIT') ?? get('RAILWAY_GIT_COMMIT_SHA'),
235
+ runningCommit: runningCommitFrom(env, baked.commit),
236
+ bakedCommit: baked.commit ?? null,
124
237
  timings,
125
238
  branches,
126
239
  production,
@@ -164,7 +277,7 @@ function isBranchName(name: string): boolean {
164
277
  );
165
278
  }
166
279
 
167
- /** The repo: GITHUB_REPO overrides Railway's own variables (Decision 3). */
280
+ /** The repo: GITHUB_REPO overrides Railway's own variables (M4 Decision 3; runs-anywhere Decision 6). */
168
281
  function readRepo(env: Env, problems: string[]): { owner: string; repo: string } | null {
169
282
  const override = env.GITHUB_REPO?.trim();
170
283
  if (override) {
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The editor's notices in Redis (M4 Decision 7 and 10 step 1), in one place so the
3
+ * status cache hears of every write (Redis diet Decision 2).
4
+ *
5
+ * - **"Published as it stood"** (`caracms:notices`, a hash by notice id): Publish leaves
6
+ * one for each person who had a published document open; dismissing it deletes it.
7
+ * - **Dismissed "draft kept" notices** (`caracms:dismissed`, a set of ids): those are
8
+ * shared, worked out from git (`git.keptDraftNotices()`), so a dismissal is remembered
9
+ * here; a new conflict has a new id and shows again.
10
+ *
11
+ * Both expire a week after their last write (`autosaveTtlMs`), as a backstop.
12
+ */
13
+ import type { NoticeView } from '../editor/types.ts';
14
+ import type { Person } from './messages.ts';
15
+ import type { RedisClient } from './redis.ts';
16
+ import { landed, type StatusCache } from './status-cache.ts';
17
+ import type { LiveTimings } from './timings.ts';
18
+
19
+ /** "Aoife published Home as it stood at 14:20", for one person. */
20
+ export interface StoodNotice {
21
+ id: string;
22
+ /** The person it's for, lower-cased. */
23
+ for: string;
24
+ file: string;
25
+ title: string;
26
+ by: Person;
27
+ at: string;
28
+ }
29
+
30
+ export class Notices {
31
+ readonly redis: RedisClient;
32
+ readonly timings: LiveTimings;
33
+ private readonly cache: StatusCache | null;
34
+ readonly noticesKey: string;
35
+ readonly dismissedKey: string;
36
+
37
+ constructor(redis: RedisClient, timings: LiveTimings, prefix = 'caracms:', cache: StatusCache | null = null) {
38
+ this.redis = redis;
39
+ this.timings = timings;
40
+ this.cache = cache;
41
+ this.noticesKey = `${prefix}notices`;
42
+ this.dismissedKey = `${prefix}dismissed`;
43
+ }
44
+
45
+ /** Leave these "published as it stood" notices. */
46
+ async addStood(notices: readonly StoodNotice[]): Promise<void> {
47
+ if (notices.length === 0) return;
48
+ const fields = Object.fromEntries(notices.map((n) => [n.id, JSON.stringify(n)]));
49
+ await landed(this.cache, this.noticesKey, () => this.redis.hset(this.noticesKey, fields));
50
+ this.cache?.hash(this.noticesKey, fields);
51
+ await this.redis.pexpire(this.noticesKey, this.timings.autosaveTtlMs);
52
+ }
53
+
54
+ /** A "published as it stood" notice is done with. */
55
+ async remove(id: string): Promise<void> {
56
+ await landed(this.cache, this.noticesKey, () => this.redis.hdel(this.noticesKey, id));
57
+ this.cache?.hash(this.noticesKey, {}, [id]);
58
+ }
59
+
60
+ /** A shared "draft kept" notice was dismissed. */
61
+ async dismiss(id: string): Promise<void> {
62
+ await landed(this.cache, this.dismissedKey, () => this.redis.sadd(this.dismissedKey, id));
63
+ this.cache?.add(this.dismissedKey, id);
64
+ await this.redis.pexpire(this.dismissedKey, this.timings.autosaveTtlMs);
65
+ }
66
+
67
+ /** This person's notices, read from Redis now. `kept` and `titleFor` are git's (`keptDraftNotices`, `titleFor`). */
68
+ async of(person: Person, kept: readonly { file: string; commit: string }[], titleFor: (file: string) => string): Promise<NoticeView[]> {
69
+ const [dismissed, stood] = await this.redis.batch([
70
+ ['smembers', this.dismissedKey],
71
+ ['hgetall', this.noticesKey],
72
+ ] as const);
73
+ return Notices.forPerson(person, kept, titleFor, new Set(dismissed), Object.values(stood));
74
+ }
75
+
76
+ /** This person's notices from what was read: the dismissed ids, and the stood notices' values. */
77
+ static forPerson(
78
+ person: Person,
79
+ kept: readonly { file: string; commit: string }[],
80
+ titleFor: (file: string) => string,
81
+ dismissed: ReadonlySet<string>,
82
+ stood: Iterable<string>,
83
+ ): NoticeView[] {
84
+ const out: NoticeView[] = [];
85
+ for (const n of kept) {
86
+ const id = `kept:${n.file}:${n.commit}`;
87
+ if (!dismissed.has(id)) out.push({ id, kind: 'draft-kept', file: n.file, title: titleFor(n.file) });
88
+ }
89
+ const email = person.email.toLowerCase();
90
+ for (const raw of stood) {
91
+ try {
92
+ const n = JSON.parse(raw) as StoodNotice;
93
+ if (n.for === email) out.push({ id: n.id, kind: 'published-as-it-stood', file: n.file, title: n.title, by: n.by, at: n.at });
94
+ } catch {
95
+ /* skip */
96
+ }
97
+ }
98
+ return out;
99
+ }
100
+ }