Skip to content

Commit 71b69b1

Browse files
committed
Add Google session login for dumps
1 parent 1e86a2b commit 71b69b1

22 files changed

Lines changed: 684 additions & 140 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ NEXT_PUBLIC_UMAMI_WEBSITE_ID=
66
# Supabase project settings
77
# For local prod-release checks, mirror production values in `.env.production.local`.
88
NEXT_PUBLIC_SUPABASE_URL=
9+
NEXT_PUBLIC_SUPABASE_ANON_KEY=
910
SUPABASE_SERVICE_ROLE_KEY=
1011

1112
# Server-only salts/secrets. Generate long random values before deploy.

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,13 +74,15 @@ The frontend now uses these backend route handlers for spaces, posts, reactions,
7474

7575
1. Create a Supabase project.
7676
2. Copy `.env.example` to `.env.local`.
77-
3. Fill in `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `MUMBL_TOKEN_HASH_SECRET`, `MUMBL_SIDE_QUEST_ENCRYPTION_KEY`, and `CRON_SECRET`.
77+
3. Fill in `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `MUMBL_TOKEN_HASH_SECRET`, `MUMBL_SIDE_QUEST_ENCRYPTION_KEY`, and `CRON_SECRET`.
7878
4. Authenticate the Supabase CLI with `npx supabase login`.
7979
5. Run `npm run db:link -- your-project-ref` or `npm run db:link -- https://your-project.supabase.co`.
8080
`npm run db:link:staging` reads `.env.local`; `npm run db:link:prod` reads `.env.production.local`.
81-
6. Run `npm run db:push` to apply `supabase/migrations/0001_initial_schema.sql`.
81+
6. Run `npm run db:push` to apply the migrations.
8282
7. Restart `npm run app`.
8383

84+
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.
85+
8486
Until those variables exist, API routes return a setup `503`.
8587

8688
## Branches And Environments

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { badRequest, ok, serverError } from "../../../../src/server/http";
2+
import { resolveRequestOwner } from "../../../../src/server/auth";
3+
import { getSupabaseAdmin } from "../../../../src/server/supabase";
4+
import { cleanString } from "../../../../src/server/validation";
5+
6+
export async function POST(request) {
7+
try {
8+
const body = await request.json();
9+
const sessionToken = cleanString(body.sessionToken, 256);
10+
if (!sessionToken) return badRequest("session token is required");
11+
12+
const owner = await resolveRequestOwner({ request, sessionToken });
13+
if (!owner.userId) return badRequest("login is required");
14+
15+
const supabase = getSupabaseAdmin();
16+
const updates = await Promise.all([
17+
linkTable({ supabase, table: "dumps", owner }),
18+
linkTable({ supabase, table: "field_notes", owner }),
19+
linkTable({ supabase, table: "public_profiles", owner, tolerateMissing: true }),
20+
linkTable({ supabase, table: "dump_insights", owner, tolerateMissing: true }),
21+
]);
22+
23+
return ok({
24+
linked: true,
25+
dumps: updates[0],
26+
fieldNotes: updates[1],
27+
publicProfiles: updates[2],
28+
dumpInsights: updates[3],
29+
});
30+
} catch (error) {
31+
return serverError(error);
32+
}
33+
}
34+
35+
async function linkTable({ supabase, table, owner, tolerateMissing = false }) {
36+
const { count, error } = await supabase
37+
.from(table)
38+
.update({ user_id: owner.userId }, { count: "exact" })
39+
.eq("session_token_hash", owner.sessionTokenHash)
40+
.is("user_id", null);
41+
42+
if (tolerateMissing && (isMissingTableError(error) || isMissingColumnError(error))) return 0;
43+
if (isMissingColumnError(error)) return throwMissingAuthMigration();
44+
if (error) throw error;
45+
return count || 0;
46+
}
47+
48+
function isMissingTableError(error) {
49+
const message = `${error?.message || ""} ${error?.details || ""} ${error?.hint || ""}`.toLowerCase();
50+
return error?.code === "42P01" || error?.code === "PGRST205" || message.includes("could not find the table");
51+
}
52+
53+
function isMissingColumnError(error) {
54+
const message = `${error?.message || ""} ${error?.details || ""} ${error?.hint || ""}`.toLowerCase();
55+
return error?.code === "42703" || error?.code === "PGRST204" || (message.includes("could not find") && message.includes("column"));
56+
}
57+
58+
function throwMissingAuthMigration() {
59+
const error = new Error("Dump auth migration is not applied yet. Run supabase/migrations/0016_dump_auth_ownership.sql.");
60+
error.status = 503;
61+
throw error;
62+
}

