This commit is contained in:
Laan Tungir
2026-07-18 17:29:20 -04:00
commit 8e16e0229e
33 changed files with 7933 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.vsix
.DS_Store
+13
View File
@@ -0,0 +1,13 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Run Extension",
"type": "extensionHost",
"request": "launch",
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"preLaunchTask": "npm: compile"
}
]
}
+27
View File
@@ -0,0 +1,27 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "npm: compile",
"type": "npm",
"script": "compile",
"problemMatcher": ["$tsc"],
"isBackground": false,
"presentation": {
"reveal": "silent",
"panel": "shared"
}
},
{
"label": "npm: watch",
"type": "npm",
"script": "watch",
"isBackground": true,
"problemMatcher": ["$esbuild-watch"],
"presentation": {
"reveal": "silent",
"panel": "shared"
}
}
]
}
+9
View File
@@ -0,0 +1,9 @@
.vscode/**
src/**
node_modules/**
esbuild.mjs
tsconfig.json
*.map
plans/**
.gitignore
.gitattributes
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 laantungir
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+206
View File
@@ -0,0 +1,206 @@
# Nostr Publish
A Codium / VSCode extension to publish files to Nostr.
## Phases
- **Phase 0**: Publish raw unsigned Nostr events from a `.json` file.
Generate a skeleton event, fill in the fields, validate, sign, and broadcast.
- **Phase 1**: Publish `.md` files as NIP-23 long-form notes
(kind `30023`) with YAML front-matter metadata.
- **Phase 2**: Delegate signing to a running
[`n_signer`](https://laantungir.net/git/laantungir/n_signer.git) process
over a Linux abstract Unix socket.
See [`plans/`](plans/) for the full design:
- [`plans/phase0-raw-event-publishing.md`](plans/phase0-raw-event-publishing.md)
- [`plans/longform-metadata-strategy.md`](plans/longform-metadata-strategy.md)
- [`plans/implementation-plan.md`](plans/implementation-plan.md)
## Prerequisites
1. **Node.js** ≥ 18.
2. **The laantungir fork of `nostr-tools`** must be cloned as a sibling
directory and built:
```bash
git clone git@laantungir.net:laantungir/nostr-tools.git ../nostr-tools
cd ../nostr-tools
npm install --ignore-scripts # the prepublish hook needs `just`, which we skip
rm -rf lib && bun run build.js # or: node build.js
npx tsc # produces lib/types/*.d.ts
```
The extension depends on it via `"nostr-tools": "file:../nostr-tools"`.
## Install (development)
```bash
npm install
npm run compile # esbuild -> dist/extension.js
```
Press `F5` in Codium/VSCode to launch an Extension Development Host with the
extension loaded, or package and install:
```bash
npx vsce package # produces nostr-publish-0.1.0.vsix
code --install-extension nostr-publish-0.1.0.vsix
```
## Phase 0 usage
### 1. Sign in
Run **Nostr: Sign In** from the command palette. Enter your secret key as
`nsec1...` or 64-char hex. The key is held in memory only for the session —
it is never written to disk. The status bar shows your `npub1...`.
Run **Nostr: Sign Out** to clear the key (the buffer is zeroed).
### 2. Create an unsigned event
Run **Nostr: Create Unsigned Event**. Pick a kind (1 = short text note,
30023 = long-form article, 10002 = relay list, 0 = metadata, 3 = contacts,
or Custom…). A skeleton JSON document opens in an untitled editor:
```json
{
"kind": 1,
"created_at": 1721329200,
"tags": [],
"content": ""
}
```
Fill in `tags` and `content`. Save it as a `.json` file if you want to reuse
it, or publish directly from the untitled buffer.
### 3. Validate
Run **Nostr: Validate Event File** (or right-click in a JSON editor →
Nostr: Validate Event File). Checks the shape of the event and reports any
errors or warnings (e.g. unknown fields, or `pubkey`/`id`/`sig` left over
from a previously signed event).
### 4. Publish
Run **Nostr: Publish Event from JSON File** (or right-click → Nostr: Publish
Event from JSON File). The extension:
1. Validates the JSON.
2. Signs the event with your session key (injects `pubkey`, `id`, `sig`).
3. Shows a confirmation dialog with a preview and relay checkboxes.
4. Broadcasts to the selected relays via WebSocket.
5. Reports per-relay OK/failed results and the event id.
If the JSON contains `pubkey`/`id`/`sig`, they are stripped and the event is
re-signed with your active key (useful for re-signing an event as yourself).
## Configuration
| Setting | Default | Description |
|---|---|---|
| `nostr.relays` | `["wss://relay.damus.io", "wss://relay.primal.net", "wss://laantungir.net/relay"]` | Relays to publish to. |
| `nostr.publish.confirm` | `true` | Show a confirmation dialog before broadcasting. |
| `nostr.publish.timeoutMs` | `10000` | Per-relay publish timeout in milliseconds. |
## File format
The `.json` file is a plain Nostr `EventTemplate` — no extension-specific
wrapper. It is interoperable with any other Nostr tooling that accepts
`EventTemplate` JSON:
```json
{
"kind": 30023,
"created_at": 1721329200,
"tags": [
["d", "my-post"],
["title", "My Post"]
],
"content": "# My Post\n\nBody text..."
}
```
## Architecture
- **`Signer` interface**: reuses `nostr-tools`' `Signer`
([`nostr-tools/signer.ts`](https://laantungir.net/git/laantungir/nostr-tools.git)).
`LocalSigner` wraps `PlainKeySigner`; Phase 2's `NsignerBackend` will
implement the same interface.
- **`LongFormArticle = 30023`** constant from `nostr-tools/kinds` (Phase 1).
- **Relay publishing**: `ws` WebSocket, `["EVENT", signed]`, waits for
`["OK", id, true|false, reason]`.
- **Bundling**: esbuild → single `dist/extension.js`, `vscode` external.
## Phase 2: n_signer signing backend
Phase 2 delegates signing to a running
[`n_signer`](https://laantungir.net/git/laantungir/n_signer.git) process over
a Linux abstract Unix socket. The key material stays in `n_signer`'s
mlock'd RAM — the extension never sees it.
### Setup
1. Build and run `n_signer` (see its README for build instructions):
```bash
nsigner --listen unix --socket-name nsigner
```
Enter your mnemonic at the prompt. `n_signer` binds the abstract socket
`@nsigner` and shows its status TUI.
2. In the extension, run **Nostr: Select Signer Backend** (command palette):
- Pick **n_signer (remote signing)**.
- The extension discovers running `n_signer` sockets via `/proc/net/unix`
and lists them. Pick `@nsigner` (or enter a custom socket name).
- The extension calls `get_public_key` to verify reachability and shows
your npub. If it can't connect, it shows an error with the command to
start `n_signer`.
3. Publish as usual — the extension calls `sign_event` on `n_signer` for each
publish. The first sign from a new caller may prompt for approval at the
`n_signer` terminal (per its deny-by-default policy).
### Wire contract
- Transport: Node `net.createConnection({ path: "\u0000nsigner" })` (abstract
socket, the `\u0000` prefix is the Linux abstract-namespace marker).
- Framing: 4-byte big-endian length prefix + UTF-8 JSON payload.
- Verbs: `get_public_key` (params `[{nostr_index: <n>}]`), `sign_event`
(params `[JSON.stringify(event), {nostr_index: <n>}]`).
- No auth envelope needed for unix sockets — identity is UID-based via
`SO_PEERCRED`.
### Configuration
| Setting | Default | Description |
|---|---|---|
| `nostr.signerBackend` | `"local"` | `"local"` or `"nsigner"`. |
| `nostr.nsigner.socketName` | `"nsigner"` | Abstract socket name (without `@`). |
| `nostr.nsigner.nostrIndex` | `0` | `nostr_index` for key derivation. |
## Sidebar
The Nostr sidebar (Activity Bar icon) shows:
- **Identity**: signed-in state + npub + signer backend label + Sign In/Out.
- **Relays**: checkboxes to toggle which relays to publish to, plus a
**Fetch My Relays (NIP-65)** button that loads your kind 10002 relay list.
- **Actions**: + New Event, Publish Current File as Long-Form Note,
Validate Front-Matter, Publish Current JSON File, Validate Current File.
Buttons enable/disable based on the active editor's language (markdown vs
JSON). The sidebar refreshes on sign-in/out and when switching editors.
## Known limitations
- No key persistence for the local backend — the secret key must be
re-entered each session (by design; Phase 2's `n_signer` backend holds the
key material instead).
- No image upload — `image` URLs in front-matter must be hosted externally
(future Blossom/NIP-96 phase).
- `n_signer` backend is Linux-only (abstract Unix sockets are a Linux
primitive).
- The `published_at` workspace-state map is per-workspace; switching
workspaces loses the first-publish timestamps.
+25
View File
@@ -0,0 +1,25 @@
import * as esbuild from "esbuild";
const watch = process.argv.includes("--watch");
/** @type {import("esbuild").BuildOptions} */
const options = {
entryPoints: ["src/extension.ts"],
bundle: true,
platform: "node",
format: "cjs",
target: "node18",
outfile: "dist/extension.js",
external: ["vscode"], // always external — provided by the extension host
sourcemap: true,
logLevel: "info",
};
if (watch) {
const ctx = await esbuild.context(options);
await ctx.watch();
console.log("esbuild watching for changes...");
} else {
await esbuild.build(options);
console.log("esbuild build complete.");
}
+8
View File
@@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Nostr broadcast icon: a central dot with radiating signal arcs -->
<circle cx="12" cy="13" r="2" fill="currentColor"/>
<path d="M8.5 9.5C7 11 7 15 8.5 16.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" fill="none"/>
<path d="M15.5 9.5C17 11 17 15 15.5 16.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" fill="none"/>
<path d="M6 7C3.5 9.5 3.5 16.5 6 19" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" fill="none"/>
<path d="M18 7C20.5 9.5 20.5 16.5 18 19" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 709 B

