Skip to content

@shutter-network/concorde/nostr-channel

The Nostr Channel is a Channel implementation for the Messenger, reaching a User in the Nostr client they already use and letting them reach the shared agent from it. The Messenger owns the log and reaches nobody; a Channel is what reaches a person over one medium. This one exchanges NIP-17 private direct messages over a single connection to one Relay the Operator runs, and a message from a public key the Operator recorded becomes an inbound Message and its Signal in one transaction, so a Signal Handler or a Prompt template written against the Messenger needs no change.

createNostrChannel makes one. NostrChannel is what comes back. Its programmatic API is recordPublicKey, which admits one User to this medium, and publicKey, which is the address an Operator tells that User to write to; everything else on it the Messenger and the Relay drive. NostrChannelOptions takes the shared agent's Nostr secret key as 32 raw bytes, a second keypair that the signing identity neither is nor can become.

It registers no route on either server, a Relay being what a User reaches over this medium, so a deployment running this and nothing else has a Public server carrying only the login. It publishes one thing about itself and no profile, a relay list naming that Relay, so the agent appears in a client as a bare public key.

Construct the Messenger and Users first: the constructor registers with the Messenger, and these public keys belong to Users. A Messenger accepts at most one Channel and refuses a second at registration, so a deployment runs Nostr or HTTP and not both.

A key recorded here decides where the agent writes and nothing else. It grants no access to the HTTP API: that is @shutter-network/concorde/nostr-auth, which keeps a table of its own that this one never reads. A deployment wanting both writes both, and nothing checks that they agree.

The three tables, pubkeys, received and outbox, are on @shutter-network/concorde/nostr-channel/schema and nowhere else. List @shutter-network/concorde/users/schema beside it, because two of those tables reference the Users component's table, and a configuration without it generates a foreign key onto a table nothing creates.

Example

A Gateway a User reaches over Nostr, with their public key recorded out of band.

ts
import { readFileSync } from "node:fs";
import { createGateway } from "@shutter-network/concorde/gateway";
import { createMessenger } from "@shutter-network/concorde/messenger";
import { createNostrChannel } from "@shutter-network/concorde/nostr-channel";
import { createPiRuntime } from "@shutter-network/concorde/pi";
import { createUsers } from "@shutter-network/concorde/users";

// The framework parses no key material: 32 raw bytes, decoded by the deployment.
const secretKey = Uint8Array.from(
  Buffer.from(readFileSync(process.env.NOSTR_KEY_FILE ?? "", "utf8").trim(), "hex"),
);

const gateway = createGateway({
  databaseUrl: process.env.DATABASE_URL ?? "",
  runtime: createPiRuntime({ image: "my-agent:1" }),
  // Not loopback: the agent reaches this server from a container of its own.
  agentListen: { host: "0.0.0.0", port: 8081 },
  publicListen: { host: "0.0.0.0", port: 8080 },
  extend: ({ db, agentServer, worker }) => {
    // No Public server here: this deployment accepts no HTTP scheme at all, and
    // `GET /users/me` is unbuildable without one.
    const users = createUsers({ db, agentServer });
    const messenger = createMessenger({ db, users, worker, agentServer });
    return {
      users,
      messenger,
      nostr: createNostrChannel({
        db,
        messenger,
        users,
        secretKey,
        relayUrl: process.env.RELAY_URL ?? "",
      }),
    };
  },
  handlers: () => ({}),
});

await gateway.start();

// Admission, out of band and from trusted code, in a transaction of the Operator's own.
const { db, nostr } = gateway.components;
await db.tx((tx) => nostr.recordPublicKey(tx, "a-user-id", "ab".repeat(32)));

// What an Operator tells that User to message.
console.log(nostr.publicKey);

Classes

MalformedPublicKeyError

The public key offered was not a Nostr public key: 64 lowercase hex characters are what one is.

