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(),