@morphemecms/cms 0.4.0 → 0.5.0-next-20261104200208
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/CHANGELOG.md +6 -0
- package/bin/morpheme.mjs +2 -2
- package/package.json +1 -1
- package/scripts/built-site.mjs +24 -7
- package/scripts/check-store-connection.mjs +226 -0
- package/scripts/check-store.mjs +53 -3
- package/scripts/editor-screens.mjs +26 -0
- package/src/AppList.tsx +16 -2
- package/src/StoreConnectNotice.tsx +34 -0
- package/src/StoreConnectedRow.tsx +36 -0
- package/src/i18n/da-DK/apps.ts +7 -0
- package/src/i18n/en-US/apps.ts +8 -0
- package/src/store.ts +5 -0
- package/src/storeConnection.ts +24 -0
- package/src/storeConnectionCall.test.mjs +57 -0
- package/src/storeConnectionCall.ts +37 -0
- package/worker/index.js +4 -1
- package/worker/store-connection.js +106 -0
- package/worker/store.js +14 -6
- package/lib/apps.test.d.mts +0 -1
- package/lib/caseCollisions.test.d.mts +0 -1
- package/lib/changelog.test.d.mts +0 -1
- package/lib/cms-version.test.d.mts +0 -1
- package/lib/lockfile.test.d.mts +0 -1
- package/lib/range.test.d.mts +0 -1
- package/lib/side-block.test.d.mts +0 -1
- package/lib/storeEntries.test.d.mts +0 -1
- package/site/storeTheme.test.d.mts +0 -1
- package/src/cmsView.test.d.mts +0 -1
- package/src/frontmatter.test.d.mts +0 -1
- package/src/standard/leads.test.d.mts +0 -1
package/CHANGELOG.md
CHANGED
package/bin/morpheme.mjs
CHANGED
|
@@ -12,7 +12,7 @@ const COMMANDS = {
|
|
|
12
12
|
'after-build': ['scheduled-list', 'link-counts', 'page-colors'],
|
|
13
13
|
'check:content': ['check-content'],
|
|
14
14
|
'check:editor': ['editor-roundtrip'],
|
|
15
|
-
'check:worker': ['check-worker', 'check-consent', 'check-store', 'check-cms-update'],
|
|
15
|
+
'check:worker': ['check-worker', 'check-consent', 'check-store', 'check-cms-update', 'check-store-connection'],
|
|
16
16
|
'check:screens': ['editor-screens'],
|
|
17
17
|
'check:themes': ['check-themes'],
|
|
18
18
|
'check:demo': ['check-demo'],
|
|
@@ -24,7 +24,7 @@ const COMMANDS = {
|
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
// Node warns that node:sqlite is experimental, which the consent log's check uses as D1.
|
|
27
|
-
const QUIET = new Set(['check-consent']);
|
|
27
|
+
const QUIET = new Set(['check-consent', 'check-store-connection']);
|
|
28
28
|
|
|
29
29
|
const [command, ...args] = process.argv.slice(2);
|
|
30
30
|
if (!COMMANDS[command]) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morphemecms/cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-next-20261104200208",
|
|
4
4
|
"description": "Morpheme CMS: the editor for a site built with Astro, its screens, its blocks and the rules a site gives it. See README.md.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Lars Adolfsen",
|
package/scripts/built-site.mjs
CHANGED
|
@@ -124,19 +124,36 @@ const CMS_STATUS = {
|
|
|
124
124
|
notes: [{ version: '99.0.0', text: 'A made-up note for the checks.\n\nA second paragraph.' }],
|
|
125
125
|
};
|
|
126
126
|
|
|
127
|
+
// The site's key to the store (/api/store/connection, packages/editor/worker/store-connection.js), made up, in the three
|
|
128
|
+
// states "Appstore" shows: a key from the button, no key and the editor may connect, no key and it may only read. A POST
|
|
129
|
+
// or DELETE moves a fake store between the first two, as the Worker would.
|
|
130
|
+
export const STORE_STATES = {
|
|
131
|
+
button: { connected: true, by: 'button', site: 'site_sample', since: '2026-11-04T08:00:00.000Z', mayChange: true },
|
|
132
|
+
open: { connected: false, mayChange: true },
|
|
133
|
+
readOnly: { connected: false, mayChange: false },
|
|
134
|
+
};
|
|
135
|
+
|
|
127
136
|
/** Opens the editor (/editor/) of the site at `site` in `page`, signed in, with GitHub answered from the files on disk
|
|
128
|
-
* (`texts`: some with other texts), and waits until it has loaded the site.
|
|
129
|
-
|
|
137
|
+
* (`texts`: some with other texts), and waits until it has loaded the site. `store`: the state of the site's key to the
|
|
138
|
+
* store (a key of STORE_STATES). */
|
|
139
|
+
export async function openEditor(page, site, texts = {}, store = 'button') {
|
|
140
|
+
let state = STORE_STATES[store];
|
|
130
141
|
// R2 caching under "Avanceret": Cloudflare has the bucket (packages/editor/worker/r2-status.js).
|
|
131
142
|
await page.route(`${site}/api/r2?**`, (r) =>
|
|
132
143
|
r.fulfill({ contentType: 'application/json', body: JSON.stringify({ bound: false, bucket: 'ready' }) }),
|
|
133
144
|
);
|
|
134
145
|
await page.route(`${site}/api/cms`, (r) => r.fulfill({ contentType: 'application/json', body: JSON.stringify(CMS_STATUS) }));
|
|
135
|
-
await page.route(`${site}/api/store/**`, (r) =>
|
|
136
|
-
new URL(r.request().url()).pathname
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
146
|
+
await page.route(`${site}/api/store/**`, (r) => {
|
|
147
|
+
const path = new URL(r.request().url()).pathname;
|
|
148
|
+
const answer = (body) => r.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
|
|
149
|
+
if (path === '/api/store/catalog') return answer(state.connected ? STORE_CATALOG : { connected: false });
|
|
150
|
+
if (path === '/api/store/connection') {
|
|
151
|
+
if (r.request().method() === 'POST') state = STORE_STATES.button;
|
|
152
|
+
if (r.request().method() === 'DELETE') state = STORE_STATES.open;
|
|
153
|
+
return answer(state);
|
|
154
|
+
}
|
|
155
|
+
return r.fulfill({ status: 404 });
|
|
156
|
+
});
|
|
140
157
|
await page.route(`${site}/api/github/**`, (r) => {
|
|
141
158
|
const route = new URL(r.request().url()).pathname.slice('/api/github/'.length);
|
|
142
159
|
const found = workerAnswer(route, JSON.parse(r.request().postData() || '{}'), texts);
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Checks how the site's Worker gets and keeps a store key from "Connect to Morpheme" (packages/editor/worker/store-connection.js):
|
|
2
|
+
// /api/store/connection for GET, POST and DELETE, who may do what, the table with the key (D1 played by Node's SQLite), and
|
|
3
|
+
// storeKey(). GitHub and the store are played by a fake fetch that records each request; nothing goes to either.
|
|
4
|
+
// Run: npm run check:worker
|
|
5
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
6
|
+
import { workerModules } from './worker-modules.mjs';
|
|
7
|
+
|
|
8
|
+
const [{ handleConnection, storeKey, forgetKey }] = await workerModules('store-connection.js', 'session.js', 'store.js');
|
|
9
|
+
|
|
10
|
+
const problems = [];
|
|
11
|
+
const expect = (ok, what) => ok || problems.push(what);
|
|
12
|
+
|
|
13
|
+
/** D1, as much of it as the Worker uses, on an SQLite database in memory. */
|
|
14
|
+
function d1() {
|
|
15
|
+
const db = new DatabaseSync(':memory:');
|
|
16
|
+
const statement = (sql, args = []) => ({
|
|
17
|
+
bind: (...values) => statement(sql, values),
|
|
18
|
+
run: async () => ({ meta: { changes: Number(db.prepare(sql).run(...args).changes) } }),
|
|
19
|
+
all: async () => ({ results: db.prepare(sql).all(...args) }),
|
|
20
|
+
first: async (column) => db.prepare(sql).get(...args)?.[column] ?? null,
|
|
21
|
+
});
|
|
22
|
+
return { db, prepare: (sql) => statement(sql), batch: async (list) => Promise.all(list.map((s) => s.run())) };
|
|
23
|
+
}
|
|
24
|
+
const rowsOf = (store) => store.db.prepare('SELECT site, key FROM store_connection').all();
|
|
25
|
+
|
|
26
|
+
const SITE = `site_${'a1'.repeat(12)}`;
|
|
27
|
+
const KEY = 'isk_x';
|
|
28
|
+
const HAND = 'hand-key-for-test';
|
|
29
|
+
|
|
30
|
+
/** A fake fetch: GitHub says what `repoAccess` says; the store answers with `store(url, init)`. `storeCalls` records its requests. */
|
|
31
|
+
function world({ repoAccess = { pull: true, push: true }, store = () => Response.json({ site: SITE, key: KEY }, { status: 201 }) } = {}) {
|
|
32
|
+
const storeCalls = [];
|
|
33
|
+
const ask = async (url, init = {}) => {
|
|
34
|
+
if (String(url).startsWith('https://api.github.com/')) return Response.json({ permissions: repoAccess });
|
|
35
|
+
storeCalls.push({ url: String(url), method: init.method ?? 'GET', headers: init.headers ?? {}, body: init.body });
|
|
36
|
+
return store(String(url), init);
|
|
37
|
+
};
|
|
38
|
+
return { ask, storeCalls };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let n = 0;
|
|
42
|
+
/** A request from the editor's page; a fresh token each time (the Worker remembers what GitHub said about a token). */
|
|
43
|
+
const call = async (w, method, env, { signedIn = true } = {}) => {
|
|
44
|
+
const request = new Request('https://site.example/api/store/connection', {
|
|
45
|
+
method,
|
|
46
|
+
headers: { ...(signedIn ? { cookie: `editor_session=token-${++n}` } : {}), 'sec-fetch-site': 'same-origin' },
|
|
47
|
+
});
|
|
48
|
+
const res = await handleConnection(request, env, {}, w.ask);
|
|
49
|
+
const text = await res.text();
|
|
50
|
+
return { res, text, body: text.startsWith('{') ? JSON.parse(text) : null };
|
|
51
|
+
};
|
|
52
|
+
/** An env with a fresh database, with a row for each [site, key] given. */
|
|
53
|
+
const envWith = async (rows = [], extra = {}) => {
|
|
54
|
+
const CONSENT = d1();
|
|
55
|
+
if (rows.length) {
|
|
56
|
+
await call(world(), 'GET', { CONSENT }); // makes the table on first use
|
|
57
|
+
for (const [site, key] of rows) {
|
|
58
|
+
CONSENT.db.prepare('INSERT INTO store_connection (site, key, connected) VALUES (?, ?, ?)').run(site, key, '2026-11-04T10:00:00.000Z');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { CONSENT, ...extra };
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Signed out: 401 for each method, and the store is not asked.
|
|
65
|
+
for (const method of ['GET', 'POST', 'DELETE']) {
|
|
66
|
+
const w = world();
|
|
67
|
+
const { res } = await call(w, method, await envWith(), { signedIn: false });
|
|
68
|
+
expect(res.status === 401, `uden login var ${method} ${res.status}, ikke 401`);
|
|
69
|
+
expect(w.storeCalls.length === 0, `uden login blev butikken spurgt (${method})`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Not even allowed to read: 403.
|
|
73
|
+
{
|
|
74
|
+
const w = world({ repoAccess: { pull: false, push: false } });
|
|
75
|
+
const { res } = await call(w, 'GET', await envWith());
|
|
76
|
+
expect(res.status === 403, `uden læseret var svaret ${res.status}, ikke 403`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Read-only: GET works, POST and DELETE are 403, and the store is not asked.
|
|
80
|
+
{
|
|
81
|
+
const w = world({ repoAccess: { pull: true, push: false } });
|
|
82
|
+
const env = await envWith();
|
|
83
|
+
const get = await call(w, 'GET', env);
|
|
84
|
+
expect(get.res.status === 200 && get.body.connected === false && get.body.mayChange === false, `GET med kun læseret gav ${get.text}`);
|
|
85
|
+
for (const method of ['POST', 'DELETE']) {
|
|
86
|
+
const { res } = await call(w, method, env);
|
|
87
|
+
expect(res.status === 403, `${method} med kun læseret gav ${res.status}, ikke 403`);
|
|
88
|
+
}
|
|
89
|
+
expect(w.storeCalls.length === 0, 'med kun læseret blev butikken spurgt');
|
|
90
|
+
expect(rowsOf(env.CONSENT).length === 0, 'med kun læseret blev der gemt en række');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Connect: the store gets the origin and owner/name; the row has the key; no answer has the key; a second connect is 409.
|
|
94
|
+
{
|
|
95
|
+
const w = world();
|
|
96
|
+
const env = await envWith();
|
|
97
|
+
const { res, text, body } = await call(w, 'POST', env);
|
|
98
|
+
expect(
|
|
99
|
+
res.status === 200 &&
|
|
100
|
+
body.connected === true &&
|
|
101
|
+
body.by === 'button' &&
|
|
102
|
+
body.site === SITE &&
|
|
103
|
+
body.mayChange === true &&
|
|
104
|
+
typeof body.since === 'string',
|
|
105
|
+
`POST gav ${res.status} ${text}`,
|
|
106
|
+
);
|
|
107
|
+
expect(!text.includes(KEY), 'svaret på POST indeholdt nøglen');
|
|
108
|
+
expect(w.storeCalls.length === 1 && w.storeCalls[0].method === 'POST', `butikken blev spurgt ${w.storeCalls.length} gange`);
|
|
109
|
+
expect(w.storeCalls[0]?.url.endsWith('/store/connect'), `adressen var ${w.storeCalls[0]?.url}`);
|
|
110
|
+
const sent = JSON.parse(w.storeCalls[0]?.body ?? '{}');
|
|
111
|
+
expect(sent.address === 'https://site.example' && /^[^/]+\/[^/]+$/.test(sent.repo), `butikken fik ${w.storeCalls[0]?.body}`);
|
|
112
|
+
const rows = rowsOf(env.CONSENT);
|
|
113
|
+
expect(rows.length === 1 && rows[0].site === SITE && rows[0].key === KEY, 'rækken har ikke nøglen');
|
|
114
|
+
const get = await call(w, 'GET', env);
|
|
115
|
+
expect(get.body.by === 'button' && get.body.site === SITE && !get.text.includes(KEY), `GET efter POST gav ${get.text}`);
|
|
116
|
+
const second = await call(w, 'POST', env);
|
|
117
|
+
expect(second.res.status === 409, `en anden POST gav ${second.res.status}, ikke 409`);
|
|
118
|
+
expect(w.storeCalls.length === 1, 'en anden POST spurgte butikken');
|
|
119
|
+
// MORPHEME_STORE_URL is used when set.
|
|
120
|
+
const w2 = world();
|
|
121
|
+
await call(w2, 'POST', await envWith([], { MORPHEME_STORE_URL: 'https://store.example' }));
|
|
122
|
+
expect(w2.storeCalls[0]?.url === 'https://store.example/store/connect', `MORPHEME_STORE_URL blev ikke brugt: ${w2.storeCalls[0]?.url}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The store's 429: 429 and no row. Its 500, an answer without a key, or a thrown fetch: 502 and no row.
|
|
126
|
+
{
|
|
127
|
+
const env = await envWith();
|
|
128
|
+
const { res, body } = await call(world({ store: () => new Response('{}', { status: 429 }) }), 'POST', env);
|
|
129
|
+
expect(res.status === 429 && body.error === 'busy', `butikkens 429 gav ${res.status}`);
|
|
130
|
+
expect(rowsOf(env.CONSENT).length === 0, 'butikkens 429 gav en række');
|
|
131
|
+
}
|
|
132
|
+
for (const [what, store] of [
|
|
133
|
+
['500', () => new Response(`secret ${KEY}`, { status: 500 })],
|
|
134
|
+
['et svar uden nøgle', () => Response.json({ site: SITE }, { status: 201 })],
|
|
135
|
+
[
|
|
136
|
+
'en netværksfejl',
|
|
137
|
+
() => {
|
|
138
|
+
throw new Error(`no network, ${KEY}`);
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
]) {
|
|
142
|
+
const env = await envWith();
|
|
143
|
+
const { res, text, body } = await call(world({ store }), 'POST', env);
|
|
144
|
+
expect(res.status === 502 && body.error === 'store failed', `butikkens ${what} gav ${res.status} ${text}`);
|
|
145
|
+
expect(!text.includes(KEY), `svaret på butikkens ${what} indeholdt nøglen`);
|
|
146
|
+
expect(rowsOf(env.CONSENT).length === 0, `butikkens ${what} gav en række`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// No database (a branch preview): GET says not connected, POST is 503.
|
|
150
|
+
{
|
|
151
|
+
const w = world();
|
|
152
|
+
const get = await call(w, 'GET', {});
|
|
153
|
+
expect(get.res.status === 200 && get.body.connected === false, `GET uden CONSENT gav ${get.text}`);
|
|
154
|
+
const post = await call(w, 'POST', {});
|
|
155
|
+
expect(post.res.status === 503 && post.body.error === 'no database', `POST uden CONSENT gav ${post.res.status}`);
|
|
156
|
+
expect(w.storeCalls.length === 0, 'POST uden CONSENT spurgte butikken');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A key set by hand: GET says so, POST and DELETE are 409, and the store is not asked.
|
|
160
|
+
{
|
|
161
|
+
const w = world();
|
|
162
|
+
const env = await envWith([], { MORPHEME_STORE_KEY: HAND });
|
|
163
|
+
const get = await call(w, 'GET', env);
|
|
164
|
+
expect(get.body.connected === true && get.body.by === 'hand' && !get.text.includes(HAND), `GET med nøgle i hånden gav ${get.text}`);
|
|
165
|
+
expect((await call(w, 'DELETE', env)).res.status === 409, 'DELETE med nøgle i hånden gav ikke 409');
|
|
166
|
+
expect((await call(w, 'POST', env)).res.status === 409, 'POST med nøgle i hånden gav ikke 409');
|
|
167
|
+
expect(w.storeCalls.length === 0, 'med nøgle i hånden blev butikken spurgt');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Disconnect: on the store's 200 or 401 the row goes (the bearer is the row's key); on 500 or a thrown fetch it stays.
|
|
171
|
+
for (const status of [200, 401]) {
|
|
172
|
+
const w = world({ store: () => new Response('{}', { status }) });
|
|
173
|
+
const env = await envWith([[SITE, KEY]]);
|
|
174
|
+
const { res, body, text } = await call(w, 'DELETE', env);
|
|
175
|
+
expect(
|
|
176
|
+
res.status === 200 && body.connected === false && body.mayChange === true,
|
|
177
|
+
`DELETE med butikkens ${status} gav ${res.status} ${text}`,
|
|
178
|
+
);
|
|
179
|
+
expect(rowsOf(env.CONSENT).length === 0, `DELETE med butikkens ${status} lod rækken stå`);
|
|
180
|
+
expect(w.storeCalls[0]?.method === 'DELETE' && w.storeCalls[0].url.endsWith('/store/connect'), 'DELETE spurgte ikke butikken rigtigt');
|
|
181
|
+
expect(w.storeCalls[0]?.headers.authorization === `Bearer ${KEY}`, 'DELETE brugte ikke rækkens nøgle som bearer');
|
|
182
|
+
}
|
|
183
|
+
for (const store of [
|
|
184
|
+
() => new Response('{}', { status: 500 }),
|
|
185
|
+
() => {
|
|
186
|
+
throw new Error('no network');
|
|
187
|
+
},
|
|
188
|
+
]) {
|
|
189
|
+
const env = await envWith([[SITE, KEY]]);
|
|
190
|
+
const { res, body } = await call(world({ store }), 'DELETE', env);
|
|
191
|
+
expect(res.status === 502 && body.error === 'store failed', `DELETE med en fejl i butikken gav ${res.status}`);
|
|
192
|
+
expect(rowsOf(env.CONSENT).length === 1, 'DELETE med en fejl i butikken slettede rækken');
|
|
193
|
+
}
|
|
194
|
+
// No key at all: DELETE is a quiet 200 and the store is not asked.
|
|
195
|
+
{
|
|
196
|
+
const w = world();
|
|
197
|
+
const { res, body } = await call(w, 'DELETE', await envWith());
|
|
198
|
+
expect(res.status === 200 && body.connected === false, `DELETE uden nøgle gav ${res.status}`);
|
|
199
|
+
expect(w.storeCalls.length === 0, 'DELETE uden nøgle spurgte butikken');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Other methods: 405.
|
|
203
|
+
expect((await call(world(), 'PUT', await envWith())).res.status === 405, 'PUT gav ikke 405');
|
|
204
|
+
|
|
205
|
+
// storeKey: the hand key first, then the row, else null; forgetKey deletes the rows.
|
|
206
|
+
{
|
|
207
|
+
const env = await envWith([[SITE, KEY]], { MORPHEME_STORE_KEY: HAND });
|
|
208
|
+
const hand = await storeKey(env);
|
|
209
|
+
expect(hand?.key === HAND && hand.by === 'hand', `storeKey med begge gav ${JSON.stringify(hand)}`);
|
|
210
|
+
const row = await storeKey({ CONSENT: env.CONSENT });
|
|
211
|
+
expect(
|
|
212
|
+
row?.key === KEY && row.by === 'button' && row.site === SITE && typeof row.since === 'string',
|
|
213
|
+
`storeKey med en række gav ${JSON.stringify(row)}`,
|
|
214
|
+
);
|
|
215
|
+
expect((await storeKey({ MORPHEME_STORE_KEY: HAND }))?.by === 'hand', 'storeKey uden CONSENT gav ikke nøglen i hånden');
|
|
216
|
+
expect((await storeKey({})) === null, 'storeKey uden noget gav ikke null');
|
|
217
|
+
await forgetKey(env);
|
|
218
|
+
expect(rowsOf(env.CONSENT).length === 0, 'forgetKey slettede ikke rækken');
|
|
219
|
+
expect((await storeKey({ CONSENT: env.CONSENT })) === null, 'storeKey efter forgetKey gav ikke null');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (problems.length) {
|
|
223
|
+
console.error(problems.map((p) => `- ${p}`).join('\n'));
|
|
224
|
+
process.exit(1);
|
|
225
|
+
}
|
|
226
|
+
console.log('Workerens nøgle fra Forbind til Morpheme er i orden.');
|
package/scripts/check-store.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// Checks the site's Worker's questions to the store (packages/editor/worker/store.js): /api/store/catalog and /api/store/files/<oid>,
|
|
2
|
-
// who may ask, what happens without a key,
|
|
3
|
-
//
|
|
2
|
+
// who may ask, what happens without a key, the key from the button (in D1, played by Node's SQLite) that is forgotten when
|
|
3
|
+
// the store refuses it, and the store's refusals and failures. GitHub and the store are played by a fake fetch that records
|
|
4
|
+
// each request; nothing goes to either.
|
|
4
5
|
// Run: npm run check:worker
|
|
5
6
|
import { APP_API, THEME_API } from '../lib/contracts.mjs';
|
|
7
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
6
8
|
import { workerModules } from './worker-modules.mjs';
|
|
7
9
|
|
|
8
|
-
const [{ handleStore, STORE_URL }] = await workerModules('store.js', 'session.js');
|
|
10
|
+
const [{ handleStore, STORE_URL }, , { storeKey }] = await workerModules('store.js', 'session.js', 'store-connection.js');
|
|
9
11
|
|
|
10
12
|
const problems = [];
|
|
11
13
|
const expect = (ok, what) => ok || problems.push(what);
|
|
@@ -25,6 +27,28 @@ function world({ repoAccess = { pull: true, push: false }, store = () => new Res
|
|
|
25
27
|
return { ask, storeCalls };
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
/** D1, as much of it as the Worker uses, on an SQLite database in memory. */
|
|
31
|
+
function d1() {
|
|
32
|
+
const db = new DatabaseSync(':memory:');
|
|
33
|
+
const statement = (sql, args = []) => ({
|
|
34
|
+
bind: (...values) => statement(sql, values),
|
|
35
|
+
run: async () => ({ meta: { changes: Number(db.prepare(sql).run(...args).changes) } }),
|
|
36
|
+
all: async () => ({ results: db.prepare(sql).all(...args) }),
|
|
37
|
+
first: async (column) => db.prepare(sql).get(...args)?.[column] ?? null,
|
|
38
|
+
});
|
|
39
|
+
return { db, prepare: (sql) => statement(sql), batch: async (list) => Promise.all(list.map((s) => s.run())) };
|
|
40
|
+
}
|
|
41
|
+
/** An env with a key from the button in its database (the table is made by storeKey, as on first use). */
|
|
42
|
+
async function envWithRow(key) {
|
|
43
|
+
const CONSENT = d1();
|
|
44
|
+
await storeKey({ CONSENT });
|
|
45
|
+
CONSENT.db
|
|
46
|
+
.prepare('INSERT INTO store_connection (site, key, connected) VALUES (?, ?, ?)')
|
|
47
|
+
.run(`site_${'a1'.repeat(12)}`, key, '2026-11-04T10:00:00.000Z');
|
|
48
|
+
return { CONSENT };
|
|
49
|
+
}
|
|
50
|
+
const rowCount = (env) => env.CONSENT.db.prepare('SELECT COUNT(*) AS n FROM store_connection').get().n;
|
|
51
|
+
|
|
28
52
|
let n = 0;
|
|
29
53
|
/** A request from the editor's page; a fresh token each time (the Worker remembers what GitHub said about a token). */
|
|
30
54
|
const request = (path, { signedIn = true, method = 'GET' } = {}) =>
|
|
@@ -145,6 +169,32 @@ for (const [status, error] of [
|
|
|
145
169
|
expect(!text.includes(KEY), 'svaret på en netværksfejl indeholdt nøglen');
|
|
146
170
|
}
|
|
147
171
|
|
|
172
|
+
// A key from the button: the store gets it as the bearer; when the store refuses it, the row goes, the catalog says
|
|
173
|
+
// "not connected" and a file is not found. A key set by hand that is refused stays a 502.
|
|
174
|
+
{
|
|
175
|
+
const w = world();
|
|
176
|
+
await call(w, 'catalog', await envWithRow('isk_row'));
|
|
177
|
+
expect(
|
|
178
|
+
w.storeCalls[0]?.headers.authorization === 'Bearer isk_row',
|
|
179
|
+
`kataloget blev spurgt med ${w.storeCalls[0]?.headers.authorization}`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
for (const status of [401, 403]) {
|
|
183
|
+
for (const path of ['catalog', `files/${OID}`]) {
|
|
184
|
+
const env = await envWithRow('isk_row');
|
|
185
|
+
const { res, text } = await call(world({ store: () => new Response('no', { status }) }), path, env);
|
|
186
|
+
const want = path === 'catalog' ? res.status === 200 && JSON.parse(text).connected === false : res.status === 404;
|
|
187
|
+
expect(want, `butikkens ${status} på ${path} med en nøgle fra knappen gav ${res.status} ${text}`);
|
|
188
|
+
expect(rowCount(env) === 0, `butikkens ${status} på ${path} lod nøglen fra knappen stå`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
{
|
|
192
|
+
const env = { ...(await envWithRow('isk_row')), MORPHEME_STORE_KEY: KEY };
|
|
193
|
+
const { res, text } = await call(world({ store: () => new Response('no', { status: 401 }) }), 'catalog', env);
|
|
194
|
+
expect(res.status === 502 && JSON.parse(text).error === 'store refused', `en nøgle i hånden, der blev afvist, gav ${res.status} ${text}`);
|
|
195
|
+
expect(rowCount(env) === 1, 'en nøgle i hånden, der blev afvist, slettede rækken');
|
|
196
|
+
}
|
|
197
|
+
|
|
148
198
|
// Other methods and addresses.
|
|
149
199
|
{
|
|
150
200
|
const w = world();
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
11
11
|
import { join, relative, resolve } from 'node:path';
|
|
12
12
|
import { chromium } from 'playwright';
|
|
13
|
+
import { mkdirSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
13
15
|
import { openEditor, serveBuilt } from './built-site.mjs';
|
|
14
16
|
|
|
15
17
|
const ROOT = `${process.cwd()}/`;
|
|
@@ -92,6 +94,30 @@ for (const [width, height] of [
|
|
|
92
94
|
await page.close();
|
|
93
95
|
}
|
|
94
96
|
|
|
97
|
+
// "Appstore" in the three states of the site's key to the store (STORE_STATES): what each shows, and a picture of it in
|
|
98
|
+
// SCREENS_DIR (the temporary folder's morpheme-screens by default).
|
|
99
|
+
const shots = process.env.SCREENS_DIR || join(tmpdir(), 'morpheme-screens');
|
|
100
|
+
mkdirSync(shots, { recursive: true });
|
|
101
|
+
const keyStates = {
|
|
102
|
+
open: { shows: ['Hjemmesiden er ikke forbundet til Morpheme', 'Forbind til Morpheme'], hides: ['Afbryd forbindelsen til Morpheme'] },
|
|
103
|
+
readOnly: { shows: ['Hjemmesiden har ingen Appstore'], hides: ['Forbind til Morpheme', 'Afbryd forbindelsen til Morpheme'] },
|
|
104
|
+
button: { shows: ['Forbundet til Morpheme', 'site_sample', 'Afbryd forbindelsen til Morpheme'], hides: ['Forbind til Morpheme'] },
|
|
105
|
+
};
|
|
106
|
+
for (const [state, { shows, hides }] of Object.entries(keyStates)) {
|
|
107
|
+
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, locale: 'da-DK' });
|
|
108
|
+
page.on('pageerror', (e) => problems.push(`Appstore (${state}): ${e.message}`));
|
|
109
|
+
await openEditor(page, site, {}, state);
|
|
110
|
+
await page.evaluate(() => (location.hash = 'indstillinger/appstore'));
|
|
111
|
+
await page.waitForTimeout(500);
|
|
112
|
+
const text = await page.evaluate(() => document.body.innerText);
|
|
113
|
+
for (const x of shows) if (!text.includes(x)) problems.push(`Appstore (${state}): "${x}" mangler`);
|
|
114
|
+
for (const x of hides) if (text.includes(x)) problems.push(`Appstore (${state}): "${x}" skal ikke vises`);
|
|
115
|
+
const file = join(shots, `appstore-${state}.png`);
|
|
116
|
+
await page.screenshot({ path: file });
|
|
117
|
+
console.log(`Appstore (${state}): ${file}`);
|
|
118
|
+
await page.close();
|
|
119
|
+
}
|
|
120
|
+
|
|
95
121
|
await browser.close();
|
|
96
122
|
server.close();
|
|
97
123
|
if (problems.length) {
|
package/src/AppList.tsx
CHANGED
|
@@ -98,6 +98,9 @@ import { config } from '@site/editor';
|
|
|
98
98
|
import { allThemes, ThemeRow, themeInstalled, useThemeCatalog } from './ThemeStore';
|
|
99
99
|
import { siteT, siteText, t } from './i18n';
|
|
100
100
|
import { OwnerLicenses, StoreLicenseRows } from './Licenses';
|
|
101
|
+
import { StoreConnectNotice } from './StoreConnectNotice';
|
|
102
|
+
import { StoreConnectedRow } from './StoreConnectedRow';
|
|
103
|
+
import { connection, type StoreConnection } from './storeConnection';
|
|
101
104
|
import { appType, setOn, told, useNotificationChoices } from './notificationData';
|
|
102
105
|
import { kindAndName } from './deploy';
|
|
103
106
|
|
|
@@ -594,8 +597,18 @@ export function DeveloperRow({ name }: { name: string }) {
|
|
|
594
597
|
* and those installed, each with its version and who made it; each opening its page. With search (by name, what it
|
|
595
598
|
* does, who made it and the words it is found by, in both languages), the order and the filters by category (apps)
|
|
596
599
|
* and by whether it is installed, each picked one as a chip. */
|
|
597
|
-
export function StorePage(
|
|
600
|
+
export function StorePage(props: { env: Env; files: RepoFile[]; tab: 'apps' | 'themes' }) {
|
|
601
|
+
// Connecting or disconnecting draws the screen again from the start, so the catalogs and the key are asked again.
|
|
602
|
+
const [round, setRound] = useState(0);
|
|
603
|
+
return <StoreScreen key={round} {...props} again={() => setRound((r) => r + 1)} />;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function StoreScreen({ env, files, tab, again }: { env: Env; files: RepoFile[]; tab: 'apps' | 'themes'; again: () => void }) {
|
|
598
607
|
const installed = installedApps(files);
|
|
608
|
+
const [key, setKey] = useState<StoreConnection | null>(null);
|
|
609
|
+
useEffect(() => {
|
|
610
|
+
connection().then(setKey, () => {});
|
|
611
|
+
}, []);
|
|
599
612
|
const catalog = useCatalog(env);
|
|
600
613
|
const themeCatalog = useThemeCatalog(env);
|
|
601
614
|
const connected = useStoreConnected(env);
|
|
@@ -689,7 +702,7 @@ export function StorePage({ env, files, tab }: { env: Env; files: RepoFile[]; ta
|
|
|
689
702
|
)}
|
|
690
703
|
{/* The site's own are shown also when the store cannot be read. */}
|
|
691
704
|
{connected === false ? (
|
|
692
|
-
<
|
|
705
|
+
<StoreConnectNotice mayChange={key?.mayChange ?? false} onConnected={again} />
|
|
693
706
|
) : read === null || connected === null ? (
|
|
694
707
|
<EmptyNote busy>{t.apps.loadingStore}</EmptyNote>
|
|
695
708
|
) : read === 'failed' ? (
|
|
@@ -714,6 +727,7 @@ export function StorePage({ env, files, tab }: { env: Env; files: RepoFile[]; ta
|
|
|
714
727
|
</Group>
|
|
715
728
|
))
|
|
716
729
|
)}
|
|
730
|
+
{key?.connected && key.by === 'button' && key.mayChange && <StoreConnectedRow site={key.site} onDisconnected={again} />}
|
|
717
731
|
</Screen>
|
|
718
732
|
);
|
|
719
733
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The box in "Appstore" for a site without a key to the store: the button "Connect to Morpheme", or a note for editors who may only read.
|
|
2
|
+
// Exposes StoreConnectNotice({ mayChange, onConnected }); uses Notice, Button, EmptyNote (ui.tsx), snackbar (lib/snackbar.ts) and connect (storeConnection.ts).
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { snackbar } from '../lib/snackbar';
|
|
5
|
+
import { connect } from './storeConnection';
|
|
6
|
+
import { Button, EmptyNote, Notice } from './ui';
|
|
7
|
+
import { t } from './i18n';
|
|
8
|
+
|
|
9
|
+
export function StoreConnectNotice({ mayChange, onConnected }: { mayChange: boolean; onConnected: () => void }) {
|
|
10
|
+
const [busy, setBusy] = useState(false);
|
|
11
|
+
if (!mayChange) return <EmptyNote>{t.apps.noStore}</EmptyNote>;
|
|
12
|
+
async function connectNow() {
|
|
13
|
+
setBusy(true);
|
|
14
|
+
try {
|
|
15
|
+
await connect();
|
|
16
|
+
onConnected();
|
|
17
|
+
} catch (e) {
|
|
18
|
+
snackbar({ text: (e as Error).message, error: true });
|
|
19
|
+
setBusy(false);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return (
|
|
23
|
+
<Notice
|
|
24
|
+
title={t.apps.connectTitle}
|
|
25
|
+
action={
|
|
26
|
+
<Button disabled={busy} onClick={connectNow}>
|
|
27
|
+
{t.apps.connect}
|
|
28
|
+
</Button>
|
|
29
|
+
}
|
|
30
|
+
>
|
|
31
|
+
{t.apps.connectText}
|
|
32
|
+
</Notice>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The row at the bottom of "Appstore" that says the site is connected to Morpheme, with the button "Disconnect from Morpheme" (asks first).
|
|
2
|
+
// Exposes StoreConnectedRow({ site, onDisconnected }); uses Group, ListItem, Button (ui.tsx), ask (ask.tsx), snackbar (lib/snackbar.ts) and disconnect (storeConnection.ts).
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { snackbar } from '../lib/snackbar';
|
|
5
|
+
import { ask } from './ask';
|
|
6
|
+
import { disconnect } from './storeConnection';
|
|
7
|
+
import { Button, Group, ListItem } from './ui';
|
|
8
|
+
import { t } from './i18n';
|
|
9
|
+
|
|
10
|
+
export function StoreConnectedRow({ site, onDisconnected }: { site: string; onDisconnected: () => void }) {
|
|
11
|
+
const [busy, setBusy] = useState(false);
|
|
12
|
+
async function disconnectNow() {
|
|
13
|
+
if (!(await ask(t.apps.disconnectAsk, t.apps.disconnect))) return;
|
|
14
|
+
setBusy(true);
|
|
15
|
+
try {
|
|
16
|
+
await disconnect();
|
|
17
|
+
onDisconnected();
|
|
18
|
+
} catch (e) {
|
|
19
|
+
snackbar({ text: (e as Error).message, error: true });
|
|
20
|
+
setBusy(false);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return (
|
|
24
|
+
<Group>
|
|
25
|
+
<ListItem
|
|
26
|
+
headline={t.apps.connected}
|
|
27
|
+
supporting={site}
|
|
28
|
+
actions={
|
|
29
|
+
<Button disabled={busy} onClick={disconnectNow}>
|
|
30
|
+
{t.apps.disconnect}
|
|
31
|
+
</Button>
|
|
32
|
+
}
|
|
33
|
+
/>
|
|
34
|
+
</Group>
|
|
35
|
+
);
|
|
36
|
+
}
|
package/src/i18n/da-DK/apps.ts
CHANGED
|
@@ -53,6 +53,13 @@ export const apps: Messages['apps'] = {
|
|
|
53
53
|
loadingStore: 'Henter apps',
|
|
54
54
|
storeFailed: 'Kunne ikke hente Appstore.',
|
|
55
55
|
noStore: 'Sitet har ingen Appstore.',
|
|
56
|
+
connectTitle: 'Hjemmesiden er ikke forbundet til Morpheme',
|
|
57
|
+
connectText: 'Forbind den for at installere apps og temaer fra Appstore.',
|
|
58
|
+
connect: 'Forbind til Morpheme',
|
|
59
|
+
connected: 'Forbundet til Morpheme',
|
|
60
|
+
disconnect: 'Afbryd forbindelsen til Morpheme',
|
|
61
|
+
disconnectAsk: 'Afbryd hjemmesidens forbindelse til Morpheme? Installerede apps og temaer bliver.',
|
|
62
|
+
connectBusy: 'Mange hjemmesider forbinder lige nu. Prøv igen om en time.',
|
|
56
63
|
storeBroken: (app) => `${app} kunne ikke hentes rigtigt fra Appstore.`,
|
|
57
64
|
notInStore: 'Appen findes ikke i Appstore.',
|
|
58
65
|
categoryFilter: 'Kategori',
|
package/src/i18n/en-US/apps.ts
CHANGED
|
@@ -80,6 +80,14 @@ export const apps = {
|
|
|
80
80
|
storeFailed: 'Couldn’t load the Appstore',
|
|
81
81
|
/** On "Appstore" when the site's Worker has no store key: only the site's own are there. */
|
|
82
82
|
noStore: 'The site has no Appstore',
|
|
83
|
+
/** On "Appstore": the box that connects the site to the Morpheme store, the row that disconnects it, and the answer at the store's hourly limit. */
|
|
84
|
+
connectTitle: 'The website is not connected to Morpheme',
|
|
85
|
+
connectText: 'Connect it to install apps and themes from the Appstore.',
|
|
86
|
+
connect: 'Connect to Morpheme',
|
|
87
|
+
connected: 'Connected to Morpheme',
|
|
88
|
+
disconnect: 'Disconnect from Morpheme',
|
|
89
|
+
disconnectAsk: 'Disconnect the website from Morpheme? Installed apps and themes stay.',
|
|
90
|
+
connectBusy: 'Many websites are connecting right now. Try again in an hour.',
|
|
83
91
|
/** An app's files did not come from the store as its catalog says. */
|
|
84
92
|
storeBroken: (app: string) => `${app} couldn’t be loaded correctly from the Appstore`,
|
|
85
93
|
/** At an address of an app the store does not have. */
|
package/src/store.ts
CHANGED
|
@@ -310,6 +310,11 @@ function catalogText(): Promise<string> {
|
|
|
310
310
|
return catalog;
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
/** Makes the catalog be read again, the next time it is asked for (after the site is connected or disconnected). */
|
|
314
|
+
export function forgetCatalog(): void {
|
|
315
|
+
catalog = null;
|
|
316
|
+
}
|
|
317
|
+
|
|
313
318
|
/** Whether the site's Worker has a store key; without one, "Appstore" has only the site's own apps and themes. Fails when
|
|
314
319
|
* the catalog cannot be read. */
|
|
315
320
|
export const storeConnected = (): Promise<boolean> =>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The site's connection to the Morpheme store, from the editor: connection() reads it, connect() and disconnect() change it.
|
|
2
|
+
// Exposes connection, connect, disconnect and StoreConnection; used by StoreConnectNotice.tsx, StoreConnectedRow.tsx and AppList.tsx.
|
|
3
|
+
// Depends on storeConnectionCall.ts (the request), store.ts (forgetCatalog) and i18n (the texts of a failure).
|
|
4
|
+
import { callConnection, type ConnectionDeps, type StoreConnection } from './storeConnectionCall';
|
|
5
|
+
import { forgetCatalog } from './store';
|
|
6
|
+
import { t } from './i18n';
|
|
7
|
+
|
|
8
|
+
export type { StoreConnection };
|
|
9
|
+
|
|
10
|
+
const deps = (): ConnectionDeps => ({
|
|
11
|
+
fetch: (url, init) => fetch(url, init),
|
|
12
|
+
forgetCatalog,
|
|
13
|
+
busy: t.apps.connectBusy,
|
|
14
|
+
failed: t.apps.storeFailed,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/** Whether the site has a key to the store, and who may change that. */
|
|
18
|
+
export const connection = () => callConnection('GET', deps());
|
|
19
|
+
|
|
20
|
+
/** Asks the store for a key for this site. */
|
|
21
|
+
export const connect = () => callConnection('POST', deps());
|
|
22
|
+
|
|
23
|
+
/** Tells the store to drop the site's key. */
|
|
24
|
+
export const disconnect = () => callConnection('DELETE', deps());
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Tests for src/storeConnectionCall.ts: the request for "Connect to Morpheme" and the text of a failure.
|
|
2
|
+
// Run: node --test packages/editor/src/storeConnectionCall.test.mjs
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import { callConnection } from './storeConnectionCall.ts';
|
|
6
|
+
|
|
7
|
+
const make = (answer) => {
|
|
8
|
+
const calls = { fetched: [], forgot: 0 };
|
|
9
|
+
const deps = {
|
|
10
|
+
fetch: async (url, init) => {
|
|
11
|
+
calls.fetched.push([url, init.method]);
|
|
12
|
+
if (answer instanceof Error) throw answer;
|
|
13
|
+
return answer;
|
|
14
|
+
},
|
|
15
|
+
forgetCatalog: () => calls.forgot++,
|
|
16
|
+
busy: 'BUSY',
|
|
17
|
+
failed: 'FAILED',
|
|
18
|
+
};
|
|
19
|
+
return { deps, calls };
|
|
20
|
+
};
|
|
21
|
+
const ok = (body) => ({ ok: true, status: 200, json: async () => body });
|
|
22
|
+
|
|
23
|
+
test('a read gives the answer and keeps the catalog', async () => {
|
|
24
|
+
const { deps, calls } = make(ok({ connected: false, mayChange: true }));
|
|
25
|
+
assert.deepEqual(await callConnection('GET', deps), { connected: false, mayChange: true });
|
|
26
|
+
assert.deepEqual(calls.fetched, [['/api/store/connection', 'GET']]);
|
|
27
|
+
assert.equal(calls.forgot, 0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('connect and disconnect make the catalog be read again', async () => {
|
|
31
|
+
for (const method of ['POST', 'DELETE']) {
|
|
32
|
+
const { deps, calls } = make(ok({ connected: false, mayChange: true }));
|
|
33
|
+
await callConnection(method, deps);
|
|
34
|
+
assert.equal(calls.fetched[0][1], method);
|
|
35
|
+
assert.equal(calls.forgot, 1);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('429 says the store is busy', async () => {
|
|
40
|
+
const { deps, calls } = make({ ok: false, status: 429, json: async () => ({}) });
|
|
41
|
+
await assert.rejects(callConnection('POST', deps), { message: 'BUSY' });
|
|
42
|
+
assert.equal(calls.forgot, 0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const failures = [
|
|
46
|
+
{ ok: false, status: 502, json: async () => ({}) },
|
|
47
|
+
new Error('offline'),
|
|
48
|
+
{ ok: true, status: 200, json: async () => Promise.reject(new Error('not json')) },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
test('any other failure says the store failed', async () => {
|
|
52
|
+
for (const answer of failures) {
|
|
53
|
+
const { deps, calls } = make(answer);
|
|
54
|
+
await assert.rejects(callConnection('DELETE', deps), { message: 'FAILED' });
|
|
55
|
+
assert.equal(calls.forgot, 0);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The request to the site's Worker for "Connect to Morpheme" (/api/store/connection): GET, POST, DELETE, and what a failure says.
|
|
2
|
+
// Exposes StoreConnection and callConnection(method, deps); no imports of the editor, so node --test runs it (storeConnectionCall.test.mjs).
|
|
3
|
+
// Used by storeConnection.ts, which gives it the real fetch, forgetCatalog and texts.
|
|
4
|
+
|
|
5
|
+
/** What the Worker says about the site's key to the store (packages/editor/worker/store-connection.js). */
|
|
6
|
+
export type StoreConnection =
|
|
7
|
+
| { connected: false; mayChange: boolean }
|
|
8
|
+
| { connected: true; by: 'hand'; mayChange: boolean }
|
|
9
|
+
| { connected: true; by: 'button'; site: string; since: string; mayChange: boolean };
|
|
10
|
+
|
|
11
|
+
export type ConnectionDeps = {
|
|
12
|
+
fetch: (url: string, init: { method: string }) => Promise<{ ok: boolean; status: number; json: () => Promise<unknown> }>;
|
|
13
|
+
/** Asks the store's catalog again (store.ts forgetCatalog). */
|
|
14
|
+
forgetCatalog: () => void;
|
|
15
|
+
/** The texts of a failure: the store's hourly limit (429) and any other. */
|
|
16
|
+
busy: string;
|
|
17
|
+
failed: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Sends one request. A failure throws an Error with the text for it; POST and DELETE that succeed make the catalog be read again. */
|
|
21
|
+
export async function callConnection(method: 'GET' | 'POST' | 'DELETE', deps: ConnectionDeps): Promise<StoreConnection> {
|
|
22
|
+
let res;
|
|
23
|
+
try {
|
|
24
|
+
res = await deps.fetch('/api/store/connection', { method });
|
|
25
|
+
} catch {
|
|
26
|
+
throw new Error(deps.failed);
|
|
27
|
+
}
|
|
28
|
+
if (!res.ok) throw new Error(res.status === 429 ? deps.busy : deps.failed);
|
|
29
|
+
let found: StoreConnection;
|
|
30
|
+
try {
|
|
31
|
+
found = (await res.json()) as StoreConnection;
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error(deps.failed);
|
|
34
|
+
}
|
|
35
|
+
if (method !== 'GET') deps.forgetCatalog();
|
|
36
|
+
return found;
|
|
37
|
+
}
|
package/worker/index.js
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
// (packages/editor/worker/session.js), and what the editor asks of GitHub with it (/api/github/*, packages/editor/worker/editor-github.js), the site's
|
|
15
15
|
// files through the cache in R2 (packages/editor/worker/files.js).
|
|
16
16
|
// - what the editor asks of the app store (/api/store/catalog and /api/store/files/<oid>, packages/editor/worker/store.js), with the
|
|
17
|
-
// site's key (MORPHEME_STORE_KEY
|
|
17
|
+
// site's key (MORPHEME_STORE_KEY, or the one from "Connect to Morpheme", /api/store/connection, packages/editor/worker/store-connection.js, in D1)
|
|
18
|
+
// that never leaves the Worker.
|
|
18
19
|
// - the addresses visitors came to that do not exist, for the editor (/api/not-found, packages/editor/worker/not-found.js).
|
|
19
20
|
// - the visitors' answers in the cookie banner, the consent log (/api/consent, packages/editor/worker/consent.js, in D1), and each hour
|
|
20
21
|
// those older than two years deleted.
|
|
@@ -36,6 +37,7 @@ import { handleNotFound, isVisit, json, recordNotFound } from './not-found.js';
|
|
|
36
37
|
import { forgetOldConsents, handleConsent } from './consent.js';
|
|
37
38
|
import { access, mayEdit } from './session.js';
|
|
38
39
|
import { handleGitHub } from './editor-github.js';
|
|
40
|
+
import { handleConnection } from './store-connection.js';
|
|
39
41
|
import { handleStore } from './store.js';
|
|
40
42
|
import { handleR2 } from './r2-status.js';
|
|
41
43
|
import { publishScheduled, SCHEDULED_LIST } from './publish.js';
|
|
@@ -149,6 +151,7 @@ export default {
|
|
|
149
151
|
if (url.pathname === '/api/consent') return handleConsent(request, env);
|
|
150
152
|
if (url.pathname === '/api/r2') return handleR2(request, env);
|
|
151
153
|
if (url.pathname.startsWith('/api/github/')) return handleGitHub(request, env, ctx);
|
|
154
|
+
if (url.pathname === '/api/store/connection') return handleConnection(request, env, ctx);
|
|
152
155
|
if (url.pathname.startsWith('/api/store/')) return handleStore(request, env, ctx);
|
|
153
156
|
if (/^\/api\/push(\/wait)?\/?$/.test(url.pathname)) return handlePush(request, env);
|
|
154
157
|
if (/^\/api\/cms(\/update)?\/?$/.test(url.pathname)) return handleCms(request, env, ctx);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// The key the site gets from "Connect to Morpheme": kept in D1 (the binding CONSENT), asked for and removed by the editor.
|
|
2
|
+
// Exposes handleConnection (/api/store/connection), storeKey(env), forgetKey(env) and STORE_URL; used by worker/index.js and worker/store.js.
|
|
3
|
+
// Depends on worker/session.js (sign-in and access), worker/not-found.js (json) and @site/config (the repository). The key never leaves the Worker.
|
|
4
|
+
// GET /api/store/connection { connected, by?, site?, since?, mayChange }; mayChange = the editor may save the site
|
|
5
|
+
// POST /api/store/connection asks the store for a key (POST <store>/store/connect) and keeps it
|
|
6
|
+
// DELETE /api/store/connection tells the store to drop the key (DELETE <store>/store/connect) and forgets it
|
|
7
|
+
import { config } from '@site/config';
|
|
8
|
+
import { json } from './not-found.js';
|
|
9
|
+
import { access, sessionToken } from './session.js';
|
|
10
|
+
|
|
11
|
+
// Lives here, not in store.js, so store.js can import storeKey from this file without a cycle; store.js exports it again.
|
|
12
|
+
export const STORE_URL = 'https://morpheme-appstore.larsadolfsen.workers.dev';
|
|
13
|
+
|
|
14
|
+
// The table, made the first time an isolate needs it for a database (D1 has no migrations on deploy), as in consent.js.
|
|
15
|
+
const ready = new WeakMap();
|
|
16
|
+
function table(db) {
|
|
17
|
+
if (!ready.has(db)) {
|
|
18
|
+
const made = db
|
|
19
|
+
.prepare('CREATE TABLE IF NOT EXISTS store_connection (site TEXT PRIMARY KEY, key TEXT NOT NULL, connected TEXT NOT NULL)')
|
|
20
|
+
.run()
|
|
21
|
+
.catch((e) => {
|
|
22
|
+
ready.delete(db);
|
|
23
|
+
throw e;
|
|
24
|
+
});
|
|
25
|
+
ready.set(db, made);
|
|
26
|
+
}
|
|
27
|
+
return ready.get(db);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The key the Worker asks the store with: the one set by hand (MORPHEME_STORE_KEY) first, else the one from the button; null without. */
|
|
31
|
+
export async function storeKey(env) {
|
|
32
|
+
if (env.MORPHEME_STORE_KEY) return { key: env.MORPHEME_STORE_KEY, by: 'hand' };
|
|
33
|
+
if (!env.CONSENT) return null;
|
|
34
|
+
await table(env.CONSENT);
|
|
35
|
+
const { results } = await env.CONSENT.prepare('SELECT site, key, connected FROM store_connection ORDER BY connected DESC LIMIT 1').all();
|
|
36
|
+
const row = results[0];
|
|
37
|
+
return row ? { key: row.key, by: 'button', site: row.site, since: row.connected } : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Forgets the key from the button (the store no longer knows it, or the owner disconnected). */
|
|
41
|
+
export async function forgetKey(env) {
|
|
42
|
+
if (!env.CONSENT) return;
|
|
43
|
+
await table(env.CONSENT);
|
|
44
|
+
await env.CONSENT.prepare('DELETE FROM store_connection').run();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The answer for GET (never the key). @param {Awaited<ReturnType<typeof storeKey>>} found @param {boolean} mayChange */
|
|
48
|
+
const state = (found, mayChange) =>
|
|
49
|
+
json(
|
|
50
|
+
!found
|
|
51
|
+
? { connected: false, mayChange }
|
|
52
|
+
: found.by === 'hand'
|
|
53
|
+
? { connected: true, by: 'hand', mayChange }
|
|
54
|
+
: { connected: true, by: 'button', site: found.site, since: found.since, mayChange },
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
/** @param {Request} request @param {Record<string, any>} env @param {unknown} _ctx @param {typeof fetch} [ask] fetch, for the store and GitHub */
|
|
58
|
+
export async function handleConnection(request, env, _ctx, ask = fetch) {
|
|
59
|
+
if (!sessionToken(request)) return json({ error: 'signed out' }, 401);
|
|
60
|
+
const may = await access(request, ask);
|
|
61
|
+
if (!may) return json({ error: 'signed out' }, 401);
|
|
62
|
+
if (!may.read) return json({ error: 'forbidden' }, 403);
|
|
63
|
+
const method = request.method;
|
|
64
|
+
if (method !== 'GET' && method !== 'POST' && method !== 'DELETE') return json({ error: 'method' }, 405);
|
|
65
|
+
if (method === 'GET') return state(await storeKey(env), may.push);
|
|
66
|
+
if (!may.push) return json({ error: 'forbidden' }, 403);
|
|
67
|
+
|
|
68
|
+
const base = env.MORPHEME_STORE_URL ?? STORE_URL;
|
|
69
|
+
const found = await storeKey(env);
|
|
70
|
+
if (method === 'POST') {
|
|
71
|
+
if (found) return json({ error: 'connected' }, 409);
|
|
72
|
+
if (!env.CONSENT) return json({ error: 'no database' }, 503);
|
|
73
|
+
let res;
|
|
74
|
+
try {
|
|
75
|
+
res = await ask(`${base}/store/connect`, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'content-type': 'application/json' },
|
|
78
|
+
body: JSON.stringify({ address: new URL(request.url).origin, repo: `${config.repo.owner}/${config.repo.name}` }),
|
|
79
|
+
});
|
|
80
|
+
} catch {
|
|
81
|
+
return json({ error: 'store failed' }, 502);
|
|
82
|
+
}
|
|
83
|
+
if (res.status === 429) return json({ error: 'busy' }, 429);
|
|
84
|
+
if (res.status !== 201) return json({ error: 'store failed' }, 502);
|
|
85
|
+
const given = await res.json().catch(() => null);
|
|
86
|
+
if (typeof given?.site !== 'string' || typeof given?.key !== 'string' || !given.key) return json({ error: 'store failed' }, 502);
|
|
87
|
+
await table(env.CONSENT);
|
|
88
|
+
await env.CONSENT.prepare('INSERT OR REPLACE INTO store_connection (site, key, connected) VALUES (?, ?, ?)')
|
|
89
|
+
.bind(given.site, given.key, new Date().toISOString())
|
|
90
|
+
.run();
|
|
91
|
+
return state(await storeKey(env), true);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// DELETE
|
|
95
|
+
if (found?.by === 'hand') return json({ error: 'set by hand' }, 409);
|
|
96
|
+
if (!found) return json({ connected: false, mayChange: true });
|
|
97
|
+
let res;
|
|
98
|
+
try {
|
|
99
|
+
res = await ask(`${base}/store/connect`, { method: 'DELETE', headers: { authorization: `Bearer ${found.key}` } });
|
|
100
|
+
} catch {
|
|
101
|
+
return json({ error: 'store failed' }, 502);
|
|
102
|
+
}
|
|
103
|
+
if (res.status !== 200 && res.status !== 401) return json({ error: 'store failed' }, 502);
|
|
104
|
+
await forgetKey(env);
|
|
105
|
+
return json({ connected: false, mayChange: true });
|
|
106
|
+
}
|
package/worker/store.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
// What the editor asks of the store, through the Worker (the store's key never leaves it): the catalog and the files in it.
|
|
2
2
|
// Exposes handleStore(request, env, ctx, ask) and STORE_URL; used by packages/editor/worker/index.js (/api/store/*).
|
|
3
|
-
// Depends on packages/editor/worker/session.js (sign-in and access) and packages/editor/lib/contracts.mjs (APP_API, THEME_API).
|
|
4
|
-
// GET /api/store/catalog the store's catalog for this site's contracts; { connected: false } without
|
|
3
|
+
// Depends on packages/editor/worker/session.js (sign-in and access), packages/editor/worker/store-connection.js (the key) and packages/editor/lib/contracts.mjs (APP_API, THEME_API).
|
|
4
|
+
// GET /api/store/catalog the store's catalog for this site's contracts; { connected: false } without a key
|
|
5
5
|
// GET /api/store/files/<oid> a file of the store by its git id, streamed through with its content-type and cache-control
|
|
6
|
+
// The key is MORPHEME_STORE_KEY or the one from "Connect to Morpheme"; a key from the button that the store refuses is forgotten.
|
|
6
7
|
import { APP_API, THEME_API } from '../lib/contracts.mjs';
|
|
7
8
|
import { access, sessionToken } from './session.js';
|
|
8
9
|
import { json } from './not-found.js';
|
|
10
|
+
import { forgetKey, storeKey } from './store-connection.js';
|
|
9
11
|
|
|
10
|
-
export
|
|
12
|
+
export { STORE_URL } from './store-connection.js';
|
|
13
|
+
import { STORE_URL } from './store-connection.js';
|
|
11
14
|
|
|
12
15
|
const OID = /^[0-9a-f]{40}$/;
|
|
13
16
|
|
|
@@ -22,17 +25,22 @@ export async function handleStore(request, env, _ctx, ask = fetch) {
|
|
|
22
25
|
const isCatalog = route === 'catalog';
|
|
23
26
|
const oid = route.startsWith('files/') ? route.slice('files/'.length) : null;
|
|
24
27
|
if (!isCatalog && !(oid !== null && OID.test(oid))) return json({ error: 'not found' }, 404);
|
|
25
|
-
|
|
28
|
+
const found = await storeKey(env);
|
|
29
|
+
if (!found) return isCatalog ? json({ connected: false }) : json({ error: 'not found' }, 404);
|
|
26
30
|
|
|
27
31
|
const base = env.MORPHEME_STORE_URL ?? STORE_URL;
|
|
28
32
|
const url = isCatalog ? `${base}/store/catalog?app=${APP_API}&theme=${THEME_API}` : `${base}/store/files/${oid}`;
|
|
29
33
|
let res;
|
|
30
34
|
try {
|
|
31
|
-
res = await ask(url, { headers: { authorization: `Bearer ${
|
|
35
|
+
res = await ask(url, { headers: { authorization: `Bearer ${found.key}` } });
|
|
32
36
|
} catch {
|
|
33
37
|
return json({ error: 'store failed' }, 502);
|
|
34
38
|
}
|
|
35
|
-
if (res.status === 401 || res.status === 403)
|
|
39
|
+
if (res.status === 401 || res.status === 403) {
|
|
40
|
+
if (found.by !== 'button') return json({ error: 'store refused' }, 502);
|
|
41
|
+
await forgetKey(env);
|
|
42
|
+
return isCatalog ? json({ connected: false }) : json({ error: 'not found' }, 404);
|
|
43
|
+
}
|
|
36
44
|
if (!isCatalog && res.status === 404) return json({ error: 'not found' }, 404);
|
|
37
45
|
if (res.status !== 200) return json({ error: 'store failed' }, 502);
|
|
38
46
|
if (isCatalog) {
|
package/lib/apps.test.d.mts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/lib/changelog.test.d.mts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/lib/lockfile.test.d.mts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/lib/range.test.d.mts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/src/cmsView.test.d.mts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|