node-app-build 6.12.2

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
//! The `harness pair-browser` in-page JS snippet.
//!
//! Implements spec D2
//! (`docs/superpowers/specs/2026-08-10-harness-browser-pairing-design.md`).
//! `render` performs no network I/O itself — it only builds the JS source
//! text that a human pastes into a browser's devtools console at the node's
//! own origin. All pairing work (key generation, the challenge/pair/session
//! round trip, the IndexedDB write) happens in the page, because the PWA's
//! device key is generated with `crypto.subtle.generateKey("Ed25519", false,
//! …)` — non-extractable — so a keypair minted in Rust could never be
//! injected into the browser.
//!
//! The derivations below MUST match, byte for byte:
//! - `client/adapters-web/src/identity/did.js:8-21` (did / multibase / nodeId)
//! - `client/adapters-web/src/identity/key-custody.js:13-28,197-200` (custody
//!   mode, `keyId`)
//! - `client/adapters-web/src/storage/identity-store.js:3-9` (the exact
//!   IndexedDB record keys)

/// Render the pairing snippet for a single harness instance.
///
/// `node_id` is the target node's LDK node id (used only to verify — never to
/// address — the page this snippet ends up running in, see the origin guard
/// below). `browser_origin` is the origin an operator should have loaded
/// before pasting (the instance's real HTTPS origin when one was allocated,
/// else its plain HTTP `base_url` — see `probes::browser_origin`); it is
/// interpolated only into human-readable error text, never used to build a
/// request URL. `device_name` is interpolated as-is (already
/// operator-controlled); `owner_token` is opaque bearer material read off
/// disk and is treated as hostile input for escaping purposes. All
/// interpolated values are escaped for a JS double-quoted string literal
/// before interpolation, so no value can break out of its string context and
/// inject script.
///
/// Deliberately carries NO absolute origin (no `BASE_URL`): every fetch below
/// is a same-origin relative URL. This snippet is, by construction, pasted
/// into devtools at the node's own origin — carrying an absolute origin only
/// invites exactly the class of bug this replaces, where a snippet generated
/// for the daemon's plain-HTTP `base_url` was pasted (correctly, per the old
/// instructions) into a page loaded over HTTPS, and every fetch was silently
/// blocked client-side by the PWA's `connect-src 'self' https: ws: wss:` CSP
/// before a single byte left the browser. Relative URLs satisfy `'self'`
/// unconditionally and work identically over HTTP or HTTPS.
pub fn render(node_id: &str, browser_origin: &str, owner_token: &str, device_name: &str) -> String {
    let node_id_js = js_string_literal(node_id);
    let browser_origin_js = js_string_literal(browser_origin);
    let owner_token_js = js_string_literal(owner_token);
    let device_name_js = js_string_literal(device_name);

    format!(
        r#"(async () => {{
  // Generated by `node-app harness pair-browser` — implements spec D2
  // (docs/superpowers/specs/2026-08-10-harness-browser-pairing-design.md).
  // This snippet embeds a LIVE owner bearer token. Do not save it, paste it
  // into a shared channel, or commit it anywhere.
  const OWNER_TOKEN = {owner_token_js};
  const DEVICE_NAME = {device_name_js};

  // 0. Origin guard. Every fetch below is a same-origin RELATIVE url — there
  //    is no absolute BASE_URL in this snippet at all — so if this page is
  //    not actually this node's own origin, the very first fetch would
  //    silently hit whatever else is listening on THIS origin instead of
  //    failing loudly. Rather than let that happen, prove same-origin
  //    identity first via a same-origin GET of the node's own well-known
  //    origin-proof endpoint (AC-0,
  //    docs/superpowers/plans/2026-08-10-harness-browser-pairing.md): what
  //    was attempted, what was observed, and the remedy.
  const EXPECTED_NODE_ID = {node_id_js};
  const EXPECTED_ORIGIN_HINT = {browser_origin_js};
  let originProof;
  try {{
    const originResponse = await fetch("/.well-known/client-node-origin");
    if (!originResponse.ok) {{
      throw new Error(`HTTP ${{originResponse.status}}`);
    }}
    originProof = (await originResponse.json()).data;
  }} catch (err) {{
    throw new Error(
      `harness pair-browser: attempted a same-origin GET /.well-known/client-node-origin ` +
      `to confirm this page (${{location.origin}}) belongs to the target node before pairing; ` +
      `observed: ${{err}}. Remedy: open the node's own origin (${{EXPECTED_ORIGIN_HINT}}) in ` +
      `this browser tab, then paste the snippet again.`,
    );
  }}
  if (!originProof || originProof.node_id !== EXPECTED_NODE_ID) {{
    throw new Error(
      `harness pair-browser: attempted a same-origin GET /.well-known/client-node-origin ` +
      `to confirm this page (${{location.origin}}) belongs to the target node before pairing; ` +
      `observed node_id "${{originProof && originProof.node_id}}", expected ` +
      `"${{EXPECTED_NODE_ID}}" — this page belongs to a DIFFERENT node. Remedy: open the ` +
      `node's own origin (${{EXPECTED_ORIGIN_HINT}}) in this browser tab, then paste the ` +
      `snippet again.`,
    );
  }}

  const BASE58_ALPHABET =
    "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";

  // Matches client/adapters-web/src/identity/did.js's base58Btc byte for byte.
  function base58btc(bytes) {{
    if (bytes.byteLength === 0) return "";
    let leadingZeros = 0;
    while (leadingZeros < bytes.byteLength && bytes[leadingZeros] === 0) {{
      leadingZeros += 1;
    }}
    if (leadingZeros === bytes.byteLength) return "1".repeat(leadingZeros);
    const digits = [0];
    for (const byte of bytes.slice(leadingZeros)) {{
      let carry = byte;
      for (let index = 0; index < digits.length; index += 1) {{
        const value = (digits[index] ?? 0) * 256 + carry;
        digits[index] = value % 58;
        carry = Math.floor(value / 58);
      }}
      while (carry > 0) {{
        digits.push(carry % 58);
        carry = Math.floor(carry / 58);
      }}
    }}
    let encoded = "1".repeat(leadingZeros);
    for (let index = digits.length - 1; index >= 0; index -= 1) {{
      encoded += BASE58_ALPHABET[digits[index] ?? 0];
    }}
    return encoded;
  }}

  function hex(bytes) {{
    return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
  }}

  function base64UrlNoPad(bytes) {{
    let binary = "";
    for (const byte of bytes) binary += String.fromCharCode(byte);
    return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
  }}

  // Every fetch is routed through here so every response is status-checked —
  // a silent partial write would leave the PWA worse off than unpaired.
  async function checkedFetch(url, init) {{
    const response = await fetch(url, init);
    if (!response.ok) {{
      const body = await response.text().catch(() => "<unreadable body>");
      throw new Error(`harness pair-browser: ${{init.method}} ${{url}} failed: ${{response.status}} ${{body}}`);
    }}
    return response.json();
  }}

  // 1. Generate the canonical non-extractable Ed25519 device key. `false` is
  //    required: it is what makes the private key non-extractable, matching
  //    client/adapters-web/src/identity/key-custody.js's
  //    generateNonExtractableKeyMaterial exactly.
  const keyPair = await crypto.subtle.generateKey({{ name: "Ed25519" }}, false, ["sign", "verify"]);
  const publicKeyBytes = new Uint8Array(await crypto.subtle.exportKey("raw", keyPair.publicKey));
  if (publicKeyBytes.byteLength !== 32) {{
    throw new Error(`harness pair-browser: unexpected Ed25519 public key length ${{publicKeyBytes.byteLength}}`);
  }}

  // 2. Derive did / publicKeyMultibase / nodeId — matches
  //    client/adapters-web/src/identity/did.js:8-21 byte for byte.
  const publicKeyMultibase = "z" + base58btc(publicKeyBytes);
  const didKeyMaterial = new Uint8Array(2 + publicKeyBytes.byteLength);
  didKeyMaterial.set([0xed, 0x01]);
  didKeyMaterial.set(publicKeyBytes, 2);
  const did = "did:key:z" + base58btc(didKeyMaterial);
  const nodeId = hex(publicKeyBytes);

  // keyId = hex of the first 16 bytes of SHA-256(publicKey), matching
  // key-custody.js:197-200's deriveKeyId.
  const publicKeyDigest = new Uint8Array(await crypto.subtle.digest("SHA-256", publicKeyBytes));
  const keyId = hex(publicKeyDigest.slice(0, 16));

  const identity = {{
    device_did: did,
    public_key_multibase: publicKeyMultibase,
    iroh_node_id: nodeId,
  }};

  async function signChallenge(nonce) {{
    const signatureBytes = new Uint8Array(
      await crypto.subtle.sign({{ name: "Ed25519" }}, keyPair.privateKey, new TextEncoder().encode(nonce)),
    );
    return base64UrlNoPad(signatureBytes);
  }}

  async function requestChallenge(purpose) {{
    const response = await checkedFetch(`/api/v2/client-devices/challenge`, {{
      method: "POST",
      headers: {{ "content-type": "application/json" }},
      body: JSON.stringify({{ identity, purpose }}),
    }});
    return response.data;
  }}

  // 3-4. Pairing challenge, sign, pair WITH the owner bearer header. The
  //      transport re-verifies that token and stamps `authorized_owner_id`,
  //      which triggers `activate_for_token_owner` server-side and
  //      self-approves the device immediately — no coordinator, no six-digit
  //      code, no second browser.
  const pairingChallenge = await requestChallenge("pairing");
  const pairingSignature = await signChallenge(pairingChallenge.nonce);
  await checkedFetch(`/api/v2/client-devices/pair`, {{
    method: "POST",
    headers: {{ "content-type": "application/json", authorization: `Bearer ${{OWNER_TOKEN}}` }},
    body: JSON.stringify({{
      challenge_id: pairingChallenge.challenge_id,
      signature: pairingSignature,
      identity,
      device_name: DEVICE_NAME,
      device_type: "pwa",
    }}),
  }});

  // 5. Repeat the challenge with purpose "session"; sign; establish session.
  const sessionChallenge = await requestChallenge("session");
  const sessionSignature = await signChallenge(sessionChallenge.nonce);
  const session = await checkedFetch(`/api/v2/client-devices/session`, {{
    method: "POST",
    headers: {{ "content-type": "application/json" }},
    body: JSON.stringify({{
      challenge_id: sessionChallenge.challenge_id,
      signature: sessionSignature,
      identity,
      device_name: DEVICE_NAME,
      device_type: "pwa",
    }}),
  }});
  const established = session.data;

  // 6. Write IndexedDB `node-client-identity` / store `identity` — the three
  //    explicit keys the boot gate reads
  //    (client/domain/src/services/device-boot-state-service.js:5-45), in the
  //    exact shape client/adapters-web/src/storage/identity-store.js writes.
  const db = await new Promise((resolve, reject) => {{
    const request = indexedDB.open("node-client-identity", 1);
    request.addEventListener("upgradeneeded", () => {{
      if (!request.result.objectStoreNames.contains("identity")) {{
        request.result.createObjectStore("identity");
      }}
    }});
    request.addEventListener("success", () => resolve(request.result));
    request.addEventListener("error", () => reject(new Error("harness pair-browser: failed to open node-client-identity")));
  }});
  try {{
    await new Promise((resolve, reject) => {{
      const tx = db.transaction("identity", "readwrite");
      const store = tx.objectStore("identity");
      store.put(
        {{
          createdAt: new Date().toISOString(),
          custodyMode: "non-extractable",
          keyId,
          privateKey: keyPair.privateKey,
          publicKey: publicKeyBytes,
          version: 1,
        }},
        "key-material",
      );
      store.put(
        {{
          credential: established.delegation_credential,
          expiresAt: established.delegation_expires_at,
          issuedAt: established.delegation_issued_at,
          revocationId: established.revocation_id,
          scopes: established.scopes,
        }},
        "delegation",
      );
      store.put({{ generation: 1, state: "active" }}, "lifecycle");
      tx.addEventListener("complete", () => resolve());
      tx.addEventListener("abort", () => reject(new Error("harness pair-browser: failed to write node-client-identity")));
      tx.addEventListener("error", () => reject(new Error("harness pair-browser: failed to write node-client-identity")));
    }});
  }} finally {{
    db.close();
  }}

  // 7. Trust snapshot — the pairing gate probes https://<host> even when the
  //    page itself is served over HTTP.
  const trustOrigin = location.origin.replace(/^http:/, "https:");
  localStorage.setItem(`node.trust.verified.${{trustOrigin}}`, new Date().toISOString());

  console.log("harness pair-browser: paired", {{ did, deviceId: established.device_id }});

  // 8. Reload so the shell re-reads the boot gate over the real delegation.
  location.reload();
}})();
"#
    )
}

