Files
claudemesh/apps/broker/scripts/backfill-owner-pubkey.ts
Alejandro Gutiérrez 759a22e7c0
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
fix(api): sign invites with stored owner keypair instead of unsigned placeholder
Production /join on the broker (from feat 18c) rejects every invite
with invite_bad_signature because the web UI was emitting unsigned
payloads. This fixes that.

createMyMesh now generates ed25519 owner keypair + 32-byte root key
and stores all three on the mesh row. createMyInvite loads them,
signs the canonical invite bytes via crypto_sign_detached, and
emits a fully-signed payload matching what the broker expects:

  payload = {v, mesh_id, mesh_slug, broker_url, expires_at,
             mesh_root_key, role, owner_pubkey, signature}
  canonical = same fields minus signature, "|"-delimited
  signature = ed25519_sign(canonical, mesh.owner_secret_key)
  token = base64url(JSON(payload))   ← stored as invite.token

The base64url(JSON) token IS the DB lookup key — broker's /join
does `WHERE invite.token = <that string>`, then re-verifies the
signature it extracts from the decoded payload.

Also drops the sha256 derivePlaceholderRootKey() helper and the
encodeInviteLink helper, both replaced by inline logic.

backfill extended: the one-off script now populates owner_pubkey
AND owner_secret_key AND root_key together in a single pass. Query
condition is `WHERE any of the three IS NULL`, so running it
post-migration catches every row regardless of partial prior fills.

requires packages/api to depend on libsodium-wrappers + types
(added). 64/64 broker tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:12:04 +01:00

82 lines
2.2 KiB
TypeScript

#!/usr/bin/env bun
/**
* One-off backfill: populate owner_pubkey + owner_secret_key +
* root_key for meshes created before Step 18c crypto landed.
*
* Runs idempotently: only touches rows where ANY of those three
* columns is NULL. Generates a fresh keypair + root key per mesh
* and stores ALL THREE server-side (invites are signed server-side
* by the web UI's create-invite flow, so it needs the secret key).
*
* Usage:
* DATABASE_URL=... bun apps/broker/scripts/backfill-owner-pubkey.ts
*
* Output (stdout): one tab-separated row per patched mesh:
* <mesh_id> <mesh_slug> <owner_pubkey> <owner_secret_key> <root_key>
*/
import sodium from "libsodium-wrappers";
import { eq, isNull, or } from "drizzle-orm";
import { db } from "../src/db";
import { mesh } from "@turbostarter/db/schema/mesh";
async function main(): Promise<void> {
await sodium.ready;
const missing = await db
.select({
id: mesh.id,
slug: mesh.slug,
ownerPubkey: mesh.ownerPubkey,
ownerSecretKey: mesh.ownerSecretKey,
rootKey: mesh.rootKey,
})
.from(mesh)
.where(
or(
isNull(mesh.ownerPubkey),
isNull(mesh.ownerSecretKey),
isNull(mesh.rootKey),
)!,
);
if (missing.length === 0) {
console.error("[backfill] no rows to patch");
return;
}
console.error(`[backfill] patching ${missing.length} mesh(es)`);
for (const row of missing) {
const kp = sodium.crypto_sign_keypair();
const pubHex = sodium.to_hex(kp.publicKey);
const secHex = sodium.to_hex(kp.privateKey);
const rootKey = sodium.to_base64(
sodium.randombytes_buf(32),
sodium.base64_variants.URLSAFE_NO_PADDING,
);
await db
.update(mesh)
.set({
ownerPubkey: pubHex,
ownerSecretKey: secHex,
rootKey,
})
.where(eq(mesh.id, row.id));
console.log(
`${row.id}\t${row.slug}\t${pubHex}\t${secHex}\t${rootKey}`,
);
console.error(`[backfill] patched mesh "${row.slug}" (${row.id})`);
}
console.error("[backfill] done.");
}
main()
.then(() => process.exit(0))
.catch((e) => {
console.error(
"[backfill] error:",
e instanceof Error ? e.message : String(e),
);
process.exit(1);
});