diff --git a/README.md b/README.md index 52eb95e..4632d85 100644 --- a/README.md +++ b/README.md @@ -52,5 +52,6 @@ You can now utilize the GraphQL Playground to communicate with the server. Relay url is `ws://localhost:8000`. -### Join our community -wss://relayed.deno.dev \ No newline at end of file +### Join our community + +wss://relayed.deno.dev diff --git a/deploy/example.ts b/deploy/example.ts index 1d3a5ea..d1223ea 100644 --- a/deploy/example.ts +++ b/deploy/example.ts @@ -6,7 +6,7 @@ const relay = await run({ allowed_kinds: "all", // or none, }, password: Deno.env.get("relayed_pw"), - information: { + default_information: { name: "Relayed Example", description: "A lightweight relay written in Deno.", pubkey: "", diff --git a/graphql-schema.ts b/graphql-schema.ts index c817d2f..d77f6bb 100644 --- a/graphql-schema.ts +++ b/graphql-schema.ts @@ -5,6 +5,7 @@ export const typeDefs = gql` events(pubkey: String, offset: Int, limit: Int): Events event(id: String): Event policies: [Policy] + relayInformation: RelayInformation } type Mutation { @@ -13,6 +14,7 @@ export const typeDefs = gql` add_allow(kind: Int, pubkey: String, ): Policy! remove_allow(kind: Int, pubkey: String, ): Policy! set_policy(kind: Int, read: Boolean, write: Boolean): Policy! + set_relay_information(name: String, description: String, pubkey: String, contact: String, icon: String): RelayInformation! } type Events { @@ -40,4 +42,14 @@ export const typeDefs = gql` allow: [String!]! block: [String!]! } + type RelayInformation { + name: String + description: String + pubkey: String + contact: String + supported_nips: [Int!] + software: String + version: String + icon: String + } `; diff --git a/main.tsx b/main.tsx index 4276921..7c7c724 100644 --- a/main.tsx +++ b/main.tsx @@ -14,6 +14,7 @@ import { Policies } from "./resolvers/policy.ts"; import { interface_GetEventsByAuthors } from "./resolvers/event.ts"; import Landing from "./routes/landing.tsx"; import Error404 from "./routes/_404.tsx"; +import { RelayInformation, RelayInformationStore } from "./resolvers/nip11.ts"; const schema = gql.buildSchema(gql.print(typeDefs)); @@ -24,6 +25,7 @@ export type DefaultPolicy = { export type Relay = { server: Deno.HttpServer; url: string; + password: string; shutdown: () => Promise; set_policy: (args: { kind: NostrKind; @@ -32,6 +34,8 @@ export type Relay = { block?: Set; }) => Promise; get_policy: (kind: NostrKind) => Promise; + set_relay_information: (args: RelayInformation) => Promise; + get_relay_information: () => Promise; default_policy: DefaultPolicy; }; @@ -39,7 +43,7 @@ export async function run(args: { port: number; admin?: PublicKey; password?: string; - information?: RelayInformation; + default_information?: RelayInformation; default_policy: DefaultPolicy; kv?: Deno.Kv; }): Promise { @@ -55,7 +59,7 @@ export async function run(args: { args.kv = await Deno.openKv(); } - const { port, default_policy } = args; + const { port, default_policy, default_information } = args; let resolve_hostname; const hostname = new Promise((resolve) => { @@ -64,6 +68,10 @@ export async function run(args: { const get_all_policies = Policies(args.kv); const policyStore = new PolicyStore(default_policy, args.kv, await get_all_policies()); + const relayInformationStore = new RelayInformationStore( + args.kv, + default_information, + ); const eventStore = await EventStore.New(args.kv); @@ -86,12 +94,14 @@ export async function run(args: { get_events_by_kinds: eventStore.get_events_by_kinds.bind(eventStore), get_events_by_authors: eventStore.get_events_by_authors.bind(eventStore), policyStore, + relayInformationStore, kv: args.kv, }), ); return { server, + password, url: `ws://${await hostname}:${port}`, shutdown: async () => { await server.shutdown(); @@ -99,6 +109,8 @@ export async function run(args: { }, set_policy: policyStore.set_policy, get_policy: policyStore.resolvePolicyByKind, + set_relay_information: relayInformationStore.set_relay_information, + get_relay_information: relayInformationStore.resolveRelayInformation, default_policy: args.default_policy, }; } @@ -117,6 +129,7 @@ const root_handler = ( default_policy: DefaultPolicy; resolvePolicyByKind: func_ResolvePolicyByKind; policyStore: PolicyStore; + relayInformationStore: RelayInformationStore; kv: Deno.Kv; } & EventReadWriter, ) => @@ -143,68 +156,66 @@ async (req: Request, info: Deno.ServeHandlerInfo) => { return resp; }; -const graphql_handler = - (args: { password: string; kv: Deno.Kv; policyStore: PolicyStore }) => async (req: Request) => { - const { password, policyStore } = args; - if (req.method == "POST") { - const query = await req.json(); - const nip42 = req.headers.get("nip42"); - console.log("nip42 header", nip42); +const graphql_handler = ( + args: { + password: string; + kv: Deno.Kv; + policyStore: PolicyStore; + relayInformationStore: RelayInformationStore; + }, +) => +async (req: Request) => { + const { password, policyStore } = args; + if (req.method == "POST") { + const query = await req.json(); + const nip42 = req.headers.get("nip42"); + console.log("nip42 header", nip42); - const pw = req.headers.get("password"); - if (pw != password) { - return new Response(`{"errors":"incorrect password"}`); - } + const pw = req.headers.get("password"); + if (pw != password) { + return new Response(`{"errors":"incorrect password"}`); + } - if (nip42) { - const auth_event = parseJSON(nip42); - if (auth_event instanceof Error) { - return new Response(`{errors:["no auth"]}`); - } - const ok = await verifyEvent(auth_event); - if (!ok) { - return new Response(`{"errors":["no auth"]}`); - } + if (nip42) { + const auth_event = parseJSON(nip42); + if (auth_event instanceof Error) { + return new Response(`{errors:["no auth"]}`); + } + const ok = await verifyEvent(auth_event); + if (!ok) { + return new Response(`{"errors":["no auth"]}`); } - const result = await gql.graphql({ - schema: schema, - source: query.query, - variableValues: query.variables, - rootValue: RootResolver(args), - }); - console.log(result); - return new Response(JSON.stringify(result)); - } else if (req.method == "GET") { - const res = new Response(graphiql); - res.headers.set("content-type", "html"); - return res; - } else { - return new Response(undefined, { status: 405 }); } - }; - -export type RelayInformation = { - name?: string; - description?: string; - pubkey?: string; - contact?: string; - supported_nips?: number[]; - software?: string; - version?: string; - icon?: string; + const result = await gql.graphql({ + schema: schema, + source: query.query, + variableValues: query.variables, + rootValue: RootResolver(args), + }); + console.log(result); + return new Response(JSON.stringify(result)); + } else if (req.method == "GET") { + const res = new Response(graphiql); + res.headers.set("content-type", "html"); + return res; + } else { + return new Response(undefined, { status: 405 }); + } }; export const supported_nips = [1, 2]; export const software = "https://github.com/BlowaterNostr/relayed"; -const landing_handler = (args: { information?: RelayInformation }) => { - const resp = new Response(render(Landing(args.information)), { status: 200 }); +const landing_handler = async (args: { relayInformationStore: RelayInformationStore }) => { + const resp = new Response( + render(Landing(await args.relayInformationStore.resolveRelayInformation()), { status: 200 }), + ); resp.headers.set("content-type", "html"); return resp; }; -const information_handler = (args: { information?: RelayInformation }) => { - const resp = new Response(JSON.stringify({ ...args.information, supported_nips, software }), { +const information_handler = async (args: { relayInformationStore: RelayInformationStore }) => { + const resp = new Response(JSON.stringify(await args.relayInformationStore.resolveRelayInformation()), { status: 200, }); resp.headers.set("content-type", "application/json; charset=utf-8"); diff --git a/queries/getRelayInformation.gql b/queries/getRelayInformation.gql new file mode 100644 index 0000000..b7fd978 --- /dev/null +++ b/queries/getRelayInformation.gql @@ -0,0 +1,12 @@ +query getRelayInformation { + relayInformation { + name + contact + description + icon + pubkey + software + supported_nips + version + } +} diff --git a/queries/setRelayInformation.gql b/queries/setRelayInformation.gql new file mode 100644 index 0000000..f24960f --- /dev/null +++ b/queries/setRelayInformation.gql @@ -0,0 +1,18 @@ +mutation setRelayInformation($pubkey: String, $contact: String, $name: String, $description: String, $icon: String) { + set_relay_information( + pubkey: $pubkey + contact: $contact + name: $name + description: $description + icon: $icon + ) { + name + contact + description + icon + pubkey + software + supported_nips + version + } +} diff --git a/resolvers/nip11.ts b/resolvers/nip11.ts new file mode 100644 index 0000000..a557b07 --- /dev/null +++ b/resolvers/nip11.ts @@ -0,0 +1,51 @@ +export type RelayInformation = { + name?: string; + description?: string; + pubkey?: string; + contact?: string; + supported_nips?: number[]; + software?: string; + version?: string; + icon?: string; +}; + +const not_modifiable_information: { + software: string; + supported_nips: number[]; + version: string; +} = { + software: "https://github.com/BlowaterNostr/relayed", + supported_nips: [1, 2, 11], + version: "RC5", +}; + +export class RelayInformationStore { + default_information: RelayInformation; + + constructor( + private kv: Deno.Kv, + default_information?: RelayInformation, + ) { + this.default_information = default_information ? default_information : {}; + } + + resolveRelayInformation = async (): Promise => { + const get_relay_information = (await this.kv.get(["relay_information"])).value; + return { ...this.default_information, ...get_relay_information, ...not_modifiable_information }; + }; + + set_relay_information = async ( + args: { + name?: string; + description?: string; + pubkey?: string; + contact?: string; + icon?: string; + }, + ) => { + const old_information = await this.resolveRelayInformation(); + const new_information = { ...old_information, ...args }; + await this.kv.set(["relay_information"], new_information); + return { ...new_information, ...not_modifiable_information }; + }; +} diff --git a/resolvers/root.ts b/resolvers/root.ts index 28ef963..0968e5f 100644 --- a/resolvers/root.ts +++ b/resolvers/root.ts @@ -1,11 +1,13 @@ import { PolicyStore } from "./policy.ts"; import { Policies } from "./policy.ts"; +import { RelayInformationStore } from "./nip11.ts"; export const Mutation = (args: { policyStore: PolicyStore; + relayInformationStore: RelayInformationStore; kv: Deno.Kv; }) => { - const { policyStore, kv } = args; + const { policyStore, relayInformationStore, kv } = args; return { add_block: async (args: { kind: number; pubkey: string }) => { const policy = await policyStore.resolvePolicyByKind(args.kind); @@ -32,15 +34,18 @@ export const Mutation = (args: { return policy; }, set_policy: policyStore.set_policy, + set_relay_information: relayInformationStore.set_relay_information, }; }; export function RootResolver(args: { policyStore: PolicyStore; + relayInformationStore: RelayInformationStore; kv: Deno.Kv; }) { return { policies: Policies(args.kv), + relayInformation: args.relayInformationStore.resolveRelayInformation, ...Mutation(args), }; } diff --git a/routes/landing.tsx b/routes/landing.tsx index ce8af52..7aef46f 100644 --- a/routes/landing.tsx +++ b/routes/landing.tsx @@ -1,4 +1,5 @@ -import { RelayInformation, software, supported_nips } from "../main.tsx"; +import { software, supported_nips } from "../main.tsx"; +import { RelayInformation } from "../resolvers/nip11.ts"; export default function Landing(information?: RelayInformation) { return ( diff --git a/test.ts b/test.ts index 3326c3b..3ed4272 100644 --- a/test.ts +++ b/test.ts @@ -14,17 +14,28 @@ import { SubscriptionStream, } from "./_libs.ts"; -Deno.test("main", async (t) => { +const test_kv = async () => { try { await Deno.remove("test.sqlite"); } catch (e) {} + return await Deno.openKv("test.sqlite"); +}; + +// Need to keep consistent with resolvers/nip11.ts +const not_modifiable_information = { + software: "https://github.com/BlowaterNostr/relayed", + supported_nips: [1, 2, 11], + version: "RC5", +}; + +Deno.test("main", async (t) => { const relay = await run({ password: "123", port: 8080, default_policy: { allowed_kinds: "none", }, - kv: await Deno.openKv("test.sqlite"), + kv: await test_kv(), }) as Relay; const policy = await relay.get_policy(NostrKind.CONTACTS); @@ -129,7 +140,67 @@ Deno.test("main", async (t) => { await relay.shutdown(); }); -Deno.test("graphql", async () => {}); +// https://github.com/nostr-protocol/nips/blob/master/11.md +Deno.test("NIP-11: Relay Information Document", async (t) => { + const relay = await run({ + password: "123", + port: 8080, + default_policy: { + allowed_kinds: "none", + }, + default_information: { + name: "Nostr Relay", + }, + kv: await test_kv(), + }) as Relay; + + await t.step("get relay information", async () => { + const information = await relay.get_relay_information(); + console.log(`information`, information); + + assertEquals(information, { name: "Nostr Relay", ...not_modifiable_information }); + }); + + await t.step("set relay information", async () => { + await relay.set_relay_information({ + name: "Nostr Relay2", + }); + + const information2 = await relay.get_relay_information(); + assertEquals(information2, { name: "Nostr Relay2", ...not_modifiable_information }); + }); + + await t.step("graphql get relay information", async () => { + const query = await Deno.readTextFile("./queries/getRelayInformation.gql"); + const json = await queryGql(relay, query); + assertEquals(json.data.relayInformation, { + name: "Nostr Relay2", + icon: null, + contact: null, + description: null, + pubkey: null, + ...not_modifiable_information, + }); + }); + + await t.step("graphql set relay information", async () => { + const variables = { + name: "Nostr Relay3", + }; + const query = await Deno.readTextFile("./queries/setRelayInformation.gql"); + const json = await queryGql(relay, query, variables); + assertEquals(json.data.set_relay_information, { + name: "Nostr Relay3", + icon: null, + contact: null, + description: null, + pubkey: null, + ...not_modifiable_information, + }); + }); + + await relay.shutdown(); +}); async function randomEvent(ctx: InMemoryAccountContext, kind?: NostrKind, content?: string) { const event = await prepareNormalNostrEvent(ctx, { @@ -138,3 +209,16 @@ async function randomEvent(ctx: InMemoryAccountContext, kind?: NostrKind, conten }); return event; } + +async function queryGql(relay: Relay, query: string, variables?: Record) { + const { hostname, port } = new URL(relay.url); + const res = await fetch(`http://${hostname}:${port}/api`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "password": relay.password, + }, + body: JSON.stringify({ query, variables }), + }); + return await res.json(); +}