@kanf/wema-kake 0.0.0-stage → 0.1.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.
Files changed (84) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +123 -2
  3. package/bin/wema-kake.mjs +220 -0
  4. package/dist/lib/index.d.ts +13 -0
  5. package/dist/lib/index.js +14 -0
  6. package/dist/lib/shared/api.d.ts +97 -0
  7. package/dist/lib/shared/api.js +23 -0
  8. package/dist/lib/shared/delta.d.ts +24 -0
  9. package/dist/lib/shared/delta.js +19 -0
  10. package/dist/lib/shared/guide.d.ts +32 -0
  11. package/dist/lib/shared/guide.js +259 -0
  12. package/dist/lib/shared/hierarchy.d.ts +31 -0
  13. package/dist/lib/shared/hierarchy.js +40 -0
  14. package/dist/lib/shared/i18n.d.ts +23 -0
  15. package/dist/lib/shared/i18n.js +58 -0
  16. package/dist/lib/shared/note-html.d.ts +10 -0
  17. package/dist/lib/shared/note-html.js +28 -0
  18. package/dist/lib/shared/note-info.d.ts +20 -0
  19. package/dist/lib/shared/note-info.js +29 -0
  20. package/dist/lib/shared/note-text.d.ts +11 -0
  21. package/dist/lib/shared/note-text.js +118 -0
  22. package/dist/lib/shared/op-summary.d.ts +30 -0
  23. package/dist/lib/shared/op-summary.js +37 -0
  24. package/dist/lib/shared/protocol.d.ts +74 -0
  25. package/dist/lib/shared/protocol.js +12 -0
  26. package/dist/lib/shared/site.d.ts +16 -0
  27. package/dist/lib/shared/site.js +28 -0
  28. package/dist/lib/shared/slug.d.ts +4 -0
  29. package/dist/lib/shared/slug.js +6 -0
  30. package/dist/lib/shared/tools.d.ts +67 -0
  31. package/dist/lib/shared/tools.js +353 -0
  32. package/dist/lib/worker/access.d.ts +34 -0
  33. package/dist/lib/worker/access.js +114 -0
  34. package/dist/lib/worker/apply-ops.d.ts +75 -0
  35. package/dist/lib/worker/apply-ops.js +335 -0
  36. package/dist/lib/worker/assets.d.ts +4 -0
  37. package/dist/lib/worker/assets.js +35 -0
  38. package/dist/lib/worker/config.d.ts +52 -0
  39. package/dist/lib/worker/config.js +23 -0
  40. package/dist/lib/worker/first-page.d.ts +10 -0
  41. package/dist/lib/worker/first-page.js +30 -0
  42. package/dist/lib/worker/images.d.ts +3 -0
  43. package/dist/lib/worker/images.js +54 -0
  44. package/dist/lib/worker/index.d.ts +8 -0
  45. package/dist/lib/worker/index.js +72 -0
  46. package/dist/lib/worker/indexer.d.ts +42 -0
  47. package/dist/lib/worker/indexer.js +93 -0
  48. package/dist/lib/worker/mcp/board-access.d.ts +28 -0
  49. package/dist/lib/worker/mcp/board-access.js +67 -0
  50. package/dist/lib/worker/mcp/server.d.ts +9 -0
  51. package/dist/lib/worker/mcp/server.js +39 -0
  52. package/dist/lib/worker/page-do.d.ts +330 -0
  53. package/dist/lib/worker/page-do.js +1168 -0
  54. package/dist/lib/worker/page-queries.d.ts +28 -0
  55. package/dist/lib/worker/page-queries.js +88 -0
  56. package/dist/lib/worker/pages-api.d.ts +5 -0
  57. package/dist/lib/worker/pages-api.js +257 -0
  58. package/dist/lib/worker/plain-text.d.ts +22 -0
  59. package/dist/lib/worker/plain-text.js +113 -0
  60. package/dist/lib/worker/revert.d.ts +21 -0
  61. package/dist/lib/worker/revert.js +126 -0
  62. package/dist/lib/worker/sanitize.d.ts +4 -0
  63. package/dist/lib/worker/sanitize.js +135 -0
  64. package/dist/lib/worker/validate.d.ts +21 -0
  65. package/dist/lib/worker/validate.js +218 -0
  66. package/dist/web/assets/index-CVwMafiH.js +5 -0
  67. package/dist/web/assets/index-Cgi5orcJ.css +1 -0
  68. package/dist/web/favicon.svg +13 -0
  69. package/dist/web/index.html +14 -0
  70. package/migrations/0001_init.sql +18 -0
  71. package/migrations/0002_fts.sql +9 -0
  72. package/migrations/0003_hierarchy.sql +9 -0
  73. package/migrations/0004_settings.sql +12 -0
  74. package/migrations/0005_bookmarks.sql +8 -0
  75. package/migrations/0006_page_color.sql +5 -0
  76. package/package.json +75 -4
  77. package/template/.dev.vars.example +4 -0
  78. package/template/README.md +55 -0
  79. package/template/gitignore +8 -0
  80. package/template/package.json +25 -0
  81. package/template/src/config.ts +18 -0
  82. package/template/src/index.ts +9 -0
  83. package/template/tsconfig.json +16 -0
  84. package/template/wrangler.jsonc +57 -0
