@actagent/nostr 2026.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +142 -0
  2. package/actagent.plugin.json +17 -0
  3. package/api.ts +11 -0
  4. package/channel-plugin-api.ts +2 -0
  5. package/doctor-contract-api.test.ts +105 -0
  6. package/doctor-contract-api.ts +297 -0
  7. package/index.ts +96 -0
  8. package/npm-shrinkwrap.json +137 -0
  9. package/package.json +67 -0
  10. package/runtime-api.ts +6 -0
  11. package/setup-api.ts +2 -0
  12. package/setup-entry.ts +10 -0
  13. package/setup-plugin-api.ts +3 -0
  14. package/src/channel-api.ts +12 -0
  15. package/src/channel.inbound.test.ts +203 -0
  16. package/src/channel.lifecycle.test.ts +97 -0
  17. package/src/channel.outbound.test.ts +175 -0
  18. package/src/channel.setup.ts +161 -0
  19. package/src/channel.test.ts +527 -0
  20. package/src/channel.ts +215 -0
  21. package/src/config-schema.ts +99 -0
  22. package/src/default-relays.ts +2 -0
  23. package/src/gateway.ts +338 -0
  24. package/src/inbound-direct-dm-runtime.ts +2 -0
  25. package/src/metrics.ts +454 -0
  26. package/src/nostr-bus.fuzz.test.ts +383 -0
  27. package/src/nostr-bus.inbound.test.ts +598 -0
  28. package/src/nostr-bus.integration.test.ts +491 -0
  29. package/src/nostr-bus.test.ts +256 -0
  30. package/src/nostr-bus.ts +799 -0
  31. package/src/nostr-key-utils.ts +93 -0
  32. package/src/nostr-profile-core.ts +135 -0
  33. package/src/nostr-profile-http-runtime.ts +7 -0
  34. package/src/nostr-profile-http.test.ts +632 -0
  35. package/src/nostr-profile-http.ts +583 -0
  36. package/src/nostr-profile-import.test.ts +196 -0
  37. package/src/nostr-profile-import.ts +273 -0
  38. package/src/nostr-profile-url-safety.ts +22 -0
  39. package/src/nostr-profile.fuzz.test.ts +431 -0
  40. package/src/nostr-profile.test.ts +416 -0
  41. package/src/nostr-profile.ts +144 -0
  42. package/src/nostr-state-store.test.ts +172 -0
  43. package/src/nostr-state-store.ts +132 -0
  44. package/src/runtime.ts +10 -0
  45. package/src/seen-tracker.ts +291 -0
  46. package/src/session-route.ts +26 -0
  47. package/src/setup-adapter.ts +86 -0
  48. package/src/setup-surface.ts +204 -0
  49. package/src/test-fixtures.ts +46 -0
  50. package/src/types.ts +118 -0
  51. package/test/setup.ts +5 -0
  52. package/test-api.ts +2 -0
  53. package/tsconfig.json +16 -0
