Files
codium_nostr_extension/plans/phase0-raw-event-publishing.md
T
2026-07-18 17:29:20 -04:00

9.1 KiB

Phase 0 — Raw Unsigned Event Publishing

Goal

Before any NIP-23 ergonomics, front-matter parsing, or long-form-specific logic, ship the thinnest possible end-to-end path:

  1. The extension can generate a skeleton unsigned event JSON file in the correct Nostr format.
  2. The user edits the file to fill in kind, content, tags, etc.
  3. The extension validates the file against the Nostr event shape.
  4. The extension signs the event (Phase 1 local key) and publishes it to the configured relays.

This proves the signer + relay pipeline works before any higher-level abstractions are layered on. Phase 1 (long-form notes) then becomes a specialized generator + builder on top of the same validate → sign → publish core.

Why a separate phase

  • Smallest testable slice: one command to create, one to publish, no parsing complexity.
  • Forces the Signer + relay publisher to be correct in isolation, without front-matter or NIP-23 tag logic in the critical path.
  • The generated skeleton doubles as documentation of the event format — the user can see exactly what fields exist.
  • The validator is reusable: Phase 1's NIP-23 builder will run its output through the same validator before signing.

Commands

Command ID Title When
nostr.createUnsignedEvent Nostr: Create Unsigned Event always
nostr.publishEventFile Nostr: Publish Event from JSON File editorLangId == json
nostr.validateEventFile Nostr: Validate Event File editorLangId == json

nostr.signIn and nostr.signOut are also part of Phase 0 — signing requires a key. They are unchanged from the existing plan (nsec/hex, in-memory, no persistence).

nostr.createUnsignedEvent — skeleton generator

Flow

  1. vscode.window.showInputBox({ prompt: "Event kind (integer, e.g. 1 for text note, 30023 for long-form)" }).
    • Validate it parses as a non-negative integer. Abort on empty/invalid.
    • Optional: offer a QuickPick of common kinds (1, 30023, 10002, 0, 3) with "Custom..." as the last entry. Keeps it fast for the common case while allowing any kind.
  2. Build a skeleton EventTemplate:
    {
      "kind": <chosen>,
      "created_at": <now, unix seconds>,
      "tags": [],
      "content": ""
    }
    
    • No pubkey — the signer injects it from getPublicKey() at sign time.
    • No id / sig — those are produced by signing.
  3. Open the skeleton in a new untitled JSON editor (vscode.workspace.openTextDocument({ content, language: "json" }) then showTextDocument). The user can Save As... to a .json file or publish directly from the untitled buffer.

Why untitled rather than writing a file

The user may not want a leftover .json on disk for a one-off event. Untitled buffers let them decide: save it for reuse, or discard after publishing. If they save it, the file-based commands below work on it.

nostr.validateEventFile — validator

Validates the active JSON document against the Nostr unsigned-event shape. Reusable by Phase 1's NIP-23 path.

Validation rules

  1. Document parses as JSON (else: "Invalid JSON: ").
  2. Top-level is an object with exactly these keys (for an unsigned event): kind, created_at, tags, content. Extra keys → warning (non-blocking).
    • pubkey, id, sig are absent in an unsigned event. If present, warn: "This looks like a signed event; publish will re-sign and overwrite id/sig." (Non-blocking — lets users re-sign an existing event.)
  3. kind is a non-negative integer.
  4. created_at is a non-negative integer (unix seconds).
  5. tags is an array of arrays of strings. Each inner array has length ≥ 1.
  6. content is a string (may be empty).
  7. If kind is a known constant from nostr-tools/kinds.ts (e.g. 30023 → "LongFormArticle"), include the friendly name in the output.

Output

vscode.window.showInformationMessage (all valid) or showErrorMessage (hard errors), plus a showWarningMessage for soft warnings. Example success:

Valid unsigned event. kind=30023 (LongFormArticle) tags=0 content=1234 chars

This is non-blocking — no publish happens. It's the user's pre-flight check.

nostr.publishEventFile — sign + publish

