fix(spaces): fix space creation routing and use /rspace URLs

Space creation was broken because the canvas module has id "rspace" but
all navigation URLs used "/canvas". On production subdomain routing this
resulted in 404s after creating a space.

- Switch create-space form from deprecated /api/communities to /api/spaces
- Replace all /canvas navigation URLs with /rspace to match module ID
- Fix DID matching in space listing to check both sub and did:key formats
- Add proper client DID support in EncryptID registration flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-20 11:07:01 -07:00
parent 8bd6e61ffc
commit 73ad020812
12 changed files with 57 additions and 24 deletions

View File

@ -339,7 +339,7 @@ export class FolkCanvas extends FolkShape {
enterBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (this.#sourceSlug) {
window.open(`/${this.#sourceSlug}/canvas`, "_blank");
window.open(`/${this.#sourceSlug}/rspace`, "_blank");
}
});
@ -462,7 +462,7 @@ export class FolkCanvas extends FolkShape {
statusBar.style.display = "none";
const enterBtn = content.querySelector(".enter-btn");
enterBtn?.addEventListener("click", () => {
if (this.#sourceSlug) window.open(`/${this.#sourceSlug}/canvas`, "_blank");
if (this.#sourceSlug) window.open(`/${this.#sourceSlug}/rspace`, "_blank");
});
// Disconnect when collapsed
this.#disconnect();

View File

@ -681,7 +681,7 @@ export class FolkRApp extends FolkShape {
const attrMode = this.getAttribute("mode");
if (attrMode === "iframe" || attrMode === "widget") this.#mode = attrMode;
// Auto-derive spaceSlug from current URL if not explicitly provided (/{space}/canvas → space)
// Auto-derive spaceSlug from current URL if not explicitly provided (/{space}/rspace → space)
if (!this.#spaceSlug) {
const pathParts = window.location.pathname.split("/").filter(Boolean);
if (pathParts.length >= 1) this.#spaceSlug = pathParts[0];

View File

@ -2,7 +2,7 @@
* Canvas module the collaborative infinite canvas.
*
* This is the original rSpace canvas restructured as an rSpace module.
* Routes are relative to the mount point (/:space/canvas in unified mode,
* Routes are relative to the mount point (/:space/rspace in unified mode,
* / in standalone mode).
*/

View File

@ -133,7 +133,8 @@ const DIST_DIR = resolve(import.meta.dir, "../dist");
// ── Hono app ──
const app = new Hono();
type AppEnv = { Variables: { isSubdomain: boolean } };
const app = new Hono<AppEnv>();
// Detect subdomain routing and set context flag
app.use("*", async (c, next) => {

View File

@ -165,7 +165,7 @@ include action markers in your response. Each marker is on its own line:
[MI_ACTION:{"type":"update-shape","shapeId":"shape-123","fields":{"content":"Updated text"}}]
[MI_ACTION:{"type":"delete-shape","shapeId":"shape-123"}]
[MI_ACTION:{"type":"move-shape","shapeId":"shape-123","x":400,"y":200}]
[MI_ACTION:{"type":"navigate","path":"/myspace/canvas"}]
[MI_ACTION:{"type":"navigate","path":"/myspace/rspace"}]
Use "$1", "$2", etc. as ref values when creating shapes, then reference them in subsequent connect actions.
Available shape types: folk-markdown, folk-wrapper, folk-image-gen, folk-video-gen, folk-prompt,

View File

@ -244,8 +244,11 @@ spaces.get("/", async (c) => {
seenSlugs.add(slug);
let vis = data.meta.visibility || "public";
const isOwner = !!(claims && data.meta.ownerDID === claims.sub);
const memberEntry = claims ? data.members?.[claims.sub] : undefined;
// Check both claims.sub (raw userId) and did:key: format for
// compatibility — auto-provisioned spaces store ownerDID as did:key:
const callerDid = claims ? ((claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`) : "";
const isOwner = !!(claims && (data.meta.ownerDID === claims.sub || data.meta.ownerDID === callerDid));
const memberEntry = claims ? (data.members?.[claims.sub] || data.members?.[callerDid]) : undefined;
const isMember = !!memberEntry;
// Owner's personal space (slug matches username) is always private
@ -354,7 +357,7 @@ spaces.post("/", async (c) => {
name: result.name,
visibility: result.visibility,
ownerDID: result.ownerDID,
url: `/${result.slug}/canvas`,
url: `/${result.slug}/rspace`,
}, 201);
});

View File

@ -444,7 +444,7 @@ export class RStackSpaceSwitcher extends HTMLElement {
});
const data = await res.json();
if (res.ok && data.slug) {
window.location.href = rspaceNavUrl(data.slug, "canvas");
window.location.href = rspaceNavUrl(data.slug, "rspace");
} else {
status.textContent = data.error || "Failed to create space";
submitBtn.disabled = false;

View File

@ -82,10 +82,10 @@ export async function getUserByUsername(username: string) {
// CREDENTIAL OPERATIONS
// ============================================================================
export async function storeCredential(cred: StoredCredential): Promise<void> {
export async function storeCredential(cred: StoredCredential, did?: string): Promise<void> {
// 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);
// If a proper DID is provided (e.g. from PRF key derivation), use it; otherwise omit
await createUser(cred.userId, cred.username, cred.username, did || undefined);
await sql`
INSERT INTO credentials (credential_id, user_id, public_key, counter, transports, created_at, rp_id)
@ -237,6 +237,17 @@ export async function getUserById(userId: string) {
return user || null;
}
/** Update a user's DID (e.g. upgrading from truncated to proper did:key:z6Mk...) */
export async function updateUserDid(userId: string, newDid: string): Promise<void> {
await sql`UPDATE users SET did = ${newDid}, updated_at = NOW() WHERE id = ${userId}`;
}
/** Update all space memberships from one DID to another */
export async function migrateSpaceMemberDid(oldDid: string, newDid: string): Promise<number> {
const result = await sql`UPDATE space_members SET user_did = ${newDid} WHERE user_did = ${oldDid}`;
return result.count;
}
// ============================================================================
// RECOVERY TOKEN OPERATIONS
// ============================================================================

View File

@ -562,7 +562,7 @@ app.post('/api/register/start', async (c) => {
* Complete registration - verify and store credential
*/
app.post('/api/register/complete', async (c) => {
const { challenge, credential, userId, username, email } = await c.req.json();
const { challenge, credential, userId, username, email, clientDid, eoaAddress } = await c.req.json();
// Verify challenge
const challengeRecord = await getChallenge(challenge);
@ -578,8 +578,11 @@ app.post('/api/register/complete', async (c) => {
// In production, verify the attestation properly
// For now, we trust the client-side verification
// Create user and store credential in database
const did = `did:key:${userId.slice(0, 32)}`;
// Use client-derived DID if available (proper did:key:z6Mk... from PRF),
// otherwise generate a provisional one from userId
const did = (clientDid && typeof clientDid === 'string' && clientDid.startsWith('did:key:z'))
? clientDid
: `did:key:${userId.slice(0, 32)}`;
await createUser(userId, username, username, did);
// Set recovery email if provided during registration
@ -599,11 +602,18 @@ app.post('/api/register/complete', async (c) => {
transports: credential.transports,
rpId,
};
await storeCredential(storedCredential);
await storeCredential(storedCredential, did);
// Store wallet address if provided (from PRF-derived EOA)
if (eoaAddress && typeof eoaAddress === 'string' && eoaAddress.startsWith('0x')) {
await updateUserProfile(userId, { walletAddress: eoaAddress });
}
console.log('EncryptID: Credential registered', {
credentialId: credential.credentialId.slice(0, 20) + '...',
userId: userId.slice(0, 20) + '...',
did: did.slice(0, 30) + '...',
hasWallet: !!eoaAddress,
});
// Auto-provision user space at <username>.rspace.online
@ -736,12 +746,16 @@ app.post('/api/auth/complete', async (c) => {
storedCredential.username
);
// Read stored DID from database
const authUser = await getUserById(storedCredential.userId);
const authDid = authUser?.did || `did:key:${storedCredential.userId.slice(0, 32)}`;
return c.json({
success: true,
userId: storedCredential.userId,
username: storedCredential.username,
token,
did: `did:key:${storedCredential.userId.slice(0, 32)}`,
did: authDid,
});
});
@ -1580,6 +1594,10 @@ async function generateSessionToken(userId: string, username: string): Promise<s
const hasWallet = !!profile?.walletAddress;
const upInfo = await getUserUPAddress(userId);
// Read stored DID from database instead of deriving from userId
const user = await getUserById(userId);
const did = user?.did || `did:key:${userId.slice(0, 32)}`;
const payload = {
iss: 'https://auth.rspace.online',
sub: userId,
@ -1588,7 +1606,7 @@ async function generateSessionToken(userId: string, username: string): Promise<s
exp: now + CONFIG.sessionDuration,
jti: Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('base64url'),
username,
did: `did:key:${userId.slice(0, 32)}`,
did,
eid: {
authLevel: 3, // ELEVATED (fresh WebAuthn)
authTime: now,

View File

@ -687,7 +687,7 @@
<td class="owner-cell" title="${escapeHtml(s.ownerDID)}">${truncateDID(s.ownerDID)}</td>
<td>
<div class="actions-cell">
<a href="/${encodeURIComponent(s.slug)}/canvas" class="action-link">Open</a>
<a href="/${encodeURIComponent(s.slug)}/rspace" class="action-link">Open</a>
<button class="action-link danger" onclick="window.__deleteSpace('${escapeHtml(s.slug)}', '${escapeHtml(s.name)}')">Delete</button>
</div>
</td>

View File

@ -371,7 +371,7 @@
submitBtn.textContent = "Creating...";
try {
const response = await fetch("/api/communities", {
const response = await fetch("/api/spaces", {
method: "POST",
headers: {
"Content-Type": "application/json",
@ -383,10 +383,10 @@
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Failed to create community");
throw new Error(data.error || "Failed to create space");
}
window.location.href = `/${slug}/canvas`;
window.location.href = `/${slug}/rspace`;
} catch (err) {
errorMessage.textContent = err.message;
errorMessage.style.display = "block";

View File

@ -555,7 +555,7 @@
const demo = document.getElementById('cta-demo');
if (primary) {
primary.textContent = 'Go to My Space';
primary.href = '/' + username + '/canvas';
primary.href = '/' + username + '/rspace';
}
if (demo) {
demo.textContent = 'View Demo Space';