+2995
View File
File diff suppressed because it is too large Load Diff
+225
View File
@@ -0,0 +1,225 @@
{
"name": "nostr-publish",
"displayName": "Nostr Publish",
"description": "Publish files to Nostr from Codium/VSCode. Phase 0: raw unsigned event JSON. Phase 1: long-form notes (.md). Phase 2: n_signer signing backend.",
"version": "0.1.0",
"publisher": "laantungir",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://laantungir.net/git/laantungir/nostr-publish.git"
},
"engines": {
"vscode": "^1.85.0"
},
"categories": [
"Other"
],
"main": "./dist/extension.js",
"activationEvents": [
"onCommand:nostr.signIn",
"onCommand:nostr.createUnsignedEvent",
"onCommand:nostr.publishEventFile",
"onCommand:nostr.validateEventFile",
"onCommand:nostr.publishLongForm",
"onCommand:nostr.validateFrontMatter",
"onCommand:nostr.fetchRelays",
"onCommand:nostr.selectBackend",
"onCommand:nostr.signInFromSidebar",
"onView:nostr.sidebar"
],
"contributes": {
"viewsContainers": {
"activitybar": [
{
"id": "nostr",
"title": "Nostr",
"icon": "media/activitybar.svg"
}
]
},
"views": {
"nostr": [
{
"id": "nostr.sidebar",
"name": "Nostr",
"type": "webview"
}
]
},
"commands": [
{
"command": "nostr.signIn",
"title": "Nostr: Sign In",
"category": "Nostr"
},
{
"command": "nostr.signOut",
"title": "Nostr: Sign Out",
"category": "Nostr"
},
{
"command": "nostr.createUnsignedEvent",
"title": "Nostr: Create Unsigned Event",
"category": "Nostr"
},
{
"command": "nostr.validateEventFile",
"title": "Nostr: Validate Event File",
"category": "Nostr",
"enablement": "editorLangId == json"
},
{
"command": "nostr.publishEventFile",
"title": "Nostr: Publish Event from JSON File",
"category": "Nostr",
"enablement": "editorLangId == json"
},
{
"command": "nostr.publishLongForm",
"title": "Nostr: Publish Current File as Long-Form Note",
"category": "Nostr",
"enablement": "editorLangId == markdown"
},
{
"command": "nostr.validateFrontMatter",
"title": "Nostr: Validate Front-Matter",
"category": "Nostr",
"enablement": "editorLangId == markdown"
},
{
"command": "nostr.resetPublishedTimestamps",
"title": "Nostr: Reset Published Timestamps",
"category": "Nostr"
},
{
"command": "nostr.fetchRelays",
"title": "Nostr: Fetch My Relays (NIP-65)",
"category": "Nostr"
},
{
"command": "nostr.selectBackend",
"title": "Nostr: Select Signer Backend",
"category": "Nostr"
}
],
"menus": {
"editor/context": [
{
"command": "nostr.validateEventFile",
"when": "editorLangId == json",
"group": "nostr@1"
},
{
"command": "nostr.publishEventFile",
"when": "editorLangId == json",
"group": "nostr@2"
},
{
"command": "nostr.validateFrontMatter",
"when": "editorLangId == markdown",
"group": "nostr@1"
},
{
"command": "nostr.publishLongForm",
"when": "editorLangId == markdown",
"group": "nostr@2"
}
]
},
"configuration": {
"title": "Nostr Publish",
"properties": {
"nostr.relays": {
"type": "array",
"items": {
"type": "string"
},
"default": [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://laantungir.net/relay"
],
"description": "Relays to publish events to."
},
"nostr.publish.confirm": {
"type": "boolean",
"default": true,
"description": "Show a confirmation dialog before broadcasting an event."
},
"nostr.publish.timeoutMs": {
"type": "number",
"default": 10000,
"description": "Per-relay publish timeout in milliseconds."
},
"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 over an abstract Unix socket."
},
"nostr.nsigner.socketName": {
"type": "string",
"default": "nsigner",
"description": "Abstract socket name (without @) of the running n_signer. Used when transport=unix."
},
"nostr.nsigner.nostrIndex": {
"type": "number",
"default": 0,
"description": "nostr_index passed to n_signer get_public_key / sign_event."
},
"nostr.nsigner.transport": {
"type": "string",
"enum": ["unix", "qrexec", "tcp"],
"default": "unix",
"description": "Transport for reaching n_signer: unix (local abstract socket), qrexec (Qubes OS inter-qube), tcp (remote / FIPS mesh)."
},
"nostr.nsigner.tcpHost": {
"type": "string",
"default": "127.0.0.1",
"description": "TCP host for n_signer. Used when transport=tcp."
},
"nostr.nsigner.tcpPort": {
"type": "number",
"default": 8080,
"description": "TCP port for n_signer. Used when transport=tcp."
},
"nostr.nsigner.qrexecQube": {
"type": "string",
"default": "nostr_signer",
"description": "Target qube name for qrexec transport. Used when transport=qrexec."
},
"nostr.nsigner.qrexecService": {
"type": "string",
"default": "qubes.NsignerRpc",
"description": "Qrexec service name. Used when transport=qrexec."
},
"nostr.nsigner.callerNsec": {
"type": "string",
"default": "",
"description": "Caller nsec/hex for TCP auth envelopes (kind 27235). Required for transport=tcp if not signed in locally. Not persisted to disk by the extension."
}
}
}
},
"scripts": {
"compile": "node esbuild.mjs",
"watch": "node esbuild.mjs --watch",
"package": "vsce package",
"publish": "vsce publish",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"nostr-tools": "file:../nostr-tools",
"ws": "^8.16.0",
"yaml": "^2.9.0"
},
"devDependencies": {
"@types/node": "^20.10.0",
"@types/vscode": "^1.85.0",
"@types/ws": "^8.5.10",
"@vscode/vsce": "^2.22.0",
"esbuild": "^0.19.0",
"typescript": "^5.3.0"
}
}
+756
View File
@@ -0,0 +1,756 @@
# 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`](../nostr-tools/signer.ts:4) — the extension does
**not** define its own signer abstraction; it reuses the upstream one.
Companion design doc: [`longform-metadata-strategy.md`](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`
```json
"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`
```json
"activationEvents": ["onCommand:nostr.publishLongForm"]
```
Lazy activation — the extension only loads when a Nostr command is first run.
### `main`
```json
"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`
```jsonc
"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
```ts
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:
```markdown
---
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`](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
```ts
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
```ts
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`](../nostr-tools/signer.ts:4):
```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:
```ts
// 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`](../nostr-tools/signer.ts:9)), extended with the
`NostrSigner` preflight/describe fields:
```ts
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`](../n_signer/documents/CLIENT_IMPLEMENTATION.md:345)
section 9.3, adapted to a class with a configurable socket name:
```ts
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`
```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.
```ts
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`](../n_signer/client/demo_javascript.js:99)
(`get_public_key` params `[{nostr_index}]`) and
[`n_signer/client/README.md`](../n_signer/client/README.md:50) (`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`](../n_signer/documents/SECURITY.md:428)).
---
## 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:
```json
"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`](../nostr-tools/package.json:15)).
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`](../nostr-tools/signer.ts:4) — the extension
reuses this interface directly instead of inventing its own.
- **`LongFormArticle = 30023`** constant in
[`nostr-tools/kinds.ts`](../nostr-tools/kinds.ts:190) — 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:
```bash
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`
```js
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
```bash
npm install
npm run compile # node esbuild.mjs
npm run package # vsce package -> nostr-publish-0.1.0.vsix
```
`package.json` scripts:
```json
"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").
+169
View File
@@ -0,0 +1,169 @@
# Long-Form Note Metadata Strategy
## Goal
When publishing a `.md` file as a NIP-23 long-form note (kind `30023`), the
author needs to control metadata that is **not** part of the body content:
- `d` tag — the parameterized-replaceable identifier (slug)
- `title` — note title
- `summary` — short abstract shown in feeds
- `image` — cover/hero image URL
- `t` tags — topics / hashtags
- `published_at` — first-publish timestamp (set once, preserved on updates)
- `alt` — accessibility/relay hint text
We need a way to express these per-document. Two complementary mechanisms are
proposed: **front-matter** (primary, authoring-time) and a **publish dialog**
(secondary, last-mile override + confirmation).
## NIP-23 tag reference
| Tag | NIP-23 meaning | Source of value |
|---|---|---|
| `d` | identifier | front-matter `slug`, else filename stem, else prompt |
| `title` | title | front-matter `title`, else filename stem |
| `summary` | abstract | front-matter `summary`, else first paragraph |
| `image` | cover image URL | front-matter `image` |
| `t` | topic hashtag | front-matter `tags` (array) |
| `published_at` | first publish unix ts | auto-managed by extension, persisted in workspace state keyed by `d` |
| `alt` | relay hint | auto-generated: "Long-form post: <title>" |
## Mechanism 1 — YAML front-matter (primary)
A fenced YAML block at the very top of the `.md` file, delimited by `---`.
```markdown
---
slug: my-first-post
title: My First Post
summary: A short abstract for feeds.
image: https://cdn.example.com/cover.png
tags: [nostr, longform, writing]
---
# My First Post
Body content starts here. The front-matter block above is parsed by the
extension, used to build event tags, and **stripped** from the published
content so readers never see it.
```
### Parsing rules
1. The extension only treats a leading `---\n...\n---\n` block as front-matter.
A `---` later in the document (e.g. a horizontal rule) is left alone.
2. Unknown keys are ignored (forward-compatible).
3. `tags` accepts either a YAML inline array `[a, b]` or a comma-separated
string `a, b`. Each value is lowercased and trimmed; `#` prefix is optional
and stripped. Empty entries dropped.
4. `slug` is sanitized: lowercase, spaces → `-`, strip non-`[a-z0-9-]`.
5. If `slug` is absent, default to the filename stem (same sanitization).
6. If `title` is absent, default to the filename stem.
7. If `summary` is absent, default to the first non-empty, non-heading line of
the body, truncated to 280 chars.
8. If `image` is absent, no `image` tag is emitted.
### Stripping on publish
The front-matter block is removed from the `content` field of the signed event.
The on-disk file is **not** modified — the author keeps editing with the
front-matter intact. Stripping happens in-memory at publish time only.
### Front-matter validation command
A `Nostr: Validate Front-Matter` command parses the active document and shows
an information message: which tags will be emitted, any unknown keys, and the
final `d` slug. Useful as a pre-publish sanity check without opening the dialog.
## Mechanism 2 — Publish dialog (secondary, last-mile)
Triggered by `Nostr: Publish Current File as Long-Form Note`. A
`QuickPick`-style multi-step dialog (or a webview for richer UI) that:
1. Shows a **preview** of the parsed event: `d`, `title`, `summary`, `image`,
tags list, body length, target relays, signer backend, and the resolved
pubkey (npub).
2. Lets the user **override** any field for this publish only (does not write
back to the file):
- slug
- title
- summary
- image URL
- add/remove tags
3. Shows the `published_at` behavior: "First publish — setting
published_at=now" or "Update — preserving published_at=<existing>".
4. Confirms target relays (checkboxes, defaults from config + front-matter).
5. A final `Publish` action signs and relays the event.
Overrides are session-only. If the user wants a permanent change, they edit
the front-matter. This keeps the file as the source of truth while allowing
one-off tweaks (e.g. fixing a typo right before publishing).
### Why both mechanisms
- **Front-matter** makes the document self-describing and version-controllable.
The same file produces the same event every time. Good for git workflows and
re-publishing updates.
- **Publish dialog** handles the human-in-the-loop confirmation that matters
for a broadcast operation, and lets the author catch a missing `image` or
wrong `slug` before it goes on-chain. It also surfaces the signer backend
and relay set, which front-matter should not control (those are
machine/environment concerns, not document concerns).
## `published_at` lifecycle
NIP-23 expects `published_at` to reflect the **first** time the note was
published, and to remain stable across updates (edits use the same `d` tag).
The extension stores a map `{ dSlug -> publishedAtUnix }` in workspace state
(`context.workspaceState`). On publish:
- If `dSlug` is present in the map → reuse the stored timestamp.
- If absent → set `published_at = now` and store it.
This survives editor reloads (workspace state is persisted by VSCode) but is
per-workspace, which matches the per-document intent. A command
`Nostr: Reset Published Timestamps` clears the map for cases where the author
wants a clean slate (e.g. changing the `d` slug intentionally).
## Image handling
Front-matter `image` is a URL string. The extension does **not** upload images
in Phase 1/2. A future Phase 3 could add Blossom (NIP-96) upload via the
`blossom` project already in `~/lt/blossom`, then rewrite local image paths to
the uploaded URL before signing. For now, the author is responsible for
hosting the image and pasting the URL.
Local relative paths in the body (`![](./img/x.png)`) are left as-is in
Phase 1/2 — they will not render on relays. This is documented in the README
as a known limitation with a pointer to the future Blossom phase.
## Tag emission order
NIP-23 does not mandate tag order, but for relay compatibility we emit in
this order:
1. `d`
2. `title`
3. `summary`
4. `image`
5. `published_at`
6. `t` (one per topic)
7. `alt`
## Open questions for the user
1. **Front-matter format**: YAML (`---` fences) vs TOML (`+++` fences) vs
JSON front-matter. Recommendation: YAML — most common in markdown tooling,
human-friendly, supports arrays cleanly.
2. **Publish dialog UI**: lightweight multi-step QuickPick (faster to build,
native feel) vs a Webview panel (richer preview, more code). Recommendation:
start with QuickPick for Phase 1/2, upgrade to Webview if the preview feels
too cramped.
3. **`published_at` storage scope**: workspace state (per-workspace, survives
reload) vs global state (survives workspace switches). Recommendation:
workspace state — a post belongs to the workspace that authored it.
4. **Unknown front-matter keys**: ignore silently vs warn in the publish
dialog. Recommendation: warn (non-blocking) so authors catch typos like
`tag:` instead of `tags:`.
+233
View File
@@ -0,0 +1,233 @@
# 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.
+58
View File
@@ -0,0 +1,58 @@
import * as vscode from "vscode";
/** Typed wrapper over the `nostr.*` configuration section. */
export class ConfigService {
private cfg(): vscode.WorkspaceConfiguration {
return vscode.workspace.getConfiguration("nostr");
}
get relays(): string[] {
const relays = this.cfg().get<string[]>("relays", []);
return relays.filter((r) => r.length > 0);
}
get publishConfirm(): boolean {
return this.cfg().get<boolean>("publish.confirm", true);
}
get publishTimeoutMs(): number {
return this.cfg().get<number>("publish.timeoutMs", 10000);
}
get signerBackend(): "local" | "nsigner" {
return this.cfg().get<"local" | "nsigner">("signerBackend", "local");
}
get nsignerSocketName(): string {
return this.cfg().get<string>("nsigner.socketName", "nsigner");
}
get nsignerNostrIndex(): number {
return this.cfg().get<number>("nsigner.nostrIndex", 0);
}
get nsignerTransport(): "unix" | "qrexec" | "tcp" {
return this.cfg().get<"unix" | "qrexec" | "tcp">("nsigner.transport", "unix");
}
get nsignerTcpHost(): string {
return this.cfg().get<string>("nsigner.tcpHost", "127.0.0.1");
}
get nsignerTcpPort(): number {
return this.cfg().get<number>("nsigner.tcpPort", 8080);
}
get nsignerQrexecQube(): string {
return this.cfg().get<string>("nsigner.qrexecQube", "nostr_signer");
}
get nsignerQrexecService(): string {
return this.cfg().get<string>("nsigner.qrexecService", "qubes.NsignerRpc");
}
/** Caller nsec for TCP auth envelopes (stored in-memory only, not persisted). */
get nsignerCallerNsec(): string | undefined {
return this.cfg().get<string | undefined>("nsigner.callerNsec", undefined);
}
}
+1230
View File
File diff suppressed because it is too large Load Diff
+151
View File
@@ -0,0 +1,151 @@
import { parse as parseYaml } from "yaml";
/** Known front-matter keys. */
const KNOWN_KEYS = new Set(["slug", "title", "summary", "image", "tags"]);
/** Result of parsing a markdown document's front-matter. */
export interface FrontMatterResult {
/** Parsed and sanitized metadata. */
frontMatter: ParsedFrontMatter;
/** Body content with the front-matter block stripped. */
content: string;
/** Non-blocking warnings (unknown keys, etc.). */
warnings: string[];
}
export interface ParsedFrontMatter {
slug: string;
title: string;
summary: string;
image?: string;
tags: string[];
}
/** Error thrown when front-matter is malformed. */
export class FrontMatterError extends Error {
constructor(message: string) {
super(message);
this.name = "FrontMatterError";
}
}
/**
* Parse a leading YAML front-matter block from a markdown document.
*
* Format:
* ---
* slug: my-post
* title: My Post
* summary: A short abstract.
* image: https://cdn.example.com/cover.png
* tags: [nostr, longform, writing]
* ---
*
* The block is stripped from the returned `content`. Unknown keys produce
* warnings (non-blocking). Defaults are applied for missing fields:
* - slug -> sanitized filename stem (caller-provided) or "post"
* - title -> filename stem or "Untitled"
* - summary -> first non-empty, non-heading line, truncated to 280 chars
* - image -> omitted (no `image` tag emitted)
* - tags -> []
*
* Throws FrontMatterError if the leading `---` block is present but the YAML
* body is unparseable.
*
* @param text Full markdown document text.
* @param filename Optional filename stem (without extension) for defaults.
*/
export function parseFrontMatter(text: string, filename?: string): FrontMatterResult {
const stem = sanitizeSlug(filename ?? "post");
const warnings: string[] = [];
// Detect a leading `---` fence. The document must start with `---\n` (allow
// leading whitespace? No — front-matter must be the very first thing).
const fmMatch = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
if (!fmMatch) {
// No front-matter; return defaults derived from the body.
const content = text;
return {
frontMatter: {
slug: stem,
title: filename ?? "Untitled",
summary: deriveSummary(content),
tags: [],
},
content,
warnings,
};
}
const yamlBody = fmMatch[1];
const content = text.slice(fmMatch[0].length);
let parsed: Record<string, unknown>;
try {
const obj = parseYaml(yamlBody);
parsed = (obj && typeof obj === "object" && !Array.isArray(obj))
? obj as Record<string, unknown>
: {};
} catch (err) {
throw new FrontMatterError(`YAML parse error: ${(err as Error).message}`);
}
// Warn on unknown keys.
for (const key of Object.keys(parsed)) {
if (!KNOWN_KEYS.has(key)) {
warnings.push(`unknown front-matter key "${key}" will be ignored`);
}
}
const slug = sanitizeSlug(typeof parsed.slug === "string" && parsed.slug ? parsed.slug : stem);
const title = typeof parsed.title === "string" && parsed.title ? parsed.title : (filename ?? "Untitled");
const summary = typeof parsed.summary === "string" && parsed.summary ? parsed.summary : deriveSummary(content);
const image = typeof parsed.image === "string" && parsed.image ? parsed.image : undefined;
const tags = parseTags(parsed.tags);
return {
frontMatter: { slug, title, summary, image, tags },
content,
warnings,
};
}
/** Sanitize a string into a d-tag slug: lowercase, spaces→-, strip non-[a-z0-9-]. */
function sanitizeSlug(s: string): string {
return s
.trim()
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "") || "post";
}
/** Parse the `tags` field: accept a YAML array or a comma-separated string. */
function parseTags(raw: unknown): string[] {
if (!raw) return [];
let arr: unknown[];
if (Array.isArray(raw)) {
arr = raw;
} else if (typeof raw === "string") {
arr = raw.split(",");
} else {
return [];
}
return arr
.map((t) => (typeof t === "string" ? t.trim() : ""))
.map((t) => t.replace(/^#/, ""))
.map((t) => t.toLowerCase())
.filter((t) => t.length > 0);
}
/** Derive a summary from the first non-empty, non-heading line, truncated to 280 chars. */
function deriveSummary(content: string): string {
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith("#")) continue; // skip headings
return trimmed.length > 280 ? trimmed.slice(0, 280) + "…" : trimmed;
}
return "";
}
+43
View File
@@ -0,0 +1,43 @@
import { LongFormArticle } from "nostr-tools/kinds";
import type { EventTemplate } from "nostr-tools/core";
import type { ParsedFrontMatter } from "../frontmatter";
/** Input for building a NIP-23 long-form article event. */
export interface LongFormInput {
/** Parsed front-matter (slug, title, summary, image, tags). */
meta: ParsedFrontMatter;
/** Body content with front-matter stripped. */
content: string;
/** First-publish timestamp (unix seconds). Reused on updates. */
publishedAt: number;
}
/**
* Build an unsigned NIP-23 long-form article event (kind 30023).
*
* Tag order: d, title, summary, image (if present), published_at, t…, alt.
* The `pubkey`, `id`, and `sig` are added by the signer at sign time.
*/
export function buildLongFormEvent(input: LongFormInput): EventTemplate {
const { meta, content, publishedAt } = input;
const tags: string[][] = [
["d", meta.slug],
["title", meta.title],
["summary", meta.summary],
];
if (meta.image) {
tags.push(["image", meta.image]);
}
tags.push(["published_at", String(publishedAt)]);
for (const t of meta.tags) {
tags.push(["t", t]);
}
tags.push(["alt", `Long-form post: ${meta.title}`]);
return {
kind: LongFormArticle,
created_at: Math.floor(Date.now() / 1000),
tags,
content,
};
}
+29
View File
@@ -0,0 +1,29 @@
import { decode, npubEncode } from "nostr-tools/nip19";
/** Encode a 32-byte hex pubkey as an npub1 string. */
export function hexToNpub(hex: string): string {
return npubEncode(hex);
}
/** Shorten an npub for display: first 10 + … + last 4 chars. */
export function shortNpub(hex: string): string {
const npub = hexToNpub(hex);
return `${npub.slice(0, 10)}…${npub.slice(-4)}`;
}
/**
* Decode an nsec1 string into its 32-byte secret key.
* Throws on malformed input or wrong type.
*/
export function decodeNsec(nsec: string): Uint8Array {
const decoded = decode(nsec);
if (decoded.type !== "nsec") {
throw new Error(`Expected nsec1... but got ${decoded.type}`);
}
return decoded.data;
}
/** True if the string looks like a 64-char hex secret key. */
export function isHexSecretKey(s: string): boolean {
return /^[0-9a-fA-F]{64}$/.test(s.trim());
}
+286
View File
@@ -0,0 +1,286 @@
import WebSocket from "ws";
import type { Event } from "nostr-tools/core";
/** Result of publishing one event to one relay. */
export interface PublishResult {
relay: string;
ok: boolean;
message: string; // OK reason, NOTICE text, or error message
}
/** A relay entry parsed from a NIP-65 (kind 10002) event's `r` tags. */
export interface Nip65Relay {
url: string;
read: boolean;
write: boolean;
}
/** NIP-01 profile metadata (kind 0 event content, parsed). */
export interface NostrProfile {
name?: string;
about?: string;
picture?: string;
nip05?: string;
}
/**
* Fetch a user's NIP-01 profile metadata (kind 0) from the given relays.
* Returns the parsed profile from the newest kind-0 event, or null if none
* is found. Non-fatal on parse errors — returns null.
*/
export async function fetchUserProfile(
pubkey: string,
seedRelays: string[],
timeoutMs = 8000,
): Promise<NostrProfile | null> {
const events = await queryRelays(
seedRelays,
{ kinds: [0], authors: [pubkey], limit: 1 },
timeoutMs,
);
if (events.length === 0) return null;
events.sort((a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id));
try {
const parsed = JSON.parse(events[0].content);
return {
name: typeof parsed.name === "string" ? parsed.name : undefined,
about: typeof parsed.about === "string" ? parsed.about : undefined,
picture: typeof parsed.picture === "string" ? parsed.picture : undefined,
nip05: typeof parsed.nip05 === "string" ? parsed.nip05 : undefined,
};
} catch {
return null;
}
}
/**
* Fetch a user's NIP-65 relay list (kind 10002) from the given seed relays.
*
* Connects to each seed relay concurrently, sends a REQ filter for the
* author's most recent kind-10002 event, and returns the relay list from the
* newest event found across all seeds. If no 10002 event is found on any
* seed relay within the timeout, returns null (caller falls back to
* configured defaults).
*
* Per NIP-65, `r` tags are parsed as:
* ["r", "<url>"] -> read=true, write=true
* ["r", "<url>", "read"] -> read=true, write=false
* ["r", "<url>", "write"] -> read=false, write=true
*/
export async function fetchUserRelays(
pubkey: string,
seedRelays: string[],
timeoutMs = 8000,
): Promise<Nip65Relay[] | null> {
const events = await queryRelays(
seedRelays,
{ kinds: [10002], authors: [pubkey], limit: 1 },
timeoutMs,
);
if (events.length === 0) {
return null;
}
// Pick the newest by created_at (then by id lexicographically for ties).
events.sort((a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id));
return parseNip65Relays(events[0]);
}
/** Parse `r` tags from a kind 10002 event into Nip65Relay entries. */
export function parseNip65Relays(event: Event): Nip65Relay[] {
const relays: Nip65Relay[] = [];
for (const tag of event.tags) {
if (tag.length >= 2 && tag[0] === "r") {
const url = tag[1];
const marker = tag[2];
relays.push({
url,
read: marker === undefined || marker === "read",
write: marker === undefined || marker === "write",
});
}
}
return relays;
}
/**
* Query multiple relays concurrently for events matching a filter.
*
* Sends `["REQ", subId, filter]` to each relay, collects EVENT messages until
* EOSE or the timeout, then closes the subscription and socket. Returns the
* union of all events received across all relays (deduplicated by id).
*/
async function queryRelays(
relays: string[],
filter: Record<string, unknown>,
timeoutMs: number,
): Promise<Event[]> {
const subId = "nostr-ext-" + Math.random().toString(36).slice(2, 10);
const results = await Promise.all(
relays.map((relay) => queryOneRelay(relay, subId, filter, timeoutMs)),
);
// Deduplicate by event id.
const seen = new Set<string>();
const out: Event[] = [];
for (const events of results) {
for (const ev of events) {
if (!seen.has(ev.id)) {
seen.add(ev.id);
out.push(ev);
}
}
}
return out;
}
function queryOneRelay(
relay: string,
subId: string,
filter: Record<string, unknown>,
timeoutMs: number,
): Promise<Event[]> {
return new Promise((resolve) => {
const events: Event[] = [];
let settled = false;
let socket: WebSocket | null = null;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
socket?.close();
resolve(events);
}, timeoutMs);
try {
socket = new WebSocket(relay);
} catch {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(events);
return;
}
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timer);
try {
socket?.send(JSON.stringify(["CLOSE", subId]));
} catch {
// ignore
}
socket?.close();
resolve(events);
};
socket.on("open", () => {
try {
socket!.send(JSON.stringify(["REQ", subId, filter]));
} catch {
finish();
}
});
socket.on("message", (data: WebSocket.RawData) => {
try {
const msg = JSON.parse(data.toString());
if (Array.isArray(msg) && msg[0] === "EVENT" && msg[1] === subId) {
events.push(msg[2] as Event);
} else if (Array.isArray(msg) && msg[0] === "EOSE" && msg[1] === subId) {
finish();
}
} catch {
// ignore unparseable
}
});
socket.on("error", () => finish());
socket.on("close", () => {
if (!settled) finish();
});
});
}
/**
* Publish a signed event to a list of relays concurrently.
*
* For each relay, opens a WebSocket, sends `["EVENT", event]`, and waits for
* either an `["OK", id, true|false, reason]` message, a `["NOTICE", text]`
* message, or the per-relay timeout. Returns one PublishResult per relay.
*
* Sockets are always closed in a finally block.
*/
export async function publishToRelays(
event: Event,
relays: string[],
timeoutMs = 10000,
): Promise<PublishResult[]> {
return Promise.all(relays.map((relay) => publishToOne(event, relay, timeoutMs)));
}
function publishToOne(event: Event, relay: string, timeoutMs: number): Promise<PublishResult> {
return new Promise((resolve) => {
let settled = false;
let socket: WebSocket | null = null;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
socket?.close();
resolve({ relay, ok: false, message: `timeout after ${timeoutMs}ms` });
}, timeoutMs);
try {
socket = new WebSocket(relay);
} catch (err) {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ relay, ok: false, message: `connection error: ${(err as Error).message}` });
return;
}
const finish = (result: PublishResult) => {
if (settled) return;
settled = true;
clearTimeout(timer);
socket?.close();
resolve(result);
};
socket.on("open", () => {
try {
socket!.send(JSON.stringify(["EVENT", event]));
} catch (err) {
finish({ relay, ok: false, message: `send error: ${(err as Error).message}` });
}
});
socket.on("message", (data: WebSocket.RawData) => {
try {
const msg = JSON.parse(data.toString());
if (Array.isArray(msg) && msg[0] === "OK" && msg[1] === event.id) {
const accepted = msg[2] === true;
const reason = typeof msg[3] === "string" ? msg[3] : "";
finish({
relay,
ok: accepted,
message: accepted ? (reason || "accepted") : `rejected: ${reason}`,
});
} else if (Array.isArray(msg) && msg[0] === "NOTICE") {
finish({ relay, ok: false, message: `NOTICE: ${msg[1]}` });
}
// Ignore other message types (e.g. EVENT echoes) — wait for OK.
} catch {
// Ignore unparseable messages; keep waiting for OK/timeout.
}
});
socket.on("error", (err) => {
finish({ relay, ok: false, message: `connection error: ${err.message}` });
});
socket.on("close", () => {
// If we close before an OK message, treat as failure (unless already settled).
finish({ relay, ok: false, message: "connection closed before OK" });
});
});
}
+119
View File
@@ -0,0 +1,119 @@
import * as kinds from "nostr-tools/kinds";
/** Result of validating a parsed JSON object as a Nostr event template. */
export interface ValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
kindName?: string;
}
/** Keys that belong to an unsigned event template. */
const TEMPLATE_KEYS = new Set(["kind", "created_at", "tags", "content"]);
/** Keys added by signing. */
const SIGNED_KEYS = new Set(["pubkey", "id", "sig"]);
/**
* Validate a parsed JSON object as a Nostr unsigned event template.
*
* Hard errors (make `valid` false):
* - not an object
* - missing or wrong-typed `kind` / `created_at` / `tags` / `content`
* - `tags` not an array of arrays of strings, or inner arrays empty
*
* Soft warnings (do not affect `valid`):
* - extra unknown keys
* - presence of `pubkey` / `id` / `sig` (looks like a signed event; publish
* will re-sign and overwrite them)
*
* If `kind` matches a known constant from `nostr-tools/kinds`, its friendly
* name is returned in `kindName`.
*/
export function validateEventTemplate(obj: unknown): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
return { valid: false, errors: ["event must be a JSON object"], warnings };
}
const record = obj as Record<string, unknown>;
// Check for signed-event keys (warning, not error).
for (const key of Object.keys(record)) {
if (SIGNED_KEYS.has(key)) {
warnings.push(
`field "${key}" is present — this looks like a signed event; publish will re-sign and overwrite id/sig`,
);
} else if (!TEMPLATE_KEYS.has(key)) {
warnings.push(`unknown field "${key}" will be ignored`);
}
}
// kind
if (!("kind" in record)) {
errors.push("missing required field: kind");
} else if (typeof record.kind !== "number" || !Number.isInteger(record.kind) || record.kind < 0) {
errors.push("kind must be a non-negative integer");
}
// created_at
if (!("created_at" in record)) {
errors.push("missing required field: created_at");
} else if (
typeof record.created_at !== "number" ||
!Number.isInteger(record.created_at) ||
record.created_at < 0
) {
errors.push("created_at must be a non-negative integer (unix seconds)");
}
// tags
if (!("tags" in record)) {
errors.push("missing required field: tags");
} else if (!Array.isArray(record.tags)) {
errors.push("tags must be an array");
} else {
for (let i = 0; i < record.tags.length; i++) {
const tag = record.tags[i];
if (!Array.isArray(tag) || tag.length < 1) {
errors.push(`tags[${i}] must be a non-empty array`);
continue;
}
for (let j = 0; j < tag.length; j++) {
if (typeof tag[j] !== "string") {
errors.push(`tags[${i}][${j}] must be a string`);
}
}
}
}
// content
if (!("content" in record)) {
errors.push("missing required field: content");
} else if (typeof record.content !== "string") {
errors.push("content must be a string");
}
// Resolve friendly kind name.
let kindName: string | undefined;
if (typeof record.kind === "number") {
kindName = kindToName(record.kind);
}
return { valid: errors.length === 0, errors, warnings, kindName };
}
/** Map a kind integer to its friendly name from nostr-tools/kinds, if known. */
function kindToName(kind: number): string | undefined {
// Build the mapping lazily from the exported constants. We check a curated
// set of common kinds to avoid importing every constant by name.
const map: Record<number, string> = {
[kinds.Metadata]: "Metadata",
[kinds.ShortTextNote]: "ShortTextNote",
[kinds.Contacts]: "Contacts",
[kinds.LongFormArticle]: "LongFormArticle",
[kinds.DraftLong]: "DraftLong",
};
return map[kind];
}
+63
View File
@@ -0,0 +1,63 @@
import { createHash } from "node:crypto";
import { finalizeEvent, getPublicKey } from "nostr-tools/pure";
import type { Event } from "nostr-tools/core";
import { jcsCanonicalize } from "./jcs";
/**
* Build a kind-27235 auth envelope for an n_signer TCP request.
*
* The auth envelope is a NIP-01-shaped event signed by the **caller's**
* private key (separate from n_signer's signing key). It binds the signature
* to a specific request id, method, and params hash so a captured envelope
* cannot be replayed with a different request.
*
* Wire contract (from `n_signer/plans/caller_token_identity.md` §6):
* - kind: 27235
* - tags:
* - ["nsigner_rpc", <request id>]
* - ["nsigner_method", <method>]
* - ["nsigner_body_hash", SHA-256(JCS(params)) as hex]
* - content: optional client label (e.g. "nostr-publish@codium")
* - signed with BIP-340 Schnorr via nostr-tools finalizeEvent
*
* @param requestId The request's `id` field.
* @param method The request's `method` field.
* @param params The request's `params` (array or object). Canonicalized
* via JCS before hashing.
* @param callerSecretKey The caller's 32-byte secp256k1 secret key.
* @param label Optional human-readable client label for the `content` field.
*/
export function buildAuthEnvelope(
requestId: string,
method: string,
params: unknown,
callerSecretKey: Uint8Array,
label = "nostr-publish@codium",
): Event {
const bodyHash = sha256Hex(jcsCanonicalize(params));
const created_at = Math.floor(Date.now() / 1000);
const template = {
kind: 27235,
created_at,
tags: [
["nsigner_rpc", requestId],
["nsigner_method", method],
["nsigner_body_hash", bodyHash],
],
content: label,
};
// finalizeEvent injects pubkey, computes id, and signs with Schnorr.
return finalizeEvent(template, callerSecretKey);
}
/** Derive the caller's pubkey hex from their secret key. */
export function callerPubkeyHex(callerSecretKey: Uint8Array): string {
return getPublicKey(callerSecretKey);
}
/** SHA-256 of a UTF-8 string, returned as hex. */
function sha256Hex(input: string): string {
return createHash("sha256").update(input, "utf8").digest("hex");
}
+124
View File
@@ -0,0 +1,124 @@
import net from "node:net";
/**
* JSON-RPC response shape from n_signer.
*/
export interface NsignerResponse {
id?: string;
result?: unknown;
error?: { code?: number; message: string };
}
/**
* Common interface for all n_signer transports (unix, qrexec, tcp).
* Each `call()` sends one framed JSON-RPC request and returns one response.
*/
export interface NsignerTransport {
call(method: string, params: unknown[], timeoutMs?: number): Promise<NsignerResponse>;
}
/**
* Client for talking to a running `n_signer` process over a Linux abstract
* Unix socket.
*
* Wire contract (confirmed against `n_signer/documents/CLIENT_IMPLEMENTATION.md`
* §9.3 and `n_signer/client/demo_javascript.js`):
* - Transport: abstract Unix socket. Node connects via
* `net.createConnection({ path: "\u0000<socketName>" })` — the leading
* `\u0000` is the Linux abstract-namespace marker.
* - Framing: 4-byte big-endian length prefix + UTF-8 JSON payload.
* - Request: `{"id":"<id>","method":"<verb>","params":[...]}`.
* - Response: `{"id":"<id>","result":<...>}` or
* `{"id":"<id>","error":{"code":<n>,"message":"<msg>"}}`.
* - No auth envelope needed for unix sockets — identity is UID-based via
* SO_PEERCRED (see `n_signer/documents/SECURITY.md` §428).
*
* Each `call()` opens a fresh connection, sends one framed request, reads one
* framed response, and closes. This matches the reference client behavior.
*/
export class NsignerClient implements NsignerTransport {
/** Socket name without the leading `@` (e.g. "nsigner"). */
readonly socketName: string;
constructor(socketName: string) {
this.socketName = socketName;
}
/**
* Send a JSON-RPC request to n_signer and return the parsed response.
* Throws on connection error, timeout, or frame parse failure.
*
* @param method RPC verb (e.g. "get_public_key", "sign_event").
* @param params Params array (e.g. `[{nostr_index: 0}]`).
* @param timeoutMs Per-call timeout (default 30s — signing may need human
* approval at the n_signer terminal, so this is deliberately long).
*/
async call(method: string, params: unknown[], timeoutMs = 30000): Promise<NsignerResponse> {
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) => {
// Abstract socket: prefix the path with a NUL byte.
const socket = net.createConnection({ path: `\u0000${this.socketName}` });
let chunks: Buffer[] = [];
let needed = 4;
let mode: "header" | "body" = "header";
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
socket.destroy();
reject(new Error(`n_signer call "${method}" timed out after ${timeoutMs}ms`));
}, timeoutMs);
const finish = (err: Error | null, resp: NsignerResponse | null) => {
if (settled) return;
settled = true;
clearTimeout(timer);
socket.destroy();
if (err) reject(err);
else resolve(resp!);
};
socket.on("connect", () => {
try {
socket.write(Buffer.concat([header, payload]));
} catch (err) {
finish(err as Error, null);
}
});
socket.on("data", (data: Buffer) => {
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 {
try {
const resp = JSON.parse(part.toString("utf8")) as NsignerResponse;
finish(null, resp);
return;
} catch (err) {
finish(new Error(`n_signer: failed to parse response: ${(err as Error).message}`), null);
return;
}
}
}
chunks = [buf];
});
socket.on("error", (err) => {
finish(new Error(`n_signer connection error (@${this.socketName}): ${err.message}`), null);
});
});
}
}
+42
View File
@@ -0,0 +1,42 @@
import * as fs from "node:fs/promises";
/**
* Discover running n_signer instances by reading `/proc/net/unix`.
*
* Abstract socket paths appear in `/proc/net/unix` with a leading NUL byte
* (`\0`). n_signer binds to `@nsigner` (explicit `--socket-name nsigner`) or
* `@nsigner_<word1>_<word2>` (random BIP-39 pair). See
* `n_signer/plans/nsigner.md` and `n_signer/documents/CLIENT_IMPLEMENTATION.md`.
*
* Returns socket names **without** the leading `@` (i.e. ready to pass to
* `NsignerClient`).
*/
export async function discoverNsignerSockets(): Promise<string[]> {
let content: string;
try {
content = await fs.readFile("/proc/net/unix", "utf8");
} catch {
return [];
}
const sockets = new Set<string>();
for (const line of content.split("\n")) {
// Abstract socket paths in /proc/net/unix are prefixed with a NUL byte.
// Match `\0nsigner...` and strip the NUL.
const match = line.match(/\x00(nsigner[^\s]*)/);
if (match) {
sockets.add(match[1]);
}
}
return [...sockets];
}
/**
* Check whether a given abstract socket name appears to be bound.
*
* Reads `/proc/net/unix` and looks for `\0<socketName>`. Returns true if found.
*/
export async function isNsignerSocketBound(socketName: string): Promise<boolean> {
const sockets = await discoverNsignerSockets();
return sockets.includes(socketName);
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Minimal JCS (RFC 8785, JSON Canonicalization Scheme) implementation.
*
* Used to compute the `nsigner_body_hash` for the kind-27235 auth envelope
* required by n_signer's TCP transport. The signer recomputes
* `SHA-256(JCS(params))` and rejects on mismatch, so the canonicalization
* must match RFC 8785 exactly.
*
* This implementation covers the subset of JSON used by n_signer RPC params:
* objects, arrays, strings, numbers, booleans, null. It does NOT handle:
* - number serialization edge cases (NaN/Infinity, -0, exotic exponents) —
* n_signer params never contain these.
* - string escaping beyond the RFC 8785 required set.
*
* Reference: https://tools.ietf.org/html/rfc8785
*/
/** Canonicalize a JSON-serializable value per RFC 8785. */
export function jcsCanonicalize(value: unknown): string {
return canonicalizeValue(value);
}
function canonicalizeValue(value: unknown): string {
if (value === null) return "null";
if (value === true) return "true";
if (value === false) return "false";
if (typeof value === "string") return canonicalizeString(value);
if (typeof value === "number") return canonicalizeNumber(value);
if (Array.isArray(value)) {
return "[" + value.map(canonicalizeValue).join(",") + "]";
}
if (typeof value === "object") {
const obj = value as Record<string, unknown>;
// RFC 8785 §3.2.3: sort keys by UTF-16 code unit order.
const keys = Object.keys(obj).sort();
const entries = keys.map((k) => canonicalizeString(k) + ":" + canonicalizeValue(obj[k]));
return "{" + entries.join(",") + "}";
}
// Fallback (shouldn't happen for valid JSON-serializable input).
return "null";
}
/** Canonicalize a string per RFC 8785 §3.2.2. */
function canonicalizeString(s: string): string {
// Escape per RFC 8785: ", \, and control chars < 0x20.
let out = '"';
for (let i = 0; i < s.length; i++) {
const ch = s.charCodeAt(i);
if (ch === 0x22) out += '\\"';
else if (ch === 0x5c) out += "\\\\";
else if (ch === 0x08) out += "\\b";
else if (ch === 0x09) out += "\\t";
else if (ch === 0x0a) out += "\\n";
else if (ch === 0x0c) out += "\\f";
else if (ch === 0x0d) out += "\\r";
else if (ch < 0x20) out += "\\u" + ch.toString(16).padStart(4, "0");
else out += s[i];
}
out += '"';
return out;
}
/**
* Canonicalize a number per RFC 8785 §3.2.2.3.
*
* For the integer and simple-decimal values used in n_signer params,
* `String(n)` produces the correct canonical form. Special cases (NaN,
* Infinity, -0, very large/small exponents) are not expected in n_signer
* RPC params.
*/
function canonicalizeNumber(n: number): string {
if (!Number.isFinite(n)) {
// RFC 8785: these are not representable. n_signer params won't contain them.
throw new Error("JCS: non-finite number in canonicalization");
}
// -0 should canonicalize as "0".
if (n === 0) return "0";
return String(n);
}
+94
View File
@@ -0,0 +1,94 @@
import { spawn } from "node:child_process";
import type { NsignerResponse, NsignerTransport } from "./client";
/**
* n_signer transport over Qubes OS qrexec.
*
* Spawns `qrexec-client-vm <targetQube> qubes.NsignerRpc` per call, sends one
* framed JSON-RPC request via stdin, reads one framed response via stdout.
* No auth envelope needed — caller identity is `qubes:<source-vm>` from
* `QREXEC_REMOTE_DOMAIN` on the server side.
*
* Wire contract confirmed against
* `n_signer/examples/n_signer_qube_example_qrexec.js` and
* `n_signer/documents/CLIENT_IMPLEMENTATION.md` §2.4.
*/
export class QrexecClient implements NsignerTransport {
readonly targetQube: string;
readonly serviceName: string;
constructor(targetQube: string, serviceName = "qubes.NsignerRpc") {
this.targetQube = targetQube;
this.serviceName = serviceName;
}
async call(
method: string,
params: unknown[],
timeoutMs = 30000,
): Promise<NsignerResponse> {
const request = { id: "1", method, params };
const payload = Buffer.from(JSON.stringify(request), "utf8");
const header = Buffer.alloc(4);
header.writeUInt32BE(payload.length, 0);
const framed = Buffer.concat([header, payload]);
return new Promise((resolve, reject) => {
let settled = false;
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
const proc = spawn("qrexec-client-vm", [this.targetQube, this.serviceName], {
stdio: ["pipe", "pipe", "pipe"],
});
const timer = setTimeout(() => {
if (settled) return;
settled = true;
try { proc.kill(); } catch { /* ignore */ }
reject(new Error(`qrexec call "${method}" timed out after ${timeoutMs}ms`));
}, timeoutMs);
proc.stdout.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
proc.stderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
proc.on("error", (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(new Error(`failed to spawn qrexec-client-vm: ${err.message}`));
});
proc.on("close", (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (code !== 0) {
const stderr = Buffer.concat(stderrChunks).toString("utf8");
reject(new Error(`qrexec-client-vm exited with code ${code}: ${stderr.trim()}`));
return;
}
const buf = Buffer.concat(stdoutChunks);
if (buf.length < 4) {
reject(new Error("qrexec: short response (missing frame header)"));
return;
}
const len = buf.readUInt32BE(0);
const body = buf.subarray(4, 4 + len);
if (body.length !== len) {
reject(new Error(`qrexec: short response payload: expected ${len}, got ${body.length}`));
return;
}
try {
resolve(JSON.parse(body.toString("utf8")) as NsignerResponse);
} catch (err) {
reject(new Error(`qrexec: failed to parse response: ${(err as Error).message}`));
}
});
// Send the framed request and close stdin.
proc.stdin.write(framed);
proc.stdin.end();
});
}
}
+118
View File
@@ -0,0 +1,118 @@
import net from "node:net";
import type { NsignerResponse, NsignerTransport } from "./client";
import { buildAuthEnvelope } from "./authEnvelope";
/**
* n_signer transport over TCP.
*
* Connects to `nsigner --listen tcp:<host>:<port>`, sends framed JSON-RPC
* requests, reads framed responses. **Requires an auth envelope** on every
* request (kind 27235, signed by the caller's key) — TCP has no
* kernel-vouched identity like AF_UNIX's SO_PEERCRED, so the signer requires
* application-layer authentication.
*
* Wire contract (from `n_signer/documents/CLIENT_IMPLEMENTATION.md` §2.5 and
* `n_signer/plans/caller_token_identity.md` §6):
* - Framing: 4-byte big-endian length prefix + UTF-8 JSON (same as unix).
* - Request includes an `auth` field: a kind-27235 Nostr event signed by
* the caller's secp256k1 key, binding the signature to the request id,
* method, and SHA-256(JCS(params)).
* - Missing/invalid auth → error codes 2010-2017.
*/
export class TcpClient implements NsignerTransport {
readonly host: string;
readonly port: number;
private callerSecretKey: Uint8Array;
private callerLabel: string;
constructor(
host: string,
port: number,
callerSecretKey: Uint8Array,
callerLabel = "nostr-publish@codium",
) {
this.host = host;
this.port = port;
this.callerSecretKey = callerSecretKey;
this.callerLabel = callerLabel;
}
async call(
method: string,
params: unknown[],
timeoutMs = 30000,
): Promise<NsignerResponse> {
const requestId = "1";
// Build the auth envelope binding this request's id/method/params.
const auth = buildAuthEnvelope(
requestId,
method,
params,
this.callerSecretKey,
this.callerLabel,
);
const request = { id: requestId, method, params, auth };
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({ host: this.host, port: this.port });
let chunks: Buffer[] = [];
let needed = 4;
let mode: "header" | "body" = "header";
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
socket.destroy();
reject(new Error(`n_signer TCP call "${method}" timed out after ${timeoutMs}ms`));
}, timeoutMs);
const finish = (err: Error | null, resp: NsignerResponse | null) => {
if (settled) return;
settled = true;
clearTimeout(timer);
socket.destroy();
if (err) reject(err);
else resolve(resp!);
};
socket.on("connect", () => {
try {
socket.write(Buffer.concat([header, payload]));
} catch (err) {
finish(err as Error, null);
}
});
socket.on("data", (data: Buffer) => {
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 {
try {
const resp = JSON.parse(part.toString("utf8")) as NsignerResponse;
finish(null, resp);
return;
} catch (err) {
finish(new Error(`n_signer TCP: failed to parse response: ${(err as Error).message}`), null);
return;
}
}
}
chunks = [buf];
});
socket.on("error", (err) => {
finish(new Error(`n_signer TCP connection error (${this.host}:${this.port}): ${err.message}`), null);
});
});
}
}
+24
View File
@@ -0,0 +1,24 @@
import type { Signer } from "nostr-tools/signer";
/**
* Extension signer interface.
*
* Extends nostr-tools' `Signer` (getPublicKey + signEvent) with preflight and
* UI concerns. Both `LocalSigner` (Phase 1) and `NsignerBackend` (Phase 2)
* implement this, so the publish flow is backend-agnostic.
*/
export interface NostrSigner extends Signer {
readonly kind: "local" | "nsigner";
/** Preflight check: key loaded / n_signer reachable. */
ready(): Promise<boolean>;
/** Human-readable description for the status bar and error messages. */
describe(): string;
}
/** Thrown when a signer backend is not ready to sign. */
export class SignerNotReadyError extends Error {
constructor(public readonly backend: string, public readonly hint: string) {
super(`signer not ready (${backend}): ${hint}`);
this.name = "SignerNotReadyError";
}
}
+36
View File
@@ -0,0 +1,36 @@
import { PlainKeySigner } from "nostr-tools/signer";
import type { EventTemplate, VerifiedEvent } from "nostr-tools/core";
import type { NostrSigner } from "./backend";
/**
* Phase 1 signer: signs locally in-memory using nostr-tools' PlainKeySigner.
*
* The secret key is held by StateService and zeroed on sign-out. No
* persistence — the key is re-entered each session.
*/
export class LocalSigner implements NostrSigner {
readonly kind = "local" as const;
private inner: PlainKeySigner | null;
constructor(secretKey: Uint8Array) {
this.inner = new PlainKeySigner(secretKey);
}
async getPublicKey(): Promise<string> {
if (!this.inner) throw new Error("local signer: no key loaded");
return this.inner.getPublicKey();
}
async signEvent(event: EventTemplate): Promise<VerifiedEvent> {
if (!this.inner) throw new Error("local signer: no key loaded");
return this.inner.signEvent(event);
}
async ready(): Promise<boolean> {
return this.inner !== null;
}
describe(): string {
return "local in-memory key";
}
}
+130
View File
@@ -0,0 +1,130 @@
import type { EventTemplate, VerifiedEvent } from "nostr-tools/core";
import type { NostrSigner } from "./backend";
import type { NsignerTransport } from "../nsigner/client";
import { NsignerClient } from "../nsigner/client";
import { QrexecClient } from "../nsigner/qrexecClient";
import { TcpClient } from "../nsigner/tcpClient";
/** Transport type selector for n_signer connections. */
export type NsignerTransportKind = "unix" | "qrexec" | "tcp";
/** Configuration for constructing an n_signer backend. */
export interface NsignerBackendConfig {
transport: NsignerTransportKind;
/** unix: abstract socket name (without @). */
socketName?: string;
/** qrexec: target qube name. */
qrexecQube?: string;
/** qrexec: qrexec service name (default qubes.NsignerRpc). */
qrexecService?: string;
/** tcp: host. */
tcpHost?: string;
/** tcp: port. */
tcpPort?: number;
/** tcp: caller secret key for auth envelopes (required for tcp). */
callerSecretKey?: Uint8Array;
/** NIP-06 nostr_index for key selection (default 0). */
nostrIndex: number;
}
/**
* Phase 2 signer: delegates signing to a running `n_signer` process over one
* of three transports: unix abstract socket, Qubes qrexec, or TCP.
*
* Wire contract (confirmed against `n_signer/client/demo_javascript.js`,
* `n_signer/examples/n_signer_qube_example_qrexec.js`, and
* `n_signer/plans/caller_token_identity.md`):
* - `get_public_key`: params `[{nostr_index: <n>}]` → hex pubkey string.
* - `sign_event`: params `[JSON.stringify(event), {nostr_index: <n>}]` →
* JSON string of signed event.
* - unix/qrexec: no auth envelope (UID / QREXEC_REMOTE_DOMAIN identity).
* - tcp: auth envelope required (kind 27235, caller-signed).
*/
export class NsignerBackend implements NostrSigner {
readonly kind = "nsigner" as const;
private transport: NsignerTransport;
private nostrIndex: number;
private describeText: string;
constructor(config: NsignerBackendConfig) {
this.transport = buildTransport(config);
this.nostrIndex = config.nostrIndex;
this.describeText = describeTransport(config);
}
async ready(): Promise<boolean> {
try {
await this.getPublicKey();
return true;
} catch {
return false;
}
}
async getPublicKey(): Promise<string> {
const res = await this.transport.call("get_public_key", [{ nostr_index: this.nostrIndex }]);
if (res.error) {
throw new Error(`n_signer: ${res.error.message}`);
}
if (typeof res.result !== "string") {
throw new Error(`n_signer: unexpected get_public_key result type: ${typeof res.result}`);
}
return res.result;
}
async signEvent(event: EventTemplate): Promise<VerifiedEvent> {
const res = await this.transport.call("sign_event", [
JSON.stringify(event),
{ nostr_index: this.nostrIndex },
]);
if (res.error) {
throw new Error(`n_signer: ${res.error.message}`);
}
if (typeof res.result !== "string") {
throw new Error(`n_signer: unexpected sign_event result type: ${typeof res.result}`);
}
return JSON.parse(res.result) as VerifiedEvent;
}
describe(): string {
return this.describeText;
}
}
/** Construct the appropriate transport from the config. */
function buildTransport(config: NsignerBackendConfig): NsignerTransport {
switch (config.transport) {
case "unix":
return new NsignerClient(config.socketName ?? "nsigner");
case "qrexec":
return new QrexecClient(
config.qrexecQube ?? "nostr_signer",
config.qrexecService,
);
case "tcp":
if (!config.callerSecretKey) {
throw new Error("n_signer TCP transport requires a caller secret key for auth envelopes");
}
return new TcpClient(
config.tcpHost ?? "127.0.0.1",
config.tcpPort ?? 8080,
config.callerSecretKey,
);
default:
throw new Error(`n_signer: unknown transport "${config.transport}"`);
}
}
/** Human-readable description for the status bar / error messages. */
function describeTransport(config: NsignerBackendConfig): string {
switch (config.transport) {
case "unix":
return `n_signer @${config.socketName ?? "nsigner"} idx=${config.nostrIndex}`;
case "qrexec":
return `n_signer qrexec:${config.qrexecQube ?? "nostr_signer"} idx=${config.nostrIndex}`;
case "tcp":
return `n_signer tcp:${config.tcpHost ?? "127.0.0.1"}:${config.tcpPort ?? 8080} idx=${config.nostrIndex}`;
default:
return `n_signer (${config.transport}) idx=${config.nostrIndex}`;
}
}
+81
View File
@@ -0,0 +1,81 @@
import * as vscode from "vscode";
/**
* In-memory session state for the Nostr extension.
*
* The secret key is held only in RAM for the session. It is never written to
* disk or to SecretStorage (per the Phase 1 design decision: keep it simple,
* re-enter on reload). On sign-out or extension deactivate, the key buffer is
* zeroed.
*/
export class StateService {
private secretKey: Uint8Array | null = null;
private pubkeyHex: string | null = null;
private static readonly PUBLISHED_AT_KEY = "nostr.publishedAtMap";
// context is retained for Phase 1+ workspace-state features; unused in Phase 0.
constructor(private readonly context: vscode.ExtensionContext) {
void this.context;
}
/** Store the session secret key and its derived pubkey. */
setKey(secretKey: Uint8Array, pubkeyHex: string): void {
// Zero any previous key before replacing it.
this.clearKey();
this.secretKey = secretKey;
this.pubkeyHex = pubkeyHex;
}
/** Returns the session secret key, or null if signed out. */
getSecretKey(): Uint8Array | null {
return this.secretKey;
}
/** Returns the derived pubkey hex, or null if signed out. */
getPubkeyHex(): string | null {
return this.pubkeyHex;
}
/** True when a secret key is loaded for the session. */
isSignedIn(): boolean {
return this.secretKey !== null && this.pubkeyHex !== null;
}
/** Zero the key buffer and clear session identity. */
clearKey(): void {
if (this.secretKey) {
this.secretKey.fill(0);
}
this.secretKey = null;
this.pubkeyHex = null;
}
// --- published_at map (NIP-23 first-publish timestamp tracking) -----------
/** Get the stored first-publish timestamp for a d-slug, or null if unset. */
getPublishedAt(slug: string): number | null {
const map = this.getPublishedAtMap();
const ts = map[slug];
return typeof ts === "number" ? ts : null;
}
/** Store the first-publish timestamp for a d-slug. */
setPublishedAt(slug: string, timestamp: number): void {
const map = this.getPublishedAtMap();
map[slug] = timestamp;
this.context.workspaceState.update(StateService.PUBLISHED_AT_KEY, map);
}
/** Clear all stored first-publish timestamps. */
resetPublishedTimestamps(): void {
this.context.workspaceState.update(StateService.PUBLISHED_AT_KEY, {});
}
private getPublishedAtMap(): Record<string, number> {
const map = this.context.workspaceState.get<Record<string, number>>(
StateService.PUBLISHED_AT_KEY,
{},
);
return map ?? {};
}
}
+465
View File
@@ -0,0 +1,465 @@
import * as vscode from "vscode";
import type { StateService } from "../state";
import type { ConfigService } from "../config";
import { hexToNpub, shortNpub } from "../nostr/npub";
/**
* Message sent from the webview to the extension host.
*/
type WebviewMessage =
| { type: "signIn" }
| { type: "signOut" }
| { type: "signInMethod"; method: SignInMethod; config: SignInConfig }
| { type: "createEvent" }
| { type: "publishCurrentFile" }
| { type: "validateCurrentFile" }
| { type: "publishLongForm" }
| { type: "validateFrontMatter" }
| { type: "fetchRelays" }
| { type: "toggleRelay"; relay: string; enabled: boolean };
/** Sign-in method selection. */
type SignInMethod = "local" | "nsigner-unix" | "nsigner-qrexec" | "nsigner-tcp";
/** Per-method config fields sent from the sidebar. */
interface SignInConfig {
// local
nsec?: string;
// nsigner common
nostrIndex?: number;
// nsigner unix
socketName?: string;
// nsigner qrexec
qrexecQube?: string;
// nsigner tcp
tcpHost?: string;
tcpPort?: number;
callerNsec?: string;
}
/**
* State snapshot sent from the extension host to the webview.
*/
interface SidebarState {
signedIn: boolean;
npub?: string;
shortNpub?: string;
name?: string;
avatarUrl?: string;
signerBackend: string;
relays: { url: string; enabled: boolean }[];
canPublish: boolean;
canPublishLongForm: boolean;
}
/**
* Sidebar webview view provider.
*
* Section order: Actions → Relays → Identity.
*
* The Identity section has a sign-in method dropdown:
* - Local (nsec)
* - n_signer (Unix socket)
* - n_signer (Qubes qrexec)
* - n_signer (TCP)
* Per-method config fields appear below the dropdown. When signed in, the
* identity section shows the avatar (if available), npub, and a Sign Out button.
*/
export class NostrSidebarProvider implements vscode.WebviewViewProvider {
private view?: vscode.WebviewView;
private enabledRelays: Set<string>;
constructor(
private readonly state: StateService,
private readonly config: ConfigService,
private readonly onStateChange: vscode.Event<void>,
) {
this.enabledRelays = new Set(config.relays);
}
getEnabledRelays(): string[] {
const list = this._relayOverride ?? this.config.relays;
return list.filter((r) => this.enabledRelays.has(r));
}
setRelays(urls: string[]): void {
this.enabledRelays = new Set(urls);
this._relayOverride = urls;
this.refresh();
}
private _relayOverride: string[] | null = null;
resolveWebviewView(webviewView: vscode.WebviewView): void {
this.view = webviewView;
webviewView.webview.options = {
enableScripts: true,
localResourceRoots: [],
};
webviewView.webview.html = this.getHtml();
webviewView.webview.onDidReceiveMessage(async (msg: WebviewMessage) => {
switch (msg.type) {
case "signIn":
await vscode.commands.executeCommand("nostr.signIn");
break;
case "signOut":
await vscode.commands.executeCommand("nostr.signOut");
break;
case "signInMethod":
await vscode.commands.executeCommand("nostr.signInFromSidebar", msg.method, msg.config);
break;
case "createEvent":
await vscode.commands.executeCommand("nostr.createUnsignedEvent");
break;
case "publishCurrentFile":
await vscode.commands.executeCommand("nostr.publishEventFile");
break;
case "validateCurrentFile":
await vscode.commands.executeCommand("nostr.validateEventFile");
break;
case "publishLongForm":
await vscode.commands.executeCommand("nostr.publishLongForm");
break;
case "validateFrontMatter":
await vscode.commands.executeCommand("nostr.validateFrontMatter");
break;
case "fetchRelays":
await vscode.commands.executeCommand("nostr.fetchRelays");
break;
case "toggleRelay":
if (msg.enabled) {
this.enabledRelays.add(msg.relay);
} else {
this.enabledRelays.delete(msg.relay);
}
break;
}
});
this.onStateChange(() => this.refresh());
vscode.window.onDidChangeActiveTextEditor(() => this.refresh());
this.refresh();
}
/** Set the avatar/profile info (called by the extension after fetching kind 0). */
setProfile(name: string | undefined, avatarUrl: string | undefined): void {
this._name = name;
this._avatarUrl = avatarUrl;
this.refresh();
}
private _name: string | undefined;
private _avatarUrl: string | undefined;
refresh(): void {
if (!this.view) return;
const editor = vscode.window.activeTextEditor;
const canPublish = !!editor && editor.document.languageId === "json";
const canPublishLongForm = !!editor && editor.document.languageId === "markdown";
const pubkey = this.state.getPubkeyHex();
let signerBackend = "local";
if (this.config.signerBackend === "nsigner") {
const t = this.config.nsignerTransport;
if (t === "unix") signerBackend = `nsigner @${this.config.nsignerSocketName}`;
else if (t === "qrexec") signerBackend = `nsigner qrexec:${this.config.nsignerQrexecQube}`;
else if (t === "tcp") signerBackend = `nsigner tcp:${this.config.nsignerTcpHost}:${this.config.nsignerTcpPort}`;
else signerBackend = "nsigner";
}
const relayList = this._relayOverride ?? this.config.relays;
const snap: SidebarState = {
signedIn: this.state.isSignedIn(),
npub: pubkey ? hexToNpub(pubkey) : undefined,
shortNpub: pubkey ? shortNpub(pubkey) : undefined,
name: this._name,
avatarUrl: this._avatarUrl,
signerBackend,
relays: relayList.map((url) => ({ url, enabled: this.enabledRelays.has(url) })),
canPublish,
canPublishLongForm,
};
this.view.webview.postMessage({ type: "state", state: snap });
}
private getHtml(): string {
const nonce = getNonce();
const csp = [
`default-src 'none'`,
`img-src https: data:`,
`script-src 'nonce-${nonce}'`,
`style-src 'unsafe-inline'`,
].join("; ");
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="${csp}" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nostr</title>
<style>
:root {
--bg: var(--vscode-sideBar-background);
--fg: var(--vscode-foreground);
--muted: var(--vscode-descriptionForeground);
--border: var(--vscode-panel-border, var(--vscode-editorWidget-border));
--btn-bg: var(--vscode-button-background);
--btn-fg: var(--vscode-button-foreground);
--btn-bg-hover: var(--vscode-button-hoverBackground);
--btn2-bg: var(--vscode-button-secondaryBackground);
--btn2-fg: var(--vscode-button-secondaryForeground);
--input-bg: var(--vscode-inputBackground);
--input-fg: var(--vscode-inputForeground);
--input-border: var(--vscode-inputBorder);
--focus-border: var(--vscode-focusBorder);
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 12px;
background: var(--bg); color: var(--fg);
font-family: var(--vscode-font-family); font-size: var(--vscode-font-size);
}
.section { margin-bottom: 16px; }
.section h2 {
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.5px; color: var(--muted); margin: 0 0 8px 0;
}
button {
display: block; width: 100%; padding: 6px 10px; margin-bottom: 6px;
background: var(--btn-bg); color: var(--btn-fg); border: none;
border-radius: 2px; cursor: pointer; font-family: inherit; font-size: 13px; text-align: left;
}
button:hover { background: var(--btn-bg-hover); }
button.secondary { background: var(--btn2-bg); color: var(--btn2-fg); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
select, input[type="text"], input[type="number"], input[type="password"] {
width: 100%; padding: 4px 6px; margin-bottom: 6px;
background: var(--input-bg); color: var(--input-fg);
border: 1px solid var(--input-border); border-radius: 2px;
font-family: inherit; font-size: 12px;
}
select:focus, input:focus { outline: 1px solid var(--focus-border); border-color: var(--focus-border); }
.field-label { font-size: 11px; color: var(--muted); margin: 4px 0 2px; }
.relay-list { list-style: none; margin: 0; padding: 0; }
.relay-list li {
display: flex; align-items: center; gap: 6px; padding: 4px 0;
font-size: 12px; font-family: var(--vscode-editor-font-family);
}
.relay-list input[type="checkbox"] { margin: 0; }
.relay-list .url { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.empty { color: var(--muted); font-size: 12px; font-style: italic; }
.identity-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.avatar {
width: 32px; height: 32px; border-radius: 50%; object-fit: cover;
background: var(--input-bg); border: 1px solid var(--input-border);
}
.avatar-placeholder {
width: 32px; height: 32px; border-radius: 50%;
background: var(--input-bg); border: 1px solid var(--input-border);
display: flex; align-items: center; justify-content: center;
font-size: 14px; color: var(--muted);
}
.identity-info { flex: 1; overflow: hidden; }
.identity-name { font-size: 12px; font-weight: 600; }
.identity-npub {
font-family: var(--vscode-editor-font-family); font-size: 11px;
color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.config-fields { margin: 8px 0; }
.config-fields.hidden { display: none; }
</style>
</head>
<body>
<!-- ACTIONS -->
<div class="section">
<h2>Actions</h2>
<button id="createBtn">+ New Event</button>
<button id="publishLongFormBtn" disabled>Publish Current File as Long-Form Note</button>
<button id="validateFrontMatterBtn" class="secondary" disabled>Validate Front-Matter</button>
<button id="publishBtn" disabled>Publish Current JSON File</button>
<button id="validateBtn" class="secondary" disabled>Validate Current File</button>
</div>
<!-- RELAYS -->
<div class="section">
<h2>Relays</h2>
<ul class="relay-list" id="relayList">
<li class="empty">No relays configured.</li>
</ul>
<button id="fetchRelaysBtn" class="secondary" disabled>Fetch My Relays (NIP-65)</button>
</div>
<!-- IDENTITY -->
<div class="section">
<h2>Identity</h2>
<div id="signedInBlock" class="hidden">
<div class="identity-row">
<div id="avatarBox" class="avatar-placeholder">?</div>
<div class="identity-info">
<div class="identity-name" id="identityName"></div>
<div class="identity-npub" id="identityNpub"></div>
</div>
</div>
<div class="field-label" id="backendLabel"></div>
<button id="signOutBtn" class="secondary">Sign Out</button>
</div>
<div id="signedOutBlock">
<div class="field-label">Sign in method</div>
<select id="signInMethod">
<option value="local">Local (nsec / hex key)</option>
<option value="nsigner-unix">n_signer (Unix socket)</option>
<option value="nsigner-qrexec">n_signer (Qubes qrexec)</option>
<option value="nsigner-tcp">n_signer (TCP)</option>
</select>
<!-- local config -->
<div id="localFields" class="config-fields">
<div class="field-label">Secret key (nsec1... or hex)</div>
<input type="password" id="localNsec" placeholder="nsec1..." />
</div>
<!-- nsigner common -->
<div id="nsignerCommon" class="config-fields hidden">
<div class="field-label">Key index (nostr_index)</div>
<input type="number" id="nostrIndex" value="0" min="0" />
</div>
<!-- nsigner unix config -->
<div id="nsignerUnixFields" class="config-fields hidden">
<div class="field-label">Socket name (without @)</div>
<input type="text" id="socketName" value="nsigner" />
</div>
<!-- nsigner qrexec config -->
<div id="nsignerQrexecFields" class="config-fields hidden">
<div class="field-label">Target qube</div>
<input type="text" id="qrexecQube" value="nostr_signer" />
</div>
<!-- nsigner tcp config -->
<div id="nsignerTcpFields" class="config-fields hidden">
<div class="field-label">Host</div>
<input type="text" id="tcpHost" value="127.0.0.1" />
<div class="field-label">Port</div>
<input type="number" id="tcpPort" value="8080" />
<div class="field-label">Caller nsec (for auth envelope)</div>
<input type="password" id="callerNsec" placeholder="nsec1... (your signing key)" />
</div>
<button id="signInBtn">Sign In</button>
</div>
</div>
<script nonce="${nonce}">
const vscode = acquireVsCodeApi();
const $ = (id) => document.getElementById(id);
// Action buttons
$("createBtn").addEventListener("click", () => vscode.postMessage({ type: "createEvent" }));
$("publishBtn").addEventListener("click", () => vscode.postMessage({ type: "publishCurrentFile" }));
$("validateBtn").addEventListener("click", () => vscode.postMessage({ type: "validateCurrentFile" }));
$("publishLongFormBtn").addEventListener("click", () => vscode.postMessage({ type: "publishLongForm" }));
$("validateFrontMatterBtn").addEventListener("click", () => vscode.postMessage({ type: "validateFrontMatter" }));
$("fetchRelaysBtn").addEventListener("click", () => vscode.postMessage({ type: "fetchRelays" }));
$("signOutBtn").addEventListener("click", () => vscode.postMessage({ type: "signOut" }));
// Sign-in method dropdown — show/hide config fields
const methodSelect = $("signInMethod");
function updateMethodFields() {
const m = methodSelect.value;
$("localFields").classList.toggle("hidden", m !== "local");
$("nsignerCommon").classList.toggle("hidden", !m.startsWith("nsigner"));
$("nsignerUnixFields").classList.toggle("hidden", m !== "nsigner-unix");
$("nsignerQrexecFields").classList.toggle("hidden", m !== "nsigner-qrexec");
$("nsignerTcpFields").classList.toggle("hidden", m !== "nsigner-tcp");
}
methodSelect.addEventListener("change", updateMethodFields);
updateMethodFields();
// Sign In button — gather config and send
$("signInBtn").addEventListener("click", () => {
const method = methodSelect.value;
const config = {};
if (method === "local") {
config.nsec = $("localNsec").value.trim();
} else {
config.nostrIndex = parseInt($("nostrIndex").value, 10) || 0;
if (method === "nsigner-unix") {
config.socketName = $("socketName").value.trim();
} else if (method === "nsigner-qrexec") {
config.qrexecQube = $("qrexecQube").value.trim();
} else if (method === "nsigner-tcp") {
config.tcpHost = $("tcpHost").value.trim();
config.tcpPort = parseInt($("tcpPort").value, 10) || 8080;
config.callerNsec = $("callerNsec").value.trim();
}
}
vscode.postMessage({ type: "signInMethod", method, config });
});
// State updates
window.addEventListener("message", (event) => {
const msg = event.data;
if (msg.type !== "state") return;
const s = msg.state;
// Action button states
$("publishBtn").disabled = !s.canPublish;
$("validateBtn").disabled = !s.canPublish;
$("publishLongFormBtn").disabled = !s.canPublishLongForm;
$("validateFrontMatterBtn").disabled = !s.canPublishLongForm;
$("fetchRelaysBtn").disabled = !s.signedIn;
// Identity
if (s.signedIn) {
$("signedInBlock").classList.remove("hidden");
$("signedOutBlock").classList.add("hidden");
// Avatar
const avatarBox = $("avatarBox");
if (s.avatarUrl) {
avatarBox.innerHTML = '<img class="avatar" src="' + s.avatarUrl + '" alt="avatar" />';
} else {
avatarBox.className = "avatar-placeholder";
avatarBox.textContent = "?";
}
$("identityName").textContent = s.name || "";
$("identityNpub").textContent = s.shortNpub || "";
$("identityNpub").title = s.npub || "";
$("backendLabel").textContent = "Signer: " + s.signerBackend;
} else {
$("signedInBlock").classList.add("hidden");
$("signedOutBlock").classList.remove("hidden");
}
// Relays
const relayList = $("relayList");
if (s.relays.length === 0) {
relayList.innerHTML = '<li class="empty">No relays configured.</li>';
} else {
relayList.innerHTML = s.relays.map(r =>
'<li><input type="checkbox" data-relay="' + r.url + '"' + (r.enabled ? ' checked' : '') + ' />' +
'<span class="url" title="' + r.url + '">' + r.url + '</span></li>'
).join('');
relayList.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.addEventListener("change", () => {
vscode.postMessage({ type: "toggleRelay", relay: cb.dataset.relay, enabled: cb.checked });
});
});
}
});
</script>
</body>
</html>`;
}
}
function getNonce(): string {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let nonce = "";
for (let i = 0; i < 32; i++) {
nonce += chars[Math.floor(Math.random() * chars.length)];
}
return nonce;
}
+40
View File
@@ -0,0 +1,40 @@
import * as vscode from "vscode";
import type { StateService } from "../state";
import { shortNpub } from "../nostr/npub";
/**
* Status bar item showing the Nostr session state.
*
* - Signed out: "$(broadcast) Nostr: signed out"
* - Signed in: "$(broadcast) npub1…abc local"
*
* Clicking runs `nostr.signIn` when signed out, `nostr.signOut` when signed
* in. Refreshed explicitly via refresh() after sign-in/out.
*/
export class StatusBar {
private item: vscode.StatusBarItem;
constructor(private readonly state: StateService) {
this.item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
this.item.command = "nostr.signIn";
this.refresh();
}
refresh(): void {
if (this.state.isSignedIn()) {
const pubkey = this.state.getPubkeyHex()!;
this.item.text = `$(broadcast) ${shortNpub(pubkey)} local`;
this.item.tooltip = "Nostr: signed in. Click to sign out.";
this.item.command = "nostr.signOut";
} else {
this.item.text = "$(broadcast) Nostr: signed out";
this.item.tooltip = "Nostr: signed out. Click to sign in.";
this.item.command = "nostr.signIn";
}
this.item.show();
}
dispose(): void {
this.item.dispose();
}
}
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noImplicitAny": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node", "vscode"],
"baseUrl": ".",
"paths": {
"nostr-tools": ["../nostr-tools/lib/types/index.d.ts"],
"nostr-tools/*": ["../nostr-tools/lib/types/*.d.ts"]
}
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "plans"]
}