234 lines
9.1 KiB
Markdown
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.
|