Orgchain v1.2: tapkeys and lineage's leaves
A node's chain key is now its tapkey: its NIP-06 npub with the BIP-86 tweak, so one secret signs its nostr events and its chain spends, and its address follows from its npub. Its leaves hang off the same tree, byte for byte as on lineage's key tree page: an agent's ssh, git, dkim and wg keys from its nostr secret, an org's ssh-ca, mtls and noise keys from its seed. Inspect shows the tapkey, npub, derivation and leaves, with the public lines to paste. Labs keyed by v1.1 keep their separate m/86' chain keys until they are reset. PROVENANCE.md and public/licenses now carry @scure/base, bip32 and bip39, which came in with v1.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4upF5LVwmPDDqrhdUfnXh
This commit is contained in:
1 parent
744c5946aa
commit
00a35613c5
15 files changed
+283
-39
No files matched your search
@@ -9,5 +9,7 @@ Orgchain is lineage's app for creating and operating lineage holdings on Super T
|
||||
rather than rewriting it, and keep its tests passing. Preserve upstream licenses and provenance.
|
||||
- Keys follow the tree (`src/orgchain/keys.js`): store roots only (one per holding, one per graduated org) and derive
|
||||
every other key from its position. Every act is signed by whichever key holds the authorizing inscription.
|
||||
- A node's chain key is its tapkey: its npub with the BIP-86 tweak. Leaves are HKDF-SHA256 (salt `lineage/v0`) and
|
||||
must stay byte for byte with lineage's key tree page; `tests/keys.test.js` holds values read off that page.
|
||||
- The page makes no network requests; keep it that way (the deployed CSP sets `connect-src 'none'`).
|
||||
- Before shipping: `npm test`, `npm run build`, `npm run test:e2e`.
|
||||
+5
-2
@@ -14,8 +14,11 @@ The upstream CC0 legal text is retained unchanged at [`public/licenses/supertest
|
||||
Runtime dependencies are pinned in `package.json` and `package-lock.json`:
|
||||
|
||||
- **@cmdcode/tapscript 1.5.3**, Christopher Scott, [upstream](https://github.com/cmdruid/tapscript), CC-BY-1.0. Used for transaction encoding, signing/verification, Taproot commitments and addresses. Installed package license retained unchanged at [`public/licenses/tapscript.txt`](public/licenses/tapscript.txt). Library source is unmodified; Vite bundles/minifies it for the browser.
|
||||
- **@noble/curves 1.9.7**, Paul Miller and contributors, [upstream](https://github.com/paulmillr/noble-curves), MIT. Used for key generation/validation and public keys. License retained at [`public/licenses/noble-curves.txt`](public/licenses/noble-curves.txt).
|
||||
- **@noble/hashes 1.8.0**, Paul Miller and contributors, [upstream](https://github.com/paulmillr/noble-hashes), MIT. Used for synthetic block hashes. License retained at [`public/licenses/noble-hashes.txt`](public/licenses/noble-hashes.txt).
|
||||
- **@noble/curves 1.9.7**, Paul Miller and contributors, [upstream](https://github.com/paulmillr/noble-curves), MIT. Used for key generation/validation and public keys: secp256k1 for chain and nostr keys, Ed25519 and X25519 for lineage's leaves. License retained at [`public/licenses/noble-curves.txt`](public/licenses/noble-curves.txt).
|
||||
- **@noble/hashes 1.8.0**, Paul Miller and contributors, [upstream](https://github.com/paulmillr/noble-hashes), MIT. Used for synthetic block hashes, BIP-85's HMAC and the leaves' HKDF-SHA256. License retained at [`public/licenses/noble-hashes.txt`](public/licenses/noble-hashes.txt).
|
||||
- **@scure/base 1.2.6**, Paul Miller, [upstream](https://github.com/paulmillr/scure-base), MIT. Used for bech32 npubs and base64 leaf keys. License retained at [`public/licenses/scure-base.txt`](public/licenses/scure-base.txt).
|
||||
- **@scure/bip32 1.7.0**, Patricio Palladino and Paul Miller, [upstream](https://github.com/paulmillr/scure-bip32), MIT. Used for HD derivation under the BIP-85, BIP-86 and NIP-06 paths. License retained at [`public/licenses/scure-bip32.txt`](public/licenses/scure-bip32.txt).
|
||||
- **@scure/bip39 1.6.0**, Patricio Palladino and Paul Miller, [upstream](https://github.com/paulmillr/scure-bip39), MIT. Used for the 24-word lab roots and their seeds. License retained at [`public/licenses/scure-bip39.txt`](public/licenses/scure-bip39.txt).
|
||||
|
||||
Vite and Playwright are build/test dependencies, not runtime network services. Their installed package notices remain in the locked dependency installation. No unlicensed upstream code was copied.
|
||||
|
||||
|
||||
@@ -16,10 +16,14 @@ chain itself records who did what, and when.
|
||||
anything is signed. Events it rejects stay on the chain and show as rejected; they just don't change the tree.
|
||||
- **Keys.** Each holding gets its own 24-word lab root, and every node has lineage's keys at its place in the tree
|
||||
(`src/orgchain/keys.js`): an org's seed is BIP-85 from its parent's, or its own root once it graduates; agents are
|
||||
accounts 1..n of their org's seed; each key is BIP-86 (the wallet that holds the node's inscription and signs for it)
|
||||
and NIP-06 (its npub). Every act is signed by whichever key holds the authorizing inscription, and a birth hands the
|
||||
child its own. Graduation is a claim by the org's fresh root, countersigned by its derived key, and the derived
|
||||
subtree re-keys under the new root, so the parent's line can no longer sign for it.
|
||||
accounts 1..n of their org's seed. A node's **tapkey** is its NIP-06 npub with the BIP-86 tweak: one secret signs its
|
||||
nostr events and its chain spends, and anyone with the npub can find what it holds. Its **leaves** hang off the same
|
||||
tree, byte for byte as on lineage's key tree page (`src/orgchain/leaves.js`): an agent's ssh, git, dkim and wg keys
|
||||
from its nostr secret, an org's ssh-ca, mtls and noise keys from its seed, each with the public line to paste. Every
|
||||
act is signed by the tapkey that holds the authorizing inscription, and a birth hands the child its own. Graduation is
|
||||
a claim by the org's fresh root, countersigned by its derived key, and the derived subtree re-keys under the new
|
||||
root, so the parent's line can no longer sign for it. Labs keyed by v1.1 keep their separate m/86' chain keys until
|
||||
they are reset.
|
||||
|
||||
Test coins and test keys only: a local chain, the lab wallet (the faucet that funds every key) and each holding's lab
|
||||
root, kept in this browser (`localStorage`, key `orgchain:lab:v1`). Nothing here is a real key; minting a real root is
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2022 Paul Miller (https://paulmillr.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the “Software”), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2022 Patricio Palladino, Paul Miller (paulmillr.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the “Software”), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2022 Patricio Palladino, Paul Miller (paulmillr.com)
|
||||
|
||||
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.
|
||||
@@ -30,9 +30,14 @@ function assertIndex(value, what) {
|
||||
if (!Number.isSafeInteger(value) || value < 0 || value > MAX_INDEX) throw new Error(`Invalid ${what}.`);
|
||||
return value;
|
||||
}
|
||||
// PBKDF2 is the slow step, so each mnemonic's master key is computed once per page.
|
||||
// PBKDF2 is the slow step, so each mnemonic's seed and master key are computed once per page.
|
||||
const seeds = new Map();
|
||||
export function seedBytes(mnemonic) {
|
||||
if (!seeds.has(mnemonic)) seeds.set(mnemonic, mnemonicToSeedSync(assertMnemonic(mnemonic)));
|
||||
return seeds.get(mnemonic);
|
||||
}
|
||||
function master(mnemonic) {
|
||||
if (!masters.has(mnemonic)) masters.set(mnemonic, HDKey.fromMasterSeed(mnemonicToSeedSync(assertMnemonic(mnemonic))));
|
||||
if (!masters.has(mnemonic)) masters.set(mnemonic, HDKey.fromMasterSeed(seedBytes(mnemonic)));
|
||||
return masters.get(mnemonic);
|
||||
}
|
||||
// BIP-85: entropy = HMAC-SHA512(key = "bip-entropy-from-k", msg = k) for the hardened child key k.
|
||||
@@ -49,6 +54,10 @@ export function childMnemonic(parentMnemonic, index) {
|
||||
export function walletSecret(mnemonic, account = 0, coin = 1) {
|
||||
return bytesToHex(master(mnemonic).derive(PATHS.wallet(assertIndex(account, 'account'), coin)).privateKey);
|
||||
}
|
||||
// The NIP-06 secret itself: Orgchain tweaks it into the node's tapkey (BIP-86), so one secret signs nostr and chain.
|
||||
export function nostrSecret(mnemonic, account = 0) {
|
||||
return bytesToHex(master(mnemonic).derive(PATHS.nostr(assertIndex(account, 'account'))).privateKey);
|
||||
}
|
||||
export function nostrKey(mnemonic, account = 0) {
|
||||
const pubkey = schnorr.getPublicKey(master(mnemonic).derive(PATHS.nostr(assertIndex(account, 'account'))).privateKey);
|
||||
return { pubkey: bytesToHex(pubkey), npub: bech32.encode('npub', bech32.toWords(pubkey)) };
|
||||
|
||||
+23
-8
@@ -5,7 +5,7 @@ import { MetadataIndex } from './adapters/metadata-index.js';
|
||||
import { census, lineageEvents, reduce, rows, slug, summary, trail } from './orgchain/lineage.js';
|
||||
import { LAB_KEY, beat, decodeLab, emptyLab, encodeLab, mutate, readLab, setTempo, tick } from './orgchain/store.js';
|
||||
import { SAMPLE, act, seedSample } from './orgchain/actions.js';
|
||||
import { holderOf, keyOf } from './orgchain/keys.js';
|
||||
import { holderOf, keyOf, leavesOf } from './orgchain/keys.js';
|
||||
import { readEnvelope } from './adapters/inscription.js';
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
@@ -202,7 +202,7 @@ function renderInspect() {
|
||||
if (!n) return box.replaceChildren(h('p', { class: 'note pad', text: `${known.name} is not born yet at beat ${ui.beat}.` }));
|
||||
box.replaceChildren(...nodeView(n));
|
||||
}
|
||||
const fact = ([k, v]) => h('div', { class: 'f' }, h('dt', { text: k }), h('dd', { text: v }));
|
||||
const fact = ([k, v, note]) => h('div', { class: 'f' }, h('dt', { text: k }), h('dd', {}, v, note && h('span', { class: 'fn', text: note })));
|
||||
function nodeView(n) {
|
||||
const parent = node(n.parent, view);
|
||||
const facts = [
|
||||
@@ -211,7 +211,6 @@ function nodeView(n) {
|
||||
parent && ['Edge', n.edge === 'adopted' ? `adopted at ${at(n.graduated)}: it holds its own root` : `derived: ${parent.name} holds its seed`],
|
||||
n.closed !== null && ['Closed', at(n.closed)],
|
||||
['Children', n.children.length ? n.children.map(id => node(id, view)?.name).join(', ') : 'none yet'],
|
||||
...keyFacts(n),
|
||||
['Inscription', n.id],
|
||||
].filter(Boolean);
|
||||
return [
|
||||
@@ -222,15 +221,31 @@ function nodeView(n) {
|
||||
n.badges.length > 0 && h('div', {}, h('p', { class: 'lab', text: 'Belts' }), h('ul', { class: 'list' }, n.badges.map(b =>
|
||||
h('li', {}, belt(b), h('span', { class: 'when', text: ` from ${node(b.by)?.name ?? '?'}, ${at(b.beat)}` }))))),
|
||||
n.decisions.length > 0 && h('div', {}, h('p', { class: 'lab', text: 'Decisions' }), h('ul', { class: 'list' }, n.decisions.map(d =>
|
||||
h('li', {}, h('b', { text: d.title }), d.why && ` · ${d.why}`, h('span', { class: 'when', text: ` (${at(d.beat)})` })))))),
|
||||
h('li', {}, h('b', { text: d.title }), d.why && ` · ${d.why}`, h('span', { class: 'when', text: ` (${at(d.beat)})` }))))),
|
||||
keysView(n)),
|
||||
];
|
||||
}
|
||||
// Lineage's keys for a node: its derivation, npub and address as of the viewed beat, and who holds its inscription now.
|
||||
// Lineage's keys for a node as of the viewed beat: its tapkey and npub, where they come from, who holds its inscription
|
||||
// now, and its leaves with the public lines to paste. The leaves are the ones the lineage key tree page derives.
|
||||
const HELD = { own: 'its own key', lab: 'the lab wallet', parent: "its parent's key (its handoff is cued)", other: 'a key outside this lab' };
|
||||
function keyFacts(n) {
|
||||
const LEAF = { ssh: 'shell access, certified by the CA', git: 'commit signing', dkim: 'mail signing', wg: 'WireGuard peer key',
|
||||
'ssh-ca': 'SSH certificate authority', mtls: 'TLS identity between nodes', noise: 'Headscale noise key' };
|
||||
function keysView(n) {
|
||||
const k = keyOf(lab.keys, view, n), holder = holderOf(lab, live, n.id);
|
||||
return [['Key', k ? k.path : 'the lab wallet: this holding predates lab keys'], k && ['npub', k.npub], k && ['Address', k.wallet.address],
|
||||
['Held now by', holder ? HELD[holder.who] : 'nobody: its inscription has been spent']];
|
||||
const held = ['Held now by', holder ? HELD[holder.who] : 'nobody: its inscription has been spent'];
|
||||
if (!k) return h('div', { class: 'keys' }, h('p', { class: 'lab', text: 'Keys' }),
|
||||
h('dl', { class: 'fl' }, [['Signs with', 'the lab wallet: this holding predates lab keys, so it has no tapkey or leaves'], held].map(fact)));
|
||||
const agent = n.kind === 'agent', leaves = leavesOf(lab.keys, view, n), lines = leaves.map(([, , line]) => line).filter(Boolean);
|
||||
const chain = k.tapkey ? ['Tapkey', k.wallet.address, 'Its npub with the BIP-86 tweak: one secret signs its nostr events and its chain spends.']
|
||||
: ['Chain key', k.wallet.address, "A separate key at m/86', the way v1.1 keyed this lab. Reset the lab to key it by tapkey."];
|
||||
return h('div', { class: 'keys' },
|
||||
h('p', { class: 'lab', text: 'Keys' }),
|
||||
h('dl', { class: 'fl' }, [chain, ['npub', k.npub], ['Derived', k.path], held].map(fact)),
|
||||
h('p', { class: 'lab' }, 'Leaves', h('span', { text: ` · HKDF-SHA256 from its ${agent ? 'nostr secret' : 'seed'}` })),
|
||||
h('ul', { class: 'list leaves' }, leaves.map(([label, value]) =>
|
||||
h('li', {}, h('b', { text: label }), h('span', { class: 'when', text: ` ${LEAF[label]}` }), h('code', { text: value })))),
|
||||
h('div', { class: 'code' }, h('p', { class: 'code-h', text: agent ? 'Public lines to paste' : 'CA key for TrustedUserCAKeys' }),
|
||||
h('pre', { text: lines.join('\n\n') })));
|
||||
}
|
||||
// The key that signed an event's reveal, named when it belongs to the node that authorized it.
|
||||
function signerOf(e) {
|
||||
|
||||
@@ -210,6 +210,13 @@ textarea { resize: vertical; line-height: 1.5; }
|
||||
.code { border: 2px solid var(--ink); min-width: 0; }
|
||||
.code-h { padding: 6px 10px; border-bottom: 2px solid var(--ink); background: var(--concrete); font: 700 var(--t-xs)/1.3 var(--mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-2); }
|
||||
.code pre { margin: 0; padding: 10px; font-size: var(--t-xs); line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.fn { display: block; margin-top: 4px; font-size: var(--t-xs); color: var(--ink-2); }
|
||||
.keys { display: grid; gap: 10px; min-width: 0; }
|
||||
.keys .fl { margin-top: -4px; }
|
||||
.keys .f:first-child { margin-top: 0; }
|
||||
.keys .lab:not(:first-child) { margin-top: 6px; }
|
||||
.leaves { margin: 0; }
|
||||
.leaves code { display: block; margin-top: 2px; font: var(--t-xs)/1.5 var(--mono); overflow-wrap: anywhere; }
|
||||
|
||||
/* timeline */
|
||||
.scrub { flex: 1 1 400px; display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; }
|
||||
|
||||
@@ -6,11 +6,11 @@ import { InscriptionAdapter, POSTAGE, FEE } from '../adapters/inscription.js';
|
||||
import { MetadataIndex } from '../adapters/metadata-index.js';
|
||||
import { TestWallet } from '../adapters/wallet.js';
|
||||
import { getVin, getVout } from '../upstream/primitives.js';
|
||||
import { newMnemonic, walletSecret } from '../adapters/lineage-keys.js';
|
||||
import { newMnemonic } from '../adapters/lineage-keys.js';
|
||||
import { holderCoin, makeClaim, countersign } from '../adapters/handoff.js';
|
||||
import { PROTOCOL, lineageEvents, reduce } from './lineage.js';
|
||||
import { armClock, beat } from './store.js';
|
||||
import { derivedSubtree, emptyKeys, holderOf, keyOf } from './keys.js';
|
||||
import { derivedSubtree, emptyKeys, holderOf, keyOf, rootWallet } from './keys.js';
|
||||
|
||||
const FIELDS = { found: ['name'], birth: ['name', 'kind'], award: ['to', 'skill', 'belt'], decide: ['title', 'why'], graduate: ['of'], close: [] };
|
||||
const BODY = { found: m => `found ${m.name}`, birth: m => `birth ${m.name}`, award: m => `award ${m.skill} ${m.belt}`,
|
||||
@@ -75,7 +75,7 @@ function signerFor(lab, state, id) {
|
||||
// and its derived subtree re-keys under that root, so the parent's line can no longer sign for it.
|
||||
function perform(lab, op, by, metadata) {
|
||||
if (op === 'found') {
|
||||
const mnemonic = newMnemonic(), root = new TestWallet(walletSecret(mnemonic, 0));
|
||||
const mnemonic = newMnemonic(), root = rootWallet(lab.keys, mnemonic);
|
||||
supply(lab, root);
|
||||
const id = inscribe(lab, root, op, null, metadata);
|
||||
lab.keys.roots[id] = mnemonic;
|
||||
|
||||
+35
-7
@@ -1,13 +1,25 @@
|
||||
// Lineage's key tree over Orgchain's holdings (docs/lineage.md), using the tree branch's lineage-keys.js.
|
||||
// Each holding has its own 24-word lab root. An org's seed is BIP-85 from its parent's seed at its index, or its own
|
||||
// root once it graduates. Agents (seats) are accounts 1..n of their org's seed. Every key is BIP-86 (the wallet that
|
||||
// holds the node's inscription and signs for it) and NIP-06 (its npub). Lab only: nothing here is a real key.
|
||||
import { Script } from '@cmdcode/tapscript';
|
||||
import { PATHS, childMnemonic, nostrKey, walletSecret } from '../adapters/lineage-keys.js';
|
||||
// root once it graduates. Agents (seats) are accounts 1..n of their org's seed. A node's tapkey is its NIP-06 npub with
|
||||
// the BIP-86 tweak: one secret signs its nostr events and its chain spends, and its address follows from its npub. Its
|
||||
// leaves (ssh, git, dkim, wg for agents; ssh-ca, mtls, noise for orgs) hang off the same tree. Lab only: nothing here
|
||||
// is a real key.
|
||||
import { Script, Tap } from '@cmdcode/tapscript';
|
||||
import { PATHS, childMnemonic, nostrKey, nostrSecret, seedBytes, walletSecret } from '../adapters/lineage-keys.js';
|
||||
import { holderCoin } from '../adapters/handoff.js';
|
||||
import { TestWallet } from '../adapters/wallet.js';
|
||||
import { agentLeaves, agentRecords, nodeLeaves, nodeRecords } from './leaves.js';
|
||||
|
||||
export const emptyKeys = () => ({ roots: {}, adopted: {} });
|
||||
// 'tapkey' (from v1.2): the chain key is the npub, tweaked. 'wallet': labs keyed by v1.1, whose chain key was a
|
||||
// separate, untweaked key at m/86'. A lab keeps its scheme until it is reset.
|
||||
export const SCHEMES = ['tapkey', 'wallet'];
|
||||
export const emptyKeys = () => ({ scheme: 'tapkey', roots: {}, adopted: {} });
|
||||
// BIP-86: the output key commits to the internal key and no scripts, so anyone with an npub can find its coins.
|
||||
export const tapkeyOf = pubkey => Tap.getPubKey(pubkey)[0];
|
||||
const chainSecret = (keys, mnemonic, account) =>
|
||||
(keys.scheme === 'wallet' ? walletSecret(mnemonic, account) : Tap.getSecKey(nostrSecret(mnemonic, account))[0]);
|
||||
const pathAt = (keys, account) => (keys.scheme === 'wallet' ? PATHS.wallet(account) : PATHS.nostr(account));
|
||||
export const rootWallet = (keys, mnemonic) => new TestWallet(chainSecret(keys, mnemonic, 0));
|
||||
export const scriptHex = script => Script.encode(script, false).hex;
|
||||
|
||||
// Where a node's keys come from, or null for a holding that predates lab keys (one test wallet signs for it).
|
||||
@@ -28,8 +40,9 @@ export function seedOf(keys, state, node) {
|
||||
export function keyOf(keys, state, node) {
|
||||
const seed = seedOf(keys, state, node);
|
||||
if (!seed) return null;
|
||||
const wallet = new TestWallet(walletSecret(seed.mnemonic, seed.account));
|
||||
return { wallet, npub: nostrKey(seed.mnemonic, seed.account).npub, path: `${[seed.origin, ...seed.steps].join(' › ')} · ${PATHS.wallet(seed.account)}` };
|
||||
const { npub, pubkey } = nostrKey(seed.mnemonic, seed.account);
|
||||
return { wallet: new TestWallet(chainSecret(keys, seed.mnemonic, seed.account)), npub, pubkey, tapkey: keys.scheme === 'tapkey',
|
||||
path: `${[seed.origin, ...seed.steps].join(' › ')} · ${pathAt(keys, seed.account)}` };
|
||||
}
|
||||
|
||||
// The wallet that holds an inscription now: the node's own key, its parent's (a handoff still cued), or the lab wallet.
|
||||
@@ -53,3 +66,18 @@ export function derivedSubtree(state, id) {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Names nest as domains: time.yolopreneur; an agent's handle is sol@time.yolopreneur.
|
||||
export function domainOf(state, node) {
|
||||
const names = [];
|
||||
for (let n = node; n; n = state.nodes.get(n.parent)) names.push(n.name);
|
||||
return names.join('.');
|
||||
}
|
||||
// A node's leaves as [label, short value, line to paste]: an agent's from its nostr secret, an org's from its seed.
|
||||
export function leavesOf(keys, state, node) {
|
||||
const seed = seedOf(keys, state, node);
|
||||
if (!seed) return null;
|
||||
if (node.kind !== 'agent') return nodeRecords(nodeLeaves(seedBytes(seed.mnemonic)), domainOf(state, node));
|
||||
const domain = domainOf(state, state.nodes.get(node.parent));
|
||||
return agentRecords(agentLeaves(nostrSecret(seed.mnemonic, seed.account)), `${node.name}@${domain}`, node.name, domain);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Lineage's leaf keys, derived as the key tree page at lineage.yolopreneur.lol does: HKDF-SHA256, salt 'lineage/v0'.
|
||||
// An agent's ssh, git and dkim keys (Ed25519) and wg key (X25519, clamped) come from its nostr secret; an org's
|
||||
// ssh-ca and mtls (Ed25519) and noise (X25519) keys come from its BIP-39 seed. Only public halves leave this module.
|
||||
import { hkdf } from '@noble/hashes/hkdf';
|
||||
import { sha256 } from '@noble/hashes/sha2';
|
||||
import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from '@noble/hashes/utils';
|
||||
import { ed25519, x25519 } from '@noble/curves/ed25519';
|
||||
import { base64 } from '@scure/base';
|
||||
|
||||
const SALT = utf8ToBytes('lineage/v0');
|
||||
export const leafKey = (ikm, info) => hkdf(sha256, ikm, SALT, utf8ToBytes(info), 32);
|
||||
const clamp = k => { const c = Uint8Array.from(k); c[0] &= 248; c[31] &= 127; c[31] |= 64; return c; };
|
||||
const u32 = n => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n); return b; };
|
||||
const sshStr = bytes => concatBytes(u32(bytes.length), bytes);
|
||||
const sshBlob = pub => concatBytes(sshStr(utf8ToBytes('ssh-ed25519')), sshStr(pub));
|
||||
export const sshLine = (pub, comment) => `ssh-ed25519 ${base64.encode(sshBlob(pub))}${comment ? ` ${comment}` : ''}`;
|
||||
export const sshFingerprint = pub => `SHA256:${base64.encode(sha256(sshBlob(pub))).replace(/=+$/, '')}`;
|
||||
|
||||
export function agentLeaves(nostrSecretHex) {
|
||||
const sk = hexToBytes(nostrSecretHex), ed = info => ed25519.getPublicKey(leafKey(sk, info));
|
||||
return { ssh: ed('ssh'), git: ed('git'), dkim: ed('dkim'), wg: x25519.getPublicKey(clamp(leafKey(sk, 'wg'))) };
|
||||
}
|
||||
export function nodeLeaves(seed) {
|
||||
const ed = info => ed25519.getPublicKey(leafKey(seed, info));
|
||||
return { 'ssh-ca': ed('node/ssh-ca'), mtls: ed('node/mtls'), noise: x25519.getPublicKey(clamp(leafKey(seed, 'node/noise'))) };
|
||||
}
|
||||
// What each leaf publishes, in the key tree page's forms: a label, a short value, and the line you'd paste.
|
||||
export function agentRecords(leaves, handle, selector, domain) {
|
||||
return [
|
||||
['ssh', sshFingerprint(leaves.ssh), sshLine(leaves.ssh, handle)],
|
||||
['git', sshFingerprint(leaves.git), `${handle} namespaces="git" ${sshLine(leaves.git, handle)}`],
|
||||
['dkim', `${selector}._domainkey.${domain}`, `${selector}._domainkey.${domain}. IN TXT "v=DKIM1; k=ed25519; p=${base64.encode(leaves.dkim)}"`],
|
||||
['wg', base64.encode(leaves.wg), `[Peer]\n# ${handle}\nPublicKey = ${base64.encode(leaves.wg)}`],
|
||||
];
|
||||
}
|
||||
export function nodeRecords(leaves, domain) {
|
||||
return [
|
||||
['ssh-ca', sshFingerprint(leaves['ssh-ca']), sshLine(leaves['ssh-ca'], `lineage-ca@${domain}`)],
|
||||
['mtls', base64.encode(leaves.mtls), null],
|
||||
['noise', `mkey:${bytesToHex(leaves.noise)}`, null],
|
||||
];
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
import { LocalChain } from '../adapters/chain.js';
|
||||
import { NETWORK, TestWallet } from '../adapters/wallet.js';
|
||||
import { assertMnemonic } from '../adapters/lineage-keys.js';
|
||||
import { emptyKeys } from './keys.js';
|
||||
import { SCHEMES, emptyKeys } from './keys.js';
|
||||
|
||||
export const LAB_KEY = 'orgchain:lab:v1';
|
||||
// 'each': every act is its own beat. 10/30/60: acts are cued and land together on the next beat. 'manual': they wait for Beat now.
|
||||
@@ -22,6 +22,8 @@ export function keysPreference(value) {
|
||||
if (!/^[0-9a-f]{64}i0$/.test(id)) throw new Error('Invalid lab keys.');
|
||||
keys[part][id] = assertMnemonic(mnemonic, 24);
|
||||
}
|
||||
keys.scheme = value?.scheme ?? (Object.keys(keys.roots).length ? 'wallet' : 'tapkey'); // v1.1 labs had roots and no scheme
|
||||
if (!SCHEMES.includes(keys.scheme)) throw new Error('Invalid lab keys.');
|
||||
return keys;
|
||||
}
|
||||
export const emptyLab = () => ({ wallet: null, chain: new LocalChain(), clock: clockPreference(), keys: emptyKeys() });
|
||||
|
||||
+69
-10
@@ -5,14 +5,17 @@ import { entropyToMnemonic } from '@scure/bip39';
|
||||
import { wordlist } from '@scure/bip39/wordlists/english';
|
||||
import { bytesToHex } from '@noble/hashes/utils';
|
||||
import { schnorr } from '@noble/curves/secp256k1';
|
||||
import { PATHS, bip85Entropy, childMnemonic, nostrKey, walletSecret } from '../src/adapters/lineage-keys.js';
|
||||
import { base64, bech32 } from '@scure/base';
|
||||
import { Address } from '@cmdcode/tapscript';
|
||||
import { PATHS, bip85Entropy, childMnemonic, nostrKey, nostrSecret, seedBytes, walletSecret } from '../src/adapters/lineage-keys.js';
|
||||
import { claimStatus, countersign, decodeClaim, holderCoin, makeClaim } from '../src/adapters/handoff.js';
|
||||
import { LocalChain } from '../src/adapters/chain.js';
|
||||
import { InscriptionAdapter } from '../src/adapters/inscription.js';
|
||||
import { TestWallet } from '../src/adapters/wallet.js';
|
||||
import { beat, decodeLab, emptyLab, encodeLab, keysPreference } from '../src/orgchain/store.js';
|
||||
import { act, fund, lineageState } from '../src/orgchain/actions.js';
|
||||
import { keyOf, scriptHex } from '../src/orgchain/keys.js';
|
||||
import { keyOf, leavesOf, rootWallet, scriptHex, tapkeyOf } from '../src/orgchain/keys.js';
|
||||
import { agentLeaves, agentRecords, nodeLeaves, nodeRecords, sshFingerprint } from '../src/orgchain/leaves.js';
|
||||
|
||||
// Spec vectors and the handoff test come from the tree branch (2ea7884), which wrote lineage-keys.js and handoff.js.
|
||||
const XPRV = 'xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb';
|
||||
@@ -53,10 +56,35 @@ test('handoff: the recipient claims, the holder countersigns, the inscription mo
|
||||
assert.throws(() => countersign(chain, claim, holder), /spent/);
|
||||
});
|
||||
|
||||
const at = (lab, id) => scriptHex(holderCoin(lab.chain, id).scriptPubKey);
|
||||
const key = (mnemonic, account) => `5120${new TestWallet(walletSecret(mnemonic, account)).pubkey}`;
|
||||
|
||||
test('each holding has its own root, and every node holds its inscription at its lineage key', () => {
|
||||
// Values read off the lineage key tree page (lineage.yolopreneur.lol) for this mnemonic: agent account 1, lineage.llc.
|
||||
const LEADER = 'leader monkey parrot ring guide accident before fence cannon height naive bean';
|
||||
test('tapkey: the BIP-86 tweak turns the spec internal key into the spec output key', () => {
|
||||
const tapkey = tapkeyOf('cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115');
|
||||
assert.equal(tapkey, 'a60869f0dbcf1dc659c9cecbaf8050135ea9e8cdc487053f1dc6880949dc684c');
|
||||
assert.equal(Address.p2tr.fromPubKey(tapkey, 'main'), 'bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr');
|
||||
});
|
||||
test('leaves match the lineage key tree page byte for byte', () => {
|
||||
const agent = agentLeaves(nostrSecret(LEADER, 1));
|
||||
assert.equal(nostrKey(LEADER, 1).npub, 'npub1m9m6dnc0svwugus8sz6l29rqatmdegywxtgld6ymvq6y6ca0fczq88tsjr');
|
||||
assert.deepEqual([sshFingerprint(agent.ssh), sshFingerprint(agent.git), base64.encode(agent.dkim), base64.encode(agent.wg)], [
|
||||
'SHA256:Svt6sRSfHEYSQf9+QX+XDPg9eXlEsgcy+OAcJIzeyQU', 'SHA256:fBH0g7huqXVWQHa6GiDsMMzg2PTnWwyTgJdrH3L5bo4',
|
||||
'A7j292wHd4jUXhs4dMJcKh0nDG1/cwoo4aG1NUn/x2g=', '0HVH6534UBMEVWDz8BA+NkI73Z28bHBBQNFEONCkmWI=']);
|
||||
assert.deepEqual(nodeRecords(nodeLeaves(seedBytes(LEADER)), 'lineage.llc').map(([, value]) => value), [
|
||||
'SHA256:XOvpZ24mpcsFhi0mDxyZDqSEpkVKsQkhnLCOR5mlxY4', 'ObnNHsWk0TI5evUH+wbuMXtyPtEXInA6Y0KJbFu8S7A=',
|
||||
'mkey:e35077cb9d3e78ad67898407caa1a059453fcec698f00f3585c6d87e7950e676']);
|
||||
const lines = Object.fromEntries(agentRecords(agent, 'sol@lineage.llc', 'sol', 'lineage.llc').map(([label, , line]) => [label, line]));
|
||||
assert.match(lines.ssh, /^ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI[A-Za-z0-9+/]{43} sol@lineage\.llc$/);
|
||||
assert.match(lines.git, /^sol@lineage\.llc namespaces="git" ssh-ed25519 \S+ sol@lineage\.llc$/);
|
||||
assert.equal(lines.dkim, 'sol._domainkey.lineage.llc. IN TXT "v=DKIM1; k=ed25519; p=A7j292wHd4jUXhs4dMJcKh0nDG1/cwoo4aG1NUn/x2g="');
|
||||
assert.equal(lines.wg, '[Peer]\n# sol@lineage.llc\nPublicKey = 0HVH6534UBMEVWDz8BA+NkI73Z28bHBBQNFEONCkmWI=');
|
||||
});
|
||||
|
||||
const at = (lab, id) => scriptHex(holderCoin(lab.chain, id).scriptPubKey);
|
||||
const key = (mnemonic, account) => `5120${tapkeyOf(nostrKey(mnemonic, account).pubkey)}`;
|
||||
const npubHex = npub => bytesToHex(Uint8Array.from(bech32.fromWords(bech32.decode(npub).words)));
|
||||
|
||||
test('each holding has its own root, and every node holds its inscription at its tapkey', () => {
|
||||
const lab = emptyLab();
|
||||
const root = act(lab, 'found', null, { name: 'acme' }), ops = act(lab, 'birth', root, { name: 'ops' });
|
||||
const bot = act(lab, 'birth', ops, { name: 'bot', kind: 'agent' }), lead = act(lab, 'birth', root, { name: 'lead', kind: 'agent' });
|
||||
@@ -64,14 +92,28 @@ test('each holding has its own root, and every node holds its inscription at its
|
||||
assert.equal(m.split(' ').length, 24);
|
||||
assert.deepEqual([root, ops, bot, lead].map(id => at(lab, id)), [key(m, 0), key(opsSeed, 0), key(opsSeed, 1), key(m, 1)]);
|
||||
const s = lineageState(lab), k = keyOf(lab.keys, s, s.nodes.get(bot));
|
||||
assert.equal(k.path, "acme root › 0' · m/86'/1'/1'/0/0");
|
||||
assert.match(k.npub, /^npub1[02-9ac-hj-np-z]{58}$/);
|
||||
assert.equal(k.path, "acme root › 0' · m/44'/1237'/1'/0/0");
|
||||
assert.equal(k.npub, nostrKey(opsSeed, 1).npub);
|
||||
assert.equal(k.wallet.pubkey, tapkeyOf(npubHex(k.npub)), 'the tapkey is the npub, tweaked: anyone with the npub finds the coins');
|
||||
assert.equal(k.wallet.address, Address.p2tr.fromPubKey(k.wallet.pubkey, 'regtest'));
|
||||
assert.ok(k.tapkey);
|
||||
const award = act(lab, 'award', ops, { to: bot, skill: 'launch', belt: 'green' });
|
||||
assert.equal(at(lab, award), key(opsSeed, 0), 'an act is signed by its authorizer, and its record stays with that key');
|
||||
const other = act(lab, 'found', null, { name: 'other' });
|
||||
assert.notEqual(lab.keys.roots[other], m);
|
||||
});
|
||||
|
||||
test('leaves hang off the same tree: agents from their nostr secret, orgs from their seed', () => {
|
||||
const lab = emptyLab();
|
||||
const root = act(lab, 'found', null, { name: 'acme' }), ops = act(lab, 'birth', root, { name: 'ops' });
|
||||
const bot = act(lab, 'birth', ops, { name: 'bot', kind: 'agent' });
|
||||
const opsSeed = childMnemonic(lab.keys.roots[root], 0), s = lineageState(lab), node = id => s.nodes.get(id);
|
||||
assert.deepEqual(leavesOf(lab.keys, s, node(bot)), agentRecords(agentLeaves(nostrSecret(opsSeed, 1)), 'bot@ops.acme', 'bot', 'ops.acme'));
|
||||
assert.deepEqual(leavesOf(lab.keys, s, node(ops)), nodeRecords(nodeLeaves(seedBytes(opsSeed)), 'ops.acme'));
|
||||
assert.deepEqual(leavesOf(lab.keys, s, node(root)).map(([label]) => label), ['ssh-ca', 'mtls', 'noise']);
|
||||
assert.match(leavesOf(lab.keys, s, node(root))[0][2], / lineage-ca@acme$/);
|
||||
});
|
||||
|
||||
test('graduation: a fresh root claims the org, the derived key countersigns, and the subtree re-keys', () => {
|
||||
const lab = emptyLab();
|
||||
const root = act(lab, 'found', null, { name: 'acme' }), studio = act(lab, 'birth', root, { name: 'studio' });
|
||||
@@ -85,15 +127,31 @@ test('graduation: a fresh root claims the org, the derived key countersigns, and
|
||||
assert.ok(fresh && fresh !== old);
|
||||
assert.deepEqual([studio, kid, deep, seat, solo].map(id => at(lab, id)),
|
||||
[key(fresh, 0), key(childMnemonic(fresh, 0), 0), key(childMnemonic(childMnemonic(fresh, 0), 0), 0), key(fresh, 1), key(soloRoot, 0)]);
|
||||
const leftovers = lab.chain.coins(new TestWallet(walletSecret(old, 0)).script).flatMap(c => (c.inscriptions ?? []).map(i => i.id));
|
||||
const leftovers = lab.chain.coins(rootWallet(lab.keys, old).script).flatMap(c => (c.inscriptions ?? []).map(i => i.id));
|
||||
assert.deepEqual(leftovers.filter(id => [studio, kid, deep, seat].includes(id)), []);
|
||||
const decision = act(lab, 'decide', studio, { title: 'Our own root now' });
|
||||
assert.equal(at(lab, decision), key(fresh, 0), 'the org now signs with its own root');
|
||||
const s = lineageState(lab);
|
||||
assert.equal(keyOf(lab.keys, s, s.nodes.get(seat)).path, "studio root · m/86'/1'/1'/0/0");
|
||||
assert.equal(keyOf(lab.keys, s, s.nodes.get(seat)).path, "studio root · m/44'/1237'/1'/0/0");
|
||||
assert.match(leavesOf(lab.keys, s, s.nodes.get(seat))[0][2], / seat@studio\.acme$/, 'names still nest after graduation');
|
||||
assert.deepEqual(decodeLab(encodeLab(lab)).keys, lab.keys);
|
||||
});
|
||||
|
||||
test('a lab keyed by v1.1 keeps its separate, untweaked chain keys', () => {
|
||||
const lab = emptyLab();
|
||||
lab.keys.scheme = 'wallet';
|
||||
const root = act(lab, 'found', null, { name: 'old' }), seat = act(lab, 'birth', root, { name: 'seat', kind: 'agent' });
|
||||
const m = lab.keys.roots[root];
|
||||
assert.deepEqual([root, seat].map(id => at(lab, id)), [0, 1].map(a => `5120${new TestWallet(walletSecret(m, a)).pubkey}`));
|
||||
const s = lineageState(lab), k = keyOf(lab.keys, s, s.nodes.get(seat));
|
||||
assert.equal(k.path, "old root · m/86'/1'/1'/0/0");
|
||||
assert.equal(k.tapkey, false);
|
||||
const saved = JSON.parse(encodeLab(lab));
|
||||
delete saved.keys.scheme; // v1.1 saved roots and no scheme
|
||||
assert.equal(decodeLab(JSON.stringify(saved)).keys.scheme, 'wallet');
|
||||
assert.throws(() => keysPreference({ scheme: 'nope' }), /Invalid lab keys/);
|
||||
});
|
||||
|
||||
test('holdings from before lab keys still act, with the one lab wallet', () => {
|
||||
const lab = emptyLab();
|
||||
fund(lab);
|
||||
@@ -105,9 +163,10 @@ test('holdings from before lab keys still act, with the one lab wallet', () => {
|
||||
assert.equal(at(lab, kid), `5120${lab.wallet.pubkey}`, 'without a root there is nothing to re-key; the event is still recorded');
|
||||
const s = lineageState(lab);
|
||||
assert.equal(keyOf(lab.keys, s, s.nodes.get(kid)), null);
|
||||
assert.equal(leavesOf(lab.keys, s, s.nodes.get(kid)), null);
|
||||
assert.equal(s.nodes.get(kid).edge, 'adopted');
|
||||
const saved = JSON.parse(encodeLab(lab));
|
||||
delete saved.keys;
|
||||
assert.deepEqual(decodeLab(JSON.stringify(saved)).keys, { roots: {}, adopted: {} });
|
||||
assert.deepEqual(decodeLab(JSON.stringify(saved)).keys, { scheme: 'tapkey', roots: {}, adopted: {} });
|
||||
assert.throws(() => keysPreference({ roots: { [`${'a'.repeat(64)}i0`]: 'not a mnemonic' } }), /Invalid lab mnemonic/);
|
||||
});
|
||||
+12
-2
@@ -101,10 +101,20 @@ test('the sample replays, any beat can be inspected, and the lab survives a relo
|
||||
await expect(page.locator('#inspect-body')).toContainText('"op": "graduate"');
|
||||
await expect(page.locator('#inspect-body')).toContainText("well-known's key");
|
||||
await row(page, 'lineage').click();
|
||||
await expect(page.locator('#inspect-body')).toContainText("lineage root · m/86'/1'/0'/0/0");
|
||||
await expect(page.locator('#inspect-body')).toContainText("lineage root · m/44'/1237'/0'/0/0");
|
||||
await expect(page.locator('#inspect-body')).toContainText('its own key');
|
||||
await row(page, 'time').click();
|
||||
await expect(page.locator('#act-key')).toHaveText("Signs as time: yolopreneur root › 1' · m/86'/1'/0'/0/0");
|
||||
await expect(page.locator('#act-key')).toHaveText("Signs as time: yolopreneur root › 1' · m/44'/1237'/0'/0/0");
|
||||
const inspect = page.locator('#inspect-body');
|
||||
for (const text of ['ssh-ca', 'lineage-ca@time.yolopreneur', 'mkey:']) await expect(inspect).toContainText(text);
|
||||
await row(page, 'sol').click();
|
||||
const fact = name => inspect.locator('.f').filter({ has: page.locator('dt', { hasText: new RegExp(`^${name}$`) }) });
|
||||
await expect(fact('Tapkey')).toContainText('bcrt1p');
|
||||
await expect(fact('npub')).toContainText('npub1');
|
||||
await expect(fact('Derived')).toContainText("yolopreneur root › 1' · m/44'/1237'/2'/0/0");
|
||||
for (const text of ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5', 'sol@time.yolopreneur namespaces="git"', 'sol._domainkey.time.yolopreneur. IN TXT "v=DKIM1; k=ed25519; p=', 'PublicKey = ']) {
|
||||
await expect(inspect.locator('pre')).toContainText(text);
|
||||
}
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await page.reload();
|
||||
await expect(page.locator('#tree-rows .sel')).toHaveCount(12);
|
||||
|
||||
Reference in new issue
Block a user