/// Escape `value` for a JS double-quoted string literal. Treats the input as
/// hostile: backslashes and double quotes are escaped, and every control
/// character — including the LINE SEPARATOR / PARAGRAPH SEPARATOR code
/// points that were historically illegal unescaped inside a JS string
/// literal — is escaped rather than passed through, so no interpolated value
/// can terminate the literal or inject additional script.
fn js_string_literal(value: &str) -> String {
    let mut out = String::with_capacity(value.len() + 2);
    out.push('"');
    for ch in value.chars() {
        match ch {
            '\\' => out.push_str("\\\\"),
            '"' => out.push_str("\\\""),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            '\u{2028}' => out.push_str("\\u2028"),
            '\u{2029}' => out.push_str("\\u2029"),
            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
            c => out.push(c),
        }
    }
    out.push('"');
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn render_interpolates_node_id_origin_and_token() {
        let out = render("03aabb", "https://127.0.0.1:4431", "tok-123", "harness-browser");
        assert!(out.contains("03aabb"));
        assert!(out.contains("https://127.0.0.1:4431"));
        assert!(out.contains("tok-123"));
        assert!(out.contains("harness-browser"));
    }

    #[test]
    fn render_escapes_values_for_a_js_string_literal() {
        let out = render("node", "https://origin", "a'b\\c", "n");
        assert!(
            !out.contains("a'b\\c"),
            "raw value must not land unescaped in JS source"
        );
        // The escaped form (backslash doubled) must still be present.
        assert!(out.contains("a'b\\\\c"));
    }

    #[test]
    fn escaping_neutralizes_a_string_breakout_attempt() {
        // An owner token is opaque; treat it as hostile. A value trying to
        // close the string literal and inject a statement must have its
        // double quote escaped, so the breakout sequence only ever appears
        // preceded by the escaping backslash, never bare.
        let out = render("node", "https://origin", "\"; fetch('https://evil'); //", "n");
        assert!(
            out.contains("\\\"; fetch('https://evil'); //"),
            "the escaped form (backslash-quote) must be present"
        );
        let bare = "\"; fetch";
        let mut search_start = 0;
        while let Some(found) = out[search_start..].find(bare) {
            let absolute = search_start + found;
            assert!(
                absolute > 0 && out.as_bytes()[absolute - 1] == b'\\',
                "every occurrence of `\"; fetch` must be immediately preceded by an \
                 escaping backslash, found a bare one at byte {absolute}"
            );
            search_start = absolute + bare.len();
        }
    }

    #[test]
    fn snippet_checks_every_fetch_response() {
        let out = render("node", "https://origin", "t", "n");
        let fetches = out.matches("await fetch(").count();
        let checks = out.matches("response.ok").count()
            + out.matches("Response.ok").count()
            + out.matches(".ok)").count();
        assert!(
            checks >= fetches,
            "every fetch must be status-checked: {fetches} fetches, {checks} checks"
        );
    }

    #[test]
    fn snippet_uses_non_extractable_key_generation() {
        let out = render("node", "https://origin", "t", "n");
        assert!(out.contains(r#"generateKey({ name: "Ed25519" }, false, ["sign", "verify"])"#));
    }

    #[test]
    fn snippet_pairs_with_the_owner_bearer_header() {
        let out = render("node", "https://origin", "t", "n");
        assert!(out.contains("authorization: `Bearer ${OWNER_TOKEN}`"));
    }

    #[test]
    fn render_uses_only_relative_urls_in_every_fetch_call() {
        let out = render(
            "03aabb",
            "https://127.0.0.1:4431",
            "tok-123",
            "harness-browser",
        );
        // The direct `fetch(...)` call and every `checkedFetch(...)` call
        // target a same-origin relative path — never an absolute origin —
        // so `connect-src 'self'` is satisfied regardless of whether the
        // operator loaded the page over HTTP or HTTPS.
        for literal in [
            "fetch(\"/.well-known/client-node-origin\")",
            "checkedFetch(`/api/v2/client-devices/challenge`",
            "checkedFetch(`/api/v2/client-devices/pair`",
            "checkedFetch(`/api/v2/client-devices/session`",
        ] {
            assert!(
                out.contains(literal),
                "expected a relative fetch call {literal:?} in the rendered snippet"
            );
        }
        // No absolute origin construct — old or new — leaks into a request
        // path. `https://127.0.0.1:4431` (the `browser_origin` hint) is
        // still allowed to appear as human-readable guard/error text; it
        // must just never prefix a fetch target.
        assert!(
            !out.contains("const BASE_URL"),
            "no absolute BASE_URL constant declaration should remain in the snippet"
        );
        assert!(
            !out.contains("${BASE_URL}"),
            "no fetch call should interpolate a BASE_URL variable any more"
        );
        for absolute_fetch in [
            "fetch(`${",
            "fetch(`http",
            "fetch(\"http",
            "checkedFetch(`https://",
            "checkedFetch(`http://",
        ] {
            assert!(
                !out.contains(absolute_fetch),
                "found an absolute-URL fetch pattern {absolute_fetch:?} in the snippet"
            );
        }
    }

    #[test]
    fn render_includes_an_origin_guard_before_any_pairing_fetch() {
        let out = render(
            "03aabb",
            "https://127.0.0.1:4431",
            "tok-123",
            "harness-browser",
        );
        assert!(out.contains("/.well-known/client-node-origin"));
        assert!(out.contains("EXPECTED_NODE_ID"));
        assert!(
            out.contains("03aabb"),
            "the expected node_id must be embedded for comparison"
        );
        assert!(
            out.contains("https://127.0.0.1:4431"),
            "the browser_origin hint must be embedded in the guard's error text"
        );
        assert!(out.contains("originProof.node_id !== EXPECTED_NODE_ID"));

        // The guard must run BEFORE the pairing challenge fetch — a snippet
        // pasted at the wrong origin must fail at the guard, not three
        // fetches later.
        let guard_pos = out
            .find("/.well-known/client-node-origin")
            .expect("origin guard fetch present");
        let challenge_pos = out
            .find("client-devices/challenge")
            .expect("challenge fetch present");
        assert!(
            guard_pos < challenge_pos,
            "origin guard must run before the pairing challenge fetch"
        );
    }

    /// A JS execution harness is not available inside this Rust test binary
    /// (no lib target links a JS engine — see the crate's Cargo.toml). Per
    /// the plan's documented fallback, this asserts the snippet contains the
    /// exact multicodec prefix bytes and the base58btc alphabet used by the
    /// derivation, and defers full known-vector coverage against the real
    /// `did.js`/`key-custody.js` implementations to the Playwright spec
    /// (Task 5, out of scope here).
    #[test]
    fn snippet_derivation_matches_the_pwas_multicodec_prefix_and_alphabet() {
        let out = render("node", "https://origin", "t", "n");
        assert!(
            out.contains("didKeyMaterial.set([0xed, 0x01]);"),
            "did:key derivation must use the Ed25519 multicodec prefix (0xed, 0x01)"
        );
        assert!(out.contains("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"));
        assert!(out.contains("publicKeyDigest.slice(0, 16)"));
    }
}