Skip to content

Commit facaa12

Browse files
committed
Link room creator access to login
1 parent 80b22a7 commit facaa12

16 files changed

Lines changed: 124 additions & 34 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ The frontend now uses these backend route handlers for spaces, posts, reactions,
8383

8484
For dump login, enable Supabase Google OAuth and allow `/auth/callback` in the Supabase Auth redirect URLs for each environment, for example `http://127.0.0.1:3000/auth/callback` locally and `https://mumbl.wtf/auth/callback` in production. Email magic-link code is kept dormant for now; custom SMTP is recommended before exposing it again.
8585

86+
Creator access starts with the local room creator token. When a logged-in creator presents that token, Mumbl links the room to their auth account so creator controls survive across browsers without tracking normal room membership.
87+
8688
Until those variables exist, API routes return a setup `503`.
8789

8890
## Slack Beta Setup

app/api/auth/link-session/route.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { badRequest, ok, serverError } from "../../../../src/server/http";
2+
import { hashToken } from "../../../../src/server/hash";
23
import { resolveRequestOwner } from "../../../../src/server/auth";
34
import { getSupabaseAdmin } from "../../../../src/server/supabase";
45
import { cleanString } from "../../../../src/server/validation";
@@ -7,6 +8,7 @@ export async function POST(request) {
78
try {
89
const body = await request.json();
910
const sessionToken = cleanString(body.sessionToken, 256);
11+
const creatorTokens = parseCreatorTokens(body.creatorTokens);
1012
if (!sessionToken) return badRequest("session token is required");
1113

1214
const owner = await resolveRequestOwner({ request, sessionToken });
@@ -18,6 +20,7 @@ export async function POST(request) {
1820
linkTable({ supabase, table: "field_notes", owner }),
1921
linkTable({ supabase, table: "public_profiles", owner, tolerateMissing: true }),
2022
linkTable({ supabase, table: "dump_insights", owner, tolerateMissing: true }),
23+
linkCreatorSpaces({ supabase, owner, creatorTokens }),
2124
]);
2225

2326
return ok({
@@ -26,12 +29,41 @@ export async function POST(request) {
2629
fieldNotes: updates[1],
2730
publicProfiles: updates[2],
2831
dumpInsights: updates[3],
32+
creatorSpaces: updates[4],
2933
});
3034
} catch (error) {
3135
return serverError(error);
3236
}
3337
}
3438

