From 0c3d8728a0834135771fa74e45686b29e6e3edaa Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 12 Mar 2026 01:31:24 -0700 Subject: [PATCH] fix: graph 3d-force-graph resolution, DID/username display, space role checks - Switch 3d-force-graph CDN from jsdelivr to esm.sh with bundle-deps to resolve missing "three-forcegraph" bare specifier error - Fix storeCredential() to pass displayName and DID to createUser() (prevents NULL did column for credential-first user creation) - Fix invite acceptance to use claims.did instead of claims.sub for space_members.user_did (DID format consistency) - Fix session refresh to look up username from DB when missing from old token (prevents empty username after token refresh) - Fix resolveCallerRole() in spaces.ts to check both claims.sub and claims.did against ownerDID and member keys (auto-provisioned spaces store ownerDID as did:key:, API-created as raw userId) - Refactor CRM route to use URL subpath tabs with renderCrm helper Co-Authored-By: Claude Opus 4.6 --- modules/rnetwork/mod.ts | 44 ++++++++++++++++++++-------- server/spaces.ts | 8 +++-- shared/components/rstack-identity.ts | 5 ++-- src/encryptid/db.ts | 5 ++-- src/encryptid/server.ts | 19 ++++++++---- 5 files changed, 58 insertions(+), 23 deletions(-) diff --git a/modules/rnetwork/mod.ts b/modules/rnetwork/mod.ts index 52cf273..3c8687d 100644 --- a/modules/rnetwork/mod.ts +++ b/modules/rnetwork/mod.ts @@ -19,7 +19,7 @@ const GRAPH3D_IMPORTMAP = ``; @@ -383,9 +383,18 @@ routes.get("/api/opportunities", async (c) => { }); // ── CRM sub-route — API-driven CRM view ── -routes.get("/crm", (c) => { - const space = c.req.param("space") || "demo"; - return c.html(renderShell({ +const CRM_TABS = [ + { id: "pipeline", label: "Pipeline" }, + { id: "contacts", label: "Contacts" }, + { id: "companies", label: "Companies" }, + { id: "graph", label: "Graph" }, + { id: "delegations", label: "Delegations" }, +] as const; + +const CRM_TAB_IDS = new Set(CRM_TABS.map(t => t.id)); + +function renderCrm(space: string, activeTab: string) { + return renderShell({ title: `${space} — CRM | rSpace`, moduleId: "rnetwork", spaceSlug: space, @@ -395,14 +404,25 @@ routes.get("/crm", (c) => { `, styles: ``, - tabs: [ - { id: "pipeline", label: "Pipeline" }, - { id: "contacts", label: "Contacts" }, - { id: "companies", label: "Companies" }, - { id: "graph", label: "Graph" }, - { id: "delegations", label: "Delegations" }, - ], - })); + tabs: [...CRM_TABS], + activeTab, + tabBasePath: `/${space}/rnetwork/crm`, + }); +} + +routes.get("/crm", (c) => { + const space = c.req.param("space") || "demo"; + return c.html(renderCrm(space, "pipeline")); +}); + +// Tab subpath routes: /crm/:tabId +routes.get("/crm/:tabId", (c) => { + const space = c.req.param("space") || "demo"; + const tabId = c.req.param("tabId"); + if (!CRM_TAB_IDS.has(tabId as any)) { + return c.redirect(`/${space}/rnetwork/crm`, 302); + } + return c.html(renderCrm(space, tabId)); }); // ── Page route ── diff --git a/server/spaces.ts b/server/spaces.ts index 0d2e8f4..8c5b0c3 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -95,10 +95,14 @@ export async function resolveCallerRole( if (!claims) return { role: 'viewer', isOwner: false }; - const isOwner = data.meta.ownerDID === claims.sub; + // Check both claims.sub (raw userId) and claims.did (did:key:...) for + // compatibility — auto-provisioned spaces store ownerDID as did:key:, + // while API-created spaces store it as raw userId. + const callerDid = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`; + const isOwner = data.meta.ownerDID === claims.sub || data.meta.ownerDID === callerDid; if (isOwner) return { role: 'admin', isOwner: true }; - const member = data.members?.[claims.sub]; + const member = data.members?.[claims.sub] || data.members?.[callerDid]; if (member) return { role: member.role, isOwner: false }; // Non-member defaults diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index 5a5c162..793d631 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -349,10 +349,11 @@ export class RStackIdentity extends HTMLElement { if (!session) { _removeSessionCookie(); } return; } - const { token: newToken } = await res.json(); + const refreshData = await res.json(); + const newToken = refreshData.token; if (newToken) { const payload = parseJWT(newToken); - storeSession(newToken, (payload.username as string) || username, (payload.did as string) || did); + storeSession(newToken, (payload.username as string) || refreshData.username || username, (payload.did as string) || did); this.#render(); this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); } diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index cfc4a7e..11abe52 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -83,8 +83,9 @@ export async function getUserByUsername(username: string) { // ============================================================================ export async function storeCredential(cred: StoredCredential): Promise { - // Ensure user exists first - await createUser(cred.userId, cred.username); + // Ensure user exists first (with display name + DID so they're never NULL) + const did = `did:key:${cred.userId.slice(0, 32)}`; + await createUser(cred.userId, cred.username, cred.username, did); await sql` INSERT INTO credentials (credential_id, user_id, public_key, counter, transports, created_at, rp_id) diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 50c529b..1e2c5ed 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -806,13 +806,20 @@ app.post('/api/session/refresh', async (c) => { try { const payload = await verify(token, CONFIG.jwtSecret, { alg: 'HS256', exp: false }); // Allow expired tokens for refresh + // Ensure username is present — look up from DB if missing from old token + let username = payload.username as string | undefined; + if (!username) { + const user = await getUserById(payload.sub as string); + username = user?.username || ''; + } + // Issue new token const newToken = await generateSessionToken( payload.sub as string, - payload.username as string + username ); - return c.json({ token: newToken }); + return c.json({ token: newToken, username }); } catch { return c.json({ error: 'Invalid token' }, 401); } @@ -3346,7 +3353,8 @@ app.post('/api/spaces/:slug/members', async (c) => { return c.json({ error: `Invalid role. Must be one of: ${VALID_ROLES.join(', ')}` }, 400); } - const member = await upsertSpaceMember(slug, body.userDID, body.role, claims.sub); + const grantedByDid = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`; + const member = await upsertSpaceMember(slug, body.userDID, body.role, grantedByDid); return c.json({ userDID: member.userDID, spaceSlug: member.spaceSlug, @@ -3488,8 +3496,9 @@ app.post('/api/invites/:token/accept', async (c) => { const accepted = await acceptSpaceInvite(token, claims.sub); if (!accepted) return c.json({ error: 'Failed to accept invite' }, 500); - // Add to space_members with the invite's role - await upsertSpaceMember(accepted.spaceSlug, claims.sub, accepted.role, accepted.invitedBy); + // Add to space_members with the invite's role (use DID, not raw userId) + const userDid = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`; + await upsertSpaceMember(accepted.spaceSlug, userDid, accepted.role, accepted.invitedBy); return c.json({ ok: true,