| 1 | // One Ward, however you spell its address.
|
|---|
| 2 | //
|
|---|
| 3 | // Shaer never asks for a URL. You type a handle, and its `Handle` parser turns a
|
|---|
| 4 | // bare host into `acct:<host>@<host>` — the WebFinger convention for "give me
|
|---|
| 5 | // this server's primary actor". WebFinger here only ever looked the user part up
|
|---|
| 6 | // as a site slug, so that resource 404'd and the app could not find a Ward it was
|
|---|
| 7 | // pointed straight at.
|
|---|
| 8 | //
|
|---|
| 9 | // The emoji host makes the second half of the problem visible. Foundation's URL
|
|---|
| 10 | // (and Node's, and every browser's) silently punycodes a host, so a pasted
|
|---|
| 11 | // `https://🩵.is.wildenvrij.nl` arrives as `xn--zz9h.is.wildenvrij.nl` while a
|
|---|
| 12 | // typed `🩵.is.wildenvrij.nl` arrives verbatim. Same Ward, two spellings, and a
|
|---|
| 13 | // byte comparison says they are strangers.
|
|---|
| 14 | import { test } from 'node:test';
|
|---|
| 15 | import assert from 'node:assert/strict';
|
|---|
| 16 |
|
|---|
| 17 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 18 | process.env.PUBLIC_BASE_URL = 'https://test.example';
|
|---|
| 19 |
|
|---|
| 20 | const dbMod = await import('../src/config/database.js');
|
|---|
| 21 | const db = dbMod.default;
|
|---|
| 22 | dbMod.initializeDatabase();
|
|---|
| 23 | const express = (await import('express')).default;
|
|---|
| 24 | const routes = (await import('../src/routes/activitypub.js')).default;
|
|---|
| 25 |
|
|---|
| 26 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
|
|---|
| 27 | .run('u1', 'u1', 'u1@t', 'x', 'god');
|
|---|
| 28 | // `kid` is the primary site; `oma` is a second public site that must NOT be
|
|---|
| 29 | // what a bare host resolves to.
|
|---|
| 30 | // Explicit created_at: getPrimarySite() falls back to the OLDEST site, and two
|
|---|
| 31 | // rows inserted in the same second would make that order a coin flip.
|
|---|
| 32 | db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, created_at) VALUES (?,?,?,?,1,?)')
|
|---|
| 33 | .run('s1', 'kid', 'kid', 'u1', '2026-01-01 00:00:00');
|
|---|
| 34 | db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, created_at) VALUES (?,?,?,?,0,?)')
|
|---|
| 35 | .run('s2', 'oma', 'oma', 'u1', '2026-06-01 00:00:00');
|
|---|
| 36 |
|
|---|
| 37 | const app = express();
|
|---|
| 38 | app.use(routes);
|
|---|
| 39 | const server = app.listen(0);
|
|---|
| 40 | await new Promise((r) => server.once('listening', r));
|
|---|
| 41 | const port = server.address().port;
|
|---|
| 42 | test.after(() => server.close());
|
|---|
| 43 |
|
|---|
| 44 | /// Ask WebFinger for a resource while the server believes it is served at `base`.
|
|---|
| 45 | async function finger(resource, base = 'https://test.example') {
|
|---|
| 46 | const previous = process.env.PUBLIC_BASE_URL;
|
|---|
| 47 | process.env.PUBLIC_BASE_URL = base;
|
|---|
| 48 | try {
|
|---|
| 49 | const url = `http://127.0.0.1:${port}/.well-known/webfinger?resource=${encodeURIComponent(resource)}`;
|
|---|
| 50 | const res = await fetch(url);
|
|---|
| 51 | return { status: res.status, body: res.status === 200 ? await res.json() : null };
|
|---|
| 52 | } finally {
|
|---|
| 53 | process.env.PUBLIC_BASE_URL = previous;
|
|---|
| 54 | }
|
|---|
| 55 | }
|
|---|
| 56 |
|
|---|
| 57 | /// The `self` link is the actor the client will actually fetch next.
|
|---|
| 58 | const actorOf = (body) => body.links.find((l) => l.rel === 'self').href;
|
|---|
| 59 |
|
|---|
| 60 | test('a normal handle still resolves (the case that already worked)', async () => {
|
|---|
| 61 | const { status, body } = await finger('acct:oma@test.example');
|
|---|
| 62 | assert.equal(status, 200);
|
|---|
| 63 | assert.equal(actorOf(body), 'https://test.example/ap/users/oma', 'a named slug wins over the primary fallback');
|
|---|
| 64 | });
|
|---|
| 65 |
|
|---|
| 66 | test('a bare host resolves the primary actor', async () => {
|
|---|
| 67 | // The whole bug: this is what Shaer sends when you type `test.example`.
|
|---|
| 68 | const { status, body } = await finger('acct:test.example@test.example');
|
|---|
| 69 | assert.equal(status, 200, 'the bare host is a valid address, not a 404');
|
|---|
| 70 | assert.equal(actorOf(body), 'https://test.example/ap/users/kid', 'and it means the PRIMARY site, not just any site');
|
|---|
| 71 | });
|
|---|
| 72 |
|
|---|
| 73 | test('unicode and punycode spellings of one host find one Ward', async () => {
|
|---|
| 74 | const unicode = 'https://🩵.is.wildenvrij.nl';
|
|---|
| 75 | const punycode = 'https://xn--zz9h.is.wildenvrij.nl';
|
|---|
| 76 |
|
|---|
| 77 | const typed = await finger('acct:🩵.is.wildenvrij.nl@🩵.is.wildenvrij.nl', unicode);
|
|---|
| 78 | const pasted = await finger('acct:xn--zz9h.is.wildenvrij.nl@xn--zz9h.is.wildenvrij.nl', unicode);
|
|---|
| 79 |
|
|---|
| 80 | assert.equal(typed.status, 200, 'typed by hand: the emoji host');
|
|---|
| 81 | assert.equal(pasted.status, 200, 'pasted as a URL: the client already punycoded it');
|
|---|
| 82 | assert.deepEqual(actorOf(typed.body), actorOf(pasted.body), 'both spellings are the same Ward');
|
|---|
| 83 |
|
|---|
| 84 | // And it does not matter which spelling the server itself is configured with.
|
|---|
| 85 | const configuredAscii = await finger('acct:🩵.is.wildenvrij.nl@🩵.is.wildenvrij.nl', punycode);
|
|---|
| 86 | assert.equal(configuredAscii.status, 200, 'PUBLIC_BASE_URL may be written either way too');
|
|---|
| 87 |
|
|---|
| 88 | assert.equal(typed.body.subject, 'acct:kid@xn--zz9h.is.wildenvrij.nl',
|
|---|
| 89 | 'the subject we answer with is the canonical one, never the alias that was asked for');
|
|---|
| 90 | });
|
|---|
| 91 |
|
|---|
| 92 | test('a bare host resolves even when no site carries the primary flag', async () => {
|
|---|
| 93 | // This is the state a fresh instance is actually in: is_primary defaults to 0
|
|---|
| 94 | // and the backfill only runs when the column is first added, so a site created
|
|---|
| 95 | // afterwards leaves the instance with no primary at all. The HTML side coped
|
|---|
| 96 | // (getPrimarySite falls back to the oldest) while this route kept its own
|
|---|
| 97 | // is_primary-only lookup — so / served the site and WebFinger said 404.
|
|---|
| 98 | db.prepare('UPDATE sites SET is_primary = 0').run();
|
|---|
| 99 | try {
|
|---|
| 100 | const { status, body } = await finger('acct:test.example@test.example');
|
|---|
| 101 | assert.equal(status, 200, 'an unflagged instance is still discoverable');
|
|---|
| 102 | assert.equal(actorOf(body), 'https://test.example/ap/users/kid', 'falls back to the oldest site');
|
|---|
| 103 | } finally {
|
|---|
| 104 | db.prepare('UPDATE sites SET is_primary = 1 WHERE id = ?').run('s1');
|
|---|
| 105 | }
|
|---|
| 106 | });
|
|---|
| 107 |
|
|---|
| 108 | test('an unknown user is still a 404', async () => {
|
|---|
| 109 | // The fallback must not turn every miss into the primary actor, or a typo
|
|---|
| 110 | // silently connects a child to the wrong account.
|
|---|
| 111 | const { status } = await finger('acct:nobody@test.example');
|
|---|
| 112 | assert.equal(status, 404);
|
|---|
| 113 | });
|
|---|
| 114 |
|
|---|
| 115 | test('a bare host that is not ours is still a 404', async () => {
|
|---|
| 116 | const { status } = await finger('acct:elders.example@elders.example');
|
|---|
| 117 | assert.equal(status, 404, 'we only answer for the host we are actually serving');
|
|---|
| 118 | });
|
|---|
| 119 |
|
|---|
| 120 | test('a malformed resource is a 400', async () => {
|
|---|
| 121 | const { status } = await finger('https://test.example/ap/users/kid');
|
|---|
| 122 | assert.equal(status, 400);
|
|---|
| 123 | });
|
|---|