app/api/dumps/[dumpId]/route.js

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { badRequest, notFound, ok, serverError } from "../../../../src/server/http";
2-
import { hashToken } from "../../../../src/server/hash";
2+
import { applyOwnerFilter, resolveRequestOwner } from "../../../../src/server/auth";
33
import { makeLocalReflection, serializeDump } from "../../../../src/server/dumps";
44
import { getSupabaseAdmin } from "../../../../src/server/supabase";
55
import { cleanString } from "../../../../src/server/validation";
@@ -17,15 +17,18 @@ export async function PATCH(request, { params }) {
1717
if (!content) return badRequest("dump content is required");
1818

1919
const supabase = getSupabaseAdmin();
20-
const { data: dump, error } = await supabase
21-
.from("dumps")
22-
.update({
23-
content,
24-
ai_reflection: wantsReflection ? makeLocalReflection(content) : null,
25-
updated_at: new Date().toISOString(),
26-
})
27-
.eq("id", dumpId)
28-
.eq("session_token_hash", hashToken(sessionToken))
20+
const owner = await resolveRequestOwner({ request, sessionToken });
21+
const { data: dump, error } = await applyOwnerFilter(
22+
supabase
23+
.from("dumps")
24+
.update({
25+
content,
26+
ai_reflection: wantsReflection ? makeLocalReflection(content) : null,
27+
updated_at: new Date().toISOString(),
28+
})
29+
.eq("id", dumpId),
30+
owner,
31+
)
2932
.select("*")
3033
.single();
3134
if (error?.code === "PGRST116") return notFound("dump not found");
@@ -47,11 +50,8 @@ export async function DELETE(request, { params }) {
4750
if (!sessionToken) return badRequest("session token is required");
4851

4952
const supabase = getSupabaseAdmin();
50-
const { error, count } = await supabase
51-
.from("dumps")
52-
.delete({ count: "exact" })
53-
.eq("id", dumpId)
54-
.eq("session_token_hash", hashToken(sessionToken));
53+
const owner = await resolveRequestOwner({ request, sessionToken });
54+
const { error, count } = await applyOwnerFilter(supabase.from("dumps").delete({ count: "exact" }).eq("id", dumpId), owner);
5555
if (error) throw error;
5656
if (!count) return notFound("dump not found");
5757

app/api/dumps/field-notes/[fieldNoteId]/public/route.js

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { badRequest, notFound, ok, serverError } from "../../../../../../src/server/http";
2-
import { hashToken } from "../../../../../../src/server/hash";
2+
import { applyOwnerFilter, resolveRequestOwner } from "../../../../../../src/server/auth";
33
import { serializeFieldNote } from "../../../../../../src/server/dumps";
44
import { getSupabaseAdmin } from "../../../../../../src/server/supabase";
55
import { cleanString } from "../../../../../../src/server/validation";
@@ -18,26 +18,22 @@ export async function PATCH(request, { params }) {
1818
if (isPublic && !handle) return badRequest("choose a public handle first");
1919

2020
const supabase = getSupabaseAdmin();
21-
const sessionTokenHash = hashToken(sessionToken);
22-
const { data: fieldNote, error: noteError } = await supabase
23-
.from("field_notes")
24-
.select("*")
25-
.eq("id", fieldNoteId)
26-
.eq("session_token_hash", sessionTokenHash)
27-
.single();
21+
const owner = await resolveRequestOwner({ request, sessionToken });
22+
const { data: fieldNote, error: noteError } = await applyOwnerFilter(
23+
supabase.from("field_notes").select("*").eq("id", fieldNoteId),
24+
owner,
25+
).single();
2826
if (noteError?.code === "PGRST116") return notFound("field note not found");
2927
if (isMissingColumnError(noteError) || isMissingTableError(noteError)) return serverError(missingPublicProfileMigrationError());
3028
if (noteError) throw noteError;
3129
if (isPublic && !fieldNote.is_published) return badRequest("publish to team reads before putting this on your profile");
3230

3331
let profile = null;
3432
if (isPublic) {
35-
const { data: profileRow, error: profileError } = await supabase
36-
.from("public_profiles")
37-
.select("*")
38-
.eq("session_token_hash", sessionTokenHash)
39-
.eq("handle", handle)
40-
.single();
33+
const { data: profileRow, error: profileError } = await applyOwnerFilter(
34+
supabase.from("public_profiles").select("*").eq("handle", handle),
35+
owner,
36+
).single();
4137
if (profileError?.code === "PGRST116") return badRequest("create that public handle first");
4238
if (isMissingTableError(profileError)) return serverError(missingPublicProfileMigrationError());
4339
if (profileError) throw profileError;
@@ -60,7 +56,6 @@ export async function PATCH(request, { params }) {
6056
.from("field_notes")
6157
.update(updates)
6258
.eq("id", fieldNote.id)
63-
.eq("session_token_hash", sessionTokenHash)
6459
.select("*")
6560
.single();
6661
if (isMissingColumnError(updateError)) return serverError(missingPublicProfileMigrationError());

app/api/dumps/field-notes/[fieldNoteId]/publish/route.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { badRequest, notFound, ok, serverError } from "../../../../../../src/server/http";
22
import { enforceRateLimit } from "../../../../../../src/server/rateLimit";
3-
import { hashToken } from "../../../../../../src/server/hash";
3+
import { applyOwnerFilter, resolveRequestOwner } from "../../../../../../src/server/auth";
44
import { serializeFieldNote } from "../../../../../../src/server/dumps";
55
import { getSupabaseAdmin } from "../../../../../../src/server/supabase";
66
import { cleanString } from "../../../../../../src/server/validation";
@@ -24,10 +24,10 @@ export async function POST(request, { params }) {
2424

2525
const supabase = getSupabaseAdmin();
2626
await enforceRateLimit({ supabase, action: "post", sessionToken });
27-
const sessionTokenHash = hashToken(sessionToken);
27+
const owner = await resolveRequestOwner({ request, sessionToken });
2828

2929
const [{ data: fieldNote, error: noteError }, { data: space, error: spaceError }] = await Promise.all([
30-
supabase.from("field_notes").select("*").eq("id", fieldNoteId).eq("session_token_hash", sessionTokenHash).single(),
30+
applyOwnerFilter(supabase.from("field_notes").select("*").eq("id", fieldNoteId), owner).single(),
3131
supabase.from("spaces").select("id").eq("slug", slug).single(),
3232
]);
3333
if (noteError?.code === "PGRST116") return notFound("field note not found");
@@ -61,15 +61,14 @@ export async function POST(request, { params }) {
6161
published_at: new Date().toISOString(),
6262
})
6363
.eq("id", fieldNote.id)
64-
.eq("session_token_hash", sessionTokenHash)
6564
.select("*")
6665
.single();
6766
if (updateError) throw updateError;
6867

6968
if (isAnonymous) {
7069
await supabase.from("anon_audit").insert({
7170
post_id: post.id,
72-
session_token_hash: sessionTokenHash,
71+
session_token_hash: owner.sessionTokenHash,
7372
});
7473
}
7574

app/api/dumps/field-notes/[fieldNoteId]/route.js

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { badRequest, notFound, ok, serverError } from "../../../../../src/server/http";
2-
import { hashToken } from "../../../../../src/server/hash";
2+
import { applyOwnerFilter, resolveRequestOwner } from "../../../../../src/server/auth";
33
import { serializeFieldNote } from "../../../../../src/server/dumps";
44
import { getSupabaseAdmin } from "../../../../../src/server/supabase";
55
import { cleanString } from "../../../../../src/server/validation";
@@ -18,13 +18,11 @@ export async function PATCH(request, { params }) {
1818
if (!content) return badRequest("field note content is required");
1919

2020
const supabase = getSupabaseAdmin();
21-
const sessionTokenHash = hashToken(sessionToken);
22-
const { data: fieldNote, error: noteError } = await supabase
23-
.from("field_notes")
24-
.select("*")
25-
.eq("id", fieldNoteId)
26-
.eq("session_token_hash", sessionTokenHash)
27-
.single();
21+
const owner = await resolveRequestOwner({ request, sessionToken });
22+
const { data: fieldNote, error: noteError } = await applyOwnerFilter(
23+
supabase.from("field_notes").select("*").eq("id", fieldNoteId),
24+
owner,
25+
).single();
2826
if (noteError?.code === "PGRST116") return notFound("field note not found");
2927
if (noteError) throw noteError;
3028

@@ -33,7 +31,6 @@ export async function PATCH(request, { params }) {
3331
.from("field_notes")
3432
.update(updates)
3533
.eq("id", fieldNote.id)
36-
.eq("session_token_hash", sessionTokenHash)
3734
.select("*")
3835
.single();
3936
if (updateError) throw updateError;
@@ -62,13 +59,11 @@ export async function DELETE(request, { params }) {
6259
if (!sessionToken) return badRequest("session token is required");
6360

6461
const supabase = getSupabaseAdmin();
65-
const sessionTokenHash = hashToken(sessionToken);
66-
const { data: fieldNote, error: noteError } = await supabase
67-
.from("field_notes")
68-
.select("*")
69-
.eq("id", fieldNoteId)
70-
.eq("session_token_hash", sessionTokenHash)
71-
.single();
62+
const owner = await resolveRequestOwner({ request, sessionToken });
63+
const { data: fieldNote, error: noteError } = await applyOwnerFilter(
64+
supabase.from("field_notes").select("*").eq("id", fieldNoteId),
65+
owner,
66+
).single();
7267
if (noteError?.code === "PGRST116") return notFound("field note not found");
7368
if (noteError) throw noteError;
7469

@@ -80,8 +75,7 @@ export async function DELETE(request, { params }) {
8075
const { error: deleteError } = await supabase
8176
.from("field_notes")
8277
.delete()
83-
.eq("id", fieldNote.id)
84-
.eq("session_token_hash", sessionTokenHash);
78+
.eq("id", fieldNote.id);
8579
if (deleteError) throw deleteError;
8680

8781
return ok({ deleted: true });

app/api/dumps/field-notes/draft/route.js

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { badRequest, ok, serverError } from "../../../../../src/server/http";
22
import { draftFieldNote } from "../../../../../src/server/fieldNotes";
3-
import { hashToken } from "../../../../../src/server/hash";
3+
import { applyOwnerFilter, ownerInsertFields, resolveRequestOwner } from "../../../../../src/server/auth";
44
import { enforceRateLimit } from "../../../../../src/server/rateLimit";
55
import { serializeFieldNote } from "../../../../../src/server/dumps";
66
import { getSupabaseAdmin } from "../../../../../src/server/supabase";
@@ -21,12 +21,8 @@ export async function POST(request) {
2121
const supabase = getSupabaseAdmin();
2222
await enforceRateLimit({ supabase, action: "field_note", sessionToken });
2323

24-
const sessionTokenHash = hashToken(sessionToken);
25-
const { data: dumps, error: dumpsError } = await supabase
26-
.from("dumps")
27-
.select("*")
28-
.eq("session_token_hash", sessionTokenHash)
29-
.in("id", dumpIds);
24+
const owner = await resolveRequestOwner({ request, sessionToken });
25+
const { data: dumps, error: dumpsError } = await applyOwnerFilter(supabase.from("dumps").select("*").in("id", dumpIds), owner);
3026
if (dumpsError) throw dumpsError;
3127
if (!dumps?.length) return badRequest("no matching private dumps found");
3228

@@ -36,7 +32,7 @@ export async function POST(request) {
3632
const { data: fieldNote, error: noteError } = await supabase
3733
.from("field_notes")
3834
.insert({
39-
session_token_hash: sessionTokenHash,
35+
...ownerInsertFields(owner),
4036
source_dump_ids: draft.sourceDumpIds,
4137
title: draft.title || "field note",
4238
content: draft.content,

0 commit comments

Comments
 (0)