@microtoll/mcp 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.
@@ -0,0 +1,39 @@
1
+ # __NAME__
2
+
3
+ An end-to-end-encrypted notes app on [Microtoll Engine](https://microtoll.dev),
4
+ scaffolded by `@microtoll/mcp`. It is the engine's notes example with your
5
+ namespace and origin filled in: read `notes.js` (the whole model, about two
6
+ hundred lines) before changing anything.
7
+
8
+ ## Run it
9
+
10
+ ```sh
11
+ npm install # fetches the @microtoll packages this Compose file mounts
12
+ docker compose up
13
+ ```
14
+
15
+ Then open __ORIGIN__. Sign up with a recovery code, write a note, share it
16
+ by link, open the link in a private window as a second person.
17
+
18
+ ## What is where
19
+
20
+ - `notes.js` — the model: list (the shelf query), create, update, share,
21
+ open, join, members, remove (rotation), heal, delete, live watch.
22
+ - `page.js`, `index.html` — the page. The packages load from `node_modules`
23
+ through an import map: no build step.
24
+ - `docker-compose.yml`, `nginx.conf` — Postgres with the engine schema, the
25
+ blind-store reference server (namespace `__NAMESPACE__`, collection
26
+ `notes` with a two-character selector), nginx serving this directory and
27
+ forwarding `/ws`.
28
+
29
+ ## Before you change it
30
+
31
+ - **The namespace is one string used twice**: `__NAMESPACE__` in `page.js`
32
+ (`createCryptoCore`) and in `docker-compose.yml`
33
+ (`BLIND_STORE_NAMESPACE`). Sign-in is bound to it and to the page's origin.
34
+ - **Do not add cryptography.** Everything needed is in the packages, with
35
+ its threat model. If something seems to need a new primitive, ask a person.
36
+ - **Say what is not protected.** Copy the engine's honest limits
37
+ (<https://microtoll.dev/honest-limits.html>) into your own about page.
38
+ - **For a deployment**, use the engine's `deploy/` kit: secret files, TLS at
39
+ the proxy, the server as its own database role, no access log.
@@ -0,0 +1,65 @@
1
+ # Scaffolded by @microtoll/mcp: Postgres with the engine schema, the
2
+ # blind-store reference server from the published package, and nginx serving
3
+ # this directory and forwarding /ws. Run `npm install` first (it fetches the
4
+ # packages this Compose file mounts), then `docker compose up` and open
5
+ # __ORIGIN__.
6
+ #
7
+ # LOCAL ONLY as written: fixed database passwords, the database reachable
8
+ # only inside this network. For a deployment, see the engine's deploy/ kit.
9
+ name: __NAME__
10
+
11
+ services:
12
+ db:
13
+ image: postgres:17
14
+ environment:
15
+ - TZ=Etc/UTC
16
+ - PGTZ=UTC
17
+ - POSTGRES_USER=app_owner
18
+ - POSTGRES_DB=app
19
+ - POSTGRES_PASSWORD=${DB_OWNER_PASSWORD:-local-only-owner}
20
+ - BLIND_STORE_APP_PASSWORD=${DB_APP_PASSWORD:-local-only-app}
21
+ volumes:
22
+ - ./node_modules/@microtoll/blind-store/schema:/docker-entrypoint-initdb.d:ro
23
+ healthcheck:
24
+ test: ["CMD-SHELL", "pg_isready -U app_owner -d app"]
25
+ interval: 5s
26
+ timeout: 5s
27
+ retries: 20
28
+
29
+ blind-store:
30
+ image: node:24-alpine
31
+ working_dir: /app
32
+ command: ["node", "node_modules/@microtoll/blind-store/bin/blind-store.mjs"]
33
+ user: node
34
+ read_only: true
35
+ tmpfs:
36
+ - /tmp
37
+ cap_drop:
38
+ - ALL
39
+ security_opt:
40
+ - no-new-privileges:true
41
+ volumes:
42
+ - ./:/app:ro
43
+ environment:
44
+ - BLIND_STORE_NAMESPACE=__NAMESPACE__
45
+ - BLIND_STORE_ALLOWED_ORIGINS=__ORIGIN__
46
+ - BLIND_STORE_COLLECTIONS={"notes":{"selectorLength":2}}
47
+ - BLIND_STORE_PORT=8020
48
+ - DB_HOST=db
49
+ - DB_PORT=5432
50
+ - DB_USER=blind_store_app
51
+ - DB_NAME=app
52
+ - DB_PASSWORD=${DB_APP_PASSWORD:-local-only-app}
53
+ depends_on:
54
+ db:
55
+ condition: service_healthy
56
+
57
+ web:
58
+ image: nginx:1.30-alpine
59
+ ports:
60
+ - "__PORT__:80"
61
+ volumes:
62
+ - ./nginx.conf:/etc/nginx/nginx.conf:ro
63
+ - ./:/usr/share/nginx/html:ro
64
+ depends_on:
65
+ - blind-store
@@ -0,0 +1,73 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>__NAME__</title>
7
+ <style>
8
+ :root { color-scheme: light dark; font-family: system-ui, sans-serif; }
9
+ body { max-width: 46rem; margin: 2rem auto; padding: 0 1rem; line-height: 1.45; }
10
+ h1 { font-size: 1.4rem; }
11
+ fieldset { border: 1px solid #8884; border-radius: 6px; margin: 1rem 0; padding: 0.75rem 1rem; }
12
+ legend { font-weight: 600; }
13
+ button { margin: 0.2rem 0.3rem 0.2rem 0; padding: 0.35rem 0.6rem; }
14
+ input, textarea { padding: 0.35rem; width: 100%; box-sizing: border-box; font-family: ui-monospace, monospace; }
15
+ textarea { min-height: 8rem; }
16
+ pre { background: #8881; padding: 0.6rem; border-radius: 6px; white-space: pre-wrap; word-break: break-all; font-size: 0.85rem; }
17
+ li { margin: 0.3rem 0; }
18
+ .note { color: #888; font-size: 0.85rem; }
19
+ </style>
20
+ <script type="importmap">
21
+ { "imports": {
22
+ "@microtoll/crypto-core": "/node_modules/@microtoll/crypto-core/src/index.js",
23
+ "@microtoll/identity": "/node_modules/@microtoll/identity/src/index.js",
24
+ "@microtoll/access": "/node_modules/@microtoll/access/src/index.js"
25
+ } }
26
+ </script>
27
+ </head>
28
+ <body>
29
+ <h1>__NAME__</h1>
30
+ <p>End-to-end-encrypted notes on Microtoll Engine. The server holds sealed
31
+ blobs, hashed capabilities and the shelf each note is filed on; it cannot
32
+ read a note or say who owns one. Read <code>notes.js</code> for the model,
33
+ and put the engine's honest limits in your own about page.</p>
34
+
35
+ <fieldset><legend>1. Start</legend>
36
+ <button id="boot">Boot</button>
37
+ <div id="signed-out">
38
+ <button id="signup">Sign up with a recovery code</button>
39
+ <button id="signup-passkey">Sign up with a passkey + recovery code</button>
40
+ <p class="note">Or unlock an account you made before:</p>
41
+ <input id="code" placeholder="recovery code">
42
+ <button id="unlock">Unlock</button>
43
+ </div>
44
+ <p id="recovery"></p>
45
+ <div id="signed-in" hidden><button id="lock">Lock</button> <button id="delete-account">Delete account</button></div>
46
+ </fieldset>
47
+
48
+ <fieldset><legend>2. Your notes</legend>
49
+ <input id="new-title" placeholder="a title"> <button id="new">New note</button>
50
+ <ul id="notes"></ul>
51
+ <div id="editor" hidden>
52
+ <input id="note-id" type="hidden">
53
+ <input id="title" placeholder="title">
54
+ <textarea id="body" placeholder="body"></textarea>
55
+ <button id="save">Save</button>
56
+ </div>
57
+ </fieldset>
58
+
59
+ <fieldset><legend>3. Share and open</legend>
60
+ <input id="share-url" placeholder="the share link appears here" readonly>
61
+ <input id="link" placeholder="paste a share link (or its token) here">
62
+ <button id="open-link">Open link</button>
63
+ <div id="members-panel" hidden>
64
+ <p><b>Members</b> (the owner can remove one: every key rotates)</p>
65
+ <ul id="members"></ul>
66
+ </div>
67
+ </fieldset>
68
+
69
+ <fieldset><legend>Log</legend><pre id="log"></pre></fieldset>
70
+
71
+ <script type="module" src="./page.js"></script>
72
+ </body>
73
+ </html>
@@ -0,0 +1,58 @@
1
+ # Scaffolded by @microtoll/mcp: the page and the packages (from
2
+ # node_modules) as static files, /ws forwarded to blind-store, the client's
3
+ # address blanked, no access log. Plain HTTP for localhost; a deployment
4
+ # terminates TLS in front (see the engine's deploy/nginx.sample.conf).
5
+ worker_processes 1;
6
+ events { worker_connections 256; }
7
+
8
+ http {
9
+ include mime.types;
10
+ default_type application/octet-stream;
11
+ types { text/javascript js mjs; }
12
+ server_tokens off;
13
+ access_log off;
14
+ error_log /dev/stderr warn;
15
+
16
+ map $http_upgrade $connection_upgrade {
17
+ default upgrade;
18
+ '' close;
19
+ }
20
+
21
+ server {
22
+ listen 80;
23
+ root /usr/share/nginx/html;
24
+
25
+ add_header X-Content-Type-Options "nosniff" always;
26
+ add_header Referrer-Policy "no-referrer" always;
27
+ add_header Cache-Control "no-store" always;
28
+ add_header Content-Security-Policy "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' __WS_ORIGIN__; img-src 'self' data:" always;
29
+
30
+ location /ws {
31
+ proxy_pass http://blind-store:8020;
32
+ proxy_http_version 1.1;
33
+ proxy_set_header Upgrade $http_upgrade;
34
+ proxy_set_header Connection $connection_upgrade;
35
+ proxy_set_header X-Forwarded-For "";
36
+ proxy_set_header X-Real-IP "";
37
+ proxy_set_header Forwarded "";
38
+ proxy_read_timeout 300s;
39
+ }
40
+
41
+ location = /healthz {
42
+ proxy_pass http://blind-store:8020/healthz;
43
+ proxy_http_version 1.1;
44
+ }
45
+
46
+ # Only the app's files and the packages' sources are served; nothing else in node_modules.
47
+ location ~ ^/node_modules/@microtoll/[a-z-]+/src/ { try_files $uri =404; }
48
+ location /node_modules/ { return 404; }
49
+
50
+ location / {
51
+ try_files $uri $uri/ =404;
52
+ add_header X-Content-Type-Options "nosniff" always;
53
+ add_header Referrer-Policy "no-referrer" always;
54
+ add_header Cache-Control "no-store" always;
55
+ add_header Content-Security-Policy "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' __WS_ORIGIN__; img-src 'self' data:" always;
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,214 @@
1
+ // The notes app, without a page: everything it does with the packages, so
2
+ // the same code runs in the browser (page.js) and in Node (test/). Read
3
+ // this file first; it is the whole model.
4
+ //
5
+ // A note is an object in the `notes` collection. Its content {title, body}
6
+ // is sealed under K_object; its owner holds the admin capability; a reader
7
+ // who opens a share link gets K_object and a read-only pointer, and becomes
8
+ // a member by "joining" (the protocol calls it a first reaction). The owner
9
+ // can remove a member: every key rotates and the removed key opens nothing
10
+ // written afterwards.
11
+ //
12
+ // THE SHELF is the coarse public selector. When a note is created the app
13
+ // picks one of 256 shelves at random and keeps it in the note's pointer
14
+ // (the access package's pointer extension). To list its notes the app asks
15
+ // the server for every note on the shelves it uses and decrypts only its
16
+ // own. What the server learns: which shelves this routing key reads. What
17
+ // it cannot learn: which notes on a shelf are this account's. Cover is only
18
+ // as deep as the crowd on a shelf -- the same honesty any coarse selector
19
+ // owes its users.
20
+ import { createAccess, asIs } from '@microtoll/access';
21
+ import * as ac from '@microtoll/access';
22
+
23
+ export const COLLECTION = 'notes';
24
+ export const SHELVES = 256;
25
+
26
+ /** One of 256 shelves, at random: two hex characters. */
27
+ export function pickShelf(cc) {
28
+ return cc.randomBytes(1)[0].toString(16).padStart(2, '0');
29
+ }
30
+
31
+ /**
32
+ * createNotes({ cryptoCore, session })
33
+ * session an identity session (createIdentitySession) that is unlocked:
34
+ * its `state()` gives the identity and the open socket, and
35
+ * `ensureConnected()` reconnects.
36
+ */
37
+ export function createNotes({ cryptoCore: cc, session }) {
38
+ const access = createAccess({ cryptoCore: cc, pointerFields: { shelf: { wire: 'shelf', ...asIs } } });
39
+ const me = () => { const s = session.state(); if (s.locked || !s.identity) throw new Error('unlock first'); return s.identity; };
40
+ const ws = () => session.ensureConnected();
41
+ const selectorOf = (shelf) => ({ collection: COLLECTION, selector: shelf });
42
+
43
+ /** Every pointer this account holds, opened: objectId -> { pointerId, p }. */
44
+ async function myPointers() {
45
+ const identity = me();
46
+ const out = new Map();
47
+ for (const { pointerId, sealed } of await access.fetchPointers(await ws())) {
48
+ try {
49
+ const p = await access.pointerCodec.open(identity, sealed);
50
+ out.set(p.objectId, { pointerId, p });
51
+ } catch { /* not this app's pointer, or not this account's: skipped */ }
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /** The content a note's holder can read, or null when the held key no longer opens it (removed after a rotation). */
57
+ async function openNote(objectId, kObjectRaw, fetched) {
58
+ try {
59
+ const key = await cc.importSymmetricKey(kObjectRaw);
60
+ const content = await ac.openContent(cc, key, objectId, fetched.keyEpoch, fetched.encryptedContent);
61
+ return content;
62
+ } catch { return null; }
63
+ }
64
+
65
+ /**
66
+ * The notes this account can read: the shelves from its pointers, one
67
+ * query for all of them, and only the rows it holds keys for opened. A
68
+ * row that will not open with the held key is reported `stale` (the note
69
+ * was re-keyed; heal() fetches the member's own row for the new key).
70
+ */
71
+ async function list() {
72
+ const pointers = await myPointers();
73
+ const shelves = [...new Set([...pointers.values()].map(({ p }) => p.shelf).filter(Boolean))];
74
+ if (shelves.length === 0) return [];
75
+ const { objects } = await access.queryObjects(await ws(), { collection: COLLECTION, selectors: shelves });
76
+ const notes = [];
77
+ for (const o of objects) {
78
+ const held = pointers.get(o.objectId);
79
+ if (!held) continue; // cover traffic: someone else's note on a shared shelf
80
+ const content = await openNote(o.objectId, held.p.kObject, o);
81
+ notes.push({
82
+ id: o.objectId, shelf: o.fields.selector, keyEpoch: o.keyEpoch,
83
+ title: content ? content.title : null, body: content ? content.body : null,
84
+ mine: Boolean(held.p.adminCapabilitySecret), member: Boolean(held.p.rowCapabilitySecret),
85
+ stale: !content || held.p.keyEpoch !== o.keyEpoch,
86
+ });
87
+ }
88
+ // Pointers whose note no longer exists are dropped here, on the next use.
89
+ const seen = new Set(objects.map((o) => o.objectId));
90
+ for (const [objectId, { pointerId }] of pointers) if (!seen.has(objectId)) await access.deletePointer(await ws(), pointerId).catch(() => {});
91
+ return notes;
92
+ }
93
+
94
+ async function create({ title, body }) {
95
+ const identity = me();
96
+ const shelf = pickShelf(cc);
97
+ const c = await access.createObject({ identity, content: { title, body }, ownerRowContent: { role: 'owner' } });
98
+ // The pointer carries the shelf so the app knows where to ask for this note.
99
+ c.pointer = await access.pointerCodec.build(identity, {
100
+ objectId: c.objectId, kObject: c.kObjectRaw, keyEpoch: 1, rowCapabilitySecret: c.ownerRow.rowCapabilitySecret, adminCapabilitySecret: c.adminCapabilitySecret, shelf,
101
+ });
102
+ await access.createObjectMessage(await ws(), c, selectorOf(shelf));
103
+ return { id: c.objectId, shelf };
104
+ }
105
+
106
+ /** The owner edits: the content is re-sealed under the current key at the current epoch. */
107
+ async function update(id, { title, body }) {
108
+ const held = (await myPointers()).get(id);
109
+ if (!held || !held.p.adminCapabilitySecret) throw new Error('not your note');
110
+ const fetched = await access.fetchObject(await ws(), id);
111
+ const key = await cc.importSymmetricKey(held.p.kObject);
112
+ const current = await ac.openContent(cc, key, id, fetched.keyEpoch, fetched.encryptedContent);
113
+ const sealed = await ac.sealContent(cc, key, id, fetched.keyEpoch, { ...current, title, body });
114
+ await access.updateObject(await ws(), { objectId: id, adminCapabilitySecret: held.p.adminCapabilitySecret, keyEpoch: fetched.keyEpoch, encryptedContent: sealed, selector: selectorOf(held.p.shelf) });
115
+ }
116
+
117
+ /** A share link: the token stays in the URL fragment and never reaches the server. Returns the fragment to append to the page's address. */
118
+ async function share(id, { maxUses = 10, days = 7 } = {}) {
119
+ const held = (await myPointers()).get(id);
120
+ if (!held || !held.p.adminCapabilitySecret) throw new Error('not your note');
121
+ const link = await access.createShareLink({ objectId: id, kObjectRaw: held.p.kObject, maxUses, expiresAt: new Date(Date.now() + days * 86400000).toISOString(), creator: me(), keyEpoch: held.p.keyEpoch });
122
+ await access.publishShareLink(await ws(), link);
123
+ return { fragment: `#note=${link.token}`, token: link.token };
124
+ }
125
+
126
+ /** Opening a link: redeem it, fetch the note, keep a read-only pointer with the shelf, read it. */
127
+ async function open(token) {
128
+ const identity = me();
129
+ const socket = await ws();
130
+ const payload = await access.redeemShareLinkMessage(socket, await access.hashToken(token));
131
+ const redeemed = await access.redeemShareLink(token, payload);
132
+ const fetched = await access.fetchObject(socket, redeemed.objectId);
133
+ const pointer = await access.buildReadOnlyPointer({ identity, objectId: redeemed.objectId, kObjectRaw: redeemed.kObjectRaw, keyEpoch: fetched.keyEpoch, extra: { shelf: fetched.fields.selector } });
134
+ await access.joinObject(socket, { objectId: redeemed.objectId, pointer });
135
+ const content = await openNote(redeemed.objectId, redeemed.kObjectRaw, fetched);
136
+ return { id: redeemed.objectId, title: content && content.title, body: content && content.body, verifiedCreator: redeemed.verified === true };
137
+ }
138
+
139
+ /** Becoming a member of a note held read-only: the first reaction, which gives this account a signed row the owner can see and remove. */
140
+ async function join(id, { name }) {
141
+ const identity = me();
142
+ const held = (await myPointers()).get(id);
143
+ if (!held) throw new Error('no pointer for this note');
144
+ const first = await access.buildFirstReaction({ identity, objectId: id, kObjectRaw: held.p.kObject, keyEpoch: held.p.keyEpoch, content: { name }, existingPointer: held.p });
145
+ await access.createMemberRow(await ws(), { objectId: id, row: first.row, pointerId: held.pointerId, pointer: first.pointer, readCapabilitySecret: first.readCapabilitySecret });
146
+ }
147
+
148
+ /** Who holds a row on this note, with the display rule applied (verified rows show their name; forged ones show nothing). */
149
+ async function members(id) {
150
+ const held = (await myPointers()).get(id);
151
+ if (!held) throw new Error('no pointer for this note');
152
+ const auth = held.p.adminCapabilitySecret ? { adminCapabilitySecret: held.p.adminCapabilitySecret } : { readCapabilitySecret: await access.readCapability(held.p.kObject) };
153
+ const rows = await access.fetchMembers(await ws(), id, auth);
154
+ const roster = access.roster(await access.openRows({ kObjectRaw: held.p.kObject, objectId: id, rows }));
155
+ return roster.map((e) => ({ rowId: e.id, name: e.content ? (e.content.name || e.content.role || null) : null, verified: e.verified, quiet: e.quiet }));
156
+ }
157
+
158
+ /** The owner removes a member: a new key for everyone else in one checked transaction; the owner's own pointer moves to the new key. */
159
+ async function remove(id, rowId) {
160
+ const identity = me();
161
+ const held = (await myPointers()).get(id);
162
+ if (!held || !held.p.adminCapabilitySecret) throw new Error('not your note');
163
+ const socket = await ws();
164
+ const fetched = await access.fetchObject(socket, id);
165
+ const key = await cc.importSymmetricKey(held.p.kObject);
166
+ const content = await ac.openContent(cc, key, id, fetched.keyEpoch, fetched.encryptedContent);
167
+ const rows = await access.fetchMembers(socket, id, { adminCapabilitySecret: held.p.adminCapabilitySecret });
168
+ const plan = await access.buildRotationPlan({ objectId: id, identity, oldKObjectRaw: held.p.kObject, oldEpoch: fetched.keyEpoch, content, rows, remove: [rowId], selfRowId: null });
169
+ const newEpoch = await access.rotateObjectKey(socket, id, held.p.adminCapabilitySecret, plan);
170
+ const pointer = await access.pointerCodec.build(identity, { ...held.p, kObject: plan.newKObjectRaw, keyEpoch: newEpoch, adminCapabilitySecret: plan.adminCapabilitySecret });
171
+ await access.updatePointer(socket, held.pointerId, pointer);
172
+ return newEpoch;
173
+ }
174
+
175
+ /** A member whose note was re-keyed (and who was kept) picks up the new key from their own row. */
176
+ async function heal(id) {
177
+ const identity = me();
178
+ const held = (await myPointers()).get(id);
179
+ if (!held || !held.p.rowCapabilitySecret) throw new Error('no member row to heal from');
180
+ const socket = await ws();
181
+ const myRow = await access.fetchMyRow(socket, id, held.p.rowCapabilitySecret);
182
+ const refreshed = await access.refreshPointerAfterRotation({ identity, objectId: id, myRow, existingPointer: held.p });
183
+ await access.updatePointer(socket, held.pointerId, refreshed.pointer);
184
+ return refreshed.kObjectRaw !== null;
185
+ }
186
+
187
+ async function destroy(id) {
188
+ const held = (await myPointers()).get(id);
189
+ if (!held || !held.p.adminCapabilitySecret) throw new Error('not your note');
190
+ const socket = await ws();
191
+ await access.deleteObject(socket, id, held.p.adminCapabilitySecret);
192
+ await access.deletePointer(socket, held.pointerId);
193
+ }
194
+
195
+ /** Live: re-run `onChange` when any note on this account's shelves changes. Returns a stop function. */
196
+ async function watch(onChange) {
197
+ const pointers = await myPointers();
198
+ const shelves = [...new Set([...pointers.values()].map(({ p }) => p.shelf).filter(Boolean))];
199
+ const socket = await ws();
200
+ if (shelves.length) await access.watchObjects(socket, { collection: COLLECTION, selectors: shelves });
201
+ return access.onLiveObject(socket, () => onChange());
202
+ }
203
+
204
+ /** The pointer this account holds for a note, opened: { pointerId, p } or null. (The invite example builds on it.) */
205
+ async function held(id) { return (await myPointers()).get(id) || null; }
206
+
207
+ return { access, list, create, update, share, open, join, members, remove, heal, destroy, watch, held, pickShelf: () => pickShelf(cc) };
208
+ }
209
+
210
+ /** The token from a page address such as https://host/#note=<token>, or null. */
211
+ export function tokenFromLocation(hash) {
212
+ const m = /(?:^#|[#&])note=([A-Za-z0-9_-]+)/.exec(hash || '');
213
+ return m ? m[1] : null;
214
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "__NAME__",
3
+ "private": true,
4
+ "type": "module",
5
+ "description": "An end-to-end-encrypted notes app on Microtoll Engine, scaffolded by @microtoll/mcp.",
6
+ "engines": {
7
+ "node": ">=24"
8
+ },
9
+ "dependencies": {
10
+ "@microtoll/access": "^0.1.0",
11
+ "@microtoll/blind-store": "^0.1.0",
12
+ "@microtoll/crypto-core": "^0.1.0",
13
+ "@microtoll/identity": "^0.1.0"
14
+ }
15
+ }
@@ -0,0 +1,130 @@
1
+ // The page: the identity session wired to buttons, and notes.js wired to a
2
+ // list. Nothing here touches a key; the packages do, in this browser only.
3
+ import { createCryptoCore } from '@microtoll/crypto-core';
4
+ import { createIdentitySession, createSessionStore, createKnownAccountStore, createWebAuthn } from '@microtoll/identity';
5
+ import { createNotes, tokenFromLocation } from './notes.js';
6
+
7
+ const cc = createCryptoCore({ namespace: 'notes-example' });
8
+ const $ = (id) => document.getElementById(id);
9
+ const log = (line) => { $('log').textContent = `${new Date().toISOString().slice(11, 19)} ${line}\n` + $('log').textContent; };
10
+
11
+ // The socket: the same host as the page, at /ws (nginx forwards it).
12
+ function connect() {
13
+ return new Promise((resolve, reject) => {
14
+ const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`);
15
+ ws.addEventListener('open', () => resolve(ws), { once: true });
16
+ ws.addEventListener('error', () => reject(new Error('could not reach the server')), { once: true });
17
+ });
18
+ }
19
+
20
+ const session = createIdentitySession({
21
+ cryptoCore: cc,
22
+ origin: location.origin,
23
+ transport: { connect },
24
+ sessionStore: createSessionStore({ cryptoCore: cc }),
25
+ knownAccounts: createKnownAccountStore({ cryptoCore: cc }),
26
+ webauthn: createWebAuthn({ rpName: 'Microtoll notes' }),
27
+ ui: {
28
+ askRecoveryCode: async (reason) => prompt(`Recovery code needed to ${reason}`),
29
+ confirmDeletion: async () => confirm('Delete the account and every note it owns?'),
30
+ passkeyName: () => 'Microtoll notes',
31
+ },
32
+ hooks: {
33
+ afterUnlock: () => { log('unlocked'); refresh(); },
34
+ onLocked: () => { log('locked'); render([]); },
35
+ },
36
+ });
37
+ const notes = createNotes({ cryptoCore: cc, session });
38
+ let stopWatch = null;
39
+
40
+ function setSignedIn(on) {
41
+ $('signed-out').hidden = on;
42
+ $('signed-in').hidden = !on;
43
+ }
44
+
45
+ async function run(name, fn) {
46
+ try { const r = await fn(); log(`${name}: ok`); return r; }
47
+ catch (e) { log(`${name}: ${e.code || e.reason || 'error'} — ${e.message}`); }
48
+ }
49
+
50
+ function render(list) {
51
+ const ul = $('notes');
52
+ ul.innerHTML = '';
53
+ for (const n of list) {
54
+ const li = document.createElement('li');
55
+ const title = document.createElement('b');
56
+ title.textContent = n.stale ? '(re-keyed — press heal, or you were removed)' : (n.title || '(untitled)');
57
+ li.append(title, ' ', n.mine ? '· yours' : n.member ? '· member' : '· reader', ' ');
58
+ const b = (label, fn) => { const x = document.createElement('button'); x.textContent = label; x.onclick = fn; li.append(x); };
59
+ if (!n.stale) b('open', () => { $('editor').hidden = false; $('note-id').value = n.id; $('title').value = n.title || ''; $('body').value = n.body || ''; $('save').hidden = !n.mine; });
60
+ if (n.mine) {
61
+ b('share', () => run('share', async () => { const { fragment } = await notes.share(n.id); $('share-url').value = `${location.origin}${location.pathname}${fragment}`; }));
62
+ b('members', () => run('members', async () => showMembers(n.id)));
63
+ b('delete', () => run('delete', async () => { await notes.destroy(n.id); await refresh(); }));
64
+ } else if (!n.member && !n.stale) {
65
+ b('join as member', () => run('join', async () => { await notes.join(n.id, { name: prompt('Your name, as the owner will see it') || 'someone' }); await refresh(); }));
66
+ } else if (n.member && n.stale) {
67
+ b('heal', () => run('heal', async () => { const ok = await notes.heal(n.id); log(ok ? 'new key picked up' : 'no new key for you: you were removed'); await refresh(); }));
68
+ }
69
+ ul.append(li);
70
+ }
71
+ }
72
+
73
+ async function showMembers(id) {
74
+ const list = await notes.members(id);
75
+ const ul = $('members');
76
+ ul.innerHTML = '';
77
+ for (const m of list) {
78
+ const li = document.createElement('li');
79
+ li.textContent = `${m.name || '(unverified row: nothing shown)'} ${m.verified ? '✓' : ''} `;
80
+ if (m.name !== 'owner') {
81
+ const x = document.createElement('button'); x.textContent = 'remove';
82
+ x.onclick = () => run('remove', async () => { await notes.remove(id, m.rowId); await showMembers(id); await refresh(); });
83
+ li.append(x);
84
+ }
85
+ ul.append(li);
86
+ }
87
+ $('members-panel').hidden = false;
88
+ }
89
+
90
+ async function refresh() {
91
+ const s = session.state();
92
+ setSignedIn(!s.locked && s.hasAccount);
93
+ if (s.locked || !s.hasAccount) return;
94
+ const list = await run('list', () => notes.list());
95
+ render(list || []);
96
+ if (!stopWatch) stopWatch = await notes.watch(() => refresh()).catch(() => null);
97
+ }
98
+
99
+ $('boot').onclick = () => run('boot', async () => {
100
+ const r = await session.bootFromTrustedSession();
101
+ if (!r.restored) { await session.bootGuest(); log(`no trusted session (${r.reason}); started as a guest — sign up or unlock`); }
102
+ await refresh();
103
+ });
104
+ $('signup').onclick = () => run('sign up', async () => {
105
+ const { recoveryCode } = await session.registerCurrentIdentity({ label: 'this browser', passkey: 'none' });
106
+ $('recovery').textContent = `Your recovery code (shown once, keep it): ${recoveryCode}`;
107
+ await refresh();
108
+ });
109
+ $('signup-passkey').onclick = () => run('sign up with a passkey', async () => {
110
+ const { recoveryCode } = await session.registerCurrentIdentity({ label: 'this browser', passkey: 'platform' });
111
+ $('recovery').textContent = `Your recovery code (shown once, keep it): ${recoveryCode}`;
112
+ await refresh();
113
+ });
114
+ $('unlock').onclick = () => run('unlock', async () => { await session.unlockWithRecoveryCode($('code').value); await refresh(); });
115
+ $('lock').onclick = () => run('lock', () => session.lock());
116
+ $('new').onclick = () => run('create', async () => { await notes.create({ title: $('new-title').value || 'Untitled', body: '' }); $('new-title').value = ''; await refresh(); });
117
+ $('save').onclick = () => run('save', async () => { await notes.update($('note-id').value, { title: $('title').value, body: $('body').value }); await refresh(); });
118
+ $('open-link').onclick = () => run('open link', async () => {
119
+ const token = tokenFromLocation($('link').value.includes('#') ? $('link').value.slice($('link').value.indexOf('#')) : `#note=${$('link').value}`);
120
+ if (!token) throw new Error('no token in that address');
121
+ const note = await notes.open(token);
122
+ log(`opened "${note.title}" (creator ${note.verifiedCreator ? 'verified' : 'not verified'})`);
123
+ await refresh();
124
+ });
125
+ $('delete-account').onclick = () => run('delete account', async () => { await session.deleteAccount(); await refresh(); });
126
+
127
+ const token = tokenFromLocation(location.hash);
128
+ if (token) { $('link').value = location.href; log('a shared note is in this address: boot, sign up or unlock, then press "open link"'); }
129
+ setSignedIn(false);
130
+ log('ready — press Boot');
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@microtoll/mcp",
3
+ "version": "0.1.0",
4
+ "description": "Microtoll Engine inside your coding tool: docs search, page reading and a project scaffold over the Model Context Protocol (stdio). Zero dependencies, no network, nothing runs but what is in src/.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./src/index.js",
8
+ "types": "./types/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./types/index.d.ts",
12
+ "default": "./src/index.js"
13
+ }
14
+ },
15
+ "bin": {
16
+ "microtoll-mcp": "bin/microtoll-mcp.mjs"
17
+ },
18
+ "files": [
19
+ "src",
20
+ "bin",
21
+ "generated",
22
+ "types",
23
+ "README.md",
24
+ "CHANGELOG.md",
25
+ "LICENSE"
26
+ ],
27
+ "engines": {
28
+ "node": ">=24"
29
+ }
30
+ }