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

234 lines
9.1 KiB
Markdown

# 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`:
```json
{
"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: <parse error>").
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`](../nostr-tools/kinds.ts:1) (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`:
```ts
const template: EventTemplate = {
kind: obj.kind,
created_at: obj.created_at,
tags: obj.tags,
content: obj.content,
};
```
5. Sign via the active `NostrSigner`:
```ts
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`:
```json
{
"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
```mermaid
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.