diff --git a/e2e/ARCHITECTURE.md b/e2e/ARCHITECTURE.md index bcef743c..6594411f 100644 --- a/e2e/ARCHITECTURE.md +++ b/e2e/ARCHITECTURE.md @@ -22,6 +22,9 @@ this space?". Seeding a membership on `closed` is therefore not possible — `jo publish a claimless join and the relay refuses it — so a scenario there seeds admin-created rooms and lets the spec do the joining. +`indexer` and `outbox` are not spaces: groups are off, anyone may read and write, and nothing seeded +on them carries an `h` tag. They are what "The follow graph" below is made of. + ### Why the relays are called `.test` The container listens on plaintext loopback, but a url that is local or insecure is dropped from @@ -244,8 +247,8 @@ have on disk anyway. The price is that no spec exercises the bootstrap: every one of them starts already knowing which relays it belongs to, so a regression in the chain from relay list to room list, or in `authPolicy`'s -conservative gate, leaves the suite green. Covering it needs a scenario with a public relay standing -in for an indexer. +conservative gate, leaves the suite green. The public relays under "The follow graph" are the +standing-in indexer that covering it would need. Each user is a separate `BrowserContext`, which also gives each one its own IndexedDB and localStorage, so nothing bleeds between users. @@ -269,6 +272,65 @@ hosted spaces are created under — or the app dials a host nothing serves and t leak. The NIP-07 provider is a real signer rather than a stub, because the session it produces has to sign the NIP-42 challenges the members-only relays send. +## The follow graph + +Everything `/home`'s network column is made of sits outside a space: a follow list, each followed +pubkey's relay list, and an indexer to resolve those from. `open("indexer")` and `open("outbox")` +are the two public relays that hold it, and a scenario says which of them a given fixture lands on: + +```ts +const scenario = await seed(({relay, open, user, at}) => { + const space = relay("space") + const indexer = open("indexer") + const outbox = open("outbox") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + + indexer.relayList(user.alice, { + read: [space.url, indexer.url], + write: [space.url, indexer.url, outbox.url], + }) + indexer.follows(user.alice, [user.bob]) + + indexer.relayList(user.bob, {read: [outbox.url], write: [outbox.url]}) + outbox.profile(user.bob, {name: "Bob Barker"}) + outbox.note(user.bob, "the lighthouse has been dark since tuesday", at(30, MINUTE)) +}) +``` + +Bob is in none of alice's spaces, so the only thing that can put his note on her screen is his relay +list. Keeping the indexer and the outbox apart is what makes that assertable: a client that ignored +the list would ask the indexer, find nothing, and fail the spec, where one relay serving both roles +would pass either way. `VITE_INDEXER_RELAYS` points at the indexer as soon as a scenario opens one, +and at the scenario's spaces otherwise, which is what every spec written before there was one still +gets. + +An open relay's url reads before seeding has run, unlike a space's, because a relay list has to name +the relay a note is seeded on. + +### Why a reader has to name the relays it reads from + +zooid answers no REQ without NIP-42, whatever `public_read` says, and `authPolicy` is conservative: +it identifies only to relays the user's own room list or relay list names. So a relay a spec expects +the client to read from has to appear in that user's list, and a relay list is seeded for the reader +as well as for the people they follow. + +Read and write are separate there, and the difference is what a routing assertion hangs on. Above, +alice writes to bob's relay and reads from her space and the indexer: the write url is enough for +her client to identify to it, while a feed's context, which is built from her read urls, has no +reason to ask it for anything. A reply that only lives on bob's relay therefore counts only if the +feed asked the relay the note itself came from. + +The write urls have a second job. A user's own follow list is loaded through their outbox, so the +relay a scenario seeds that list on has to be one of the relays their list says they write to — the +indexer, here. Seed it somewhere they only read from and the feed comes up empty with nothing to say +why. + +Her own relay list reaches her client as cache alongside her room list, for the same reason the room +list does: it is the list that says which relays may be identified to, so it cannot be the thing +that has to be fetched first. + ## Layout ``` @@ -297,8 +359,10 @@ e2e/ webln.ts a window.webln that enables and reports what it supports seed/ scenario.ts the `seed()` builder and relative-time helpers + publish.ts the queue every seeding call goes through, and what it hands back space.ts one space's fixtures: rooms, members, messages, replies, profiles, direct messages, and anything a domain writer renders + openRelay.ts one public relay's fixtures: relay lists, follow lists, profiles, notes specs/ *.spec.ts ``` diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index a779394a..e7c0027b 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -1569,7 +1569,9 @@ that home is worth opening when nothing is waiting for me. Acceptance: - The Network section lists notes from her follows, resolved through the relays - those people publish to. + those people publish to. A follow who is in none of her spaces reads the same + way, replies included, since a note's replies are counted from the relay the + note itself came from. - It is a list of notes: a reply is counted on the note it answers rather than drawn underneath it, and never appears as an item of its own. - Every note carries its reply count, including the ones with no replies. diff --git a/e2e/harness/app/boot.ts b/e2e/harness/app/boot.ts index 8ac87b5e..6d29c303 100644 --- a/e2e/harness/app/boot.ts +++ b/e2e/harness/app/boot.ts @@ -15,6 +15,9 @@ export type BootOptions = { // Every relay list the app reads at startup is pointed here, so it can only dial relays the // scenario created. relays: string[] + // What a pubkey's own lists are resolved from, which is a relay of its own only when the + // scenario opened one. Defaults to `relays`. + indexers?: string[] spaces?: string[] user?: TestUser // What this user's client already has in local storage, e.g. their room list. @@ -27,7 +30,7 @@ export type BootOptions = { export const boot = async ( context: BrowserContext, - {relays, spaces = [], user, events = [], path = "/", env = {}}: BootOptions, + {relays, indexers = relays, spaces = [], user, events = [], path = "/", env = {}}: BootOptions, ) => { const urls = relays.join(",") @@ -48,7 +51,7 @@ export const boot = async ( TEST_ENV_READ_KEY, { VITE_DEFAULT_RELAYS: urls, - VITE_INDEXER_RELAYS: urls, + VITE_INDEXER_RELAYS: indexers.join(","), VITE_DEFAULT_SEARCH_RELAYS: urls, VITE_DEFAULT_MESSAGING_RELAYS: urls, VITE_SIGNER_RELAYS: urls, diff --git a/e2e/harness/index.ts b/e2e/harness/index.ts index 8b26f4c9..3b28fb01 100644 --- a/e2e/harness/index.ts +++ b/e2e/harness/index.ts @@ -27,8 +27,10 @@ export {expect} export {makeTestUser, users} from "./keys" export type {TestUser} from "./keys" export type {Scenario} from "./seed/scenario" +export type {SeededEvent} from "./seed/publish" +export type {SeededOpenRelay} from "./seed/openRelay" export type {SeededRumor, SeededSpace} from "./seed/space" -export type {TenantName} from "./zooid/config" +export type {OpenRelayName, SpaceName, TenantName} from "./zooid/config" export type {TranscriptEntry} from "./net/websocket" export {forgetRelay, formatTranscript, getTranscript} from "./net/websocket" export {readCachedEvents} from "./app/cache" @@ -152,7 +154,7 @@ export const test = base.extend({ } const open = async (path: string, options: PageOptions, user?: TestUser) => { - const {urls, cache} = requireScenario() + const {urls, indexerUrls, cache} = requireScenario() // The project's own `use` first, so a viewport or device descriptor set in // playwright.config.ts reaches the context rather than being dropped. // @@ -209,6 +211,7 @@ export const test = base.extend({ path, env: options.env, relays: urls, + indexers: indexerUrls, spaces: urls, events: user ? cache(user) : [], }) diff --git a/e2e/harness/seed/openRelay.ts b/e2e/harness/seed/openRelay.ts new file mode 100644 index 00000000..a14c592d --- /dev/null +++ b/e2e/harness/seed/openRelay.ts @@ -0,0 +1,115 @@ +import {Resolver} from "@welshman/util" +import {EventWriter, FollowList, Note, Profile, RelayList} from "@welshman/domain" +import type {BaseEventReader, ConfiguredKind, EventQuery, KindFactory} from "@welshman/domain" +import type {Zooid} from "../zooid/relay" +import {tenantUrl} from "../zooid/config" +import type {OpenRelayName} from "../zooid/config" +import type {TestUser} from "../keys" +import {makePublisher} from "./publish" +import type {Enqueue, ProfileValues, SeededEvent, SeededTemplate} from "./publish" + +// A nip-65 relay list, as the two sets a client reads off it: `write` is what an outbox-routed load +// for this pubkey resolves to, `read` is what a feed asks for that pubkey's context. +export type RelayListUrls = { + read?: string[] + write?: string[] +} + +/** + * One public relay's fixtures. Unlike a space it has no rooms, no members and nothing behind an + * `h` tag: it holds the things a client reaches for by pubkey rather than by space — a relay list, + * a follow list, a profile, a note. + */ +export type SeededOpenRelay = { + readonly name: OpenRelayName + // Known before seeding runs, since a relay list has to name the relay a note is seeded on. + readonly url: string + // Every relay list seeded here, which the scenario hands back to its own author as cache. + readonly relayLists: SeededEvent[] + note(user: TestUser, content: string, createdAt?: number): SeededEvent + profile(user: TestUser, values: ProfileValues, createdAt?: number): SeededEvent + // Where this user reads and writes. A relay a scenario expects the client to read from has to + // appear in the reader's own list: zooid answers no REQ without nip-42, and Flotilla only + // identifies to relays that list names. + relayList(user: TestUser, urls: RelayListUrls, createdAt?: number): SeededEvent + follows(user: TestUser, follows: TestUser[], createdAt?: number): SeededEvent + event(user: TestUser, template: SeededTemplate, createdAt?: number): SeededEvent + // This relay's domain kinds, bound to a resolver that answers with its url, the same way a + // space's `kind()` does. + kind, Q extends EventQuery>( + factory: KindFactory, + ): ConfiguredKind +} + +export type SeedOpenRelayOptions = { + zooid: Zooid + enqueue: Enqueue + startedAt: number + name: OpenRelayName +} + +export const seedOpenRelay = ({ + zooid, + enqueue, + startedAt, + name, +}: SeedOpenRelayOptions): SeededOpenRelay => { + const url = tenantUrl(name) + const relay = zooid.relay(name) + const relayLists: SeededEvent[] = [] + const context = {resolver: new Resolver(() => [url])} + + const {event} = makePublisher({ + name, + enqueue, + startedAt, + sign: (user, template) => relay.event(user, template), + }) + + const kind = , Q extends EventQuery>( + factory: KindFactory, + ) => factory.configure(context) + + const note = (user: TestUser, content: string, createdAt = startedAt) => + event(user, () => kind(Note).writer().setContent(content).renderTemplate(), createdAt) + + const profile = (user: TestUser, values: ProfileValues, createdAt = startedAt) => + event( + user, + () => Profile.configure(context).writer().update(values).renderTemplate(), + createdAt, + ) + + const relayList = ( + user: TestUser, + {read = [], write = []}: RelayListUrls, + createdAt = startedAt, + ) => { + const seeded = event( + user, + () => kind(RelayList).writer().setReadUrls(read).setWriteUrls(write).renderTemplate(), + createdAt, + ) + + relayLists.push(seeded) + + return seeded + } + + const follows = (user: TestUser, followed: TestUser[], createdAt = startedAt) => + event( + user, + () => { + const writer = kind(FollowList).writer() + + for (const {pubkey} of followed) { + writer.follow(pubkey) + } + + return writer.renderTemplate() + }, + createdAt, + ) + + return {name, url, relayLists, note, profile, relayList, follows, event, kind} +} diff --git a/e2e/harness/seed/publish.ts b/e2e/harness/seed/publish.ts new file mode 100644 index 00000000..8bfb1905 --- /dev/null +++ b/e2e/harness/seed/publish.ts @@ -0,0 +1,83 @@ +import type {Maybe, MaybeAsync} from "@welshman/lib" +import type {EventTemplate, SignedEvent, StampedEvent} from "@welshman/util" +import type {TestUser} from "../keys" + +// A queued write, drained in declaration order by `seed` in scenario.ts. +export type Enqueue = (write: () => Promise) => void + +// A handle to an event the scenario is going to publish. Seeding calls record what to write and +// return before anything is written, so the event is filled in when its turn in the queue comes up. +export type SeededEvent = { + readonly event: SignedEvent + readonly id: string +} + +// An event to seed, either already rendered or built when its turn comes up. A domain writer needs +// a relay url to resolve its hints against, and a relay has none until the queue has drained, so +// anything built by one has to be deferred. +export type SeededTemplate = StampedEvent | (() => MaybeAsync) + +export type ProfileValues = { + name?: string + about?: string + picture?: string + nip05?: string +} + +export type PublisherOptions = { + // The relay these events are seeded into, for the error a read-too-early raises. + name: string + enqueue: Enqueue + // The moment the scenario began. A fixture declared without a timestamp is stamped with it + // rather than with the wall clock, so two fixtures describing the same thing cannot land + // seconds apart and decide which of them wins. + startedAt: number + // How an event this process signs reaches the relay, deferred because a space's relay handle + // only exists once the queue has started draining. + sign: (user: TestUser, template: StampedEvent) => Promise +} + +// The queue every seeding call goes through: what to write is recorded now and published when +// `seed()` drains, and what it produced only reads back after that. +export const makePublisher = ({name, enqueue, startedAt, sign}: PublisherOptions) => { + // Queues a write and hands back a getter for whatever it produced. + const seeded = (write: () => Promise) => { + let value: Maybe + + enqueue(async () => { + value = await write() + }) + + return () => { + if (value) return value + + throw new Error(`An event seeded into "${name}" was read before seed() published it`) + } + } + + const publish = (write: () => Promise): SeededEvent => { + const event = seeded(write) + + return { + get event() { + return event() + }, + get id() { + return event().id + }, + } + } + + const event = (user: TestUser, template: SeededTemplate, createdAt = startedAt) => + publish(async () => { + if (typeof template === "function") { + return sign(user, {...(await template()), created_at: createdAt}) + } + + return sign(user, template) + }) + + return {seeded, publish, event} +} + +export type Publisher = ReturnType diff --git a/e2e/harness/seed/scenario.ts b/e2e/harness/seed/scenario.ts index 6174a6aa..3cbbb089 100644 --- a/e2e/harness/seed/scenario.ts +++ b/e2e/harness/seed/scenario.ts @@ -3,19 +3,25 @@ import type {MaybeAsync} from "@welshman/lib" import {ROOMS, makeEvent} from "@welshman/util" import type {SignedEvent} from "@welshman/util" import type {Zooid} from "../zooid/relay" -import type {TenantName} from "../zooid/config" +import type {OpenRelayName, SpaceName} from "../zooid/config" import {users} from "../keys" import type {TestUser} from "../keys" import {seedSpace} from "./space" import type {SeededSpace} from "./space" +import {seedOpenRelay} from "./openRelay" +import type {SeededOpenRelay} from "./openRelay" // A fixture timestamp, as an offset from the moment the scenario started. `at(2, HOUR)` is two // hours before the test began, count-first like int and ago. export type At = (count: number, unit: number) => number export type SeedTools = { - // Names a relay the container already serves. Its policy is its toml in zooid/docker/config. - relay: (name: TenantName) => SeededSpace + // Names a space the container already serves. Its policy is its toml in zooid/docker/config. + relay: (name: SpaceName) => SeededSpace + // Names one of the public relays, which is where the follow graph lives: `indexer` is what a + // pubkey's own lists are resolved from, `outbox` is a followed pubkey's write relay. See + // ARCHITECTURE.md, "The follow graph". + open: (name: OpenRelayName) => SeededOpenRelay user: typeof users at: At } @@ -23,11 +29,16 @@ export type SeedTools = { export type Scenario = { readonly startedAt: number readonly at: At + // The spaces this scenario seeded, which are the relays the app is handed as its own. readonly urls: string[] - space(name: TenantName): SeededSpace - // The events a returning user's client would already have on disk, which is just the room list. - // A members-only relay won't serve the list that would tell authPolicy it may identify to it. See - // ARCHITECTURE.md, "Users and sessions". + // What a pubkey's own lists are resolved from: the open indexer when a scenario declared one, + // and the spaces otherwise, which is what a scenario that knows nothing about open relays gets. + readonly indexerUrls: string[] + space(name: SpaceName): SeededSpace + open(name: OpenRelayName): SeededOpenRelay + // The events a returning user's client would already have on disk: their room list, and their + // relay list when the scenario seeded one. A relay won't serve the list that would tell + // authPolicy it may identify to it. See ARCHITECTURE.md, "Users and sessions". cache(user: TestUser): SignedEvent[] } @@ -38,23 +49,30 @@ export const seed = async ( const startedAt = now() const at: At = (count, unit) => startedAt - int(count, unit) const spaces = new Map() + const opened: SeededOpenRelay[] = [] const writes: (() => Promise)[] = [] const roomLists = new Map() + const relayLists = new Map() - const relay = (name: TenantName) => { - const space = seedSpace({ - zooid, - startedAt, - name, - enqueue: write => writes.push(write), - }) + const enqueue = (write: () => Promise) => writes.push(write) + + const relay = (name: SpaceName) => { + const space = seedSpace({zooid, startedAt, name, enqueue}) spaces.set(name, space) return space } - await build({relay, user: users, at}) + const open = (name: OpenRelayName) => { + const relay = seedOpenRelay({zooid, startedAt, name, enqueue}) + + opened.push(relay) + + return relay + } + + await build({relay, open, user: users, at}) // Seeding is async and fixtures depend on one another, so the builder only records what to // write. Draining the queue here publishes each fixture in the order it was declared. @@ -88,7 +106,13 @@ export const seed = async ( roomLists.set(user.pubkey, event) } - const getSpace = (name: TenantName) => { + for (const relay of opened) { + for (const {event} of relay.relayLists) { + relayLists.set(event.pubkey, event) + } + } + + const getSpace = (name: SpaceName) => { const space = spaces.get(name) if (space) return space @@ -96,17 +120,35 @@ export const seed = async ( throw new Error(`No space named "${name}" was seeded`) } - const cache = (user: TestUser) => { - const roomList = roomLists.get(user.pubkey) + const getOpenRelay = (name: OpenRelayName) => { + const relay = opened.find(candidate => candidate.name === name) - return roomList ? [roomList] : [] + if (relay) return relay + + throw new Error(`No open relay named "${name}" was seeded`) } + const cache = (user: TestUser) => { + const events: SignedEvent[] = [] + const roomList = roomLists.get(user.pubkey) + const relayList = relayLists.get(user.pubkey) + + if (roomList) events.push(roomList) + if (relayList) events.push(relayList) + + return events + } + + const urls = seeded.map(space => space.url) + const indexers = opened.filter(relay => relay.name === "indexer").map(relay => relay.url) + return { startedAt, at, cache, - urls: seeded.map(space => space.url), + urls, + indexerUrls: indexers.length > 0 ? indexers : urls, space: getSpace, + open: getOpenRelay, } } diff --git a/e2e/harness/seed/space.ts b/e2e/harness/seed/space.ts index 55106837..d91fcbab 100644 --- a/e2e/harness/seed/space.ts +++ b/e2e/harness/seed/space.ts @@ -1,5 +1,5 @@ import {neventEncode} from "nostr-tools/nip19" -import type {Maybe, MaybeAsync} from "@welshman/lib" +import type {Maybe} from "@welshman/lib" import { MESSAGE, ROOM_ADD_MEMBER, @@ -10,28 +10,24 @@ import { tagValue, toNostrURI, } from "@welshman/util" -import type {EventTemplate, HashedEvent, SignedEvent, StampedEvent} from "@welshman/util" +import type {HashedEvent} from "@welshman/util" import {Nip59} from "@welshman/signer" import {DirectMessage, EventWriter, Profile} from "@welshman/domain" import type {BaseEventReader, ConfiguredKind, EventQuery, KindFactory} from "@welshman/domain" import type {RoomOptions, TestRelay} from "../zooid/types" import type {Zooid} from "../zooid/relay" -import type {TenantName} from "../zooid/config" +import {tenantUrl} from "../zooid/config" +import type {SpaceName} from "../zooid/config" import {users} from "../keys" import type {TestUser} from "../keys" +import {makePublisher} from "./publish" +import type {Enqueue, ProfileValues, SeededEvent, SeededTemplate} from "./publish" // @welshman/domain has no writer for NIP-29 kind-9 messages, and none of its readers describe one, // so this pairs the base writer with the base reader. The behavior tags it renders are everything a // room message carries: `h` via setRoom, `q` and `p` via addQuote and addMention. class MessageWriter extends EventWriter {} -// A handle to an event the scenario is going to publish. Seeding calls record what to write and -// return before anything is written, so the event is filled in when its turn in the queue comes up. -export type SeededEvent = { - readonly event: SignedEvent - readonly id: string -} - // The kind-14 a direct message really is. It is never published, since each participant gets it // inside a gift wrap, so this is what a spec asserts on. export type SeededRumor = { @@ -39,21 +35,6 @@ export type SeededRumor = { readonly id: string } -// An event to seed, either already rendered or built when its turn comes up. A domain writer needs -// a relay url to resolve its hints against, and this space has none until the queue has drained, -// so anything built by one has to be deferred. -export type SeededTemplate = StampedEvent | (() => MaybeAsync) - -export type ProfileValues = { - name?: string - about?: string - picture?: string - nip05?: string -} - -// A queued write, drained in declaration order by `seed` in scenario.ts. -export type Enqueue = (write: () => Promise) => void - // A user's membership as their own client sees it, which the scenario turns into one room list // per user once every space has been seeded. export type SeededMembership = { @@ -62,8 +43,7 @@ export type SeededMembership = { } export type SeededSpace = { - readonly name: TenantName - // The url is read off the relay handle, so this only reads once seeding has run. + readonly name: SpaceName readonly url: string readonly memberships: SeededMembership[] room(h: string, options?: RoomOptions): void @@ -92,11 +72,14 @@ export type SeedSpaceOptions = { // The moment the scenario began. A fixture declared without a timestamp is stamped with it // rather than with the wall clock. startedAt: number - name: TenantName + name: SpaceName } export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions): SeededSpace => { const memberships: SeededMembership[] = [] + // Known before seeding runs, unlike the relay handle below, so a fixture on another relay can + // name this one. + const url = tenantUrl(name) let testRelay: Maybe @@ -110,55 +93,21 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions): testRelay = await zooid.relay(name) }) + const {seeded, publish, event} = makePublisher({ + name, + enqueue, + startedAt, + sign: (user, template) => relay().event(user, template), + }) + // Every fixture is published to this space, so a relay hint always resolves to its url. - const context = {resolver: new Resolver(() => [relay().url])} - - // Queues a write and hands back a getter for whatever it produced, which only reads once the - // scenario's queue has drained. - const seeded = (write: () => Promise) => { - let value: Maybe - - enqueue(async () => { - value = await write() - }) - - return () => { - if (value) return value - - throw new Error(`An event seeded into "${name}" was read before seed() published it`) - } - } - - const publish = (write: () => Promise): SeededEvent => { - const event = seeded(write) - - return { - get event() { - return event() - }, - get id() { - return event().id - }, - } - } - - const publishTemplate = (user: TestUser, build: () => MaybeAsync) => - publish(async () => relay().event(user, await build())) + const context = {resolver: new Resolver(() => [url])} const room = (h: string, roomOptions: RoomOptions = {}) => enqueue(() => relay().room(h, roomOptions, startedAt)) const member = (user: TestUser, h?: string) => enqueue(() => relay().member(user, h, startedAt)) - const event = (user: TestUser, template: SeededTemplate, createdAt = startedAt) => - publishTemplate(user, async () => { - if (typeof template === "function") { - return {...(await template()), created_at: createdAt} - } - - return template - }) - const join = (user: TestUser, ...roomIds: string[]) => { memberships.push({user, rooms: roomIds}) member(user) @@ -184,30 +133,33 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions): // content rather than from the q tag, so the uri is prepended as prependParent does in // src/app/rooms.ts. const reply = (user: TestUser, parent: SeededEvent, content: string, createdAt = startedAt) => - publishTemplate(user, async () => { - const h = tagValue(tagSpec("h"), parent.event.tags) + event( + user, + async () => { + const h = tagValue(tagSpec("h"), parent.event.tags) - if (h) { - const url = relay().url - const nevent = neventEncode({...parent.event, relays: [url]}) - const template = await new MessageWriter(MESSAGE, context) - .setRoom(url, h) - .addQuote(parent.event) - .addMention(parent.event.pubkey) - .setContent(toNostrURI(nevent) + "\n\n" + content) - .renderTemplate() + if (h) { + const nevent = neventEncode({...parent.event, relays: [url]}) - return {...template, created_at: createdAt} - } + return new MessageWriter(MESSAGE, context) + .setRoom(url, h) + .addQuote(parent.event) + .addMention(parent.event.pubkey) + .setContent(toNostrURI(nevent) + "\n\n" + content) + .renderTemplate() + } - throw new Error(`Cannot reply to ${parent.id}, it is not in a room`) - }) + throw new Error(`Cannot reply to ${parent.id}, it is not in a room`) + }, + createdAt, + ) const profile = (user: TestUser, values: ProfileValues, createdAt = startedAt) => - publishTemplate(user, async () => ({ - ...(await Profile.configure(context).writer().update(values).renderTemplate()), - created_at: createdAt, - })) + event( + user, + () => Profile.configure(context).writer().update(values).renderTemplate(), + createdAt, + ) const kind = , Q extends EventQuery>( factory: KindFactory, @@ -247,9 +199,7 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions): return { name, - get url() { - return relay().url - }, + url, memberships, room, member, diff --git a/e2e/harness/zooid/config.ts b/e2e/harness/zooid/config.ts index 4bb6634b..f59ddca7 100644 --- a/e2e/harness/zooid/config.ts +++ b/e2e/harness/zooid/config.ts @@ -1,8 +1,8 @@ /** * The virtual relays the container serves, and the only place their names are written down. * - * zooid binds a config to a Host header and serves any number of them from one process, so a second - * space costs a toml in docker/config and nothing else. The names are a union rather than a string + * zooid binds a config to a Host header and serves any number of them from one process, so another + * relay costs a toml in docker/config and nothing else. The names are a union rather than a string * so that a scenario naming a relay that has no config fails to compile instead of hanging on a 404 * from the dispatcher. * @@ -10,7 +10,10 @@ * process fails to connect rather than reaching a host. transport.ts covers why the container is * told to call itself this rather than its loopback address. */ -export const tenants = { + +// The relays a space is seeded on: members-only, with nip-29 groups on, which is what a Flotilla +// space is. +export const spaceTenants = { space: "space.test", other: "other.test", // Policy space.toml cannot express at the same time. `closed` refuses a join without an invite, @@ -19,6 +22,20 @@ export const tenants = { unsigned: "unsigned.test", } as const +// Public relays with no groups, which is where anything outside a space lives: `indexer` is what a +// pubkey's own lists are resolved from, `outbox` is a followed pubkey's write relay. See +// ARCHITECTURE.md, "The follow graph". +export const openTenants = { + indexer: "indexer.test", + outbox: "outbox.test", +} as const + +export const tenants = {...spaceTenants, ...openTenants} as const + +export type SpaceName = keyof typeof spaceTenants + +export type OpenRelayName = keyof typeof openTenants + export type TenantName = keyof typeof tenants export const tenantUrl = (name: TenantName) => `wss://${tenants[name]}/` diff --git a/e2e/harness/zooid/docker/config/indexer.toml b/e2e/harness/zooid/docker/config/indexer.toml new file mode 100644 index 00000000..56a7887a --- /dev/null +++ b/e2e/harness/zooid/docker/config/indexer.toml @@ -0,0 +1,43 @@ +# A public relay rather than a space: no groups, and anyone may read and write. It stands in for the +# indexers a client resolves a pubkey's own lists from, so a relay list, a follow list or a profile +# seeded here is reachable by a user who is a member of nothing. +# +# zooid answers no REQ without nip-42 whatever `public_read` says, and Flotilla only identifies to +# relays the user's own lists name, so a scenario expecting the client to read from this one seeds +# it into that user's relay list. See ARCHITECTURE.md, "The follow graph". +# +# `host` must equal the Host header the harness sends, which is the hostname of the url the client +# is given; anything else gets a 404 from the dispatcher instead of a relay. Keep it in step with +# `tenants` in harness/zooid/config.ts, and see harness/zooid/transport.ts for why it is a name +# that resolves nowhere. +host = "indexer.test" +schema = "e2e_indexer" +secret = "10de7e40000000000000000000000000000000000000000000000000000000e8" + +[info] +# Served to the client as nip-11. `pubkey` is the owner, as it is on every other relay here, so a +# scenario that does reach for management gets the same identity back. +name = "indexer" +pubkey = "6ada7b6eabb3a8349f88667d278a275c704b553a7c57f9d8156555986884a08e" +description = "Throwaway relay for Flotilla's end-to-end suite." + +[policy] +public_read = true +public_write = true +public_join = false +strip_signatures = false + +# Not a space: nothing seeded here carries an `h` tag, and the client is never offered it as one. +[groups] +enabled = false + +[management] +enabled = false + +# Blossom and push are off, and [livekit] is omitted entirely, because each of them would have +# the relay or the client talk to a service the test did not create. +[blossom] +enabled = false + +[push] +enabled = false diff --git a/e2e/harness/zooid/docker/config/outbox.toml b/e2e/harness/zooid/docker/config/outbox.toml new file mode 100644 index 00000000..142bc163 --- /dev/null +++ b/e2e/harness/zooid/docker/config/outbox.toml @@ -0,0 +1,41 @@ +# The other public relay, identical in policy to indexer.toml. It is a followed pubkey's own write +# relay: somewhere to seed a note that the reader reaches only by resolving that pubkey's relay +# list, which is the whole of the cross-space half of the app. +# +# Keeping it apart from the indexer is what makes routing assertable. A feed that ignored a relay +# list would ask the indexer, find nothing, and fail — where one relay serving both roles would +# pass either way. +# +# `host` must equal the Host header the harness sends, which is the hostname of the url the client +# is given; anything else gets a 404 from the dispatcher instead of a relay. Keep it in step with +# `tenants` in harness/zooid/config.ts, and see harness/zooid/transport.ts for why it is a name +# that resolves nowhere. +host = "outbox.test" +schema = "e2e_outbox" +secret = "07b0c0000000000000000000000000000000000000000000000000000000000a" + +[info] +name = "outbox" +pubkey = "6ada7b6eabb3a8349f88667d278a275c704b553a7c57f9d8156555986884a08e" +description = "Throwaway relay for Flotilla's end-to-end suite." + +[policy] +public_read = true +public_write = true +public_join = false +strip_signatures = false + +# Not a space: nothing seeded here carries an `h` tag, and the client is never offered it as one. +[groups] +enabled = false + +[management] +enabled = false + +# Blossom and push are off, and [livekit] is omitted entirely, because each of them would have +# the relay or the client talk to a service the test did not create. +[blossom] +enabled = false + +[push] +enabled = false diff --git a/e2e/specs/notifications.spec.ts b/e2e/specs/notifications.spec.ts index 8bb139eb..46612fe4 100644 --- a/e2e/specs/notifications.spec.ts +++ b/e2e/specs/notifications.spec.ts @@ -1,14 +1,11 @@ import {neventEncode, npubEncode} from "nostr-tools/nip19" import {HOUR, MINUTE} from "@welshman/lib" import {displayRelayUrl} from "@welshman/util" +import {RelayMessageType} from "@welshman/net" import {Classified, FollowList, MessagingRelayList, Note, RelayList, Thread} from "@welshman/domain" import type {Locator, Page} from "@playwright/test" -import {expect, roomPath, spacePath, test, users} from "../harness" -import type {SeededSpace, TestUser} from "../harness" - -// A handle to a seeded event. SeededEvent isn't exported from the harness, and only its id is ever -// read back here. -type Seeded = {readonly id: string} +import {expect, getTranscript, roomPath, spacePath, test, users} from "../harness" +import type {SeededEvent, SeededSpace, TestUser} from "../harness" // A literal as a pattern, for a url that carries a query string alongside the path being matched, // or a host whose dots would otherwise be wildcards. @@ -472,6 +469,68 @@ test("US-117 read the network feed on home", async ({seed, as}) => { await expect(network.getByRole("button", {name: /\d+\/\d+\/\d+/}).first()).toBeVisible() }) +test("US-117 read a follow who is in none of your spaces", async ({seed, as}) => { + const note = "the lighthouse has been dark since tuesday" + const answer = "the keeper is on holiday" + + const scenario = await seed(({relay, open, user, at}) => { + const space = relay("space") + const indexer = open("indexer") + const outbox = open("outbox") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + + // Alice reads from her space and the indexer. Bob's relay is somewhere she writes and nowhere + // she reads, which is enough for her client to identify to it -- zooid answers no REQ without + // nip-42, and Flotilla only identifies to relays her own lists name -- while leaving it out of + // the read urls a feed's context is asked of. + indexer.relayList(user.alice, { + read: [space.url, indexer.url], + write: [space.url, indexer.url, outbox.url], + }) + indexer.follows(user.alice, [user.bob]) + + // Bob is in none of her spaces and writes nowhere she reads, so his relay list is the only + // thing that can point the feed at his notes. + indexer.relayList(user.bob, {read: [outbox.url], write: [outbox.url]}) + outbox.profile(user.bob, {name: "Bob Barker"}) + + const posted = outbox.note(user.bob, note, at(30, MINUTE)) + + outbox.event( + user.bob, + () => outbox.kind(Note).writer().setParent(posted.event).setContent(answer).renderTemplate(), + at(20, MINUTE), + ) + }) + + const space = scenario.space("space") + const page = await as(users.alice, "/home") + + const network = page + .locator("section") + .filter({has: page.getByRole("heading", {name: "Network"})}) + + await expect(network.getByText(note)).toBeVisible() + await expect(network.getByText("Bob Barker")).toBeVisible() + + // The reply is on bob's relay too, and alice reads from her space rather than from that relay, + // so a context asked of her own read relays comes back with nothing. + await expect(network.getByRole("button", {name: "1 reply", exact: true})).toBeVisible() + + // Her space never held any of it. + const fromSpace = getTranscript(page.context()).filter( + ({url, direction, message}) => + url === space.url && + direction === "toClient" && + message[0] === RelayMessageType.Event && + [note, answer].includes(message[2].content), + ) + + expect(fromSpace).toEqual([]) +}) + test("US-106 share text into the app", async ({seed, as}) => { const shared = "the offsite is moving to the 14th" @@ -526,7 +585,7 @@ test("US-106 share text into the app", async ({seed, as}) => { }) test("US-107 open a nostr link", async ({seed, as}) => { - let posted!: Seeded + let posted!: SeededEvent const scenario = await seed(({relay, user, at}) => { const space = relay("space") diff --git a/e2e/specs/rooms.spec.ts b/e2e/specs/rooms.spec.ts index 9b2b1a49..fa8d6299 100644 --- a/e2e/specs/rooms.spec.ts +++ b/e2e/specs/rooms.spec.ts @@ -13,11 +13,7 @@ import { test, users, } from "../harness" -import type {SeededSpace, TestUser} from "../harness" - -// A handle to a seeded event. SeededEvent isn't exported from the harness, and only its id and -// timestamp are ever read back here. -type Seeded = {readonly id: string; readonly event: {readonly created_at: number}} +import type {SeededEvent, SeededSpace, TestUser} from "../harness" // A path as a pattern, for a url that carries a query string or a modal's hash alongside it. const pathPattern = (path: string) => new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) @@ -192,7 +188,7 @@ test("US-118 messages sent in the same second are in one order for everyone", as seed, as, }) => { - const tied: Seeded[] = [] + const tied: SeededEvent[] = [] const scenario = await seed(({relay, user, at}) => { const space = relay("space") @@ -687,7 +683,7 @@ test("US-025 react to a message", async ({seed, as}) => { }) test("US-026 pin a message and browse pins", async ({seed, as}) => { - let pinned!: Seeded + let pinned!: SeededEvent const scenario = await seed(({relay, user, at}) => { const space = relay("space") @@ -733,8 +729,8 @@ test("US-026 pin a message and browse pins", async ({seed, as}) => { }) test("US-027 find a past message and jump to it", async ({seed, as}) => { - let lastWeek!: Seeded - let older!: Seeded + let lastWeek!: SeededEvent + let older!: SeededEvent const scenario = await seed(({relay, user, at}) => { const space = relay("space") @@ -792,7 +788,7 @@ test("US-027 find a past message and jump to it", async ({seed, as}) => { // event in the room" is the wrong question to hang the button on; whether the loaded window has // caught up to the present is the right one. test("US-027a a permalink near the newest end lands at the bottom", async ({seed, as}) => { - let recent!: Seeded + let recent!: SeededEvent const scenario = await seed(({relay, user, at}) => { const space = relay("space")