From 4212a651e16ad6d0f72927cc1076ae0ad270499e Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 11 Mar 2026 17:13:19 -0700 Subject: [PATCH] fix(auth): exchange WebAuthn credential for server-signed JWT + rNetwork CRM cleanup - Session manager now calls EncryptID /api/auth/start + /api/auth/complete to get a properly signed JWT instead of creating unsigned local tokens. This fixes 401 errors on /api/payments, /api/notifications, and other authenticated endpoints that verify tokens via EncryptID server. - Token refresh calls /api/session/refresh instead of extending unsigned tokens - Server generateSessionToken now includes authTime, jti, recoveryConfigured - rNetwork: /crm route renders folk-crm-view instead of iframe - rNetwork: ?view=app redirects 301 to /crm (backward compat) - rNetwork: graph viewer always uses API (removed hardcoded demo data) - docker-compose: pass through TWENTY_API_TOKEN from Infisical - rcart: add catalog product images Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 1 + modules/rcart/components/folk-cart-shop.ts | 59 ++++--- .../rnetwork/components/folk-graph-viewer.ts | 62 -------- modules/rnetwork/mod.ts | 24 +-- src/encryptid/server.ts | 3 + src/encryptid/session.ts | 146 ++++++++++++------ 6 files changed, 149 insertions(+), 146 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5822de0..1c58193 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,6 +43,7 @@ services: - SMTP_USER=${SMTP_USER:-noreply@rmail.online} - SMTP_PASS=${SMTP_PASS} - TWENTY_API_URL=http://twenty-ch-server:3000 + - TWENTY_API_TOKEN=${TWENTY_API_TOKEN} - OLLAMA_URL=http://ollama:11434 - INFISICAL_AI_CLIENT_ID=${INFISICAL_AI_CLIENT_ID} - INFISICAL_AI_CLIENT_SECRET=${INFISICAL_AI_CLIENT_SECRET} diff --git a/modules/rcart/components/folk-cart-shop.ts b/modules/rcart/components/folk-cart-shop.ts index 2123c0d..ab53647 100644 --- a/modules/rcart/components/folk-cart-shop.ts +++ b/modules/rcart/components/folk-cart-shop.ts @@ -221,14 +221,14 @@ class FolkCartShop extends HTMLElement { // Existing catalog demo data this.catalog = [ - { id: "demo-cat-1", title: "The Commons", description: "A pocket book exploring shared resources and collective stewardship.", price: 12, currency: "USD", tags: ["books"], product_type: "pocket book", status: "active", created_at: new Date(now - 30 * 86400000).toISOString() }, - { id: "demo-cat-2", title: "Mycelium Networks", description: "Illustrated poster mapping underground fungal communication pathways.", price: 18, currency: "USD", tags: ["prints"], product_type: "poster", status: "active", created_at: new Date(now - 25 * 86400000).toISOString() }, - { id: "demo-cat-3", title: "#DefectFi", description: "Organic cotton tee shirt with the #DefectFi campaign logo.", price: 25, currency: "USD", tags: ["apparel"], product_type: "tee shirt", status: "active", created_at: new Date(now - 20 * 86400000).toISOString() }, - { id: "demo-cat-4", title: "Cosmolocal Sticker Sheet", description: "Die-cut sticker sheet with cosmolocal design motifs.", price: 5, currency: "USD", tags: ["stickers"], product_type: "sticker sheet", status: "active", created_at: new Date(now - 15 * 86400000).toISOString() }, - { id: "demo-cat-5", title: "Doughnut Economics", description: "A zine breaking down Kate Raworth's doughnut economics framework.", price: 8, currency: "USD", tags: ["books"], product_type: "zine", status: "active", created_at: new Date(now - 10 * 86400000).toISOString() }, - { id: "demo-cat-6", title: "rSpace Logo", description: "Embroidered patch featuring the rSpace logo on twill backing.", price: 6, currency: "USD", tags: ["accessories"], product_type: "embroidered patch", status: "active", created_at: new Date(now - 5 * 86400000).toISOString() }, - { id: "demo-cat-7", title: "Cosmolocal Network Tee", description: "Bella+Canvas 3001 tee with the Cosmolocal Network radial design.", price: 25, currency: "USD", tags: ["apparel", "cosmolocal"], product_type: "tee", required_capabilities: ["dtg-print"], status: "active", created_at: new Date(now - 3 * 86400000).toISOString() }, - { id: "demo-cat-8", title: "Cosmolocal Sticker Sheet", description: "Kiss-cut vinyl sticker sheet with cosmolocal network motifs.", price: 5, currency: "USD", tags: ["stickers", "cosmolocal"], product_type: "sticker-sheet", required_capabilities: ["vinyl-cut"], status: "active", created_at: new Date(now - 1 * 86400000).toISOString() }, + { id: "demo-cat-1", title: "The Commons", description: "A pocket book exploring shared resources and collective stewardship.", price: 12, currency: "USD", tags: ["books"], product_type: "pocket book", status: "active", image_url: "/images/catalog/catalog-the-commons.jpg", created_at: new Date(now - 30 * 86400000).toISOString() }, + { id: "demo-cat-2", title: "Mycelium Networks", description: "Illustrated poster mapping underground fungal communication pathways.", price: 18, currency: "USD", tags: ["prints"], product_type: "poster", status: "active", image_url: "/images/catalog/catalog-mycelium-networks.jpg", created_at: new Date(now - 25 * 86400000).toISOString() }, + { id: "demo-cat-3", title: "#DefectFi Tee", description: "Organic cotton tee with the #DefectFi campaign logo. Circuit patterns dissolving into organic roots.", price: 25, currency: "USD", tags: ["apparel"], product_type: "tee shirt", status: "active", image_url: "/images/catalog/catalog-defectfi-tee.jpg", created_at: new Date(now - 20 * 86400000).toISOString() }, + { id: "demo-cat-4", title: "Cosmolocal Sticker Sheet", description: "Die-cut sticker set with cosmolocal motifs — nodes, mycelium, community gardens, mesh networks.", price: 5, currency: "USD", tags: ["stickers"], product_type: "sticker sheet", status: "active", image_url: "/images/catalog/catalog-cosmolocal-stickers.jpg", created_at: new Date(now - 15 * 86400000).toISOString() }, + { id: "demo-cat-5", title: "Doughnut Economics Zine", description: "Punk zine breaking down Kate Raworth's doughnut economics framework. Cut-and-paste collage aesthetic.", price: 8, currency: "USD", tags: ["books", "zines"], product_type: "zine", status: "active", image_url: "/images/catalog/catalog-doughnut-economics.jpg", created_at: new Date(now - 10 * 86400000).toISOString() }, + { id: "demo-cat-6", title: "rSpace Logo Patch", description: "Embroidered patch featuring the rSpace logo in teal and white on navy twill backing.", price: 6, currency: "USD", tags: ["accessories"], product_type: "embroidered patch", status: "active", image_url: "/images/catalog/catalog-rspace-patch.jpg", created_at: new Date(now - 5 * 86400000).toISOString() }, + { id: "demo-cat-7", title: "Cosmolocal Network Tee", description: "Bella+Canvas 3001 tee with the Cosmolocal Network radial design in teal and coral.", price: 25, currency: "USD", tags: ["apparel", "cosmolocal"], product_type: "tee", required_capabilities: ["dtg-print"], status: "active", image_url: "/images/catalog/catalog-cosmolocal-tee.jpg", created_at: new Date(now - 3 * 86400000).toISOString() }, + { id: "demo-cat-8", title: "Cosmolocal Vinyl Stickers", description: "Kiss-cut vinyl sticker sheet with network constellation patterns and holographic accents.", price: 5, currency: "USD", tags: ["stickers", "cosmolocal"], product_type: "sticker-sheet", required_capabilities: ["vinyl-cut"], status: "active", image_url: "/images/catalog/catalog-cosmolocal-vinyl-stickers.jpg", created_at: new Date(now - 1 * 86400000).toISOString() }, ]; this.orders = [ @@ -657,19 +657,24 @@ class FolkCartShop extends HTMLElement { return `
No items in the catalog yet. Ingest artifacts from rPubs or Swag Designer to list them here.
`; } - return `
+ return `
${this.catalog.map((entry) => ` -
-

