feat(beehive): configure owner npub and join communities without approval exchange

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-10 17:34:41 -04:00
parent 91d2246c5e
commit fcb82cf74b
13 changed files with 365 additions and 175 deletions

View File

@@ -1,101 +1,80 @@
# First private demo: setup → approval → Start
# First private demo: configure → join → host availability
Start with **`beehive setup`** on the computer that will run agents.
It prepares that computer, not an agent and not a relay account. The wizard saves
its request automatically and prints where it went and what to do next.
On the host computer run **`beehive setup`**. Fresh setup asks only for the
existing owner's **public npub** (valid hex also works) and the management relay
URL, then deliberate OS credential consent. Host name and private state paths are
automatic. Never enter the owner's private key on a host.
**Already stopped at the old “NEW private pairing file” prompt?** Cancel that old
wizard with Ctrl-C. After refreshing the launcher to the published version, run
`beehive setup` again (or `beehive setup <same-explicit-directory>` if you chose
one). It recognizes the retained identity; press Enter to export it. This needs
no key read or new identity and never overwrites an earlier request. Do **not**
choose create, delete host state, or reinitialize Keychain. If you already have
the returned approval, choose `import` instead. Keep its original fingerprint.
**Resuming the old pairing-file prompt or an unapproved f215 installation?**
Cancel the old wizard. After the verified update, run **`beehive setup`** again,
or `beehive setup <same-explicit-directory>` if you previously chose one.
It reuses the original host key reference, owner and relay, even if an agent
manifest already exists. No create/export/import menu, approval exchange, identity
reset, credential repair or migration is required or performed. Approved
installations and their history remain usable.
Quick route (details and prerequisites below):
1. Host: `beehive setup` → create, then privately transfer the printed request.
2. Trusted owner computer: `beehive setup ~/.beehive/owner-exchange` → approve,
choose the transferred request, compare the full fingerprint, choose validity
(e.g. `7d`), and enter the existing owner key at the hidden prompt.
3. Host: `beehive setup` → import the returned approval.
4. Host: `beehive provision-agent ~/.beehive/host <binding-file> <genesis-file>`,
then `beehive auth-info ~/.beehive/host` for deliberate provider configuration.
5. Owner: `beehive catalog <approval-file>` → follow the printed `beehive tui` command.
6. Host: `beehive host --owner-present`; owner TUI: `hosts`, `select 1`, `show`, `start`.
## Short host route
Outputs use new private names, not passwords or authorization tokens. Request and
approval contain private infrastructure/ownership metadata but no private keys;
transfer them privately. Shell and wizard path inputs in this route support `~`
and `~/…` (not `~another-user`). Explicit output choices remain available with
wizard `export-to` / `approve-to` and `catalog <new-file> <approval-files...>`.
1. `beehive setup` uses `~/.beehive/host`, never your current directory. Confirm
host-key access only while deliberately present as the host OS user.
2. Accept **Check/join community** to verify ordinary membership with the host's
own key using NIP-98. An already-admitted host is not asked for an invite.
A nonmember needs a community-issued code/link or administrator admission of
the displayed host public key. Supported inputs are a bare code,
`https://<relay>/invite/<code>` or `buzz://join?relay=<url>&code=<code>`.
A link for another relay is refused, never used to retarget the host.
If community policy acceptance is required, complete its normal client/admin
process; this wizard neither fabricates policy receipts nor bypasses policy.
Enter at the invite prompt or `no` at confirmation safely leaves configuration
retained for the same `beehive setup` retry. Network/refusal errors preserve it
too. Unknown claim outcome requires rechecking membership before retrying.
3. Setup is **configuration-only, NOT serving**, even after membership succeeds.
Deliberately start the foreground host: **`beehive host --owner-present`**.
For a nondefault folder: `beehive host <same-directory> --owner-present`.
Startup rechecks membership and actual runtime support; only `Host online`
means serving. Zero agents is supported; configuring/joining starts none.
Ctrl-C stops this foreground host; no daemon/service is installed.
4. On the **trusted owner computer** run
**`beehive tui discover <management-relay> ~/.beehive/owner`** and provide the
existing owner signer at its hidden prompt. The owner must also be a direct
community member. `hosts` shows private available infrastructure; `agents`
shows actual agent inventory separately. No approval/catalog file is required.
A host offer is routing/availability, **not human consent or agent authority**.
The tested path is **public `host` CLI → private owner `tui` → Start → agent-signed
reply → Stop**. Automated evidence uses a loopback membership-enforced relay model,
a synthetic credential helper, the installed Buzz ACP/CLI and a deterministic
provider harness. It proves neither macOS Keychain readiness nor deployment/provider
compatibility. Workers use isolated fixtures, never frozen installations or real
owner state; owner-run continuation preserves the existing host identity.
## Prerequisites and limits
## Prerequisites
- Node 22.18+ (tested 24.15.0), existing standalone package dependencies, and
supported POSIX process containment. The reversible launcher is
`beehive/bin/beehive.cjs`; link it once into a user-owned directory already on
PATH. No reinstall is needed when that link already points to this checkout.
- Private directories/files use 0700/0600. OS credential access uses only the
Beehive namespace. Creation/readback may synchronously show an OS dialog;
confirm only when owner-present. Live reads use an owned bounded helper;
SIGINT/SIGTERM awaits its close, but may not dismiss an OS-owned dialog.
Missing, denied and unavailable credentials are not automatically regenerated.
- Relay must advertise its self key and NIP-43 and support membership-gated
signed `/query`, NIP-42 and private NIP-59 traffic. Open-relay AUTH/ACK is not
admission. Membership verification is not proof of all runtime compatibility.
Host membership is ordinary membership, not owner OA or a management-only role.
- Joining/configuring does **not** configure provider credentials, create an
agent, grant initial placement or authorize a conversation channel. Those
deliberate inputs remain below. Do not use a new genesis to reset an existing
agent's assignment.
- Historical explicit approval exchange is available only through
`legacy-enrollment <directory>`; legacy `catalog` input still works. Neither
is required by normal setup or private discovery.
- Node 24.15.0 (any Node.js 22.18+ works; the `beehive` launcher checks the
version and says what to do) and this package's frozen standalone
dependencies. Install the command once, reversibly, into a user bin directory
already on your PATH, matching nothing else:
`ln -s /absolute/path/to/this/checkout/beehive/bin/beehive.cjs ~/.local/bin/beehive`
(adjust the target to a user-owned bin directory that is on *your* PATH;
undo with `rm`). Then run `beehive <command>` from any directory. Advanced
fallback inside the package: `/absolute/path/to/node src/cli.ts <command>`;
no root bootstrap is needed either way.
- `beehive setup` with no directory uses the default host state folder
`~/.beehive/host`, resolved from your home (never the current directory) and
displayed once before the wizard. An occupied default is never silently
reinitialized; pass an explicit directory for additional hosts. Owner
approval files use a separate owner exchange folder; automatic catalogs save
beside the approval file supplied to catalog.
- The retained/default private host directory, a deliberately chosen host service OS
user with an interactive unlocked credential store, and a trusted owner computer.
A fresh setup creates the directory with mode 0700; never reset an existing identity.
`@napi-rs/keyring` 2.0.0 may show OS approval/unlock prompts; it cannot suppress
them. Only `beehive` service entries `host:<public-key>` and `agent:<public-key>`
are used. Never approve access to someone else's account/cache.
- Existing owner signer and an explicitly supplied demo agent key/authorization.
Agent enrollment/channel membership on the conversation relay is a separate
prerequisite: host registration is not an agent-owner grant.
- Relay operator has already enrolled the host and owner public keys as ordinary
direct members. This CLI currently supports **already-member verification**;
it does not mint invites or expose invite claims. The relay must advertise its
`self` key and NIP-43, with membership-gated signed `POST /query`, NIP-42 and
NIP-59 giftwraps. Open-relay AUTH/ACK and a saved roster are not admission.
- One installed supported provider configuration. The example below chooses Buzz
Agent / Databricks v2, dedicated service HOME/config, plus installed buzz-acp and
sibling Buzz CLI. Real provider/native protocol compatibility remains unverified.
Do not substitute a different provider until its configuration is intentionally
supplied. No login, inference, invite or production enrollment was run by workers.
Automated coverage uses actual setup/host/TUI subprocesses, loopback membership
and invite models, explicit isolated credential adapters, installed Buzz ACP/CLI,
and a deterministic provider fixture. It is **not live Keychain, production
relay/invite, or vendor authentication proof**.
## Manual setup → Start → reply → Stop
## Optional initial demo agent → signed reply → Stop
1. **Host, owner present:** `beehive setup`
(default host folder `~/.beehive/host`; `beehive setup <explicit-directory>`
for an additional host). Choose `create`, accept the computer name or supply a label, then supply the
owner's **public** hex key (from the existing owner identity) and the management
relay URL (from your community operator). The request saves automatically under
`<host-folder>/exchange/` with a unique name. Expect OS key creation/readback approval.
If interrupted, use `export` on the same directory; never regenerate identity.
2. Transfer the pairing file privately to the owner. **Trusted owner computer:** run
`beehive setup ~/.beehive/owner-exchange` (a separate directory keeps owner
approval files outside host state), choose `approve`, select the pairing,
compare the full fingerprint with the host, confirm `yes`, choose approval validity such as `7d`
(future Unix seconds also work). The approval saves automatically under this
owner folder's `exchange/` with a unique name. Enter the existing owner key only
at the hidden prompt. Transfer the approval file privately back to the host.
3. **Host:** rerun `beehive setup` on the same default host folder, choose `import`, select
that approval. The wizard explains where to transfer it for an Enter default:
`<host-folder>/exchange/approval-<full-fingerprint>.json`; otherwise supply its
actual path. Only this exact path is offered, never a search through your files,
and signature/request checks still apply. This registers infrastructure only. Arrange the **already-member**
prerequisite above out of band; there is no administrative operation here.
4. Prepare a private local `binding.json` (absolute paths, no identity keys):
Stop the host before changing structural local configuration. Skip this section
if the goal is only to make empty infrastructure available.
1. Prepare a private local `binding.json` (absolute paths, no identity keys):
```json
{
@@ -116,7 +95,7 @@ owner state; owner-run continuation preserves the existing host identity.
Supply the owner's deliberately approved **initial demo placement** public
genesis file: `{ "v":1, "id":"<fresh UUID v4>", "owner":"<owner hex>",
"agent":"<demo agent hex>", "initialHost":"<paired host hex>" }`.
"agent":"<demo agent hex>", "initialHost":"<configured host hex>" }`.
This is a locally pinned root, not a signed bearer grant. It must match the
intended agent and host. **Never replace an existing agent's retained genesis
or invent a new root to bypass its assignment**; use a separately authorized
@@ -126,31 +105,17 @@ owner state; owner-run continuation preserves the existing host identity.
Enter the matching agent key at the hidden prompt. Expect OS create/readback
prompts. The owner key is never placed on the host. If interrupted, retain all
artifacts and use `reconcile-provision` with these exact same inputs, not a new key.
5. Run `beehive auth-info ~/.beehive/host` for the exact service-user
native login context. Deliberately configure/login the supported provider as
that user only. No borrowed Desktop cache. Presence of files is not login proof.
6. **Owner:** `beehive catalog /private/approval.json`. It saves a new private
catalog beside the approval and prints the exact next TUI command. The explicit
`catalog /new/private-catalog.json /private/approval.json` form still works.
7. **Host, owner present:**
`beehive host --owner-present`
(default host folder and its retained registered relay; the explicit `beehive host <host-directory> <relay>
--owner-present` form remains available). The flag consents to live OS reads, not relay admin or provider login. Each key
read uses an owned helper with a 10s deadline; SIGINT/SIGTERM cancels and awaits
its actual close. An OS-owned dialog may remain after helper termination;
inspect/dismiss it deliberately. Denied/timeout is not a missing key. Expect a
fresh host-signed HTTP membership check, then `Host online`.
8. **Owner:** `beehive tui <printed-catalog-path>` (uses the verified catalog relay; an
explicit matching relay argument remains supported).
Enter owner key at the hidden prompt. A separate owner-signed membership check
precedes private transport. Use `hosts`, `select 1` (the intended numbered host/agent row), `show`, then
`start`. Wait for `completed | accepted` and running actual state; publication
alone does not prove Start. From the owner's usual Buzz client, mention the demo
agent in its authorized conversation channel and observe its signed reply.
9. In the TUI, `stop`; wait for accepted receipt and `show` stopped/no actual run.
`quit` closes only the owner UI. Ctrl-C the foreground host for owned teardown.
Missing provider credentials do not prevent Stop. Preserve journals if teardown
reports uncertainty; do not clear locks or kill unrelated PIDs.
2. Run `beehive auth-info ~/.beehive/host` for the intended service-user
provider context. Configure the supported provider locally as that user;
never borrow Desktop credentials. Presence of files is not login proof.
3. Start `beehive host --owner-present`, then open the owner discovery TUI above.
Use `hosts`, `agents`, select the intended host/agent, `show`, then explicit
`start`. Wait for an accepted receipt and actual running state; publication
alone does not prove Start. Mention the authorized demo agent in its usual
Buzz channel and observe its signed reply.
4. `stop`; wait for accepted receipt and stopped/no actual run. `quit` closes
only the owner UI. Ctrl-C stops the foreground host and its owned runner.
Preserve journals and locks if teardown reports uncertainty.
## Remaining deliberate inputs and first-demo limits
@@ -160,11 +125,11 @@ relay, or login consent. Its JSON paths are absolute paths, not shell expression
The owner-approved genesis still explicitly pins the intended agent and initial
host: it is not safe to synthesize a replacement root for an existing agent.
This PoC does not yet integrate owner-approved initial placement/provider setup
into a single wizard. Keep those two input files private (0600); registration
into a single wizard. Keep those two input files private (0600); host configuration
alone does not authorize an agent or grant conversation channel membership.
No internal revisions or CAS IDs are needed for first Start: the TUI supplies them
from the selected fresh host report. Do not use legacy two-argument setup to bypass
this private enrollment path.
this private configuration path.
Membership proof is fresh and single-use for one connection (10s admission window),
not indefinitely cached. After transport loss, no private automatic reconnect:

