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🔑, API-created as raw userId)
- Refactor CRM route to use URL subpath tabs with renderCrm helper

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-12 01:31:24 -07:00
parent b60c0f565e
commit 0c3d8728a0
5 changed files with 58 additions and 23 deletions

View File

@ -19,7 +19,7 @@ const GRAPH3D_IMPORTMAP = `<script type="importmap">
"three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js", "three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/", "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/",
"three/examples/jsm/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/", "three/examples/jsm/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/",
"3d-force-graph": "https://cdn.jsdelivr.net/npm/3d-force-graph@1/dist/3d-force-graph.mjs" "3d-force-graph": "https://esm.sh/3d-force-graph@1?bundle-deps&external=three"
} }
} }
</script>`; </script>`;
@ -383,9 +383,18 @@ routes.get("/api/opportunities", async (c) => {
}); });
// ── CRM sub-route — API-driven CRM view ── // ── CRM sub-route — API-driven CRM view ──
routes.get("/crm", (c) => { const CRM_TABS = [
const space = c.req.param("space") || "demo"; { id: "pipeline", label: "Pipeline" },
return c.html(renderShell({ { 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`, title: `${space} — CRM | rSpace`,
moduleId: "rnetwork", moduleId: "rnetwork",
spaceSlug: space, spaceSlug: space,
@ -395,14 +404,25 @@ routes.get("/crm", (c) => {
<script type="module" src="/modules/rnetwork/folk-delegation-manager.js"></script> <script type="module" src="/modules/rnetwork/folk-delegation-manager.js"></script>
<script type="module" src="/modules/rnetwork/folk-trust-sankey.js"></script>`, <script type="module" src="/modules/rnetwork/folk-trust-sankey.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`, styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
tabs: [ tabs: [...CRM_TABS],
{ id: "pipeline", label: "Pipeline" }, activeTab,
{ id: "contacts", label: "Contacts" }, tabBasePath: `/${space}/rnetwork/crm`,
{ id: "companies", label: "Companies" }, });
{ id: "graph", label: "Graph" }, }
{ id: "delegations", label: "Delegations" },
], 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 ── // ── Page route ──

View File

@ -95,10 +95,14 @@ export async function resolveCallerRole(
if (!claims) return { role: 'viewer', isOwner: false }; 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 }; 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 }; if (member) return { role: member.role, isOwner: false };
// Non-member defaults // Non-member defaults

View File

@ -349,10 +349,11 @@ export class RStackIdentity extends HTMLElement {
if (!session) { _removeSessionCookie(); } if (!session) { _removeSessionCookie(); }
return; return;
} }
const { token: newToken } = await res.json(); const refreshData = await res.json();
const newToken = refreshData.token;
if (newToken) { if (newToken) {
const payload = parseJWT(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.#render();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
} }

View File

@ -83,8 +83,9 @@ export async function getUserByUsername(username: string) {
// ============================================================================ // ============================================================================
export async function storeCredential(cred: StoredCredential): Promise<void> { export async function storeCredential(cred: StoredCredential): Promise<void> {
// Ensure user exists first // Ensure user exists first (with display name + DID so they're never NULL)
await createUser(cred.userId, cred.username); const did = `did:key:${cred.userId.slice(0, 32)}`;
await createUser(cred.userId, cred.username, cred.username, did);
await sql` await sql`
INSERT INTO credentials (credential_id, user_id, public_key, counter, transports, created_at, rp_id) INSERT INTO credentials (credential_id, user_id, public_key, counter, transports, created_at, rp_id)

View File

@ -806,13 +806,20 @@ app.post('/api/session/refresh', async (c) => {
try { try {
const payload = await verify(token, CONFIG.jwtSecret, { alg: 'HS256', exp: false }); // Allow expired tokens for refresh 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 // Issue new token
const newToken = await generateSessionToken( const newToken = await generateSessionToken(
payload.sub as string, payload.sub as string,
payload.username as string username
); );
return c.json({ token: newToken }); return c.json({ token: newToken, username });
} catch { } catch {
return c.json({ error: 'Invalid token' }, 401); 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); 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({ return c.json({
userDID: member.userDID, userDID: member.userDID,
spaceSlug: member.spaceSlug, spaceSlug: member.spaceSlug,
@ -3488,8 +3496,9 @@ app.post('/api/invites/:token/accept', async (c) => {
const accepted = await acceptSpaceInvite(token, claims.sub); const accepted = await acceptSpaceInvite(token, claims.sub);
if (!accepted) return c.json({ error: 'Failed to accept invite' }, 500); if (!accepted) return c.json({ error: 'Failed to accept invite' }, 500);
// Add to space_members with the invite's role // Add to space_members with the invite's role (use DID, not raw userId)
await upsertSpaceMember(accepted.spaceSlug, claims.sub, accepted.role, accepted.invitedBy); 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({ return c.json({
ok: true, ok: true,