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:
11
packages/db/migrations/0017_mesh-slug-non-unique.sql
Normal file
11
packages/db/migrations/0017_mesh-slug-non-unique.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Drop global uniqueness on mesh.slug.
|
||||
--
|
||||
-- Identity for a mesh is mesh.id (opaque, generated). The slug is now
|
||||
-- cosmetic only — derived from the display name at creation time and
|
||||
-- embedded in invite payloads for debugging/display. Two meshes may
|
||||
-- freely share a slug.
|
||||
--
|
||||
-- Safe to run on populated tables: the constraint is removed, no data
|
||||
-- is altered, no rows are locked for content changes.
|
||||
|
||||
ALTER TABLE "mesh"."mesh" DROP CONSTRAINT IF EXISTS "mesh_slug_unique";
|
||||
20
packages/db/migrations/0018_invite-short-code.sql
Normal file
20
packages/db/migrations/0018_invite-short-code.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Add a short opaque URL-shortener code to mesh invites.
|
||||
--
|
||||
-- Purpose: make invite URLs human-friendly (claudemesh.com/i/abc12345)
|
||||
-- instead of ~400 char base64url payloads. The short code resolves
|
||||
-- server-side to the existing long token — the broker protocol and
|
||||
-- canonical signed payload are UNCHANGED.
|
||||
--
|
||||
-- This is NOT the v2 invite protocol (see spec
|
||||
-- .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md).
|
||||
-- It is a backward-compatible URL shortener only. The root_key is
|
||||
-- still embedded in the underlying long token; v2 will address that
|
||||
-- in a coordinated broker + CLI + web change.
|
||||
--
|
||||
-- Column is nullable so existing invites remain valid without backfill.
|
||||
|
||||
ALTER TABLE "mesh"."invite" ADD COLUMN IF NOT EXISTS "code" text;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "invite_code_unique_idx"
|
||||
ON "mesh"."invite" ("code")
|
||||
WHERE "code" IS NOT NULL;
|
||||
54
packages/db/migrations/0019_invite-v2-and-email.sql
Normal file
54
packages/db/migrations/0019_invite-v2-and-email.sql
Normal file
@@ -0,0 +1,54 @@
|
||||
-- v2 invite protocol + email invites.
|
||||
--
|
||||
-- Spec: .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md
|
||||
--
|
||||
-- Two concerns in one migration (both touch the invite surface):
|
||||
--
|
||||
-- 1. v2 invite protocol — the mesh root_key no longer travels in the
|
||||
-- invite URL. Instead the recipient generates a curve25519 keypair at
|
||||
-- claim time and sends the pubkey to the broker; the broker seals
|
||||
-- root_key with crypto_box_seal to that pubkey. The DB captures the
|
||||
-- protocol version, the canonical signed bytes that the broker
|
||||
-- re-verifies against mesh.owner_pubkey, and an audit-only record of
|
||||
-- which recipient pubkey received the sealed key.
|
||||
--
|
||||
-- 2. Email invites — admins can send invites to an email address. A
|
||||
-- pending_invite row tracks the send; when the recipient lands on
|
||||
-- /i/{code} it is matched to an underlying mesh.invite row (mint on
|
||||
-- send). acceptedAt / revokedAt capture lifecycle.
|
||||
--
|
||||
-- Both additions are backward-compatible: version defaults to 1, new
|
||||
-- columns are nullable, the new table is independent of existing rows.
|
||||
|
||||
ALTER TABLE "mesh"."invite"
|
||||
ADD COLUMN IF NOT EXISTS "version" integer NOT NULL DEFAULT 1;
|
||||
|
||||
ALTER TABLE "mesh"."invite"
|
||||
ADD COLUMN IF NOT EXISTS "capability_v2" text;
|
||||
|
||||
ALTER TABLE "mesh"."invite"
|
||||
ADD COLUMN IF NOT EXISTS "claimed_by_pubkey" text;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "mesh"."pending_invite" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"mesh_id" text NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"code" text NOT NULL,
|
||||
"sent_at" timestamp DEFAULT now() NOT NULL,
|
||||
"accepted_at" timestamp,
|
||||
"revoked_at" timestamp,
|
||||
"created_by" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "pending_invite_mesh_id_fk"
|
||||
FOREIGN KEY ("mesh_id") REFERENCES "mesh"."mesh"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "pending_invite_created_by_fk"
|
||||
FOREIGN KEY ("created_by") REFERENCES "public"."user"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "pending_invite_email_idx"
|
||||
ON "mesh"."pending_invite" ("email");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "pending_invite_mesh_idx"
|
||||
ON "mesh"."pending_invite" ("mesh_id");
|
||||
Reference in New Issue
Block a user