View File

@@ -1,7 +1,7 @@
# Beehive (experimental first slice)
Standalone TypeScript host + relay + terminal UI. **Not Desktop parity, not
production-ready, not compatible with the current Buzz relay.** No existing
production-ready; deployed relay/provider compatibility remains unverified.** No existing
client, relay policy, credential store, or native service is changed.
A locally created owner secp256k1 identity signs encrypted commands/receipts.
@@ -25,37 +25,32 @@ ln -s "$(pwd)/bin/beehive.cjs" ~/.local/bin/beehive # adjust if your PATH diff
`beehive setup` (no directory) then uses the default host state folder
`~/.beehive/host`, resolved from your home and displayed once; an occupied
default is never reinitialized. Pass an explicit directory for additional
hosts. Owner approval/catalog files remain explicit and outside host state.
hosts. Normal configuration and private discovery need no approval files.
Undo the command with `rm ~/.local/bin/beehive` (or your equivalent link).
## Offline standalone host enrollment (partial, no network)
## Configure and join a private host
`beehive setup <directory>` (or `node src/cli.ts setup <directory>` inside the
package) without an identity file opens Beehive's
own enrollment interface. No Buzz Desktop is needed. Use **create** on a fresh
host directory: enter its label, relay URL and your existing owner **public** key.
The host generates its durable independent key and exports a private pairing file.
Compare the full displayed fingerprint in your owner context.
See [FIRST_DEMO.md](FIRST_DEMO.md) for the current short flow:
`beehive setup` → OWNER NPUB + RELAY → ordinary community check/join →
`beehive host --owner-present`. Setup merely configures; host start is an explicit
foreground lifecycle decision. Retained unapproved and approved identities resume
without key replacement, retargeting, file exchange or approval import.
Run the same command in your separate owner context and choose **approve**. Read
the pairing file, verify the host/key/label/relay/fingerprint, confirm registration,
choose an expiration and a new approval file, then enter your existing owner key
through hidden local input. Do not run owner approval on a host you do not trust
with that input. No new controller key is created and the owner key is not saved.
Transfer the approval privately back to the host and choose **import**. **export**
can repeat pairing-file export without replacing the retained host key.
The locally configured owner public key is the management trust root. Only that
owner's actual signed commands can manage the host. A host signs and privately
advertises its own availability, including with zero agents; this offer proves
neither owner consent nor agent placement. The owner uses
`beehive tui discover <relay> ~/.beehive/owner` without a catalog file.
Files must be owner-only regular files; transfer them privately and retain mode
0600. Registration is domain-separated from agent NIP-OA, binds this exact pairing
request, and grants neither agent placement nor owner account permissions.
Registration expiration is checked locally; offline verification cannot discover
remote revocation, prove a physical machine, or erase a copied key.
Ordinary direct membership is independently verified with the host's own key;
issued invites use existing NIP-98 claim APIs, never host owner-account delegation.
Policy acceptance/admin action remains the community's gate. Joining starts no
agent and grants no placement. Actual per-agent grants, local genesis and retained
assignment still gate Start. Owner secrets stay on the owner computer.
**This milestone stops at registered/pending/no-network.** Narrow relay admission,
private host inventory in the TUI, and Start/reply/Move/reply over the new transport
are not implemented. Broad NIP-OA cannot enroll a host. The legacy diagnostic
workflow below remains separate and stores a shared owner key; do not use it for
real host enrollment. Existing a8 OA identity files are rejected, not migrated.
Historical explicit registration tooling lives under `legacy-enrollment` and
`catalog`, not the normal setup menu. Legacy two-argument diagnostic setup below
stores a shared owner key; do not use it for normal private configuration.
## Run an isolated fixture

