From 8e16e0229ef61017b65250ae7338a6c32323b382 Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Sat, 18 Jul 2026 17:29:20 -0400 Subject: [PATCH] first --- .gitignore | 4 + .vscode/launch.json | 13 + .vscode/tasks.json | 27 + .vscodeignore | 9 + LICENSE | 21 + README.md | 206 ++ esbuild.mjs | 25 + media/activitybar.svg | 8 + package-lock.json | 2995 ++++++++++++++++++++++++++ package.json | 225 ++ plans/implementation-plan.md | 756 +++++++ plans/longform-metadata-strategy.md | 169 ++ plans/phase0-raw-event-publishing.md | 233 ++ src/config.ts | 58 + src/extension.ts | 1230 +++++++++++ src/frontmatter.ts | 151 ++ src/nostr/nip23.ts | 43 + src/nostr/npub.ts | 29 + src/nostr/relay.ts | 286 +++ src/nostr/validate.ts | 119 + src/nsigner/authEnvelope.ts | 63 + src/nsigner/client.ts | 124 ++ src/nsigner/discovery.ts | 42 + src/nsigner/jcs.ts | 79 + src/nsigner/qrexecClient.ts | 94 + src/nsigner/tcpClient.ts | 118 + src/signer/backend.ts | 24 + src/signer/localSigner.ts | 36 + src/signer/nsignerBackend.ts | 130 ++ src/state.ts | 81 + src/ui/sidebar.ts | 465 ++++ src/ui/statusBar.ts | 40 + tsconfig.json | 30 + 33 files changed, 7933 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json create mode 100644 .vscodeignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 esbuild.mjs create mode 100644 media/activitybar.svg create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 plans/implementation-plan.md create mode 100644 plans/longform-metadata-strategy.md create mode 100644 plans/phase0-raw-event-publishing.md create mode 100644 src/config.ts create mode 100644 src/extension.ts create mode 100644 src/frontmatter.ts create mode 100644 src/nostr/nip23.ts create mode 100644 src/nostr/npub.ts create mode 100644 src/nostr/relay.ts create mode 100644 src/nostr/validate.ts create mode 100644 src/nsigner/authEnvelope.ts create mode 100644 src/nsigner/client.ts create mode 100644 src/nsigner/discovery.ts create mode 100644 src/nsigner/jcs.ts create mode 100644 src/nsigner/qrexecClient.ts create mode 100644 src/nsigner/tcpClient.ts create mode 100644 src/signer/backend.ts create mode 100644 src/signer/localSigner.ts create mode 100644 src/signer/nsignerBackend.ts create mode 100644 src/state.ts create mode 100644 src/ui/sidebar.ts create mode 100644 src/ui/statusBar.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b33dfe --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.vsix +.DS_Store diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..3c25536 --- /dev/null +++ b/.vscode/launch.json @@ -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" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..6302b89 --- /dev/null +++ b/.vscode/tasks.json @@ -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" + } + } + ] +} diff --git a/.vscodeignore b/.vscodeignore new file mode 100644 index 0000000..e94244f --- /dev/null +++ b/.vscodeignore @@ -0,0 +1,9 @@ +.vscode/** +src/** +node_modules/** +esbuild.mjs +tsconfig.json +*.map +plans/** +.gitignore +.gitattributes diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5762d73 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..27bea05 --- /dev/null +++ b/README.md @@ -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: }]`), `sign_event` + (params `[JSON.stringify(event), {nostr_index: }]`). +- 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. diff --git a/esbuild.mjs b/esbuild.mjs new file mode 100644 index 0000000..f65136c --- /dev/null +++ b/esbuild.mjs @@ -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."); +} diff --git a/media/activitybar.svg b/media/activitybar.svg new file mode 100644 index 0000000..e44350d --- /dev/null +++ b/media/activitybar.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d7aa292 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2995 @@ +{ + "name": "nostr-publish", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nostr-publish", + "version": "0.1.0", + "license": "MIT", + "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" + }, + "engines": { + "vscode": "^1.85.0" + } + }, + "../nostr-tools": { + "version": "2.23.3", + "license": "Unlicense", + "dependencies": { + "@noble/ciphers": "2.1.1", + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0", + "@scure/bip32": "2.0.1", + "@scure/bip39": "2.0.1", + "nostr-wasm": "0.1.0" + }, + "devDependencies": { + "@types/node": "^18.13.0", + "@types/node-fetch": "^2.6.3", + "@typescript-eslint/eslint-plugin": "^6.5.0", + "@typescript-eslint/parser": "^6.5.0", + "bun-types": "^1.0.18", + "esbuild": "0.16.9", + "eslint": "^8.56.0", + "eslint-config-prettier": "^9.0.0", + "events": "^3.3.0", + "mitata": "^0.1.6", + "mock-socket": "^9.3.1", + "node-fetch": "^2.6.9", + "prettier": "^3.0.3", + "typescript": "^5.8.2" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.2.0.tgz", + "integrity": "sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.11.0.tgz", + "integrity": "sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.11.0.tgz", + "integrity": "sha512-JjQWO6akOck45PH/XBrxzsQGAiKrfFl4m5iggJ0ItMIz5omRufOXWpqCPpdjKN3vKDzlSUvFjaMb7Zwf0gvAdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.25.0.tgz", + "integrity": "sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.4.0.tgz", + "integrity": "sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.14.0.tgz", + "integrity": "sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", + "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^5.1.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.4.0.tgz", + "integrity": "sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.17.1.tgz", + "integrity": "sha512-zBhRGzABKSI7hfWh5EaZmril5ybZ7imBN1qEZl5sDTaelr+l8SnPjZO50Q4dnKnm347YPIlBMSnXKZyh3Yu5DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.11.2" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.11.2", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.11.2.tgz", + "integrity": "sha512-yDhtBOGDCdK9ipQ9g3+wmlMEPnZx2pXaDicDd9jYyR1L+7lEbvEohTDmF5qejZDutZY3m9pWPxeYxzNC701A2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.4.1.tgz", + "integrity": "sha512-yqgoyOIMCH7TNaSLMBTP+4LUlbMMf1zgC8nzOFG95lmW82CmsAEtUT0J93e4BdqDcnX5qle/9X+yb7A8Mw9M0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.11.2", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", + "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", + "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", + "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", + "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", + "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", + "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", + "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", + "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", + "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", + "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", + "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", + "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", + "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", + "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", + "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", + "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", + "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", + "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", + "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", + "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", + "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", + "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", + "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.125.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.125.0.tgz", + "integrity": "sha512-0icm/ZQAaism87P0ekHqi4/Ju9du+Tm0RUW+y7vqRsxY2cY0FNRX1nAnaW7nT6npPt2tfHiheZ55Zm9UhqonFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.7.tgz", + "integrity": "sha512-JVUD8X2tfDMWjcjLs4yVxxVrS8yR5vnh386GAXT9Qj79nBxxXSaHFQZg5FweLmT8HlPQ3kii6noUB+Z9RN7DvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@vscode/vsce": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-2.32.0.tgz", + "integrity": "sha512-3EFJfsgrSftIqt3EtdRcAygy/OJ3hstyI1cDmIgkU9CFZW5C+3djr6mfosndCUqcVYuyjmxOK1xmFp/Bq7+NIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/identity": "^4.1.0", + "@vscode/vsce-sign": "^2.0.0", + "azure-devops-node-api": "^12.5.0", + "chalk": "^2.4.2", + "cheerio": "^1.0.0-rc.9", + "cockatiel": "^3.1.2", + "commander": "^6.2.1", + "form-data": "^4.0.0", + "glob": "^7.0.6", + "hosted-git-info": "^4.0.2", + "jsonc-parser": "^3.2.0", + "leven": "^3.1.0", + "markdown-it": "^12.3.2", + "mime": "^1.3.4", + "minimatch": "^3.0.3", + "parse-semver": "^1.1.1", + "read": "^1.0.7", + "semver": "^7.5.2", + "tmp": "^0.2.1", + "typed-rest-client": "^1.8.4", + "url-join": "^4.0.1", + "xml2js": "^0.5.0", + "yauzl": "^2.3.1", + "yazl": "^2.2.2" + }, + "bin": { + "vsce": "vsce" + }, + "engines": { + "node": ">= 16" + }, + "optionalDependencies": { + "keytar": "^7.7.0" + } + }, + "node_modules/@vscode/vsce-sign": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.0.9.tgz", + "integrity": "sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g==", + "dev": true, + "hasInstallScript": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optionalDependencies": { + "@vscode/vsce-sign-alpine-arm64": "2.0.6", + "@vscode/vsce-sign-alpine-x64": "2.0.6", + "@vscode/vsce-sign-darwin-arm64": "2.0.6", + "@vscode/vsce-sign-darwin-x64": "2.0.6", + "@vscode/vsce-sign-linux-arm": "2.0.6", + "@vscode/vsce-sign-linux-arm64": "2.0.6", + "@vscode/vsce-sign-linux-x64": "2.0.6", + "@vscode/vsce-sign-win32-arm64": "2.0.6", + "@vscode/vsce-sign-win32-x64": "2.0.6" + } + }, + "node_modules/@vscode/vsce-sign-alpine-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.6.tgz", + "integrity": "sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-alpine-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.6.tgz", + "integrity": "sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.6.tgz", + "integrity": "sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz", + "integrity": "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz", + "integrity": "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz", + "integrity": "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz", + "integrity": "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-win32-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz", + "integrity": "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/vsce-sign-win32-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz", + "integrity": "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/azure-devops-node-api": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", + "integrity": "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "0.0.6", + "typed-rest-client": "^1.8.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/cockatiel": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", + "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", + "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.19.12", + "@esbuild/android-arm": "0.19.12", + "@esbuild/android-arm64": "0.19.12", + "@esbuild/android-x64": "0.19.12", + "@esbuild/darwin-arm64": "0.19.12", + "@esbuild/darwin-x64": "0.19.12", + "@esbuild/freebsd-arm64": "0.19.12", + "@esbuild/freebsd-x64": "0.19.12", + "@esbuild/linux-arm": "0.19.12", + "@esbuild/linux-arm64": "0.19.12", + "@esbuild/linux-ia32": "0.19.12", + "@esbuild/linux-loong64": "0.19.12", + "@esbuild/linux-mips64el": "0.19.12", + "@esbuild/linux-ppc64": "0.19.12", + "@esbuild/linux-riscv64": "0.19.12", + "@esbuild/linux-s390x": "0.19.12", + "@esbuild/linux-x64": "0.19.12", + "@esbuild/netbsd-x64": "0.19.12", + "@esbuild/openbsd-x64": "0.19.12", + "@esbuild/sunos-x64": "0.19.12", + "@esbuild/win32-arm64": "0.19.12", + "@esbuild/win32-ia32": "0.19.12", + "@esbuild/win32-x64": "0.19.12" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keytar": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", + "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.0.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/linkify-it": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", + "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^1.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/markdown-it": { + "version": "12.3.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", + "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "~2.1.0", + "linkify-it": "^3.0.1", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + }, + "bin": { + "markdown-it": "bin/markdown-it.js" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", + "dev": true, + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/nostr-tools": { + "resolved": "../nostr-tools", + "link": true + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-semver": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz", + "integrity": "sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.1.0" + } + }, + "node_modules/parse-semver/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/read": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", + "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "mute-stream": "~0.0.4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typed-rest-client": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", + "integrity": "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "qs": "^6.9.1", + "tunnel": "0.0.6", + "underscore": "^1.12.1" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yazl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", + "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c035312 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/plans/implementation-plan.md b/plans/implementation-plan.md new file mode 100644 index 0000000..78d6fd3 --- /dev/null +++ b/plans/implementation-plan.md @@ -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` — 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=`. +- 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 +``` + +### 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 + signEvent(event: EventTemplate): Promise +} +``` + +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; // 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 { + 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 { + 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 { + try { await this.getPublicKey(); return true; } catch { return false; } + } + + async getPublicKey(): Promise { + 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 { + // n_signer expects the event JSON as a string in params[0]; it fills in + // pubkey, id, sig and returns the signed event as a JSON string. + const res = await this.client.call("sign_event", [ + JSON.stringify(event), + { nostr_index: this.nostrIndex }, + ]); + const r = res as { result?: string; error?: { message: string } }; + if (r.error) throw new Error(`n_signer: ${r.error.message}`); + return JSON.parse(r.result!) as VerifiedEvent; + } + + describe() { return `n_signer @${this.client["socketName"]} idx=${this.nostrIndex}`; } +} +``` + +Wire contract confirmed against +[`n_signer/client/demo_javascript.js`](../n_signer/client/demo_javascript.js:99) +(`get_public_key` params `[{nostr_index}]`) and +[`n_signer/client/README.md`](../n_signer/client/README.md:50) (`sign_event` +params `[eventJson, {nostr_index|role}]`). No auth envelope needed for unix +abstract sockets — identity is UID-based via `SO_PEERCRED` (confirmed in +[`n_signer/documents/SECURITY.md`](../n_signer/documents/SECURITY.md:428)). + +--- + +## 10. `src/ui/publishDialog.ts` — publish confirmation + +Multi-step `vscode.window.showQuickPick` flow (per user decision: start with +QuickPick, reserve Webview for a future phase). Steps: + +1. **Preview step** — show a single QuickPick with one item per field, each + item's `label` is the field name and `description` is the resolved value: + ``` + d: my-post + title: My Post + summary: Short abstract. + image: https://cdn.example.com/cover.png + tags: nostr, longform, writing + published_at: NEW (will be set to now) + body: 1234 chars + signer: local in-memory key + pubkey: npub1... + relays: damus, primal, laantungir + ``` + Items are non-selectable (`canPickMany=false`, and the user picks a final + action item at the bottom): `Publish`, `Edit field...`, `Cancel`. + +2. **Edit field step** — if the user picks `Edit field...`, show a QuickPick + of editable fields (`d`, `title`, `summary`, `image`, `tags`), then an + `showInputBox` pre-filled with the current value. The override is applied + to an in-memory copy (not written back to the file). Loop back to preview. + +3. **Relay toggle step** — `canPickMany=true` QuickPick of configured relays, + all checked by default. User can uncheck relays for this publish only. + +4. **Publish** — builds the event with (possibly overridden) fields, signs via + the active backend, publishes to the selected relays, stores + `published_at` if first publish, shows a summary message with per-relay + OK/failed counts and the note's `nevent1`/`naddr1` identifier. + +If `nostr.publish.confirm` is `false`, skip the dialog and publish +immediately with front-matter values and all configured relays. + +--- + +## 11. `src/ui/statusBar.ts` — status bar item + +A `vscode.window.createStatusBarItem` on the left side, priority 100. Text: + +- Signed out: `$(nostr) Nostr: signed out` +- Local signed in: `$(nostr) npub1…abc local` +- n_signer connected: `$(nostr) npub1…abc nsigner` +- n_signer unreachable: `$(nostr) nsigner: unreachable` + +Clicking runs `nostr.selectBackend`. Refreshed on sign-in/out, backend +switch, and on a 5s interval timer (to catch n_signer going up/down). + +--- + +## 12. Dependencies (`package.json`) + +The extension depends on the **local laantungir fork** of `nostr-tools` +(cloned at `~/lt/nostr-tools`, remote +`git@laantungir.net:laantungir/nostr-tools.git`, currently tracking upstream +v2.23.3). Use a `file:` dependency so the extension builds against the local +checkout without publishing to a registry: + +```json +"dependencies": { + "nostr-tools": "file:../nostr-tools", + "ws": "^8.16.0", + "yaml": "^2.3.4" +}, +"devDependencies": { + "@types/vscode": "^1.85.0", + "@types/node": "^20.10.0", + "@types/ws": "^8.5.10", + "typescript": "^5.3.0", + "esbuild": "^0.19.0", + "@vscode/vsce": "^2.22.0" +} +``` + +The fork is ESM (`"type": "module"`) but ships a CJS build at +`lib/cjs/index.js` (see [`nostr-tools/package.json`](../nostr-tools/package.json:15)). +esbuild resolves the `require` export and bundles it into `dist/extension.js` +for the Node-based extension host. `@noble/secp256k1` and `@noble/hashes` +come transitively via `nostr-tools`. + +### Why the fork over upstream npm + +- **Newer version** (2.23.3 vs upstream npm's 2.5.0) — bug fixes and NIP + coverage. +- **`Signer` interface + `PlainKeySigner`** in + [`nostr-tools/signer.ts`](../nostr-tools/signer.ts:4) — the extension + reuses this interface directly instead of inventing its own. +- **`LongFormArticle = 30023`** constant in + [`nostr-tools/kinds.ts`](../nostr-tools/kinds.ts:190) — replaces a + hardcoded magic number. +- **`nip06`** module available if a future phase adds mnemonic-derived keys + (matches n_signer's derivation). +- **Self-hosted source of truth** — the laantungir git server is the canonical + remote for this ecosystem; depending on it keeps the extension aligned with + any local patches that may land in the fork. + +### Build prerequisite + +`nostr-tools` must be built before the extension can compile it: + +```bash +cd ../nostr-tools && npm install && npm run build +``` + +This produces `lib/cjs/*.js` and `lib/types/*.d.ts`. The extension's esbuild +step then bundles from there. Documented in the extension README. + +### `esbuild.mjs` + +```js +import esbuild from "esbuild"; +esbuild.build({ + entryPoints: ["src/extension.ts"], + bundle: true, + platform: "node", + format: "cjs", + target: "node18", + outfile: "dist/extension.js", + external: ["vscode"], // always external + logLevel: "info", +}); +``` + +### `tsconfig.json` + +Standard strict TS config, `module: "commonjs"`, `target: "ES2022"`, +`lib: ["ES2022"]`, `skipLibCheck: true`, `outDir: "dist"`. + +### `.vscodeignore` + +``` +.vscode/** +src/** +node_modules/** +esbuild.mjs +tsconfig.json +*.map +``` + +Keep `dist/extension.js`, `package.json`, `README.md`, `LICENSE`, `media/`. + +--- + +## 13. Build & package + +```bash +npm install +npm run compile # node esbuild.mjs +npm run package # vsce package -> nostr-publish-0.1.0.vsix +``` + +`package.json` scripts: + +```json +"scripts": { + "compile": "node esbuild.mjs", + "watch": "node esbuild.mjs --watch", + "package": "vsce package", + "publish": "vsce publish" +} +``` + +### F5 verification + +`.vscode/launch.json` with a single "Run Extension" config launching the +Extension Development Host with the workspace folder. Verify: + +1. F5 launches a new Codium window with the extension loaded. +2. `Nostr: Sign In` accepts an nsec and the status bar updates. +3. Open a `.md` file with front-matter, run `Nostr: Validate Front-Matter`, + see the parsed tags. +4. Run `Nostr: Publish Current File as Long-Form Note`, see the dialog, pick + Publish, see per-relay results. +5. Start `nsigner --listen unix --socket-name nsigner` in a terminal, run + `Nostr: Select Signer Backend`, pick `nsigner`, see the status bar switch + and a publish go through n_signer (approval prompt appears in the n_signer + terminal). + +--- + +## 14. Implementation order (matches the todo list) + +1. Scaffold project: `package.json`, `tsconfig.json`, `esbuild.mjs`, + `.vscodeignore`, `README.md` stub, `LICENSE`, `media/icon.png`. +2. `src/config.ts`, `src/state.ts` — config + state plumbing. +3. `src/nostr/npub.ts`, `src/nostr/nip23.ts`, `src/nostr/relay.ts` — core + nostr layer. +4. `src/frontmatter.ts` — parser + validator. +5. `src/signer/backend.ts`, `src/signer/localSigner.ts` — Phase 1 signer. +6. `src/ui/statusBar.ts`, `src/extension.ts` — wire commands + status bar. +7. `src/ui/publishDialog.ts` — publish confirmation flow. +8. `src/nsigner/client.ts`, `src/nsigner/discovery.ts`, + `src/signer/nsignerBackend.ts` — Phase 2 signer. +9. `nostr.selectBackend` command + `SignerFactory` wiring. +10. README full content (front-matter format, Phase 1, Phase 2, limitations). +11. `npm install`, `npm run compile`, `vsce package`, F5 verify. + +Each step is independently testable: steps 1–7 deliver a working Phase 1; +steps 8–9 add Phase 2; steps 10–11 close out. + +--- + +## 15. Out of scope (future phases) + +- **Image upload** (Blossom / NIP-96) — local `![](./x.png)` paths are left + as-is in Phase 1/2; documented as a known limitation. Future phase rewrites + them to uploaded URLs using `~/lt/blossom`. +- **Webview publish dialog** — richer preview with rendered markdown. QuickPick + is the Phase 1/2 UI. +- **Sidebar "Published Notes" tree view** — list past publishes from a local + cache or relay fetch. +- **NIP-19 `naddr1` sharing** — generate a shareable address after publish + (shown in the success message; full sharing UI deferred). +- **Key persistence via `SecretStorage`** — explicitly deferred per user + decision ("keep it simple, quickly go to Phase 2"). diff --git a/plans/longform-metadata-strategy.md b/plans/longform-metadata-strategy.md new file mode 100644 index 0000000..a8e8a07 --- /dev/null +++ b/plans/longform-metadata-strategy.md @@ -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: " | + +## Mechanism 1 — YAML front-matter (primary) + +A fenced YAML block at the very top of the `.md` file, delimited by `---`. + +```markdown +--- +slug: my-first-post +title: My First Post +summary: A short abstract for feeds. +image: https://cdn.example.com/cover.png +tags: [nostr, longform, writing] +--- + +# My First Post + +Body content starts here. The front-matter block above is parsed by the +extension, used to build event tags, and **stripped** from the published +content so readers never see it. +``` + +### Parsing rules + +1. The extension only treats a leading `---\n...\n---\n` block as front-matter. + A `---` later in the document (e.g. a horizontal rule) is left alone. +2. Unknown keys are ignored (forward-compatible). +3. `tags` accepts either a YAML inline array `[a, b]` or a comma-separated + string `a, b`. Each value is lowercased and trimmed; `#` prefix is optional + and stripped. Empty entries dropped. +4. `slug` is sanitized: lowercase, spaces → `-`, strip non-`[a-z0-9-]`. +5. If `slug` is absent, default to the filename stem (same sanitization). +6. If `title` is absent, default to the filename stem. +7. If `summary` is absent, default to the first non-empty, non-heading line of + the body, truncated to 280 chars. +8. If `image` is absent, no `image` tag is emitted. + +### Stripping on publish + +The front-matter block is removed from the `content` field of the signed event. +The on-disk file is **not** modified — the author keeps editing with the +front-matter intact. Stripping happens in-memory at publish time only. + +### Front-matter validation command + +A `Nostr: Validate Front-Matter` command parses the active document and shows +an information message: which tags will be emitted, any unknown keys, and the +final `d` slug. Useful as a pre-publish sanity check without opening the dialog. + +## Mechanism 2 — Publish dialog (secondary, last-mile) + +Triggered by `Nostr: Publish Current File as Long-Form Note`. A +`QuickPick`-style multi-step dialog (or a webview for richer UI) that: + +1. Shows a **preview** of the parsed event: `d`, `title`, `summary`, `image`, + tags list, body length, target relays, signer backend, and the resolved + pubkey (npub). +2. Lets the user **override** any field for this publish only (does not write + back to the file): + - slug + - title + - summary + - image URL + - add/remove tags +3. Shows the `published_at` behavior: "First publish — setting + published_at=now" or "Update — preserving published_at=<existing>". +4. Confirms target relays (checkboxes, defaults from config + front-matter). +5. A final `Publish` action signs and relays the event. + +Overrides are session-only. If the user wants a permanent change, they edit +the front-matter. This keeps the file as the source of truth while allowing +one-off tweaks (e.g. fixing a typo right before publishing). + +### Why both mechanisms + +- **Front-matter** makes the document self-describing and version-controllable. + The same file produces the same event every time. Good for git workflows and + re-publishing updates. +- **Publish dialog** handles the human-in-the-loop confirmation that matters + for a broadcast operation, and lets the author catch a missing `image` or + wrong `slug` before it goes on-chain. It also surfaces the signer backend + and relay set, which front-matter should not control (those are + machine/environment concerns, not document concerns). + +## `published_at` lifecycle + +NIP-23 expects `published_at` to reflect the **first** time the note was +published, and to remain stable across updates (edits use the same `d` tag). + +The extension stores a map `{ dSlug -> publishedAtUnix }` in workspace state +(`context.workspaceState`). On publish: + +- If `dSlug` is present in the map → reuse the stored timestamp. +- If absent → set `published_at = now` and store it. + +This survives editor reloads (workspace state is persisted by VSCode) but is +per-workspace, which matches the per-document intent. A command +`Nostr: Reset Published Timestamps` clears the map for cases where the author +wants a clean slate (e.g. changing the `d` slug intentionally). + +## Image handling + +Front-matter `image` is a URL string. The extension does **not** upload images +in Phase 1/2. A future Phase 3 could add Blossom (NIP-96) upload via the +`blossom` project already in `~/lt/blossom`, then rewrite local image paths to +the uploaded URL before signing. For now, the author is responsible for +hosting the image and pasting the URL. + +Local relative paths in the body (`![](./img/x.png)`) are left as-is in +Phase 1/2 — they will not render on relays. This is documented in the README +as a known limitation with a pointer to the future Blossom phase. + +## Tag emission order + +NIP-23 does not mandate tag order, but for relay compatibility we emit in +this order: + +1. `d` +2. `title` +3. `summary` +4. `image` +5. `published_at` +6. `t` (one per topic) +7. `alt` + +## Open questions for the user + +1. **Front-matter format**: YAML (`---` fences) vs TOML (`+++` fences) vs + JSON front-matter. Recommendation: YAML — most common in markdown tooling, + human-friendly, supports arrays cleanly. +2. **Publish dialog UI**: lightweight multi-step QuickPick (faster to build, + native feel) vs a Webview panel (richer preview, more code). Recommendation: + start with QuickPick for Phase 1/2, upgrade to Webview if the preview feels + too cramped. +3. **`published_at` storage scope**: workspace state (per-workspace, survives + reload) vs global state (survives workspace switches). Recommendation: + workspace state — a post belongs to the workspace that authored it. +4. **Unknown front-matter keys**: ignore silently vs warn in the publish + dialog. Recommendation: warn (non-blocking) so authors catch typos like + `tag:` instead of `tags:`. diff --git a/plans/phase0-raw-event-publishing.md b/plans/phase0-raw-event-publishing.md new file mode 100644 index 0000000..5a2f006 --- /dev/null +++ b/plans/phase0-raw-event-publishing.md @@ -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. diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..29669c1 --- /dev/null +++ b/src/config.ts @@ -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); + } +} diff --git a/src/extension.ts b/src/extension.ts new file mode 100644 index 0000000..05eb0f8 --- /dev/null +++ b/src/extension.ts @@ -0,0 +1,1230 @@ +import * as vscode from "vscode"; +import { bytesToHex, hexToBytes } from "nostr-tools/utils"; +import { getPublicKey } from "nostr-tools/pure"; +import { neventEncode, naddrEncode } from "nostr-tools/nip19"; +import { LongFormArticle } from "nostr-tools/kinds"; +import type { EventTemplate, VerifiedEvent } from "nostr-tools/core"; + +import { ConfigService } from "./config"; +import { StateService } from "./state"; +import { StatusBar } from "./ui/statusBar"; +import { NostrSidebarProvider } from "./ui/sidebar"; +import { LocalSigner } from "./signer/localSigner"; +import { NsignerBackend } from "./signer/nsignerBackend"; +import type { NostrSigner } from "./signer/backend"; +import { decodeNsec, isHexSecretKey, hexToNpub } from "./nostr/npub"; +import { validateEventTemplate } from "./nostr/validate"; +import { publishToRelays, fetchUserRelays, fetchUserProfile, type PublishResult } from "./nostr/relay"; +import { parseFrontMatter, FrontMatterError } from "./frontmatter"; +import { buildLongFormEvent } from "./nostr/nip23"; +import { discoverNsignerSockets } from "./nsigner/discovery"; +import * as path from "node:path"; + +/** Event fired when identity state changes (sign-in / sign-out). */ +const onStateChange = new vscode.EventEmitter<void>(); +let sidebarProvider: NostrSidebarProvider | null = null; + +export async function activate(context: vscode.ExtensionContext): Promise<void> { + const state = new StateService(context); + const config = new ConfigService(); + const statusBar = new StatusBar(state); + + context.subscriptions.push(statusBar); + context.subscriptions.push(onStateChange); + + // Sidebar view provider. + sidebarProvider = new NostrSidebarProvider(state, config, onStateChange.event); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider("nostr.sidebar", sidebarProvider, { + webviewOptions: { retainContextWhenHidden: true }, + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand("nostr.signIn", () => + cmdSignIn(state, config, statusBar), + ), + ); + context.subscriptions.push( + vscode.commands.registerCommand("nostr.signOut", () => + cmdSignOut(state, statusBar), + ), + ); + context.subscriptions.push( + vscode.commands.registerCommand("nostr.createUnsignedEvent", () => + cmdCreateUnsignedEvent(), + ), + ); + context.subscriptions.push( + vscode.commands.registerCommand("nostr.validateEventFile", () => cmdValidateEventFile()), + ); + context.subscriptions.push( + vscode.commands.registerCommand("nostr.publishEventFile", () => + cmdPublishEventFile(state, config), + ), + ); + context.subscriptions.push( + vscode.commands.registerCommand("nostr.fetchRelays", () => + cmdFetchRelays(state, config), + ), + ); + context.subscriptions.push( + vscode.commands.registerCommand("nostr.publishLongForm", () => + cmdPublishLongForm(state, config), + ), + ); + context.subscriptions.push( + vscode.commands.registerCommand("nostr.validateFrontMatter", () => + cmdValidateFrontMatter(), + ), + ); + context.subscriptions.push( + vscode.commands.registerCommand("nostr.resetPublishedTimestamps", () => + cmdResetTimestamps(state), + ), + ); + context.subscriptions.push( + vscode.commands.registerCommand("nostr.selectBackend", () => + cmdSelectBackend(config, state, statusBar), + ), + ); + context.subscriptions.push( + vscode.commands.registerCommand("nostr.signInFromSidebar", (method: string, cfg: Record<string, unknown>) => + cmdSignInFromSidebar(method, cfg, state, config, statusBar), + ), + ); + + // Zero the key on extension deactivate. + context.subscriptions.push({ + dispose: () => state.clearKey(), + }); +} + +/** Notify the sidebar (and any other listeners) that identity state changed. */ +function fireStateChange(): void { + onStateChange.fire(); +} + +export function deactivate(): void { + // Disposal handled by subscriptions in activate(). +} + +// --------------------------------------------------------------------------- +// Sign in / out +// --------------------------------------------------------------------------- + +async function cmdSignIn( + state: StateService, + config: ConfigService, + statusBar: StatusBar, +): Promise<void> { + const input = await vscode.window.showInputBox({ + prompt: "Enter your Nostr secret key (nsec1... or 64-char hex)", + password: true, + ignoreFocusOut: true, + placeHolder: "nsec1... or hex", + }); + if (!input) { + return; + } + + let secretKey: Uint8Array; + try { + const trimmed = input.trim(); + if (trimmed.startsWith("nsec1")) { + secretKey = decodeNsec(trimmed); + } else if (isHexSecretKey(trimmed)) { + secretKey = hexToBytes(trimmed); + } else { + vscode.window.showErrorMessage( + "Invalid secret key: must be an nsec1... string or 64-char hex.", + ); + return; + } + } catch (err) { + vscode.window.showErrorMessage(`Failed to decode secret key: ${(err as Error).message}`); + return; + } + + if (secretKey.length !== 32) { + vscode.window.showErrorMessage( + `Invalid secret key: expected 32 bytes, got ${secretKey.length}.`, + ); + return; + } + + let pubkeyHex: string; + try { + pubkeyHex = getPublicKey(secretKey); + } catch (err) { + vscode.window.showErrorMessage( + `Failed to derive public key: ${(err as Error).message}`, + ); + return; + } + + state.setKey(secretKey, pubkeyHex); + statusBar.refresh(); + fireStateChange(); + vscode.window.showInformationMessage(`Signed in as ${hexToNpub(pubkeyHex)}`); + + // Fetch the user's NIP-65 relay list (kind 10002) and replace the sidebar's + // relay set with it. Falls back silently to the configured defaults if no + // 10002 event is found or the fetch fails. + await fetchAndApplyRelays(pubkeyHex, config, /*showErrors=*/ false); + // Fetch the user's kind 0 profile (avatar + name) for the sidebar. + await fetchAndDisplayAvatar(pubkeyHex, config); +} + +/** Fetch the user's NIP-65 relay list and apply it to the sidebar. */ +async function fetchAndApplyRelays( + pubkeyHex: string, + config: ConfigService, + showErrors: boolean, +): Promise<void> { + const provider = sidebarProvider; + if (!provider) { + return; + } + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Window, + title: "Fetching your NIP-65 relay list…", + cancellable: false, + }, + async () => { + try { + const userRelays = await fetchUserRelays(pubkeyHex, config.relays); + if (userRelays && userRelays.length > 0) { + // Use write-capable relays for publishing; if none are marked + // write, fall back to all of them. + const writeRelays = userRelays.filter((r) => r.write).map((r) => r.url); + const urls = writeRelays.length > 0 ? writeRelays : userRelays.map((r) => r.url); + provider.setRelays(urls); + vscode.window.showInformationMessage( + `Loaded ${urls.length} relay(s) from your NIP-65 relay list.`, + ); + } else if (showErrors) { + vscode.window.showWarningMessage( + "No NIP-65 relay list (kind 10002) found for your pubkey on the configured relays.", + ); + } + } catch (err) { + const msg = (err as Error).message; + if (showErrors) { + vscode.window.showErrorMessage(`Failed to fetch NIP-65 relay list: ${msg}`); + } else { + console.warn("NIP-65 relay fetch failed:", msg); + } + } + }, + ); +} + +/** Command: manually fetch the user's NIP-65 relay list. */ +async function cmdFetchRelays(state: StateService, config: ConfigService): Promise<void> { + if (!state.isSignedIn()) { + const action = await vscode.window.showErrorMessage( + "Sign in to Nostr first to fetch your relay list.", + "Sign In", + ); + if (action === "Sign In") { + await vscode.commands.executeCommand("nostr.signIn"); + } + if (!state.isSignedIn()) { + return; + } + } + await fetchAndApplyRelays(state.getPubkeyHex()!, config, /*showErrors=*/ true); +} + +async function cmdSignOut(state: StateService, statusBar: StatusBar): Promise<void> { + state.clearKey(); + statusBar.refresh(); + fireStateChange(); + vscode.window.showInformationMessage("Signed out of Nostr."); +} + +// --------------------------------------------------------------------------- +// Create unsigned event +// --------------------------------------------------------------------------- + +const COMMON_KINDS: { label: string; kind: number; detail: string }[] = [ + { label: "1 — Short Text Note", kind: 1, detail: "A simple text note. Fill in content." }, + { label: "30023 — Long-Form Article", kind: LongFormArticle, detail: "NIP-23 long-form post with d/title/summary/image/tags + markdown body." }, + { label: "10002 — Relay List", kind: 10002, detail: "NIP-65 relay list with r tags (read/write markers)." }, + { label: "0 — Metadata (Profile)", kind: 0, detail: "NIP-01 profile: name/about/picture/nip05 as JSON content." }, + { label: "3 — Contacts", kind: 3, detail: "NIP-02 contact list with p tags (pubkey/relay/petname)." }, +]; + +async function cmdCreateUnsignedEvent(): Promise<void> { + const custom = { label: "Custom…", kind: -1, detail: "Enter any kind integer." }; + const picked = await vscode.window.showQuickPick([...COMMON_KINDS, custom], { + placeHolder: "Select an event kind", + ignoreFocusOut: true, + }); + if (!picked) { + return; + } + + let kind: number; + if (picked.kind === -1) { + const input = await vscode.window.showInputBox({ + prompt: "Enter event kind (non-negative integer)", + ignoreFocusOut: true, + validateInput: (v) => (/^\d+$/.test(v.trim()) ? null : "must be a non-negative integer"), + }); + if (!input) { + return; + } + kind = parseInt(input.trim(), 10); + } else { + kind = picked.kind; + } + + // Long-form articles are authored as markdown with YAML front-matter. + if (kind === LongFormArticle) { + const md = buildLongFormMarkdownSkeleton(); + const doc = await vscode.workspace.openTextDocument({ content: md, language: "markdown" }); + await vscode.window.showTextDocument(doc); + vscode.window.showInformationMessage( + `Created long-form article skeleton (.md with YAML front-matter). Edit the body, then run "Nostr: Publish Current File as Long-Form Note".`, + ); + return; + } + + // Other kinds: JSON skeleton. + const template = buildSkeleton(kind); + const content = JSON.stringify(template, null, 2) + "\n"; + const doc = await vscode.workspace.openTextDocument({ content, language: "json" }); + await vscode.window.showTextDocument(doc); + const kindName = template.tags.find((t) => t[0] === "alt")?.[1] ?? `kind=${kind}`; + vscode.window.showInformationMessage( + `Created ${kindName} skeleton. Fill in the placeholder values, then run "Nostr: Publish Event from JSON File".`, + ); +} + +/** + * Build a long-form article skeleton as a markdown document with YAML + * front-matter. This is the natural authoring format for NIP-23 notes — + * the user writes markdown, and the extension parses the front-matter at + * publish time. + */ +function buildLongFormMarkdownSkeleton(): string { + return `--- +slug: my-article-slug +title: My Article Title +summary: A one-sentence abstract for feeds. +image: https://example.com/cover.png +tags: [nostr, writing] +--- + +# My Article Title + +Write your article in Markdown here. + +## Section + +Body text… +`; +} + +/** + * Build a kind-specific skeleton EventTemplate with placeholder tags and + * content, so the user can see exactly which fields the event kind expects. + * + * - kind 1 (ShortTextNote): empty content, no tags. + * - kind 30023 (LongFormArticle): d/title/summary/image/published_at/t tags + * + markdown body placeholder. + * - kind 10002 (RelayList): r tags with read/write markers. + * - kind 0 (Metadata): name/about/picture/npub JSON content. + * - kind 3 (Contacts): empty contact list placeholder. + * - other: bare template with empty tags/content. + */ +function buildSkeleton(kind: number): EventTemplate { + const now = Math.floor(Date.now() / 1000); + switch (kind) { + case 1: // ShortTextNote + return { + kind: 1, + created_at: now, + tags: [], + content: "What's happening?", + }; + case LongFormArticle: // 30023 + return { + kind: LongFormArticle, + created_at: now, + tags: [ + ["d", "my-article-slug"], + ["title", "My Article Title"], + ["summary", "A one-sentence abstract for feeds."], + ["image", "https://example.com/cover.png"], + ["published_at", String(now)], + ["t", "nostr"], + ["t", "writing"], + ["alt", "Long-form article"], + ], + content: "# My Article Title\n\nWrite your article in Markdown here.\n\n## Section\n\nBody text…", + }; + case 10002: // RelayList (NIP-65) + return { + kind: 10002, + created_at: now, + tags: [ + ["r", "wss://relay.damus.io", "read"], + ["r", "wss://relay.damus.io", "write"], + ["r", "wss://relay.primal.net", "read"], + ["r", "wss://relay.primal.net", "write"], + ["r", "wss://laantungir.net/relay", "write"], + ["alt", "Relay list"], + ], + content: "", + }; + case 0: // Metadata (NIP-01) + return { + kind: 0, + created_at: now, + tags: [["alt", "Profile metadata"]], + content: JSON.stringify( + { + name: "your-name", + about: "A short bio.", + picture: "https://example.com/avatar.png", + nip05: "your-name@example.com", + }, + null, + 2, + ), + }; + case 3: // Contacts (NIP-02) + return { + kind: 3, + created_at: now, + tags: [ + // ["p", "<pubkey-hex>", "<optional-relay-url>", "<optional-petname>"] + ["p", "0000000000000000000000000000000000000000000000000000000000000000", "", "example-petname"], + ["alt", "Contact list"], + ], + content: "", + }; + default: + return { + kind, + created_at: now, + tags: [["alt", `kind=${kind}`]], + content: "", + }; + } +} + +// --------------------------------------------------------------------------- +// Validate event file +// --------------------------------------------------------------------------- + +async function cmdValidateEventFile(): Promise<void> { + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showErrorMessage("No active JSON editor."); + return; + } + + const text = editor.document.getText(); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (err) { + vscode.window.showErrorMessage(`Invalid JSON: ${(err as Error).message}`); + return; + } + + const result = validateEventTemplate(parsed); + if (result.valid) { + const obj = parsed as { kind: number; tags: unknown[]; content: string }; + const kindLabel = result.kindName ? `${obj.kind} (${result.kindName})` : `${obj.kind}`; + const tags = obj.tags; + const content = obj.content; + vscode.window.showInformationMessage( + `Valid unsigned event. kind=${kindLabel} tags=${tags.length} content=${content.length} chars`, + ); + } else { + vscode.window.showErrorMessage(`Invalid event: ${result.errors.join("; ")}`); + } + + if (result.warnings.length > 0) { + vscode.window.showWarningMessage(result.warnings.join("; ")); + } +} + +// --------------------------------------------------------------------------- +// Publish event file +// --------------------------------------------------------------------------- + +async function cmdPublishEventFile(state: StateService, config: ConfigService): Promise<void> { + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showErrorMessage("No active JSON editor."); + return; + } + + // Require a signed-in signer. + let signer = getSigner(state, config); + if (!signer) { + const action = await vscode.window.showErrorMessage( + "Sign in to Nostr first before publishing.", + "Sign In", + ); + if (action === "Sign In") { + await vscode.commands.executeCommand("nostr.signIn"); + } + signer = getSigner(state, config); + if (!signer) { + return; + } + } + + // Parse + validate the active JSON document. + const text = editor.document.getText(); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (err) { + vscode.window.showErrorMessage(`Invalid JSON: ${(err as Error).message}`); + return; + } + + const result = validateEventTemplate(parsed); + if (!result.valid) { + vscode.window.showErrorMessage(`Invalid event: ${result.errors.join("; ")}`); + return; + } + if (result.warnings.length > 0) { + const proceed = await vscode.window.showWarningMessage( + `${result.warnings.join("; ")}\n\nProceed with publish?`, + "Publish", + "Cancel", + ); + if (proceed !== "Publish") { + return; + } + } + + // Build the EventTemplate (strip any signed fields the user may have left in). + const obj = parsed as Record<string, unknown>; + const template: EventTemplate = { + kind: obj.kind as number, + created_at: obj.created_at as number, + tags: obj.tags as string[][], + content: obj.content as string, + }; + + // Sign. + let signed: VerifiedEvent; + try { + signed = await signer.signEvent(template); + } catch (err) { + vscode.window.showErrorMessage(`Signing failed: ${(err as Error).message}`); + return; + } + + // Use the sidebar's enabled-relay set if available; otherwise fall back to + // all configured relays. + const relays = sidebarProvider ? sidebarProvider.getEnabledRelays() : config.relays; + if (relays.length === 0) { + vscode.window.showErrorMessage( + "No relays enabled. Toggle relays on in the Nostr sidebar or set `nostr.relays` in settings.", + ); + return; + } + + const selectedRelays = config.publishConfirm + ? await confirmPublish(signed, relays, signer) + : relays; + if (selectedRelays.length === 0) { + vscode.window.showInformationMessage("Publish cancelled: no relays selected."); + return; + } + + // Publish. + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Publishing Nostr event…", + cancellable: false, + }, + async () => { + const results = await publishToRelays(signed, selectedRelays, config.publishTimeoutMs); + showPublishResults(signed, results); + }, + ); +} + +function getSigner(state: StateService, config: ConfigService): NostrSigner | null { + if (config.signerBackend === "nsigner") { + return new NsignerBackend(buildNsignerConfig(config, state)); + } + const sk = state.getSecretKey(); + if (!sk) { + return null; + } + return new LocalSigner(sk); +} + +/** + * Build an NsignerBackendConfig from extension config + session state. + * For TCP, the caller secret key is needed for auth envelopes — it's taken + * from the session state (the signed-in local key) or a configured caller nsec. + */ +function buildNsignerConfig(config: ConfigService, state: StateService): import("./signer/nsignerBackend").NsignerBackendConfig { + const cfg: import("./signer/nsignerBackend").NsignerBackendConfig = { + transport: config.nsignerTransport, + socketName: config.nsignerSocketName, + qrexecQube: config.nsignerQrexecQube, + qrexecService: config.nsignerQrexecService, + tcpHost: config.nsignerTcpHost, + tcpPort: config.nsignerTcpPort, + nostrIndex: config.nsignerNostrIndex, + }; + if (config.nsignerTransport === "tcp") { + // Prefer the session key (if signed in locally) as the caller key; + // otherwise try the configured caller nsec. + const sessionKey = state.getSecretKey(); + if (sessionKey) { + cfg.callerSecretKey = sessionKey; + } else { + const nsec = config.nsignerCallerNsec; + if (nsec) { + try { + cfg.callerSecretKey = nsec.startsWith("nsec1") + ? decodeNsec(nsec) + : hexToBytes(nsec); + } catch { + // leave undefined — TCP will error with a clear message + } + } + } + } + return cfg; +} + +async function confirmPublish( + signed: VerifiedEvent, + relays: string[], + signer: NostrSigner, +): Promise<string[]> { + const npub = hexToNpub(signed.pubkey); + const previewItems: vscode.QuickPickItem[] = [ + { label: `kind: ${signed.kind}` }, + { label: `pubkey: ${npub}` }, + { label: `id: ${signed.id}` }, + { label: `tags: ${signed.tags.length}` }, + { label: `content: ${signed.content.length} chars` }, + { label: `signer: ${signer.describe()}` }, + { label: `relays: ${relays.join(", ")}` }, + { label: "", + }, + ]; + + const relayItems: vscode.QuickPickItem[] = relays.map((r) => ({ + label: r, + picked: true, + })); + + const pick = await vscode.window.showQuickPick([...previewItems, ...relayItems], { + canPickMany: true, + placeHolder: "Preview signed event. Toggle relays to publish, then confirm.", + ignoreFocusOut: true, + title: "Confirm Nostr Publish", + }); + + if (!pick) { + return []; + } + + // Selected relays are the relay-URL items the user left checked. + const selected = pick + .filter((item) => item.label.startsWith("wss://") || item.label.startsWith("ws://")) + .map((item) => item.label); + + if (selected.length === 0) { + return []; + } + + const confirm = await vscode.window.showQuickPick(["Publish", "Cancel"], { + placeHolder: `Publish to ${selected.length} relay(s)?`, + ignoreFocusOut: true, + }); + return confirm === "Publish" ? selected : []; +} + +function showPublishResults(signed: VerifiedEvent, results: PublishResult[]): void { + const ok = results.filter((r) => r.ok).length; + const failed = results.length - ok; + + const lines = results.map((r) => ` ${r.ok ? "✓" : "✗"} ${r.relay} — ${r.message}`); + const summary = `Published ${ok}/${results.length} relay(s)${failed ? `, ${failed} failed` : ""}.\nEvent id: ${signed.id}\n\n${lines.join("\n")}`; + + if (failed === 0) { + vscode.window.showInformationMessage(summary, "Copy Event ID").then((action) => { + if (action === "Copy Event ID") { + vscode.env.clipboard.writeText(signed.id); + } + }); + } else { + vscode.window.showWarningMessage(summary, "Copy Event ID").then((action) => { + if (action === "Copy Event ID") { + vscode.env.clipboard.writeText(signed.id); + } + }); + } +} + +// --------------------------------------------------------------------------- +// Phase 1: Long-form note (.md) publishing +// --------------------------------------------------------------------------- + +/** Derive a filename stem from the active editor's filename (without extension). */ +function activeFilenameStem(): string | undefined { + const editor = vscode.window.activeTextEditor; + if (!editor) return undefined; + const fsPath = editor.document.fileName; + if (!fsPath || fsPath.startsWith("Untitled:")) return undefined; + return path.basename(fsPath, path.extname(fsPath)); +} + +/** Command: validate the front-matter of the active .md document. */ +async function cmdValidateFrontMatter(): Promise<void> { + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showErrorMessage("No active Markdown editor."); + return; + } + if (editor.document.languageId !== "markdown") { + vscode.window.showErrorMessage("Active file is not Markdown."); + return; + } + + const text = editor.document.getText(); + const stem = activeFilenameStem(); + let result; + try { + result = parseFrontMatter(text, stem); + } catch (err) { + if (err instanceof FrontMatterError) { + vscode.window.showErrorMessage(`Front-matter error: ${err.message}`); + } else { + vscode.window.showErrorMessage(`Failed to parse front-matter: ${(err as Error).message}`); + } + return; + } + + const fm = result.frontMatter; + const lines = [ + `d: ${fm.slug}`, + `title: ${fm.title}`, + `summary: ${fm.summary.slice(0, 60)}${fm.summary.length > 60 ? "…" : ""}`, + `image: ${fm.image ?? "(none)"}`, + `tags: ${fm.tags.join(", ") || "(none)"}`, + `body: ${result.content.length} chars`, + ]; + vscode.window.showInformationMessage(lines.join("\n")); + if (result.warnings.length > 0) { + vscode.window.showWarningMessage(result.warnings.join("; ")); + } +} + +/** Command: publish the active .md file as a NIP-23 long-form note. */ +async function cmdPublishLongForm(state: StateService, config: ConfigService): Promise<void> { + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showErrorMessage("No active Markdown editor."); + return; + } + if (editor.document.languageId !== "markdown") { + vscode.window.showErrorMessage("Active file is not Markdown."); + return; + } + + // Require sign-in. + let signer = getSigner(state, config); + if (!signer) { + const action = await vscode.window.showErrorMessage( + "Sign in to Nostr first before publishing.", + "Sign In", + ); + if (action === "Sign In") { + await vscode.commands.executeCommand("nostr.signIn"); + } + signer = getSigner(state, config); + if (!signer) { + return; + } + } + + // Parse front-matter. + const text = editor.document.getText(); + const stem = activeFilenameStem(); + let parsed; + try { + parsed = parseFrontMatter(text, stem); + } catch (err) { + if (err instanceof FrontMatterError) { + vscode.window.showErrorMessage(`Front-matter error: ${err.message}`); + } else { + vscode.window.showErrorMessage(`Failed to parse front-matter: ${(err as Error).message}`); + } + return; + } + if (parsed.warnings.length > 0) { + const proceed = await vscode.window.showWarningMessage( + `${parsed.warnings.join("; ")}\n\nProceed with publish?`, + "Publish", + "Cancel", + ); + if (proceed !== "Publish") { + return; + } + } + + // Resolve published_at: reuse stored value if this slug was published before, + // otherwise set it to now. + const slug = parsed.frontMatter.slug; + const existing = state.getPublishedAt(slug); + const publishedAt = existing ?? Math.floor(Date.now() / 1000); + const isNew = existing === null; + + // Build the event. + const template = buildLongFormEvent({ + meta: parsed.frontMatter, + content: parsed.content, + publishedAt, + }); + + // Validate the built template (reuse Phase 0 validator). + const validation = validateEventTemplate(template); + if (!validation.valid) { + vscode.window.showErrorMessage(`Built event is invalid: ${validation.errors.join("; ")}`); + return; + } + + // Sign. + let signed: VerifiedEvent; + try { + signed = await signer.signEvent(template); + } catch (err) { + vscode.window.showErrorMessage(`Signing failed: ${(err as Error).message}`); + return; + } + + // Relays. + const relays = sidebarProvider ? sidebarProvider.getEnabledRelays() : config.relays; + if (relays.length === 0) { + vscode.window.showErrorMessage( + "No relays enabled. Toggle relays in the Nostr sidebar or set `nostr.relays` in settings.", + ); + return; + } + + // Confirmation dialog. + const selectedRelays = config.publishConfirm + ? await confirmLongFormPublish(signed, relays, signer, slug, isNew, parsed.frontMatter) + : relays; + if (selectedRelays.length === 0) { + vscode.window.showInformationMessage("Publish cancelled: no relays selected."); + return; + } + + // Publish. + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Publishing long-form note…", + cancellable: false, + }, + async () => { + const results = await publishToRelays(signed, selectedRelays, config.publishTimeoutMs); + // Store published_at on success (first publish only). + if (isNew) { + const anyOk = results.some((r) => r.ok); + if (anyOk) { + state.setPublishedAt(slug, publishedAt); + } + } + showPublishResults(signed, results); + }, + ); +} + +/** Confirmation dialog for long-form publish with NIP-23 preview fields. */ +async function confirmLongFormPublish( + signed: VerifiedEvent, + relays: string[], + signer: NostrSigner, + slug: string, + isNew: boolean, + meta: { title: string; summary: string; image?: string; tags: string[] }, +): Promise<string[]> { + const npub = hexToNpub(signed.pubkey); + const previewItems: vscode.QuickPickItem[] = [ + { label: `d: ${slug}` }, + { label: `title: ${meta.title}` }, + { label: `summary: ${meta.summary.slice(0, 60)}${meta.summary.length > 60 ? "…" : ""}` }, + { label: `image: ${meta.image ?? "(none)"}` }, + { label: `tags: ${meta.tags.join(", ") || "(none)"}` }, + { label: `published_at: ${isNew ? "NEW (set to now)" : "UPDATE (preserving original)"}` }, + { label: `body: ${signed.content.length} chars` }, + { label: `pubkey: ${npub}` }, + { label: `id: ${signed.id}` }, + { label: `signer: ${signer.describe()}` }, + { label: `relays: ${relays.join(", ")}` }, + { label: "" }, + ]; + + const relayItems: vscode.QuickPickItem[] = relays.map((r) => ({ label: r, picked: true })); + + const pick = await vscode.window.showQuickPick([...previewItems, ...relayItems], { + canPickMany: true, + placeHolder: "Preview long-form note. Toggle relays, then confirm.", + ignoreFocusOut: true, + title: "Confirm NIP-23 Publish", + }); + if (!pick) return []; + + const selected = pick + .filter((item) => item.label.startsWith("wss://") || item.label.startsWith("ws://")) + .map((item) => item.label); + if (selected.length === 0) return []; + + const confirm = await vscode.window.showQuickPick(["Publish", "Cancel"], { + placeHolder: `Publish to ${selected.length} relay(s)?`, + ignoreFocusOut: true, + }); + return confirm === "Publish" ? selected : []; +} + +/** Command: reset all stored published_at timestamps. */ +async function cmdResetTimestamps(state: StateService): Promise<void> { + const confirm = await vscode.window.showWarningMessage( + "Reset all stored first-publish timestamps? This only affects the local tracking map, not events already on relays.", + "Reset", + "Cancel", + ); + if (confirm !== "Reset") return; + state.resetPublishedTimestamps(); + vscode.window.showInformationMessage("Published timestamps reset."); +} + +/** + * Command: sign in from the sidebar's method dropdown. + * + * Handles all four sign-in methods: + * - local: nsec/hex → LocalSigner + * - nsigner-unix: persist transport=unix + socketName, connect, get pubkey + * - nsigner-qrexec: persist transport=qrexec + qube, connect, get pubkey + * - nsigner-tcp: persist transport=tcp + host/port/callerNsec, connect, get pubkey + * + * After successful sign-in, fetches the user's kind 0 profile (avatar + name) + * and updates the sidebar. + */ +async function cmdSignInFromSidebar( + method: string, + cfg: Record<string, unknown>, + state: StateService, + config: ConfigService, + statusBar: StatusBar, +): Promise<void> { + const vscodeCfg = vscode.workspace.getConfiguration("nostr"); + + if (method === "local") { + // Local sign-in: reuse the existing cmdSignIn flow with the provided nsec. + const nsec = typeof cfg.nsec === "string" ? cfg.nsec : ""; + if (!nsec) { + vscode.window.showErrorMessage("Enter your nsec or hex secret key."); + return; + } + // Derive key directly here (avoid the InputBox prompt). + let secretKey: Uint8Array; + try { + if (nsec.startsWith("nsec1")) { + secretKey = decodeNsec(nsec); + } else if (isHexSecretKey(nsec)) { + secretKey = hexToBytes(nsec); + } else { + vscode.window.showErrorMessage("Invalid secret key: must be nsec1... or 64-char hex."); + return; + } + } catch (err) { + vscode.window.showErrorMessage(`Failed to decode secret key: ${(err as Error).message}`); + return; + } + if (secretKey.length !== 32) { + vscode.window.showErrorMessage(`Invalid secret key: expected 32 bytes, got ${secretKey.length}.`); + return; + } + const pubkeyHex = getPublicKey(secretKey); + state.setKey(secretKey, pubkeyHex); + await vscodeCfg.update("signerBackend", "local", vscode.ConfigurationTarget.Global); + statusBar.refresh(); + fireStateChange(); + vscode.window.showInformationMessage(`Signed in as ${hexToNpub(pubkeyHex)}`); + await fetchAndApplyRelays(pubkeyHex, config, false); + await fetchAndDisplayAvatar(pubkeyHex, config); + return; + } + + // n_signer methods: persist transport + config, then connect. + const nostrIndex = typeof cfg.nostrIndex === "number" ? cfg.nostrIndex : 0; + let transport: "unix" | "qrexec" | "tcp"; + if (method === "nsigner-unix") transport = "unix"; + else if (method === "nsigner-qrexec") transport = "qrexec"; + else if (method === "nsigner-tcp") transport = "tcp"; + else { + vscode.window.showErrorMessage(`Unknown sign-in method: ${method}`); + return; + } + + // Persist transport + index. + await vscodeCfg.update("nsigner.transport", transport, vscode.ConfigurationTarget.Global); + await vscodeCfg.update("nsigner.nostrIndex", nostrIndex, vscode.ConfigurationTarget.Global); + + // Persist transport-specific config. + if (transport === "unix") { + const socketName = typeof cfg.socketName === "string" ? cfg.socketName : "nsigner"; + await vscodeCfg.update("nsigner.socketName", socketName, vscode.ConfigurationTarget.Global); + } else if (transport === "qrexec") { + const qube = typeof cfg.qrexecQube === "string" ? cfg.qrexecQube : "nostr_signer"; + await vscodeCfg.update("nsigner.qrexecQube", qube, vscode.ConfigurationTarget.Global); + } else if (transport === "tcp") { + const host = typeof cfg.tcpHost === "string" ? cfg.tcpHost : "127.0.0.1"; + const port = typeof cfg.tcpPort === "number" ? cfg.tcpPort : 8080; + const callerNsec = typeof cfg.callerNsec === "string" ? cfg.callerNsec : ""; + await vscodeCfg.update("nsigner.tcpHost", host, vscode.ConfigurationTarget.Global); + await vscodeCfg.update("nsigner.tcpPort", port, vscode.ConfigurationTarget.Global); + if (callerNsec) { + await vscodeCfg.update("nsigner.callerNsec", callerNsec, vscode.ConfigurationTarget.Global); + } + } + await vscodeCfg.update("signerBackend", "nsigner", vscode.ConfigurationTarget.Global); + + // Build the backend config and try to connect. + // Re-read config to pick up the persisted values. + const backendConfig = buildNsignerConfig(new ConfigService(), state); + const backend = new NsignerBackend(backendConfig); + const startHint = transport === "unix" + ? `nsigner --listen unix --socket-name ${backendConfig.socketName}` + : transport === "qrexec" + ? `qrexec-client-vm ${backendConfig.qrexecQube} qubes.NsignerRpc` + : `nsigner --listen tcp:${backendConfig.tcpHost}:${backendConfig.tcpPort}`; + + try { + const pubkey = await backend.getPublicKey(); + state.setKey(new Uint8Array(32), pubkey); // dummy key — n_signer holds the real one + statusBar.refresh(); + fireStateChange(); + vscode.window.showInformationMessage( + `Signed in via ${backend.describe()}. Pubkey: ${hexToNpub(pubkey)}`, + ); + await fetchAndApplyRelays(pubkey, config, false); + await fetchAndDisplayAvatar(pubkey, config); + } catch (err) { + statusBar.refresh(); + fireStateChange(); + vscode.window.showErrorMessage( + `Could not reach n_signer: ${(err as Error).message}\nStart it with: ${startHint}`, + ); + } +} + +/** Fetch the user's kind 0 profile and update the sidebar with avatar + name. */ +async function fetchAndDisplayAvatar(pubkeyHex: string, config: ConfigService): Promise<void> { + const provider = sidebarProvider; + if (!provider) return; + try { + const profile = await fetchUserProfile(pubkeyHex, config.relays); + provider.setProfile(profile?.name, profile?.picture); + } catch { + // Non-fatal — avatar is optional. + } +} + +/** + * Command: select the signer backend (local in-memory key vs n_signer). + * + * For `nsigner`, discovers running n_signer sockets via `/proc/net/unix` and + * lets the user pick one (or use the configured default). Surfaces connection + * errors with actionable hints. + */ +async function cmdSelectBackend( + config: ConfigService, + state: StateService, + statusBar: StatusBar, +): Promise<void> { + const current = config.signerBackend; + const items: (vscode.QuickPickItem & { backend: "local" | "nsigner" })[] = [ + { + label: "Local (in-memory key)", + description: current === "local" ? "$(check) current" : "", + detail: "Sign with a secret key entered via Nostr: Sign In. Key held in RAM only.", + backend: "local", + }, + { + label: "n_signer (remote signing)", + description: current === "nsigner" ? "$(check) current" : "", + detail: "Delegate signing to a running n_signer over an abstract Unix socket.", + backend: "nsigner", + }, + ]; + + const picked = await vscode.window.showQuickPick(items, { + placeHolder: "Select a signer backend", + ignoreFocusOut: true, + }); + if (!picked) return; + + if (picked.backend === "local") { + await vscode.workspace.getConfiguration("nostr").update( + "signerBackend", + "local", + vscode.ConfigurationTarget.Global, + ); + statusBar.refresh(); + fireStateChange(); + vscode.window.showInformationMessage("Signer backend: local (in-memory key)."); + return; + } + + // n_signer: pick a transport, then configure it. + const transportItems: (vscode.QuickPickItem & { transport: "unix" | "qrexec" | "tcp" })[] = [ + { + label: "Unix socket (local)", + description: config.nsignerTransport === "unix" ? "$(check) current" : "", + detail: "Abstract Unix socket. Same machine. No auth envelope (UID identity).", + transport: "unix", + }, + { + label: "Qubes qrexec (inter-qube)", + description: config.nsignerTransport === "qrexec" ? "$(check) current" : "", + detail: "Spawn qrexec-client-vm to reach n_signer in another qube. No auth envelope.", + transport: "qrexec", + }, + { + label: "TCP (remote / FIPS mesh)", + description: config.nsignerTransport === "tcp" ? "$(check) current" : "", + detail: "Connect to nsigner --listen tcp:host:port. Requires auth envelope (caller key).", + transport: "tcp", + }, + ]; + + const transportPicked = await vscode.window.showQuickPick(transportItems, { + placeHolder: "Select an n_signer transport", + ignoreFocusOut: true, + }); + if (!transportPicked) return; + const transport = transportPicked.transport; + + // Per-transport configuration. + let socketName = config.nsignerSocketName; + let qrexecQube = config.nsignerQrexecQube; + let tcpHost = config.nsignerTcpHost; + let tcpPort = config.nsignerTcpPort; + + if (transport === "unix") { + const discovered = await discoverNsignerSockets(); + const socketItems: (vscode.QuickPickItem & { socketName: string })[] = []; + socketItems.push({ + label: `@${config.nsignerSocketName}`, + description: discovered.includes(config.nsignerSocketName) ? "reachable" : "not found in /proc/net/unix", + detail: "Configured socket name.", + socketName: config.nsignerSocketName, + }); + for (const name of discovered) { + if (name !== config.nsignerSocketName) { + socketItems.push({ label: `@${name}`, description: "discovered", detail: "Found in /proc/net/unix.", socketName: name }); + } + } + socketItems.push({ label: "Enter socket name manually…", description: "", detail: "Specify an abstract socket name (without @).", socketName: "" }); + + const socketPicked = await vscode.window.showQuickPick(socketItems, { + placeHolder: discovered.length === 0 + ? "No n_signer sockets found. Start one with `nsigner --listen unix --socket-name nsigner`." + : "Select an n_signer socket", + ignoreFocusOut: true, + }); + if (!socketPicked) return; + if (!socketPicked.socketName) { + const input = await vscode.window.showInputBox({ + prompt: "Enter n_signer abstract socket name (without @)", + value: config.nsignerSocketName, + ignoreFocusOut: true, + }); + if (!input) return; + socketName = input.trim(); + } else { + socketName = socketPicked.socketName; + } + } else if (transport === "qrexec") { + const input = await vscode.window.showInputBox({ + prompt: "Enter target qube name (e.g. nostr_signer)", + value: config.nsignerQrexecQube, + ignoreFocusOut: true, + }); + if (!input) return; + qrexecQube = input.trim(); + } else if (transport === "tcp") { + const hostInput = await vscode.window.showInputBox({ + prompt: "Enter n_signer TCP host", + value: config.nsignerTcpHost, + ignoreFocusOut: true, + }); + if (!hostInput) return; + tcpHost = hostInput.trim(); + const portInput = await vscode.window.showInputBox({ + prompt: "Enter n_signer TCP port", + value: String(config.nsignerTcpPort), + ignoreFocusOut: true, + validateInput: (v) => (/^\d+$/.test(v.trim()) ? null : "must be a number"), + }); + if (!portInput) return; + tcpPort = parseInt(portInput.trim(), 10); + } + + // Persist transport-specific config + backend. + const cfg = vscode.workspace.getConfiguration("nostr"); + await cfg.update("nsigner.transport", transport, vscode.ConfigurationTarget.Global); + if (transport === "unix") { + await cfg.update("nsigner.socketName", socketName, vscode.ConfigurationTarget.Global); + } else if (transport === "qrexec") { + await cfg.update("nsigner.qrexecQube", qrexecQube, vscode.ConfigurationTarget.Global); + } else if (transport === "tcp") { + await cfg.update("nsigner.tcpHost", tcpHost, vscode.ConfigurationTarget.Global); + await cfg.update("nsigner.tcpPort", tcpPort, vscode.ConfigurationTarget.Global); + } + await cfg.update("signerBackend", "nsigner", vscode.ConfigurationTarget.Global); + + // Preflight: try to reach n_signer and fetch the pubkey. + const backend = new NsignerBackend(buildNsignerConfig(config, state)); + const startHint = transport === "unix" + ? `nsigner --listen unix --socket-name ${socketName}` + : transport === "qrexec" + ? `qrexec-client-vm ${qrexecQube} qubes.NsignerRpc (ensure service is installed in ${qrexecQube})` + : `nsigner --listen tcp:${tcpHost}:${tcpPort}`; + try { + const pubkey = await backend.getPublicKey(); + state.setKey(new Uint8Array(32), pubkey); // dummy key — n_signer holds the real one + statusBar.refresh(); + fireStateChange(); + vscode.window.showInformationMessage( + `Signer backend: ${backend.describe()}. Pubkey: ${hexToNpub(pubkey)}`, + ); + } catch (err) { + statusBar.refresh(); + fireStateChange(); + vscode.window.showErrorMessage( + `Switched to ${backend.describe()}, but could not reach it: ${(err as Error).message}\n` + + `Start it with: ${startHint}`, + ); + } +} + +// Re-exported for potential future use (kept to avoid unused-import errors). +export { bytesToHex, neventEncode, naddrEncode }; diff --git a/src/frontmatter.ts b/src/frontmatter.ts new file mode 100644 index 0000000..c2fd3d9 --- /dev/null +++ b/src/frontmatter.ts @@ -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 ""; +} diff --git a/src/nostr/nip23.ts b/src/nostr/nip23.ts new file mode 100644 index 0000000..511f405 --- /dev/null +++ b/src/nostr/nip23.ts @@ -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, + }; +} diff --git a/src/nostr/npub.ts b/src/nostr/npub.ts new file mode 100644 index 0000000..776b299 --- /dev/null +++ b/src/nostr/npub.ts @@ -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()); +} diff --git a/src/nostr/relay.ts b/src/nostr/relay.ts new file mode 100644 index 0000000..b1e2953 --- /dev/null +++ b/src/nostr/relay.ts @@ -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" }); + }); + }); +} diff --git a/src/nostr/validate.ts b/src/nostr/validate.ts new file mode 100644 index 0000000..e4bb0f8 --- /dev/null +++ b/src/nostr/validate.ts @@ -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]; +} diff --git a/src/nsigner/authEnvelope.ts b/src/nsigner/authEnvelope.ts new file mode 100644 index 0000000..b8df29c --- /dev/null +++ b/src/nsigner/authEnvelope.ts @@ -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"); +} diff --git a/src/nsigner/client.ts b/src/nsigner/client.ts new file mode 100644 index 0000000..3bca135 --- /dev/null +++ b/src/nsigner/client.ts @@ -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); + }); + }); + } +} diff --git a/src/nsigner/discovery.ts b/src/nsigner/discovery.ts new file mode 100644 index 0000000..2e8eed9 --- /dev/null +++ b/src/nsigner/discovery.ts @@ -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); +} diff --git a/src/nsigner/jcs.ts b/src/nsigner/jcs.ts new file mode 100644 index 0000000..9554885 --- /dev/null +++ b/src/nsigner/jcs.ts @@ -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); +} diff --git a/src/nsigner/qrexecClient.ts b/src/nsigner/qrexecClient.ts new file mode 100644 index 0000000..0b459ee --- /dev/null +++ b/src/nsigner/qrexecClient.ts @@ -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(); + }); + } +} diff --git a/src/nsigner/tcpClient.ts b/src/nsigner/tcpClient.ts new file mode 100644 index 0000000..eb95bfb --- /dev/null +++ b/src/nsigner/tcpClient.ts @@ -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); + }); + }); + } +} diff --git a/src/signer/backend.ts b/src/signer/backend.ts new file mode 100644 index 0000000..d3f5215 --- /dev/null +++ b/src/signer/backend.ts @@ -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"; + } +} diff --git a/src/signer/localSigner.ts b/src/signer/localSigner.ts new file mode 100644 index 0000000..e7191e5 --- /dev/null +++ b/src/signer/localSigner.ts @@ -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"; + } +} diff --git a/src/signer/nsignerBackend.ts b/src/signer/nsignerBackend.ts new file mode 100644 index 0000000..d2e7007 --- /dev/null +++ b/src/signer/nsignerBackend.ts @@ -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}`; + } +} diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..50af111 --- /dev/null +++ b/src/state.ts @@ -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 ?? {}; + } +} diff --git a/src/ui/sidebar.ts b/src/ui/sidebar.ts new file mode 100644 index 0000000..092df50 --- /dev/null +++ b/src/ui/sidebar.ts @@ -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 + + + + + +
+

Actions

+ + + + + +
+ + +
+

Relays

+
    +
  • No relays configured.
  • +
+ +
+ + +
+

Identity

+ +
+
Sign in method
+ + + +
+
Secret key (nsec1... or hex)
+ +
+ + + + + + + + + + + + + + +
+
+ + + +`; + } +} + +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; +} diff --git a/src/ui/statusBar.ts b/src/ui/statusBar.ts new file mode 100644 index 0000000..5e2a9fb --- /dev/null +++ b/src/ui/statusBar.ts @@ -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(); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..be7c724 --- /dev/null +++ b/tsconfig.json @@ -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"] +}