caracms 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/documents/actions.ts +6 -4
- package/src/editor/client/documents/dialogs.tsx +2 -1
- 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/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;
|
|
@@ -139,6 +139,8 @@ export interface GitBackend {
|
|
|
139
139
|
listDeployments(count: number): Promise<Deployment[]>;
|
|
140
140
|
/** A deployment's newest status, or null when it has none yet. */
|
|
141
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;
|
|
142
144
|
}
|
|
143
145
|
|
|
144
146
|
/** Is this GraphQL error a stale head (someone moved the ref)? M1 → G6 (a): often it can't tell. */
|
|
@@ -188,6 +190,10 @@ export class GitHubBackend implements GitBackend {
|
|
|
188
190
|
return this.gh.tracker;
|
|
189
191
|
}
|
|
190
192
|
|
|
193
|
+
accessProblem(): GitHubAccessProblem | null {
|
|
194
|
+
return this.gh.accessProblem();
|
|
195
|
+
}
|
|
196
|
+
|
|
191
197
|
async readHeads(): Promise<Heads> {
|
|
192
198
|
const d = await this.gh.graphql<{ repository: { id: string; main: RefNode; drafts: RefNode } }>(
|
|
193
199
|
HEADS_QUERY,
|
|
@@ -411,6 +417,8 @@ export class MemoryBackend implements GitBackend {
|
|
|
411
417
|
deployments: MemoryDeployment[] = [];
|
|
412
418
|
/** Called before each recorded request, with its endpoint: tests inject races here. */
|
|
413
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;
|
|
414
422
|
private readonly now: () => number;
|
|
415
423
|
private inHook = false;
|
|
416
424
|
private blobs = new Map<string, string>();
|
|
@@ -541,6 +549,10 @@ export class MemoryBackend implements GitBackend {
|
|
|
541
549
|
|
|
542
550
|
// GitBackend.
|
|
543
551
|
|
|
552
|
+
accessProblem(): GitHubAccessProblem | null {
|
|
553
|
+
return this.access;
|
|
554
|
+
}
|
|
555
|
+
|
|
544
556
|
async readHeads() {
|
|
545
557
|
await this.rec('graphql readHeads', false);
|
|
546
558
|
return { main: this.head('main'), drafts: this.head('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 };
|