| … | … | @@ -1158,8 +1158,11 @@ export function rooms (list) { |
| 1158 | 1158 | * a composer to somebody whose posts apply would silently drop is the "it returned, so it worked" |
| 1159 | 1159 | * failure this stack refuses everywhere else. |
| 1160 | 1160 | */ |
| 1161 | export function room (key, r, profiles = null) { | |
| 1161 | export function room (key, r, profiles = null, { message = null, invitation = null } = {}) { | |
| 1162 | 1162 | 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' | |
| 1163 | 1166 | const title = r.config.title || 'untitled room' |
| 1164 | 1167 | const who_ = (root) => { |
| 1165 | 1168 | const p = profiles && Object.values(profiles).find((x) => x && x.root === root) |
| … | … | @@ -1173,27 +1176,46 @@ export function room (key, r, profiles = null) { |
| 1173 | 1176 | render(m.body || '') + '</div>').join('') + '</div>' |
| 1174 | 1177 | : '<p class="who">Nothing said yet.</p>' |
| 1175 | 1178 | |
| 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 | |
| 1191 | 1199 | ? '<h2>Asking to join</h2><ul class="people">' + r.requests.map((q) => |
| 1192 | 1200 | '<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>' | |
| 1197 | 1219 | : '' |
| 1198 | 1220 | |
| 1199 | 1221 | const members = '<h2>Members</h2><ul class="people">' + r.members.map((m) => |
| … | … | @@ -1202,9 +1224,10 @@ export function room (key, r, profiles = null) { |
| 1202 | 1224 | // ⚠ The sentence stays on screen and the key folds under it: what the key DOES is the part a |
| 1203 | 1225 | // reader needs, and it is also the warning. |
| 1204 | 1226 | 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>' + | |
| 1206 | 1229 | keyline('room key', key) + |
| 1207 | compose + asks + msgs + members) | |
| 1230 | minted + compose + inviteForm + asks + msgs + members) | |
| 1208 | 1231 | } |
| 1209 | 1232 | |
| 1210 | 1233 | export function following (keys, missing, profiles = null) { |
| … | … | @@ -621,7 +621,7 @@ export async function route (req, res, ctx) { |
| 621 | 621 | if (!/^[0-9a-f]{64}$/i.test(key)) return send(res, pages.error('Not a room key'), 404) |
| 622 | 622 | let r |
| 623 | 623 | 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') })) | |
| 625 | 625 | } |
| 626 | 626 | |
| 627 | 627 | if (req.method === 'GET' && path === '/mentions') { |
| … | … | @@ -742,16 +742,23 @@ export async function route (req, res, ctx) { |
| 742 | 742 | const [, , key, action] = path.split('/') |
| 743 | 743 | if (!/^[0-9a-f]{64}$/i.test(key || '')) return send(res, pages.error('Not a room key'), 404) |
| 744 | 744 | 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. | |
| 745 | 747 | 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 })) | |
| 754 | 756 | } |
| 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 }) | |
| 755 | 762 | } catch (err) { |
| 756 | 763 | return seeOther(res, '/r/' + key + '?m=' + encodeURIComponent(err.message)) |
| 757 | 764 | } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-surface", |
| 3 | "version": "0.0.64", | |
| 3 | "version": "0.0.65", | |
| 4 | 4 | "description": "The favela renderer \u2014 server-side HTML over the daemon's socket. No storage, no keys, no swarm.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -943,37 +943,74 @@ test('★★ a room shows a composer only to somebody who may WRITE', async (t) |
| 943 | 943 | t.is(r.body.includes('action="/r/' + RK + '/post"'), expect, 'composer for ' + role) |
| 944 | 944 | } |
| 945 | 945 | // ⛔ 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. | |
| 948 | 948 | reset() |
| 949 | answers = { room: { ...base, role: null }, profiles: {} } | |
| 949 | answers = { room: { ...base, role: null, bound: false }, profiles: {} } | |
| 950 | 950 | 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') | |
| 953 | 953 | }) |
| 954 | 954 | |
| 955 | test('⛔ 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]]) { | |
| 955 | test('⛔ 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 | ||
| 964 | test('⛔ 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]]) { | |
| 958 | 967 | 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: {} } | |
| 960 | 969 | 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) | |
| 962 | 973 | } |
| 963 | 974 | }) |
| 964 | 975 | |
| 965 | test('★ letting somebody in adds the MEMBER and admits the WRITER', async (t) => { | |
| 976 | test('★ 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 | ||
| 989 | test('★ joining with an invitation hands the token to the daemon', async (t) => { | |
| 966 | 990 | 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), | |
| 970 | 994 | headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' } |
| 971 | 995 | }) |
| 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 | ||
| 1000 | test('⛔ 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') | |
| 977 | 1014 | }) |
| 978 | 1015 | |
| 979 | 1016 | test('⛔ a room this node is not in reports the daemon\'s reason, not a blank 404', async (t) => { |