View File

@@ -589,3 +589,34 @@ host errno test checks receipt sanitization, not inventory workspace suppression
owner TUI before the installed ACP/Buzz CLI signed-reply journey. Other provider
journeys are unchanged; no provider parity expansion, OS-store or live enrollment
proof. Workspace V3_BINDINGS_7FDD records exact execution/failures.
## Owner configuration and ordinary join (3a946)
Normal setup now uses OWNER NPUB/hex + retained relay configuration, not approval
exchange. `host-availability.test.ts` drives actual fresh CLI configuration,
nonmember cancellation, policy-required refusal, issued-link claim confirmation,
member resume, zero-agent host/private owner discovery, wrong-owner/absent-agent
Start rejection and missing-key refusal. Both credential boundaries are explicitly
isolated. `launcher.test.ts` covers npub/default paths and retained resume;
`enrollment-input.test.ts` retains historical explicit exchange compatibility.
`catalog-tui.test.ts` now runs normal setup before approved and unapproved host
Start/Stop, including installed Buzz signed-reply and v3-conversion scenarios.
Validation: one default-concurrent installed-enabled whole-package run completed
naturally in 311.190s: **167 passed, 0 failed, 0 skipped**, including all 19 installed
opt-in cases (`BEEHIVE_REAL_BUZZ_ACP=/Applications/Buzz.app/Contents/MacOS/buzz-acp`).
This run predates the subsequently discovered NIP-98 nonce correction: same-second
requests previously had identical event IDs and native replay protection refused
rechecks. `membership-signer.test.ts` binds the production signer to unique signed
IDs and a replay-enforcing loopback fixture; the nonce matches native Buzz CLI's
existing contract, not a new relay API. Final source strict + focused
`membership-signer`, `direct-membership`, `host-availability`, `catalog-tui`,
`nostr-transport`, `launcher`, `enrollment-input`: **30 passed, 0 failed, 0 skipped**,
installed opt-in enabled. The whole suite was not repeated after that causal fix.
Original failing replay assertion and stale setup-check-count assertions are
preserved in the external work log evidence; no retry-to-green or serial masking.
These are synthetic local credential/relay/provider fixtures using installed
binaries, not live OS credential, community admission, policy acceptance or vendor
readiness evidence. Authority foundation 91d2246 received a scoped independent
no-blocker review; it must not be represented as review of this later setup slice.

View File