package/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # @actagent/nostr
2
+
3
+ Nostr DM channel plugin for ACTAgent using NIP-04 encrypted direct messages.
4
+
5
+ ## Overview
6
+
7
+ This extension adds Nostr as a messaging channel to ACTAgent. It enables your bot to:
8
+
9
+ - Receive encrypted DMs from Nostr users
10
+ - Send encrypted responses back
11
+ - Work with any NIP-04 compatible Nostr client (Damus, Amethyst, etc.)
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ actagent plugins install @actagent/nostr
17
+ ```
18
+
19
+ ## Quick Setup
20
+
21
+ 1. Generate a Nostr keypair (if you don't have one):
22
+
23
+ ```bash
24
+ # Using nak CLI
25
+ nak key generate
26
+
27
+ # Or use any Nostr key generator
28
+ ```
29
+
30
+ 2. Add to your config:
31
+
32
+ ```json
33
+ {
34
+ "channels": {
35
+ "nostr": {
36
+ "privateKey": "${NOSTR_PRIVATE_KEY}",
37
+ "relays": ["wss://relay.damus.io", "wss://nos.lol"]
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ 3. Set the environment variable:
44
+
45
+ ```bash
46
+ export NOSTR_PRIVATE_KEY="nsec1..." # or hex format
47
+ ```
48
+
49
+ 4. Restart the gateway
50
+
51
+ ## Configuration
52
+
53
+ | Key | Type | Default | Description |
54
+ | ------------ | -------- | ------------------------------------------- | ---------------------------------------------------------- |
55
+ | `privateKey` | string | required | Bot's private key (nsec or hex format) |
56
+ | `relays` | string[] | `["wss://relay.damus.io", "wss://nos.lol"]` | WebSocket relay URLs |
57
+ | `dmPolicy` | string | `"pairing"` | Access control: `pairing`, `allowlist`, `open`, `disabled` |
58
+ | `allowFrom` | string[] | `[]` | Allowed sender pubkeys (npub or hex) |
59
+ | `enabled` | boolean | `true` | Enable/disable the channel |
60
+ | `name` | string | - | Display name for the account |
61
+
62
+ ## Access Control
63
+
64
+ ### DM Policies
65
+
66
+ - **pairing** (default): Unknown senders receive a pairing code to request access
67
+ - **allowlist**: Only pubkeys in `allowFrom` can message the bot
68
+ - **open**: Anyone can message the bot (use with caution)
69
+ - **disabled**: DMs are disabled
70
+
71
+ Inbound event signatures are verified before policy enforcement and NIP-04 decryption.
72
+ Unknown senders in `pairing` mode can receive a pairing reply, but their original DM body is not
73
+ processed unless approved.
74
+
75
+ ### Example: Allowlist Mode
76
+
77
+ ```json
78
+ {
79
+ "channels": {
80
+ "nostr": {
81
+ "privateKey": "${NOSTR_PRIVATE_KEY}",
82
+ "dmPolicy": "allowlist",
83
+ "allowFrom": ["npub1abc...", "0123456789abcdef..."]
84
+ }
85
+ }
86
+ }
87
+ ```
88
+
89
+ ## Testing
90
+
91
+ ### Local Relay (Recommended)
92
+
93
+ ```bash
94
+ # Using strfry
95
+ docker run -p 7777:7777 ghcr.io/hoytech/strfry
96
+
97
+ # Configure actagent to use local relay
98
+ "relays": ["ws://localhost:7777"]
99
+ ```
100
+
101
+ ### Manual Test
102
+
103
+ 1. Start the gateway with Nostr configured
104
+ 2. Open Damus, Amethyst, or another Nostr client
105
+ 3. Send a DM to your bot's npub
106
+ 4. Verify the bot responds
107
+
108
+ ## Protocol Support
109
+
110
+ | NIP | Status | Notes |
111
+ | ------ | --------- | ---------------------- |
112
+ | NIP-01 | Supported | Basic event structure |
113
+ | NIP-04 | Supported | Encrypted DMs (kind:4) |
114
+ | NIP-17 | Planned | Gift-wrapped DMs (v2) |
115
+
116
+ ## Security Notes
117
+
118
+ - Private keys are never logged
119
+ - Event signatures are verified before processing
120
+ - Sender policy is checked before expensive crypto work
121
+ - Inbound DMs are rate-limited and oversized payloads are dropped before decrypt
122
+ - Use environment variables for keys, never commit to config files
123
+ - Consider using `allowlist` mode in production
124
+
125
+ ## Troubleshooting
126
+
127
+ ### Bot not receiving messages
128
+
129
+ 1. Verify private key is correctly configured
130
+ 2. Check relay connectivity
131
+ 3. Ensure `enabled` is not set to `false`
132
+ 4. Check the bot's public key matches what you're sending to
133
+
134
+ ### Messages not being delivered
135
+
136
+ 1. Check relay URLs are correct (must use `wss://`)
137
+ 2. Verify relays are online and accepting connections
138
+ 3. Check for rate limiting (reduce message frequency)
139
+
140
+ ## License
141
+
142
+ MIT
@@ -0,0 +1,17 @@
1
+ {
2
+ "id": "nostr",
3
+ "name": "Nostr",
4
+ "description": "ACTAgent Nostr channel plugin for NIP-04 encrypted direct messages.",
5
+ "activation": {
6
+ "onStartup": false
7
+ },
8
+ "channels": ["nostr"],
9
+ "channelEnvVars": {
10
+ "nostr": ["NOSTR_PRIVATE_KEY"]
11
+ },
12
+ "configSchema": {
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "properties": {}
16
+ }
17
+ }
package/api.ts ADDED
@@ -0,0 +1,11 @@
1
+ // Nostr API module exposes the plugin public contract.
2
+ export {
3
+ getPluginRuntimeGatewayRequestScope,
4
+ type ACTAgentConfig,
5
+ type PluginRuntime,
6
+ } from "./runtime-api.js";
7
+ export { nostrPlugin } from "./src/channel.js";
8
+ export { createNostrProfileHttpHandler } from "./src/nostr-profile-http.js";
9
+ export { getNostrRuntime, setNostrRuntime } from "./src/runtime.js";
10
+ export { resolveNostrAccount } from "./src/types.js";
11
+ export type { ResolvedNostrAccount } from "./src/types.js";
@@ -0,0 +1,2 @@
1
+ // Nostr API module exposes the plugin public contract.
2
+ export { nostrPlugin } from "./src/channel.js";
@@ -0,0 +1,105 @@
1
+ // Nostr tests cover doctor contract api plugin behavior.
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import {
6
+ createPluginStateKeyedStoreForTests,
7
+ resetPluginStateStoreForTests,
8
+ } from "actagent/plugin-sdk/plugin-state-test-runtime";
9
+ import type {
10
+ OpenKeyedStoreOptions,
11
+ PluginDoctorStateMigrationContext,
12
+ } from "actagent/plugin-sdk/runtime-doctor";
13
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
14
+ import { stateMigrations } from "./doctor-contract-api.js";
15
+
16
+ function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext {
17
+ return {
18
+ openPluginStateKeyedStore<T>(options: OpenKeyedStoreOptions) {
19
+ return createPluginStateKeyedStoreForTests<T>("nostr", {
20
+ ...options,
21
+ env: options.env ?? env,
22
+ });
23
+ },
24
+ };
25
+ }
26
+
27
+ describe("nostr doctor state migration", () => {
28
+ let stateDir = "";
29
+ let env: NodeJS.ProcessEnv;
30
+
31
+ beforeEach(async () => {
32
+ resetPluginStateStoreForTests();
33
+ stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "actagent-nostr-doctor-"));
34
+ env = { ...process.env, ACTAGENT_STATE_DIR: stateDir };
35
+ });
36
+
37
+ afterEach(async () => {
38
+ await fs.rm(stateDir, { recursive: true, force: true });
39
+ });
40
+
41
+ it("imports legacy bus and profile state into plugin state", async () => {
42
+ const nostrDir = path.join(stateDir, "nostr");
43
+ const busPath = path.join(nostrDir, "bus-state-main.json");
44
+ const profilePath = path.join(nostrDir, "profile-state-main.json");
45
+ await fs.mkdir(nostrDir, { recursive: true });
46
+ await fs.writeFile(
47
+ busPath,
48
+ JSON.stringify({
49
+ version: 1,
50
+ lastProcessedAt: 1700,
51
+ gatewayStartedAt: 1600,
52
+ }),
53
+ );
54
+ await fs.writeFile(
55
+ profilePath,
56
+ JSON.stringify({
57
+ version: 1,
58
+ lastPublishedAt: 1800,
59
+ lastPublishedEventId: "event-1",
60
+ lastPublishResults: { "wss://relay.example": "ok", bad: "nope" },
61
+ }),
62
+ );
63
+
64
+ const context = createDoctorContext(env);
65
+ const busResult = await stateMigrations[0].migrateLegacyState({
66
+ config: {},
67
+ env,
68
+ stateDir,
69
+ oauthDir: path.join(stateDir, "oauth"),
70
+ context,
71
+ });
72
+ const profileResult = await stateMigrations[1].migrateLegacyState({
73
+ config: {},
74
+ env,
75
+ stateDir,
76
+ oauthDir: path.join(stateDir, "oauth"),
77
+ context,
78
+ });
79
+
80
+ expect(busResult.warnings).toEqual([]);
81
+ expect(profileResult.warnings).toEqual([]);
82
+ await expect(fs.access(busPath)).rejects.toThrow();
83
+ await expect(fs.access(profilePath)).rejects.toThrow();
84
+ await expect(fs.access(`${busPath}.migrated`)).resolves.toBeUndefined();
85
+ await expect(fs.access(`${profilePath}.migrated`)).resolves.toBeUndefined();
86
+ await expect(
87
+ context.openPluginStateKeyedStore({ namespace: "bus-state", maxEntries: 256 }).lookup("main"),
88
+ ).resolves.toEqual({
89
+ version: 2,
90
+ lastProcessedAt: 1700,
91
+ gatewayStartedAt: 1600,
92
+ recentEventIds: [],
93
+ });
94
+ await expect(
95
+ context
96
+ .openPluginStateKeyedStore({ namespace: "profile-state", maxEntries: 256 })
97
+ .lookup("main"),
98
+ ).resolves.toEqual({
99
+ version: 1,
100
+ lastPublishedAt: 1800,
101
+ lastPublishedEventId: "event-1",
102
+ lastPublishResults: { "wss://relay.example": "ok" },
103
+ });
104
+ });
105
+ });
@@ -0,0 +1,297 @@
1
+ // Nostr API module exposes the plugin public contract.
2
+ import type { Dirent } from "node:fs";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import type { PluginDoctorStateMigration } from "actagent/plugin-sdk/runtime-doctor";
6
+
7
+ type NostrBusState = {
8
+ version: 2;
9
+ lastProcessedAt: number | null;
10
+ gatewayStartedAt: number | null;
11
+ recentEventIds: string[];
12
+ };
13
+
14
+ type NostrProfileState = {
15
+ version: 1;
16
+ lastPublishedAt: number | null;
17
+ lastPublishedEventId: string | null;
18
+ lastPublishResults: Record<string, "ok" | "failed" | "timeout"> | null;
19
+ };
20
+
21
+ const BUS_STATE_NAMESPACE = "bus-state";
22
+ const PROFILE_STATE_NAMESPACE = "profile-state";
23
+ const MAX_NOSTR_STATE_ENTRIES = 256;
24
+
25
+ function normalizeAccountId(accountId?: string): string {
26
+ const trimmed = accountId?.trim();
27
+ if (!trimmed) {
28
+ return "default";
29
+ }
30
+ return trimmed.replace(/[^a-z0-9._-]+/gi, "_");
31
+ }
32
+
33
+ function finiteNumberOrNull(value: unknown): number | null {
34
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
35
+ }
36
+
37
+ function parseBusState(value: unknown): NostrBusState | null {
38
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
39
+ return null;
40
+ }
41
+ const parsed = value as Record<string, unknown>;
42
+ if (parsed.version !== 1 && parsed.version !== 2) {
43
+ return null;
44
+ }
45
+ return {
46
+ version: 2,
47
+ lastProcessedAt: finiteNumberOrNull(parsed.lastProcessedAt),
48
+ gatewayStartedAt: finiteNumberOrNull(parsed.gatewayStartedAt),
49
+ recentEventIds:
50
+ parsed.version === 2 && Array.isArray(parsed.recentEventIds)
51
+ ? parsed.recentEventIds.filter((entry): entry is string => typeof entry === "string")
52
+ : [],
53
+ };
54
+ }
55
+
56
+ function parseProfileState(value: unknown): NostrProfileState | null {
57
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
58
+ return null;
59
+ }
60
+ const parsed = value as Record<string, unknown>;
61
+ if (parsed.version !== 1) {
62
+ return null;
63
+ }
64
+ const rawResults = parsed.lastPublishResults;
65
+ const lastPublishResults: Record<string, "ok" | "failed" | "timeout"> = {};
66
+ if (rawResults && typeof rawResults === "object" && !Array.isArray(rawResults)) {
67
+ for (const [relay, result] of Object.entries(rawResults)) {
68
+ if (result === "ok" || result === "failed" || result === "timeout") {
69
+ lastPublishResults[relay] = result;
70
+ }
71
+ }
72
+ }
73
+ return {
74
+ version: 1,
75
+ lastPublishedAt: finiteNumberOrNull(parsed.lastPublishedAt),
76
+ lastPublishedEventId:
77
+ typeof parsed.lastPublishedEventId === "string" ? parsed.lastPublishedEventId : null,
78
+ lastPublishResults:
79
+ rawResults === null || Object.keys(lastPublishResults).length === 0
80
+ ? null
81
+ : lastPublishResults,
82
+ };
83
+ }
84
+
85
+ async function fileExists(filePath: string): Promise<boolean> {
86
+ try {
87
+ const stat = await fs.stat(filePath);
88
+ return stat.isFile();
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ async function readJsonFile(filePath: string): Promise<unknown> {
95
+ return JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
96
+ }
97
+
98
+ async function listLegacyFiles(params: {
99
+ stateDir: string;
100
+ prefix: string;
101
+ parse: (value: unknown) => unknown;
102
+ }): Promise<Array<{ accountId: string; filePath: string; value: unknown }>> {
103
+ const dir = path.join(params.stateDir, "nostr");
104
+ let entries: Dirent[];
105
+ try {
106
+ entries = await fs.readdir(dir, { withFileTypes: true });
107
+ } catch {
108
+ return [];
109
+ }
110
+ const suffix = ".json";
111
+ const files: Array<{ accountId: string; filePath: string; value: unknown }> = [];
112
+ for (const entry of entries) {
113
+ if (!entry.isFile() || !entry.name.startsWith(params.prefix) || !entry.name.endsWith(suffix)) {
114
+ continue;
115
+ }
116
+ const rawAccountId = entry.name.slice(params.prefix.length, -suffix.length);
117
+ const accountId = normalizeAccountId(rawAccountId);
118
+ const filePath = path.join(dir, entry.name);
119
+ try {
120
+ const value = params.parse(await readJsonFile(filePath));
121
+ if (value) {
122
+ files.push({ accountId, filePath, value });
123
+ }
124
+ } catch {
125
+ // Malformed legacy cache/cursor files are ignored by migration.
126
+ }
127
+ }
128
+ return files;
129
+ }
130
+
131
+ async function archiveLegacySource(params: {
132
+ filePath: string;
133
+ label: string;
134
+ changes: string[];
135
+ warnings: string[];
136
+ }): Promise<void> {
137
+ const archivedPath = `${params.filePath}.migrated`;
138
+ if (await fileExists(archivedPath)) {
139
+ params.warnings.push(
140
+ `Left migrated ${params.label} source in place because ${archivedPath} already exists`,
141
+ );
142
+ return;
143
+ }
144
+ try {
145
+ await fs.rename(params.filePath, archivedPath);
146
+ params.changes.push(`Archived ${params.label} legacy source -> ${archivedPath}`);
147
+ } catch (err) {
148
+ params.warnings.push(`Failed archiving ${params.label} legacy source: ${String(err)}`);
149
+ }
150
+ }
151
+
152
+ async function ensureStoreCapacity(params: {
153
+ files: Array<{ accountId: string }>;
154
+ store: { entries: () => Promise<Array<{ key: string; value: unknown }>> };
155
+ maxEntries: number;
156
+ label: string;
157
+ warnings: string[];
158
+ }): Promise<Set<string> | null> {
159
+ const existingKeys = new Set((await params.store.entries()).map((entry) => entry.key));
160
+ const missingKeys = new Set(
161
+ params.files.map((file) => file.accountId).filter((key) => !existingKeys.has(key)),
162
+ );
163
+ if (missingKeys.size > params.maxEntries - existingKeys.size) {
164
+ params.warnings.push(
165
+ `Skipped migrating ${params.label} because plugin state has room for ${params.maxEntries - existingKeys.size} of ${missingKeys.size} missing entries; left legacy sources in place`,
166
+ );
167
+ return null;
168
+ }
169
+ return existingKeys;
170
+ }
171
+
172
+ export const stateMigrations: PluginDoctorStateMigration[] = [
173
+ {
174
+ id: "nostr-bus-state-json-to-plugin-state",
175
+ label: "Nostr bus state",
176
+ async detectLegacyState(params) {
177
+ const files = await listLegacyFiles({
178
+ stateDir: params.stateDir,
179
+ prefix: "bus-state-",
180
+ parse: parseBusState,
181
+ });
182
+ if (files.length === 0) {
183
+ return null;
184
+ }
185
+ return {
186
+ preview: [
187
+ `- Nostr bus state: ${files.length} ${files.length === 1 ? "account" : "accounts"} -> plugin state (${BUS_STATE_NAMESPACE})`,
188
+ ],
189
+ };
190
+ },
191
+ async migrateLegacyState(params) {
192
+ const changes: string[] = [];
193
+ const warnings: string[] = [];
194
+ const files = await listLegacyFiles({
195
+ stateDir: params.stateDir,
196
+ prefix: "bus-state-",
197
+ parse: parseBusState,
198
+ });
199
+ const store = params.context.openPluginStateKeyedStore<NostrBusState>({
200
+ namespace: BUS_STATE_NAMESPACE,
201
+ maxEntries: MAX_NOSTR_STATE_ENTRIES,
202
+ });
203
+ const existingKeys = await ensureStoreCapacity({
204
+ files,
205
+ store,
206
+ maxEntries: MAX_NOSTR_STATE_ENTRIES,
207
+ label: "Nostr bus state",
208
+ warnings,
209
+ });
210
+ if (!existingKeys) {
211
+ return { changes, warnings };
212
+ }
213
+ let imported = 0;
214
+ for (const file of files) {
215
+ if (!existingKeys.has(file.accountId)) {
216
+ await store.register(file.accountId, file.value as NostrBusState);
217
+ existingKeys.add(file.accountId);
218
+ imported++;
219
+ }
220
+ await archiveLegacySource({
221
+ filePath: file.filePath,
222
+ label: "Nostr bus state",
223
+ changes,
224
+ warnings,
225
+ });
226
+ }
227
+ if (imported > 0) {
228
+ changes.unshift(
229
+ `Migrated ${imported} Nostr bus-state ${imported === 1 ? "entry" : "entries"} -> plugin state`,
230
+ );
231
+ }
232
+ return { changes, warnings };
233
+ },
234
+ },
235
+ {
236
+ id: "nostr-profile-state-json-to-plugin-state",
237
+ label: "Nostr profile state",
238
+ async detectLegacyState(params) {
239
+ const files = await listLegacyFiles({
240
+ stateDir: params.stateDir,
241
+ prefix: "profile-state-",
242
+ parse: parseProfileState,
243
+ });
244
+ if (files.length === 0) {
245
+ return null;
246
+ }
247
+ return {
248
+ preview: [
249
+ `- Nostr profile state: ${files.length} ${files.length === 1 ? "account" : "accounts"} -> plugin state (${PROFILE_STATE_NAMESPACE})`,
250
+ ],
251
+ };
252
+ },
253
+ async migrateLegacyState(params) {
254
+ const changes: string[] = [];
255
+ const warnings: string[] = [];
256
+ const files = await listLegacyFiles({
257
+ stateDir: params.stateDir,
258
+ prefix: "profile-state-",
259
+ parse: parseProfileState,
260
+ });
261
+ const store = params.context.openPluginStateKeyedStore<NostrProfileState>({
262
+ namespace: PROFILE_STATE_NAMESPACE,
263
+ maxEntries: MAX_NOSTR_STATE_ENTRIES,
264
+ });
265
+ const existingKeys = await ensureStoreCapacity({
266
+ files,
267
+ store,
268
+ maxEntries: MAX_NOSTR_STATE_ENTRIES,
269
+ label: "Nostr profile state",
270
+ warnings,
271
+ });
272
+ if (!existingKeys) {
273
+ return { changes, warnings };
274
+ }
275
+ let imported = 0;
276
+ for (const file of files) {
277
+ if (!existingKeys.has(file.accountId)) {
278
+ await store.register(file.accountId, file.value as NostrProfileState);
279
+ existingKeys.add(file.accountId);
280
+ imported++;
281
+ }
282
+ await archiveLegacySource({
283
+ filePath: file.filePath,
284
+ label: "Nostr profile state",
285
+ changes,
286
+ warnings,
287
+ });
288
+ }
289
+ if (imported > 0) {
290
+ changes.unshift(
291
+ `Migrated ${imported} Nostr profile-state ${imported === 1 ? "entry" : "entries"} -> plugin state`,
292
+ );
293
+ }
294
+ return { changes, warnings };
295
+ },
296
+ },
297
+ ];
package/index.ts ADDED
@@ -0,0 +1,96 @@
1
+ // Nostr plugin entrypoint registers its ACTAgent integration.
2
+ import {
3
+ defineBundledChannelEntry,
4
+ loadBundledEntryExportSync,
5
+ } from "actagent/plugin-sdk/channel-entry-contract";
6
+ import type { ACTAgentConfig, PluginRuntime, ResolvedNostrAccount } from "./api.js";
7
+
8
+ function createNostrProfileHttpHandler() {
9
+ return loadBundledEntryExportSync<
10
+ (params: Record<string, unknown>) => (ctx: unknown) => Promise<void> | void
11
+ >(import.meta.url, {
12
+ specifier: "./api.js",
13
+ exportName: "createNostrProfileHttpHandler",
14
+ });
15
+ }
16
+
17
+ function getNostrRuntime() {
18
+ return loadBundledEntryExportSync<() => PluginRuntime>(import.meta.url, {
19
+ specifier: "./api.js",
20
+ exportName: "getNostrRuntime",
21
+ })();
22
+ }
23
+
24
+ function resolveNostrAccount(params: { cfg: unknown; accountId: string }) {
25
+ return loadBundledEntryExportSync<
26
+ (params: { cfg: unknown; accountId: string }) => ResolvedNostrAccount
27
+ >(import.meta.url, {
28
+ specifier: "./api.js",
29
+ exportName: "resolveNostrAccount",
30
+ })(params);
31
+ }
32
+
33
+ export default defineBundledChannelEntry({
34
+ id: "nostr",
35
+ name: "Nostr",
36
+ description: "Nostr DM channel plugin via NIP-04",
37
+ importMetaUrl: import.meta.url,
38
+ plugin: {
39
+ specifier: "./channel-plugin-api.js",
40
+ exportName: "nostrPlugin",
41
+ },
42
+ runtime: {
43
+ specifier: "./api.js",
44
+ exportName: "setNostrRuntime",
45
+ },
46
+ registerFull(api) {
47
+ const httpHandler = createNostrProfileHttpHandler()({
48
+ getConfigProfile: (accountId: string) => {
49
+ const runtime = getNostrRuntime();
50
+ const cfg = runtime.config.current() as ACTAgentConfig;
51
+ const account = resolveNostrAccount({ cfg, accountId });
52
+ return account.profile;
53
+ },
54
+ updateConfigProfile: async (_accountId: string, profile: unknown) => {
55
+ const runtime = getNostrRuntime();
56
+
57
+ await runtime.config.mutateConfigFile({
58
+ afterWrite: { mode: "auto" },
59
+ mutate: (draft) => {
60
+ const channels = (draft.channels ?? {}) as Record<string, unknown>;
61
+ const nostrConfig = (channels.nostr ?? {}) as Record<string, unknown>;
62
+
63
+ draft.channels = {
64
+ ...channels,
65
+ nostr: {
66
+ ...nostrConfig,
67
+ profile,
68
+ },
69
+ };
70
+ },
71
+ });
72
+ },
73
+ getAccountInfo: (accountId: string) => {
74
+ const runtime = getNostrRuntime();
75
+ const cfg = runtime.config.current() as ACTAgentConfig;
76
+ const account = resolveNostrAccount({ cfg, accountId });
77
+ if (!account.configured || !account.publicKey) {
78
+ return null;
79
+ }
80
+ return {
81
+ pubkey: account.publicKey,
82
+ relays: account.relays,
83
+ };
84
+ },
85
+ log: api.logger,
86
+ });
87
+
88
+ api.registerHttpRoute({
89
+ path: "/api/channels/nostr",
90
+ auth: "gateway",
91
+ match: "prefix",
92
+ gatewayRuntimeScopeSurface: "trusted-operator",
93
+ handler: httpHandler,
94
+ });
95
+ },
96
+ });