@morphemecms/cms 0.4.0 → 0.5.0-next-20261104215254

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/bin/morpheme.mjs +2 -2
  3. package/lib/licenses.mjs +1 -1
  4. package/package.json +6 -1
  5. package/scripts/built-site.mjs +24 -7
  6. package/scripts/check-store-connection.mjs +226 -0
  7. package/scripts/check-store.mjs +53 -3
  8. package/scripts/editor-screens.mjs +26 -0
  9. package/scripts/types-include.test.mjs +93 -0
  10. package/site/theme.d.mts +6 -0
  11. package/site/theme.mjs +30 -5
  12. package/site/theme.test.mjs +54 -0
  13. package/src/AppList.tsx +16 -2
  14. package/src/CmsVersion.tsx +14 -0
  15. package/src/Library.tsx +3 -2
  16. package/src/StartStatus.tsx +24 -0
  17. package/src/StoreConnectNotice.tsx +34 -0
  18. package/src/StoreConnectedRow.tsx +36 -0
  19. package/src/SwapText.tsx +22 -0
  20. package/src/allIcons.ts +3 -6
  21. package/src/cmsVersionView.d.mts +7 -0
  22. package/src/cmsVersionView.mjs +11 -0
  23. package/src/cmsVersionView.test.mjs +32 -0
  24. package/src/i18n/da-DK/apps.ts +7 -0
  25. package/src/i18n/da-DK/library.ts +2 -0
  26. package/src/i18n/en-US/apps.ts +8 -0
  27. package/src/i18n/en-US/library.ts +4 -0
  28. package/src/store.ts +5 -0
  29. package/src/storeConnection.ts +24 -0
  30. package/src/storeConnectionCall.test.mjs +57 -0
  31. package/src/storeConnectionCall.ts +37 -0
  32. package/src/virtual.d.ts +5 -0
  33. package/starter/package.json +5 -0
  34. package/theme/editor/looks.d.mts +2 -0
  35. package/worker/index.js +4 -1
  36. package/worker/store-connection.js +106 -0
  37. package/worker/store.js +14 -6
  38. package/lib/apps.test.d.mts +0 -1
  39. package/lib/caseCollisions.test.d.mts +0 -1
  40. package/lib/changelog.test.d.mts +0 -1
  41. package/lib/cms-version.test.d.mts +0 -1
  42. package/lib/lockfile.test.d.mts +0 -1
  43. package/lib/range.test.d.mts +0 -1
  44. package/lib/side-block.test.d.mts +0 -1
  45. package/lib/storeEntries.test.d.mts +0 -1
  46. package/site/storeTheme.test.d.mts +0 -1
  47. package/src/cmsView.test.d.mts +0 -1
  48. package/src/frontmatter.test.d.mts +0 -1
  49. package/src/standard/leads.test.d.mts +0 -1
@@ -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 MORPHEME_STORE_KEY
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 const STORE_URL = 'https://morpheme-appstore.larsadolfsen.workers.dev';
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
- if (!env.MORPHEME_STORE_KEY) return isCatalog ? json({ connected: false }) : json({ error: 'not found' }, 404);
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 ${env.MORPHEME_STORE_KEY}` } });
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) return json({ error: 'store refused' }, 502);
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) {
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};