Refused at the call site rather than stored, because a stored one fails silently and permanently. A key written as an npub1…, in upper case, or with a 0x in front of it is compared byte for byte against the author of every decrypted message and matches none of them, so the User never hears from the agent and nothing anywhere says why.

Nothing here decodes anything. An Operator holding an npub calls nip19.decode on it themselves, the way they decode the secret key the constructor takes.

Extends

  • Error

Constructors

Constructor
ts
new (publicKey: string): MalformedPublicKeyError;
Parameters
publicKey

string

Returns

MalformedPublicKeyError

Overrides
ts
Error.constructor

MessageTooLargeError

The finished gift wrap is larger than the Relay said it accepts.

Thrown inside the caller's transaction and before the Message row survives, so an over-long reply is a refusal at the call site rather than something that fails after the fact. What is measured is the whole message that goes on the wire and not the reply, because sealing more than doubles its length: reckon on a 1.4 KB floor plus 2.1 times the reply, the payload being base64 of base64, so a 32 KB reply is roughly a 66 KB wrap against a common Relay default of 65536.

The maximum comes from the Relay's own NIP-11 document. A Relay that advertises none is a Relay this is never thrown for, and an over-long reply is then whatever that Relay does with it.

Extends

  • Error

Constructors

Constructor
ts
new (
  userId: string,
  bytes: number,
  limit: number
): MessageTooLargeError;
Parameters
userId

string

bytes

number

limit

number

Returns

MessageTooLargeError

Overrides
ts
Error.constructor

NoSuchUserError

No User has that id, so no key was recorded for them.

The write itself is what establishes that the User exists, so a User created earlier in the caller's own transaction counts and needs no commit first.

Extends

  • Error

Constructors

Constructor
ts
new (userId: string): NoSuchUserError;
Parameters
userId

string

Returns

NoSuchUserError

Overrides
ts
Error.constructor

PublicKeyConflictError

The key, or the User, is already spoken for.

Both directions are refused. A key already recorded cannot be claimed by a second User, or one person's messages would land in another's log; and a User already holding a key cannot be given a second, because there would then be no answer to which one the agent writes back to.

Neither is replaced. Whichever mapping exists is the one that stays, and getting rid of it is a delete an Operator writes against the table.

Extends

  • Error

Constructors

Constructor
ts
new (userId: string, publicKey: string): PublicKeyConflictError;
Parameters
userId

string

publicKey

string

Returns

PublicKeyConflictError

Overrides
ts
Error.constructor

UnrecordedPublicKeyError

The User has no Nostr public key recorded, so there is no address to answer them at.

Thrown inside the transaction the Message is being written in and before that Message row survives, which is the point: a Message recorded as sent that nothing can deliver is a durable claim that somebody was told something. Nothing was written, and the fix is recording a key rather than sending again.

Extends

  • Error

Constructors

Constructor
ts
new (userId: string): UnrecordedPublicKeyError;
Parameters
userId

string

Returns

UnrecordedPublicKeyError

Overrides
ts
Error.constructor

Type Aliases

NostrChannel

ts
type NostrChannel = Channel & {
  readonly publicKey: string;
  drain: () => Promise<void>;
  recordPublicKey: <TSchema>(tx: Handle<TSchema>, userId: string, publicKey: string) => Promise<void>;
  send: <TSchema>(tx: Handle<TSchema>, message: MessageRecord) => Promise<void>;
  start: () => Promise<void>;
  stop: () => Promise<void>;
};

The Nostr Channel as a Component: an identity, one Relay connection, and the one act of admission.

Three tables are what it keeps, and no Message is among them: which public key belongs to which User, which envelopes it has already turned into Messages, and which replies the Relay has not taken yet. The Messages themselves are the Messenger's, whichever medium they travelled by.

It admits nobody by itself. A message from a public key nobody recorded through NostrChannel.recordPublicKey is dropped with nothing stored for it, so a stranger who learns the agent's public key can neither reach the log nor grow the tables.

