feat(db): owner_secret_key + root_key columns on mesh for server-side signing

Completes the server-side invite-signing story. The web UI's
create-invite flow needs the mesh owner's ed25519 SECRET key to sign
each invite payload; these columns let the backend hold + use them
per mesh.

- mesh.mesh.owner_secret_key (text, nullable): ed25519 secret key
  (hex, 64 bytes) paired with owner_pubkey. Stored PLAINTEXT AT REST
  for v0.1.0. Acceptable trade-off for a managed-broker SaaS launch —
  the operator controls the key anyway. v0.2.0 will either encrypt
  with a column-level KEK or migrate to client-held keys.
- mesh.mesh.root_key (text, nullable): 32-byte shared key
  (base64url, no padding) used by channel/broadcast encryption in
  later steps. Embedded in every invite so joiners receive it at
  join time.

migrations/0002_vengeful_enchantress.sql — two ALTER TABLE ADD
COLUMN. Nullable so existing rows don't need backfill to migrate;
the backfill script populates them idempotently.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-04 23:11:46 +01:00
parent 533dcc11f6
commit 1c773be577
4 changed files with 2859 additions and 0 deletions

View File

@@ -89,6 +89,23 @@ export const mesh = meshSchema.table("mesh", {
* rows; required for new meshes.
*/
ownerPubkey: text(),
/**
* ed25519 secret key (hex, 64 bytes) that signs invites server-side.
*
* v0.1.0: stored plaintext-at-rest. Acceptable trade-off for a
* managed-broker SaaS launch — the operator controls the key.
* v0.2.0 will either (a) encrypt-at-rest with a column-level KEK,
* or (b) migrate to client-held keys so the server never holds
* admin material.
*/
ownerSecretKey: text(),
/**
* 32-byte shared key (base64url) used by channels/broadcasts in the
* mesh. Embedded in invites so joiners can encrypt/decrypt channel
* traffic. Not used by 1:1 direct messages (those use crypto_box
* with recipient's ed25519 pubkey).
*/
rootKey: text(),
createdAt: timestamp().defaultNow().notNull(),
archivedAt: timestamp(),
});