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:
- The extension can generate a skeleton unsigned event JSON file in the correct Nostr format.
- The user edits the file to fill in
kind,content,tags, etc. - The extension validates the file against the Nostr event shape.
- 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
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.
- Build a skeleton
EventTemplate:{ "kind": <chosen>, "created_at": <now, unix seconds>, "tags": [], "content": "" }- No
pubkey— the signer injects it fromgetPublicKey()at sign time. - No
id/sig— those are produced by signing.
- No
- Open the skeleton in a new untitled JSON editor (
vscode.workspace.openTextDocument({ content, language: "json" })thenshowTextDocument). The user canSave As...to a.jsonfile 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
- Document parses as JSON (else: "Invalid JSON: ").
- 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,sigare 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.)
kindis a non-negative integer.created_atis a non-negative integer (unix seconds).tagsis an array of arrays of strings. Each inner array has length ≥ 1.contentis a string (may be empty).- If
kindis a known constant fromnostr-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
- Require signed-in signer (
state.secretKeypresent for Phase 1). If not, prompt: "Sign in first" with an action button that runsnostr.signIn. - Read the active JSON document text.
- Validate (same rules as
nostr.validateEventFile). On hard error, abort with the message. - Parse into an
EventTemplate:const template: EventTemplate = { kind: obj.kind, created_at: obj.created_at, tags: obj.tags, content: obj.content, }; - Sign via the active
NostrSigner:const signed: VerifiedEvent = await signer.signEvent(template);signer.signEventinjectspubkey, computesid, and producessig.- For
LocalSignerthis isPlainKeySigner.signEvent→finalizeEvent.
- Show a confirmation dialog (unless
nostr.publish.confirmis false):- Preview:
kind,pubkey(npub),id,tagscount,contentlength, target relays. - Relay checkboxes (all configured relays checked by default).
Publish/Cancel.
- Preview:
- Publish to selected relays via
publishToRelays(signed, relays). - Show result: per-relay OK/failed summary + the event
idand anevent1/naddr1identifier (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:
- Parses front-matter from the
.mdfile. - Builds an
EventTemplateviabuildLongFormEvent. - Runs the same validator on the built template.
- 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 LocalSignerwrappingPlainKeySigner- 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_atlifecycle) - The
.md→ long-form publish command nostr.selectBackend/NsignerBackend(Phase 2)published_atworkspace-state mapnostr.resetPublishedTimestampsnostr.validateFrontMatter
Phase 0 implementation order
- Scaffold project (
package.jsonwithfile:../nostr-toolsdep, tsconfig, esbuild,.vscodeignore, README stub). - Build prerequisite:
cd ../nostr-tools && npm install && npm run build. src/config.ts,src/state.ts— config + in-memory key state.src/nostr/npub.ts— hex ↔ npub vianostr-toolsnip19.src/nostr/relay.ts—publishToRelays(signed, relays)viaws.src/nostr/validate.ts—validateEventTemplate(obj)returning{ valid, errors, warnings, kindName? }. Reusable by Phase 1.src/signer/backend.ts—NostrSignerextendsnostr-toolsSigner.src/signer/localSigner.ts— wrapsPlainKeySigner.src/ui/statusBar.ts— signed-in state + npub.src/extension.ts— registersignIn,signOut,createUnsignedEvent,validateEventFile,publishEventFile; wire status bar.- README Phase 0 section.
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.