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/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/",
"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>`;
@ -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) => {
<script type="module" src="/modules/rnetwork/folk-delegation-manager.js"></script>
<script type="module" src="/modules/rnetwork/folk-trust-sankey.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
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 ──

View File

@ -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

View File

@ -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 }));
}

View File

@ -83,8 +83,9 @@ export async function getUserByUsername(username: string) {
// ============================================================================
export async function storeCredential(cred: StoredCredential): Promise<void> {
// 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)

View File

@ -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,