The Relay connection is real work at both ends: nothing connects at construction, start opens it and stop closes it. What survives a stop is what PostgreSQL holds. A reply that was queued and not published keeps its row and goes out at the next start, and a Message already written stays written.

Type Declaration

publicKey
ts
readonly publicKey: string;

The shared agent's own Nostr public key, in lowercase hex, derived from the secret key it was built with.

What a User's client shows as the agent, and what an Operator tells a User to message. It is an address as well as an identity, which is what makes it unrotatable in practice: every recorded key was written from the other side, and every User's client holds this one.

Hex and not an npub, for the reason the constructor takes bytes. An Operator who wants the human-facing form calls nip19.npubEncode on it themselves.

drain()
ts
drain(): Promise<void>;

Publishes every queued reply the Relay has not answered for yet, and resolves when none is left.

The half of a send that happens after the commit, exposed so that a caller can wait for it rather than for a database notification. A running deployment needs no call: start wires the notification a queued reply raises to this same method.

A reply the Relay accepts leaves no trace. A reply it refuses keeps its row, carrying the Relay's own reason, and is never attempted again, not by a later notification and not by a later process. A refusal is a row and a log line rather than a throw.

A reply the Relay took but never answered for, because the process stopped between the two, still has its row and goes out again at the next start. Both the Relay and the recipient's client key on the event's own id, so what a User sees is still one message.

It publishes nothing while the Channel is stopped, and whatever is queued then waits for the next start.

Returns

Promise<void>

recordPublicKey()
ts
recordPublicKey<TSchema>(
  tx: Handle<TSchema>,
  userId: string,
  publicKey: string
): Promise<void>;

Records that one Nostr public key belongs to one User, and proves nothing.

The Operator establishes out of band that the key is that person's, and this stores what they decided. It is the whole of admission over this medium, and deliberately the whole: no route on either server records a key, because recording one grants access to a Message log, so it sits with the other writes an injected prompt cannot reach. The cost is that the agent cannot admit a stranger.

A write, so it takes the caller's transaction first: the key and whatever the Operator records about the admission commit together or not at all. publicKey is 64 lowercase hex characters, which is what a Nostr public key is on the wire.

It replaces nothing. There is no rotation here, in the same sense that there is none for either identity.

Type Parameters
TSchema

TSchema extends Record<string, unknown>

Parameters
tx

Handle<TSchema>

userId

string

publicKey

string

Returns

Promise<void>

Throws

MalformedPublicKeyError if that is not what publicKey is.

Throws

NoSuchUserError if no User has that id.

Throws

PublicKeyConflictError if that key belongs to another User, or that User already has one. Every refusal here runs in a savepoint, so none of them aborts the caller's transaction.

send()
ts
send<TSchema>(tx: Handle<TSchema>, message: MessageRecord): Promise<void>;

Takes an outbound Message inside the transaction writing it, and publishes nothing.

The Messenger calls this, and trusted code reaches its send instead and gets this for free. What happens here is everything knowable before a commit: the recipient's key is read on the caller's own transaction, the reply is sealed into one gift wrap, its size on the wire is compared against what the Relay advertises, and the finished wrap is queued. A failure at any of those steps throws and rolls the Message back with it, so a Message recorded as sent was always one that could go out.

It never touches the Relay. The publish waits for the commit and happens in NostrChannel.drain, so a rollback after this returns leaves nobody holding words the log denies.

Type Parameters
TSchema

TSchema extends Record<string, unknown>

Parameters
tx

Handle<TSchema>

message

MessageRecord

Returns

Promise<void>

Throws

UnrecordedPublicKeyError if no Nostr public key is recorded for that User.

Throws

MessageTooLargeError if the wrap exceeds the Relay's advertised maximum message length. The Relay is asked for that maximum once per connection, so a Channel that has not started has not asked and bounds nothing: an over-long reply sent to a stopped Channel is queued, and fails once at the next start rather than here.

