From d1c85d58f6c9f8cc0c776e4721299ab6a8e2ed32 Mon Sep 17 00:00:00 2001 From: krusty-agent Date: Thu, 19 Feb 2026 10:06:38 -0800 Subject: [PATCH] feat: resource-based list sharing via did:webvh Lists are now Originals resources under the user's DID: did:webvh:{scid}:trypoo.app:user-abc123/resources/list-{id} Instead of creating separate DIDs per list or using service endpoints, lists are addressable resources at a sub-path of the user's DID. Changes: - Add DID log storage in Convex (didLogs table + HTTP endpoints) - Add public resource serving at /{userPath}/resources/list-{id} - Add DID log resolution at /{userPath}/did.jsonl - Update ShareModal and PublishModal to use resource DID URIs - Add SharedListResource component for read-only shared list view - Remove old addListServiceToDid/createListWebVHDid approaches - Make didDocument/didLog optional in publication mutation --- convex/didLogs.ts | 83 ++++++++++ convex/didLogsHttp.ts | 103 +++++++++++++ convex/didResources.ts | 76 ++++++++++ convex/didResourcesHttp.ts | 171 +++++++++++++++++++++ convex/http.ts | 15 ++ convex/publication.ts | 4 +- convex/schema.ts | 10 ++ src/App.tsx | 2 + src/components/ShareModal.tsx | 26 ++-- src/components/SharedListResource.tsx | 194 ++++++++++++++++++++++++ src/components/publish/PublishModal.tsx | 22 +-- src/hooks/useAuth.tsx | 15 ++ src/lib/webvh.ts | 127 ++++++++++------ 13 files changed, 768 insertions(+), 80 deletions(-) create mode 100644 convex/didLogs.ts create mode 100644 convex/didLogsHttp.ts create mode 100644 convex/didResources.ts create mode 100644 convex/didResourcesHttp.ts create mode 100644 src/components/SharedListResource.tsx diff --git a/convex/didLogs.ts b/convex/didLogs.ts new file mode 100644 index 0000000..f5ebeb1 --- /dev/null +++ b/convex/didLogs.ts @@ -0,0 +1,83 @@ +/** + * DID log storage and retrieval for did:webvh resolution. + * + * Stores DID logs in Convex so they can be served at + * https://trypoo.app/{path}/did.jsonl for DID resolution. + */ + +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; + +/** + * Store or update a user's DID log. + */ +export const upsertDidLog = mutation({ + args: { + userDid: v.string(), + path: v.string(), + log: v.string(), + }, + handler: async (ctx, args) => { + const existing = await ctx.db + .query("didLogs") + .withIndex("by_user_did", (q) => q.eq("userDid", args.userDid)) + .first(); + + if (existing) { + await ctx.db.patch(existing._id, { + log: args.log, + path: args.path, + updatedAt: Date.now(), + }); + return existing._id; + } + + return await ctx.db.insert("didLogs", { + userDid: args.userDid, + path: args.path, + log: args.log, + updatedAt: Date.now(), + }); + }, +}); + +/** + * Get a DID log by path (for resolution). + */ +export const getDidLogByPath = query({ + args: { path: v.string() }, + handler: async (ctx, args) => { + const record = await ctx.db + .query("didLogs") + .withIndex("by_path", (q) => q.eq("path", args.path)) + .first(); + return record?.log ?? null; + }, +}); + +/** + * Get the full DID log record by path (includes userDid). + */ +export const getDidLogRecordByPath = query({ + args: { path: v.string() }, + handler: async (ctx, args) => { + return await ctx.db + .query("didLogs") + .withIndex("by_path", (q) => q.eq("path", args.path)) + .first(); + }, +}); + +/** + * Get a DID log by user DID. + */ +export const getDidLogByUserDid = query({ + args: { userDid: v.string() }, + handler: async (ctx, args) => { + const record = await ctx.db + .query("didLogs") + .withIndex("by_user_did", (q) => q.eq("userDid", args.userDid)) + .first(); + return record ?? null; + }, +}); diff --git a/convex/didLogsHttp.ts b/convex/didLogsHttp.ts new file mode 100644 index 0000000..b6b827b --- /dev/null +++ b/convex/didLogsHttp.ts @@ -0,0 +1,103 @@ +/** + * HTTP actions for DID log storage and retrieval. + * + * POST /api/did/log - Store/update a DID log (requires auth) + * GET /api/did/log?path={path} - Get a DID log by path (public) + */ + +import { httpAction } from "./_generated/server"; +import { api } from "./_generated/api"; + +function getCorsHeaders(request: Request): Record { + const origin = request.headers.get("Origin") || "*"; + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Allow-Credentials": "true", + }; +} + +/** + * Store/update a DID log. Requires JWT auth. + */ +export const storeDidLog = httpAction(async (ctx, request) => { + const corsHeaders = getCorsHeaders(request); + + try { + // Verify auth via Authorization header + const authHeader = request.headers.get("Authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } + + const body = await request.json(); + const { userDid, path, log } = body as { userDid: string; path: string; log: string }; + + if (!userDid || !path || !log) { + return new Response(JSON.stringify({ error: "Missing required fields: userDid, path, log" }), { + status: 400, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } + + await ctx.runMutation(api.didLogs.upsertDidLog, { userDid, path, log }); + + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } catch (error) { + console.error("[didLogsHttp] Store error:", error); + return new Response(JSON.stringify({ error: "Failed to store DID log" }), { + status: 500, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } +}); + +/** + * Get a DID log by path. Public endpoint (no auth required). + */ +export const getDidLog = httpAction(async (ctx, request) => { + const corsHeaders = getCorsHeaders(request); + + try { + const url = new URL(request.url); + const path = url.searchParams.get("path"); + + if (!path) { + return new Response(JSON.stringify({ error: "Missing 'path' query parameter" }), { + status: 400, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } + + const log = await ctx.runQuery(api.didLogs.getDidLogByPath, { path }); + + if (!log) { + return new Response("Not found", { + status: 404, + headers: corsHeaders, + }); + } + + // Return as JSONL (text/plain with each line being a JSON object) + return new Response(log, { + status: 200, + headers: { + "Content-Type": "application/jsonl+json", + ...corsHeaders, + }, + }); + } catch (error) { + console.error("[didLogsHttp] Get error:", error); + return new Response("Internal server error", { + status: 500, + headers: corsHeaders, + }); + } +}); diff --git a/convex/didResources.ts b/convex/didResources.ts new file mode 100644 index 0000000..d175acb --- /dev/null +++ b/convex/didResources.ts @@ -0,0 +1,76 @@ +/** + * Queries for serving list resources publicly. + * + * These are used by the HTTP handlers to serve lists as Originals resources + * at /{userPath}/resources/list-{id}. + */ + +import { v } from "convex/values"; +import { query } from "./_generated/server"; +import type { Id } from "./_generated/dataModel"; + +/** + * Get a list by its Convex ID, verifying ownership by DID. + * Returns null if not found or owner doesn't match. + */ +export const getPublicList = query({ + args: { + listId: v.string(), + ownerDid: v.string(), + }, + handler: async (ctx, args) => { + // Try to normalize the listId to a Convex ID + let list; + try { + list = await ctx.db.get(args.listId as Id<"lists">); + } catch { + // Invalid ID format + return null; + } + + if (!list || list.ownerDid !== args.ownerDid) { + return null; + } + + return list; + }, +}); + +/** + * Get all items for a list (public view — no auth required). + * Only returns non-sensitive fields. + */ +export const getPublicListItems = query({ + args: { + listId: v.id("lists"), + }, + handler: async (ctx, args) => { + const items = await ctx.db + .query("items") + .withIndex("by_list", (q) => q.eq("listId", args.listId)) + .collect(); + + // Sort by order, then createdAt + items.sort((a, b) => { + if (a.order !== undefined && b.order !== undefined) return a.order - b.order; + if (a.order !== undefined) return -1; + if (b.order !== undefined) return 1; + return a.createdAt - b.createdAt; + }); + + // Only return top-level items (no sub-items) for the resource view + return items + .filter((item) => !item.parentId) + .map((item) => ({ + _id: item._id, + name: item.name, + checked: item.checked, + createdAt: item.createdAt, + checkedAt: item.checkedAt, + description: item.description, + priority: item.priority, + dueDate: item.dueDate, + order: item.order, + })); + }, +}); diff --git a/convex/didResourcesHttp.ts b/convex/didResourcesHttp.ts new file mode 100644 index 0000000..4c59bc1 --- /dev/null +++ b/convex/didResourcesHttp.ts @@ -0,0 +1,171 @@ +/** + * HTTP actions for serving DID logs and list resources at canonical paths. + * + * Resolution paths: + * GET /{userPath}/did.jsonl → user's DID log (JSONL) + * GET /{userPath}/resources/list-{id} → list resource (JSON) + */ + +import { httpAction } from "./_generated/server"; +import { api } from "./_generated/api"; + +function corsHeaders(request: Request): Record { + const origin = request.headers.get("Origin") || "*"; + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Allow-Credentials": "true", + }; +} + +/** + * Catch-all handler for /{userPath}/did.jsonl and /{userPath}/resources/list-{id}. + * Convex pathPrefix routes match everything under a prefix, so we parse the URL ourselves. + */ +export const didResourceHandler = httpAction(async (ctx, request) => { + const headers = corsHeaders(request); + + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers }); + } + + const url = new URL(request.url); + const pathname = url.pathname; + + // Strip leading slash and split + const parts = pathname.replace(/^\//, "").split("/"); + + if (parts.length < 2) { + return new Response("Not found", { status: 404, headers }); + } + + const userPath = parts[0]; // e.g. "user-abc123" + + // /{userPath}/did.jsonl + if (parts.length === 2 && parts[1] === "did.jsonl") { + return await serveDidLog(ctx, userPath, headers); + } + + // /{userPath}/resources/list-{listId} + if (parts.length === 3 && parts[1] === "resources" && parts[2].startsWith("list-")) { + const listId = parts[2].slice("list-".length); + return await serveListResource(ctx, userPath, listId, headers); + } + + return new Response("Not found", { status: 404, headers }); +}); + +async function serveDidLog( + ctx: { runQuery: Function }, + userPath: string, + headers: Record +): Promise { + try { + const log = await ctx.runQuery(api.didLogs.getDidLogByPath, { path: userPath }); + + if (!log) { + return new Response("DID not found", { + status: 404, + headers: { "Content-Type": "text/plain", ...headers }, + }); + } + + return new Response(log, { + status: 200, + headers: { + "Content-Type": "application/jsonl+json", + "Cache-Control": "public, max-age=60", + ...headers, + }, + }); + } catch (error) { + console.error("[didResources] Error serving DID log:", error); + return new Response("Internal server error", { status: 500, headers }); + } +} + +async function serveListResource( + ctx: { runQuery: Function }, + userPath: string, + listId: string, + headers: Record +): Promise { + try { + // Look up the user's DID from the didLogs table + const didLogRecord = await ctx.runQuery(api.didLogs.getDidLogByPath, { path: userPath }); + if (!didLogRecord) { + return new Response("User not found", { status: 404, headers }); + } + + // We need the userDid — query didLogs by path to get the full record + const fullRecord = await ctx.runQuery(api.didLogs.getDidLogRecordByPath, { path: userPath }); + if (!fullRecord) { + return new Response("User not found", { status: 404, headers }); + } + + const userDid = fullRecord.userDid; + + // Look up the list + const list = await ctx.runQuery(api.didResources.getPublicList, { + listId, + ownerDid: userDid, + }); + + if (!list) { + return new Response("List not found", { + status: 404, + headers: { "Content-Type": "text/plain", ...headers }, + }); + } + + // Get list items + const items = await ctx.runQuery(api.didResources.getPublicListItems, { + listId: list._id, + }); + + const checkedCount = items.filter((i: { checked: boolean }) => i.checked).length; + + // Build the resource response + const resource = { + "@context": ["https://www.w3.org/ns/did/v1"], + id: `${userDid}/resources/list-${listId}`, + type: "PooList", + controller: userDid, + name: list.name, + items: items.map((item: { + name: string; + checked: boolean; + createdAt: number; + checkedAt?: number; + description?: string; + priority?: string; + dueDate?: number; + order?: number; + }) => ({ + name: item.name, + checked: item.checked, + createdAt: item.createdAt, + ...(item.checkedAt && { checkedAt: item.checkedAt }), + ...(item.description && { description: item.description }), + ...(item.priority && { priority: item.priority }), + ...(item.dueDate && { dueDate: item.dueDate }), + })), + createdAt: list.createdAt, + itemCount: items.length, + checkedCount, + }; + + return new Response(JSON.stringify(resource, null, 2), { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "public, max-age=30", + ...headers, + }, + }); + } catch (error) { + console.error("[didResources] Error serving list resource:", error); + return new Response("Internal server error", { status: 500, headers }); + } +} diff --git a/convex/http.ts b/convex/http.ts index dfd9355..ee62a38 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -22,6 +22,8 @@ import { reorderItems, } from "./itemsHttp"; import { updateUserDID } from "./userHttp"; +import { storeDidLog, getDidLog } from "./didLogsHttp"; +import { didResourceHandler } from "./didResourcesHttp"; import { getUserLists as agentGetUserLists, agentListHandler, @@ -373,6 +375,11 @@ http.route({ path: "/api/items/reorder", method: "OPTIONS", handler: corsHandler http.route({ path: "/api/user/updateDID", method: "POST", handler: updateUserDID }); http.route({ path: "/api/user/updateDID", method: "OPTIONS", handler: corsHandler }); +// --- DID log endpoints --- +http.route({ path: "/api/did/log", method: "POST", handler: storeDidLog }); +http.route({ path: "/api/did/log", method: "GET", handler: getDidLog }); +http.route({ path: "/api/did/log", method: "OPTIONS", handler: corsHandler }); + // ============================================================================ // Agent API endpoints (RESTful API for programmatic access) // All endpoints require JWT authentication via Authorization header. @@ -399,4 +406,12 @@ http.route({ pathPrefix: "/api/agent/items/", method: "PATCH", handler: agentIte http.route({ pathPrefix: "/api/agent/items/", method: "DELETE", handler: agentItemHandler }); http.route({ pathPrefix: "/api/agent/items/", method: "OPTIONS", handler: agentCorsHandler }); +// ============================================================================ +// DID Resolution & Resource endpoints (public, no auth) +// Serves /{userPath}/did.jsonl and /{userPath}/resources/list-{id} +// These MUST come after all /api/ routes to avoid conflicts. +// ============================================================================ +http.route({ pathPrefix: "/user-", method: "GET", handler: didResourceHandler }); +http.route({ pathPrefix: "/user-", method: "OPTIONS", handler: didResourceHandler }); + export default http; diff --git a/convex/publication.ts b/convex/publication.ts index 3d35865..8101a28 100644 --- a/convex/publication.ts +++ b/convex/publication.ts @@ -16,8 +16,8 @@ export const publishList = mutation({ args: { listId: v.id("lists"), webvhDid: v.string(), - didDocument: v.string(), - didLog: v.string(), + didDocument: v.optional(v.string()), + didLog: v.optional(v.string()), publisherDid: v.string(), }, handler: async (ctx, args) => { diff --git a/convex/schema.ts b/convex/schema.ts index 25df002..06598f3 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -9,6 +9,16 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; export default defineSchema({ + // DID logs table - stores did:webvh logs for resolution + didLogs: defineTable({ + userDid: v.string(), // The user's did:webvh + path: v.string(), // URL path slug (e.g. "user-abc123") + log: v.string(), // JSONL content (one JSON object per line) + updatedAt: v.number(), + }) + .index("by_path", ["path"]) + .index("by_user_did", ["userDid"]), + // Rate limits table - for tracking auth endpoint rate limits (Phase 9.2) rateLimits: defineTable({ key: v.string(), // Unique identifier (IP address or session ID) diff --git a/src/App.tsx b/src/App.tsx index 29ead4b..bac6efe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ const Landing = lazy(() => import('./pages/Landing').then(m => ({ default: m.Lan const ListView = lazy(() => import('./pages/ListView').then(m => ({ default: m.ListView }))) const JoinList = lazy(() => import('./pages/JoinList').then(m => ({ default: m.JoinList }))) const PublicList = lazy(() => import('./pages/PublicList').then(m => ({ default: m.PublicList }))) +const SharedListResource = lazy(() => import('./components/SharedListResource').then(m => ({ default: m.SharedListResource }))) const Profile = lazy(() => import('./pages/Profile').then(m => ({ default: m.Profile }))) const Templates = lazy(() => import('./pages/Templates').then(m => ({ default: m.Templates }))) const PriorityFocus = lazy(() => import('./pages/PriorityFocus').then(m => ({ default: m.PriorityFocus }))) @@ -164,6 +165,7 @@ function App() { : } /> } /> } /> + } /> {/* Landing page for unauthenticated, redirect to app if logged in */} : } /> diff --git a/src/components/ShareModal.tsx b/src/components/ShareModal.tsx index 8d3d05e..8dd02f0 100644 --- a/src/components/ShareModal.tsx +++ b/src/components/ShareModal.tsx @@ -9,8 +9,7 @@ import { api } from "../../convex/_generated/api"; import type { Doc } from "../../convex/_generated/dataModel"; import { useCurrentUser } from "../hooks/useCurrentUser"; import { useSettings } from "../hooks/useSettings"; -import { getPublicListUrl } from "../lib/publication"; -import { createListWebVHDid } from "../lib/webvh"; +import { buildListResourceDid, buildListResourceUrl } from "../lib/webvh"; import { Panel } from "./ui/Panel"; import { ListProvenanceInfo } from "./ProvenanceInfo"; @@ -34,8 +33,11 @@ export function ShareModal({ list, onClose }: ShareModalProps) { const [error, setError] = useState(null); const isPublished = publicationStatus?.status === "active"; - const publicUrl = isPublished - ? getPublicListUrl(publicationStatus.webvhDid) + const publicUrl = isPublished && did + ? buildListResourceUrl(did, list._id) + : null; + const resourceDid = isPublished && did + ? buildListResourceDid(did, list._id) : null; const handlePublish = async () => { @@ -49,19 +51,13 @@ export function ShareModal({ list, onClose }: ShareModalProps) { haptic('medium'); try { - const slug = `list-${list._id.replace(/[^a-zA-Z0-9-]/g, "")}`; - - const result = await createListWebVHDid({ - subOrgId, - userDid: did, - slug, - }); + // The list is a resource under the user's DID — no separate DID needed. + // The resource DID URI is: {userDid}/resources/list-{listId} + const listResourceDid = buildListResourceDid(did, list._id); await publishListMutation({ listId: list._id, - webvhDid: result.did, - didDocument: JSON.stringify(result.didDocument), - didLog: JSON.stringify(result.didLog), + webvhDid: listResourceDid, publisherDid: did, }); @@ -192,7 +188,7 @@ export function ShareModal({ list, onClose }: ShareModalProps) {

DID:{" "} - {publicationStatus?.webvhDid} + {resourceDid}

diff --git a/src/components/SharedListResource.tsx b/src/components/SharedListResource.tsx new file mode 100644 index 0000000..872d847 --- /dev/null +++ b/src/components/SharedListResource.tsx @@ -0,0 +1,194 @@ +/** + * Read-only view of a shared list resource. + * Accessed via /{userPath}/resources/list-{listId} + * + * Fetches the list data from the Convex HTTP endpoint and renders it. + */ + +import { useEffect, useState } from "react"; +import { useParams, Link } from "react-router-dom"; + +interface ListItem { + name: string; + checked: boolean; + createdAt: number; + checkedAt?: number; + description?: string; + priority?: string; + dueDate?: number; +} + +interface ListResource { + "@context": string[]; + id: string; + type: string; + controller: string; + name: string; + items: ListItem[]; + createdAt: number; + itemCount: number; + checkedCount: number; +} + +export function SharedListResource() { + const { userPath, listId } = useParams<{ userPath: string; listId: string }>(); + const [resource, setResource] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!userPath || !listId) return; + + const convexUrl = import.meta.env.VITE_CONVEX_URL as string; + // Convex HTTP URL is the site URL derived from the deployment URL + // e.g. https://xxx.convex.cloud -> https://xxx.convex.site + const siteUrl = convexUrl?.replace(".cloud", ".site") ?? ""; + + fetch(`${siteUrl}/${userPath}/resources/list-${listId}`) + .then(async (res) => { + if (!res.ok) { + throw new Error(res.status === 404 ? "List not found" : "Failed to load list"); + } + return res.json(); + }) + .then((data) => { + setResource(data); + setLoading(false); + }) + .catch((err) => { + setError(err.message); + setLoading(false); + }); + }, [userPath, listId]); + + if (loading) { + return ( +
+
+
+ ); + } + + if (error || !resource) { + return ( +
+
+

+ {error === "List not found" ? "💩 List not found" : "💩 Something went wrong"} +

+

{error}

+ + Go to Poo App → + +
+
+ ); + } + + const progress = resource.itemCount > 0 + ? Math.round((resource.checkedCount / resource.itemCount) * 100) + : 0; + + return ( +
+
+ {/* Header */} +
+
+ 💩 + Shared List +
+

+ {resource.name} +

+
+ {resource.itemCount} items + · + {resource.checkedCount} done + {progress > 0 && ( + <> + · + {progress}% + + )} +
+ {/* Progress bar */} + {resource.itemCount > 0 && ( +
+
+
+ )} +
+ + {/* Items */} +
+ {resource.items.map((item, i) => ( +
+
+ {item.checked && ( + + + + )} +
+
+

+ {item.name} +

+ {item.description && ( +

+ {item.description} +

+ )} + {item.priority && ( + + {item.priority} + + )} +
+
+ ))} +
+ + {/* Footer */} +
+

+ Shared via{" "} + + Poo App + + {" "}· Verified with{" "} + did:webvh +

+

+ {resource.id} +

+
+
+
+ ); +} diff --git a/src/components/publish/PublishModal.tsx b/src/components/publish/PublishModal.tsx index 3f6a936..eeb24dd 100644 --- a/src/components/publish/PublishModal.tsx +++ b/src/components/publish/PublishModal.tsx @@ -12,8 +12,7 @@ import { api } from "../../../convex/_generated/api"; import type { Doc } from "../../../convex/_generated/dataModel"; import { useCurrentUser } from "../../hooks/useCurrentUser"; import { useSettings } from "../../hooks/useSettings"; -import { getPublicListUrl } from "../../lib/publication"; -import { createListWebVHDid } from "../../lib/webvh"; +import { buildListResourceDid, buildListResourceUrl } from "../../lib/webvh"; import { Panel } from "../ui/Panel"; interface PublishModalProps { @@ -37,8 +36,8 @@ export function PublishModal({ list, onClose }: PublishModalProps) { const [error, setError] = useState(null); const isPublished = publicationStatus?.status === "active"; - const publicUrl = isPublished - ? getPublicListUrl(publicationStatus.webvhDid) + const publicUrl = isPublished && did + ? buildListResourceUrl(did, list._id) : null; const handlePublish = async () => { @@ -52,22 +51,13 @@ export function PublishModal({ list, onClose }: PublishModalProps) { haptic('medium'); try { - // Create the slug from list ID (alphanumeric only) - const slug = `list-${list._id.replace(/[^a-zA-Z0-9-]/g, "")}`; - - // Create did:webvh client-side, domain derived from user's DID - const result = await createListWebVHDid({ - subOrgId, - userDid: did, - slug, - }); + // The list is a resource under the user's DID — no separate DID needed. + const listResourceDid = buildListResourceDid(did, list._id); // Record publication in Convex await publishListMutation({ listId: list._id, - webvhDid: result.did, - didDocument: JSON.stringify(result.didDocument), - didLog: JSON.stringify(result.didLog), + webvhDid: listResourceDid, publisherDid: did, }); diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx index 4fe985c..582ab1d 100644 --- a/src/hooks/useAuth.tsx +++ b/src/hooks/useAuth.tsx @@ -190,6 +190,21 @@ export function AuthProvider({ children }: AuthProviderProps) { body: JSON.stringify({ did: webvhResult.did }), }); + // Store DID log in Convex for resolution + await fetch(`${httpUrl}/api/did/log`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${parsed.token}`, + }, + credentials: "include", + body: JSON.stringify({ + userDid: webvhResult.did, + path: webvhResult.path, + log: webvhResult.didLogJsonl, + }), + }); + const upgradedUser = { ...parsed.user, did: webvhResult.did }; setUser(upgradedUser); const updatedState: PersistedAuthState = { user: upgradedUser, token: parsed.token }; diff --git a/src/lib/webvh.ts b/src/lib/webvh.ts index 2bd86b3..522ba13 100644 --- a/src/lib/webvh.ts +++ b/src/lib/webvh.ts @@ -1,10 +1,14 @@ import { createDID, MultibaseEncoding, multibaseEncode, prepareDataForSigning } from "didwebvh-ts"; +import type { DIDLog } from "didwebvh-ts"; import { getPublicKeyAsync, signAsync, utils, verifyAsync } from "@noble/ed25519"; import { Capacitor } from '@capacitor/core'; const KEY_STORAGE_PREFIX = "lisa-webvh-ed25519"; const ED25519_MULTICODEC_PREFIX = new Uint8Array([0xed, 0x01]); +/** DID log stored locally for updates (serialized as JSON in localStorage) */ +const DID_LOG_STORAGE_KEY = "lisa-webvh-did-log"; + function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes) .map((b) => b.toString(16).padStart(2, "0")) @@ -58,8 +62,6 @@ class BrowserWebVHSigner { async function getOrCreateKeyPair(subOrgId: string) { const storageKey = `${KEY_STORAGE_PREFIX}:${subOrgId}`; - // TODO: Migrate to async storageAdapter for native support (see storageAdapter.ts) - // This would provide better security on native platforms via Capacitor Preferences const existingPrivateKey = localStorage.getItem(storageKey); const privateKey = existingPrivateKey ? hexToBytes(existingPrivateKey) @@ -80,10 +82,59 @@ async function getOrCreateKeyPair(subOrgId: string) { } function toUserSlug(_email: string, subOrgId: string) { - // Use subOrgId only — no PII in the DID return `user-${subOrgId.slice(0, 16)}`; } +/** + * Serialize a DID log array to JSONL format (one JSON object per line). + */ +export function serializeDidLog(log: DIDLog): string { + return log.map((entry) => JSON.stringify(entry)).join("\n"); +} + +/** + * Deserialize a JSONL string back to a DID log array. + */ +export function deserializeDidLog(jsonl: string): DIDLog { + return jsonl + .split("\n") + .filter((line) => line.trim()) + .map((line) => JSON.parse(line)); +} + +/** + * Store the DID log locally for future updateDID operations. + */ +function storeDidLogLocally(subOrgId: string, log: DIDLog) { + localStorage.setItem(`${DID_LOG_STORAGE_KEY}:${subOrgId}`, JSON.stringify(log)); +} + +/** + * Retrieve the locally stored DID log. + */ +function getStoredDidLog(subOrgId: string): DIDLog | null { + const stored = localStorage.getItem(`${DID_LOG_STORAGE_KEY}:${subOrgId}`); + if (!stored) return null; + try { + return JSON.parse(stored); + } catch { + return null; + } +} + +/** + * Extract the path slug from a did:webvh DID. + * e.g. "did:webvh:{scid}:trypoo.app:user-abc123" → "user-abc123" + */ +export function pathFromDid(did: string): string { + const parts = did.split(":"); + // did:webvh:{scid}:{domain}:{path...} + if (parts.length < 5 || parts[1] !== "webvh") { + throw new Error(`Cannot extract path from DID: ${did}`); + } + return parts.slice(4).join(":"); +} + export async function createUserWebVHDid(params: { email: string; subOrgId: string; @@ -91,11 +142,12 @@ export async function createUserWebVHDid(params: { }) { const { privateKey, publicKeyMultibase } = await getOrCreateKeyPair(params.subOrgId); const signer = new BrowserWebVHSigner(privateKey, publicKeyMultibase); - // In Capacitor native apps, window.location.host returns "localhost", so use production domain const host = Capacitor.isNativePlatform() ? 'trypoo.app' : window.location.host; const domain = params.domain || (import.meta.env.VITE_WEBVH_DOMAIN as string | undefined) || host; + const userSlug = toUserSlug(params.email, params.subOrgId); + const result = await createDID({ domain, signer, @@ -115,69 +167,50 @@ export async function createUserWebVHDid(params: { publicKeyMultibase, }, ], - paths: [toUserSlug(params.email, params.subOrgId)], + paths: [userSlug], portable: false, authentication: ["#key-0"], assertionMethod: ["#key-1"], }); + // Store log locally for future updates + storeDidLogLocally(params.subOrgId, result.log); + return { did: result.did, didDocument: result.doc, didLog: result.log, + didLogJsonl: serializeDidLog(result.log), + path: userSlug, }; } /** * Extract the domain from a did:webvh string. - * e.g. "did:webvh:trypoo.app:user-abc123" → "trypoo.app" + * e.g. "did:webvh:{scid}:trypoo.app:user-abc123" → "trypoo.app" */ export function domainFromDid(did: string): string { - // did:webvh:: const parts = did.split(":"); - if (parts.length < 3 || parts[1] !== "webvh") { + if (parts.length < 4 || parts[1] !== "webvh") { throw new Error(`Cannot extract domain from DID: ${did}`); } - return parts[2]; + return parts[3]; } -export async function createListWebVHDid(params: { - subOrgId: string; - userDid: string; // user's did:webvh — domain is derived from this - slug: string; -}) { - const domain = domainFromDid(params.userDid); - const { privateKey, publicKeyMultibase } = await getOrCreateKeyPair(params.subOrgId); - const signer = new BrowserWebVHSigner(privateKey, publicKeyMultibase); - - const result = await createDID({ - domain, - signer, - verifier: signer, - updateKeys: [signer.getVerificationMethodId()], - verificationMethods: [ - { - id: "#key-0", - type: "Multikey", - controller: "", - publicKeyMultibase, - }, - { - id: "#key-1", - type: "Multikey", - controller: "", - publicKeyMultibase, - }, - ], - paths: [params.slug], - portable: false, - authentication: ["#key-0"], - assertionMethod: ["#key-1"], - }); +/** + * Build the DID URI for a list resource under the user's DID. + * e.g. "did:webvh:{scid}:trypoo.app:user-abc123/resources/list-{listId}" + */ +export function buildListResourceDid(userDid: string, listId: string): string { + return `${userDid}/resources/list-${listId}`; +} - return { - did: result.did, - didDocument: result.doc, - didLog: result.log, - }; +/** + * Build the HTTPS URL for a list resource. + * e.g. "https://trypoo.app/user-abc123/resources/list-{listId}" + */ +export function buildListResourceUrl(userDid: string, listId: string): string { + const domain = domainFromDid(userDid); + const path = pathFromDid(userDid); + return `https://${domain}/${path}/resources/list-${listId}`; }