Files
2026-07-18 17:29:20 -04:00

26 KiB
Raw Permalink Blame History

Codium Nostr Extension — Full Implementation Plan

Publish .md files as NIP-23 long-form notes (kind 30023) to Nostr relays from inside Codium/VSCode. Two signing backends:

  • Phase 1 — LocalSigner: user enters nsec1... or hex secret key, kept in-memory only (no persistence), signs locally with nostr-tools' PlainKeySigner.
  • Phase 2 — NsignerBackend: delegates signing to a running n_signer process over a Linux abstract Unix socket via length-prefixed JSON-RPC.

Both backends implement the Signer interface from nostr-tools/signer.ts — the extension does not define its own signer abstraction; it reuses the upstream one.

Companion design doc: longform-metadata-strategy.md.


1. Project layout

codium_nostr_extension/
├── package.json              # extension manifest, commands, config schema
├── tsconfig.json
├── esbuild.mjs               # bundler config (commonjs, bundle, platform=node)
├── .vscodeignore
├── README.md
├── LICENSE
├── media/
│   └── icon.png              # extension icon (16x16/128x128)
├── src/
│   ├── extension.ts          # activate(), command registration, status bar
│   ├── config.ts             # typed wrapper over vscode.workspace.getConfiguration
│   ├── state.ts              # workspace-state published_at map + session key holder
│   ├── frontmatter.ts        # YAML front-matter parser + stripper + validator
│   ├── nostr/
│   │   ├── nip23.ts          # build kind 30023 event from front-matter + body
│   │   ├── relay.ts          # WebSocket publish to N relays, collect OK/NOTICE
│   │   └── npub.ts           # hex <-> npub helpers (via nostr-tools nip19)
│   ├── signer/
│   │   ├── localSigner.ts    # Phase 1: thin wrapper over nostr-tools PlainKeySigner
│   │   └── nsignerBackend.ts # Phase 2: implements nostr-tools Signer via unix-abstract JSON-RPC
│   ├── nsigner/
│   │   ├── client.ts         # 4-byte BE length-framed JSON-RPC over net.createConnection
│   │   └── discovery.ts      # parse /proc/net/unix for @nsigner* sockets
│   └── ui/
│       ├── publishDialog.ts  # multi-step QuickPick: preview, overrides, relays, confirm
│       └── statusBar.ts      # status bar item: backend + npub + relay count
└── webview/                  # (reserved — empty for Phase 1/2; future rich preview)

Why esbuild

VSCode recommends esbuild for extension bundling. It produces a single dist/extension.js with all deps inlined, fast cold-start, and works in both VSCode and Codium (which share the extension host runtime). webpack is the alternative; esbuild is simpler and faster for this scope.


2. package.json manifest

engines

"engines": { "vscode": "^1.85.0" }

Codium advertises the same vscode engine API; no Codium-specific engine key exists. Targeting 1.85 keeps compatibility with current Codium releases while allowing modern API (e.g. window.showInputBox options, SecretStorage).

activationEvents

"activationEvents": ["onCommand:nostr.publishLongForm"]

Lazy activation — the extension only loads when a Nostr command is first run.

main

"main": "./dist/extension.js"

contributes.commands

Command ID Title When
nostr.signIn Nostr: Sign In always
nostr.signOut Nostr: Sign Out nostr.signedIn
nostr.publishLongForm Nostr: Publish Current File as Long-Form Note editorLangId == markdown
nostr.validateFrontMatter Nostr: Validate Front-Matter editorLangId == markdown
nostr.resetPublishedTimestamps Nostr: Reset Published Timestamps always
nostr.selectBackend Nostr: Select Signer Backend always

contributes.configuration

"nostr.relays": {
  "type": "array",
  "items": { "type": "string" },
  "default": [
    "wss://relay.damus.io",
    "wss://relay.primal.net",
    "wss://laantungir.net/relay"
  ],
  "description": "Relays to publish long-form notes to."
},
"nostr.signerBackend": {
  "type": "string",
  "enum": ["local", "nsigner"],
  "default": "local",
  "description": "Signing backend. 'local' uses an in-memory key; 'nsigner' delegates to a running n_signer."
},
"nostr.nsigner.socketName": {
  "type": "string",
  "default": "nsigner",
  "description": "Abstract socket name (without @) of the running n_signer. Used when signerBackend=nsigner."
},
"nostr.nsigner.nostrIndex": {
  "type": "number",
  "default": 0,
  "description": "nostr_index passed to n_signer get_public_key / sign_event."
},
"nostr.publish.confirm": {
  "type": "boolean",
  "default": true,
  "description": "Show the publish confirmation dialog before broadcasting."
}

