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 <noreply@anthropic.com>
This commit is contained in:
parent
c2c3d1fb06
commit
4212a651e1
|
|
@ -43,6 +43,7 @@ services:
|
||||||
- SMTP_USER=${SMTP_USER:-noreply@rmail.online}
|
- SMTP_USER=${SMTP_USER:-noreply@rmail.online}
|
||||||
- SMTP_PASS=${SMTP_PASS}
|
- SMTP_PASS=${SMTP_PASS}
|
||||||
- TWENTY_API_URL=http://twenty-ch-server:3000
|
- TWENTY_API_URL=http://twenty-ch-server:3000
|
||||||
|
- TWENTY_API_TOKEN=${TWENTY_API_TOKEN}
|
||||||
- OLLAMA_URL=http://ollama:11434
|
- OLLAMA_URL=http://ollama:11434
|
||||||
- INFISICAL_AI_CLIENT_ID=${INFISICAL_AI_CLIENT_ID}
|
- INFISICAL_AI_CLIENT_ID=${INFISICAL_AI_CLIENT_ID}
|
||||||
- INFISICAL_AI_CLIENT_SECRET=${INFISICAL_AI_CLIENT_SECRET}
|
- INFISICAL_AI_CLIENT_SECRET=${INFISICAL_AI_CLIENT_SECRET}
|
||||||
|
|
|
||||||
|
|
@ -221,14 +221,14 @@ class FolkCartShop extends HTMLElement {
|
||||||
|
|
||||||
// Existing catalog demo data
|
// Existing catalog demo data
|
||||||
this.catalog = [
|
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-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", created_at: new Date(now - 25 * 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", 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-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 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-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", 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-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", 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-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.", 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-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 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-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 = [
|
this.orders = [
|
||||||
|
|
@ -657,19 +657,24 @@ class FolkCartShop extends HTMLElement {
|
||||||
return `<div class="empty">No items in the catalog yet. Ingest artifacts from rPubs or Swag Designer to list them here.</div>`;
|
return `<div class="empty">No items in the catalog yet. Ingest artifacts from rPubs or Swag Designer to list them here.</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `<div class="grid">
|
return `<div class="grid catalog-grid">
|
||||||
${this.catalog.map((entry) => `
|
${this.catalog.map((entry) => `
|
||||||
<div class="card" data-collab-id="product:${entry.id || entry.title}">
|
<div class="card catalog-card" data-collab-id="product:${entry.id || entry.title}">
|
||||||
<h3 class="card-title">${this.esc(entry.title || "Untitled")}</h3>
|
${entry.image_url ? `<div class="catalog-img"><img src="${this.esc(entry.image_url)}" alt="${this.esc(entry.title || '')}" loading="lazy" /></div>` : ""}
|
||||||
<div class="card-meta">
|
<div class="catalog-body">
|
||||||
${entry.product_type ? `<span class="tag tag-type">${this.esc(entry.product_type)}</span>` : ""}
|
<h3 class="card-title">${this.esc(entry.title || "Untitled")}</h3>
|
||||||
${(entry.required_capabilities || []).map((cap: string) => `<span class="tag tag-cap">${this.esc(cap)}</span>`).join("")}
|
<div class="card-meta">
|
||||||
${(entry.tags || []).map((t: string) => `<span class="tag tag-cap">${this.esc(t)}</span>`).join("")}
|
${entry.product_type ? `<span class="tag tag-type">${this.esc(entry.product_type)}</span>` : ""}
|
||||||
|
${(entry.required_capabilities || []).map((cap: string) => `<span class="tag tag-cap">${this.esc(cap)}</span>`).join("")}
|
||||||
|
${(entry.tags || []).map((t: string) => `<span class="tag tag-cap">${this.esc(t)}</span>`).join("")}
|
||||||
|
</div>
|
||||||
|
${entry.description ? `<div class="card-desc">${this.esc(entry.description)}</div>` : ""}
|
||||||
|
${entry.dimensions ? `<div class="dims">${entry.dimensions.width_mm}x${entry.dimensions.height_mm}mm</div>` : ""}
|
||||||
|
<div class="catalog-footer">
|
||||||
|
${entry.price != null ? `<div class="price">$${parseFloat(entry.price).toFixed(2)} ${entry.currency || ""}</div>` : ""}
|
||||||
|
<span class="status status-${entry.status}">${entry.status}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${entry.description ? `<div class="card-meta">${this.esc(entry.description)}</div>` : ""}
|
|
||||||
${entry.dimensions ? `<div class="dims">${entry.dimensions.width_mm}x${entry.dimensions.height_mm}mm</div>` : ""}
|
|
||||||
${entry.price != null ? `<div class="price">$${parseFloat(entry.price).toFixed(2)} ${entry.currency || ""}</div>` : ""}
|
|
||||||
<div style="margin-top:0.5rem"><span class="status status-${entry.status}">${entry.status}</span></div>
|
|
||||||
</div>
|
</div>
|
||||||
`).join("")}
|
`).join("")}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
@ -821,7 +826,18 @@ class FolkCartShop extends HTMLElement {
|
||||||
.status-cancelled, .status-closed { background: rgba(239,68,68,0.15); color: #f87171; }
|
.status-cancelled, .status-closed { background: rgba(239,68,68,0.15); color: #f87171; }
|
||||||
.status-shipped { background: rgba(56,189,248,0.15); color: #38bdf8; }
|
.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; }
|
.text-green { color: #4ade80; }
|
||||||
|
|
||||||
.progress-bar { background: var(--rs-bg-surface-raised); border-radius: 999px; height: 8px; overflow: hidden; }
|
.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; }
|
.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); }
|
.loading { text-align: center; padding: 3rem; color: var(--rs-text-secondary); }
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 600px) {
|
||||||
.grid { grid-template-columns: 1fr; }
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
.catalog-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
|
||||||
.url-input-row { flex-direction: column; }
|
.url-input-row { flex-direction: column; }
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -62,72 +62,10 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.space = this.getAttribute("space") || "demo";
|
this.space = this.getAttribute("space") || "demo";
|
||||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
|
||||||
this.render(); // Show loading state
|
this.render(); // Show loading state
|
||||||
this.loadData(); // Async — will re-render when data arrives
|
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 {
|
private getApiBase(): string {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
const match = path.match(/^(\/[^/]+)?\/rnetwork/);
|
const match = path.match(/^(\/[^/]+)?\/rnetwork/);
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { renderShell, renderExternalAppShell } from "../../server/shell";
|
import { renderShell } from "../../server/shell";
|
||||||
import { getModuleInfoList } from "../../shared/module";
|
import { getModuleInfoList } from "../../shared/module";
|
||||||
import type { RSpaceModule } from "../../shared/module";
|
import type { RSpaceModule } from "../../shared/module";
|
||||||
import { renderLanding } from "./landing";
|
import { renderLanding } from "./landing";
|
||||||
|
|
@ -275,35 +275,27 @@ routes.get("/api/opportunities", async (c) => {
|
||||||
return c.json({ opportunities });
|
return c.json({ opportunities });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── CRM sub-route — embed Twenty CRM via iframe ──
|
// ── CRM sub-route — API-driven CRM view ──
|
||||||
routes.get("/crm", (c) => {
|
routes.get("/crm", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
return c.html(renderShell({
|
||||||
return c.html(renderExternalAppShell({
|
|
||||||
title: `${space} — CRM | rSpace`,
|
title: `${space} — CRM | rSpace`,
|
||||||
moduleId: "rnetwork",
|
moduleId: "rnetwork",
|
||||||
spaceSlug: space,
|
spaceSlug: space,
|
||||||
modules: getModuleInfoList(),
|
modules: getModuleInfoList(),
|
||||||
appUrl: "https://crm.rspace.online",
|
body: `<folk-crm-view space="${space}"></folk-crm-view>`,
|
||||||
appName: "Twenty CRM",
|
scripts: `<script type="module" src="/modules/rnetwork/folk-crm-view.js"></script>`,
|
||||||
|
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Page route ──
|
// ── Page route ──
|
||||||
routes.get("/", (c) => {
|
routes.get("/", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
|
||||||
const view = c.req.query("view");
|
const view = c.req.query("view");
|
||||||
|
|
||||||
if (view === "app") {
|
if (view === "app") {
|
||||||
return c.html(renderExternalAppShell({
|
return c.redirect(`/${space}/rnetwork/crm`, 301);
|
||||||
title: `${space} — CRM | rSpace`,
|
|
||||||
moduleId: "rnetwork",
|
|
||||||
spaceSlug: space,
|
|
||||||
modules: getModuleInfoList(),
|
|
||||||
appUrl: "https://crm.rspace.online",
|
|
||||||
appName: "Twenty CRM",
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.html(renderShell({
|
return c.html(renderShell({
|
||||||
|
|
@ -311,7 +303,7 @@ routes.get("/", (c) => {
|
||||||
moduleId: "rnetwork",
|
moduleId: "rnetwork",
|
||||||
spaceSlug: space,
|
spaceSlug: space,
|
||||||
modules: getModuleInfoList(),
|
modules: getModuleInfoList(),
|
||||||
body: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a></div>
|
body: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="/${space}/rnetwork/crm" class="rapp-nav__btn--app-toggle">Open CRM</a></div>
|
||||||
<folk-graph-viewer space="${space}"></folk-graph-viewer>`,
|
<folk-graph-viewer space="${space}"></folk-graph-viewer>`,
|
||||||
scripts: `<script type="module" src="/modules/rnetwork/folk-graph-viewer.js"></script>`,
|
scripts: `<script type="module" src="/modules/rnetwork/folk-graph-viewer.js"></script>`,
|
||||||
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
|
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
|
||||||
|
|
|
||||||
|
|
@ -1528,16 +1528,19 @@ async function generateSessionToken(userId: string, username: string): Promise<s
|
||||||
aud: CONFIG.allowedOrigins,
|
aud: CONFIG.allowedOrigins,
|
||||||
iat: now,
|
iat: now,
|
||||||
exp: now + CONFIG.sessionDuration,
|
exp: now + CONFIG.sessionDuration,
|
||||||
|
jti: Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('base64url'),
|
||||||
username,
|
username,
|
||||||
did: `did:key:${userId.slice(0, 32)}`,
|
did: `did:key:${userId.slice(0, 32)}`,
|
||||||
eid: {
|
eid: {
|
||||||
authLevel: 3, // ELEVATED (fresh WebAuthn)
|
authLevel: 3, // ELEVATED (fresh WebAuthn)
|
||||||
|
authTime: now,
|
||||||
walletAddress: profile?.walletAddress || null,
|
walletAddress: profile?.walletAddress || null,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
encrypt: true,
|
encrypt: true,
|
||||||
sign: true,
|
sign: true,
|
||||||
wallet: hasWallet,
|
wallet: hasWallet,
|
||||||
},
|
},
|
||||||
|
recoveryConfigured: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
import { AuthenticationResult, bufferToBase64url } from './webauthn';
|
import { AuthenticationResult, bufferToBase64url } from './webauthn';
|
||||||
import { resetLinkedWalletStore } from './linked-wallets';
|
import { resetLinkedWalletStore } from './linked-wallets';
|
||||||
|
|
||||||
|
const ENCRYPTID_SERVER = 'https://auth.rspace.online';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -154,7 +156,8 @@ export class SessionManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize session from authentication result
|
* Initialize session from authentication result.
|
||||||
|
* Exchanges the credential for a server-signed JWT via EncryptID.
|
||||||
*/
|
*/
|
||||||
async createSession(
|
async createSession(
|
||||||
authResult: AuthenticationResult,
|
authResult: AuthenticationResult,
|
||||||
|
|
@ -163,38 +166,78 @@ export class SessionManager {
|
||||||
): Promise<SessionState> {
|
): Promise<SessionState> {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
// Build claims
|
// Get a server-signed JWT by exchanging the credential via EncryptID
|
||||||
const claims: EncryptIDClaims = {
|
let accessToken: string;
|
||||||
iss: 'https://auth.ridentity.online',
|
try {
|
||||||
sub: did,
|
// Step 1: Get a server challenge
|
||||||
aud: [
|
const startRes = await fetch(`${ENCRYPTID_SERVER}/api/auth/start`, {
|
||||||
'rspace.online',
|
method: 'POST',
|
||||||
'rwallet.online',
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'rvote.online',
|
body: JSON.stringify({ credentialId: authResult.credentialId }),
|
||||||
'rfiles.online',
|
});
|
||||||
'rmaps.online',
|
if (!startRes.ok) throw new Error('Failed to start server auth');
|
||||||
],
|
const { options } = await startRes.json();
|
||||||
iat: now,
|
|
||||||
exp: now + 15 * 60, // 15 minutes
|
|
||||||
jti: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer),
|
|
||||||
|
|
||||||
eid: {
|
// Step 2: Complete auth with credentialId to get signed token
|
||||||
credentialId: authResult.credentialId,
|
const completeRes = await fetch(`${ENCRYPTID_SERVER}/api/auth/complete`, {
|
||||||
authLevel: AuthLevel.ELEVATED, // Fresh WebAuthn
|
method: 'POST',
|
||||||
authTime: now,
|
headers: { 'Content-Type': 'application/json' },
|
||||||
capabilities,
|
body: JSON.stringify({
|
||||||
recoveryConfigured: false, // TODO: Check actual status
|
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
|
// Decode claims from the token (works for both signed and unsigned JWTs)
|
||||||
// For now, we create unsigned tokens for the prototype
|
let claims: EncryptIDClaims;
|
||||||
const accessToken = this.createUnsignedToken(claims);
|
try {
|
||||||
const refreshToken = this.createRefreshToken(did);
|
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 = {
|
this.session = {
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken: accessToken, // Server token serves as both
|
||||||
claims,
|
claims,
|
||||||
lastAuthTime: Date.now(),
|
lastAuthTime: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
@ -206,8 +249,9 @@ export class SessionManager {
|
||||||
this.scheduleRefresh();
|
this.scheduleRefresh();
|
||||||
|
|
||||||
console.log('EncryptID: Session created', {
|
console.log('EncryptID: Session created', {
|
||||||
did: did.slice(0, 30) + '...',
|
did: (claims.sub || did).slice(0, 30) + '...',
|
||||||
authLevel: AuthLevel[claims.eid.authLevel],
|
authLevel: AuthLevel[claims.eid?.authLevel ?? AuthLevel.ELEVATED],
|
||||||
|
signed: !accessToken.endsWith('.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.session;
|
return this.session;
|
||||||
|
|
@ -436,30 +480,38 @@ export class SessionManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshTokens(): Promise<void> {
|
private async refreshTokens(): Promise<void> {
|
||||||
// In production, this would call the server to refresh tokens
|
|
||||||
// For the prototype, we just extend the expiration
|
|
||||||
if (!this.session) return;
|
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)
|
if (!res.ok) throw new Error(`Refresh failed: ${res.status}`);
|
||||||
this.session.claims.eid.authLevel = Math.min(
|
const data = await res.json();
|
||||||
this.session.claims.eid.authLevel,
|
if (!data.token) throw new Error('No token in refresh response');
|
||||||
AuthLevel.STANDARD
|
|
||||||
);
|
|
||||||
|
|
||||||
this.session.claims.iat = now;
|
// Decode new claims
|
||||||
this.session.claims.exp = now + 15 * 60;
|
const payloadB64 = data.token.split('.')[1];
|
||||||
this.session.claims.jti = bufferToBase64url(
|
const claims = JSON.parse(atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/')));
|
||||||
crypto.getRandomValues(new Uint8Array(16)).buffer
|
|
||||||
);
|
|
||||||
|
|
||||||
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.persistSession();
|
||||||
this.scheduleRefresh();
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue