26 KiB
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 entersnsec1...or hex secret key, kept in-memory only (no persistence), signs locally withnostr-tools'PlainKeySigner. - Phase 2 —
NsignerBackend: delegates signing to a runningn_signerprocess 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/contextfornostr.publishLongFormandnostr.validateFrontMattergated oneditorLangId == 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 onsignOutand on extension deactivate. Never written toSecretStorage(per user decision: keep it simple, re-enter on reload).pubkeyHex: string | null— derived from the key, used for status bar and eventpubkeyfield.publishedAtMap: Record<string, number>— backed bycontext.workspaceStateunder keynostr.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
- Read the active document text.
- If the text starts with
---\n, find the next line that is exactly---. The text between is the YAML body; everything after the closing---\nis the content. - Parse the YAML body with
yamlnpm package (small, well-maintained, handles inline arrays and scalars). On parse error, throwFrontMatterErrorwith line info. - Extract known keys:
slug,title,summary,image,tags. - Collect unknown keys into a
warnings: string[]list (non-blocking). - Apply defaults and sanitization (see
longform-metadata-strategy.md§"Parsing rules"):slugdefault = filename stem; sanitize to[a-z0-9-].titledefault = filename stem.summarydefault = first non-empty non-heading line, truncated 280.tagsaccept array or comma string; lowercase, trim, strip#.
- 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, callstate.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
- For each relay URL, open a
WebSocket(use thewsnpm package — Node-side WebSocket, works in the extension host which is a Node process). - On open, send
["EVENT", signedEvent]. - Listen for
["OK", eventId, true, "..."](success) or["OK", eventId, false, "reason"](rejected), or["NOTICE", "..."]. - Resolve each relay's
PublishResulton first terminal message or timeout. - Close all sockets in a
finallyblock. - 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
vscode.window.showInputBox({ prompt: "Enter nsec1... or hex secret key", password: true }).- Normalize: if starts with
nsec1, decode vianip19.nsecDecode; else parse as 64-char hex. - Validate length === 32 bytes; on failure show error and abort.
- Derive pubkey via
schnorr.getPublicKey. - Store key + pubkey in
StateService(in-memory). - 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:
-
Preview step — show a single QuickPick with one item per field, each item's
labelis the field name anddescriptionis 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, laantungirItems are non-selectable (
canPickMany=false, and the user picks a final action item at the bottom):Publish,Edit field...,Cancel. -
Edit field step — if the user picks
Edit field..., show a QuickPick of editable fields (d,title,summary,image,tags), then anshowInputBoxpre-filled with the current value. The override is applied to an in-memory copy (not written back to the file). Loop back to preview. -
Relay toggle step —
canPickMany=trueQuickPick of configured relays, all checked by default. User can uncheck relays for this publish only. -
Publish — builds the event with (possibly overridden) fields, signs via the active backend, publishes to the selected relays, stores
published_atif first publish, shows a summary message with per-relay OK/failed counts and the note'snevent1/naddr1identifier.
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.
Signerinterface +PlainKeySignerinnostr-tools/signer.ts— the extension reuses this interface directly instead of inventing its own.LongFormArticle = 30023constant innostr-tools/kinds.ts— replaces a hardcoded magic number.nip06module 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:
- F5 launches a new Codium window with the extension loaded.
Nostr: Sign Inaccepts an nsec and the status bar updates.- Open a
.mdfile with front-matter, runNostr: Validate Front-Matter, see the parsed tags. - Run
Nostr: Publish Current File as Long-Form Note, see the dialog, pick Publish, see per-relay results. - Start
nsigner --listen unix --socket-name nsignerin a terminal, runNostr: Select Signer Backend, picknsigner, 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)
- Scaffold project:
package.json,tsconfig.json,esbuild.mjs,.vscodeignore,README.mdstub,LICENSE,media/icon.png. src/config.ts,src/state.ts— config + state plumbing.src/nostr/npub.ts,src/nostr/nip23.ts,src/nostr/relay.ts— core nostr layer.src/frontmatter.ts— parser + validator.src/signer/backend.ts,src/signer/localSigner.ts— Phase 1 signer.src/ui/statusBar.ts,src/extension.ts— wire commands + status bar.src/ui/publishDialog.ts— publish confirmation flow.src/nsigner/client.ts,src/nsigner/discovery.ts,src/signer/nsignerBackend.ts— Phase 2 signer.nostr.selectBackendcommand +SignerFactorywiring.- README full content (front-matter format, Phase 1, Phase 2, limitations).
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
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
naddr1sharing — 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").