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/git.ts
CHANGED
|
@@ -42,9 +42,11 @@ import { movesKey } from './autosaves.ts';
|
|
|
42
42
|
import type { WaitingChange } from '../editor/types.ts';
|
|
43
43
|
import { MissingBranchError, StaleHeadError, type Branch, type CommitSummary, type Comparison, type GitBackend, type Head, type Heads, type TreeChange } from './backend.ts';
|
|
44
44
|
import { HttpError } from './github.ts';
|
|
45
|
+
import { runningCommitFrom } from './mode.ts';
|
|
45
46
|
import { mergeFile } from './merge.ts';
|
|
46
47
|
import { draftMessage, mergeMessage, parseMessage, publishMessage, type Person } from './messages.ts';
|
|
47
48
|
import type { RedisClient } from './redis.ts';
|
|
49
|
+
import { landed, StatusCache } from './status-cache.ts';
|
|
48
50
|
import { DEFAULT_TIMINGS, type LiveTimings } from './timings.ts';
|
|
49
51
|
import { publishState, waitingFromCommits, type DeploymentFact, type PublishRecord, type PublishState, type WaitingFile } from './status.ts';
|
|
50
52
|
|
|
@@ -103,14 +105,20 @@ export interface HistoryEntry {
|
|
|
103
105
|
state: PublishState;
|
|
104
106
|
}
|
|
105
107
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
fetch?: typeof fetch
|
|
113
|
-
}
|
|
108
|
+
/**
|
|
109
|
+
* Try again's redeployer (runs-anywhere Decision 11): Railway's API with a project token
|
|
110
|
+
* (`CARACMS_RAILWAY_TOKEN`, M4 Decision 12), or a deploy hook (`CARACMS_DEPLOY_HOOK_URL`):
|
|
111
|
+
* one bare POST, any 2xx a success. The hook's URL is a secret: never logged or sent on.
|
|
112
|
+
*/
|
|
113
|
+
export type Redeployer =
|
|
114
|
+
| { kind: 'railway'; token: string; serviceId: string; environmentId: string; fetch?: typeof fetch }
|
|
115
|
+
| { kind: 'hook'; url: string; fetch?: typeof fetch; timeoutMs?: number };
|
|
116
|
+
|
|
117
|
+
/** A deploy hook that hasn't answered in this long has failed (it's one POST; nothing retries by itself). */
|
|
118
|
+
export const DEPLOY_HOOK_TIMEOUT_MS = 20_000;
|
|
119
|
+
|
|
120
|
+
/** Try again's answer when no redeployer is set: no host's name (Decision 11). */
|
|
121
|
+
export const NO_REDEPLOYER_REASON = "Try again isn't set up on this site: a developer can redeploy it.";
|
|
114
122
|
|
|
115
123
|
export interface LiveGitOptions {
|
|
116
124
|
backend: GitBackend;
|
|
@@ -119,12 +127,16 @@ export interface LiveGitOptions {
|
|
|
119
127
|
now?: () => number;
|
|
120
128
|
sleep?: (ms: number) => Promise<void>;
|
|
121
129
|
log?: (line: string) => void;
|
|
122
|
-
/** The running server's commit, read on every status (tests move it). Default:
|
|
130
|
+
/** The running server's commit, read on every status (tests move it). Default: mode.ts → `runningCommitFrom` over the environment and `bakedCommit`. */
|
|
123
131
|
runningCommit?: () => string | null;
|
|
132
|
+
/** The commit baked in at build (runs-anywhere Decision 9), for the default `runningCommit`. */
|
|
133
|
+
bakedCommit?: string | null;
|
|
124
134
|
timings?: Partial<LiveTimings>;
|
|
125
|
-
|
|
135
|
+
redeployer?: Redeployer | null;
|
|
126
136
|
/** Redis key prefix. Default `caracms:`. */
|
|
127
137
|
keyPrefix?: string;
|
|
138
|
+
/** The status poll's cache (Redis diet), told of the publish lock, the publish record and the moves Publish clears. Default: one of its own. */
|
|
139
|
+
statusCache?: StatusCache;
|
|
128
140
|
}
|
|
129
141
|
|
|
130
142
|
/** The site file that holds Site Settings, relative to the site (Decision 15). Defined in config.ts (M5a: the reserved `settings` collection). */
|
|
@@ -153,6 +165,12 @@ export class LiveGit {
|
|
|
153
165
|
readonly redis: RedisClient;
|
|
154
166
|
readonly timings: LiveTimings;
|
|
155
167
|
readonly now: () => number;
|
|
168
|
+
/**
|
|
169
|
+
* The status poll's write-through cache (status-cache.ts, Redis diet Decisions 1–5),
|
|
170
|
+
* one per process: kept here because this module writes some of what it holds; the
|
|
171
|
+
* live editor's autosaves, locks and notices write through the same one.
|
|
172
|
+
*/
|
|
173
|
+
readonly statusCache: StatusCache;
|
|
156
174
|
/** Phase 2 fills this: commit every pending autosave (Decision 10 step 1; Decision 8 before a merge). */
|
|
157
175
|
commitPending: () => Promise<void> = async () => {};
|
|
158
176
|
/**
|
|
@@ -168,7 +186,7 @@ export class LiveGit {
|
|
|
168
186
|
private readonly sleep: (ms: number) => Promise<void>;
|
|
169
187
|
private readonly log: (line: string) => void;
|
|
170
188
|
private readonly runningCommit: () => string | null;
|
|
171
|
-
private readonly
|
|
189
|
+
private readonly redeployer: Redeployer | null;
|
|
172
190
|
private readonly keyPrefix: string;
|
|
173
191
|
|
|
174
192
|
private copyHead: Head | null = null;
|
|
@@ -194,9 +212,11 @@ export class LiveGit {
|
|
|
194
212
|
this.now = options.now ?? Date.now;
|
|
195
213
|
this.sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
196
214
|
this.log = options.log ?? ((line) => console.log(`[caracms] ${line}`));
|
|
197
|
-
|
|
198
|
-
this.
|
|
215
|
+
const baked = options.bakedCommit ?? null;
|
|
216
|
+
this.runningCommit = options.runningCommit ?? (() => runningCommitFrom(process.env, baked));
|
|
217
|
+
this.redeployer = options.redeployer ?? null;
|
|
199
218
|
this.keyPrefix = options.keyPrefix ?? 'caracms:';
|
|
219
|
+
this.statusCache = options.statusCache ?? new StatusCache({ redis: this.redis, maxAgeMs: this.timings.statusCacheMs, now: this.now });
|
|
200
220
|
this.prefix = repoPrefix(options.site);
|
|
201
221
|
// Every collection's folder, shared blocks' included (M5a Decision 8); Site Settings is its one file (inScope).
|
|
202
222
|
this.folders = Object.values(options.site.collections)
|
|
@@ -241,6 +261,20 @@ export class LiveGit {
|
|
|
241
261
|
return `${this.keyPrefix}${name}`;
|
|
242
262
|
}
|
|
243
263
|
|
|
264
|
+
/** The Publish lock's key (one Publish at a time) and the newest publish's record's (Decision 10, 12). */
|
|
265
|
+
get publishLockKey(): string {
|
|
266
|
+
return this.key('publish:lock');
|
|
267
|
+
}
|
|
268
|
+
get publishRecordKey(): string {
|
|
269
|
+
return this.key('publish:last');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private async setPublishRecord(record: PublishRecord): Promise<void> {
|
|
273
|
+
const raw = JSON.stringify(record);
|
|
274
|
+
await landed(this.statusCache, this.publishRecordKey, () => this.redis.set(this.publishRecordKey, raw));
|
|
275
|
+
this.statusCache.string(this.publishRecordKey, raw);
|
|
276
|
+
}
|
|
277
|
+
|
|
244
278
|
/** Run `fn` alone among this server's git operations, so the copy is never changed by two at once. */
|
|
245
279
|
private exclusive<T>(fn: () => Promise<T>): Promise<T> {
|
|
246
280
|
const run = this.queue.then(fn, fn);
|
|
@@ -642,9 +676,11 @@ export class LiveGit {
|
|
|
642
676
|
* Redis lock); a second press while one runs joins it.
|
|
643
677
|
*/
|
|
644
678
|
async publish(by: Person): Promise<PublishResult> {
|
|
645
|
-
const lock = this.
|
|
679
|
+
const lock = this.publishLockKey;
|
|
646
680
|
const token = randomBytes(12).toString('base64url');
|
|
647
|
-
|
|
681
|
+
const taken = await landed(this.statusCache, lock, () => this.redis.set(lock, token, { nx: true, px: this.timings.publishLockMs }));
|
|
682
|
+
if (taken) this.statusCache.string(lock, token);
|
|
683
|
+
else {
|
|
648
684
|
const until = this.now() + this.timings.publishLockMs;
|
|
649
685
|
while (this.now() < until && (await this.redis.get(lock)) !== null) await this.sleep(500);
|
|
650
686
|
return { kind: 'joined', record: await this.lastPublish() };
|
|
@@ -659,7 +695,10 @@ export class LiveGit {
|
|
|
659
695
|
throw new Error(`caracms: publish gave up after ${MAX_ATTEMPTS} tries (main or drafts kept moving).`);
|
|
660
696
|
});
|
|
661
697
|
} finally {
|
|
662
|
-
|
|
698
|
+
// Let go: gone when it was still ours; otherwise (it expired and someone else took it, or the call failed) not known here.
|
|
699
|
+
const gone = await this.redis.compareAndDelete(lock, token).catch(() => false);
|
|
700
|
+
if (gone) this.statusCache.string(lock, null);
|
|
701
|
+
else this.statusCache.drop(lock);
|
|
663
702
|
}
|
|
664
703
|
}
|
|
665
704
|
|
|
@@ -710,16 +749,28 @@ export class LiveGit {
|
|
|
710
749
|
this.historyCache = null;
|
|
711
750
|
// The moves this publish took live are done with (a move made since stays waiting).
|
|
712
751
|
const published = [...records].filter(([old]) => files.includes(old)).map(([old]) => old);
|
|
713
|
-
if (published.length)
|
|
752
|
+
if (published.length) {
|
|
753
|
+
const moves = movesKey(this.keyPrefix);
|
|
754
|
+
const cleared = await this.redis.hdel(moves, ...published).then(
|
|
755
|
+
() => true,
|
|
756
|
+
() => false,
|
|
757
|
+
);
|
|
758
|
+
if (cleared) this.statusCache.hash(moves, {}, published);
|
|
759
|
+
else this.statusCache.drop(moves);
|
|
760
|
+
}
|
|
714
761
|
const record: PublishRecord = { commit, by, at: new Date(this.now()).toISOString(), files, titles, tryAgainAt: null };
|
|
715
|
-
await this.
|
|
762
|
+
await this.setPublishRecord(record);
|
|
716
763
|
this.log(`published ${commit.slice(0, 7)} (attempt ${attempt}): ${titles.join(', ')}`);
|
|
717
764
|
return { kind: 'published', record, attempts: attempt, fileTitles };
|
|
718
765
|
}
|
|
719
766
|
|
|
720
767
|
/** The newest publish, from Redis. */
|
|
721
768
|
async lastPublish(): Promise<PublishRecord | null> {
|
|
722
|
-
|
|
769
|
+
return LiveGit.publishRecordFrom(await this.redis.get(this.publishRecordKey));
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** A publish record from its text as read (the status cache's, on a poll). */
|
|
773
|
+
static publishRecordFrom(raw: string | null): PublishRecord | null {
|
|
723
774
|
if (!raw) return null;
|
|
724
775
|
try {
|
|
725
776
|
return JSON.parse(raw) as PublishRecord;
|
|
@@ -842,14 +893,15 @@ export class LiveGit {
|
|
|
842
893
|
// ── Publish status (Decision 12) ────────────────────────────────────────────
|
|
843
894
|
|
|
844
895
|
/** The newest publish's state. Asks GitHub at most every `deploymentReadMs`, and only while it isn't live. */
|
|
845
|
-
async publishStatus(): Promise<PublishStatus> {
|
|
846
|
-
|
|
896
|
+
async publishStatus(options: { record?: PublishRecord | null } = {}): Promise<PublishStatus> {
|
|
897
|
+
// `record`: the newest publish as the caller read it (the status poll's cache); otherwise read here.
|
|
898
|
+
const record = options.record !== undefined ? options.record : await this.lastPublish();
|
|
847
899
|
if (!record) return { state: 'none', record: null, assumed: false, canTryAgain: false };
|
|
848
900
|
const running = this.runningCommit();
|
|
849
901
|
if (running === null) {
|
|
850
902
|
if (!this.assumedLogged) {
|
|
851
903
|
this.assumedLogged = true;
|
|
852
|
-
this.log('no
|
|
904
|
+
this.log('no running commit known (CARACMS_RUNNING_COMMIT, a host\'s commit variable, or one baked in at build): a publish reads as live once its refs moved');
|
|
853
905
|
}
|
|
854
906
|
return { state: 'live', record, assumed: true, canTryAgain: false };
|
|
855
907
|
}
|
|
@@ -858,8 +910,8 @@ export class LiveGit {
|
|
|
858
910
|
let facts: DeploymentFact[] = [];
|
|
859
911
|
let later = new Set<string>();
|
|
860
912
|
if (!includes) ({ facts, later } = await this.deploymentFacts(record));
|
|
861
|
-
const { state } = publishState({ record, running, runningIncludesPublish: includes, laterOnMain: later, deployments: facts });
|
|
862
|
-
return { state, record, assumed: false, canTryAgain: state === 'failed' && this.
|
|
913
|
+
const { state } = publishState({ record, running, runningIncludesPublish: includes, laterOnMain: later, deployments: facts, now: this.now(), slowAfterMs: this.timings.publishSlowMs });
|
|
914
|
+
return { state, record, assumed: false, canTryAgain: (state === 'failed' || state === 'slow') && this.redeployer !== null };
|
|
863
915
|
});
|
|
864
916
|
}
|
|
865
917
|
|
|
@@ -904,7 +956,7 @@ export class LiveGit {
|
|
|
904
956
|
} catch (error) {
|
|
905
957
|
if (error instanceof HttpError && (error.status === 403 || error.status === 404)) {
|
|
906
958
|
this.deploymentsDenied = true;
|
|
907
|
-
this.log(`can't read GitHub Deployments (${error.status}): the App needs "Deployments: read".
|
|
959
|
+
this.log(`can't read GitHub Deployments (${error.status}): the site's GitHub token or App needs "Deployments: read" to tell a failed publish. Without it, a publish shows as publishing until the server runs it, then as taking longer than usual.`);
|
|
908
960
|
return { facts: [], later: new Set() };
|
|
909
961
|
}
|
|
910
962
|
throw error;
|
|
@@ -930,7 +982,7 @@ export class LiveGit {
|
|
|
930
982
|
const parsed = parseMessage(c.message);
|
|
931
983
|
if (parsed.kind !== 'publish') return;
|
|
932
984
|
const live = running === null || (runningAt !== -1 && i >= runningAt) || (status.record?.commit === c.sha && status.state === 'live');
|
|
933
|
-
const pending: PublishState = status.state === 'failed'
|
|
985
|
+
const pending: PublishState = status.state === 'failed' || status.state === 'slow' ? status.state : 'publishing';
|
|
934
986
|
entries.push({ commit: c.sha, by: parsed.publishedBy, at: c.date, titles: parsed.titles, files: parsed.publishedFiles, state: live ? 'live' : pending });
|
|
935
987
|
});
|
|
936
988
|
this.historyCache = { at: this.now(), entries };
|
|
@@ -940,13 +992,26 @@ export class LiveGit {
|
|
|
940
992
|
|
|
941
993
|
// ── Try again (Decision 12) ─────────────────────────────────────────────────
|
|
942
994
|
|
|
943
|
-
/**
|
|
995
|
+
/**
|
|
996
|
+
* Redeploy through the redeployer (runs-anywhere Decision 11): Railway's API with the
|
|
997
|
+
* project token, or the deploy hook's one POST. Never publishes again, and never
|
|
998
|
+
* retries by itself. A success restarts the "taking longer than usual" clock.
|
|
999
|
+
*/
|
|
944
1000
|
async tryAgain(): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
945
|
-
if (!this.
|
|
1001
|
+
if (!this.redeployer) return { ok: false, reason: NO_REDEPLOYER_REASON };
|
|
946
1002
|
const record = await this.lastPublish();
|
|
947
1003
|
if (!record) return { ok: false, reason: 'Nothing has been published.' };
|
|
948
|
-
const
|
|
949
|
-
|
|
1004
|
+
const r = this.redeployer.kind === 'hook' ? await this.callHook(this.redeployer) : await this.redeployOnRailway(this.redeployer);
|
|
1005
|
+
if (!r.ok) return r;
|
|
1006
|
+
await this.setPublishRecord({ ...record, tryAgainAt: new Date(this.now()).toISOString() });
|
|
1007
|
+
this.deploymentsCache = null;
|
|
1008
|
+
this.log(`try again: ${this.redeployer.kind === 'hook' ? 'the deploy hook took it' : 'Railway is redeploying'} (publish ${record.commit.slice(0, 7)})`);
|
|
1009
|
+
return { ok: true };
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
private async redeployOnRailway(railway: Extract<Redeployer, { kind: 'railway' }>): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
1013
|
+
const { token, serviceId, environmentId } = railway;
|
|
1014
|
+
const res = await (railway.fetch ?? fetch)(RAILWAY_API, {
|
|
950
1015
|
method: 'POST',
|
|
951
1016
|
headers: { 'content-type': 'application/json', 'project-access-token': token },
|
|
952
1017
|
body: JSON.stringify({
|
|
@@ -956,15 +1021,28 @@ export class LiveGit {
|
|
|
956
1021
|
});
|
|
957
1022
|
const body = (await res.json().catch(() => ({}))) as { data?: { serviceInstanceRedeploy?: boolean }; errors?: { message: string }[] };
|
|
958
1023
|
if (!res.ok || body.errors?.length || !body.data?.serviceInstanceRedeploy) {
|
|
959
|
-
const reason = body.errors?.map((e) => e.message).join('; ') || `
|
|
1024
|
+
const reason = body.errors?.map((e) => e.message).join('; ') || `the host answered ${res.status}`;
|
|
960
1025
|
this.log(`try again: Railway refused the redeploy (${reason})`);
|
|
961
1026
|
return { ok: false, reason };
|
|
962
1027
|
}
|
|
963
|
-
await this.redis.set(this.key('publish:last'), JSON.stringify({ ...record, tryAgainAt: new Date(this.now()).toISOString() }));
|
|
964
|
-
this.deploymentsCache = null;
|
|
965
|
-
this.log(`try again: Railway is redeploying (publish ${record.commit.slice(0, 7)})`);
|
|
966
1028
|
return { ok: true };
|
|
967
1029
|
}
|
|
1030
|
+
|
|
1031
|
+
/** The deploy hook: one POST, no body, no extra headers; any 2xx is success. The URL is never logged or returned. */
|
|
1032
|
+
private async callHook(hook: Extract<Redeployer, { kind: 'hook' }>): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
1033
|
+
let reason: string;
|
|
1034
|
+
try {
|
|
1035
|
+
const res = await (hook.fetch ?? fetch)(hook.url, { method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(hook.timeoutMs ?? DEPLOY_HOOK_TIMEOUT_MS) });
|
|
1036
|
+
await res.body?.cancel().catch(() => {});
|
|
1037
|
+
if (res.status >= 200 && res.status < 300) return { ok: true };
|
|
1038
|
+
reason = `the deploy hook answered ${res.status}`;
|
|
1039
|
+
} catch (error) {
|
|
1040
|
+
const name = error instanceof Error ? error.name : '';
|
|
1041
|
+
reason = name === 'TimeoutError' || name === 'AbortError' ? "the deploy hook didn't answer in time" : "the deploy hook couldn't be reached";
|
|
1042
|
+
}
|
|
1043
|
+
this.log(`try again: ${reason}`);
|
|
1044
|
+
return { ok: false, reason };
|
|
1045
|
+
}
|
|
968
1046
|
}
|
|
969
1047
|
|
|
970
1048
|
export interface Moves {
|
package/src/live/github.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GitHub as the site's App (M4 Phase 1
|
|
2
|
+
* GitHub as the site's App or a fine-grained token (M4 Phase 1, lifted from M1's spike,
|
|
3
|
+
* `spikes/git/src/github.ts`; the token from runs-anywhere Decision 3).
|
|
3
4
|
*
|
|
5
|
+
* - **A fine-grained token** (`CARACMS_GITHUB_TOKEN`) is the bearer as it is. GitHub sends
|
|
6
|
+
* its expiry on every response, which is kept for the editor (Decision 5).
|
|
4
7
|
* - **The App's JWT**: RS256, signed with the App's private key, `iat` backdated a
|
|
5
8
|
* minute for clock drift, nine minutes long (GitHub's limit is ten).
|
|
6
9
|
* - **An installation token scoped to the one repo**: the installation is found with
|
|
@@ -14,16 +17,18 @@
|
|
|
14
17
|
* that stops answering fails the operation instead of holding it (and Publish's or
|
|
15
18
|
* the check's callers) forever.
|
|
16
19
|
*
|
|
17
|
-
*
|
|
20
|
+
* - **Access problems** (Decision 5): every response is watched for a refused credential
|
|
21
|
+
* (401), a write refused for lack of a permission (403 "not accessible"), and, for a
|
|
22
|
+
* token, an expiry within 14 days. `accessProblem()` says which holds now; a later
|
|
23
|
+
* success clears the first two.
|
|
24
|
+
*
|
|
25
|
+
* The private key and the token are never logged.
|
|
18
26
|
*/
|
|
19
27
|
import { createSign } from 'node:crypto';
|
|
20
28
|
|
|
21
29
|
const API = 'https://api.github.com';
|
|
22
30
|
|
|
23
|
-
|
|
24
|
-
appId: string;
|
|
25
|
-
/** The PEM. Railway variables often carry it with literal `\n`s; those are turned back into newlines. */
|
|
26
|
-
privateKey: string;
|
|
31
|
+
interface ConnectionBase {
|
|
27
32
|
owner: string;
|
|
28
33
|
repo: string;
|
|
29
34
|
/** For tests. Default: global fetch. */
|
|
@@ -34,6 +39,63 @@ export interface AppConnection {
|
|
|
34
39
|
timeoutMs?: number;
|
|
35
40
|
}
|
|
36
41
|
|
|
42
|
+
export interface AppConnection extends ConnectionBase {
|
|
43
|
+
appId: string;
|
|
44
|
+
/** The PEM. Railway variables often carry it with literal `\n`s; those are turned back into newlines. */
|
|
45
|
+
privateKey: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A fine-grained personal access token on the one repo (Decision 3). */
|
|
49
|
+
export interface TokenConnection extends ConnectionBase {
|
|
50
|
+
token: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type GitHubConnection = AppConnection | TokenConnection;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What's wrong with GitHub access right now (Decision 5), shown in the editor while it holds.
|
|
57
|
+
* `credential` says which one the site uses, so the sentence names the right fix.
|
|
58
|
+
*/
|
|
59
|
+
export type GitHubAccessProblem =
|
|
60
|
+
| { kind: 'refused'; credential: 'app' | 'token' }
|
|
61
|
+
| { kind: 'permission'; credential: 'app' | 'token'; permission: string }
|
|
62
|
+
| { kind: 'expiring'; credential: 'token'; expires: string };
|
|
63
|
+
|
|
64
|
+
/** A token expiring within this long shows in the editor (Decision 5). */
|
|
65
|
+
export const TOKEN_EXPIRY_WARNING_MS = 14 * 24 * 60 * 60_000;
|
|
66
|
+
|
|
67
|
+
/** The permission every write needs; Deployments read is optional and never shown (Decision 10). */
|
|
68
|
+
const CONTENTS_PERMISSION = 'Contents: Read and write';
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `github-authentication-token-expiration`: "2026-11-03 10:00:00 UTC" or "… -0700".
|
|
72
|
+
* Null when it doesn't parse.
|
|
73
|
+
*/
|
|
74
|
+
export function parseTokenExpiry(value: string | null): number | null {
|
|
75
|
+
const m = value && /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}(?::\d{2})?)\s*(UTC|Z|[+-]\d{2}:?\d{2})?$/i.exec(value.trim());
|
|
76
|
+
if (!m) return null;
|
|
77
|
+
const zone = !m[3] || /^(utc|z)$/i.test(m[3]) ? 'Z' : m[3].replace(/^([+-]\d{2}):?(\d{2})$/, '$1:$2');
|
|
78
|
+
const at = Date.parse(`${m[1]}T${m[2]}${zone}`);
|
|
79
|
+
return Number.isNaN(at) ? null : at;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The sentence the editor shows for an access problem, for the developer it's forwarded to. */
|
|
83
|
+
export function accessProblemMessage(problem: GitHubAccessProblem): string {
|
|
84
|
+
const what = problem.credential === 'token' ? 'GitHub token (CARACMS_GITHUB_TOKEN)' : 'GitHub App';
|
|
85
|
+
switch (problem.kind) {
|
|
86
|
+
case 'refused':
|
|
87
|
+
return problem.credential === 'token'
|
|
88
|
+
? `GitHub refused this site's ${what}: it may have expired or been revoked. Changes can't be saved to GitHub until a developer makes a new fine-grained token and sets it.`
|
|
89
|
+
: `GitHub refused this site's ${what}: it may have been uninstalled from the repo or suspended. Changes can't be saved to GitHub until a developer reinstalls it.`;
|
|
90
|
+
case 'permission':
|
|
91
|
+
return `GitHub refused a change because this site's ${what} lacks the "${problem.permission}" permission. A developer can add it in the ${problem.credential === 'token' ? "token's settings" : "App's settings"}.`;
|
|
92
|
+
case 'expiring': {
|
|
93
|
+
const date = new Date(problem.expires).toLocaleDateString('en-IE', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' });
|
|
94
|
+
return `This site's ${what} expires on ${date}. After that, changes can't be saved to GitHub: a developer can regenerate it, or make a new one, and set it before then.`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
37
99
|
/** A GitHub request that takes longer than this fails (its caller treats it as any failure). */
|
|
38
100
|
export const GITHUB_TIMEOUT_MS = 20_000;
|
|
39
101
|
|
|
@@ -143,6 +205,8 @@ export class GitHubClient {
|
|
|
143
205
|
readonly tracker: Tracker;
|
|
144
206
|
readonly owner: string;
|
|
145
207
|
readonly repo: string;
|
|
208
|
+
/** Which credential this client signs in with. */
|
|
209
|
+
readonly credential: 'app' | 'token';
|
|
146
210
|
private readonly appId: string;
|
|
147
211
|
private readonly key: string;
|
|
148
212
|
private readonly fetcher: typeof fetch;
|
|
@@ -151,10 +215,23 @@ export class GitHubClient {
|
|
|
151
215
|
private token = '';
|
|
152
216
|
private tokenExpires = 0;
|
|
153
217
|
private signingIn: Promise<void> | null = null;
|
|
218
|
+
/** Decision 5: what the last responses said about access. */
|
|
219
|
+
private refused = false;
|
|
220
|
+
private missingPermission: string | null = null;
|
|
221
|
+
private personalTokenExpires: number | null = null;
|
|
154
222
|
|
|
155
|
-
constructor(connection:
|
|
156
|
-
|
|
157
|
-
|
|
223
|
+
constructor(connection: GitHubConnection, tracker: Tracker) {
|
|
224
|
+
if ('token' in connection) {
|
|
225
|
+
this.credential = 'token';
|
|
226
|
+
this.appId = '';
|
|
227
|
+
this.key = '';
|
|
228
|
+
this.token = connection.token;
|
|
229
|
+
this.tokenExpires = Number.POSITIVE_INFINITY;
|
|
230
|
+
} else {
|
|
231
|
+
this.credential = 'app';
|
|
232
|
+
this.appId = connection.appId;
|
|
233
|
+
this.key = connection.privateKey.includes('\\n') ? connection.privateKey.replace(/\\n/g, '\n') : connection.privateKey;
|
|
234
|
+
}
|
|
158
235
|
this.owner = connection.owner;
|
|
159
236
|
this.repo = connection.repo;
|
|
160
237
|
this.fetcher = connection.fetch ?? fetch;
|
|
@@ -163,6 +240,37 @@ export class GitHubClient {
|
|
|
163
240
|
this.tracker = tracker;
|
|
164
241
|
}
|
|
165
242
|
|
|
243
|
+
/** What's wrong with GitHub access now, the most serious first; null when nothing is (Decision 5). */
|
|
244
|
+
accessProblem(): GitHubAccessProblem | null {
|
|
245
|
+
if (this.refused) return { kind: 'refused', credential: this.credential };
|
|
246
|
+
if (this.missingPermission) return { kind: 'permission', credential: this.credential, permission: this.missingPermission };
|
|
247
|
+
const expires = this.personalTokenExpires;
|
|
248
|
+
if (this.credential === 'token' && expires !== null && expires - this.now() < TOKEN_EXPIRY_WARNING_MS) {
|
|
249
|
+
return { kind: 'expiring', credential: 'token', expires: new Date(expires).toISOString() };
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Keep what a response says about access. A 401 is a refused credential. A write
|
|
256
|
+
* refused for lack of a permission (`notPermitted`: a 403 or a GraphQL error saying
|
|
257
|
+
* "not accessible") is a missing permission, except on Deployments (optional,
|
|
258
|
+
* Decision 10); a rate limit's 403 isn't one. Any success clears a refusal; a
|
|
259
|
+
* successful write clears a missing permission.
|
|
260
|
+
*/
|
|
261
|
+
private observe(res: Response, path: string, write: boolean, notPermitted: boolean, hadErrors = false): void {
|
|
262
|
+
if (this.credential === 'token') {
|
|
263
|
+
const expires = parseTokenExpiry(res.headers.get('github-authentication-token-expiration'));
|
|
264
|
+
if (expires !== null) this.personalTokenExpires = expires;
|
|
265
|
+
}
|
|
266
|
+
if (res.status === 401) this.refused = true;
|
|
267
|
+
else if (res.ok && !notPermitted) {
|
|
268
|
+
this.refused = false;
|
|
269
|
+
if (write && !hadErrors) this.missingPermission = null; // a GraphQL 200 carrying errors didn't land
|
|
270
|
+
}
|
|
271
|
+
if (notPermitted && write && !/\/deployments/.test(path)) this.missingPermission = CONTENTS_PERMISSION;
|
|
272
|
+
}
|
|
273
|
+
|
|
166
274
|
get nameWithOwner() {
|
|
167
275
|
return `${this.owner}/${this.repo}`;
|
|
168
276
|
}
|
|
@@ -176,6 +284,7 @@ export class GitHubClient {
|
|
|
176
284
|
const jwt = appJwt(this.appId, this.key, Math.floor(this.now() / 1000));
|
|
177
285
|
const install = (await this.request('GET', this.repoPath('/installation'), undefined, jwt).catch((error: unknown) => {
|
|
178
286
|
if (error instanceof HttpError && error.status === 404) {
|
|
287
|
+
this.refused = true;
|
|
179
288
|
throw new Error(`The GitHub App (id ${this.appId}) isn't installed on ${this.nameWithOwner}. Install it on that repo.`);
|
|
180
289
|
}
|
|
181
290
|
throw error;
|
|
@@ -188,8 +297,9 @@ export class GitHubClient {
|
|
|
188
297
|
this.tokenExpires = Date.parse(tok.expires_at);
|
|
189
298
|
}
|
|
190
299
|
|
|
191
|
-
/** The installation token, refreshed five minutes before it expires. */
|
|
300
|
+
/** The installation token, refreshed five minutes before it expires; a personal token as it is. */
|
|
192
301
|
private async auth(): Promise<string> {
|
|
302
|
+
if (this.credential === 'token') return this.token;
|
|
193
303
|
if (!this.token || this.now() > this.tokenExpires - 5 * 60_000) {
|
|
194
304
|
this.signingIn ??= this.signIn().finally(() => {
|
|
195
305
|
this.signingIn = null;
|
|
@@ -207,6 +317,7 @@ export class GitHubClient {
|
|
|
207
317
|
async bytes(path: string): Promise<Buffer> {
|
|
208
318
|
const res = await this.fetcher(`${API}${path}`, { headers: this.headers(await this.auth()), redirect: 'follow', signal: this.signal() });
|
|
209
319
|
this.tracker.record({ endpoint: `GET ${shorten(path.replace(/^\/repos\/[^/]+\/[^/]+/, '').replace(/\?.*$/, ''))}`, status: res.status, creating: false });
|
|
320
|
+
this.observe(res, path, false, false);
|
|
210
321
|
if (!res.ok) throw new HttpError(res.status, `GET ${path} -> ${res.status}`, await res.text().catch(() => ''));
|
|
211
322
|
return Buffer.from(await res.arrayBuffer());
|
|
212
323
|
}
|
|
@@ -234,7 +345,8 @@ export class GitHubClient {
|
|
|
234
345
|
signal: this.signal(),
|
|
235
346
|
});
|
|
236
347
|
const endpointPath = path.replace(/^\/repos\/[^/]+\/[^/]+/, '').replace(/\?.*$/, '');
|
|
237
|
-
|
|
348
|
+
const creating = !notCreating && isContentCreating(method, path, false);
|
|
349
|
+
this.tracker.record({ endpoint: `${method} ${shorten(endpointPath)}`, status: res.status, creating });
|
|
238
350
|
const text = await res.text();
|
|
239
351
|
let json: unknown = null;
|
|
240
352
|
try {
|
|
@@ -242,14 +354,15 @@ export class GitHubClient {
|
|
|
242
354
|
} catch {
|
|
243
355
|
json = text;
|
|
244
356
|
}
|
|
357
|
+
const message = (json as { message?: string } | null)?.message ?? text.slice(0, 200);
|
|
358
|
+
this.observe(res, path, creating, res.status === 403 && /not accessible/i.test(message));
|
|
245
359
|
if (!res.ok) {
|
|
246
|
-
const message = (json as { message?: string } | null)?.message ?? text.slice(0, 200);
|
|
247
360
|
throw new HttpError(res.status, `${method} ${endpointPath} -> ${res.status}: ${message}`, json);
|
|
248
361
|
}
|
|
249
362
|
return json;
|
|
250
363
|
}
|
|
251
364
|
|
|
252
|
-
/** GraphQL as the installation. Throws GraphQLError when the answer has `errors`. */
|
|
365
|
+
/** GraphQL as the installation (or the token). Throws GraphQLError when the answer has `errors`. */
|
|
253
366
|
async graphql<T = unknown>(query: string, variables: Record<string, unknown>, name: string): Promise<T> {
|
|
254
367
|
const isMutation = /^\s*mutation\b/.test(query);
|
|
255
368
|
const res = await this.fetcher(`${API}/graphql`, {
|
|
@@ -264,6 +377,8 @@ export class GitHubClient {
|
|
|
264
377
|
if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) throw error; // timed out mid-body
|
|
265
378
|
return {};
|
|
266
379
|
})) as { data?: T; errors?: { type?: string; message?: string }[] };
|
|
380
|
+
const notPermitted = (json.errors ?? []).some((e) => e.type === 'FORBIDDEN' || /not accessible/i.test(e.message ?? ''));
|
|
381
|
+
this.observe(res, '/graphql', isMutation, notPermitted, (json.errors?.length ?? 0) > 0);
|
|
267
382
|
if (!res.ok && !json.errors) throw new HttpError(res.status, `graphql ${name} -> ${res.status}`, json);
|
|
268
383
|
if (json.errors?.length) throw new GraphQLError(json.errors);
|
|
269
384
|
return json.data as T;
|