@@ -0,0 +1,34 @@
1
+ import type { ResolvedConfig, WemaKakeEnv } from './config';
2
+ /**
3
+ * 入口が受け付ける主体の種類。
4
+ * - user: Access にログインしたブラウザ(画面からの API、WebSocket、画像)
5
+ * - agent: Access の Managed OAuth で認可された MCP のクライアント(`/mcp`)
6
+ */
7
+ export type PrincipalKind = 'user' | 'agent';
8
+ export type AuthEnv = {
9
+ Bindings: WemaKakeEnv;
10
+ Variables: {
11
+ /** 利用側が `createWemaKake` に渡した設定(既定の値を埋めたもの) */
12
+ config: ResolvedConfig;
13
+ /** `Principal` の `actor` */
14
+ actor: string;
15
+ /** `Principal` の `expiresAt` */
16
+ authExpiresAt?: number;
17
+ };
18
+ };
19
+ /**
20
+ * agent の主体を、認可のリダイレクト先から決める。リダイレクト先は、Access の設定
21
+ * (Allowed redirect URIs)で許可したものに限られるので、クライアントが申告する名前と違って偽れない。
22
+ * `https://claude.ai/api/mcp/auth_callback` なら `agent:claude.ai`。
23
+ *
24
+ * 手元のクライアント(リダイレクト先が localhost やループバックのアドレス)は、どれも
25
+ * `agent:localhost` になり、互いを区別しない。ホスト名のない独自スキーム(`com.example.app:/cb`)は、
26
+ * スキームを名前にする
27
+ */
28
+ export declare function agentActor(redirectUri: unknown): string;
29
+ /**
30
+ * `kind` の主体だけを通すミドルウェア。**入口(ルート)ごとに、どちらの種類かを宣言して掛ける。**
31
+ * パスの文字列を見て種類を決める形にしない(ルーターはパスをデコードして照合するので、
32
+ * `/%6dcp` のようなパスで、判定とルーティングが食い違う)
33
+ */
34
+ export declare const requireAccess: (kind: PrincipalKind) => import("hono").MiddlewareHandler<AuthEnv, string, {}, Response>;
@@ -0,0 +1,114 @@
1
+ import { createMiddleware } from 'hono/factory';
2
+ import { createRemoteJWKSet, jwksCache, jwtVerify } from 'jose';
3
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
4
+ /**
5
+ * ローカル開発のホスト名か。`.localhost` で終わる名前も含める(ローカルのリバースプロキシが
6
+ * 付ける `<名前>.localhost`)。`.localhost` はループバック専用の予約名(RFC 6761)なので、
7
+ * デプロイ先のホスト名がこれに当たることはない
8
+ */
9
+ const isLocalHost = (hostname) => LOCAL_HOSTS.has(hostname) || hostname.endsWith('.localhost');
10
+ /**
11
+ * 取得済みの公開鍵(データ)を、鍵の取得元の URL ごとに isolate 内で使い回す。
12
+ * createRemoteJWKSet そのものはリクエストごとに作る。使い回すと取得中の Promise が
13
+ * リクエスト間で共有され、Workers では別のリクエストが作った Promise を待つと止まることがある。
14
+ * 鍵の期限、ローテーション時の取り直し、取り直しの間隔の制限は jose が行う。
15
+ */
16
+ const keyCaches = new Map();
17
+ function remoteKeys(certsUrl) {
18
+ let cache = keyCaches.get(certsUrl.href);
19
+ if (!cache) {
20
+ cache = {};
21
+ keyCaches.set(certsUrl.href, cache);
22
+ }
23
+ return createRemoteJWKSet(certsUrl, { [jwksCache]: cache });
24
+ }
25
+ /**
26
+ * agent の主体を、認可のリダイレクト先から決める。リダイレクト先は、Access の設定
27
+ * (Allowed redirect URIs)で許可したものに限られるので、クライアントが申告する名前と違って偽れない。
28
+ * `https://claude.ai/api/mcp/auth_callback` なら `agent:claude.ai`。
29
+ *
30
+ * 手元のクライアント(リダイレクト先が localhost やループバックのアドレス)は、どれも
31
+ * `agent:localhost` になり、互いを区別しない。ホスト名のない独自スキーム(`com.example.app:/cb`)は、
32
+ * スキームを名前にする
33
+ */
34
+ export function agentActor(redirectUri) {
35
+ let host = '';
36
+ try {
37
+ const url = new URL(typeof redirectUri === 'string' ? redirectUri : '');
38
+ host = LOCAL_HOSTS.has(url.hostname) ? 'localhost' : url.hostname || url.protocol;
39
+ }
40
+ catch {
41
+ // 下で unknown にする
42
+ }
43
+ const name = host.toLowerCase().replace(/[^a-z0-9.-]+/g, '-');
44
+ return `agent:${name.replace(/^[-.]+|[-.]+$/g, '').slice(0, 64) || 'unknown'}`;
45
+ }
46
+ /**
47
+ * Access が付けた JWT を検証し、主体とその種類を返す。検証できなければ null。
48
+ *
49
+ * MCP のクライアントは、Access の Managed OAuth で認可される。そのトークンで届いたリクエストの
50
+ * JWT には `oauth`(`client_id` と `client_redirect_uri`)が入り、ブラウザのログインでは入らない。
51
+ * `oauth` があれば、形がどうであれ agent として扱う(人の入口を通さない)
52
+ */
53
+ async function verifyAccessJwt(token, teamDomain, aud) {
54
+ try {
55
+ const keys = remoteKeys(new URL('/cdn-cgi/access/certs', teamDomain));
56
+ const { payload } = await jwtVerify(token, keys, {
57
+ issuer: teamDomain,
58
+ audience: aud,
59
+ requiredClaims: ['exp'],
60
+ });
61
+ if (typeof payload.email !== 'string' || payload.email === '')
62
+ return null;
63
+ const { email, exp: expiresAt, oauth } = payload;
64
+ if (oauth === undefined)
65
+ return { kind: 'user', actor: `user:${email}`, expiresAt };
66
+ const redirectUri = oauth?.client_redirect_uri;
67
+ return { kind: 'agent', actor: agentActor(redirectUri), expiresAt };
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ /**
74
+ * リクエストの主体を決める。画面からのリクエストも MCP も、ここを通す。
75
+ *
76
+ * Cloudflare Access の JWT(`Cf-Access-Jwt-Assertion`)を検証する。画面も `/mcp` も Access の
77
+ * アプリケーションで保護する前提だが、Access の設定を誤っても未認証のリクエストを通さないよう、
78
+ * Worker でも検証する。
79
+ *
80
+ * Access はパスを区別しないので、OAuth のトークンでも `/api` などを呼べてしまう。`kind` と
81
+ * 合わない主体は、ここで断る。
82
+ *
83
+ * `ACCESS_TEAM_DOMAIN` と `ACCESS_AUD` が未設定のときは、ローカル開発に限り `DEV_USER_EMAIL` を
84
+ * 使う。どちらもなければ拒否する。
85
+ */
86
+ async function authenticate(request, env, kind) {
87
+ const { ACCESS_TEAM_DOMAIN: teamDomain, ACCESS_AUD: aud, DEV_USER_EMAIL: devEmail } = env;
88
+ if (teamDomain && aud) {
89
+ const token = request.headers.get('Cf-Access-Jwt-Assertion');
90
+ const verified = token ? await verifyAccessJwt(token, teamDomain, aud) : null;
91
+ if (!verified)
92
+ return { status: 401, error: 'unauthorized' };
93
+ if (verified.kind !== kind)
94
+ return { status: 403, error: 'forbidden' };
95
+ return { principal: verified };
96
+ }
97
+ if (devEmail && isLocalHost(new URL(request.url).hostname)) {
98
+ return { principal: { actor: kind === 'user' ? `user:${devEmail}` : 'agent:local' } };
99
+ }
100
+ return { status: 500, error: 'authentication is not configured' };
101
+ }
102
+ /**
103
+ * `kind` の主体だけを通すミドルウェア。**入口(ルート)ごとに、どちらの種類かを宣言して掛ける。**
104
+ * パスの文字列を見て種類を決める形にしない(ルーターはパスをデコードして照合するので、
105
+ * `/%6dcp` のようなパスで、判定とルーティングが食い違う)
106
+ */
107
+ export const requireAccess = (kind) => createMiddleware(async (c, next) => {
108
+ const result = await authenticate(c.req.raw, c.env, kind);
109
+ if ('error' in result)
110
+ return c.json({ error: result.error }, result.status);
111
+ c.set('actor', result.principal.actor);
112
+ c.set('authExpiresAt', result.principal.expiresAt);
113
+ await next();
114
+ });
@@ -0,0 +1,75 @@
1
+ import type { BoardData, HistoryDelta, WemaEdge, WemaNote } from '../shared/delta';
2
+ import type { NoteInfos } from '../shared/note-info';
3
+ import type { BoardState } from '../shared/tools';
4
+ import { type Obj } from './validate';
5
+ /**
6
+ * 真偽値のフィールドを、すべて未設定(false)にした値。「未設定」と false が同じ意味で、
7
+ * 列には 0 / 1 で保存する。読んだ付箋には true のものしか入らないので、付箋の内容に合わせる
8
+ * デルタを作るときは、これを下に敷く
9
+ */
10
+ export declare const UNSET_FLAGS: {
11
+ autoSize: false;
12
+ foldable: false;
13
+ };
14
+ /**
15
+ * 付箋のフィールドの値が同じか。autoSize と foldable は「未設定」と false が同じ意味。
16
+ * meta は全体を比べる(キーがないことと、空は同じ)
17
+ */
18
+ export declare function sameNoteValue(key: string, a: unknown, b: unknown): boolean;
19
+ /** このページに置いてある子ページ(子ページの付箋が指すページ) */
20
+ export declare function childPages(sql: SqlStorage): Set<string>;
21
+ /** 子ページの付箋の、id、色、指している子ページ(本文は読まない) */
22
+ export declare function childNoteColors(sql: SqlStorage): {
23
+ id: string;
24
+ color: string;
25
+ page: string;
26
+ }[];
27
+ export declare function exists(sql: SqlStorage, table: 'notes' | 'edges', id: string): boolean;
28
+ export declare function readNote(sql: SqlStorage, id: string): WemaNote | undefined;
29
+ /**
30
+ * 付箋の、指定したフィールドだけを読む(`keys` のうち更新できるフィールド以外は無視する)。
31
+ * 移動だけの更新で大きな text を読まないようにするためのもの。autoSize と foldable は真偽値で返す。
32
+ */
33
+ export declare function readNoteFields(sql: SqlStorage, id: string, keys: string[]): Obj | undefined;
34
+ export declare function readEdge(sql: SqlStorage, id: string): WemaEdge | undefined;
35
+ /** 付箋だけを読む(接続線は読まない) */
36
+ export declare const readNotes: (sql: SqlStorage) => WemaNote[];
37
+ /** ボードと、付箋ごとの付加情報(作成者、更新日時)。画面に渡す */
38
+ export declare function readBoardWithInfos(sql: SqlStorage): {
39
+ data: BoardData;
40
+ info: NoteInfos;
41
+ };
42
+ /** 付箋(作成者つき)と接続線。agent に渡す */
43
+ export declare function readBoardWithAuthors(sql: SqlStorage): Pick<BoardState, 'notes' | 'edges'>;
44
+ export declare const readBoard: (sql: SqlStorage) => BoardData;
45
+ export interface Sanitized {
46
+ /** サニタイズで text が変わった付箋について、送信元の手元の状態を直すためのデルタ */
47
+ fixups: HistoryDelta[];
48
+ /** text を更新するデルタ → サニタイズした `before.text`(競合の判定に使う) */
49
+ cleanBefore: Map<HistoryDelta, string>;
50
+ }
51
+ /**
52
+ * デルタ中の text をサニタイズする(`note.text` と `after.text` を書き換える)。
53
+ * `cleanBefore: false` なら `before.text` のサニタイズを省く(競合の判定に使わない場合)。
54
+ */
55
+ export declare function sanitizeDeltas(deltas: HistoryDelta[], options?: {
56
+ cleanBefore: boolean;
57
+ }): Promise<Sanitized>;
58
+ export interface Applied {
59
+ /** 実際に適用したデルタ。before と削除対象はサーバーの保存値で置き換えてある */
60
+ deltas: HistoryDelta[];
61
+ /** 送信元が送っていないのにサーバーが足したデルタ(付箋の削除に伴う接続線の削除) */
62
+ fixups: HistoryDelta[];
63
+ }
64
+ /**
65
+ * デルタを順に適用する。`transactionSync` の中で呼ぶこと(RejectError で全体を巻き戻す)。
66
+ *
67
+ * - 作成: id が使用済み、または接続線の両端がなければ拒否する
68
+ * - 更新と削除: 対象がすでになければ、そのデルタを捨てる(他の人が先に消した場合)
69
+ * - text の更新: `before.text` が現在値と違えば拒否する。`before.text` はそのままの値か、
70
+ * サニタイズした値(`cleanBefore`)のどちらかが一致すればよい。前者は、サニタイズの規則を
71
+ * 変える前に保存した text を編集できるようにするため。後者は、送信元がサニタイズ前の
72
+ * text を手元に持ったまま続けて編集した場合のため
73
+ * - 更新で `after` にキーがなく `before` にあるものは、未設定に戻す
74
+ */
75
+ export declare function applyDeltas(sql: SqlStorage, deltas: HistoryDelta[], actor: string, now: number, cleanBefore?: Map<HistoryDelta, string>): Applied;
@@ -0,0 +1,335 @@
1
+ import { CHILD_PAGE_KEY, childPageOf, childRejection } from '../shared/hierarchy';
2
+ import { REASON_TEXT_CONFLICT } from '../shared/protocol';
3
+ import { sanitizeHtml } from './sanitize';
4
+ import { RejectError, REQUIRED_EDGE_FIELDS } from './validate';
5
+ /** WemaNote のフィールド名 → notes の列名(更新できるもの) */
6
+ const NOTE_COLUMNS = {
7
+ x: 'x',
8
+ y: 'y',
9
+ width: 'width',
10
+ height: 'height',
11
+ text: 'text',
12
+ color: 'color',
13
+ autoSize: 'auto_size',
14
+ foldable: 'foldable',
15
+ // meta は JSON の文字列で保存する。空なら NULL
16
+ meta: 'extra',
17
+ // wema が付箋にフィールドを足したら、ここで型エラーになる(知らないフィールドを黙って捨てないため)
18
+ };
19
+ /**
20
+ * 真偽値のフィールドを、すべて未設定(false)にした値。「未設定」と false が同じ意味で、
21
+ * 列には 0 / 1 で保存する。読んだ付箋には true のものしか入らないので、付箋の内容に合わせる
22
+ * デルタを作るときは、これを下に敷く
23
+ */
24
+ export const UNSET_FLAGS = { autoSize: false, foldable: false };
25
+ const FLAG_FIELDS = Object.keys(UNSET_FLAGS);
26
+ /** meta を、保存する形(JSON の文字列。空なら null)にする。キーは検証のときに並べ替えてある */
27
+ function metaJson(meta) {
28
+ return meta && Object.keys(meta).length > 0 ? JSON.stringify(meta) : null;
29
+ }
30
+ /**
31
+ * 付箋のフィールドの値が同じか。autoSize と foldable は「未設定」と false が同じ意味。
32
+ * meta は全体を比べる(キーがないことと、空は同じ)
33
+ */
34
+ export function sameNoteValue(key, a, b) {
35
+ if (FLAG_FIELDS.includes(key))
36
+ return (a === true) === (b === true);
37
+ if (key === 'meta')
38
+ return metaJson(a) === metaJson(b);
39
+ return a === b;
40
+ }
41
+ /** 保存した列の値を、付箋のフィールドの値にする */
42
+ function fromColumn(key, value) {
43
+ if (FLAG_FIELDS.includes(key))
44
+ return Boolean(value);
45
+ if (key === 'meta')
46
+ return value === null ? undefined : JSON.parse(value);
47
+ return value;
48
+ }
49
+ /** 付箋のフィールドの値を、保存する列の値にする */
50
+ function toColumn(key, value) {
51
+ if (FLAG_FIELDS.includes(key))
52
+ return value === true ? 1 : 0;
53
+ if (key === 'meta')
54
+ return metaJson(value);
55
+ return value;
56
+ }
57
+ function rowToNote(row) {
58
+ const note = {
59
+ id: row.id,
60
+ x: row.x,
61
+ y: row.y,
62
+ width: row.width,
63
+ height: row.height,
64
+ text: row.text,
65
+ color: row.color,
66
+ zIndex: row.z_index,
67
+ };
68
+ if (row.auto_size)
69
+ note.autoSize = true;
70
+ if (row.foldable)
71
+ note.foldable = true;
72
+ if (row.extra !== null)
73
+ note.meta = JSON.parse(row.extra);
74
+ return note;
75
+ }
76
+ /** このページに置いてある子ページ(子ページの付箋が指すページ) */
77
+ export function childPages(sql) {
78
+ const rows = sql
79
+ .exec(`SELECT json_extract(extra, '$.${CHILD_PAGE_KEY}') AS page FROM notes WHERE extra IS NOT NULL`)
80
+ .toArray();
81
+ return new Set(rows.map((row) => row.page).filter((page) => typeof page === 'string'));
82
+ }
83
+ /** 子ページの付箋の、id、色、指している子ページ(本文は読まない) */
84
+ export function childNoteColors(sql) {
85
+ return sql
86
+ .exec(`SELECT id, color, json_extract(extra, '$.${CHILD_PAGE_KEY}') AS page FROM notes WHERE extra IS NOT NULL`)
87
+ .toArray()
88
+ .filter((row) => typeof row.page === 'string');
89
+ }
90
+ /** `page` を指す子ページの付箋が、`exceptId` 以外にあるか */
91
+ function hasChildNote(sql, page, exceptId) {
92
+ return (sql
93
+ .exec(`SELECT 1 FROM notes WHERE json_extract(extra, '$.${CHILD_PAGE_KEY}') = ? AND id <> ? LIMIT 1`, page, exceptId)
94
+ .toArray().length > 0);
95
+ }
96
+ /** 同じ子ページを、1 つのページに 2 つ置くことはできない */
97
+ function assertNotPlaced(sql, note, noteId) {
98
+ const page = childPageOf(note);
99
+ if (page !== undefined && hasChildNote(sql, page, noteId)) {
100
+ throw new RejectError(childRejection('duplicate', page));
101
+ }
102
+ }
103
+ function rowToEdge(row) {
104
+ return {
105
+ ...JSON.parse(row.props),
106
+ id: row.id,
107
+ from: row.from_id,
108
+ to: row.to_id,
109
+ };
110
+ }
111
+ function edgeProps(edge) {
112
+ const { id: _id, from: _from, to: _to, ...props } = edge;
113
+ return JSON.stringify(props);
114
+ }
115
+ export function exists(sql, table, id) {
116
+ return sql.exec(`SELECT 1 FROM ${table} WHERE id = ?`, id).toArray().length > 0;
117
+ }
118
+ export function readNote(sql, id) {
119
+ const row = sql.exec(`SELECT * FROM notes WHERE id = ?`, id).toArray()[0];
120
+ return row && rowToNote(row);
121
+ }
122
+ /**
123
+ * 付箋の、指定したフィールドだけを読む(`keys` のうち更新できるフィールド以外は無視する)。
124
+ * 移動だけの更新で大きな text を読まないようにするためのもの。autoSize と foldable は真偽値で返す。
125
+ */
126
+ export function readNoteFields(sql, id, keys) {
127
+ const fields = keys.filter((k) => k in NOTE_COLUMNS);
128
+ const columns = fields.length > 0 ? fields.map((k) => NOTE_COLUMNS[k]).join(', ') : '1';
129
+ const row = sql.exec(`SELECT ${columns} FROM notes WHERE id = ?`, id).toArray()[0];
130
+ if (!row)
131
+ return undefined;
132
+ const out = {};
133
+ for (const k of fields) {
134
+ // 未設定のフィールド(meta)は、キーごと入れない
135
+ const value = fromColumn(k, row[NOTE_COLUMNS[k]]);
136
+ if (value !== undefined)
137
+ out[k] = value;
138
+ }
139
+ return out;
140
+ }
141
+ export function readEdge(sql, id) {
142
+ const row = sql.exec(`SELECT * FROM edges WHERE id = ?`, id).toArray()[0];
143
+ return row && rowToEdge(row);
144
+ }
145
+ const noteRows = (sql) => sql.exec(`SELECT * FROM notes ORDER BY rowid`).toArray();
146
+ /** 付箋だけを読む(接続線は読まない) */
147
+ export const readNotes = (sql) => noteRows(sql).map(rowToNote);
148
+ const readEdges = (sql) => sql.exec(`SELECT * FROM edges ORDER BY rowid`).toArray().map(rowToEdge);
149
+ /** ボードと、付箋ごとの付加情報(作成者、更新日時)。画面に渡す */
150
+ export function readBoardWithInfos(sql) {
151
+ const rows = noteRows(sql);
152
+ // 代入で組み立てない。`__proto__` という id の付箋が、表から落ちる
153
+ const info = Object.fromEntries(rows.map((row) => [
154
+ row.id,
155
+ { createdBy: row.created_by, updatedAt: row.updated_at },
156
+ ]));
157
+ return { data: { version: 1, notes: rows.map(rowToNote), edges: readEdges(sql) }, info };
158
+ }
159
+ /** 付箋(作成者つき)と接続線。agent に渡す */
160
+ export function readBoardWithAuthors(sql) {
161
+ return {
162
+ notes: noteRows(sql).map((row) => ({ ...rowToNote(row), createdBy: row.created_by })),
163
+ edges: readEdges(sql),
164
+ };
165
+ }
166
+ export const readBoard = (sql) => readBoardWithInfos(sql).data;
167
+ /**
168
+ * デルタ中の text をサニタイズする(`note.text` と `after.text` を書き換える)。
169
+ * `cleanBefore: false` なら `before.text` のサニタイズを省く(競合の判定に使わない場合)。
170
+ */
171
+ export async function sanitizeDeltas(deltas, options = { cleanBefore: true }) {
172
+ const fixups = [];
173
+ const cleanBefore = new Map();
174
+ const fix = (noteId, sent, clean) => {
175
+ // 送信元は after を適用するだけなので、送られてきた text は記録に残さない
176
+ if (sent !== clean) {
177
+ fixups.push({ type: 'note:update', noteId, before: {}, after: { text: clean } });
178
+ }
179
+ };
180
+ for (const d of deltas) {
181
+ if (d.type === 'note:create') {
182
+ const sent = d.note.text;
183
+ d.note.text = await sanitizeHtml(sent);
184
+ fix(d.note.id, sent, d.note.text);
185
+ }
186
+ else if (d.type === 'note:update' && d.after.text !== undefined) {
187
+ const sent = d.after.text;
188
+ d.after.text = await sanitizeHtml(sent);
189
+ fix(d.noteId, sent, d.after.text);
190
+ const before = d.before.text;
191
+ if (options.cleanBefore && before !== undefined) {
192
+ cleanBefore.set(d, before === sent ? d.after.text : await sanitizeHtml(before));
193
+ }
194
+ }
195
+ }
196
+ return { fixups, cleanBefore };
197
+ }
198
+ /**
199
+ * デルタを順に適用する。`transactionSync` の中で呼ぶこと(RejectError で全体を巻き戻す)。
200
+ *
201
+ * - 作成: id が使用済み、または接続線の両端がなければ拒否する
202
+ * - 更新と削除: 対象がすでになければ、そのデルタを捨てる(他の人が先に消した場合)
203
+ * - text の更新: `before.text` が現在値と違えば拒否する。`before.text` はそのままの値か、
204
+ * サニタイズした値(`cleanBefore`)のどちらかが一致すればよい。前者は、サニタイズの規則を
205
+ * 変える前に保存した text を編集できるようにするため。後者は、送信元がサニタイズ前の
206
+ * text を手元に持ったまま続けて編集した場合のため
207
+ * - 更新で `after` にキーがなく `before` にあるものは、未設定に戻す
208
+ */
209
+ export function applyDeltas(sql, deltas, actor, now, cleanBefore) {
210
+ const applied = [];
211
+ const fixups = [];
212
+ for (const d of deltas) {
213
+ switch (d.type) {
214
+ case 'note:create': {
215
+ const n = d.note;
216
+ if (exists(sql, 'notes', n.id))
217
+ throw new RejectError(`note already exists: ${n.id}`);
218
+ assertNotPlaced(sql, n, n.id);
219
+ sql.exec(`INSERT INTO notes (id, x, y, width, height, text, color, z_index, auto_size, foldable, extra,
220
+ created_by, updated_at, updated_by)
221
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, n.id, n.x, n.y, n.width, n.height, n.text, n.color, n.zIndex, n.autoSize ? 1 : 0, n.foldable ? 1 : 0, metaJson(n.meta), actor, now, actor);
222
+ applied.push(d);
223
+ break;
224
+ }
225
+ case 'note:update': {
226
+ const next = { ...d.after };
227
+ // autoSize と foldable は「未設定」と false が同じ意味
228
+ for (const flag of FLAG_FIELDS) {
229
+ if (flag in d.before || flag in next)
230
+ next[flag] = next[flag] === true;
231
+ }
232
+ // meta は全体を置き換える。after になく before にあれば、取り除く(空の meta にする)
233
+ if (!('meta' in next) && 'meta' in d.before)
234
+ next.meta = {};
235
+ const keys = Object.keys(NOTE_COLUMNS).filter((k) => next[k] !== undefined);
236
+ if (keys.length === 0)
237
+ break;
238
+ const cur = readNoteFields(sql, d.noteId, keys);
239
+ if (!cur)
240
+ break;
241
+ if (next.text !== undefined &&
242
+ d.before.text !== cur.text &&
243
+ cleanBefore?.get(d) !== cur.text) {
244
+ throw new RejectError(REASON_TEXT_CONFLICT, { notes: [readNote(sql, d.noteId)], edges: [] });
245
+ }
246
+ const changed = keys.filter((k) => !sameNoteValue(k, next[k], cur[k]));
247
+ if (changed.length === 0)
248
+ break;
249
+ if (changed.includes('meta'))
250
+ assertNotPlaced(sql, next, d.noteId);
251
+ sql.exec(`UPDATE notes SET ${changed.map((k) => `${NOTE_COLUMNS[k]} = ?`).join(', ')},
252
+ updated_at = ?, updated_by = ?
253
+ WHERE id = ?`, ...changed.map((k) => toColumn(k, next[k])), now, actor, d.noteId);
254
+ // 未設定の値(取り除いた meta、もともとなかった meta)は、キーごと入れない
255
+ const fieldsOf = (values) => Object.fromEntries(changed.filter((k) => toColumn(k, values[k]) !== null).map((k) => [k, values[k]]));
256
+ applied.push({ type: 'note:update', noteId: d.noteId, before: fieldsOf(cur), after: fieldsOf(next) });
257
+ break;
258
+ }
259
+ case 'note:delete': {
260
+ const cur = readNote(sql, d.note.id);
261
+ if (!cur)
262
+ break;
263
+ // wema は接続線の削除を先に送ってくる。残っているのは他の人が同時に足したものか、
264
+ // 接続線を指定しない呼び出し(MCP)なので、ここで削除のデルタを作って記録に残す
265
+ const rest = sql
266
+ .exec(`SELECT * FROM edges WHERE from_id = ? OR to_id = ? ORDER BY rowid`, cur.id, cur.id)
267
+ .toArray()
268
+ .map(rowToEdge);
269
+ for (const edge of rest) {
270
+ const del = { type: 'edge:delete', edge };
271
+ applied.push(del);
272
+ fixups.push(del);
273
+ }
274
+ if (rest.length > 0) {
275
+ sql.exec(`DELETE FROM edges WHERE from_id = ? OR to_id = ?`, cur.id, cur.id);
276
+ }
277
+ sql.exec(`DELETE FROM notes WHERE id = ?`, cur.id);
278
+ applied.push({ type: 'note:delete', note: cur });
279
+ break;
280
+ }
281
+ case 'edge:create': {
282
+ const e = d.edge;
283
+ if (exists(sql, 'edges', e.id))
284
+ throw new RejectError(`edge already exists: ${e.id}`);
285
+ if (!exists(sql, 'notes', e.from) || !exists(sql, 'notes', e.to)) {
286
+ throw new RejectError(`edge endpoint not found: ${e.id}`);
287
+ }
288
+ sql.exec(`INSERT INTO edges (id, from_id, to_id, props, created_by, updated_at)
289
+ VALUES (?, ?, ?, ?, ?, ?)`, e.id, e.from, e.to, edgeProps({ ...e }), actor, now);
290
+ applied.push(d);
291
+ break;
292
+ }
293
+ case 'edge:update': {
294
+ const cur = readEdge(sql, d.edgeId);
295
+ if (!cur)
296
+ break;
297
+ const next = { ...cur };
298
+ const patch = d.after;
299
+ const before = {};
300
+ const after = {};
301
+ for (const key of new Set([...Object.keys(d.before), ...Object.keys(patch)])) {
302
+ const value = patch[key];
303
+ if (value === undefined && REQUIRED_EDGE_FIELDS.includes(key))
304
+ continue;
305
+ const prev = next[key];
306
+ if (value === prev)
307
+ continue;
308
+ if (prev !== undefined)
309
+ before[key] = prev;
310
+ if (value === undefined) {
311
+ delete next[key];
312
+ }
313
+ else {
314
+ after[key] = value;
315
+ next[key] = value;
316
+ }
317
+ }
318
+ if (Object.keys(before).length === 0 && Object.keys(after).length === 0)
319
+ break;
320
+ sql.exec(`UPDATE edges SET props = ?, updated_at = ? WHERE id = ?`, edgeProps(next), now, d.edgeId);
321
+ applied.push({ type: 'edge:update', edgeId: d.edgeId, before, after });
322
+ break;
323
+ }
324
+ case 'edge:delete': {
325
+ const cur = readEdge(sql, d.edge.id);
326
+ if (!cur)
327
+ break;
328
+ sql.exec(`DELETE FROM edges WHERE id = ?`, cur.id);
329
+ applied.push({ type: 'edge:delete', edge: cur });
330
+ break;
331
+ }
332
+ }
333
+ }
334
+ return { deltas: applied, fixups };
335
+ }
@@ -0,0 +1,4 @@
1
+ import { type SiteConfig } from '../shared/site';
2
+ import type { WemaKakeEnv } from './config';
3
+ /** アセットを返す関数を作る。設定は変わらないので、埋め込む `<meta>` はここで 1 回だけ作る */
4
+ export declare function assetServer(site: SiteConfig): (request: Request, env: WemaKakeEnv) => Promise<Response>;
@@ -0,0 +1,35 @@
1
+ // 画面のアセット(ビルド済みの dist/web)を返す。`run_worker_first: true` なので、API に当たらない
2
+ // リクエストもすべて Worker に届き、ここから ASSETS バインディングへ渡す。
3
+ //
4
+ // HTML(index.html。SPA なので、/p/<slug> などにもこれが返る)には、サイトの設定を `<meta>` で
5
+ // 埋め込み、`<title>` をサイトの名前にする(src/shared/site.ts)。
6
+ import { SITE_META_NAME } from '../shared/site';
7
+ const escapeAttr = (value) => value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
8
+ /** アセットを返す関数を作る。設定は変わらないので、埋め込む `<meta>` はここで 1 回だけ作る */
9
+ export function assetServer(site) {
10
+ // 画面に要る項目だけを出す(設定のほかの項目を、画面に見せない)
11
+ const content = { siteName: site.siteName, defaultLang: site.defaultLang };
12
+ const meta = `<meta name="${SITE_META_NAME}" content="${escapeAttr(JSON.stringify(content))}">`;
13
+ const title = { element: (el) => void el.setInnerContent(site.siteName) };
14
+ const head = { element: (el) => void el.prepend(meta, { html: true }) };
15
+ return async (request, env) => {
16
+ // ページの読み込み(HTML を取りに来るリクエスト)は、条件付きの取得にしない。アセットの ETag は
17
+ // ファイルの中身だけで決まるので、設定だけを変えてデプロイしたときに、304 で古い設定の HTML が
18
+ // 使われ続ける。index.html は小さいので、毎回送る
19
+ let forward = request;
20
+ if (request.headers.get('Accept')?.includes('text/html')) {
21
+ const headers = new Headers(request.headers);
22
+ headers.delete('If-None-Match');
23
+ headers.delete('If-Modified-Since');
24
+ forward = new Request(request, { headers });
25
+ }
26
+ const res = await env.ASSETS.fetch(forward);
27
+ if (!res.headers.get('Content-Type')?.startsWith('text/html'))
28
+ return res;
29
+ const headers = new Headers(res.headers);
30
+ // 書き換えた本文は、元の ETag と中身が合わない
31
+ headers.delete('ETag');
32
+ headers.delete('Content-Length');
33
+ return new HTMLRewriter().on('title', title).on('head', head).transform(new Response(res.body, { status: res.status, statusText: res.statusText, headers }));
34
+ };
35
+ }
@@ -0,0 +1,52 @@
1
+ import { type Lang } from '../shared/i18n';
2
+ import { type SiteConfig } from '../shared/site';
3
+ import type { PageDO } from './page-do';
4
+ /** `createWemaKake` に渡す設定。どの項目も省ける */
5
+ export interface WemaKakeConfig {
6
+ /** 画面に出すサイトの名前(一覧の見出しと、タブの表示名)。既定は `wema-kake` */
7
+ siteName?: string;
8
+ /**
9
+ * 既定の言語。その端末で選んだ言語(Cookie)も、ブラウザの言語の設定も、対応する言語に
10
+ * 当たらないときに使う。既定は `en`
11
+ */
12
+ defaultLang?: Lang;
13
+ /** リモート MCP サーバー(`/mcp`)を有効にするか。`false` なら `/mcp` は 404 を返す。既定は `true` */
14
+ mcp?: boolean;
15
+ /**
16
+ * ページが 1 つもない環境で、一覧を最初に開いたときに、使い方の付箋を置いたページを作るか。
17
+ * 既定は `true`
18
+ */
19
+ firstPage?: boolean;
20
+ }
21
+ /** 既定の値を埋めた設定 */
22
+ export type ResolvedConfig = SiteConfig & {
23
+ mcp: boolean;
24
+ firstPage: boolean;
25
+ };
26
+ /**
27
+ * 既定の値を埋める。不正な値は、Worker の起動時(モジュールの読み込み時)に例外にする。
28
+ * リクエストを受けてから気付く形にしない
29
+ */
30
+ export declare function resolveConfig(config?: WemaKakeConfig): ResolvedConfig;
31
+ /**
32
+ * Worker が受け取るバインディングと変数。**名前は固定**で、利用側の `wrangler.jsonc` に
33
+ * この名前で書く(template/wrangler.jsonc)
34
+ */
35
+ export interface WemaKakeEnv {
36
+ /** ページごとの Durable Object。クラスは、利用側の入口が再 export する `PageDO` */
37
+ PAGE: DurableObjectNamespace<PageDO>;
38
+ /** 横断索引(ページの一覧、全文検索、リンク)。スキーマはパッケージの `migrations/` */
39
+ DB: D1Database;
40
+ /** 付箋に貼った画像 */
41
+ IMAGES: R2Bucket;
42
+ /** 画面のビルド済みのアセット(`assets.binding`) */
43
+ ASSETS: Fetcher;
44
+ /** Cloudflare Access のチームドメイン(`https://<team>.cloudflareaccess.com`) */
45
+ ACCESS_TEAM_DOMAIN?: string;
46
+ /** Access アプリケーションの AUD タグ */
47
+ ACCESS_AUD?: string;
48
+ /** このサイトのオリジン。付箋内の絶対 URL のリンクを、ページ間リンクとして拾うのに使う */
49
+ SITE_ORIGIN?: string;
50
+ /** ローカル開発で Access の代わりに使う email。ローカルのホスト名へのリクエストでだけ有効 */
51
+ DEV_USER_EMAIL?: string;
52
+ }