OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface commits / c1500db

0.0.65: room page for ROOM-CONTRACT v2 — invitations, accept/reject, bind this device
A non-member gets the invitation box (no ask nobody can answer); a member's unbound device gets "use this device here" instead of a composer that would be dropped; moderators and admins get the invite form and accept/turn-down. A minted invitation is rendered in the POST response, never redirected, so the secret stays out of the location header and history. ?m= errors now show. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
c1500dbfbeaddd77cb566b94c56688597661a9c4
by plankatron <fleeky@gmail.com> · 2026-10-13T18:41:35.000Z

Changed files (4) +118 -51

lib/pages.js +45 -22
……@@ -1158,8 +1158,11 @@ export function rooms (list) {
11581158 * a composer to somebody whose posts apply would silently drop is the "it returned, so it worked"
11591159 * failure this stack refuses everywhere else.
11601160 */
1161export function room (key, r, profiles = null) {
1161export function room (key, r, profiles = null, { message = null, invitation = null } = {}) {
11621162 const role = r.role
1163 // An older daemon does not report `bound`; only an explicit false means this device cannot write.
1164 const unbound = r.bound === false
1165 const steward = role === 'admin' || role === 'moderator'
11631166 const title = r.config.title || 'untitled room'
11641167 const who_ = (root) => {
11651168 const p = profiles && Object.values(profiles).find((x) => x && x.root === root)
……@@ -1173,27 +1176,46 @@ export function room (key, r, profiles = null) {
11731176 render(m.body || '') + '</div>').join('') + '</div>'
11741177 : '<p class="who">Nothing said yet.</p>'
11751178
1176 const compose = role && role !== 'read-only'
1177 ? '<form method="post" action="/r/' + esc(key) + '/post" class="composer">' +
1178 '<textarea name="body" rows="2" placeholder="say something…"></textarea>' +
1179 '<div class="row"><span class="who">posting as ' + esc(role) + '</span>' +
1180 '<button class="post">send</button></div></form>'
1181 : (role === 'read-only'
1182 ? '<p class="who">You are a read-only member here.</p>'
1183 : '<form method="post" action="/r/' + esc(key) + '/request">' +
1184 '<button>ask to join</button></form>' +
1185 '<p class="who">You hold this room and can read it. Being a MEMBER is a decision ' +
1186 'somebody inside makes — they have to admit you, because a new member cannot let ' +
1187 'themselves in.</p>')
1188
1189 // Only an admin sees asks, because only an admin can answer one.
1190 const asks = role === 'admin' && r.requests && r.requests.length
1179 // ⛔ ROOM-CONTRACT v2: a member's other device is a member with no bound core. A post from it would
1180 // be carried by a core apply does not know, and dropped — so it is offered the bind, not a composer.
1181 const compose = role && unbound
1182 ? '<form method="post" action="/r/' + esc(key) + '/add-device"><button>use this device here</button></form>' +
1183 '<p class="who">You are a member of this room on another device. This machine joins as one more ' +
1184 'of yours, carrying its own writer core.</p>'
1185 : role && role !== 'read-only'
1186 ? '<form method="post" action="/r/' + esc(key) + '/post" class="composer">' +
1187 '<textarea name="body" rows="2" placeholder="say something…"></textarea>' +
1188 '<div class="row"><span class="who">posting as ' + esc(role) + '</span>' +
1189 '<button class="post">send</button></div></form>'
1190 : (role === 'read-only'
1191 ? '<p class="who">You are a read-only member here.</p>'
1192 : '<form class="inline-form" method="post" action="/r/' + esc(key) + '/join">' +
1193 '<input type="text" name="token" placeholder="invitation (hex)"><button>join</button></form>' +
1194 '<p class="who">You hold this room and can read it. Writing in it takes an invitation from ' +
1195 'somebody inside — a new member cannot let themselves in.</p>')
1196
1197 // Requests and invitations: what a moderator or admin acts on, so only they see them.
1198 const asks = steward && r.requests && r.requests.length
11911199 ? '<h2>Asking to join</h2><ul class="people">' + r.requests.map((q) =>
11921200 '<li class="ask">' + who_(q.root) +
1193 '<form method="post" action="/r/' + esc(key) + '/admit">' +
1194 '<input type="hidden" name="writer" value="' + esc(q.writer) + '">' +
1195 '<input type="hidden" name="member" value="' + esc(q.root) + '">' +
1196 '<button>let them in</button></form></li>').join('') + '</ul>'
1201 '<form method="post" action="/r/' + esc(key) + '/accept">' +
1202 '<input type="hidden" name="writer" value="' + esc(q.writer) + '"><button>let them in</button></form>' +
1203 '<form method="post" action="/r/' + esc(key) + '/reject">' +
1204 '<input type="hidden" name="writer" value="' + esc(q.writer) + '"><button>turn down</button></form>' +
1205 '</li>').join('') + '</ul>'
1206 : ''
1207 // ⛔ Shown once, in the response that created it. It is the invitation's signing secret: whoever
1208 // holds it can join until it is revoked, so it goes to one person over a private channel.
1209 const minted = invitation
1210 ? '<div class="warn"><p>Invitation — hand this to one person, privately:</p>' +
1211 keyline('invitation', invitation.token) + '</div>'
1212 : ''
1213 const inviteForm = steward
1214 ? '<form class="inline-form" method="post" action="/r/' + esc(key) + '/invite">' +
1215 '<select name="role"><option value="peer">peer</option>' +
1216 (role === 'admin' ? '<option value="moderator">moderator</option>' : '') + '</select>' +
1217 '<label><input type="checkbox" name="confirm"> ask me first</label>' +
1218 '<button>make an invitation</button></form>'
11971219 : ''
11981220
11991221 const members = '<h2>Members</h2><ul class="people">' + r.members.map((m) =>
……@@ -1202,9 +1224,10 @@ export function room (key, r, profiles = null) {
12021224 // ⚠ The sentence stays on screen and the key folds under it: what the key DOES is the part a
12031225 // reader needs, and it is also the warning.
12041226 return layout('rooms', '<h1>' + esc(title) + '</h1>' +
1205 '<p class="who">Anyone with this key can read the room. Hand it over to invite somebody.</p>' +
1227 (message ? '<p class="warn">' + esc(message) + '</p>' : '') +
1228 '<p class="who">Anyone with this key can read the room. Writing in it takes an invitation.</p>' +
12061229 keyline('room key', key) +
1207 compose + asks + msgs + members)
1230 minted + compose + inviteForm + asks + msgs + members)
12081231}
12091232
12101233export function following (keys, missing, profiles = null) {
lib/router.js +16 -9
……@@ -621,7 +621,7 @@ export async function route (req, res, ctx) {
621621 if (!/^[0-9a-f]{64}$/i.test(key)) return send(res, pages.error('Not a room key'), 404)
622622 let r
623623 try { r = await client.call('room', { key }) } catch (err) { return send(res, pages.error(err.message), 404) }
624 return send(res, pages.room(key, r, await client.call('profiles')))
624 return send(res, pages.room(key, r, await client.call('profiles'), { message: url.searchParams.get('m') }))
625625 }
626626
627627 if (req.method === 'GET' && path === '/mentions') {
……@@ -742,16 +742,23 @@ export async function route (req, res, ctx) {
742742 const [, , key, action] = path.split('/')
743743 if (!/^[0-9a-f]{64}$/i.test(key || '')) return send(res, pages.error('Not a room key'), 404)
744744 const form = await body(req)
745 // ⛔ ROOM-CONTRACT v2. Every action is carried by THIS node's own writer core; nobody adds anybody
746 // else's machine. A joiner joins with an invitation, a member's other device binds itself.
745747 try {
746 if (action === 'post' && String(form.body || '').trim()) await client.call('room-post', { key, body: form.body })
747 else if (action === 'request') await client.call('room-request', { key })
748 else if (action === 'admit') {
749 // ⛔ Both, in this order. Admitting a writer whose identity is not a member would put a
750 // machine in the room with nobody behind it — apply refuses that, and doing it here in one
751 // press is what makes "let them in" mean what it says.
752 if (form.member) await client.call('room-add', { key, member: form.member, role: 'peer' })
753 await client.call('room-admit', { key, writer: form.writer })
748 if (action === 'invite') {
749 const inv = await client.call('room-invite', {
750 key, role: form.role === 'moderator' ? 'moderator' : 'peer', requiresConfirmation: form.confirm === 'on'
751 })
752 // ⛔ RENDERED, not redirected. The token is the invitation's signing secret: a redirect would
753 // write it into a location header and the browser's history.
754 const r = await client.call('room', { key })
755 return send(res, pages.room(key, r, await client.call('profiles'), { invitation: inv }))
754756 }
757 if (action === 'post' && String(form.body || '').trim()) await client.call('room-post', { key, body: form.body })
758 else if (action === 'join') await client.call('room-join-invite', { key, token: String(form.token || '').trim() })
759 else if (action === 'add-device') await client.call('room-add-device', { key })
760 else if (action === 'accept') await client.call('room-accept', { key, writer: form.writer })
761 else if (action === 'reject') await client.call('room-reject', { key, writer: form.writer })
755762 } catch (err) {
756763 return seeOther(res, '/r/' + key + '?m=' + encodeURIComponent(err.message))
757764 }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-surface",
3 "version": "0.0.64",
3 "version": "0.0.65",
44 "description": "The favela renderer \u2014 server-side HTML over the daemon's socket. No storage, no keys, no swarm.",
55 "type": "module",
66 "main": "index.js",
test/routes.test.js +56 -19
……@@ -943,37 +943,74 @@ test('★★ a room shows a composer only to somebody who may WRITE', async (t)
943943 t.is(r.body.includes('action="/r/' + RK + '/post"'), expect, 'composer for ' + role)
944944 }
945945 // ⛔ Rendering a composer to a non-member would let them write something apply silently drops —
946 // the "it returned, so it worked" failure this stack refuses everywhere else. They are offered
947 // the ask instead.
946 // the "it returned, so it worked" failure this stack refuses everywhere else. ROOM-CONTRACT v2:
947 // they are offered the way in, which is an invitation somebody inside hands them.
948948 reset()
949 answers = { room: { ...base, role: null }, profiles: {} }
949 answers = { room: { ...base, role: null, bound: false }, profiles: {} }
950950 const r = await request('GET', '/r/' + RK, { headers: { cookie } })
951 t.ok(r.body.includes('action="/r/' + RK + '/request"'), 'a non-member is offered the ask')
952 t.ok(r.body.includes('cannot let'), 'and told why somebody else has to admit them')
951 t.ok(r.body.includes('action="/r/' + RK + '/join"') && r.body.includes('name="token"'), 'a non-member is offered the invitation box')
952 t.absent(r.body.includes('/request"'), 'and no v1 ask, which nobody can answer any more')
953953})
954954
955test('⛔ only an ADMIN is shown asks, because only an admin can answer one', async (t) => {
956 const q = [{ writer: 'cd'.repeat(32), root: 'ef'.repeat(32), device: 'x' }]
957 for (const [role, expect] of [['admin', true], ['peer', false]]) {
955test('⛔ a member\'s OTHER device is offered to bind itself, not a composer', async (t) => {
956 reset()
957 answers = { room: { key: RK, config: {}, members: [], requests: [], messages: [], role: 'peer', bound: false }, profiles: {} }
958 const r = await request('GET', '/r/' + RK, { headers: { cookie } })
959 // The identity is a member; this machine's core is not bound, so a post from here would be dropped.
960 t.absent(r.body.includes('action="/r/' + RK + '/post"'), 'no composer on an unbound device')
961 t.ok(r.body.includes('action="/r/' + RK + '/add-device"'), 'the bind button instead')
962})
963
964test('⛔ only a MODERATOR or ADMIN is shown join requests and the invite form', async (t) => {
965 const q = [{ writer: 'cd'.repeat(32), root: 'ef'.repeat(32), device: 'x', invite: '01' }]
966 for (const [role, expect] of [['admin', true], ['moderator', true], ['peer', false]]) {
958967 reset()
959 answers = { room: { key: RK, config: {}, members: [], requests: q, messages: [], role }, profiles: {} }
968 answers = { room: { key: RK, config: {}, members: [], requests: q, invites: [], messages: [], role, bound: true }, profiles: {} }
960969 const r = await request('GET', '/r/' + RK, { headers: { cookie } })
961 t.is(r.body.includes('let them in'), expect, 'asks shown to ' + role)
970 t.is(r.body.includes('action="/r/' + RK + '/accept"'), expect, 'requests shown to ' + role)
971 t.is(r.body.includes('action="/r/' + RK + '/reject"'), expect, 'and can be turned down by ' + role)
972 t.is(r.body.includes('action="/r/' + RK + '/invite"'), expect, 'invite form for ' + role)
962973 }
963974})
964975
965test('★ letting somebody in adds the MEMBER and admits the WRITER', async (t) => {
976test('★ accepting a request is ONE call on the joiner\'s writer', async (t) => {
977 reset()
978 answers = { 'room-accept': {} }
979 await request('POST', '/r/' + RK + '/accept', {
980 body: 'writer=' + 'cd'.repeat(32),
981 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
982 })
983 // v2: the joiner already bound its own core when it joined; accepting is the whole decision.
984 t.is(called('room-accept').length, 1)
985 t.is(called('room-accept')[0].args.writer, 'cd'.repeat(32))
986 t.is(called('room-add').length + called('room-admit').length, 0, 'no v1 verbs')
987})
988
989test('★ joining with an invitation hands the token to the daemon', async (t) => {
966990 reset()
967 answers = { 'room-add': {}, 'room-admit': {} }
968 await request('POST', '/r/' + RK + '/admit', {
969 body: 'writer=' + 'cd'.repeat(32) + '&member=' + 'ef'.repeat(32),
991 answers = { 'room-join-invite': { key: RK } }
992 const r = await request('POST', '/r/' + RK + '/join', {
993 body: 'token=' + '12'.repeat(32),
970994 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
971995 })
972 // ⛔ Both, in that order. Admitting a writer whose identity is not a member puts a machine in the
973 // room with nobody behind it, which apply refuses — so one press has to do both or the button
974 // does not mean what it says.
975 t.is(called('room-add').length, 1)
976 t.is(called('room-admit').length, 1)
996 t.is(called('room-join-invite').length, 1)
997 t.is(r.status, 303)
998})
999
1000test('⛔ a created invitation is SHOWN once, never put in a URL', async (t) => {
1001 reset()
1002 answers = {
1003 'room-invite': { id: '01', token: '34'.repeat(32) },
1004 room: { key: RK, config: {}, members: [], requests: [], invites: [], messages: [], role: 'admin', bound: true },
1005 profiles: {}
1006 }
1007 const r = await request('POST', '/r/' + RK + '/invite', {
1008 body: 'role=peer', headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
1009 })
1010 // ⛔ The token is the invitation's signing secret. A redirect would carry it in the location
1011 // header and browser history; rendering the page in the response carries it nowhere else.
1012 t.is(r.status, 200, 'rendered, not redirected')
1013 t.ok(r.body.includes('34'.repeat(32)), 'the token is on the page to copy')
9771014})
9781015
9791016test('⛔ a room this node is not in reports the daemon\'s reason, not a blank 404', async (t) => {