first
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.vsix
|
||||||
|
.DS_Store
|
||||||
Vendored
+13
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+27
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.vscode/**
|
||||||
|
src/**
|
||||||
|
node_modules/**
|
||||||
|
esbuild.mjs
|
||||||
|
tsconfig.json
|
||||||
|
*.map
|
||||||
|
plans/**
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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.");
|
||||||
|
}
|
||||||
@@ -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 |
Generated
+2995
File diff suppressed because it is too large
Load Diff
+225
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 `` 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").
|
||||||
@@ -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 (``) 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:`.
|
||||||
@@ -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.
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 "";
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ?? {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user