start()
ts
start(): Promise<void>;

Opens the connection to the Relay, subscribes to the agent's own gift wraps, publishes the agent's relay list, and publishes whatever a previous process left queued.

Nothing connects before this, and this waits for none of it: a Relay that is down is an outage rather than a boot failure, and the client reconnects with a backoff of its own. A second start finds a client already built and does nothing.

The relay list is one event naming the Relay this Channel was built with, and it is the only thing the agent publishes about itself. It buys two narrow things and not discoverability: a client that refuses to message a public key with no such list will message this one, and a client that reads one is steered to the right Relay. Only a client already on that Relay can read it. A Relay that refuses it is a warning on the log and a Channel that started anyway, and a restart says it again at no cost, the kind being replaceable.

Returns

Promise<void>

stop()
ts
stop(): Promise<void>;

Closes the connection, and stops both admitting what arrives on it and publishing what is queued for it.

It returns once nothing is in flight, so a Message half-written when shutdown began is either committed or rolled back before the Db is closed under it. A publish interrupted here leaves its row untouched rather than marking it refused, so the next start attempts it.

Returns

Promise<void>


NostrChannelOptions

ts
type NostrChannelOptions = {
  readonly db: Db;
  readonly logger?: Logger;
  readonly messenger: Messenger;
  readonly relayUrl: string;
  readonly secretKey: Uint8Array;
  readonly users: Users;
};

Properties

db
ts
readonly db: Db;

The Db this component queries through, and where the inbound transaction is opened.

Writing the Message, emitting its Signal and recording that this envelope was read are one act, so they share one transaction of this component's own.

logger?
ts
readonly logger?: Logger;

Defaults to a pino instance on stdout.

A dropped envelope is a debug line and the only trace of it anywhere, nothing being stored for one. A reply the Relay refused is an error line beside the queue row that keeps the reason, and a relay list the Relay refused is a warning and nothing else.

messenger
ts
readonly messenger: Messenger;

The Messenger that owns the log. Construct it before this.

The constructor registers with it, which is what makes this the Channel that reaches people and hands back the only way to write an inbound Message. A second Channel on the same Messenger is refused there, so a deployment runs one medium.

relayUrl
ts
readonly relayUrl: string;

The Relay to connect to, as a ws:// or wss:// address.

One Relay, and the Operator's own, so that Users' conversations do not traverse a stranger's server. It is used exactly as given, with no normalisation: Relays treat trailing variants as distinct addresses, and the address this agent authenticates with and the address it publishes in its relay list are compared by whatever rule the Relay chose.

secretKey
ts
readonly secretKey: Uint8Array;

The shared agent's Nostr secret key: 32 raw bytes, and the second keypair a deployment running this holds.

Raw bytes because that is both Nostr libraries' own convention, and because the framework parses no key material and generates none. An Operator reads their own key and states it here, exactly as they hand a KeyObject they built themselves to the signing identity. No nsec decoder is shipped, so an Operator holding one calls nip19.decode themselves.

It cannot be the signing identity and could not become one, that key being Ed25519 and this curve secp256k1. Copying this one impersonates the agent to its Users; copying that one forges its commitments.

users
ts
readonly users: Users;

The Users component whose Users these public keys belong to.

Nothing is called on it. It is named because pubkeys.user_id is a foreign key onto that table of Users, so this component needs the real one rather than something shaped like it: there is no route here to authenticate, and a Nostr public key is not a credential the Gateway issued.

Functions

createNostrChannel()

ts
function createNostrChannel(options: NostrChannelOptions): NostrChannel;

Builds the Nostr Channel and registers it with the Messenger.

Nothing here connects, listens or applies DDL.

Parameters

options

NostrChannelOptions

Returns

NostrChannel

Throws

ChannelAlreadyRegisteredError if a Channel is already registered with that Messenger.