contributes.menus — editor context + command palette

  • editor/context for nostr.publishLongForm and nostr.validateFrontMatter gated on editorLangId == markdown.
  • Command palette entries for all commands (default behavior, no extra menu block needed).

contributes.viewsContainers — none for Phase 1/2

A sidebar view is out of scope. The status bar item is the primary identity surface. A future phase can add a "Published Notes" tree view.


3. src/extension.ts — activation entrypoint

export async function activate(context: vscode.ExtensionContext) {
  const state = new StateService(context);
  const config = new ConfigService();
  const statusBar = new StatusBar(state, config);
  const signer = new SignerFactory(state, config); // returns Local or Nsigner backend

  registerCommand(context, "nostr.signIn", () => cmdSignIn(state, statusBar));
  registerCommand(context, "nostr.signOut", () => cmdSignOut(state, statusBar));
  registerCommand(context, "nostr.publishLongForm", () => cmdPublish(state, config, signer, statusBar));
  registerCommand(context, "nostr.validateFrontMatter", () => cmdValidate());
  registerCommand(context, "nostr.resetPublishedTimestamps", () => cmdResetTimestamps(state));
  registerCommand(context, "nostr.selectBackend", () => cmdSelectBackend(config, statusBar));

  statusBar.refresh();
}

StateService holds:

  • secretKey: Uint8Array | null — in-memory only, wiped on signOut and on extension deactivate. Never written to SecretStorage (per user decision: keep it simple, re-enter on reload).
  • pubkeyHex: string | null — derived from the key, used for status bar and event pubkey field.
  • publishedAtMap: Record<string, number> — backed by context.workspaceState under key nostr.publishedAtMap.

SignerFactory.getBackend() reads nostr.signerBackend config and returns either LocalSigner (requires state.secretKey) or NsignerBackend (requires a reachable n_signer socket). Throws a typed SignerNotReadyError if the precondition is unmet; the command handler surfaces it as an actionable error message.


4. src/frontmatter.ts — YAML front-matter parser

Format

Leading block delimited by --- on its own line:

---
slug: my-post
title: My Post
summary: Short abstract.
image: https://cdn.example.com/cover.png
tags: [nostr, longform, writing]
---

Parsing algorithm

  1. Read the active document text.
  2. If the text starts with ---\n, find the next line that is exactly ---. The text between is the YAML body; everything after the closing ---\n is the content.
  3. Parse the YAML body with yaml npm package (small, well-maintained, handles inline arrays and scalars). On parse error, throw FrontMatterError with line info.
  4. Extract known keys: slug, title, summary, image, tags.
  5. Collect unknown keys into a warnings: string[] list (non-blocking).
  6. Apply defaults and sanitization (see longform-metadata-strategy.md §"Parsing rules"):
    • slug default = filename stem; sanitize to [a-z0-9-].
    • title default = filename stem.
    • summary default = first non-empty non-heading line, truncated 280.
    • tags accept array or comma string; lowercase, trim, strip #.
  7. Return { frontMatter, content, warnings }.

Stripping

content (the return value above) already excludes the front-matter block. The publish flow uses content as the event content field. The on-disk file is never modified.

nostr.validateFrontMatter command

Calls the parser on the active document and shows an vscode.window.showInformationMessage with:

  • Emitted tags: d=..., title=..., summary=..., image=..., t=[...], published_at=<new|existing ts>.
  • Warnings (unknown keys) if any.
  • Body length in chars.

Non-blocking — no publish happens.


5. src/nostr/nip23.ts — event builder

import { LongFormArticle } from "nostr-tools/kinds";
import type { EventTemplate } from "nostr-tools/core";

