diff --git a/server/spaces.ts b/server/spaces.ts index b122c2d4..94d7c75a 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -2479,20 +2479,21 @@ spaces.get("/:slug/invites", async (c) => { const data = getDocumentData(slug); if (!data) return c.json({ error: "Space not found" }, 404); - const isOwner = data.meta.ownerDID === claims.sub; - const callerMember = data.members?.[claims.sub]; + // ownerDID may be stored as raw userId or did:key:... — check both. + 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; + const callerMember = data.members?.[claims.sub] || data.members?.[callerDid]; if (!isOwner && callerMember?.role !== "admin") { return c.json({ error: "Admin access required" }, 403); } - // Fetch from EncryptID - + // Use the internal (no-auth) endpoint — rspace has already verified admin via the + // Automerge space doc. The public encryptid endpoint gates on the encryptid + // space_members table, which can be out of sync with Automerge ownership. try { - const res = await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/invites`, { - headers: { "Authorization": `Bearer ${token}` }, - }); - const data = await res.json(); - return c.json(data as any); + const res = await fetch(`${ENCRYPTID_URL}/api/internal/spaces/${slug}/invites`); + const payload = await res.json(); + return c.json(payload as any); } catch { return c.json({ invites: [], total: 0 }); } diff --git a/shared/components/rstack-app-switcher.ts b/shared/components/rstack-app-switcher.ts index e97850eb..65009108 100644 --- a/shared/components/rstack-app-switcher.ts +++ b/shared/components/rstack-app-switcher.ts @@ -296,44 +296,7 @@ export class RStackAppSwitcher extends HTMLElement { } } - // "Manage rApps" catalog section - const disabledModules = this.#allModules.filter( - m => m.enabled === false && m.id !== 'rspace' - ); - // Only show the Manage section when there are disabled modules to add, - // or when enabledModules is actively configured (not null/all-enabled) - const hasRestrictions = disabledModules.length > 0; - if (hasRestrictions || this.#allModules.length > this.#modules.length) { - html += ` -
- -
- `; - if (this.#catalogOpen) { - html += `
`; - // Show disabled modules with add option - if (disabledModules.length > 0) { - html += `
Available to Add
`; - for (const m of disabledModules) { - html += this.#renderCatalogItem(m, false); - } - } - // Show enabled modules with remove option (compact section below) - const enabledNonCore = this.#allModules.filter( - m => m.enabled !== false && m.id !== 'rspace' - ); - if (enabledNonCore.length > 0) { - html += `
Remove from Space
`; - for (const m of enabledNonCore) { - html += this.#renderCatalogItem(m, true); - } - } - html += `
`; - } - } + // Module enable/disable is managed via Edit Space → Modules tab, not from this dropdown. // Sort toggle + Footer html += ` diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index d9600765..581811af 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -270,6 +270,22 @@ export async function getUserById(userId: string) { return user || null; } +/** Resolve a user by internal id (UUID), real did (did:key:z6Mk...), or + * legacy synthetic did (did:key: + first 32 chars of userId). */ +export async function getUserByIdOrDid(idOrDid: string) { + const [direct] = await sql`SELECT * FROM users WHERE id = ${idOrDid} OR did = ${idOrDid} LIMIT 1`; + if (direct) return direct; + // Legacy synthetic DID: did:key: + if (idOrDid.startsWith('did:key:')) { + const idPrefix = idOrDid.slice('did:key:'.length); + if (idPrefix.length >= 16) { + const [legacy] = await sql`SELECT * FROM users WHERE id LIKE ${idPrefix + '%'} LIMIT 1`; + return legacy || null; + } + } + return null; +} + /** Record a global logout — all JWTs issued before this timestamp are revoked */ export async function setUserLoggedOutAt(userId: string): Promise { await sql`UPDATE users SET logged_out_at = NOW() WHERE id = ${userId}`; diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 904408ae..0887e5b8 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -29,6 +29,7 @@ import { setUserEmail, getUserByEmail, getUserById, + getUserByIdOrDid, getUserByUsername, storeRecoveryToken, getRecoveryToken, @@ -4629,9 +4630,12 @@ app.delete('/api/spaces/:slug/members/:did', async (c) => { // ── Internal: email lookup by userId (no auth, internal network only) ── app.get('/api/internal/user-email/:userId', async (c) => { - const userId = c.req.param('userId'); - if (!userId) return c.json({ error: 'userId required' }, 400); - const [user, profile] = await Promise.all([getUserById(userId), getUserProfile(userId)]); + const idOrDid = c.req.param('userId'); + if (!idOrDid) return c.json({ error: 'userId required' }, 400); + // Members dicts store either raw userId (UUID) or did:key:... — accept both. + const user = await getUserByIdOrDid(idOrDid); + const resolvedId = user?.id || idOrDid; + const profile = await getUserProfile(resolvedId); if (!user && !profile) return c.json({ error: 'User not found' }, 404); return c.json({ recoveryEmail: user?.email || null, @@ -4829,6 +4833,15 @@ app.get('/api/spaces/:slug/invites', async (c) => { return c.json({ invites, total: invites.length }); }); +// GET /api/internal/spaces/:slug/invites — internal (no auth) list for rspace proxy. +// Authority check lives on the rspace side against the Automerge space doc, so this +// route is safe to expose on the internal network only. +app.get('/api/internal/spaces/:slug/invites', async (c) => { + const { slug } = c.req.param(); + const invites = await listSpaceInvites(slug); + return c.json({ invites, total: invites.length }); +}); + // POST /api/spaces/:slug/invites/:id/revoke — revoke an invite app.post('/api/spaces/:slug/invites/:id/revoke', async (c) => { const { slug, id } = c.req.param(); @@ -6362,6 +6375,7 @@ function joinPage(token: string): string { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + userId: startData.userId, username, challenge: options.challenge, deviceName: detectDeviceName(), @@ -6703,6 +6717,7 @@ function oidcAcceptPage(token: string): string { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + userId: startData.userId, username, challenge: options.challenge, deviceName: detectDeviceName(),