feat: anthropic-style mesh + invite redesign (wave 1 checkpoint)

Ships the user-visible friction fixes and the foundation for the v2
invite protocol. API wiring + CLI client + email UI ship in wave 2.

Meshes — shipped
- Drop global UNIQUE on mesh.slug; mesh.id is canonical everywhere
- Server derives slug from name; create form has no slug field
- Two users can freely name their mesh "platform"; no collision errors
- Migration 0017

Invites v1 — shipped (URL shortener, backward compatible)
- New invite.code column (base62, 8 chars, nullable unique index)
- createMyInvite mints both token + short code; returns shortUrl
- GET /api/public/invite-code/:code resolves short code to token
- New route /i/[code] server-redirects to /join/[token]
- Invite generator UI shows short URL; QR encodes short URL
- Advanced fields (role/maxUses/expiresInDays) collapsed under disclosure
- Migration 0018

Invites v2 — foundation (broker + DB only; API+CLI+Web wiring in wave 2)
- Broker: canonicalInviteV2, verifyInviteV2, sealRootKeyToRecipient
- Broker: POST /invites/:code/claim endpoint (atomic single-use accounting)
- Broker tests: invite-v2.test.ts (signature, expiry, revocation, exhaustion)
- DB: mesh.invite gains version/capabilityV2/claimedByPubkey columns
- DB: new mesh.pending_invite table for email invites
- Migration 0019
- Contract locked in docs/protocol.md §v2 + SPEC.md §14b

Consent landing — shipped
- /join/[token] redesigned: explicit role, inviter, mesh stats, consent
- New server components: invite-card, role-badge, inviter-line, consent-summary
- "Join [mesh] as [Role]" primary action (not just "Join")

Error surfacing — shipped
- handle() now parses {error} responses from hono route catch blocks
- onError fallback includes timestamp so handle() can match apiErrorSchema
- Real error messages reach the UI instead of "Something went wrong"

Docs
- SPEC.md §14b: v2 invite protocol
- docs/protocol.md: v2 claim wire format
- docs/roadmap.md: status
- .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md

Deferred to wave 2/3
- API claim route wiring (packages/api)
- createMyInvite v2 capability generation
- Email invite mutation + Postmark delivery
- CLI v2 join flow (x25519 keypair + unseal)
- Web invite-generator email field + v2 display

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-10 13:41:11 +01:00
parent dbea96960f
commit c1fa3bcb5c
24 changed files with 1932 additions and 196 deletions

View File

@@ -78,7 +78,13 @@ export const messagePriorityEnum = meshSchema.enum("message_priority", [
export const mesh = meshSchema.table("mesh", {
id: text().primaryKey().notNull().$defaultFn(generateId),
name: text().notNull(),
slug: text().notNull().unique(),
/**
* Cosmetic slug derived from name at creation. NOT unique, NOT used for
* identity — `mesh.id` is the canonical identifier everywhere (URLs,
* invites, broker lookups). Kept for display/debugging only. Two meshes
* can freely share a slug.
*/
slug: text().notNull(),
ownerUserId: text()
.references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
@@ -176,6 +182,15 @@ export const invite = meshSchema.table("invite", {
.notNull(),
token: text().notNull().unique(),
tokenBytes: text(),
/**
* Short opaque URL shortener code (base62, 8 chars). Resolves server-side
* to the full canonical `token` for landing page rendering. Nullable for
* pre-shortcode invites. Not a capability boundary — the long token still
* carries the root_key. See .artifacts/specs/2026-04-10-anthropic-vision-
* meshes-invites.md for the v2 protocol that moves the root_key out of
* the URL entirely.
*/
code: text().unique(),
maxUses: integer().notNull().default(1),
usedCount: integer().notNull().default(0),
role: meshRoleEnum().notNull().default("member"),
@@ -192,8 +207,47 @@ export const invite = meshSchema.table("invite", {
.notNull(),
createdAt: timestamp().defaultNow().notNull(),
revokedAt: timestamp(),
/** Protocol version — 1 = legacy (root_key in URL), 2 = sealed delivery. Default 1 for backward compat. */
version: integer().notNull().default(1),
/**
* v2 canonical signed bytes (the string the broker re-verifies against mesh.ownerPubkey).
* Format: `v=2|mesh_id|invite_id|expires_at|role|owner_pubkey`
* Nullable for legacy v1 rows.
*/
capabilityV2: text(),
/**
* Recipient curve25519 pubkey (base64url) that the mesh root_key was sealed to
* when this invite was claimed. Audit-only — do NOT use as an authN check.
* Nullable until claim.
*/
claimedByPubkey: text(),
});
/**
* Tracks invites sent by email — one row per (mesh, email) pairing.
* `code` references an underlying mesh.invite row that will be minted
* on send; when the recipient lands on /i/{code} they claim the real invite.
*/
export const pendingInvite = meshSchema.table("pending_invite", {
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
email: text().notNull(),
/** The short code of the underlying `mesh.invite.code` row this email links to. */
code: text().notNull(),
sentAt: timestamp().defaultNow().notNull(),
acceptedAt: timestamp(),
revokedAt: timestamp(),
createdBy: text()
.references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
createdAt: timestamp().defaultNow().notNull(),
}, (table) => [
index("pending_invite_email_idx").on(table.email),
index("pending_invite_mesh_idx").on(table.meshId),
]);
/**
* Signed, hash-chained audit log. NEVER stores message content — every
* payload between peers is E2E encrypted client-side (libsodium), so
@@ -687,6 +741,11 @@ export const inviteRelations = relations(invite, ({ one }) => ({
}),
}));
export const pendingInviteRelations = relations(pendingInvite, ({ one }) => ({
mesh: one(mesh, { fields: [pendingInvite.meshId], references: [mesh.id] }),
inviter: one(user, { fields: [pendingInvite.createdBy], references: [user.id] }),
}));
export const auditLogRelations = relations(auditLog, ({ one }) => ({
mesh: one(mesh, {
fields: [auditLog.meshId],