export interface LongFormInput {
  slug: string;              // d tag
  title: string;
  summary: string;
  image?: string;
  tags: string[];            // topic strings, no #
  content: string;           // body with front-matter stripped
  publishedAt: number;       // unix seconds, from state map or now
}

export function buildLongFormEvent(input: LongFormInput): EventTemplate {
  const tags: string[][] = [
    ["d", input.slug],
    ["title", input.title],
    ["summary", input.summary],
  ];
  if (input.image) tags.push(["image", input.image]);
  tags.push(["published_at", String(input.publishedAt)]);
  for (const t of input.tags) tags.push(["t", t]);
  tags.push(["alt", `Long-form post: ${input.title}`]);

  return {
    kind: LongFormArticle,   // 30023, from nostr-tools/kinds
    created_at: Math.floor(Date.now() / 1000),
    tags,
    content: input.content,
  };
}

Returns EventTemplate (the nostr-tools unsigned-event type — no pubkey or id yet; those are added by the signer). The pubkey is injected by the signer backend from its own getPublicKey(), so the builder does not need it. This is exactly what nostr-tools' Signer.signEvent(event: EventTemplate) expects.

published_at resolution

Before building, the publish command consults state.getPublishedAt(slug):

  • If present → use the stored value (this is an update).
  • If absent → use now, and after successful publish, call state.setPublishedAt(slug, now).

This keeps the first-publish timestamp stable across edits, per NIP-23.


6. src/nostr/relay.ts — relay publisher

export interface PublishResult {
  relay: string;
  ok: boolean;
  message: string; // OK reason, NOTICE text, or error
}

export async function publishToRelays(
  signedEvent: Event,
  relays: string[],
  timeoutMs = 10000
): Promise<PublishResult[]>

Implementation

  1. For each relay URL, open a WebSocket (use the ws npm package — Node-side WebSocket, works in the extension host which is a Node process).
  2. On open, send ["EVENT", signedEvent].
  3. Listen for ["OK", eventId, true, "..."] (success) or ["OK", eventId, false, "reason"] (rejected), or ["NOTICE", "..."].
  4. Resolve each relay's PublishResult on first terminal message or timeout.
  5. Close all sockets in a finally block.
  6. Return the array so the UI can show per-relay outcomes.

ws is added as a dependency. nostr-tools also depends on ws for Node use, so this is consistent. esbuild bundles both into dist/extension.js.


7. Signer abstraction — reuse nostr-tools' Signer interface

The fork ships a Signer interface in nostr-tools/signer.ts:

// from nostr-tools/signer.ts (do not redefine)
export interface Signer {
  getPublicKey(): Promise<string>
  signEvent(event: EventTemplate): Promise<VerifiedEvent>
}

The extension does not redefine this. Both backends implement nostr-tools' Signer. The extension adds a small extension interface for UI/preflight concerns only:

// src/signer/backend.ts
import type { Signer } from "nostr-tools/signer";

export interface NostrSigner extends Signer {
  readonly kind: "local" | "nsigner";
  ready(): Promise<boolean>;   // preflight: key loaded / n_signer reachable
  describe(): string;          // status bar / error hint text
}

export class SignerNotReadyError extends Error {
  constructor(public backend: string, public hint: string) { super(...); }
}

NostrSigner extends the upstream Signer so any code that accepts a nostr-tools Signer also accepts our backends. The publish command calls backend.ready() first; on false it throws SignerNotReadyError and the command handler shows vscode.window.showErrorMessage with the hint (e.g. "Run nsigner --listen unix --socket-name nsigner and try again").


8. src/signer/localSigner.ts — Phase 1

Thin wrapper over the fork's PlainKeySigner (nostr-tools/signer.ts), extended with the NostrSigner preflight/describe fields:

import { PlainKeySigner } from "nostr-tools/signer";
import type { NostrSigner } from "./backend";

export class LocalSigner implements NostrSigner {
  readonly kind = "local";
  private inner: PlainKeySigner;

  constructor(secretKey: Uint8Array) {
    this.inner = new PlainKeySigner(secretKey);
  }

  getPublicKey() { return this.inner.getPublicKey(); }
  signEvent(event) { return this.inner.signEvent(event); }
  async ready() { return this.inner !== null; } // key present in-memory
  describe() { return "local in-memory key"; }
}

