Nostr Scheduler DVM

Portable Dart core for a Nostr Scheduler DVM.

The package accepts encrypted kind:5905 schedule requests, publishes the signed target event at schedule_at, handles kind:5 cancellations, and sends private encrypted kind:7000 feedback.

Features

  • Scheduler DVM runtime for the draft Scheduler DVM protocol.
  • NIP-44 request decryption and feedback encryption.
  • NIP-65 relay resolution for the DVM pubkey.
  • Catch-up of requests and cancellations published while the DVM was offline, through sync_engine_shim_for_ndk.
  • Optional NIP-89 discovery announcement.
  • Pluggable job persistence through DvmJobStore, with a Sembast implementation included.
  • Portable library code: no dart:io imports in lib/.

Usage

Use a dedicated NDK instance logged in as the DVM:

final syncEngine = SyncEngine(dvmNdk, db: database)..start();

final dvm = SchedulerDvm(
  SchedulerDvmConfig(
    ndk: dvmNdk,
    store: SembastDvmJobStore(database),
    syncEngine: syncEngine,
    bootstrapRelays: ['wss://relay.damus.io'],
  ),
);

await dvm.start();

Or embed a DVM in an app that already has an NDK account logged in by passing the Scheduler DVM signer explicitly:

final dvm = SchedulerDvm(
  SchedulerDvmConfig(
    ndk: appNdk,
    signer: schedulerDvmSigner,
    store: SembastDvmJobStore(database),
    syncEngine: syncEngine,
    bootstrapRelays: ['wss://relay.damus.io'],
  ),
);

await dvm.start();

SchedulerDvmConfig persists jobs through the supplied DvmJobStore and uses ndk.config.eventVerifier to validate the scheduled signed event. The caller owns the NDK lifecycle, signer lifecycle, database lifecycle, and sync engine lifecycle: start the engine, and dispose it after the DVM.

A request scheduled further ahead than maxScheduleAhead (10 years by default), or asking for more than maxRelaysPerJob target relays (20 by default), is rejected with an error feedback.

A request whose schedule_at is further in the past than maxScheduleBehind (one week by default) is ignored instead: no job, no feedback. The same cutoff applies to a job that became due while the DVM was stopped, so a DVM restarted after a long outage does not publish a backlog of stale events.

Target relays go through targetRelayPolicy, which accepts public wss:// relays only, so a request cannot point the DVM at ws://localhost or at a private address of the network it runs on. The check is lexical: a hostname that resolves to a private address still passes. For a DVM serving its own machine, or for tests, pass RelayUrlPolicy.permissive:

SchedulerDvmConfig(
  ...
  targetRelayPolicy: RelayUrlPolicy.permissive,
);

A target relay that answers auth-required (NIP-42) is answered with a key generated for that publish alone, so it cannot tie two scheduled events to the same DVM. Nothing is revealed to a relay that does not ask: the event goes out on the anonymous connection first. A DVM whitelisted on the relays it publishes to needs its own key instead, which no ephemeral one can stand in for:

SchedulerDvmConfig(
  ...
  targetRelayAuth: TargetRelayAuth.dvm,
);

TargetRelayAuth.never fails the publish rather than name anyone.

Live subscriptions deliver requests as they are published. The sync engine keeps them, and the cancellations, synced into the NDK cache, so whatever was published while the DVM was stopped or disconnected is processed on the next pass. Give NDK a persistent cache: the engine remembers what it already synced, so a cache emptied under it is not fetched again.

To use another database (SQLite, Drift, etc.), implement DvmJobStore and pass it as store. DvmJob.toJson() and DvmJob.fromJson() handle serialization.

Protocol

The wire format is the Scheduler DVM spec.

One deviation from it, for backward compatibility: feedback also carries ["ephemeral-pubkey", "<dvm_pubkey>"], where clients written against the revision that encrypted feedback with a one-time key read the sender key. Pass legacyEphemeralPubkeyTag: false to emit the spec tags only.

Checks

dart format --set-exit-if-changed .
dart analyze
dart test

Libraries

nostr_scheduler_dvm
Portable core of a Nostr Scheduler DVM, which publishes a client's signed event at the time it asks for.