caracms 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +247 -3
- package/bin/caracms.mjs +32 -16
- package/bin/cloudflare-ips.mjs +38 -0
- package/bin/gate.mjs +277 -4
- package/bin/init.mjs +64 -20
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +5 -3
- package/skills/caracms-adopt/look-same.mjs +8 -4
- package/skills/caracms-blocks/SKILL.md +20 -2
- package/skills/caracms-tina/SKILL.md +14 -10
- package/src/components/Head.astro +3 -2
- package/src/content.ts +2 -0
- package/src/editor/client/editor.ts +24 -6
- package/src/editor/client/live/publish.tsx +20 -10
- package/src/editor/client/pane/parts.tsx +34 -2
- package/src/editor/client/state/keeper.ts +51 -6
- package/src/editor/client/state/live.ts +45 -6
- package/src/editor/middleware.ts +3 -2
- package/src/editor/routes/try-again.ts +2 -1
- package/src/editor/server/admin-props.ts +2 -1
- package/src/editor/server/mode.ts +13 -3
- package/src/editor/styles/levels/live.css +1 -1
- package/src/editor/types.ts +25 -6
- package/src/files-route.ts +9 -4
- package/src/forms/endpoint.ts +11 -16
- package/src/head.ts +10 -1
- package/src/index.ts +53 -7
- package/src/live/README.md +4 -2
- package/src/live/auth/auth.ts +4 -1
- package/src/live/auth/guard.ts +3 -2
- package/src/live/auth/index.ts +6 -7
- package/src/live/auth/login.ts +17 -8
- package/src/live/auth/middleware.ts +7 -3
- package/src/live/auth/request.ts +92 -11
- package/src/live/auth/views.ts +9 -4
- package/src/live/autosaves.ts +59 -16
- package/src/live/backend.ts +13 -1
- package/src/live/drafts-store.ts +4 -3
- package/src/live/editor.ts +92 -67
- package/src/live/git.ts +112 -34
- package/src/live/github.ts +128 -13
- package/src/live/locks.ts +92 -26
- package/src/live/mode.ts +131 -18
- package/src/live/notices.ts +100 -0
- package/src/live/redis.ts +161 -39
- package/src/live/runtime.ts +11 -3
- package/src/live/status-cache.ts +219 -0
- package/src/live/status.ts +14 -2
- package/src/live/timings.ts +24 -0
- package/src/media.ts +26 -1
package/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
|
|
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(
|
|
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
|
-
|
|
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.
|
|
105
|
-
|
|
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.
|
|
109
|
-
|
|
110
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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)))
|
|
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
|
-
/**
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
262
|
-
|
|
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)
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* (
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 =
|
|
86
|
-
const privateKey = need('GITHUB_APP_PRIVATE_KEY');
|
|
172
|
+
const appId = get('GITHUB_APP_ID');
|
|
87
173
|
const repo = readRepo(env, problems);
|
|
88
|
-
|
|
89
|
-
|
|
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:
|
|
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
|
+
}
|