PlainKeySigner already calls finalizeEvent (which calls getEventHash + schnorr.sign) — no need to reimplement. The secret key is held by StateService and zeroed on sign-out / deactivate.

nostr.signIn command

  1. vscode.window.showInputBox({ prompt: "Enter nsec1... or hex secret key", password: true }).
  2. Normalize: if starts with nsec1, decode via nip19.nsecDecode; else parse as 64-char hex.
  3. Validate length === 32 bytes; on failure show error and abort.
  4. Derive pubkey via schnorr.getPublicKey.
  5. Store key + pubkey in StateService (in-memory).
  6. Refresh status bar; show info message with the npub.

nostr.signOut command

Zero the key buffer (key.fill(0)), clear pubkeyHex, refresh status bar.


9. src/signer/nsignerBackend.ts + src/nsigner/client.ts — Phase 2

nsigner/client.ts — wire client

Direct port of the TypeScript reference in n_signer/documents/CLIENT_IMPLEMENTATION.md section 9.3, adapted to a class with a configurable socket name:

import net from "node:net";

export class NsignerClient {
  constructor(private socketName: string) {} // without leading @

  async call(method: string, params: unknown[]): Promise<unknown> {
    const request = { id: "1", method, params };
    const payload = Buffer.from(JSON.stringify(request), "utf8");
    const header = Buffer.alloc(4);
    header.writeUInt32BE(payload.length, 0);

    return new Promise((resolve, reject) => {
      const socket = net.createConnection({ path: `\u0000${this.socketName}` });
      let chunks: Buffer[] = [];
      let needed = 4;
      let mode: "header" | "body" = "header";
      const timer = setTimeout(() => { socket.destroy(); reject(new Error("n_signer timeout")); }, 30000);

      socket.on("connect", () => socket.write(Buffer.concat([header, payload])));
      socket.on("data", (data) => {
        chunks.push(data);
        let buf = Buffer.concat(chunks);
        while (buf.length >= needed) {
          const part = buf.subarray(0, needed);
          buf = buf.subarray(needed);
          if (mode === "header") { needed = part.readUInt32BE(0); mode = "body"; }
          else { clearTimeout(timer); socket.end(); resolve(JSON.parse(part.toString("utf8"))); return; }
        }
        chunks = [buf];
      });
      socket.on("error", (e) => { clearTimeout(timer); reject(e); });
    });
  }
}

The \u0000 prefix is the Linux abstract-namespace marker — this is the key detail that makes it connect to @nsigner rather than a filesystem path.

nsigner/discovery.ts

export async function discoverNsignerSockets(): Promise<string[]> {
  const content = await fs.promises.readFile("/proc/net/unix", "utf8");
  const sockets: string[] = [];
  for (const line of content.split("\n")) {
    // abstract socket paths appear with a leading \0 in /proc/net/unix
    const m = line.match(/\x00nsigner[^\s]*/);
    if (m) sockets.push(m[0].slice(1)); // strip the \0, return without @
  }
  return [...new Set(sockets)];
}

Used by nostr.selectBackend when the user picks nsigner: if the configured nostr.nsigner.socketName is not found in /proc/net/unix, list discovered sockets and let the user pick one.

nsignerBackend.ts

Implements nostr-tools' Signer (via our NostrSigner extension). The signEvent signature matches Signer.signEvent(event: EventTemplate) — it receives an unsigned template and returns a verified event with id, pubkey, sig filled in by n_signer.

import type { EventTemplate, VerifiedEvent } from "nostr-tools/core";
import type { NostrSigner } from "./backend";
import { NsignerClient } from "../nsigner/client";

export class NsignerBackend implements NostrSigner {
  readonly kind = "nsigner";
  private client: NsignerClient;
  private nostrIndex: number;

  constructor(socketName: string, nostrIndex: number) {
    this.client = new NsignerClient(socketName);
    this.nostrIndex = nostrIndex;
  }

  async ready(): Promise<boolean> {
    try { await this.getPublicKey(); return true; } catch { return false; }
  }

  async getPublicKey(): Promise<string> {
    const res = await this.client.call("get_public_key", [{ nostr_index: this.nostrIndex }]);
    const r = res as { result?: string; error?: { message: string } };
    if (r.error) throw new Error(`n_signer: ${r.error.message}`);
    return r.result!;
  }

