@enbox/agent 0.2.1 → 0.3.0
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.
- package/dist/browser.mjs +9 -9
- package/dist/browser.mjs.map +4 -4
- package/dist/esm/agent-did-resolver-cache.js.map +1 -1
- package/dist/esm/anonymous-dwn-api.js +1 -1
- package/dist/esm/bearer-identity.js +1 -1
- package/dist/esm/connect.js +3 -3
- package/dist/esm/connect.js.map +1 -1
- package/dist/esm/did-api.js +3 -3
- package/dist/esm/did-api.js.map +1 -1
- package/dist/esm/dwn-api.js +150 -10
- package/dist/esm/dwn-api.js.map +1 -1
- package/dist/esm/dwn-discovery-file.js +244 -0
- package/dist/esm/dwn-discovery-file.js.map +1 -0
- package/dist/esm/dwn-discovery-payload.js +253 -0
- package/dist/esm/dwn-discovery-payload.js.map +1 -0
- package/dist/esm/dwn-encryption.js.map +1 -1
- package/dist/esm/dwn-key-delivery.js +6 -5
- package/dist/esm/dwn-key-delivery.js.map +1 -1
- package/dist/esm/dwn-protocol-cache.js +6 -7
- package/dist/esm/dwn-protocol-cache.js.map +1 -1
- package/dist/esm/dwn-record-upgrade.js.map +1 -1
- package/dist/esm/{web5-user-agent.js → enbox-user-agent.js} +18 -9
- package/dist/esm/enbox-user-agent.js.map +1 -0
- package/dist/esm/identity-api.js +4 -5
- package/dist/esm/identity-api.js.map +1 -1
- package/dist/esm/index.js +4 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/local-dwn.js +197 -0
- package/dist/esm/local-dwn.js.map +1 -0
- package/dist/esm/local-key-manager.js +2 -2
- package/dist/esm/local-key-manager.js.map +1 -1
- package/dist/esm/oidc.js +11 -11
- package/dist/esm/oidc.js.map +1 -1
- package/dist/esm/permissions-api.js.map +1 -1
- package/dist/esm/store-data.js.map +1 -1
- package/dist/esm/sync-api.js +2 -2
- package/dist/esm/sync-api.js.map +1 -1
- package/dist/esm/sync-engine-level.js +3 -4
- package/dist/esm/sync-engine-level.js.map +1 -1
- package/dist/esm/test-harness.js +5 -4
- package/dist/esm/test-harness.js.map +1 -1
- package/dist/esm/utils-internal.js +2 -2
- package/dist/types/agent-did-resolver-cache.d.ts +7 -7
- package/dist/types/agent-did-resolver-cache.d.ts.map +1 -1
- package/dist/types/anonymous-dwn-api.d.ts +3 -3
- package/dist/types/anonymous-dwn-api.d.ts.map +1 -1
- package/dist/types/bearer-identity.d.ts +1 -1
- package/dist/types/connect.d.ts +8 -8
- package/dist/types/connect.d.ts.map +1 -1
- package/dist/types/did-api.d.ts +12 -11
- package/dist/types/did-api.d.ts.map +1 -1
- package/dist/types/dwn-api.d.ts +58 -11
- package/dist/types/dwn-api.d.ts.map +1 -1
- package/dist/types/dwn-discovery-file.d.ts +122 -0
- package/dist/types/dwn-discovery-file.d.ts.map +1 -0
- package/dist/types/dwn-discovery-payload.d.ts +105 -0
- package/dist/types/dwn-discovery-payload.d.ts.map +1 -0
- package/dist/types/dwn-encryption.d.ts +8 -8
- package/dist/types/dwn-encryption.d.ts.map +1 -1
- package/dist/types/dwn-key-delivery.d.ts +9 -7
- package/dist/types/dwn-key-delivery.d.ts.map +1 -1
- package/dist/types/dwn-protocol-cache.d.ts +6 -5
- package/dist/types/dwn-protocol-cache.d.ts.map +1 -1
- package/dist/types/dwn-record-upgrade.d.ts +2 -2
- package/dist/types/dwn-record-upgrade.d.ts.map +1 -1
- package/dist/types/{web5-user-agent.d.ts → enbox-user-agent.d.ts} +21 -13
- package/dist/types/enbox-user-agent.d.ts.map +1 -0
- package/dist/types/identity-api.d.ts +10 -10
- package/dist/types/identity-api.d.ts.map +1 -1
- package/dist/types/index.d.ts +4 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/local-dwn.d.ts +121 -0
- package/dist/types/local-dwn.d.ts.map +1 -0
- package/dist/types/local-key-manager.d.ts +9 -9
- package/dist/types/local-key-manager.d.ts.map +1 -1
- package/dist/types/oidc.d.ts +23 -19
- package/dist/types/oidc.d.ts.map +1 -1
- package/dist/types/permissions-api.d.ts +4 -4
- package/dist/types/permissions-api.d.ts.map +1 -1
- package/dist/types/store-data.d.ts +3 -3
- package/dist/types/store-data.d.ts.map +1 -1
- package/dist/types/store-did.d.ts +2 -2
- package/dist/types/store-did.d.ts.map +1 -1
- package/dist/types/store-identity.d.ts +2 -2
- package/dist/types/store-identity.d.ts.map +1 -1
- package/dist/types/store-key.d.ts +2 -2
- package/dist/types/store-key.d.ts.map +1 -1
- package/dist/types/sync-api.d.ts +9 -9
- package/dist/types/sync-api.d.ts.map +1 -1
- package/dist/types/sync-engine-level.d.ts +9 -9
- package/dist/types/sync-engine-level.d.ts.map +1 -1
- package/dist/types/sync-messages.d.ts +5 -5
- package/dist/types/sync-messages.d.ts.map +1 -1
- package/dist/types/test-harness.d.ts +4 -4
- package/dist/types/test-harness.d.ts.map +1 -1
- package/dist/types/types/agent.d.ts +24 -19
- package/dist/types/types/agent.d.ts.map +1 -1
- package/dist/types/types/identity.d.ts +1 -1
- package/dist/types/types/key-manager.d.ts +2 -2
- package/dist/types/types/key-manager.d.ts.map +1 -1
- package/dist/types/types/sync.d.ts +2 -2
- package/dist/types/types/sync.d.ts.map +1 -1
- package/dist/types/utils-internal.d.ts +4 -4
- package/dist/types/utils-internal.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/agent-did-resolver-cache.ts +8 -8
- package/src/anonymous-dwn-api.ts +4 -4
- package/src/bearer-identity.ts +1 -1
- package/src/connect.ts +12 -12
- package/src/did-api.ts +13 -11
- package/src/dwn-api.ts +196 -16
- package/src/dwn-discovery-file.ts +305 -0
- package/src/dwn-discovery-payload.ts +308 -0
- package/src/dwn-encryption.ts +8 -8
- package/src/dwn-key-delivery.ts +11 -8
- package/src/dwn-protocol-cache.ts +9 -8
- package/src/dwn-record-upgrade.ts +2 -2
- package/src/{web5-user-agent.ts → enbox-user-agent.ts} +39 -19
- package/src/identity-api.ts +12 -13
- package/src/index.ts +4 -1
- package/src/local-dwn.ts +207 -0
- package/src/local-key-manager.ts +10 -10
- package/src/oidc.ts +40 -30
- package/src/permissions-api.ts +5 -5
- package/src/store-data.ts +7 -7
- package/src/store-did.ts +2 -2
- package/src/store-identity.ts +2 -2
- package/src/store-key.ts +2 -2
- package/src/sync-api.ts +10 -10
- package/src/sync-engine-level.ts +13 -14
- package/src/sync-messages.ts +5 -5
- package/src/test-harness.ts +11 -10
- package/src/types/agent.ts +31 -20
- package/src/types/identity.ts +1 -1
- package/src/types/key-manager.ts +2 -2
- package/src/types/sync.ts +2 -2
- package/src/utils-internal.ts +4 -4
- package/dist/esm/web5-user-agent.js.map +0 -1
- package/dist/types/web5-user-agent.d.ts.map +0 -1
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-based local DWN discovery for CLI and native apps.
|
|
3
|
+
*
|
|
4
|
+
* When `electrobun-dwn` (or any local DWN server) starts, it writes a
|
|
5
|
+
* well-known file (`~/.enbox/dwn.json`) containing the DWN endpoint URL
|
|
6
|
+
* and the server PID. CLI tools and native apps read this file to discover
|
|
7
|
+
* the local DWN without port probing.
|
|
8
|
+
*
|
|
9
|
+
* The filesystem operations are abstracted behind {@link DiscoveryFileFs}
|
|
10
|
+
* so the module can be tested without touching the real filesystem, and
|
|
11
|
+
* adapted to runtimes that provide different I/O primitives.
|
|
12
|
+
*
|
|
13
|
+
* @see https://github.com/enboxorg/enbox/issues/587
|
|
14
|
+
* @module
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { normalizeBaseUrl } from './local-dwn.js';
|
|
18
|
+
|
|
19
|
+
// ─── Types ────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The JSON shape persisted in the discovery file.
|
|
23
|
+
*
|
|
24
|
+
* @see https://identity.foundation/dwn-transport/#discovery-file
|
|
25
|
+
*/
|
|
26
|
+
export interface DwnDiscoveryRecord {
|
|
27
|
+
/** Base URL of the running DWN server (e.g. `"http://127.0.0.1:55500"`). */
|
|
28
|
+
endpoint: string;
|
|
29
|
+
/** OS process ID of the DWN server. Used for liveness checking. */
|
|
30
|
+
pid: number;
|
|
31
|
+
/**
|
|
32
|
+
* Transport capabilities advertised by the server (e.g. `["http", "ws"]`).
|
|
33
|
+
* Optional per the DWN Transport Spec.
|
|
34
|
+
*/
|
|
35
|
+
capabilities?: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Minimal filesystem interface required by {@link DwnDiscoveryFile}.
|
|
40
|
+
*
|
|
41
|
+
* Consumers can provide a custom implementation for testing or for
|
|
42
|
+
* runtimes that do not expose Node-compatible `fs` and `os` modules.
|
|
43
|
+
*/
|
|
44
|
+
export interface DiscoveryFileFs {
|
|
45
|
+
/** Read the file at `path` and return its UTF-8 contents, or `null` if not found. */
|
|
46
|
+
readFile(path: string): Promise<string | null>;
|
|
47
|
+
/** Write `contents` to the file at `path`, creating parent directories as needed. */
|
|
48
|
+
writeFile(path: string, contents: string): Promise<void>;
|
|
49
|
+
/** Delete the file at `path`. Must not throw if the file does not exist. */
|
|
50
|
+
removeFile(path: string): Promise<void>;
|
|
51
|
+
/** Return `true` if the process with the given PID is alive. */
|
|
52
|
+
isProcessAlive(pid: number): boolean;
|
|
53
|
+
/** Return the user's home directory (e.g. `/home/alice`). */
|
|
54
|
+
homedir(): string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Default Node/Bun filesystem implementation ──────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Creates a {@link DiscoveryFileFs} backed by Node.js / Bun built-in
|
|
61
|
+
* modules. Returns `undefined` in environments where `node:fs/promises`
|
|
62
|
+
* or `node:os` are not available (e.g. browsers).
|
|
63
|
+
*/
|
|
64
|
+
export function createNodeDiscoveryFileFs(): DiscoveryFileFs | undefined {
|
|
65
|
+
try {
|
|
66
|
+
// Dynamic require avoids hard dependency on Node built-ins so bundlers
|
|
67
|
+
// can tree-shake this path away in browser builds.
|
|
68
|
+
const nodeRequire = require;
|
|
69
|
+
const fs = nodeRequire('node:fs/promises') as {
|
|
70
|
+
readFile(path: string, encoding: string): Promise<string>;
|
|
71
|
+
writeFile(path: string, data: string, options: { encoding: string; mode?: number }): Promise<void>;
|
|
72
|
+
mkdir(path: string, options: { recursive: boolean }): Promise<string | undefined>;
|
|
73
|
+
unlink(path: string): Promise<void>;
|
|
74
|
+
};
|
|
75
|
+
const path = nodeRequire('node:path') as {
|
|
76
|
+
join(...segments: string[]): string;
|
|
77
|
+
dirname(path: string): string;
|
|
78
|
+
};
|
|
79
|
+
const os = nodeRequire('node:os') as {
|
|
80
|
+
homedir(): string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
async readFile(filePath: string): Promise<string | null> {
|
|
85
|
+
try {
|
|
86
|
+
return await fs.readFile(filePath, 'utf-8');
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
async writeFile(filePath: string, contents: string): Promise<void> {
|
|
93
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
94
|
+
// mode 0o600: owner read/write only — the file contains the PID
|
|
95
|
+
// and endpoint of a local DWN server.
|
|
96
|
+
await fs.writeFile(filePath, contents, { encoding: 'utf-8', mode: 0o600 });
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async removeFile(filePath: string): Promise<void> {
|
|
100
|
+
try {
|
|
101
|
+
await fs.unlink(filePath);
|
|
102
|
+
} catch {
|
|
103
|
+
// Ignore ENOENT — the file was already gone.
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
isProcessAlive(pid: number): boolean {
|
|
108
|
+
try {
|
|
109
|
+
process.kill(pid, 0);
|
|
110
|
+
return true;
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
homedir(): string {
|
|
117
|
+
return os.homedir();
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
} catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Constants ────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Directory under the user's home where the discovery file lives.
|
|
129
|
+
* Shared with the `electrobun-dwn` app and other Enbox tooling.
|
|
130
|
+
*/
|
|
131
|
+
export const DISCOVERY_DIR = '.enbox';
|
|
132
|
+
|
|
133
|
+
/** Filename of the discovery file. */
|
|
134
|
+
export const DISCOVERY_FILENAME = 'dwn.json';
|
|
135
|
+
|
|
136
|
+
// ─── DwnDiscoveryFile ────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Reads, writes, and validates the `~/.enbox/dwn.json` discovery file.
|
|
140
|
+
*
|
|
141
|
+
* This is the **file-based discovery channel** for CLI and native apps.
|
|
142
|
+
* It is complementary to the `dwn://register` browser redirect flow.
|
|
143
|
+
*
|
|
144
|
+
* @example Reading the discovery file
|
|
145
|
+
* ```ts
|
|
146
|
+
* const discoveryFile = new DwnDiscoveryFile();
|
|
147
|
+
* const record = await discoveryFile.read();
|
|
148
|
+
*
|
|
149
|
+
* if (record) {
|
|
150
|
+
* console.log(`Local DWN at ${record.endpoint}`);
|
|
151
|
+
* }
|
|
152
|
+
* ```
|
|
153
|
+
*
|
|
154
|
+
* @example Writing the discovery file (from electrobun-dwn)
|
|
155
|
+
* ```ts
|
|
156
|
+
* const discoveryFile = new DwnDiscoveryFile();
|
|
157
|
+
* await discoveryFile.write({
|
|
158
|
+
* endpoint : 'http://127.0.0.1:55557',
|
|
159
|
+
* pid : process.pid,
|
|
160
|
+
* });
|
|
161
|
+
* ```
|
|
162
|
+
*/
|
|
163
|
+
export class DwnDiscoveryFile {
|
|
164
|
+
private readonly _fs: DiscoveryFileFs;
|
|
165
|
+
private readonly _filePath: string;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param fs - Filesystem adapter. Defaults to Node/Bun built-ins.
|
|
169
|
+
* @param filePath - Override the discovery file path (mainly for testing).
|
|
170
|
+
* @throws If no filesystem adapter is available (e.g. in a browser).
|
|
171
|
+
*/
|
|
172
|
+
constructor(fs?: DiscoveryFileFs, filePath?: string) {
|
|
173
|
+
const resolvedFs = fs ?? createNodeDiscoveryFileFs();
|
|
174
|
+
if (!resolvedFs) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
'DwnDiscoveryFile: No filesystem adapter available. ' +
|
|
177
|
+
'Provide a DiscoveryFileFs implementation or run in Node.js / Bun.'
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
this._fs = resolvedFs;
|
|
181
|
+
|
|
182
|
+
if (filePath) {
|
|
183
|
+
this._filePath = filePath;
|
|
184
|
+
} else {
|
|
185
|
+
// Dynamic require so we can resolve the path at construction time.
|
|
186
|
+
const nodeRequire = require;
|
|
187
|
+
const path = nodeRequire('node:path') as { join(...segments: string[]): string };
|
|
188
|
+
this._filePath = path.join(resolvedFs.homedir(), DISCOVERY_DIR, DISCOVERY_FILENAME);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The absolute path of the discovery file. */
|
|
193
|
+
public get path(): string {
|
|
194
|
+
return this._filePath;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Read and validate the discovery file.
|
|
199
|
+
*
|
|
200
|
+
* Returns the parsed {@link DwnDiscoveryRecord} if:
|
|
201
|
+
* 1. The file exists and contains valid JSON.
|
|
202
|
+
* 2. The `endpoint` is a non-empty string.
|
|
203
|
+
* 3. The `pid` is a positive integer whose process is still alive.
|
|
204
|
+
*
|
|
205
|
+
* Returns `undefined` in all other cases (missing file, parse error,
|
|
206
|
+
* stale PID). Stale files are automatically removed.
|
|
207
|
+
*/
|
|
208
|
+
public async read(): Promise<DwnDiscoveryRecord | undefined> {
|
|
209
|
+
const raw = await this._fs.readFile(this._filePath);
|
|
210
|
+
if (raw === null) {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let parsed: unknown;
|
|
215
|
+
try {
|
|
216
|
+
parsed = JSON.parse(raw);
|
|
217
|
+
} catch {
|
|
218
|
+
// Corrupted file — remove it.
|
|
219
|
+
await this._fs.removeFile(this._filePath);
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!isValidRecord(parsed)) {
|
|
224
|
+
await this._fs.removeFile(this._filePath);
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Check that the server process is still alive.
|
|
229
|
+
if (!this._fs.isProcessAlive(parsed.pid)) {
|
|
230
|
+
await this._fs.removeFile(this._filePath);
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const result: DwnDiscoveryRecord = {
|
|
235
|
+
endpoint : normalizeBaseUrl(parsed.endpoint),
|
|
236
|
+
pid : parsed.pid,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
if (parsed.capabilities !== undefined) {
|
|
240
|
+
result.capabilities = parsed.capabilities;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return result;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Write the discovery file. Creates the `~/.enbox/` directory if needed.
|
|
248
|
+
*
|
|
249
|
+
* @param record - The endpoint and PID to persist.
|
|
250
|
+
*/
|
|
251
|
+
public async write(record: DwnDiscoveryRecord): Promise<void> {
|
|
252
|
+
const serialized: Record<string, unknown> = {
|
|
253
|
+
endpoint : normalizeBaseUrl(record.endpoint),
|
|
254
|
+
pid : record.pid,
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
if (record.capabilities !== undefined && record.capabilities.length > 0) {
|
|
258
|
+
serialized.capabilities = record.capabilities;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const json = JSON.stringify(serialized, null, 2);
|
|
262
|
+
await this._fs.writeFile(this._filePath, json);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Remove the discovery file. Does not throw if the file is already gone.
|
|
267
|
+
*/
|
|
268
|
+
public async remove(): Promise<void> {
|
|
269
|
+
await this._fs.removeFile(this._filePath);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ─── Internal helpers ─────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
/** Type guard for a valid {@link DwnDiscoveryRecord}. */
|
|
276
|
+
function isValidRecord(value: unknown): value is DwnDiscoveryRecord {
|
|
277
|
+
if (typeof value !== 'object' || value === null) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const record = value as Record<string, unknown>;
|
|
282
|
+
|
|
283
|
+
if (typeof record.endpoint !== 'string' || record.endpoint.length === 0) {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (typeof record.pid !== 'number' || !Number.isInteger(record.pid) || record.pid <= 0) {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// `capabilities` is optional, but when present must be a string array.
|
|
292
|
+
if (record.capabilities !== undefined) {
|
|
293
|
+
if (!Array.isArray(record.capabilities)) {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!record.capabilities.every((item: unknown) => typeof item === 'string')) {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types and utilities for the `dwn://register` discovery protocol.
|
|
3
|
+
*
|
|
4
|
+
* The payload is the JSON data exchanged between the local DWN server
|
|
5
|
+
* (electrobun-dwn) and the requesting app during the `dwn://register`
|
|
6
|
+
* redirect flow. It is encoded as base64url and placed in the URL
|
|
7
|
+
* fragment (`#`) of the callback URL.
|
|
8
|
+
*
|
|
9
|
+
* This module intentionally avoids external dependencies so it can be
|
|
10
|
+
* consumed from any environment (Bun, browser, Electrobun) without
|
|
11
|
+
* triggering transitive dependency resolution issues.
|
|
12
|
+
*
|
|
13
|
+
* @see https://github.com/enboxorg/enbox/issues/586
|
|
14
|
+
* @module
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// ─── Types ────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The JSON payload delivered via the URL fragment in a `dwn://register`
|
|
21
|
+
* callback redirect.
|
|
22
|
+
*
|
|
23
|
+
* Intentionally minimal — everything beyond the endpoint (version,
|
|
24
|
+
* WebSocket support, etc.) is obtained from `GET {endpoint}/info`.
|
|
25
|
+
*/
|
|
26
|
+
export type DwnDiscoveryPayload = {
|
|
27
|
+
/** Base URL of the running DWN server (e.g. `"http://127.0.0.1:55557"`). */
|
|
28
|
+
endpoint: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parsed result from a `dwn://register` URL.
|
|
33
|
+
*/
|
|
34
|
+
export type DwnRegisterUrlParams = {
|
|
35
|
+
/** The callback URL to redirect to with the discovery payload. */
|
|
36
|
+
callback: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// ─── Constants ────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/** The URL scheme for DWN discovery protocol handlers. */
|
|
42
|
+
export const DWN_PROTOCOL_SCHEME = 'dwn';
|
|
43
|
+
|
|
44
|
+
/** The `dwn://register` path that triggers the discovery handshake. */
|
|
45
|
+
export const DWN_REGISTER_PATH = 'register';
|
|
46
|
+
|
|
47
|
+
// ─── Register URL construction ───────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build a `dwn://register?callback=<url>` URL that, when opened by the OS,
|
|
51
|
+
* triggers electrobun-dwn (or another `dwn://` scheme handler) to redirect
|
|
52
|
+
* back to `callbackUrl` with the local DWN endpoint in the URL fragment.
|
|
53
|
+
*
|
|
54
|
+
* This is the **trigger** side of the `dwn://register` browser flow.
|
|
55
|
+
* The web app opens this URL (e.g. via `window.open()` or `location.href`),
|
|
56
|
+
* the OS routes it to the registered handler, and the handler redirects
|
|
57
|
+
* back with the discovery payload.
|
|
58
|
+
*
|
|
59
|
+
* @param callbackUrl - The URL to redirect back to after discovery.
|
|
60
|
+
* This should be the current page (or a dedicated callback page) that
|
|
61
|
+
* will read the payload from `window.location.hash`.
|
|
62
|
+
* @returns The `dwn://register?callback=<encoded-url>` URL string.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```ts
|
|
66
|
+
* const registerUrl = buildDwnRegisterUrl('https://myapp.com/callback');
|
|
67
|
+
* // => 'dwn://register?callback=https%3A%2F%2Fmyapp.com%2Fcallback'
|
|
68
|
+
* window.open(registerUrl);
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export function buildDwnRegisterUrl(callbackUrl: string): string {
|
|
72
|
+
return `${DWN_PROTOCOL_SCHEME}://${DWN_REGISTER_PATH}?callback=${encodeURIComponent(callbackUrl)}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Payload encoding/decoding ───────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Encode a {@link DwnDiscoveryPayload} as a base64url string suitable
|
|
79
|
+
* for use in a URL fragment.
|
|
80
|
+
*
|
|
81
|
+
* @param payload - The discovery payload to encode.
|
|
82
|
+
* @returns A base64url-encoded string (no padding).
|
|
83
|
+
*/
|
|
84
|
+
export function encodeDwnDiscoveryPayload(payload: DwnDiscoveryPayload): string {
|
|
85
|
+
const json = JSON.stringify(payload);
|
|
86
|
+
return stringToBase64Url(json);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Decode a base64url-encoded string back into a {@link DwnDiscoveryPayload}.
|
|
91
|
+
*
|
|
92
|
+
* @param encoded - The base64url string from the URL fragment.
|
|
93
|
+
* @returns The parsed payload, or `undefined` if decoding or parsing fails.
|
|
94
|
+
*/
|
|
95
|
+
export function decodeDwnDiscoveryPayload(encoded: string): DwnDiscoveryPayload | undefined {
|
|
96
|
+
try {
|
|
97
|
+
const json = base64UrlToString(encoded);
|
|
98
|
+
const parsed: unknown = JSON.parse(json);
|
|
99
|
+
|
|
100
|
+
if (!isValidPayload(parsed)) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return parsed;
|
|
105
|
+
} catch {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── URL parsing ─────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Parse a `dwn://register?callback=<url>` URL into its components.
|
|
114
|
+
*
|
|
115
|
+
* @param url - The full `dwn://register?callback=...` URL.
|
|
116
|
+
* @returns The parsed parameters, or `undefined` if the URL is not a
|
|
117
|
+
* valid `dwn://register` URL or is missing the `callback` parameter.
|
|
118
|
+
*/
|
|
119
|
+
export function parseDwnRegisterUrl(url: string): DwnRegisterUrlParams | undefined {
|
|
120
|
+
try {
|
|
121
|
+
// dwn://register?callback=... is not a standard hierarchical URL, so
|
|
122
|
+
// we parse it manually to avoid URL constructor quirks with custom schemes.
|
|
123
|
+
const schemePrefix = `${DWN_PROTOCOL_SCHEME}://`;
|
|
124
|
+
if (!url.startsWith(schemePrefix)) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const withoutScheme = url.slice(schemePrefix.length);
|
|
129
|
+
|
|
130
|
+
// Split on '?' to separate the path from query parameters.
|
|
131
|
+
const questionIndex = withoutScheme.indexOf('?');
|
|
132
|
+
if (questionIndex === -1) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const path = withoutScheme.slice(0, questionIndex);
|
|
137
|
+
if (path !== DWN_REGISTER_PATH) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const queryString = withoutScheme.slice(questionIndex + 1);
|
|
142
|
+
const params = new URLSearchParams(queryString);
|
|
143
|
+
const callback = params.get('callback');
|
|
144
|
+
|
|
145
|
+
if (!callback || callback.length === 0) {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { callback };
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Build the full callback redirect URL with the discovery payload
|
|
157
|
+
* encoded in the URL fragment.
|
|
158
|
+
*
|
|
159
|
+
* @param callbackUrl - The callback URL from the `dwn://register` request.
|
|
160
|
+
* @param payload - The discovery payload to encode in the fragment.
|
|
161
|
+
* @returns The full redirect URL (e.g. `https://notes.sh/dwn#eyJ...`).
|
|
162
|
+
*/
|
|
163
|
+
export function buildDwnDiscoveryRedirectUrl(
|
|
164
|
+
callbackUrl: string,
|
|
165
|
+
payload: DwnDiscoveryPayload,
|
|
166
|
+
): string {
|
|
167
|
+
const encoded = encodeDwnDiscoveryPayload(payload);
|
|
168
|
+
// Strip any existing fragment from the callback URL before appending ours.
|
|
169
|
+
const base = callbackUrl.split('#')[0];
|
|
170
|
+
return `${base}#${encoded}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Read a {@link DwnDiscoveryPayload} from a URL's fragment (hash).
|
|
175
|
+
*
|
|
176
|
+
* Intended for use in the browser callback page that receives the
|
|
177
|
+
* redirect from electrobun-dwn.
|
|
178
|
+
*
|
|
179
|
+
* @param url - The full URL including the `#` fragment, or just the
|
|
180
|
+
* fragment string (with or without the leading `#`).
|
|
181
|
+
* @returns The parsed payload, or `undefined` if the fragment is missing
|
|
182
|
+
* or contains an invalid payload.
|
|
183
|
+
*/
|
|
184
|
+
export function readDwnDiscoveryPayloadFromUrl(url: string): DwnDiscoveryPayload | undefined {
|
|
185
|
+
const hashIndex = url.indexOf('#');
|
|
186
|
+
if (hashIndex === -1) {
|
|
187
|
+
// Maybe it's just the fragment without the leading #.
|
|
188
|
+
return decodeDwnDiscoveryPayload(url);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const fragment = url.slice(hashIndex + 1);
|
|
192
|
+
if (fragment.length === 0) {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return decodeDwnDiscoveryPayload(fragment);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Internal helpers ─────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Type guard for a valid {@link DwnDiscoveryPayload}.
|
|
203
|
+
*
|
|
204
|
+
* The endpoint MUST point to a loopback address (`127.0.0.1`, `[::1]`,
|
|
205
|
+
* or `localhost`) because the `dwn://register` payload is only intended
|
|
206
|
+
* for local DWN discovery. Accepting arbitrary hostnames would allow a
|
|
207
|
+
* malicious payload to redirect agent traffic to a remote server.
|
|
208
|
+
*
|
|
209
|
+
* @see https://github.com/enboxorg/enbox/issues/633
|
|
210
|
+
*/
|
|
211
|
+
function isValidPayload(value: unknown): value is DwnDiscoveryPayload {
|
|
212
|
+
if (typeof value !== 'object' || value === null) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const record = value as Record<string, unknown>;
|
|
217
|
+
|
|
218
|
+
if (typeof record.endpoint !== 'string' || record.endpoint.length === 0) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (!isLoopbackEndpoint(record.endpoint)) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Returns `true` if the endpoint URL's hostname resolves to a loopback
|
|
231
|
+
* address. Accepts `127.0.0.1`, `::1` (with or without brackets), and
|
|
232
|
+
* `localhost` (bare or with any subdomain suffix, per RFC 6761 §6.3).
|
|
233
|
+
*
|
|
234
|
+
* This is a security boundary: the `dwn://register` redirect flow MUST
|
|
235
|
+
* NOT allow payloads that point to non-local servers.
|
|
236
|
+
*/
|
|
237
|
+
function isLoopbackEndpoint(endpoint: string): boolean {
|
|
238
|
+
try {
|
|
239
|
+
const url = new URL(endpoint);
|
|
240
|
+
const hostname = url.hostname.toLowerCase();
|
|
241
|
+
|
|
242
|
+
// IPv4 loopback: 127.0.0.1
|
|
243
|
+
if (hostname === '127.0.0.1') {
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// IPv6 loopback: [::1] — URL.hostname strips the brackets.
|
|
248
|
+
if (hostname === '::1' || hostname === '[::1]') {
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// `localhost` or any subdomain (e.g. `foo.localhost`).
|
|
253
|
+
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return false;
|
|
258
|
+
} catch {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Encode a UTF-8 string as unpadded base64url (RFC 4648 §5).
|
|
265
|
+
*
|
|
266
|
+
* Uses `TextEncoder` to correctly handle all Unicode code points
|
|
267
|
+
* (including those above U+00FF that `btoa` cannot represent).
|
|
268
|
+
*
|
|
269
|
+
* This module MUST remain dependency-free — no imports from
|
|
270
|
+
* `@enbox/common` or any other package.
|
|
271
|
+
*/
|
|
272
|
+
function stringToBase64Url(input: string): string {
|
|
273
|
+
const bytes = new TextEncoder().encode(input);
|
|
274
|
+
// Build a binary string from the UTF-8 byte array so `btoa` can
|
|
275
|
+
// consume it (btoa only accepts Latin-1 / code points ≤ U+00FF).
|
|
276
|
+
let binary = '';
|
|
277
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
278
|
+
binary += String.fromCharCode(bytes[i]);
|
|
279
|
+
}
|
|
280
|
+
return btoa(binary)
|
|
281
|
+
.replace(/\+/g, '-')
|
|
282
|
+
.replace(/\//g, '_')
|
|
283
|
+
.replace(/=+$/, '');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Decode an unpadded base64url string back to a UTF-8 string.
|
|
288
|
+
*
|
|
289
|
+
* Re-adds padding and swaps base64url characters back to standard
|
|
290
|
+
* base64 before calling `atob`, then feeds the resulting bytes through
|
|
291
|
+
* `TextDecoder` for correct UTF-8 handling.
|
|
292
|
+
*/
|
|
293
|
+
function base64UrlToString(input: string): string {
|
|
294
|
+
// Restore standard base64 alphabet and padding.
|
|
295
|
+
let base64 = input.replace(/-/g, '+').replace(/_/g, '/');
|
|
296
|
+
const remainder = base64.length % 4;
|
|
297
|
+
if (remainder === 2) {
|
|
298
|
+
base64 += '==';
|
|
299
|
+
} else if (remainder === 3) {
|
|
300
|
+
base64 += '=';
|
|
301
|
+
}
|
|
302
|
+
const binary = atob(base64);
|
|
303
|
+
const bytes = new Uint8Array(binary.length);
|
|
304
|
+
for (let i = 0; i < binary.length; i++) {
|
|
305
|
+
bytes[i] = binary.charCodeAt(i);
|
|
306
|
+
}
|
|
307
|
+
return new TextDecoder().decode(bytes);
|
|
308
|
+
}
|
package/src/dwn-encryption.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type {
|
|
|
9
9
|
} from '@enbox/dwn-sdk-js';
|
|
10
10
|
import type { KeyIdentifier, PublicKeyJwk } from '@enbox/crypto';
|
|
11
11
|
|
|
12
|
-
import type {
|
|
12
|
+
import type { EnboxPlatformAgent } from './types/agent.js';
|
|
13
13
|
import type {
|
|
14
14
|
DwnMessageReply,
|
|
15
15
|
ProcessDwnRequest,
|
|
@@ -93,7 +93,7 @@ export async function encryptAndComputeCid(
|
|
|
93
93
|
* @throws If the DID has no keyAgreement verification method or it's not X25519.
|
|
94
94
|
*/
|
|
95
95
|
export async function getEncryptionKeyInfo(
|
|
96
|
-
agent:
|
|
96
|
+
agent: EnboxPlatformAgent,
|
|
97
97
|
didUri: string,
|
|
98
98
|
): Promise<{
|
|
99
99
|
keyId: string;
|
|
@@ -172,7 +172,7 @@ export async function getEncryptionKeyInfo(
|
|
|
172
172
|
* @param iv - Initialization vector
|
|
173
173
|
*/
|
|
174
174
|
export async function deriveContextEncryptionInput(
|
|
175
|
-
agent:
|
|
175
|
+
agent: EnboxPlatformAgent,
|
|
176
176
|
didUri: string,
|
|
177
177
|
contextId: string,
|
|
178
178
|
dek: Uint8Array,
|
|
@@ -208,7 +208,7 @@ export async function deriveContextEncryptionInput(
|
|
|
208
208
|
* @param derivationScheme - The key derivation scheme
|
|
209
209
|
*/
|
|
210
210
|
export function buildKmsDecryptCallback(
|
|
211
|
-
agent:
|
|
211
|
+
agent: EnboxPlatformAgent,
|
|
212
212
|
keyId: string,
|
|
213
213
|
keyUri: KeyIdentifier,
|
|
214
214
|
derivationScheme: typeof KeyDerivationScheme.ProtocolPath | typeof KeyDerivationScheme.ProtocolContext,
|
|
@@ -240,7 +240,7 @@ export function buildKmsDecryptCallback(
|
|
|
240
240
|
* @returns An EncryptionKeyDeriver callback object
|
|
241
241
|
*/
|
|
242
242
|
export async function getEncryptionKeyDeriver(
|
|
243
|
-
agent:
|
|
243
|
+
agent: EnboxPlatformAgent,
|
|
244
244
|
didUri: string,
|
|
245
245
|
): Promise<EncryptionKeyDeriver> {
|
|
246
246
|
const { keyId, keyUri } = await getEncryptionKeyInfo(agent, didUri);
|
|
@@ -266,7 +266,7 @@ export async function getEncryptionKeyDeriver(
|
|
|
266
266
|
* @returns A KeyDecrypter callback object
|
|
267
267
|
*/
|
|
268
268
|
export async function getKeyDecrypter(
|
|
269
|
-
agent:
|
|
269
|
+
agent: EnboxPlatformAgent,
|
|
270
270
|
didUri: string,
|
|
271
271
|
): Promise<KeyDecrypter> {
|
|
272
272
|
const { keyId, keyUri } = await getEncryptionKeyInfo(agent, didUri);
|
|
@@ -311,7 +311,7 @@ export function buildContextKeyDecrypter(
|
|
|
311
311
|
* @param fetchContextKeyRecordFn - Function to fetch context key records from key-delivery protocol
|
|
312
312
|
*/
|
|
313
313
|
export async function resolveKeyDecrypter(
|
|
314
|
-
agent:
|
|
314
|
+
agent: EnboxPlatformAgent,
|
|
315
315
|
authorDid: string,
|
|
316
316
|
recordsWrite: RecordsWriteMessage,
|
|
317
317
|
targetDid: string | undefined,
|
|
@@ -409,7 +409,7 @@ export async function resolveKeyDecrypter(
|
|
|
409
409
|
export async function maybeDecryptReply<T extends DwnInterface>(
|
|
410
410
|
request: ProcessDwnRequest<T> | SendDwnRequest<T>,
|
|
411
411
|
reply: DwnMessageReply[T],
|
|
412
|
-
agent:
|
|
412
|
+
agent: EnboxPlatformAgent,
|
|
413
413
|
contextDerivedKeyCache: { get(key: string): DerivedPrivateJwk | undefined; set(key: string, value: DerivedPrivateJwk): void },
|
|
414
414
|
fetchContextKeyRecordFn: (params: {
|
|
415
415
|
ownerDid: string;
|