Flow

  1. Require signed-in signer (state.secretKey present for Phase 1). If not, prompt: "Sign in first" with an action button that runs nostr.signIn.
  2. Read the active JSON document text.
  3. Validate (same rules as nostr.validateEventFile). On hard error, abort with the message.
  4. Parse into an EventTemplate:
    const template: EventTemplate = {
      kind: obj.kind,
      created_at: obj.created_at,
      tags: obj.tags,
      content: obj.content,
    };
    
  5. Sign via the active NostrSigner:
    const signed: VerifiedEvent = await signer.signEvent(template);
    
    • signer.signEvent injects pubkey, computes id, and produces sig.
    • For LocalSigner this is PlainKeySigner.signEvent → finalizeEvent.
  6. Show a confirmation dialog (unless nostr.publish.confirm is false):
    • Preview: kind, pubkey (npub), id, tags count, content length, target relays.
    • Relay checkboxes (all configured relays checked by default).
    • Publish / Cancel.
  7. Publish to selected relays via publishToRelays(signed, relays).
  8. Show result: per-relay OK/failed summary + the event id and a nevent1 / naddr1 identifier (for kind 30023) the user can copy.

Re-signing an already-signed event

If the JSON file contains pubkey/id/sig, the validator warns but the publisher strips them and re-signs with the active key. This lets the user:

  • Take an event someone else published, change content, re-sign as themselves.
  • Recover from signing with the wrong key.

The on-disk file is not modified — the signed event exists only in memory for the publish. A future "Save Signed Event" command could write it back, but that's out of scope for Phase 0.

File format

The .json file is a plain Nostr EventTemplate:

{
  "kind": 30023,
  "created_at": 1721329200,
  "tags": [
    ["d", "my-post"],
    ["title", "My Post"]
  ],
  "content": "# My Post\n\nBody text..."
}

This is exactly what nostr-tools' Signer.signEvent(event: EventTemplate) expects — no extension-specific wrapper, no custom schema. A user could hand the same file to any other Nostr tooling that accepts EventTemplate JSON.

Relationship to later phases

flowchart TD
    A[Phase 0: raw event JSON] --> B[validate]
    B --> C[sign via Signer]
    C --> D[publish to relays]
    E[Phase 1: .md front-matter] --> F[nip23 builder]
    F --> B
    G[Phase 2: n_signer backend] --> C

Phase 1's nostr.publishLongForm command:

  1. Parses front-matter from the .md file.
  2. Builds an EventTemplate via buildLongFormEvent.
  3. Runs the same validator on the built template.
  4. Calls the same sign + publish path.

So Phase 0's validate/sign/publish core is reused verbatim — Phase 1 only adds the front-matter → EventTemplate translation and the publish dialog's NIP-23-specific preview fields.

Phase 2 swaps the Signer implementation from LocalSigner to NsignerBackend; the validate/publish code is unchanged.

Phase 0 scope (explicit)

In scope:

  • nostr.createUnsignedEvent (skeleton generator → untitled JSON buffer)
  • nostr.validateEventFile (JSON shape validator, reusable)
  • nostr.publishEventFile (sign + publish from active JSON editor)
  • nostr.signIn / nostr.signOut (nsec/hex, in-memory)
  • Relay publisher (publishToRelays) — shared with all later phases
  • LocalSigner wrapping PlainKeySigner
  • Status bar item (signed-in state + npub)
  • Config: nostr.relays, nostr.publish.confirm

Out of scope for Phase 0 (deferred to Phase 1+):

  • Front-matter parsing
  • NIP-23-specific tag construction (title, summary, image, published_at lifecycle)
  • The .md → long-form publish command
  • nostr.selectBackend / NsignerBackend (Phase 2)
  • published_at workspace-state map
  • nostr.resetPublishedTimestamps
  • nostr.validateFrontMatter

Phase 0 implementation order

  1. Scaffold project (package.json with file:../nostr-tools dep, tsconfig, esbuild, .vscodeignore, README stub).
  2. Build prerequisite: cd ../nostr-tools && npm install && npm run build.
  3. src/config.ts, src/state.ts — config + in-memory key state.
  4. src/nostr/npub.ts — hex ↔ npub via nostr-tools nip19.
  5. src/nostr/relay.ts — publishToRelays(signed, relays) via ws.
  6. src/nostr/validate.ts — validateEventTemplate(obj) returning { valid, errors, warnings, kindName? }. Reusable by Phase 1.
  7. src/signer/backend.ts — NostrSigner extends nostr-tools Signer.
  8. src/signer/localSigner.ts — wraps PlainKeySigner.
  9. src/ui/statusBar.ts — signed-in state + npub.
  10. src/extension.ts — register signIn, signOut, createUnsignedEvent, validateEventFile, publishEventFile; wire status bar.
  11. README Phase 0 section.
  12. npm install, npm run compile, vsce package, F5 verify.

This delivers a usable, testable extension end-to-end before any long-form complexity is added.