  async signEvent(event: EventTemplate): Promise<VerifiedEvent> {
    // n_signer expects the event JSON as a string in params[0]; it fills in
    // pubkey, id, sig and returns the signed event as a JSON string.
    const res = await this.client.call("sign_event", [
      JSON.stringify(event),
      { nostr_index: this.nostrIndex },
    ]);
    const r = res as { result?: string; error?: { message: string } };
    if (r.error) throw new Error(`n_signer: ${r.error.message}`);
    return JSON.parse(r.result!) as VerifiedEvent;
  }

  describe() { return `n_signer @${this.client["socketName"]} idx=${this.nostrIndex}`; }
}

Wire contract confirmed against n_signer/client/demo_javascript.js (get_public_key params [{nostr_index}]) and n_signer/client/README.md (sign_event params [eventJson, {nostr_index|role}]). No auth envelope needed for unix abstract sockets — identity is UID-based via SO_PEERCRED (confirmed in n_signer/documents/SECURITY.md).


10. src/ui/publishDialog.ts — publish confirmation

Multi-step vscode.window.showQuickPick flow (per user decision: start with QuickPick, reserve Webview for a future phase). Steps:

  1. Preview step — show a single QuickPick with one item per field, each item's label is the field name and description is the resolved value:

    d:           my-post
    title:       My Post
    summary:     Short abstract.
    image:       https://cdn.example.com/cover.png
    tags:        nostr, longform, writing
    published_at: NEW (will be set to now)
    body:        1234 chars
    signer:      local in-memory key
    pubkey:      npub1...
    relays:      damus, primal, laantungir
    

    Items are non-selectable (canPickMany=false, and the user picks a final action item at the bottom): Publish, Edit field..., Cancel.

  2. Edit field step — if the user picks Edit field..., show a QuickPick of editable fields (d, title, summary, image, tags), then an showInputBox pre-filled with the current value. The override is applied to an in-memory copy (not written back to the file). Loop back to preview.

  3. Relay toggle step — canPickMany=true QuickPick of configured relays, all checked by default. User can uncheck relays for this publish only.

  4. Publish — builds the event with (possibly overridden) fields, signs via the active backend, publishes to the selected relays, stores published_at if first publish, shows a summary message with per-relay OK/failed counts and the note's nevent1/naddr1 identifier.

If nostr.publish.confirm is false, skip the dialog and publish immediately with front-matter values and all configured relays.


11. src/ui/statusBar.ts — status bar item

A vscode.window.createStatusBarItem on the left side, priority 100. Text:

  • Signed out: $(nostr) Nostr: signed out
  • Local signed in: $(nostr) npub1…abc local
  • n_signer connected: $(nostr) npub1…abc nsigner
  • n_signer unreachable: $(nostr) nsigner: unreachable

Clicking runs nostr.selectBackend. Refreshed on sign-in/out, backend switch, and on a 5s interval timer (to catch n_signer going up/down).


12. Dependencies (package.json)

The extension depends on the local laantungir fork of nostr-tools (cloned at ~/lt/nostr-tools, remote git@laantungir.net:laantungir/nostr-tools.git, currently tracking upstream v2.23.3). Use a file: dependency so the extension builds against the local checkout without publishing to a registry:

"dependencies": {
  "nostr-tools": "file:../nostr-tools",
  "ws": "^8.16.0",
  "yaml": "^2.3.4"
},
"devDependencies": {
  "@types/vscode": "^1.85.0",
  "@types/node": "^20.10.0",
  "@types/ws": "^8.5.10",
  "typescript": "^5.3.0",
  "esbuild": "^0.19.0",
  "@vscode/vsce": "^2.22.0"
}

The fork is ESM ("type": "module") but ships a CJS build at lib/cjs/index.js (see nostr-tools/package.json). esbuild resolves the require export and bundles it into dist/extension.js for the Node-based extension host. @noble/secp256k1 and @noble/hashes come transitively via nostr-tools.

