Skip to content

Commit 8b231ba

Browse files
authored
ENG-420-create-space-and-anonymous-account (#254)
ENG-420: Upon space creation, create an anonymous supabase user for the space. Ensure it is written in as a space "participant" (for future RLS function) Trigger to delete it when the space is deleted. All this machinery is tied to getContext. Auxiliary function to get a supabase client logged in with that anonymous user.
1 parent c24466d commit 8b231ba

8 files changed

Lines changed: 234 additions & 38 deletions

File tree

apps/roam/src/utils/supabaseContext.ts

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,19 @@ import getCurrentUserDisplayName from "roamjs-components/queries/getCurrentUserD
44
import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle";
55
import getRoamUrl from "roamjs-components/dom/getRoamUrl";
66

7-
import { Database } from "@repo/database/types.gen";
7+
import { Enums } from "@repo/database/types.gen";
88
import { DISCOURSE_CONFIG_PAGE_TITLE } from "~/utils/renderNodeConfigPage";
99
import getBlockProps from "~/utils/getBlockProps";
1010
import setBlockProps from "~/utils/setBlockProps";
11+
import {
12+
createClient,
13+
type DGSupabaseClient,
14+
} from "@repo/ui/src/lib/supabase/client";
15+
import { spaceAnonUserEmail } from "@repo/ui/lib/utils";
1116

1217
declare const crypto: { randomUUID: () => string };
1318

14-
type Platform = Database["public"]["Enums"]["Platform"];
19+
type Platform = Enums<"Platform">;
1520

1621
export type SupabaseContext = {
1722
platform: Platform;
@@ -20,7 +25,7 @@ export type SupabaseContext = {
2025
spacePassword: string;
2126
};
2227

23-
let CONTEXT_CACHE: SupabaseContext | null = null;
28+
let _contextCache: SupabaseContext | null = null;
2429

2530
// TODO: This should be an util on its own.
2631
const base_url =
@@ -44,20 +49,27 @@ const getOrCreateSpacePassword = () => {
4449
return password;
4550
};
4651

47-
// Note: Some of this will be more typesafe if rewritten with direct supabase access eventually.
48-
// We're going through nextjs until we have settled security.
52+
// Note: calls in this file will still use vercel endpoints.
53+
// It is better if this is still at least protected by CORS.
54+
// But calls anywhere else should use the supabase client directly.
4955

50-
const fetchOrCreateSpaceId = async (): Promise<number> => {
56+
const fetchOrCreateSpaceId = async (
57+
account_id: number,
58+
password: string,
59+
): Promise<number> => {
5160
const url = getRoamUrl();
52-
const urlParts = url.split("/");
5361
const name = window.roamAlphaAPI.graph.name;
5462
const platform: Platform = "Roam";
5563
const response = await fetch(base_url + "/space", {
5664
method: "POST",
5765
headers: {
5866
"Content-Type": "application/json",
5967
},
60-
body: JSON.stringify({ url, name, platform }),
68+
body: JSON.stringify({
69+
space: { url, name, platform },
70+
password,
71+
account_id,
72+
}),
6173
});
6274
if (!response.ok)
6375
throw new Error(
@@ -118,23 +130,54 @@ const fetchOrCreatePlatformAccount = async ({
118130
};
119131

120132
export const getSupabaseContext = async (): Promise<SupabaseContext | null> => {
121-
if (CONTEXT_CACHE === null) {
133+
if (_contextCache === null) {
122134
try {
123-
const spaceId = await fetchOrCreateSpaceId();
124135
const accountLocalId = window.roamAlphaAPI.user.uid();
136+
const spacePassword = getOrCreateSpacePassword();
125137
const personEmail = getCurrentUserEmail();
126138
const personName = getCurrentUserDisplayName();
127-
const spacePassword = getOrCreateSpacePassword();
128139
const userId = await fetchOrCreatePlatformAccount({
129140
accountLocalId,
130141
personName,
131142
personEmail,
132143
});
133-
CONTEXT_CACHE = { platform: "Roam", spaceId, userId, spacePassword };
144+
const spaceId = await fetchOrCreateSpaceId(userId, spacePassword);
145+
_contextCache = {
146+
platform: "Roam",
147+
spaceId,
148+
userId,
149+
spacePassword,
150+
};
134151
} catch (error) {
135152
console.error(error);
136153
return null;
137154
}
138155
}
139-
return CONTEXT_CACHE;
156+
return _contextCache;
157+
};
158+
159+
let _loggedInClient: DGSupabaseClient | null = null;
160+
161+
export const getLoggedInClient = async (): Promise<DGSupabaseClient> => {
162+
if (_loggedInClient === null) {
163+
const context = await getSupabaseContext();
164+
if (context === null) throw new Error("Could not create context");
165+
_loggedInClient = createClient();
166+
const { error } = await _loggedInClient.auth.signInWithPassword({
167+
email: spaceAnonUserEmail(context.platform, context.spaceId),
168+
password: context.spacePassword,
169+
});
170+
if (error) {
171+
_loggedInClient = null;
172+
throw new Error(`Authentication failed: ${error.message}`);
173+
}
174+
} else {
175+
// renew session
176+
const { error } = await _loggedInClient.auth.getSession();
177+
if (error) {
178+
_loggedInClient = null;
179+
throw new Error(`Authentication expired: ${error.message}`);
180+
}
181+
}
182+
return _loggedInClient;
140183
};

apps/website/app/api/supabase/space/route.ts

Lines changed: 110 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { NextResponse, NextRequest } from "next/server";
2-
import type { PostgrestSingleResponse } from "@supabase/supabase-js";
3-
2+
import {
3+
type PostgrestSingleResponse,
4+
PostgrestError,
5+
type User,
6+
} from "@supabase/supabase-js";
47
import { createClient } from "~/utils/supabase/server";
58
import { getOrCreateEntity, ItemValidator } from "~/utils/supabase/dbUtils";
69
import {
@@ -10,10 +13,17 @@ import {
1013
asPostgrestFailure,
1114
} from "~/utils/supabase/apiUtils";
1215
import { Tables, TablesInsert } from "@repo/database/types.gen.ts";
16+
import { spaceAnonUserEmail } from "@repo/ui/lib/utils";
1317

1418
type SpaceDataInput = TablesInsert<"Space">;
1519
type SpaceRecord = Tables<"Space">;
1620

21+
type SpaceCreationInput = {
22+
space: SpaceDataInput;
23+
account_id: number;
24+
password: string;
25+
};
26+
1727
const spaceValidator: ItemValidator<SpaceDataInput> = (space) => {
1828
if (!space || typeof space !== "object")
1929
return "Invalid request body: expected a JSON object.";
@@ -30,36 +40,123 @@ const spaceValidator: ItemValidator<SpaceDataInput> = (space) => {
3040

3141
const processAndGetOrCreateSpace = async (
3242
supabasePromise: ReturnType<typeof createClient>,
33-
data: SpaceDataInput,
43+
data: SpaceCreationInput,
3444
): Promise<PostgrestSingleResponse<SpaceRecord>> => {
35-
const { name, url, platform } = data;
36-
const error = spaceValidator(data);
37-
if (error !== null) return asPostgrestFailure(error, "invalid");
45+
const { space, account_id, password } = data;
46+
const { name, url, platform } = space;
47+
const error = spaceValidator(space);
48+
if (error !== null) return asPostgrestFailure(error, "invalid space");
49+
if (
50+
typeof account_id !== "number" ||
51+
!Number.isInteger(account_id) ||
52+
account_id <= 0
53+
)
54+
return asPostgrestFailure(
55+
"account_id is not a number",
56+
"invalid account_id",
57+
);
58+
if (!password || typeof password !== "string" || password.length < 8)
59+
return asPostgrestFailure(
60+
"password must be at least 8 characters",
61+
"invalid password",
62+
);
3863

39-
const normalizedUrl = url.trim().replace(/\/$/, "");
40-
const trimmedName = name.trim();
4164
const supabase = await supabasePromise;
4265

4366
const result = await getOrCreateEntity<"Space">({
4467
supabase,
4568
tableName: "Space",
4669
insertData: {
47-
name: trimmedName,
48-
url: normalizedUrl,
49-
platform: platform,
70+
name: name.trim(),
71+
url: url.trim().replace(/\/$/, ""),
72+
platform,
5073
},
5174
uniqueOn: ["url"],
5275
});
76+
if (result.error) return result;
77+
const space_id = result.data.id;
5378

79+
// this is related but each step is idempotent, so con retry w/o transaction
80+
const email = spaceAnonUserEmail(platform, result.data.id);
81+
let anonymousUser: User | null = null;
82+
{
83+
const { error, data } = await supabase.auth.signInWithPassword({
84+
email,
85+
password,
86+
});
87+
if (error && error.message !== "Invalid login credentials") {
88+
// Handle unexpected errors
89+
return asPostgrestFailure(error.message, "authentication_error");
90+
}
91+
anonymousUser = data.user;
92+
}
93+
if (anonymousUser === null) {
94+
const resultCreateAnonymousUser = await supabase.auth.admin.createUser({
95+
email,
96+
password,
97+
email_confirm: true,
98+
});
99+
if (resultCreateAnonymousUser.error) {
100+
return {
101+
count: null,
102+
status: resultCreateAnonymousUser.error.status || -1,
103+
statusText: resultCreateAnonymousUser.error.message,
104+
data: null,
105+
error: new PostgrestError({
106+
message: resultCreateAnonymousUser.error.message,
107+
details:
108+
typeof resultCreateAnonymousUser.error.cause === "string"
109+
? resultCreateAnonymousUser.error.cause
110+
: "",
111+
hint: "",
112+
code: resultCreateAnonymousUser.error.code || "unknown",
113+
}),
114+
}; // space created but not its user, try again
115+
}
116+
anonymousUser = resultCreateAnonymousUser.data.user;
117+
}
118+
119+
const anonPlatformUserResult = await getOrCreateEntity<"PlatformAccount">({
120+
supabase,
121+
tableName: "PlatformAccount",
122+
insertData: {
123+
platform,
124+
account_local_id: email,
125+
name: `Anonymous of space ${space_id}`,
126+
agent_type: "anonymous",
127+
dg_account: anonymousUser.id,
128+
},
129+
uniqueOn: ["account_local_id", "platform"],
130+
});
131+
if (anonPlatformUserResult.error) return anonPlatformUserResult;
132+
133+
const resultAnonUserSpaceAccess = await getOrCreateEntity<"SpaceAccess">({
134+
supabase,
135+
tableName: "SpaceAccess",
136+
insertData: {
137+
space_id,
138+
account_id: anonPlatformUserResult.data.id,
139+
editor: true,
140+
},
141+
uniqueOn: ["space_id", "account_id"],
142+
});
143+
if (resultAnonUserSpaceAccess.error) return resultAnonUserSpaceAccess; // space created but not connected, try again
144+
145+
const resultUserSpaceAccess = await getOrCreateEntity<"SpaceAccess">({
146+
supabase,
147+
tableName: "SpaceAccess",
148+
insertData: { space_id, account_id, editor: true },
149+
uniqueOn: ["space_id", "account_id"],
150+
});
151+
if (resultUserSpaceAccess.error) return resultUserSpaceAccess; // space created but not connected, try again
54152
return result;
55153
};
56154

57155
export const POST = async (request: NextRequest): Promise<NextResponse> => {
58156
const supabasePromise = createClient();
59157

60158
try {
61-
const body: SpaceDataInput = await request.json();
62-
159+
const body: SpaceCreationInput = await request.json();
63160
const result = await processAndGetOrCreateSpace(supabasePromise, body);
64161
return createApiResponse(request, result);
65162
} catch (e: unknown) {
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
ALTER TYPE public."AgentType" ADD VALUE IF NOT EXISTS 'anonymous';
2+
3+
CREATE OR REPLACE FUNCTION public.get_space_anonymous_email(platform public."Platform", space_id BIGINT) RETURNS character varying LANGUAGE sql IMMUTABLE AS $$
4+
SELECT concat(lower(platform::text), '-', space_id, '-anon@database.discoursegraphs.com')
5+
$$;
6+
7+
CREATE OR REPLACE FUNCTION public.after_delete_space() RETURNS TRIGGER LANGUAGE plpgsql AS $$
8+
BEGIN
9+
DELETE FROM auth.users WHERE email = public.get_space_anonymous_email(OLD.platform, OLD.id);
10+
DELETE FROM public."PlatformAccount"
11+
WHERE platform = OLD.platform
12+
AND account_local_id = public.get_space_anonymous_email(OLD.platform, OLD.id);
13+
RETURN NEW;
14+
END;
15+
$$;
16+
17+
CREATE TRIGGER on_delete_space_trigger AFTER DELETE ON public."Space" FOR EACH ROW EXECUTE FUNCTION public.after_delete_space();

packages/database/supabase/schemas/account.sql

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
CREATE TYPE public."AgentType" AS ENUM (
22
'person',
33
'organization',
4-
'automated_agent'
4+
'automated_agent',
5+
'anonymous'
56
);
67

78
ALTER TYPE public."AgentType" OWNER TO postgres;

packages/database/supabase/schemas/space.sql

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,21 @@ ALTER TABLE public."Space" OWNER TO "postgres";
2727
GRANT ALL ON TABLE public."Space" TO anon;
2828
GRANT ALL ON TABLE public."Space" TO authenticated;
2929
GRANT ALL ON TABLE public."Space" TO service_role;
30+
31+
CREATE OR REPLACE FUNCTION public.get_space_anonymous_email(platform public."Platform", space_id BIGINT) RETURNS character varying LANGUAGE sql IMMUTABLE AS $$
32+
SELECT concat(lower(platform), '-', space_id, '-anon@database.discoursegraphs.com')
33+
$$;
34+
35+
36+
-- TODO: on delete trigger anonymous user
37+
CREATE OR REPLACE FUNCTION public.after_delete_space() RETURNS TRIGGER LANGUAGE plpgsql AS $$
38+
BEGIN
39+
DELETE FROM auth.users WHERE email = public.get_space_anonymous_email(OLD.platform, OLD.id);
40+
DELETE FROM public."PlatformAccount"
41+
WHERE platform = OLD.platform
42+
AND account_local_id = public.get_space_anonymous_email(OLD.platform, OLD.id);
43+
RETURN NEW;
44+
END;
45+
$$;
46+
47+
CREATE TRIGGER on_delete_space_trigger AFTER DELETE ON public."Space" FOR EACH ROW EXECUTE FUNCTION public.after_delete_space();

packages/database/types.gen.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,13 @@ export type Database = {
612612
uid_to_sync: string
613613
}[]
614614
}
615+
get_space_anonymous_email: {
616+
Args: {
617+
platform: Database["public"]["Enums"]["Platform"]
618+
space_id: number
619+
}
620+
Returns: string
621+
}
615622
match_content_embeddings: {
616623
Args: {
617624
query_embedding: string
@@ -696,7 +703,7 @@ export type Database = {
696703
}
697704
Enums: {
698705
AgentIdentifierType: "email" | "orcid"
699-
AgentType: "person" | "organization" | "automated_agent"
706+
AgentType: "person" | "organization" | "automated_agent" | "anonymous"
700707
EmbeddingName:
701708
| "openai_text_embedding_ada2_1536"
702709
| "openai_text_embedding_3_small_512"
@@ -922,7 +929,7 @@ export const Constants = {
922929
public: {
923930
Enums: {
924931
AgentIdentifierType: ["email", "orcid"],
925-
AgentType: ["person", "organization", "automated_agent"],
932+
AgentType: ["person", "organization", "automated_agent", "anonymous"],
926933
EmbeddingName: [
927934
"openai_text_embedding_ada2_1536",
928935
"openai_text_embedding_3_small_512",

0 commit comments

Comments
 (0)