caracms 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +65 -20
- package/bin/templates/catch-all.astro.txt +3 -2
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +17 -3
- package/skills/caracms-adopt/look-same.mjs +24 -11
- package/skills/caracms-blocks/SKILL.md +69 -4
- package/skills/caracms-content/SKILL.md +2 -1
- package/skills/caracms-tina/SKILL.md +431 -0
- package/skills/caracms-tina/field-kinds.md +88 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
- package/src/block-runs.ts +84 -0
- package/src/cli/validate.ts +10 -5
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +20 -1
- package/src/components/Form.astro +76 -10
- package/src/components/Head.astro +4 -2
- package/src/components/Media.astro +27 -1
- package/src/components/RichText.astro +12 -4
- package/src/components/Text.astro +32 -9
- package/src/components/form-client.ts +7 -4
- package/src/components/sitemap.xml.ts +2 -1
- package/src/config.ts +32 -1
- package/src/content-schema.ts +7 -4
- package/src/content.ts +28 -0
- package/src/document.ts +15 -2
- package/src/editor/client/block-list/summary.ts +7 -1
- package/src/editor/client/editor.ts +24 -6
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +17 -12
- package/src/editor/client/fields/ReferenceField.tsx +6 -0
- package/src/editor/client/fields/RichTextField.tsx +38 -32
- package/src/editor/client/fields/host.tsx +5 -0
- package/src/editor/client/fields/parts.tsx +2 -1
- package/src/editor/client/fields/rich-text-doc.ts +28 -2
- package/src/editor/client/fields/rich-text-schema.ts +41 -2
- package/src/editor/client/live/publish.tsx +20 -10
- package/src/editor/client/nav/DocRow.tsx +6 -0
- package/src/editor/client/overlays/rich-text-bar.ts +6 -6
- package/src/editor/client/pane/levels/DocumentLevel.tsx +3 -0
- package/src/editor/client/pane/parts.tsx +34 -2
- package/src/editor/client/preview/driver.ts +5 -5
- package/src/editor/client/preview/field-text.ts +40 -2
- package/src/editor/client/preview/page-edit.ts +2 -2
- package/src/editor/client/preview/page-editor-registry.ts +4 -3
- package/src/editor/client/preview/page-editor.ts +3 -3
- package/src/editor/client/state/document-store.ts +20 -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/editor.css +2 -0
- package/src/editor/styles/levels/live.css +1 -1
- package/src/editor/types.ts +27 -6
- package/src/fields.ts +63 -15
- package/src/files-route.ts +9 -4
- package/src/forms/endpoint.ts +11 -16
- package/src/forms/rules.ts +9 -1
- package/src/head.ts +17 -4
- package/src/index.ts +53 -7
- package/src/links.ts +35 -0
- package/src/live/README.md +5 -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 +54 -24
- package/src/live/branches.ts +19 -0
- 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 +177 -18
- package/src/live/notices.ts +100 -0
- package/src/live/redis.ts +161 -39
- package/src/live/runtime.ts +15 -4
- 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 +40 -7
- package/src/read.ts +2 -1
- package/src/rich-text.ts +30 -5
- package/src/serialize.ts +10 -1
package/src/live/auth/request.ts
CHANGED
|
@@ -6,20 +6,53 @@
|
|
|
6
6
|
const first = (value: string | null) => (value ?? '').split(',')[0]!.trim();
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* The
|
|
10
|
-
*
|
|
9
|
+
* The header `caracms start`'s gate sets on every request, replacing any copy the request
|
|
10
|
+
* brought (runs-anywhere Decision 8, the spec's Contracts): the visitor's IP, decided from
|
|
11
|
+
* the one source `CARACMS_CLIENT_IP_HEADER` names.
|
|
12
|
+
*/
|
|
13
|
+
export const CLIENT_IP_HEADER = 'x-caracms-client-ip';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The mark `caracms start` puts on its own process before importing the built site, which
|
|
17
|
+
* runs in that same process (`bin/gate.mjs` → `GATED_MARK`, `markProcessGated()`):
|
|
18
|
+
* `globalThis[Symbol.for('caracms.gated')] === true`. It's what makes the header above
|
|
19
|
+
* trustworthy: with the mark there, every request reached the site through the gate, which
|
|
20
|
+
* replaced any copy the visitor sent. Without it (`node dist/server/entry.mjs`), nothing
|
|
21
|
+
* replaced it, so the header is the visitor's to choose and is never read.
|
|
22
|
+
*/
|
|
23
|
+
export const GATED_MARK = Symbol.for('caracms.gated');
|
|
24
|
+
|
|
25
|
+
/** Whether this process was started by `caracms start` (the mark above). */
|
|
26
|
+
export const isGatedProcess = (): boolean => (globalThis as Record<symbol, unknown>)[GATED_MARK] === true;
|
|
27
|
+
|
|
28
|
+
/** The 503 on sign-in, forms and downloads of a production server with no gate (Decision 8; acceptance check 17). */
|
|
29
|
+
export const NO_GATE_MESSAGE =
|
|
30
|
+
"This site wasn't started with `caracms start`, so it can't tell its visitors apart for the limits on signing in, forms and downloads. A developer can start it with `caracms start` (the site's start script).";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The client's IP, which keys the per-IP rate limits (sign-in, forms, downloads).
|
|
11
34
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
35
|
+
* **In production, only `x-caracms-client-ip`**, which `caracms start`'s gate sets on
|
|
36
|
+
* every request from the one trusted source, and only in a process the gate marked
|
|
37
|
+
* (`GATED_MARK`). Null when the mark is missing, whatever the request carries: the server
|
|
38
|
+
* wasn't started by `caracms start`, so a header there is one the visitor sent. Null too
|
|
39
|
+
* when the header is absent. Either way the caller answers 503 with `NO_GATE_MESSAGE`.
|
|
40
|
+
* Nothing else on the request is trusted there: Astro's Node adapter reports the first
|
|
41
|
+
* `X-Forwarded-For` entry as `clientAddress` whenever the Host is trusted, which caracms
|
|
42
|
+
* always arranges for its own domain, so the adapter's address is the visitor's to choose
|
|
43
|
+
* (`node_modules/astro/dist/core/app/node.js`), and `X-Real-IP` is trustworthy only behind
|
|
44
|
+
* a proxy that overwrites it.
|
|
18
45
|
*
|
|
19
|
-
*
|
|
20
|
-
* the
|
|
46
|
+
* **Under `astro dev`** (no gate, and nothing to protect), as before: `X-Real-IP`, then
|
|
47
|
+
* the socket's address as the adapter gives it, then the first `X-Forwarded-For` hop.
|
|
48
|
+
* A spoofed one only rate-limits the spoofer's own requests.
|
|
21
49
|
*/
|
|
22
|
-
export function clientIp(request: Request, clientAddress: () => string | undefined): string {
|
|
50
|
+
export function clientIp(request: Request, clientAddress: () => string | undefined, production = false): string | null {
|
|
51
|
+
if (production) {
|
|
52
|
+
if (!isGatedProcess()) return null;
|
|
53
|
+
const gated = request.headers.get(CLIENT_IP_HEADER)?.trim();
|
|
54
|
+
return gated ? gated.slice(0, 64) : null;
|
|
55
|
+
}
|
|
23
56
|
const real = first(request.headers.get('x-real-ip'));
|
|
24
57
|
if (real) return real.slice(0, 64);
|
|
25
58
|
let socket: string | undefined;
|
|
@@ -53,4 +86,52 @@ export function isSameOrigin(request: Request): boolean {
|
|
|
53
86
|
}
|
|
54
87
|
}
|
|
55
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Runs-anywhere Decision 7: the sentence for a site reached at an address that isn't its
|
|
91
|
+
* own, naming SITE_URL. The likeliest setup mistake: SITE_URL at www while people use the
|
|
92
|
+
* apex (or the other way round), or set differently at build and at run time.
|
|
93
|
+
*/
|
|
94
|
+
export function addressMismatchMessage(reached: string, address: string | null): string {
|
|
95
|
+
return address
|
|
96
|
+
? `This site's address is set to ${address} (SITE_URL), but it was opened at ${reached}, so signing in and saving are refused here. Open it at ${address}, or set SITE_URL to ${reached} where the site is built and where it runs.`
|
|
97
|
+
: `This site's address isn't set, so signing in and saving are refused. Set SITE_URL to ${reached} where the site is built and where it runs.`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The address the visitor typed (the proxy's forwarded host, else Host), when it isn't the one Astro read the request as. */
|
|
101
|
+
export function reachedElsewhere(request: Request): string | null {
|
|
102
|
+
const host = first(request.headers.get('x-forwarded-host')) || first(request.headers.get('host'));
|
|
103
|
+
if (!host) return null;
|
|
104
|
+
const url = new URL(request.url);
|
|
105
|
+
if (host === url.host) return null;
|
|
106
|
+
const proto = first(request.headers.get('x-forwarded-proto')) || url.protocol.replace(':', '');
|
|
107
|
+
return `${proto}://${host}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* For a write refused by `isSameOrigin`: the mismatch sentence when the browser was on
|
|
112
|
+
* this site all along (it says `Sec-Fetch-Site: same-origin`, or its `Origin` is the
|
|
113
|
+
* request's own Host) but Astro, trusting only the site's address, read the request as
|
|
114
|
+
* another host. Null for a real cross-site request.
|
|
115
|
+
*/
|
|
116
|
+
export function addressMismatch(request: Request, address: string | null): string | null {
|
|
117
|
+
let origin: URL;
|
|
118
|
+
try {
|
|
119
|
+
origin = new URL(request.headers.get('origin') ?? '');
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
if (origin.host === new URL(request.url).host) return null;
|
|
124
|
+
const fetchSite = request.headers.get('sec-fetch-site');
|
|
125
|
+
const host = first(request.headers.get('x-forwarded-host')) || first(request.headers.get('host'));
|
|
126
|
+
if (fetchSite !== 'same-origin' && !(!fetchSite && origin.host === host)) return null;
|
|
127
|
+
return addressMismatchMessage(origin.origin, address);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** On a page with no `Origin` (the sign-in form's GET): the sentence when the site is opened at an address that isn't its own. */
|
|
131
|
+
export function addressNotice(request: Request, address: string | null): string | null {
|
|
132
|
+
const reached = reachedElsewhere(request);
|
|
133
|
+
if (!reached || (address && new URL(address).host === new URL(reached).host)) return null;
|
|
134
|
+
return addressMismatchMessage(reached, address);
|
|
135
|
+
}
|
|
136
|
+
|
|
56
137
|
export const isRead = (method: string) => method === 'GET' || method === 'HEAD';
|
package/src/live/auth/views.ts
CHANGED
|
@@ -301,9 +301,9 @@ const FINE = html`<p class="fine">Not you? Close this tab.</p>`;
|
|
|
301
301
|
const backupLink = (next: string) => html`<p class="si-alt"><a class="linkbtn" href="${loginHref({ backup: '1' }, next)}">Use the backup account</a></p>`;
|
|
302
302
|
|
|
303
303
|
/** The email form: plain, signed out, or with the malformed-address error. */
|
|
304
|
-
export function emailView(input: { next: string; value?: string; invalid?: string; signedOut?: boolean; backupEnabled: boolean }): SafeHtml {
|
|
304
|
+
export function emailView(input: { next: string; value?: string; invalid?: string; signedOut?: boolean; backupEnabled: boolean; notice?: string | null }): SafeHtml {
|
|
305
305
|
const bad = !!input.invalid;
|
|
306
|
-
return html`<section class="si-card">${input.signedOut ? html`<p class="notice info" role="status">${ic('check', 'i-sm')}You're signed out.</p>` : ''}<h1>Sign in</h1><p class="sub">We'll email you a sign-in link.</p><form method="post" action="${LOGIN_PATH}" novalidate><input type="hidden" name="next" value="${input.next}"><div class="field"><label class="field-label" for="si-email">Email address</label><input class="input" id="si-email" name="email" type="email" autocomplete="email" inputmode="email" spellcheck="false" autofocus value="${input.value ?? ''}"${new SafeHtml(bad ? ' aria-invalid="true" aria-describedby="si-err"' : '')}>${
|
|
306
|
+
return html`<section class="si-card">${addressNoticeHtml(input.notice)}${input.signedOut ? html`<p class="notice info" role="status">${ic('check', 'i-sm')}You're signed out.</p>` : ''}<h1>Sign in</h1><p class="sub">We'll email you a sign-in link.</p><form method="post" action="${LOGIN_PATH}" novalidate><input type="hidden" name="next" value="${input.next}"><div class="field"><label class="field-label" for="si-email">Email address</label><input class="input" id="si-email" name="email" type="email" autocomplete="email" inputmode="email" spellcheck="false" autofocus value="${input.value ?? ''}"${new SafeHtml(bad ? ' aria-invalid="true" aria-describedby="si-err"' : '')}>${
|
|
307
307
|
bad ? html`<p class="field-err" id="si-err">${ic('warn', 'i-sm')}<span>${input.invalid}</span></p>` : ''
|
|
308
308
|
}</div><button class="btn accent block" type="submit">Send sign-in link</button></form></section>${input.backupEnabled ? backupLink(input.next) : ''}`;
|
|
309
309
|
}
|
|
@@ -317,9 +317,14 @@ export function sentView(input: { email: string; next: string; again: boolean; b
|
|
|
317
317
|
return html`<section class="si-card">${input.again ? html`<p class="notice ok" role="status">${ic('check', 'i-sm')}Sent again.</p>` : ''}<h1>Check your email</h1><p>If <strong>${input.email}</strong> can sign in, a link is on its way. It expires in 15 minutes.</p><p class="fine">Not there? Check spam, or send again.</p><div class="row-acts"><form method="post" action="${LOGIN_PATH}"><input type="hidden" name="email" value="${input.email}"><input type="hidden" name="next" value="${input.next}"><input type="hidden" name="again" value="1"><button class="btn" type="submit">Send again</button></form><a class="linkbtn" href="${loginHref({}, input.next)}">Use a different address</a></div></section>${input.backupEnabled ? backupLink(input.next) : ''}`;
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
/** Runs-anywhere Decision 7: the site was opened at an address that isn't its own (request.ts → `addressNotice`). */
|
|
321
|
+
function addressNoticeHtml(notice: string | null | undefined): SafeHtml | string {
|
|
322
|
+
return notice ? html`<p class="notice fail" role="alert" data-notice="address">${ic('warn', 'i-sm')}${notice}</p>` : '';
|
|
323
|
+
}
|
|
324
|
+
|
|
320
325
|
/** The backup form. Posting it without script only shows it again. */
|
|
321
|
-
export function backupView(input: { next: string }): SafeHtml {
|
|
322
|
-
return html`<section class="si-card"
|
|
326
|
+
export function backupView(input: { next: string; notice?: string | null }): SafeHtml {
|
|
327
|
+
return html`<section class="si-card">${addressNoticeHtml(input.notice)}<p class="notice fail" id="signin-error" role="alert" hidden></p><h1>Backup account</h1><p class="sub">Details are in the shared password manager.</p>${NOSCRIPT}<form method="post" action="${loginHref({ backup: '1' }, input.next)}" data-signin="backup" data-next="${input.next}"><div class="field"><label class="field-label" for="si-user">Username</label><input class="input" id="si-user" name="username" autocomplete="username" autocapitalize="off" spellcheck="false" autofocus required></div><div class="field"><label class="field-label" for="si-pass">Password</label><input class="input" id="si-pass" name="password" type="password" autocomplete="current-password" required></div><button class="btn accent block" type="submit">Sign in</button></form></section><p class="si-alt"><a class="linkbtn" href="${loginHref({}, input.next)}">Use email sign-in instead</a></p>`;
|
|
323
328
|
}
|
|
324
329
|
|
|
325
330
|
/**
|
package/src/live/autosaves.ts
CHANGED
|
@@ -41,10 +41,14 @@
|
|
|
41
41
|
* counts while its old file is gone and its new file is there (`movesIn`, git.ts).
|
|
42
42
|
*
|
|
43
43
|
* Paths are site-relative (`content/pages/index.json`). Values are JSON text.
|
|
44
|
+
*
|
|
45
|
+
* **The status cache** (status-cache.ts, Redis diet Decision 2): every write here of a
|
|
46
|
+
* key the status poll reads (autosaves, change records, moves) tells it what landed.
|
|
44
47
|
*/
|
|
45
48
|
import type { AnyDocument } from '../document.ts';
|
|
46
49
|
import type { Person } from './messages.ts';
|
|
47
50
|
import type { RedisClient } from './redis.ts';
|
|
51
|
+
import { landed, type StatusCache } from './status-cache.ts';
|
|
48
52
|
import type { LiveTimings } from './timings.ts';
|
|
49
53
|
|
|
50
54
|
export interface Autosave {
|
|
@@ -131,17 +135,34 @@ export class Autosaves {
|
|
|
131
135
|
readonly timings: LiveTimings;
|
|
132
136
|
readonly now: () => number;
|
|
133
137
|
readonly prefix: string;
|
|
138
|
+
private readonly cache: StatusCache | null;
|
|
134
139
|
|
|
135
|
-
constructor(redis: RedisClient, timings: LiveTimings, now: () => number, prefix = 'caracms:') {
|
|
140
|
+
constructor(redis: RedisClient, timings: LiveTimings, now: () => number, prefix = 'caracms:', cache: StatusCache | null = null) {
|
|
136
141
|
this.redis = redis;
|
|
137
142
|
this.timings = timings;
|
|
138
143
|
this.now = now;
|
|
139
144
|
this.prefix = prefix;
|
|
145
|
+
this.cache = cache;
|
|
140
146
|
}
|
|
141
147
|
|
|
142
148
|
key = (file: string) => `${this.prefix}autosave:${file}`;
|
|
149
|
+
/** What every autosave key starts with (the status cache scans it). */
|
|
150
|
+
get keyPrefix(): string {
|
|
151
|
+
return this.key('');
|
|
152
|
+
}
|
|
143
153
|
private committedKey = (file: string) => `${this.prefix}committed:${file}`;
|
|
144
|
-
|
|
154
|
+
readonly changedKey = (file: string) => `${this.prefix}changed:${file}`;
|
|
155
|
+
readonly movesKey = () => movesKey(this.prefix);
|
|
156
|
+
|
|
157
|
+
/** A compare-and-swap (or delete) on an autosave key, and the status cache told what came of it. */
|
|
158
|
+
private async swap(key: string, expected: string | null, raw: string | null): Promise<boolean> {
|
|
159
|
+
const ok = await landed(this.cache, key, () =>
|
|
160
|
+
raw === null ? this.redis.compareAndDelete(key, expected!) : this.redis.compareAndSet(key, expected, raw, { px: this.timings.autosaveTtlMs }),
|
|
161
|
+
);
|
|
162
|
+
if (ok) this.cache?.string(key, raw);
|
|
163
|
+
else this.cache?.drop(key);
|
|
164
|
+
return ok;
|
|
165
|
+
}
|
|
145
166
|
|
|
146
167
|
async get(file: string): Promise<Read<Autosave> | null> {
|
|
147
168
|
const raw = await this.redis.get(this.key(file));
|
|
@@ -151,30 +172,34 @@ export class Autosaves {
|
|
|
151
172
|
|
|
152
173
|
/** Every autosave: one SCAN and one MGET. */
|
|
153
174
|
async all(): Promise<Read<Autosave>[]> {
|
|
154
|
-
const keys = await this.redis.scan(`${this.
|
|
175
|
+
const keys = await this.redis.scan(`${this.keyPrefix}*`);
|
|
155
176
|
if (keys.length === 0) return [];
|
|
156
|
-
|
|
177
|
+
return Autosaves.allFrom(await this.redis.mget(keys));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The autosaves among these values (a scan of `keyPrefix`'s, read). */
|
|
181
|
+
static allFrom(raws: Iterable<string | null>): Read<Autosave>[] {
|
|
157
182
|
const out: Read<Autosave>[] = [];
|
|
158
|
-
|
|
183
|
+
for (const raw of raws) {
|
|
159
184
|
const value = parse<Autosave>(raw);
|
|
160
185
|
if (raw !== null && value) out.push({ raw, value });
|
|
161
|
-
}
|
|
186
|
+
}
|
|
162
187
|
return out;
|
|
163
188
|
}
|
|
164
189
|
|
|
165
190
|
/** Store it only if the autosave is still what was read (`null`: only if there's none), so a write that read an older one never replaces a newer. */
|
|
166
191
|
putIf(read: Read<Autosave> | null, autosave: Autosave): Promise<boolean> {
|
|
167
|
-
return this.
|
|
192
|
+
return this.swap(this.key(autosave.file), read?.raw ?? null, JSON.stringify(autosave));
|
|
168
193
|
}
|
|
169
194
|
|
|
170
195
|
/** Delete it only if it's still what was read (a newer write wins). */
|
|
171
196
|
removeIf(read: Read<Autosave>): Promise<boolean> {
|
|
172
|
-
return this.
|
|
197
|
+
return this.swap(this.key(read.value.file), read.raw, null);
|
|
173
198
|
}
|
|
174
199
|
|
|
175
200
|
/** Replace it only if it's still what was read. */
|
|
176
201
|
replaceIf(read: Read<Autosave>, next: Autosave): Promise<boolean> {
|
|
177
|
-
return this.
|
|
202
|
+
return this.swap(this.key(next.file), read.raw, JSON.stringify(next));
|
|
178
203
|
}
|
|
179
204
|
|
|
180
205
|
async committed(file: string): Promise<Committed | null> {
|
|
@@ -186,7 +211,12 @@ export class Autosaves {
|
|
|
186
211
|
}
|
|
187
212
|
|
|
188
213
|
async changed(file: string): Promise<ChangeRecord | null> {
|
|
189
|
-
return
|
|
214
|
+
return Autosaves.changedFrom(await this.redis.get(this.changedKey(file)));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A change record from its text as read. */
|
|
218
|
+
static changedFrom(raw: string | null): ChangeRecord | null {
|
|
219
|
+
return parse<ChangeRecord>(raw);
|
|
190
220
|
}
|
|
191
221
|
|
|
192
222
|
/** Several files' records in one MGET (the documents list's "Last edited"), in the order asked; none: null. */
|
|
@@ -197,7 +227,9 @@ export class Autosaves {
|
|
|
197
227
|
|
|
198
228
|
/** No expiry: one small key per file, overwritten on each change, so "who changed it" is never forgotten while Redis keeps its data. */
|
|
199
229
|
async setChanged(file: string, record: ChangeRecord): Promise<void> {
|
|
200
|
-
|
|
230
|
+
const raw = JSON.stringify(record);
|
|
231
|
+
await landed(this.cache, this.changedKey(file), () => this.redis.set(this.changedKey(file), raw));
|
|
232
|
+
this.cache?.string(this.changedKey(file), raw);
|
|
201
233
|
}
|
|
202
234
|
|
|
203
235
|
/**
|
|
@@ -205,7 +237,8 @@ export class Autosaves {
|
|
|
205
237
|
* from `main` leaves `{ by: null }` ("only merges"), and never overwrites an editor's.
|
|
206
238
|
*/
|
|
207
239
|
async setChangedIfAbsent(file: string, record: Omit<ChangeRecord, 'by'> & { by: Person | null }): Promise<void> {
|
|
208
|
-
|
|
240
|
+
const raw = JSON.stringify(record);
|
|
241
|
+
if (await landed(this.cache, this.changedKey(file), () => this.redis.set(this.changedKey(file), raw, { nx: true }))) this.cache?.string(this.changedKey(file), raw);
|
|
209
242
|
}
|
|
210
243
|
|
|
211
244
|
/** Pre-M7 item 8: the moves waiting to publish, old file → new file. */
|
|
@@ -213,6 +246,16 @@ export class Autosaves {
|
|
|
213
246
|
return new Map(Object.entries(await this.redis.hgetall(movesKey(this.prefix))));
|
|
214
247
|
}
|
|
215
248
|
|
|
249
|
+
private async setMove(from: string, to: string): Promise<void> {
|
|
250
|
+
await landed(this.cache, movesKey(this.prefix), () => this.redis.hset(movesKey(this.prefix), { [from]: to }));
|
|
251
|
+
this.cache?.hash(movesKey(this.prefix), { [from]: to });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async dropMoves(...from: string[]): Promise<void> {
|
|
255
|
+
await landed(this.cache, movesKey(this.prefix), () => this.redis.hdel(movesKey(this.prefix), ...from));
|
|
256
|
+
this.cache?.hash(movesKey(this.prefix), {}, from);
|
|
257
|
+
}
|
|
258
|
+
|
|
216
259
|
/**
|
|
217
260
|
* A document moved from `from` to `to`. Moved since the last Publish already (a record
|
|
218
261
|
* ends at `from`): that record now ends at `to`, or goes when `to` is where it started
|
|
@@ -223,16 +266,16 @@ export class Autosaves {
|
|
|
223
266
|
const moves = await this.moves();
|
|
224
267
|
const earlier = [...moves].find(([, t]) => t === from)?.[0];
|
|
225
268
|
if (earlier !== undefined) {
|
|
226
|
-
if (earlier === to) await this.
|
|
227
|
-
else await this.
|
|
228
|
-
} else if (wasLive) await this.
|
|
269
|
+
if (earlier === to) await this.dropMoves(earlier);
|
|
270
|
+
else await this.setMove(earlier, to);
|
|
271
|
+
} else if (wasLive) await this.setMove(from, to);
|
|
229
272
|
}
|
|
230
273
|
|
|
231
274
|
/** A deleted document's move, if it was moved, is no move any more: it's a deletion. */
|
|
232
275
|
async forgetMoveTo(file: string): Promise<void> {
|
|
233
276
|
const moves = await this.moves();
|
|
234
277
|
const olds = [...moves].filter(([, t]) => t === file).map(([o]) => o);
|
|
235
|
-
if (olds.length) await this.
|
|
278
|
+
if (olds.length) await this.dropMoves(...olds);
|
|
236
279
|
}
|
|
237
280
|
|
|
238
281
|
/** The merge's ancestor for an autosave (Decision 6). See `ancestorFor`. */
|
package/src/live/backend.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { createHash } from 'node:crypto';
|
|
17
17
|
import { gunzipSync } from 'node:zlib';
|
|
18
|
-
import { GitHubClient, GraphQLError, HttpError, Tracker } from './github.ts';
|
|
18
|
+
import { GitHubClient, GraphQLError, HttpError, Tracker, type GitHubAccessProblem } from './github.ts';
|
|
19
19
|
|
|
20
20
|
export interface Head {
|
|
21
21
|
commit: string;
|
|
@@ -26,7 +26,10 @@ export interface Heads {
|
|
|
26
26
|
drafts: Head;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
import type { Branch, BranchNames } from './branches.ts';
|
|
30
|
+
import { DEFAULT_BRANCHES } from './branches.ts';
|
|
31
|
+
|
|
32
|
+
export { DEFAULT_BRANCHES, type Branch, type BranchNames };
|
|
30
33
|
|
|
31
34
|
export interface CommitSummary {
|
|
32
35
|
sha: string;
|
|
@@ -111,6 +114,8 @@ export class MissingBranchError extends Error {
|
|
|
111
114
|
export interface GitBackend {
|
|
112
115
|
readonly tracker: Tracker;
|
|
113
116
|
readonly name: 'github' | 'memory';
|
|
117
|
+
/** The ref name behind each role. */
|
|
118
|
+
readonly branches: Readonly<BranchNames>;
|
|
114
119
|
/** Both heads, in one request. Throws MissingBranchError. */
|
|
115
120
|
readHeads(): Promise<Heads>;
|
|
116
121
|
createBranch(branch: Branch, sha: string): Promise<void>;
|
|
@@ -134,6 +139,8 @@ export interface GitBackend {
|
|
|
134
139
|
listDeployments(count: number): Promise<Deployment[]>;
|
|
135
140
|
/** A deployment's newest status, or null when it has none yet. */
|
|
136
141
|
deploymentStatus(id: number): Promise<DeploymentStatus | null>;
|
|
142
|
+
/** What's wrong with GitHub access now, from the responses so far (runs-anywhere Decision 5); null when nothing is. */
|
|
143
|
+
accessProblem(): GitHubAccessProblem | null;
|
|
137
144
|
}
|
|
138
145
|
|
|
139
146
|
/** Is this GraphQL error a stale head (someone moved the ref)? M1 → G6 (a): often it can't tell. */
|
|
@@ -144,11 +151,11 @@ export function isStaleGraphQL(errors: { type?: string; message?: string }[]): b
|
|
|
144
151
|
|
|
145
152
|
// ── GitHub ────────────────────────────────────────────────────────────────────
|
|
146
153
|
|
|
147
|
-
const HEADS_QUERY = `query($owner: String!, $name: String!) {
|
|
154
|
+
const HEADS_QUERY = `query($owner: String!, $name: String!, $main: String!, $drafts: String!) {
|
|
148
155
|
repository(owner: $owner, name: $name) {
|
|
149
156
|
id
|
|
150
|
-
main: ref(qualifiedName:
|
|
151
|
-
drafts: ref(qualifiedName:
|
|
157
|
+
main: ref(qualifiedName: $main) { target { oid ... on Commit { tree { oid } } } }
|
|
158
|
+
drafts: ref(qualifiedName: $drafts) { target { oid ... on Commit { tree { oid } } } }
|
|
152
159
|
}
|
|
153
160
|
}`;
|
|
154
161
|
|
|
@@ -168,19 +175,29 @@ const COMPARE_FILE_CAP = 300;
|
|
|
168
175
|
export class GitHubBackend implements GitBackend {
|
|
169
176
|
readonly name = 'github';
|
|
170
177
|
readonly gh: GitHubClient;
|
|
178
|
+
readonly branches: Readonly<BranchNames>;
|
|
171
179
|
private repoId = '';
|
|
172
180
|
|
|
173
|
-
constructor(gh: GitHubClient) {
|
|
181
|
+
constructor(gh: GitHubClient, branches: Readonly<BranchNames> = DEFAULT_BRANCHES) {
|
|
174
182
|
this.gh = gh;
|
|
183
|
+
this.branches = branches;
|
|
184
|
+
}
|
|
185
|
+
/** `refs/heads/<name>` for a role. */
|
|
186
|
+
private qualified(branch: Branch) {
|
|
187
|
+
return `refs/heads/${this.branches[branch]}`;
|
|
175
188
|
}
|
|
176
189
|
get tracker() {
|
|
177
190
|
return this.gh.tracker;
|
|
178
191
|
}
|
|
179
192
|
|
|
193
|
+
accessProblem(): GitHubAccessProblem | null {
|
|
194
|
+
return this.gh.accessProblem();
|
|
195
|
+
}
|
|
196
|
+
|
|
180
197
|
async readHeads(): Promise<Heads> {
|
|
181
198
|
const d = await this.gh.graphql<{ repository: { id: string; main: RefNode; drafts: RefNode } }>(
|
|
182
199
|
HEADS_QUERY,
|
|
183
|
-
{ owner: this.gh.owner, name: this.gh.repo },
|
|
200
|
+
{ owner: this.gh.owner, name: this.gh.repo, main: this.qualified('main'), drafts: this.qualified('drafts') },
|
|
184
201
|
'readHeads',
|
|
185
202
|
);
|
|
186
203
|
this.repoId = d.repository.id;
|
|
@@ -192,7 +209,7 @@ export class GitHubBackend implements GitBackend {
|
|
|
192
209
|
}
|
|
193
210
|
|
|
194
211
|
async createBranch(branch: Branch, sha: string) {
|
|
195
|
-
await this.gh.rest('POST', this.gh.repoPath('/git/refs'), { ref:
|
|
212
|
+
await this.gh.rest('POST', this.gh.repoPath('/git/refs'), { ref: this.qualified(branch), sha });
|
|
196
213
|
}
|
|
197
214
|
|
|
198
215
|
async readArchive(commit: string, keep: (path: string) => boolean) {
|
|
@@ -253,7 +270,7 @@ export class GitHubBackend implements GitBackend {
|
|
|
253
270
|
COMMIT_ON_BRANCH,
|
|
254
271
|
{
|
|
255
272
|
input: {
|
|
256
|
-
branch: { repositoryNameWithOwner: this.gh.nameWithOwner, branchName: branch },
|
|
273
|
+
branch: { repositoryNameWithOwner: this.gh.nameWithOwner, branchName: this.branches[branch] },
|
|
257
274
|
expectedHeadOid: expectedHead,
|
|
258
275
|
message: { headline, body },
|
|
259
276
|
fileChanges: {
|
|
@@ -295,7 +312,7 @@ export class GitHubBackend implements GitBackend {
|
|
|
295
312
|
{
|
|
296
313
|
input: {
|
|
297
314
|
repositoryId: this.repoId,
|
|
298
|
-
refUpdates: updates.map((u) => ({ name:
|
|
315
|
+
refUpdates: updates.map((u) => ({ name: this.qualified(u.branch), beforeOid: u.before, afterOid: u.after, force: u.force })),
|
|
299
316
|
},
|
|
300
317
|
},
|
|
301
318
|
'updateRefs',
|
|
@@ -309,7 +326,7 @@ export class GitHubBackend implements GitBackend {
|
|
|
309
326
|
async listCommits(branch: Branch, count: number) {
|
|
310
327
|
const list = await this.gh.rest<{ sha: string; parents: { sha: string }[]; commit: { message: string; committer: { date: string } } }[]>(
|
|
311
328
|
'GET',
|
|
312
|
-
this.gh.repoPath(`/commits?sha=${branch}&per_page=${Math.min(100, count)}`),
|
|
329
|
+
this.gh.repoPath(`/commits?sha=${encodeURIComponent(this.branches[branch])}&per_page=${Math.min(100, count)}`),
|
|
313
330
|
);
|
|
314
331
|
return list.map((k) => ({ sha: k.sha, parents: k.parents.map((p) => p.sha), message: k.commit.message, date: k.commit.committer.date }));
|
|
315
332
|
}
|
|
@@ -395,21 +412,30 @@ export const blobSha = (contents: string) => {
|
|
|
395
412
|
export class MemoryBackend implements GitBackend {
|
|
396
413
|
readonly name = 'memory';
|
|
397
414
|
readonly tracker: Tracker;
|
|
415
|
+
readonly branches: Readonly<BranchNames>;
|
|
398
416
|
/** Deployments, newest last; tests push to it. */
|
|
399
417
|
deployments: MemoryDeployment[] = [];
|
|
400
418
|
/** Called before each recorded request, with its endpoint: tests inject races here. */
|
|
401
419
|
beforeRequest: ((endpoint: string) => Promise<void> | void) | null = null;
|
|
420
|
+
/** The GitHub access problem the status reports; tests set it (the GitHub client's own is tested with faked responses). */
|
|
421
|
+
access: GitHubAccessProblem | null = null;
|
|
402
422
|
private readonly now: () => number;
|
|
403
423
|
private inHook = false;
|
|
404
424
|
private blobs = new Map<string, string>();
|
|
405
425
|
private trees = new Map<string, MemTree>();
|
|
406
426
|
private commits = new Map<string, MemCommit>();
|
|
407
|
-
|
|
427
|
+
/** Every ref, by its name (not its role): a test can seed a branch caracms mustn't touch. */
|
|
428
|
+
refs = new Map<string, string>();
|
|
408
429
|
private n = 0;
|
|
409
430
|
|
|
410
|
-
constructor(tracker = new Tracker(), options: { now?: () => number } = {}) {
|
|
431
|
+
constructor(tracker = new Tracker(), options: { now?: () => number; branches?: Readonly<BranchNames> } = {}) {
|
|
411
432
|
this.tracker = tracker;
|
|
412
433
|
this.now = options.now ?? Date.now;
|
|
434
|
+
this.branches = options.branches ?? DEFAULT_BRANCHES;
|
|
435
|
+
}
|
|
436
|
+
/** The ref name behind a role. */
|
|
437
|
+
private ref(branch: Branch) {
|
|
438
|
+
return this.branches[branch];
|
|
413
439
|
}
|
|
414
440
|
|
|
415
441
|
private async rec(endpoint: string, creating: boolean, status = 200) {
|
|
@@ -464,7 +490,7 @@ export class MemoryBackend implements GitBackend {
|
|
|
464
490
|
return c;
|
|
465
491
|
}
|
|
466
492
|
private head(branch: Branch): Head {
|
|
467
|
-
const c = this.refs.get(branch);
|
|
493
|
+
const c = this.refs.get(this.ref(branch));
|
|
468
494
|
if (!c) throw new MissingBranchError(branch);
|
|
469
495
|
return { commit: c, tree: this.commit(c).tree };
|
|
470
496
|
}
|
|
@@ -479,12 +505,12 @@ export class MemoryBackend implements GitBackend {
|
|
|
479
505
|
|
|
480
506
|
// Test helpers (not part of GitBackend; record no requests).
|
|
481
507
|
|
|
482
|
-
/** Start the repo: one parentless commit with these files on
|
|
508
|
+
/** Start the repo: one parentless commit with these files on the main branch (and the drafts branch, if asked). */
|
|
483
509
|
seed(files: FileWrite[], options: { drafts?: boolean } = {}): string {
|
|
484
510
|
const tree = this.putTree(new Map(files.map((f) => [f.path, { sha: this.blob(f.contents), mode: '100644' }])));
|
|
485
511
|
const seed = this.putCommit(tree, [], 'Seed');
|
|
486
|
-
this.refs.set('main', seed);
|
|
487
|
-
if (options.drafts) this.refs.set('drafts', seed);
|
|
512
|
+
this.refs.set(this.ref('main'), seed);
|
|
513
|
+
if (options.drafts) this.refs.set(this.ref('drafts'), seed);
|
|
488
514
|
return seed;
|
|
489
515
|
}
|
|
490
516
|
|
|
@@ -497,7 +523,7 @@ export class MemoryBackend implements GitBackend {
|
|
|
497
523
|
else t.set(path, { sha: this.blob(contents), mode: '100644' });
|
|
498
524
|
}
|
|
499
525
|
const c = this.putCommit(this.putTree(t), [head.commit], message);
|
|
500
|
-
this.refs.set(branch, c);
|
|
526
|
+
this.refs.set(this.ref(branch), c);
|
|
501
527
|
return c;
|
|
502
528
|
}
|
|
503
529
|
|
|
@@ -523,6 +549,10 @@ export class MemoryBackend implements GitBackend {
|
|
|
523
549
|
|
|
524
550
|
// GitBackend.
|
|
525
551
|
|
|
552
|
+
accessProblem(): GitHubAccessProblem | null {
|
|
553
|
+
return this.access;
|
|
554
|
+
}
|
|
555
|
+
|
|
526
556
|
async readHeads() {
|
|
527
557
|
await this.rec('graphql readHeads', false);
|
|
528
558
|
return { main: this.head('main'), drafts: this.head('drafts') };
|
|
@@ -530,9 +560,9 @@ export class MemoryBackend implements GitBackend {
|
|
|
530
560
|
|
|
531
561
|
async createBranch(branch: Branch, sha: string) {
|
|
532
562
|
await this.rec('POST /git/refs', true);
|
|
533
|
-
if (this.refs.has(branch)) throw new HttpError(422, 'Reference already exists', null);
|
|
563
|
+
if (this.refs.has(this.ref(branch))) throw new HttpError(422, 'Reference already exists', null);
|
|
534
564
|
this.commit(sha);
|
|
535
|
-
this.refs.set(branch, sha);
|
|
565
|
+
this.refs.set(this.ref(branch), sha);
|
|
536
566
|
}
|
|
537
567
|
|
|
538
568
|
async readArchive(commit: string, keep: (path: string) => boolean) {
|
|
@@ -574,14 +604,14 @@ export class MemoryBackend implements GitBackend {
|
|
|
574
604
|
|
|
575
605
|
async commitOnBranch(branch: Branch, expectedHead: string, additions: FileWrite[], deletions: string[], headline: string, body: string) {
|
|
576
606
|
await this.rec('graphql createCommitOnBranch', true);
|
|
577
|
-
const cur = this.refs.get(branch);
|
|
607
|
+
const cur = this.refs.get(this.ref(branch));
|
|
578
608
|
if (cur !== expectedHead) throw new StaleHeadError(`Expected branch to point to "${expectedHead}" but it did not. Pull and try again.`);
|
|
579
609
|
const t = new Map(this.trees.get(this.commit(cur).tree)!);
|
|
580
610
|
for (const f of additions) t.set(f.path, { sha: this.blob(f.contents), mode: '100644' });
|
|
581
611
|
for (const p of deletions) t.delete(p);
|
|
582
612
|
const tree = this.putTree(t);
|
|
583
613
|
const c = this.putCommit(tree, [cur], body ? `${headline}\n\n${body}` : headline);
|
|
584
|
-
this.refs.set(branch, c);
|
|
614
|
+
this.refs.set(this.ref(branch), c);
|
|
585
615
|
return { commit: c, tree };
|
|
586
616
|
}
|
|
587
617
|
|
|
@@ -606,10 +636,10 @@ export class MemoryBackend implements GitBackend {
|
|
|
606
636
|
await this.rec('graphql updateRefs', true);
|
|
607
637
|
for (const u of updates) {
|
|
608
638
|
// GitHub's answer here is a generic error, not a typed one (M1 → G6 (a)).
|
|
609
|
-
if (this.refs.get(u.branch) !== u.before) throw new GraphQLError([{ message: 'Something went wrong while executing your query.' }]);
|
|
639
|
+
if (this.refs.get(this.ref(u.branch)) !== u.before) throw new GraphQLError([{ message: 'Something went wrong while executing your query.' }]);
|
|
610
640
|
if (!u.force && !this.isAncestor(u.before, u.after)) throw new GraphQLError([{ message: 'Something went wrong while executing your query.' }]);
|
|
611
641
|
}
|
|
612
|
-
for (const u of updates) this.refs.set(u.branch, u.after); // all or nothing
|
|
642
|
+
for (const u of updates) this.refs.set(this.ref(u.branch), u.after); // all or nothing
|
|
613
643
|
}
|
|
614
644
|
|
|
615
645
|
async listCommits(branch: Branch, count: number) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ref names behind each branch role (M9 Decision 6). `main` is what's published
|
|
3
|
+
* (and deployed); `drafts` is where autosaves land. `CARACMS_MAIN_BRANCH` and
|
|
4
|
+
* `CARACMS_DRAFTS_BRANCH` name them (mode.ts), defaulting to `main` and `drafts`.
|
|
5
|
+
* With both named, a branch called `main` is never read or moved.
|
|
6
|
+
*
|
|
7
|
+
* Only the backends (backend.ts) turn a role into a name; everything above them speaks
|
|
8
|
+
* in roles. No imports here, so mode.ts can read it without the backends.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** A branch's role, not its name. */
|
|
12
|
+
export type Branch = 'main' | 'drafts';
|
|
13
|
+
|
|
14
|
+
export interface BranchNames {
|
|
15
|
+
main: string;
|
|
16
|
+
drafts: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_BRANCHES: Readonly<BranchNames> = Object.freeze({ main: 'main', drafts: 'drafts' });
|
package/src/live/drafts-store.ts
CHANGED
|
@@ -41,7 +41,7 @@ import { parseAnyDocument } from '../document.ts';
|
|
|
41
41
|
import type { DraftsStore } from '../editor/server/drafts-store.ts';
|
|
42
42
|
import { confinePath, fileVersion, WriteRefused, writeText, type WriteContext, type WriteRequest, type WriteResponse } from '../editor/server/write.ts';
|
|
43
43
|
import type { CreateResponse, DeleteResponse, LastChange, LockRefusal, MoveResponse, ReplaceFileResponse } from '../editor/types.ts';
|
|
44
|
-
import type { Autosave, Autosaves, Read } from './autosaves.ts';
|
|
44
|
+
import type { Autosave, Autosaves, ChangeRecord, Read } from './autosaves.ts';
|
|
45
45
|
import { titleOf, type LiveGit } from './git.ts';
|
|
46
46
|
import type { LockRecord, Locks } from './locks.ts';
|
|
47
47
|
import { displayName, type Person } from './messages.ts';
|
|
@@ -79,8 +79,9 @@ export function unavailable(error: unknown): WriteRefused {
|
|
|
79
79
|
* expire, so a file with none was never changed in the editor or Redis lost its data, and
|
|
80
80
|
* the server can't tell which: the keeper holds edits on unknown rather than re-send them.
|
|
81
81
|
*/
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
/** `known`: the file's change record as the caller already read it (the status poll's cache); otherwise read here. */
|
|
83
|
+
export async function lastChangeOf(parts: Pick<LiveStoreParts, 'git' | 'autosaves'>, file: string, known?: ChangeRecord | null): Promise<LastChange> {
|
|
84
|
+
const record = known !== undefined ? known : await parts.autosaves.changed(file);
|
|
84
85
|
if (record) return { by: record.by, version: record.version, at: record.at };
|
|
85
86
|
const editor = parts.git.waiting().find((w) => w.file === file)?.editors[0];
|
|
86
87
|
return editor ? { by: editor.person, version: null, at: editor.at } : { by: null, version: null, at: null, known: false };
|