Why the fork over upstream npm

  • Newer version (2.23.3 vs upstream npm's 2.5.0) — bug fixes and NIP coverage.
  • Signer interface + PlainKeySigner in nostr-tools/signer.ts — the extension reuses this interface directly instead of inventing its own.
  • LongFormArticle = 30023 constant in nostr-tools/kinds.ts — replaces a hardcoded magic number.
  • nip06 module available if a future phase adds mnemonic-derived keys (matches n_signer's derivation).
  • Self-hosted source of truth — the laantungir git server is the canonical remote for this ecosystem; depending on it keeps the extension aligned with any local patches that may land in the fork.

Build prerequisite

nostr-tools must be built before the extension can compile it:

cd ../nostr-tools && npm install && npm run build

This produces lib/cjs/*.js and lib/types/*.d.ts. The extension's esbuild step then bundles from there. Documented in the extension README.

esbuild.mjs

import esbuild from "esbuild";
esbuild.build({
  entryPoints: ["src/extension.ts"],
  bundle: true,
  platform: "node",
  format: "cjs",
  target: "node18",
  outfile: "dist/extension.js",
  external: ["vscode"], // always external
  logLevel: "info",
});

tsconfig.json

Standard strict TS config, module: "commonjs", target: "ES2022", lib: ["ES2022"], skipLibCheck: true, outDir: "dist".

.vscodeignore

.vscode/**
src/**
node_modules/**
esbuild.mjs
tsconfig.json
*.map

Keep dist/extension.js, package.json, README.md, LICENSE, media/.


13. Build & package

npm install
npm run compile      # node esbuild.mjs
npm run package      # vsce package -> nostr-publish-0.1.0.vsix

package.json scripts:

"scripts": {
  "compile": "node esbuild.mjs",
  "watch": "node esbuild.mjs --watch",
  "package": "vsce package",
  "publish": "vsce publish"
}

F5 verification

.vscode/launch.json with a single "Run Extension" config launching the Extension Development Host with the workspace folder. Verify:

  1. F5 launches a new Codium window with the extension loaded.
  2. Nostr: Sign In accepts an nsec and the status bar updates.
  3. Open a .md file with front-matter, run Nostr: Validate Front-Matter, see the parsed tags.
  4. Run Nostr: Publish Current File as Long-Form Note, see the dialog, pick Publish, see per-relay results.
  5. Start nsigner --listen unix --socket-name nsigner in a terminal, run Nostr: Select Signer Backend, pick nsigner, see the status bar switch and a publish go through n_signer (approval prompt appears in the n_signer terminal).

14. Implementation order (matches the todo list)

  1. Scaffold project: package.json, tsconfig.json, esbuild.mjs, .vscodeignore, README.md stub, LICENSE, media/icon.png.
  2. src/config.ts, src/state.ts — config + state plumbing.
  3. src/nostr/npub.ts, src/nostr/nip23.ts, src/nostr/relay.ts — core nostr layer.
  4. src/frontmatter.ts — parser + validator.
  5. src/signer/backend.ts, src/signer/localSigner.ts — Phase 1 signer.
  6. src/ui/statusBar.ts, src/extension.ts — wire commands + status bar.
  7. src/ui/publishDialog.ts — publish confirmation flow.
  8. src/nsigner/client.ts, src/nsigner/discovery.ts, src/signer/nsignerBackend.ts — Phase 2 signer.
  9. nostr.selectBackend command + SignerFactory wiring.
  10. README full content (front-matter format, Phase 1, Phase 2, limitations).
  11. npm install, npm run compile, vsce package, F5 verify.

Each step is independently testable: steps 1–7 deliver a working Phase 1; steps 8–9 add Phase 2; steps 10–11 close out.


15. Out of scope (future phases)

  • Image upload (Blossom / NIP-96) — local ![](./x.png) paths are left as-is in Phase 1/2; documented as a known limitation. Future phase rewrites them to uploaded URLs using ~/lt/blossom.
  • Webview publish dialog — richer preview with rendered markdown. QuickPick is the Phase 1/2 UI.
  • Sidebar "Published Notes" tree view — list past publishes from a local cache or relay fetch.
  • NIP-19 naddr1 sharing — generate a shareable address after publish (shown in the success message; full sharing UI deferred).
  • Key persistence via SecretStorage — explicitly deferred per user decision ("keep it simple, quickly go to Phase 2").