@@ -18,6 +18,7 @@ import { claudeGuidance } from './claude.ts';
import { readAgentSecret } from './key-input.ts';
import { conversationInput } from './conversation-input.ts';
import { localSetup } from './local-setup.ts';
import { hostSetup } from './host-setup.ts';
import { enrollmentInput } from './enrollment-input.ts';
import { verifyHostCatalog, type HostCatalog } from './host-catalog.ts';
import { Profiles, profile, profileRevision, type Profile } from './profiles.ts';
@@ -45,13 +46,14 @@ const defaultHostDirectory = () => join(homedir(), '.beehive', 'host');
const help = `Beehive — private host preview (explicit direct relay membership required)
Command: beehive <command> from any directory once installed (one-time user link, reversible: ln -s <beehive-package>/bin/beehive.cjs <user bin on PATH>/beehive, e.g. ~/.local/bin); source fallback inside the package: node src/cli.ts <command>.
identity Disabled: use your existing owner signer
setup Offline pairing/owner approval/import on the default host folder ~/.beehive/host
setup <host-directory> Explicit host folder (additional hosts/tests); same offline wizard
setup Configure/resume OWNER NPUB + RELAY on ~/.beehive/host; normal community join
setup <host-directory> Same configuration/join flow on an explicit retained host folder
setup <host-directory> <identity-file> LEGACY loopback diagnostic setup (owner key copied)
provision-agent <host-directory> <binding-file> <genesis-file> Hidden matching agent import; OS credentials
reconcile-provision <host-directory> <binding-file> <genesis-file> Explicit exact first-provision recovery
catalog <approval-file> Automatic private catalog beside approval; prints tui command
catalog <new-file> <registration-files...> Retain verified public host registrations
legacy-enrollment <host-directory> Explicit historical approval exchange (not required)
presets Local process-free preset discovery/setup guidance
local-setup <host-directory> Bindings; new/reuse/hidden standby/restore identity
migrate-slots <host-directory> Explicit stopped upgrade, preserves journal
@@ -70,7 +72,7 @@ Command: beehive <command> from any directory once installed (one-time user link
tui discover <relay> <state-directory> Private availability discovery; no approval file
tui <catalog-file> [relay] Private owner UI; hidden existing owner signer
setup/add-agent accept optional <local-key-file> <public-genesis-file> for standby import.
The default host folder resolves from your home (~/.beehive/host), never the current directory; setup displays it once and never reinitializes an existing installation. Owner approval files are automatic in the separately chosen owner exchange folder. catalog <approval-file> saves beside the approval. Enrollment, provision-agent, auth-info, catalog, host and tui paths accept ~ and ~/. Explicit relay overrides must match retained local configuration.
The default host folder resolves from your home (~/.beehive/host), never the current directory; setup displays it once and never reinitializes an existing installation. No approval files required. setup never starts the host or agents; host --owner-present is a deliberate foreground start. Legacy catalog <approval-file> remains available. Enrollment, provision-agent, auth-info, catalog, host and tui paths accept ~ and ~/. Explicit relay overrides must match retained local configuration.
reconcile-agent derives the interrupted binding and genesis from the retained journal; an optional public genesis file must match it.
assignment-export accepts agent public key after filename when several slots exist.
Move is fixture-only experimental; containment acceptance remains gated. No provider login RPC. Private host/catalog requires fresh membership-enforced relay verification; no automatic private reconnect.`;
@@ -90,6 +92,8 @@ async function main() {
console.log(`Provisioned STOPPED public slot ${publicKey(secret)}; owner ${identity.pairing.owner}. Key stored and read-back verified in Beehive OS credential namespace. No Start or relay admission performed.`);
} else if (command === 'presets') {
showPresets();
} else if (command === 'legacy-enrollment') {
await enrollmentInput(setupPath(text(args[0])));
} else if (command === 'setup') {
const usingDefaultHostFolder = args.length === 0;
if (usingDefaultHostFolder) {
@@ -97,8 +101,8 @@ async function main() {
console.log(`Default host folder: ${args[0]} (pass an explicit directory for another host; existing installations are never reinitialized)`);
}
const dir = setupPath(text(args[0]));
if (args.length === 1 && !existsSync(join(dir, 'setup.json'))) {
await enrollmentInput(dir); return;
if (args.length === 1 && (existsSync(join(dir, 'host-identity.json')) || !existsSync(join(dir, 'setup.json')))) {
await hostSetup(dir); return;
}
if (existsSync(dir)) {
if (args.length !== 1) throw Error('Existing setup: supply host directory only; retained owner cannot be replaced');

View File

@@ -33,9 +33,9 @@ export async function enrollmentInput(directory: string): Promise<void> {
console.log(`Pairing fingerprint: ${fingerprint}`);
const file = action === 'export-to' ? setupPath(text(await ui.question('New private request output path (~ supported): '))) : join(exchange, `request-${fingerprint}-${randomUUID()}.json`);
writePrivate(file, identity.pairing, true);
console.log(`Request saved: ${file}\nTransfer this file privately to the owner computer (contains public keys and private infrastructure metadata, no secrets). There run beehive setup ~/.beehive/owner-exchange, choose approve and supply the transferred file. Compare the full fingerprint above. Re-export is safe; older files are never replaced.`);
console.log(`Request saved: ${file}\nTransfer this file privately to the owner computer (contains public keys and private infrastructure metadata, no secrets). There run beehive legacy-enrollment ~/.beehive/owner-exchange, choose approve and supply the transferred file. Compare the full fingerprint above. Re-export is safe; older files are never replaced.`);
} else if (action === 'approve' || action === 'approve-to') {
if (retained) throw Error('This folder belongs to a host. Approve on the trusted owner computer using beehive setup ~/.beehive/owner-exchange; no owner key requested here.');
if (retained) throw Error('This folder belongs to a host. Approve on the trusted owner computer using beehive legacy-enrollment ~/.beehive/owner-exchange; no owner key requested here.');
const request = hostPairing(readPrivate(setupPath(text(await ui.question('Private pairing file (transferred from host, ~ supported): ')))));
console.log(`Host: ${request.host}\nLabel: ${request.label}\nOwner: ${request.owner}\nRelay: ${request.relay}\nFingerprint: ${pairingFingerprint(request)}`);
if (await ui.question('Compare full fingerprint with host. Register ONLY this infrastructure (not agents or relay delegation)? [yes/no]: ') !== 'yes') return;
@@ -47,7 +47,7 @@ export async function enrollmentInput(directory: string): Promise<void> {
ui.close();
const secret = await readAgentSecret('Owner');
writePrivate(file, registerHost(request, secret, expires), true);
console.log(`Approval saved: ${file}\nOwner secret not persisted. Transfer privately back to host, then run beehive setup and choose import. You may put it at the host's displayed default approval path.\nOn this owner computer next: beehive catalog ${quote(file)}\nRelay admission remains pending.`);
console.log(`Approval saved: ${file}\nOwner secret not persisted. Transfer privately back to host, then run beehive legacy-enrollment and choose import. You may put it at the host's displayed default approval path.\nOn this owner computer next: beehive catalog ${quote(file)}\nRelay admission remains pending.`);
} else if (action === 'import') {
if (!retained) throw Error('No host identity here. Run setup on the original host folder; do not create a replacement identity.');
const expected = join(exchange, `approval-${pairingFingerprint(retained.pairing)}.json`);

83
beehive/src/host-setup.ts Normal file
View File

@@ -0,0 +1,83 @@
import { createInterface } from 'node:readline/promises';
import { stdin, stdout } from 'node:process';
import { hostname, homedir } from 'node:os';
import { join } from 'node:path';
import { existsSync } from 'node:fs';
import { decode } from 'nostr-tools/nip19';
import { bootstrapHostIdentity, readHostIdentityPublic, readHostIdentityAsync } from './host-identity.ts';
import { systemCredentials } from './credential-store.ts';
import { credentialHelperReader } from './credential-helper.ts';
import { membershipSigner } from './relay-admission.ts';
import { claimRelayMembershipInvite, relayHttpOrigin, verifyDirectMembershipEvidence } from './direct-membership.ts';
const quote = (s: string) => `'${s.replaceAll("'", "'\"'\"'")}'`;
/** Public owner input only; nsec and other NIP-19 payloads are never accepted. */
export function ownerPublicInput(input: string): string {
const value = input.trim();
if (/^[0-9a-fA-F]{64}$/.test(value)) return value.toLowerCase();
try { const decoded = decode(value); if (decoded.type === 'npub') return decoded.data; } catch { /* actionable public-input error below */ }
throw Error('Owner PUBLIC npub or 64-character hex key required; never enter a private key');
}
/** Existing Buzz invite formats, scoped to the retained relay; never retarget. */
export function hostInviteCode(input: string, relay: string): string {
const value = input.trim();
let code = value, origin: string | undefined;
if (value.includes('://')) {
const url = new URL(value);
if (url.username || url.password || url.hash) throw Error('Invalid invite link');
if (url.protocol === 'buzz:' && url.host === 'join') {
origin = url.searchParams.get('relay') ?? '';
code = url.searchParams.get('code') ?? '';
} else if (url.protocol === 'https:' || url.protocol === 'http:') {
const match = url.pathname.match(/^\/invite\/([^/]+)\/?$/);
if (!match) throw Error('Expected community /invite/<code> link');
origin = url.origin; code = decodeURIComponent(match[1]!);
} else throw Error('Expected community invite link or issued code');
if (relayHttpOrigin(origin).href !== relayHttpOrigin(relay).href) throw Error('Invite is for another relay; retained configuration unchanged');
} else if (value.includes('/')) throw Error('Expected community invite link or issued code');
if (!code || code.length > 1024 || /[\x00-\x20\x7f]/.test(code)) throw Error('Invalid issued invite code');
return code;
}
/** Configure/resume infrastructure only. No owner secret, approval, agent grant or Start. */
export async function hostSetup(directory: string): Promise<void> {
let retained = existsSync(join(directory, 'host-identity.json')) ? readHostIdentityPublic(directory) : undefined;
const ui = createInterface({ input: stdin, output: stdout });
const controller = new AbortController();
const stop = () => { controller.abort(Error('Setup cancelled; retained configuration unchanged')); ui.close(); };
process.on('SIGINT', stop); process.on('SIGTERM', stop); ui.on('SIGINT', stop);
const command = directory === join(homedir(), '.beehive', 'host') ? '' : ` ${quote(directory)}`;
try {
console.log('Configure this host with OWNER NPUB + RELAY. No infrastructure approval or file exchange. No agent, provider credentials or grants created.');
if (!retained) {
const owner = ownerPublicInput(await ui.question('Owner PUBLIC npub (hex also accepted): '));
const relay = (await ui.question('Management relay URL (wss://…): ')).trim();
relayHttpOrigin(relay);
if (!/^(wss|ws):\/\//.test(relay)) throw Error('Management relay requires wss:// (ws://127.0.0.1 for fixtures)');
console.log(`Computer name: ${hostname()}. Host folder: ${directory}`);
if (await ui.question('Create host identity in Beehive OS credentials? Owner present; OS access may prompt. [yes/no]: ') !== 'yes') return;
controller.signal.throwIfAborted();
retained = bootstrapHostIdentity(directory, hostname().slice(0, 128), owner, relay);
}
console.log(`Configured host: ${retained.pairing.host}\nOwner: ${retained.pairing.owner}\nRelay: ${retained.pairing.relay}\nIdentity, owner, relay and approval history retained. Not serving. Resume: beehive setup${command}`);
if (await ui.question('Check/join community using this host key? Owner present; OS credential read may prompt. [yes/no]: ') !== 'yes') return;
const identity = await readHostIdentityAsync(directory, { ...systemCredentials, readAsync: credentialHelperReader({ operatorApproved: true }) }, controller.signal);
const signer = membershipSigner(identity.secret), relay = identity.pairing.relay;
let evidence = await verifyDirectMembershipEvidence({ relay, signer, signal: controller.signal });
controller.signal.throwIfAborted();
if (evidence.status === 'denied') {
console.log('Community membership required for this host key. Use an operator-issued invite, or ask an administrator to admit the displayed host public key. Policy acceptance may require the community’s normal client/admin flow; this wizard cannot fabricate a policy receipt.');
const supplied = await ui.question('Issued invite code or link (Enter to cancel and resume later): ');
if (!supplied.trim()) return;
const inviteCode = hostInviteCode(supplied, relay);
if (await ui.question('Claim this issued invite for this host on the retained relay? [yes/no]: ') !== 'yes') return;
await claimRelayMembershipInvite({ relay, signer, inviteCode, signal: controller.signal });
evidence = await verifyDirectMembershipEvidence({ relay, signer, signal: controller.signal });
}
controller.signal.throwIfAborted();
if (evidence.status !== 'live-verified-member') throw Error(`Community membership pending (${evidence.status}); require a membership-enforced relay and fresh live check. Retry setup after network/operator action`);
console.log(`Community membership verified now; host configured, NOT serving. Start foreground deliberately: beehive host${command} --owner-present\nStartup rechecks membership and runtime support. Ctrl-C stops the host. No agent starts automatically.\nOn the trusted owner computer: beehive tui discover ${quote(relay)} ~/.beehive/owner\nOwner signer stays there. Use hosts to discover private availability; availability is not agent authorization. Provider configuration and explicit per-agent grants/placement remain separate.`);
} catch (error) {
console.error(`Setup incomplete; no host started. Preserve the host folder and credentials; no automatic reset. Resume: beehive setup${command}`);
throw error;
} finally { ui.close(); process.off('SIGINT', stop); process.off('SIGTERM', stop); }
}

View File

@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto';
import { finalizeEvent } from 'nostr-tools/pure';
import { publicKey } from './protocol.ts';
import { verifyDirectMembershipEvidence, type HostAuthorizationSigner } from './direct-membership.ts';
@@ -13,11 +14,13 @@ export const productionAdmission: ScopedRelayAdmission = {
admit() { throw Error('Host relay admission pending: verified ordinary direct membership required (relay admin: buzz-admin add-member --pubkey <host-public-key> --role member); NIP-OA prohibited'); },
};
/** Infrastructure or owner signs only its own fresh HTTP authentication; no OA. */
/** Infrastructure or owner signs only its own fresh HTTP authentication; no OA.
* Match Buzz CLI's nonce tag: same-second checks/claim retries must have distinct
* event IDs for the community-scoped NIP-98 replay guard. */
export function membershipSigner(secret: string): HostAuthorizationSigner {
return { hostPublicKey: publicKey(secret), async signHttpAuthentication(request) {
return JSON.stringify(finalizeEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '',
tags: [['u', request.url], ['method', request.method], ['payload', request.payloadSha256Hex]] }, Buffer.from(secret, 'hex')));
tags: [['u', request.url], ['method', request.method], ['payload', request.payloadSha256Hex], ['nonce', randomUUID()]] }, Buffer.from(secret, 'hex')));
} };
}
/** One fresh live row check authorizes ONE immediate connection. Reopen the command

View File

@@ -54,12 +54,12 @@ test(`actual owner TUI owner-public credential slots Start/Stop via private tran
const members = new Set([owner, a, b].map(publicKey));
const relay = await nostrFixture(publicKey(owner), members);
const one = registration(owner, a, relay.url), two = registration(owner, b, relay.url);
const catalog = verifyHostCatalog({ version: 1, owner: publicKey(owner), relay: relay.url, registrations: scenario === 'configured' ? [two] : [one, two] }, publicKey(owner), relay.url);
const catalog = verifyHostCatalog({ version: 1, owner: publicKey(owner), relay: relay.url, registrations: (scenario === 'configured' || scenario === 'installed') ? [two] : [one, two] }, publicKey(owner), relay.url);
const directory = join(root, 'host'); mkdirSync(directory);
const credentialFile = join(root, 'credentials.json');
const credentials = isolatedFileCredentials(credentialFile);
credentials.create(credentialReference('host', publicKey(a)), a);
writePrivate(join(directory, 'host-identity.json'), { version: 3, pairing: one.request, key: credentialReference('host', publicKey(a)), registration: scenario === 'configured' ? null : one });
writePrivate(join(directory, 'host-identity.json'), { version: 3, pairing: one.request, key: credentialReference('host', publicKey(a)), registration: (scenario === 'configured' || scenario === 'installed') ? null : one });
const conversation = installed ? await conversationRelayFixture(root, owner, publicKey(agent)) : undefined;
provisionCredentialSlot(directory, { host: publicKey(a), ownerPublic: publicKey(owner), runner: realpathSync(process.execPath), args: [resolve(installed ? 'test/conversation-harness-fixture.ts' : 'test/runner.ts')], workspace: root, mode: installed ? 'buzz-agent-databricks-v2' : 'fixture', ...(conversation ? { serviceHome: root, configDirectory: root, databricksHost: 'https://fixture.invalid', ...(!converting ? { conversation: { executable: realpathSync(process.env.BEEHIVE_REAL_BUZZ_ACP!), relay: conversation.url, replyTool: { executable: realpathSync(join(process.env.BEEHIVE_REAL_BUZZ_ACP!, '..', 'buzz')) } } } : {}) } : {}) }, agent, createGenesis(publicKey(owner), publicKey(agent), publicKey(a)), credentials);
const journalPath = join(directory, 'agents', publicKey(agent), 'journal.json');
@@ -85,6 +85,21 @@ test(`actual owner TUI owner-public credential slots Start/Stop via private tran
writeFileSync(helper, `import { readFileSync } from 'node:fs'; let input=''; for await (const b of process.stdin) input+=b; const secret=JSON.parse(readFileSync(${JSON.stringify(credentialFile)},'utf8'))[JSON.stringify(JSON.parse(input))]; process.stdout.write(JSON.stringify(secret ? {status:'present',secret} : {status:'missing'}));`);
const loader = join(root, 'credential-helper-loader.mjs');
writeFileSync(loader, `import { registerHooks } from 'node:module'; registerHooks({load(url,ctx,next){const r=next(url,ctx); if(!url.endsWith('/src/credential-helper.ts'))return r;return {...r,source:String(r.source).replace("new URL('./credential-helper-child.ts', import.meta.url)", ${JSON.stringify(`new URL(${JSON.stringify('file://' + helper)})`)})};}});`);
// Actual normal setup resumes both approved compatibility and unapproved
// installed reply/Stop fixtures, even when an agent manifest already exists.
const beforeSetup = readFileSync(join(directory, 'host-identity.json'));
const setupChild = spawn(process.execPath, ['--import', resolve('test/isolated-credentials-loader.ts'), '--import', loader, 'src/cli.ts', 'setup', directory], { env: { PATH: '/usr/bin:/bin', HOME: root, BEEHIVE_TEST_CREDENTIAL_FILE: credentialFile }, stdio: ['pipe', 'pipe', 'pipe'] });
let setupOutput = ''; setupChild.stdout.on('data', b => setupOutput += b); setupChild.stderr.on('data', b => setupOutput += b);
const setupExit = new Promise<number | null>(done => setupChild.on('close', done));
try {
for (let n = 0; n < 400 && !setupOutput.includes('Check/join community'); n++) { if (setupChild.exitCode !== null) break; await delay(20); }
assert.match(setupOutput, /Check\/join community/); setupChild.stdin.write('yes\n');
assert.equal(await setupExit, 0, setupOutput);
assert.match(setupOutput, /membership verified now; host configured, NOT serving/);
assert.ok(!setupOutput.includes('Issued invite code'));
assert.deepEqual(readFileSync(join(directory, 'host-identity.json')), beforeSetup);
assert.equal(readFileSync(join(directory, 'setup.json'), 'utf8'), manifest);
} finally { if (setupChild.exitCode === null && setupChild.signalCode === null) setupChild.kill('SIGKILL'); await setupExit; }
const hostChild = spawn(process.execPath, ['--import', resolve('test/isolated-credentials-loader.ts'), '--import', loader, 'src/cli.ts', 'host', directory, ...(installed ? [relay.url] : []), '--owner-present'], { env: { PATH: '/usr/bin:/bin', HOME: root, BEEHIVE_TEST_CREDENTIAL_FILE: credentialFile }, stdio: ['ignore','pipe','pipe'] });
let hostOutput = ''; hostChild.stdout.on('data', b => hostOutput += b); hostChild.stderr.on('data', b => hostOutput += b);
const hostExit = new Promise<number | null>(resolve => hostChild.on('close', resolve));
@@ -165,7 +180,7 @@ test(`actual owner TUI owner-public credential slots Start/Stop via private tran
assert.equal(Object.keys(journal.operations).length, converting ? 3 : 2);
} finally { ownerWire.close(); }
assert.equal(foreignMessages.length, 0, 'another admitted host cannot read owner inventory or commands');
assert.deepEqual(relay.liveChecks.sort(), [publicKey(a), publicKey(owner)].sort());
assert.deepEqual(relay.liveChecks.sort(), [publicKey(a), publicKey(a), publicKey(owner)].sort());
assert.ok(relay.history.every(e => e.kind === 1059 && e.tags.length === 1));
assert.ok(!output.includes(owner)); assert.ok(!output.includes(a)); assert.ok(!output.includes(agent));
const state = JSON.parse(readFileSync(journalPath, 'utf8'));

View File

@@ -14,7 +14,7 @@ import { createGenesis } from '../src/assignment.ts';
import { writePrivate } from '../src/storage.ts';
import { pairingFingerprint, registerHost } from '../src/host-registration.ts';
async function wizard(directory: string, steps: [string, string][], credentialFile: string, expectedCode = 0, home?: string, command = 'setup', extra: string[] = []): Promise<string> {
async function wizard(directory: string, steps: [string, string][], credentialFile: string, expectedCode = 0, home?: string, command = 'legacy-enrollment', extra: string[] = []): Promise<string> {
const env: NodeJS.ProcessEnv = { ...process.env, ...(home ? { HOME: home } : {}), BEEHIVE_TEST_CREDENTIAL_FILE: credentialFile };
delete env.BUZZ_PRIVATE_KEY; delete env.BUZZ_AUTH_TAG; delete env.BUZZ_RELAY_URL;
const child = spawn(process.execPath, ['--import', fileURLToPath(new URL('./isolated-credentials-loader.ts', import.meta.url)), fileURLToPath(new URL('../src/cli.ts', import.meta.url)), command, directory, ...extra], { env, stdio: ['pipe', 'pipe', 'pipe'] });

View File

@@ -1,3 +1,6 @@
import { npubEncode } from 'nostr-tools/nip19';
import { hostInviteCode, ownerPublicInput } from '../src/host-setup.ts';
import { readHostIdentityPublic } from '../src/host-identity.ts';
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
@@ -27,13 +30,8 @@ test('actual zero-agent host CLI advertises privately; file-free owner TUI disco
const owner = newKey(), stranger = newKey();
const file = join(root, 'credentials.json'), credentials = isolatedFileCredentials(file);
const directory = join(root, 'host');
// Retained identity is created once, with no approval or agent slot.
const identity = bootstrapHostIdentity(directory, 'Empty laptop', publicKey(owner), 'wss://example.invalid', credentials);
const relay = await nostrFixture(publicKey(owner), new Set([publicKey(owner), identity.pairing.host, publicKey(stranger)]));
// Fixture-only initial URL assignment before any runtime; never a production migration.
identity.pairing.relay = relay.url;
writePrivate(join(directory, 'host-identity.json'), { version: 3, pairing: identity.pairing, key: credentialReference('host', identity.pairing.host), registration: null });
const retained = readFileSync(join(directory, 'host-identity.json'));
const invites = { code: 'issued-fixture-code', policyRequired: true };
const relay = await nostrFixture(publicKey(owner), new Set([publicKey(owner), publicKey(stranger)]), invites);
const reads = join(root, 'reads.jsonl');
const helper = join(root, 'credential-child.mjs');
writeFileSync(helper, `import {readFileSync,appendFileSync} from 'node:fs';let input='';for await(const b of process.stdin)input+=b;appendFileSync(${JSON.stringify(reads)},input+'\\n');const secret=JSON.parse(readFileSync(${JSON.stringify(file)},'utf8'))[JSON.stringify(JSON.parse(input))];process.stdout.write(JSON.stringify(secret?{status:'present',secret}:{status:'missing'}));`);
@@ -47,6 +45,39 @@ test('actual zero-agent host CLI advertises privately; file-free owner TUI disco
const exit = new Promise<number | null>(done => child.on('close', done));
return { child, output: () => output, exit };
}
async function setup(steps: [string, string][], code = 0) {
const run = cli(['setup', directory]);
let cursor = 0;
for (const [prompt, answer] of steps) {
await wait(() => run.output().slice(cursor).includes(prompt), run.output);
cursor = run.output().length; run.child.stdin!.write(answer + '\n');
}
assert.equal(await run.exit, code, run.output());
assert.ok(!run.output().includes('Host online'));
return run.output();
}
await setup([['Owner PUBLIC npub', npubEncode(publicKey(owner))], ['Management relay URL', relay.url], ['Create host identity', 'yes'], ['Check/join community', 'yes'], ['Issued invite code', '']]);
const identity = readHostIdentityPublic(directory);
assert.equal(identity.pairing.owner, publicKey(owner));
assert.equal(identity.registration, null);
const retained = readFileSync(join(directory, 'host-identity.json'));
const retainedKeys = readFileSync(file);
// Retained unapproved f215-style state resumes without replacement or exchange.
const rejectedPolicy = await setup([['Check/join community', 'yes'], ['Issued invite code', invites.code], ['Claim this issued invite', 'yes']], 1);
assert.match(rejectedPolicy, /policy_acceptance_required/);
assert.deepEqual(readFileSync(file), retainedKeys);
invites.policyRequired = false;
await setup([['Check/join community', 'yes'], ['Issued invite code', `http://${new URL(relay.url).host}/invite/${invites.code}`], ['Claim this issued invite', 'no']]);
assert.equal(relay.claims.length, 1);
const joined = await setup([['Check/join community', 'yes'], ['Issued invite code', `buzz://join?relay=${encodeURIComponent(relay.url)}&code=${invites.code}`], ['Claim this issued invite', 'yes']]);
assert.match(joined, /membership verified now; host configured, NOT serving/);
assert.match(joined, /beehive tui discover/);
const member = await setup([['Check/join community', 'yes']]);
assert.ok(!member.includes('Issued invite code'));
assert.deepEqual(relay.claims, [identity.pairing.host, identity.pairing.host]);
assert.deepEqual(readFileSync(join(directory, 'host-identity.json')), retained);
assert.deepEqual(readFileSync(file), retainedKeys);
writeFileSync(reads, ''); // Runtime-read count starts after the explicitly consented setup reads.
const host = cli(['host', directory, '--owner-present']);
let ownerWire: ReturnType<ReturnType<typeof catalogTransport>> | undefined;
let wrong: ReturnType<typeof connectNostr> | undefined;
@@ -61,7 +92,7 @@ test('actual zero-agent host CLI advertises privately; file-free owner TUI disco
assert.equal(reports.filter(m => m.type === 'inventory').length, 0);
assert.ok(relay.liveChecks.includes(identity.pairing.host));
assert.ok(relay.history.every(e => JSON.stringify(e.tags) === JSON.stringify([['p', publicKey(owner)]])));
assert.ok(relay.history.every(e => !e.content.includes('Empty laptop') && !e.content.includes(identity.pairing.host)));
assert.ok(relay.history.every(e => !e.content.includes(identity.pairing.label) && !e.content.includes(identity.pairing.host)));
const leaked: unknown[] = [];
wrong = connectNostr(relay.url, Buffer.from(stranger, 'hex'), undefined, m => leaked.push(m), () => {});
await wrong.ready;
@@ -117,6 +148,14 @@ test('actual zero-agent host CLI advertises privately; file-free owner TUI disco
assert.match(denied.output(), /Direct membership not verified/);
assert.ok(!denied.output().includes('Host online'));
assert.ok(!relay.liveChecks.includes(pending.pairing.host));
// Missing retained host credential is not repaired or regenerated by resume.
credentials.remove(credentialReference('host', identity.pairing.host));
const missingKeys = readFileSync(file);
const missing = await setup([['Check/join community', 'yes']], 1);
assert.match(missing, /Local host key missing/);
assert.deepEqual(readFileSync(file), missingKeys);
assert.deepEqual(readFileSync(join(directory, 'host-identity.json')), retained);
} finally {
ownerWire?.close(); wrong?.close();
for (const child of children) if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
@@ -143,3 +182,14 @@ test('availability schema and catalog fences: scope, signer, freshness, no fabri
assert.match(view.rows(now)[0]!.status, /available infrastructure/);
assert.equal(view.rows(now + 7000)[0]!.status, 'unreachable/unknown');
});
test('normal public owner and issued invite inputs preserve relay binding', () => {
const owner = publicKey(newKey());
assert.equal(ownerPublicInput(npubEncode(owner)), owner);
assert.equal(ownerPublicInput(owner.toUpperCase()), owner);
assert.throws(() => ownerPublicInput('nsec1invalid'));
assert.equal(hostInviteCode('issued-code', 'wss://example.invalid'), 'issued-code');
assert.equal(hostInviteCode('https://example.invalid/invite/code/', 'wss://example.invalid'), 'code');
assert.throws(() => hostInviteCode('https://other.invalid/invite/code', 'wss://example.invalid'));
assert.throws(() => hostInviteCode('buzz://join?relay=wss://other.invalid&code=code', 'wss://example.invalid'));
});

View File

@@ -5,6 +5,8 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSyn
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { hostname } from 'node:os';
import { npubEncode } from 'nostr-tools/nip19';
import { newKey, publicKey } from '../src/protocol.ts';
/** Launcher-focused smoke: the installed symlink must behave exactly like the
@@ -39,8 +41,8 @@ async function runLauncher(installed: string, args: string[], env: NodeJS.Proces
}
const createJourney = (owner: string): [string, string][] => [
['Enrollment [', 'create'], ['Computer name [', 'launcher-host'], ['Owner PUBLIC key (hex,', publicKey(owner)],
['Management relay URL (', 'wss://example.invalid'],
['Owner PUBLIC npub', npubEncode(publicKey(owner))], ['Management relay URL (', 'wss://example.invalid'],
['Create host identity', 'yes'], ['Check/join community', 'no'],
];
function installInto(root: string, name = 'user bin dir'): string {
@@ -86,22 +88,22 @@ test('no-arg setup enrolls on the displayed default host folder via the isolated
assert.ok(created.output.includes(`Default host folder: ${folder}`), created.output);
const identity = JSON.parse(readFileSync(join(folder, 'host-identity.json'), 'utf8')) as { version: number; pairing: { host: string; owner: string; label: string }; registration: unknown };
assert.equal(identity.version, 3);
assert.equal(identity.pairing.label, 'launcher-host');
assert.equal(identity.pairing.label, hostname().slice(0, 128));
assert.equal(identity.pairing.owner, publicKey(owner));
assert.ok(/^[0-9a-f]{64}$/.test(identity.pairing.host));
assert.equal(identity.registration, null);
const fixture = readFileSync(credentials, 'utf8');
assert.ok(fixture.includes(identity.pairing.host), 'host key stored in the isolated fixture');
assert.equal(Object.keys(JSON.parse(fixture)).length, 1);
const files = [join(folder, 'host-identity.json'), join(folder, 'exchange', readdirSync(join(folder, 'exchange'))[0]!), credentials];
const files = [join(folder, 'host-identity.json'), credentials];
for (const value of [...files.map(file => readFileSync(file, 'utf8')), created.output]) assert.ok(!value.includes(owner), 'owner secret never persisted or printed');
// A second default setup must refuse to replace the retained identity.
const before = readFileSync(join(folder, 'host-identity.json'), 'utf8');
const second = await runLauncher(installed, ['setup'], isolatedEnv(home, credentials), [
['Enrollment [', 'create'],
['Check/join community', 'no'],
], cwd);
assert.equal(second.code, 1, second.output);
assert.ok(second.output.includes('Host identity already exists. Rerun setup'), second.output);
assert.equal(second.code, 0, second.output);
assert.ok(second.output.includes('Identity, owner, relay and approval history retained'), second.output);
assert.equal(readFileSync(join(folder, 'host-identity.json'), 'utf8'), before, 'retained identity unchanged');
assert.ok(!existsSync(join(root, 'second request.json')));
} finally { rmSync(root, { recursive: true, force: true }); }

View File

@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { verifyEvent } from 'nostr-tools/pure';
import { membershipSigner } from '../src/relay-admission.ts';
import { verifyDirectMembershipEvidence } from '../src/direct-membership.ts';
import { newKey, publicKey } from '../src/protocol.ts';
import { nostrFixture } from './nostr-fixture.ts';
test('same-second production HTTP signers use fresh IDs; fixture refuses real replay', async t => {
const now = Date.now(); t.mock.method(Date, 'now', () => now);
const secret = newKey(), signer = membershipSigner(secret);
const request = { url: 'https://example.invalid/query', method: 'POST' as const, payloadSha256Hex: 'a'.repeat(64) };
const first = JSON.parse(await signer.signHttpAuthentication(request));
const second = JSON.parse(await membershipSigner(secret).signHttpAuthentication(request));
assert.notEqual(first.id, second.id); assert.equal(first.created_at, second.created_at);
for (const event of [first, second]) {
assert.ok(verifyEvent(event)); assert.equal(event.pubkey, publicKey(secret));
assert.equal(event.kind, 27235); assert.equal(event.content, '');
assert.deepEqual(event.tags.slice(0, 3), [['u', request.url], ['method', 'POST'], ['payload', request.payloadSha256Hex]]);
assert.equal(event.tags[3][0], 'nonce');
}
const relay = await nostrFixture(publicKey(secret), new Set([publicKey(secret)]));
let signed: string | undefined;
const replaySigner = { hostPublicKey: signer.hostPublicKey, async signHttpAuthentication(input: typeof request) { signed ??= await signer.signHttpAuthentication(input); return signed; } };
try {
assert.equal((await verifyDirectMembershipEvidence({ relay: relay.url, signer: replaySigner })).status, 'live-verified-member');
assert.equal((await verifyDirectMembershipEvidence({ relay: relay.url, signer: replaySigner })).status, 'unknown');
assert.equal((await verifyDirectMembershipEvidence({ relay: relay.url, signer: membershipSigner(secret) })).status, 'live-verified-member');
} finally { await relay.close(); }
});

View File

@@ -11,12 +11,14 @@ import { verifyHostAttestation } from '../src/host-attestation.ts';
* explicit giftwrap author/connection mismatch exception. req.rs p-gate.
* No DB/token/push implementation; fixtures grant member MessagesWrite only.
*/
export async function nostrFixture(owner: string, independentMembers?: ReadonlySet<string>) {
export async function nostrFixture(owner: string, independentMembers?: ReadonlySet<string>, invites?: { code: string; policyRequired?: boolean }) {
// api/mod.rs direct membership is distinct from ViaOwner. This is fixture
// policy only, NOT evidence that deployed membership has narrow permissions.
const members = independentMembers ? new Set(independentMembers) : undefined;
const self = newKey();
const liveChecks: string[] = [];
const claims: string[] = [];
const httpEvents = new Set<string>();
const http = createServer(async (req, res) => {
res.setHeader('Content-Type', 'application/json');
if (req.method === 'GET' && req.url === '/') { res.end(JSON.stringify({ self: publicKey(self), supported_nips: members ? [43] : [] })); return; }
@@ -24,7 +26,17 @@ export async function nostrFixture(owner: string, independentMembers?: ReadonlyS
try {
const auth = JSON.parse(Buffer.from((req.headers.authorization ?? '').replace(/^Nostr /, ''), 'base64').toString());
if (!verifyEvent(auth) || auth.kind !== 27235 || auth.content !== '' || Math.abs(auth.created_at - Math.floor(Date.now()/1000)) > 60 ||
JSON.stringify(auth.tags) !== JSON.stringify([['u', url.replace('ws:', 'http:') + req.url], ['method', 'POST'], ['payload', digest(body).toString('hex')]])) throw Error('auth');
JSON.stringify(auth.tags.filter((tag: string[]) => tag[0] !== 'nonce')) !== JSON.stringify([['u', url.replace('ws:', 'http:') + req.url], ['method', 'POST'], ['payload', digest(body).toString('hex')]])) throw Error('auth');
if (httpEvents.has(auth.id)) { res.writeHead(401).end(JSON.stringify({ error: 'nip98_replay' })); return; }
httpEvents.add(auth.id);
if (req.url === '/api/invites/claim' && req.method === 'POST' && members && invites) {
claims.push(auth.pubkey);
const input = JSON.parse(body);
if (input.code !== invites.code) { res.writeHead(403).end(JSON.stringify({ error: 'invite_invalid' })); return; }
if (invites.policyRequired) { res.writeHead(403).end(JSON.stringify({ error: 'policy_acceptance_required' })); return; }
const status = members.has(auth.pubkey) ? 'already_member' : 'joined'; members.add(auth.pubkey);
res.end(JSON.stringify({ status, community_id: 'fixture', host: new URL(url).host, role: 'member' })); return;
}
if (!members?.has(auth.pubkey)) { res.writeHead(403).end(JSON.stringify({ error: 'relay_membership_required' })); return; }
if (req.url !== '/query' || req.method !== 'POST') throw Error('route');
liveChecks.push(auth.pubkey);
@@ -78,5 +90,5 @@ export async function nostrFixture(owner: string, independentMembers?: ReadonlyS
}
});
});
return { url, history, liveChecks, get independentWraps() { return independentWraps; }, async close() { for (const peer of connections.keys()) peer.terminate(); await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve())); await new Promise<void>((resolve, reject) => http.close(error => error ? reject(error) : resolve())); } };
return { url, history, liveChecks, claims, get independentWraps() { return independentWraps; }, async close() { for (const peer of connections.keys()) peer.terminate(); await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve())); await new Promise<void>((resolve, reject) => http.close(error => error ? reject(error) : resolve())); } };
}