${this.esc(entry.title || "Untitled")}

-
- ${entry.product_type ? `${this.esc(entry.product_type)}` : ""} - ${(entry.required_capabilities || []).map((cap: string) => `${this.esc(cap)}`).join("")} - ${(entry.tags || []).map((t: string) => `${this.esc(t)}`).join("")} +
+ ${entry.image_url ? `
${this.esc(entry.title || '')}
` : ""} +
+

${this.esc(entry.title || "Untitled")}

+
+ ${entry.product_type ? `${this.esc(entry.product_type)}` : ""} + ${(entry.required_capabilities || []).map((cap: string) => `${this.esc(cap)}`).join("")} + ${(entry.tags || []).map((t: string) => `${this.esc(t)}`).join("")} +
+ ${entry.description ? `
${this.esc(entry.description)}
` : ""} + ${entry.dimensions ? `
${entry.dimensions.width_mm}x${entry.dimensions.height_mm}mm
` : ""} +
- ${entry.description ? `
${this.esc(entry.description)}
` : ""} - ${entry.dimensions ? `
${entry.dimensions.width_mm}x${entry.dimensions.height_mm}mm
` : ""} - ${entry.price != null ? `
$${parseFloat(entry.price).toFixed(2)} ${entry.currency || ""}
` : ""} -
${entry.status}
`).join("")}
`; @@ -821,7 +826,18 @@ class FolkCartShop extends HTMLElement { .status-cancelled, .status-closed { background: rgba(239,68,68,0.15); color: #f87171; } .status-shipped { background: rgba(56,189,248,0.15); color: #38bdf8; } - .price { color: var(--rs-text-primary); font-weight: 600; font-size: 1rem; margin-top: 0.5rem; } + .price { color: var(--rs-text-primary); font-weight: 600; font-size: 1rem; } + + /* Catalog product cards */ + .catalog-grid { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); } + .catalog-card { padding: 0; overflow: hidden; display: flex; flex-direction: column; } + .catalog-img { width: 100%; aspect-ratio: 1; overflow: hidden; background: var(--rs-bg-surface-raised); } + .catalog-img img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.3s; } + .catalog-card:hover .catalog-img img { transform: scale(1.04); } + .catalog-body { padding: 1rem; display: flex; flex-direction: column; flex: 1; } + .catalog-body .card-title { margin-bottom: 0.25rem; } + .card-desc { color: var(--rs-text-secondary); font-size: 0.8125rem; margin-bottom: 0.75rem; line-height: 1.4; flex: 1; } + .catalog-footer { display: flex; align-items: center; justify-content: space-between; margin-top: auto; } .text-green { color: #4ade80; } .progress-bar { background: var(--rs-bg-surface-raised); border-radius: 999px; height: 8px; overflow: hidden; } @@ -878,8 +894,9 @@ class FolkCartShop extends HTMLElement { .empty { text-align: center; padding: 3rem; color: var(--rs-text-muted); font-size: 0.875rem; } .loading { text-align: center; padding: 3rem; color: var(--rs-text-secondary); } - @media (max-width: 480px) { + @media (max-width: 600px) { .grid { grid-template-columns: 1fr; } + .catalog-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); } .url-input-row { flex-direction: column; } } `; diff --git a/modules/rnetwork/components/folk-graph-viewer.ts b/modules/rnetwork/components/folk-graph-viewer.ts index ffa5f7b..5a53626 100644 --- a/modules/rnetwork/components/folk-graph-viewer.ts +++ b/modules/rnetwork/components/folk-graph-viewer.ts @@ -62,72 +62,10 @@ class FolkGraphViewer extends HTMLElement { connectedCallback() { this.space = this.getAttribute("space") || "demo"; - if (this.space === "demo") { this.loadDemoData(); return; } this.render(); // Show loading state this.loadData(); // Async — will re-render when data arrives } - private loadDemoData() { - this.info = { name: "rSpace Community", member_count: 10, company_count: 3, opportunity_count: 0 }; - - this.workspaces = [ - { name: "Commons DAO", slug: "commons-dao", nodeCount: 5, edgeCount: 4 }, - { name: "Mycelial Lab", slug: "mycelial-lab", nodeCount: 5, edgeCount: 4 }, - { name: "Regenerative Fund", slug: "regenerative-fund", nodeCount: 5, edgeCount: 4 }, - ]; - - // Organizations - this.nodes = [ - { id: "org-1", name: "Commons DAO", type: "company", workspace: "Commons DAO", description: "Decentralized governance cooperative" }, - { id: "org-2", name: "Mycelial Lab", type: "company", workspace: "Mycelial Lab", description: "Protocol research collective" }, - { id: "org-3", name: "Regenerative Fund", type: "company", workspace: "Regenerative Fund", description: "Impact funding vehicle" }, - - // People — Commons DAO - { id: "p-1", name: "Alice Chen", type: "person", workspace: "Commons DAO", role: "Lead Engineer", location: "Vancouver" }, - { id: "p-2", name: "Bob Nakamura", type: "person", workspace: "Commons DAO", role: "Community Lead", location: "Tokyo" }, - { id: "p-3", name: "Carol Santos", type: "person", workspace: "Commons DAO", role: "Treasury Steward", location: "São Paulo" }, - { id: "p-4", name: "Dave Okafor", type: "person", workspace: "Commons DAO", role: "Governance Facilitator", location: "Lagos" }, - - // People — Mycelial Lab - { id: "p-5", name: "Eva Larsson", type: "person", workspace: "Mycelial Lab", role: "Ops Coordinator", location: "Stockholm" }, - { id: "p-6", name: "Frank Müller", type: "person", workspace: "Mycelial Lab", role: "Protocol Designer", location: "Berlin" }, - { id: "p-7", name: "Grace Kim", type: "person", workspace: "Mycelial Lab", role: "Strategy Lead", location: "Seoul" }, - - // People — Regenerative Fund - { id: "p-8", name: "Hiro Tanaka", type: "person", workspace: "Regenerative Fund", role: "Research Lead", location: "Osaka" }, - { id: "p-9", name: "Iris Patel", type: "person", workspace: "Regenerative Fund", role: "Developer Relations", location: "Mumbai" }, - { id: "p-10", name: "James Wright", type: "person", workspace: "Regenerative Fund", role: "Security Auditor", location: "London" }, - ]; - - // Edges: work_at links + cross-org point_of_contact - this.edges = [ - // Work_at — Commons DAO - { source: "p-1", target: "org-1", type: "work_at" }, - { source: "p-2", target: "org-1", type: "work_at" }, - { source: "p-3", target: "org-1", type: "work_at" }, - { source: "p-4", target: "org-1", type: "work_at" }, - - // Work_at — Mycelial Lab - { source: "p-5", target: "org-2", type: "work_at" }, - { source: "p-6", target: "org-2", type: "work_at" }, - { source: "p-7", target: "org-2", type: "work_at" }, - - // Work_at — Regenerative Fund - { source: "p-8", target: "org-3", type: "work_at" }, - { source: "p-9", target: "org-3", type: "work_at" }, - { source: "p-10", target: "org-3", type: "work_at" }, - - // Cross-org point_of_contact edges - { source: "p-1", target: "p-6", type: "point_of_contact", label: "Alice ↔ Frank" }, - { source: "p-2", target: "p-3", type: "point_of_contact", label: "Bob ↔ Carol" }, - { source: "p-4", target: "p-7", type: "point_of_contact", label: "Dave ↔ Grace" }, - ]; - - this.layoutDirty = true; - this.render(); - requestAnimationFrame(() => this.fitView()); - } - private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^(\/[^/]+)?\/rnetwork/); diff --git a/modules/rnetwork/mod.ts b/modules/rnetwork/mod.ts index 888f409..dc73dbb 100644 --- a/modules/rnetwork/mod.ts +++ b/modules/rnetwork/mod.ts @@ -7,7 +7,7 @@ */ import { Hono } from "hono"; -import { renderShell, renderExternalAppShell } from "../../server/shell"; +import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { renderLanding } from "./landing"; @@ -275,35 +275,27 @@ routes.get("/api/opportunities", async (c) => { return c.json({ opportunities }); }); -// ── CRM sub-route — embed Twenty CRM via iframe ── +// ── CRM sub-route — API-driven CRM view ── routes.get("/crm", (c) => { const space = c.req.param("space") || "demo"; - const dataSpace = (c.get("effectiveSpace" as any) as string) || space; - return c.html(renderExternalAppShell({ + return c.html(renderShell({ title: `${space} — CRM | rSpace`, moduleId: "rnetwork", spaceSlug: space, modules: getModuleInfoList(), - appUrl: "https://crm.rspace.online", - appName: "Twenty CRM", + body: ``, + scripts: ``, + styles: ``, })); }); // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; - const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const view = c.req.query("view"); if (view === "app") { - return c.html(renderExternalAppShell({ - title: `${space} — CRM | rSpace`, - moduleId: "rnetwork", - spaceSlug: space, - modules: getModuleInfoList(), - appUrl: "https://crm.rspace.online", - appName: "Twenty CRM", - })); + return c.redirect(`/${space}/rnetwork/crm`, 301); } return c.html(renderShell({ @@ -311,7 +303,7 @@ routes.get("/", (c) => { moduleId: "rnetwork", spaceSlug: space, modules: getModuleInfoList(), - body: ` + body: ` `, scripts: ``, styles: ``, diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 7a3775b..eaee22d 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -1528,16 +1528,19 @@ async function generateSessionToken(userId: string, username: string): Promise { const now = Math.floor(Date.now() / 1000); - // Build claims - const claims: EncryptIDClaims = { - iss: 'https://auth.ridentity.online', - sub: did, - aud: [ - 'rspace.online', - 'rwallet.online', - 'rvote.online', - 'rfiles.online', - 'rmaps.online', - ], - iat: now, - exp: now + 15 * 60, // 15 minutes - jti: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer), + // Get a server-signed JWT by exchanging the credential via EncryptID + let accessToken: string; + try { + // Step 1: Get a server challenge + const startRes = await fetch(`${ENCRYPTID_SERVER}/api/auth/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credentialId: authResult.credentialId }), + }); + if (!startRes.ok) throw new Error('Failed to start server auth'); + const { options } = await startRes.json(); - eid: { - credentialId: authResult.credentialId, - authLevel: AuthLevel.ELEVATED, // Fresh WebAuthn - authTime: now, - capabilities, - recoveryConfigured: false, // TODO: Check actual status - }, - }; + // Step 2: Complete auth with credentialId to get signed token + const completeRes = await fetch(`${ENCRYPTID_SERVER}/api/auth/complete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: options.challenge, + credential: { credentialId: authResult.credentialId }, + }), + }); + if (!completeRes.ok) throw new Error('Server auth failed'); + const data = await completeRes.json(); + if (!data.token) throw new Error('No token in response'); + accessToken = data.token; + } catch (err) { + console.warn('EncryptID: Server token exchange failed, using local token', err); + // Fallback to unsigned token if server is unreachable + accessToken = this.createUnsignedToken({ + iss: 'https://auth.ridentity.online', + sub: did, + aud: ['rspace.online'], + iat: now, + exp: now + 15 * 60, + jti: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer), + eid: { + credentialId: authResult.credentialId, + authLevel: AuthLevel.ELEVATED, + authTime: now, + capabilities, + recoveryConfigured: false, + }, + }); + } - // In production, tokens would be signed by server - // For now, we create unsigned tokens for the prototype - const accessToken = this.createUnsignedToken(claims); - const refreshToken = this.createRefreshToken(did); + // Decode claims from the token (works for both signed and unsigned JWTs) + let claims: EncryptIDClaims; + try { + const payloadB64 = accessToken.split('.')[1]; + claims = JSON.parse(atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/'))); + } catch { + // Fallback claims + claims = { + iss: 'https://auth.ridentity.online', + sub: did, + aud: ['rspace.online'], + iat: now, + exp: now + 15 * 60, + jti: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer), + eid: { + credentialId: authResult.credentialId, + authLevel: AuthLevel.ELEVATED, + authTime: now, + capabilities, + recoveryConfigured: false, + }, + }; + } this.session = { accessToken, - refreshToken, + refreshToken: accessToken, // Server token serves as both claims, lastAuthTime: Date.now(), }; @@ -206,8 +249,9 @@ export class SessionManager { this.scheduleRefresh(); console.log('EncryptID: Session created', { - did: did.slice(0, 30) + '...', - authLevel: AuthLevel[claims.eid.authLevel], + did: (claims.sub || did).slice(0, 30) + '...', + authLevel: AuthLevel[claims.eid?.authLevel ?? AuthLevel.ELEVATED], + signed: !accessToken.endsWith('.'), }); return this.session; @@ -436,30 +480,38 @@ export class SessionManager { } private async refreshTokens(): Promise { - // In production, this would call the server to refresh tokens - // For the prototype, we just extend the expiration if (!this.session) return; - const now = Math.floor(Date.now() / 1000); + try { + // Call EncryptID server to refresh the token + const res = await fetch(`${ENCRYPTID_SERVER}/api/session/refresh`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.session.accessToken}`, + 'Content-Type': 'application/json', + }, + }); - // Downgrade auth level on refresh (user hasn't re-authenticated) - this.session.claims.eid.authLevel = Math.min( - this.session.claims.eid.authLevel, - AuthLevel.STANDARD - ); + if (!res.ok) throw new Error(`Refresh failed: ${res.status}`); + const data = await res.json(); + if (!data.token) throw new Error('No token in refresh response'); - this.session.claims.iat = now; - this.session.claims.exp = now + 15 * 60; - this.session.claims.jti = bufferToBase64url( - crypto.getRandomValues(new Uint8Array(16)).buffer - ); + // Decode new claims + const payloadB64 = data.token.split('.')[1]; + const claims = JSON.parse(atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/'))); - this.session.accessToken = this.createUnsignedToken(this.session.claims); + this.session.accessToken = data.token; + this.session.refreshToken = data.token; + this.session.claims = claims; - this.persistSession(); - this.scheduleRefresh(); + this.persistSession(); + this.scheduleRefresh(); - console.log('EncryptID: Tokens refreshed'); + console.log('EncryptID: Tokens refreshed (server-signed)'); + } catch (err) { + console.warn('EncryptID: Token refresh failed', err); + // Session will expire naturally; user will need to re-authenticate + } } }