39+
function parseCreatorTokens(value) {
40+
if (!Array.isArray(value)) return [];
41+
return value
42+
.map((item) => ({
43+
slug: cleanString(item?.slug, 64),
44+
token: cleanString(item?.token, 256),
45+
}))
46+
.filter((item) => item.slug && item.token)
47+
.slice(0, 50);
48+
}
49+
50+
async function linkCreatorSpaces({ supabase, owner, creatorTokens }) {
51+
if (!creatorTokens.length) return 0;
52+
let linked = 0;
53+
for (const item of creatorTokens) {
54+
const { count, error } = await supabase
55+
.from("spaces")
56+
.update({ creator_user_id: owner.userId }, { count: "exact" })
57+
.eq("slug", item.slug)
58+
.eq("creator_token_hash", hashToken(item.token))
59+
.is("creator_user_id", null);
60+
if (isMissingColumnError(error)) return 0;
61+
if (error) throw error;
62+
linked += count || 0;
63+
}
64+
return linked;
65+
}
66+
3567
async function linkTable({ supabase, table, owner, tolerateMissing = false }) {
3668
const { count, error } = await supabase
3769
.from(table)

app/api/spaces/[slug]/first-post-dismissed/route.js

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,28 @@
11
import { badRequest, notFound, ok, serverError } from "../../../../../src/server/http";
2-
import { hashToken } from "../../../../../src/server/hash";
2+
import { claimCreatorAccess, resolveCreatorAccess } from "../../../../../src/server/creatorAccess";
33
import { getSupabaseAdmin } from "../../../../../src/server/supabase";
4-
import { cleanString } from "../../../../../src/server/validation";
54

65
export async function POST(request, { params }) {
76
try {
87
const { slug } = await params;
98
const body = await request.json();
10-
const creatorToken = cleanString(body.creatorToken, 256);
119

1210
if (!slug) return badRequest("space slug is required");
13-
if (!creatorToken) return badRequest("creator token is required");
1411

1512
const supabase = getSupabaseAdmin();
1613
const { data: space, error: spaceError } = await supabase
1714
.from("spaces")
18-
.select("id,creator_token_hash")
15+
.select("id,creator_token_hash,creator_user_id")
1916
.eq("slug", slug)
2017
.single();
2118
if (spaceError?.code === "PGRST116") return notFound("space not found");
2219
if (spaceError) throw spaceError;
2320

24-
if (space.creator_token_hash !== hashToken(creatorToken)) {
21+
const access = await resolveCreatorAccess({ request, body, space });
22+
if (!access.canManage) {
2523
return badRequest("creator token did not match");
2624
}
25+
if (access.shouldClaim) await claimCreatorAccess({ supabase, spaceId: space.id, userId: access.owner.userId });
2726

2827
const { error: updateError } = await supabase.from("spaces").update({ first_post_done: true }).eq("id", space.id);
2928
if (updateError) throw updateError;

app/api/spaces/[slug]/route.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { badRequest, notFound, ok, serverError } from "../../../../src/server/http";
2+
import { resolveRequestOwner } from "../../../../src/server/auth";
3+
import { claimCreatorAccess, resolveCreatorAccess } from "../../../../src/server/creatorAccess";
24
import { hashToken } from "../../../../src/server/hash";
35
import { getOrCreateKnownPublicRoom } from "../../../../src/server/demoRoom";
46
import { getTopReactionLabels, startOfTodayIso } from "../../../../src/server/roomVibe";
@@ -18,6 +20,7 @@ export async function GET(request, { params }) {
1820
const url = new URL(request.url);
1921
const sessionToken = url.searchParams.get("sessionToken");
2022
const sessionTokenHash = sessionToken ? hashToken(sessionToken) : null;
23+
const owner = await resolveRequestOwner({ request, sessionToken: cleanString(sessionToken, 256) });
2124
const postLimit = parsePostLimit(url.searchParams.get("limit"));
2225
const postCursor = parsePostCursor(url.searchParams.get("before"));
2326
const postType = parsePostType(url.searchParams.get("type"));
@@ -98,6 +101,7 @@ export async function GET(request, { params }) {
98101
{
99102
roomVibe: getTopReactionLabels(todayReactionsResult.data),
100103
slackTeamReads: slackTeamReadsResult.error ? null : slackTeamReadsResult.data,
104+
canManage: Boolean(owner.userId && space.creator_user_id === owner.userId),
101105
postsPage: {
102106
limit: postLimit,
103107
count: postCount || 0,
@@ -140,7 +144,6 @@ export async function PATCH(request, { params }) {
140144
try {
141145
const { slug } = await params;
142146
const body = await request.json();
143-
const creatorToken = cleanString(body.creatorToken, 256);
144147
const updates = {};
145148

146149
if (Object.hasOwn(body, "isPublic")) {
@@ -154,21 +157,22 @@ export async function PATCH(request, { params }) {
154157
}
155158

156159
if (!slug) return badRequest("space slug is required");
157-
if (!creatorToken) return badRequest("creator token is required");
158160
if (!Object.keys(updates).length) return badRequest("no space settings to update");
159161

160162
const supabase = getSupabaseAdmin();
161163
const { data: space, error: spaceError } = await supabase
162164
.from("spaces")
163-
.select("id,creator_token_hash")
165+
.select("id,creator_token_hash,creator_user_id")
164166
.eq("slug", slug)
165167
.single();
166168
if (spaceError?.code === "PGRST116") return notFound("space not found");
167169
if (spaceError) throw spaceError;
168170

169-
if (space.creator_token_hash !== hashToken(creatorToken)) {
171+
const access = await resolveCreatorAccess({ request, body, space });
172+
if (!access.canManage) {
170173
return badRequest("creator token did not match");
171174
}
175+
if (access.shouldClaim) await claimCreatorAccess({ supabase, spaceId: space.id, userId: access.owner.userId });
172176

173177
const { data: updatedSpace, error: updateError } = await supabase
174178
.from("spaces")

app/api/spaces/[slug]/slack/team-reads/route.js

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,22 @@
11
import { badRequest, notFound, ok, serverError } from "../../../../../../src/server/http";
2-
import { hashToken } from "../../../../../../src/server/hash";
2+
import { claimCreatorAccess, resolveCreatorAccess } from "../../../../../../src/server/creatorAccess";
33
import { updateSlackSpacePosting } from "../../../../../../src/server/slack";
44
import { getSupabaseAdmin } from "../../../../../../src/server/supabase";
5-
import { cleanString } from "../../../../../../src/server/validation";
65

76
export async function PATCH(request, { params }) {
87
try {
98
const { slug } = await params;
109
const body = await request.json();
11-
const creatorToken = cleanString(body.creatorToken, 256);
1210
if (!slug) return badRequest("space slug is required");
13-
if (!creatorToken) return badRequest("creator token is required");
1411
if (!Object.hasOwn(body, "postingEnabled")) return badRequest("postingEnabled is required");
1512

1613
const supabase = getSupabaseAdmin();
17-
const { data: space, error } = await supabase.from("spaces").select("id,creator_token_hash").eq("slug", slug).single();
14+
const { data: space, error } = await supabase.from("spaces").select("id,creator_token_hash,creator_user_id").eq("slug", slug).single();
1815
if (error?.code === "PGRST116") return notFound("space not found");
1916
if (error) throw error;
20-
if (space.creator_token_hash !== hashToken(creatorToken)) return badRequest("creator token did not match");
17+
const access = await resolveCreatorAccess({ request, body, space });
18+
if (!access.canManage) return badRequest("creator token did not match");
19+
if (access.shouldClaim) await claimCreatorAccess({ supabase, spaceId: space.id, userId: access.owner.userId });
2120

2221
const slackTeamReads = await updateSlackSpacePosting({ spaceId: space.id, postingEnabled: body.postingEnabled === true });
2322
return ok({ slackTeamReads });

app/api/spaces/[slug]/slack/team-reads/setup/route.js

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,21 @@
11
import { badRequest, notFound, ok, serverError } from "../../../../../../../src/server/http";
2-
import { hashToken } from "../../../../../../../src/server/hash";
2+
import { claimCreatorAccess, resolveCreatorAccess } from "../../../../../../../src/server/creatorAccess";
33
import { createTeamReadsSetup, slackTeamReadsInstallUrl } from "../../../../../../../src/server/slack";
44
import { getSupabaseAdmin } from "../../../../../../../src/server/supabase";
5-
import { cleanString } from "../../../../../../../src/server/validation";
65

76
export async function POST(request, { params }) {
87
try {
98
const { slug } = await params;
109
const body = await request.json();
11-
const creatorToken = cleanString(body.creatorToken, 256);
1210
if (!slug) return badRequest("space slug is required");
13-
if (!creatorToken) return badRequest("creator token is required");
1411

1512
const supabase = getSupabaseAdmin();
16-
const { data: space, error } = await supabase.from("spaces").select("id,creator_token_hash").eq("slug", slug).single();
13+
const { data: space, error } = await supabase.from("spaces").select("id,creator_token_hash,creator_user_id").eq("slug", slug).single();
1714
if (error?.code === "PGRST116") return notFound("space not found");
1815
if (error) throw error;
19-
if (space.creator_token_hash !== hashToken(creatorToken)) return badRequest("creator token did not match");
16+
const access = await resolveCreatorAccess({ request, body, space });
17+
if (!access.canManage) return badRequest("creator token did not match");
18+
if (access.shouldClaim) await claimCreatorAccess({ supabase, spaceId: space.id, userId: access.owner.userId });
2019

2120
const setup = await createTeamReadsSetup({ spaceId: space.id });
2221
return ok({ installUrl: slackTeamReadsInstallUrl(setup) });

app/api/spaces/route.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ok, badRequest, serverError } from "../../../src/server/http";
2+
import { resolveRequestOwner } from "../../../src/server/auth";
23
import { createToken, hashToken } from "../../../src/server/hash";
34
import { getSupabaseAdmin } from "../../../src/server/supabase";
45
import { cleanString, isValidVibe, slugify } from "../../../src/server/validation";
@@ -13,6 +14,7 @@ export async function POST(request) {
1314
if (!isValidVibe(vibe)) return badRequest("unsupported vibe");
1415

1516
const supabase = getSupabaseAdmin();
17+
const owner = await resolveRequestOwner({ request, sessionToken: cleanString(body.sessionToken, 256) || "space-create" });
1618
const creatorToken = createToken();
1719
const baseSlug = slugify(name) || "team-mumbl";
1820
let slug = baseSlug;
@@ -26,6 +28,7 @@ export async function POST(request) {
2628
name,
2729
vibe,
2830
creator_token_hash: hashToken(creatorToken),
31+
...(owner.userId ? { creator_user_id: owner.userId } : {}),
2932
})
3033
.select()
3134
.single();

docs/backend-plan.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ Prototype public profiles now exist as a no-signup bridge: a browser session can
110110

111111
Email magic-link login now exists only for private dump persistence. Anonymous dumping still works without signup. When a user logs in, Mumbl links the current browser session's dumps, field-note drafts, public profile, and dump insights to the Supabase Auth user id. Visibility does not change, and room posts/reactions continue to use the anonymous browser session token so auth never becomes room identity.
112112

113+
Creator-token room ownership can also be claimed by a logged-in creator. The local creator token remains the recovery/portable key, but once a logged-in user proves possession of it, `spaces.creator_user_id` lets creator controls survive across browsers. This must not become room membership tracking; only creator access is persisted.
114+
113115
## Local Setup
114116

115117
1. Create a Supabase project.

src/components/SpacePageClient.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ export default function SpacePageClient({ slug, tab }) {
4242
const [toast, setToast] = useState("");
4343

4444
useEffect(() => {
45-
setHasCreatorToken(Boolean(getCreatorToken(slug)));
46-
}, [slug]);
45+
setHasCreatorToken(Boolean(getCreatorToken(slug)) || space?.canManage === true);
46+
}, [slug, space?.canManage]);
4747

4848
async function copyText(text, message, target = "copy") {
4949
try {

src/lib/api.js

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ export async function joinWaitlist({ email }) {
1212

1313
export async function fetchSpace(slug, { limit, before, type } = {}) {
1414
const sessionToken = loadSession();
15+
const auth = await authRequestContext();
1516
const params = new URLSearchParams({ sessionToken });
1617
if (limit) params.set("limit", String(limit));
1718
if (before) params.set("before", before);
1819
if (type) params.set("type", type);
1920

2021
const response = await fetch(`/api/spaces/${slug}?${params.toString()}`, {
22+
headers: auth.headers,
2123
cache: "no-store",
2224
});
2325
const data = await parseJson(response);
@@ -26,10 +28,11 @@ export async function fetchSpace(slug, { limit, before, type } = {}) {
2628
}
2729

2830
export async function createRemoteSpace({ name, vibe }) {
31+
const auth = await authRequestContext();
2932
const response = await fetch("/api/spaces", {
3033
method: "POST",
31-
headers: { "content-type": "application/json" },
32-
body: JSON.stringify({ name, vibe }),
34+
headers: { "content-type": "application/json", ...auth.headers },
35+
body: JSON.stringify({ name, vibe, sessionToken: loadSession() }),
3336
});
3437
const data = await parseJson(response);
3538
saveCreatorToken(data.slug, data.creatorToken);
@@ -158,9 +161,10 @@ export async function deleteFieldNote(fieldNoteId) {
158161
}
159162

160163
export async function updateRemoteSpaceVisibility({ slug, isPublic, publicName }) {
164+
const auth = await authRequestContext();
161165
const response = await fetch(`/api/spaces/${slug}`, {
162166
method: "PATCH",
163-
headers: { "content-type": "application/json" },
167+
headers: { "content-type": "application/json", ...auth.headers },
164168
body: JSON.stringify({
165169
creatorToken: getCreatorToken(slug),
166170
isPublic,
@@ -171,9 +175,10 @@ export async function updateRemoteSpaceVisibility({ slug, isPublic, publicName }
171175
}
172176

173177
export async function updateRemoteSpaceDescription({ slug, description }) {
178+
const auth = await authRequestContext();
174179
const response = await fetch(`/api/spaces/${slug}`, {
175180
method: "PATCH",
176-
headers: { "content-type": "application/json" },
181+
headers: { "content-type": "application/json", ...auth.headers },
177182
body: JSON.stringify({
178183
creatorToken: getCreatorToken(slug),
179184
description,
@@ -183,18 +188,20 @@ export async function updateRemoteSpaceDescription({ slug, description }) {
183188
}
184189

185190
export async function startSlackTeamReadsSetup({ slug }) {
191+
const auth = await authRequestContext();
186192
const response = await fetch(`/api/spaces/${slug}/slack/team-reads/setup`, {
187193
method: "POST",
188-
headers: { "content-type": "application/json" },
194+
headers: { "content-type": "application/json", ...auth.headers },
189195
body: JSON.stringify({ creatorToken: getCreatorToken(slug) }),
190196
});
191197
return parseJson(response);
192198
}
193199

194200
export async function updateSlackTeamReadsPosting({ slug, postingEnabled }) {
201+
const auth = await authRequestContext();
195202
const response = await fetch(`/api/spaces/${slug}/slack/team-reads`, {
196203
method: "PATCH",
197-
headers: { "content-type": "application/json" },
204+
headers: { "content-type": "application/json", ...auth.headers },
198205
body: JSON.stringify({ creatorToken: getCreatorToken(slug), postingEnabled }),
199206
});
200207
return parseJson(response);
@@ -210,9 +217,10 @@ export async function pinSlackSpaceForPublishing({ slug }) {
210217
}
211218

212219
export async function dismissRemoteFirstPost(slug) {
220+
const auth = await authRequestContext();
213221
const response = await fetch(`/api/spaces/${slug}/first-post-dismissed`, {
214222
method: "POST",
215-
headers: { "content-type": "application/json" },
223+
headers: { "content-type": "application/json", ...auth.headers },
216224
body: JSON.stringify({ creatorToken: getCreatorToken(slug) }),
217225
});
218226
return parseJson(response);

0 commit comments

Comments
 (0)