Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 56 additions & 13 deletions apps/roam/src/utils/supabaseContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@ import getCurrentUserDisplayName from "roamjs-components/queries/getCurrentUserD
import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle";
import getRoamUrl from "roamjs-components/dom/getRoamUrl";

import { Database } from "@repo/database/types.gen";
import { Enums } from "@repo/database/types.gen";
import { DISCOURSE_CONFIG_PAGE_TITLE } from "~/utils/renderNodeConfigPage";
import getBlockProps from "~/utils/getBlockProps";
import setBlockProps from "~/utils/setBlockProps";
import {
createClient,
type DGSupabaseClient,
} from "@repo/ui/src/lib/supabase/client";
import { spaceAnonUserEmail } from "@repo/ui/lib/utils";

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

type Platform = Database["public"]["Enums"]["Platform"];
type Platform = Enums<"Platform">;

export type SupabaseContext = {
platform: Platform;
Expand All @@ -20,7 +25,7 @@ export type SupabaseContext = {
spacePassword: string;
};

let CONTEXT_CACHE: SupabaseContext | null = null;
let _contextCache: SupabaseContext | null = null;

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

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

const fetchOrCreateSpaceId = async (): Promise<number> => {
const fetchOrCreateSpaceId = async (
account_id: number,
password: string,
): Promise<number> => {
const url = getRoamUrl();
const urlParts = url.split("/");
const name = window.roamAlphaAPI.graph.name;
const platform: Platform = "Roam";
const response = await fetch(base_url + "/space", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ url, name, platform }),
body: JSON.stringify({
space: { url, name, platform },
password,
account_id,
}),
});
if (!response.ok)
throw new Error(
Expand Down Expand Up @@ -118,23 +130,54 @@ const fetchOrCreatePlatformAccount = async ({
};

export const getSupabaseContext = async (): Promise<SupabaseContext | null> => {
if (CONTEXT_CACHE === null) {
if (_contextCache === null) {
try {
const spaceId = await fetchOrCreateSpaceId();
const accountLocalId = window.roamAlphaAPI.user.uid();
const spacePassword = getOrCreateSpacePassword();
const personEmail = getCurrentUserEmail();
const personName = getCurrentUserDisplayName();
const spacePassword = getOrCreateSpacePassword();
const userId = await fetchOrCreatePlatformAccount({
accountLocalId,
personName,
personEmail,
});
CONTEXT_CACHE = { platform: "Roam", spaceId, userId, spacePassword };
const spaceId = await fetchOrCreateSpaceId(userId, spacePassword);
_contextCache = {
platform: "Roam",
spaceId,
userId,
spacePassword,
};
} catch (error) {
console.error(error);
return null;
}
}
return CONTEXT_CACHE;
return _contextCache;
};

let _loggedInClient: DGSupabaseClient | null = null;

export const getLoggedInClient = async (): Promise<DGSupabaseClient> => {
if (_loggedInClient === null) {
const context = await getSupabaseContext();
if (context === null) throw new Error("Could not create context");
_loggedInClient = createClient();
const { error } = await _loggedInClient.auth.signInWithPassword({
email: spaceAnonUserEmail(context.platform, context.spaceId),
password: context.spacePassword,
});
if (error) {
_loggedInClient = null;
throw new Error(`Authentication failed: ${error.message}`);
}
} else {
// renew session
const { error } = await _loggedInClient.auth.getSession();
if (error) {
_loggedInClient = null;
throw new Error(`Authentication expired: ${error.message}`);
}
}
return _loggedInClient;
};
123 changes: 110 additions & 13 deletions apps/website/app/api/supabase/space/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { NextResponse, NextRequest } from "next/server";
import type { PostgrestSingleResponse } from "@supabase/supabase-js";

import {
type PostgrestSingleResponse,
PostgrestError,
type User,
} from "@supabase/supabase-js";
import { createClient } from "~/utils/supabase/server";
import { getOrCreateEntity, ItemValidator } from "~/utils/supabase/dbUtils";
import {
Expand All @@ -10,10 +13,17 @@ import {
asPostgrestFailure,
} from "~/utils/supabase/apiUtils";
import { Tables, TablesInsert } from "@repo/database/types.gen.ts";
import { spaceAnonUserEmail } from "@repo/ui/lib/utils";

type SpaceDataInput = TablesInsert<"Space">;
type SpaceRecord = Tables<"Space">;

type SpaceCreationInput = {
space: SpaceDataInput;
account_id: number;
password: string;
};

const spaceValidator: ItemValidator<SpaceDataInput> = (space) => {
if (!space || typeof space !== "object")
return "Invalid request body: expected a JSON object.";
Expand All @@ -30,36 +40,123 @@ const spaceValidator: ItemValidator<SpaceDataInput> = (space) => {

const processAndGetOrCreateSpace = async (
supabasePromise: ReturnType<typeof createClient>,
data: SpaceDataInput,
data: SpaceCreationInput,
): Promise<PostgrestSingleResponse<SpaceRecord>> => {
const { name, url, platform } = data;
const error = spaceValidator(data);
if (error !== null) return asPostgrestFailure(error, "invalid");
const { space, account_id, password } = data;
const { name, url, platform } = space;
const error = spaceValidator(space);
if (error !== null) return asPostgrestFailure(error, "invalid space");
if (
typeof account_id !== "number" ||
!Number.isInteger(account_id) ||
account_id <= 0
)
return asPostgrestFailure(
"account_id is not a number",
"invalid account_id",
);
if (!password || typeof password !== "string" || password.length < 8)
return asPostgrestFailure(
"password must be at least 8 characters",
"invalid password",
);

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

const result = await getOrCreateEntity<"Space">({
supabase,
tableName: "Space",
insertData: {
name: trimmedName,
url: normalizedUrl,
platform: platform,
name: name.trim(),
url: url.trim().replace(/\/$/, ""),
platform,
},
uniqueOn: ["url"],
});
if (result.error) return result;
const space_id = result.data.id;

// this is related but each step is idempotent, so con retry w/o transaction
const email = spaceAnonUserEmail(platform, result.data.id);
let anonymousUser: User | null = null;
{
const { error, data } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error && error.message !== "Invalid login credentials") {
// Handle unexpected errors
return asPostgrestFailure(error.message, "authentication_error");
}
anonymousUser = data.user;
}
if (anonymousUser === null) {
const resultCreateAnonymousUser = await supabase.auth.admin.createUser({
email,
password,
email_confirm: true,
});
if (resultCreateAnonymousUser.error) {
return {
count: null,
status: resultCreateAnonymousUser.error.status || -1,
statusText: resultCreateAnonymousUser.error.message,
data: null,
error: new PostgrestError({
message: resultCreateAnonymousUser.error.message,
details:
typeof resultCreateAnonymousUser.error.cause === "string"
? resultCreateAnonymousUser.error.cause
: "",
hint: "",
code: resultCreateAnonymousUser.error.code || "unknown",
}),
}; // space created but not its user, try again
}
anonymousUser = resultCreateAnonymousUser.data.user;
}

const anonPlatformUserResult = await getOrCreateEntity<"PlatformAccount">({
supabase,
tableName: "PlatformAccount",
insertData: {
platform,
account_local_id: email,
name: `Anonymous of space ${space_id}`,
agent_type: "anonymous",
dg_account: anonymousUser.id,
},
uniqueOn: ["account_local_id", "platform"],
});
if (anonPlatformUserResult.error) return anonPlatformUserResult;

const resultAnonUserSpaceAccess = await getOrCreateEntity<"SpaceAccess">({
supabase,
tableName: "SpaceAccess",
insertData: {
space_id,
account_id: anonPlatformUserResult.data.id,
editor: true,
},
uniqueOn: ["space_id", "account_id"],
});
if (resultAnonUserSpaceAccess.error) return resultAnonUserSpaceAccess; // space created but not connected, try again

const resultUserSpaceAccess = await getOrCreateEntity<"SpaceAccess">({
supabase,
tableName: "SpaceAccess",
insertData: { space_id, account_id, editor: true },
uniqueOn: ["space_id", "account_id"],
});
if (resultUserSpaceAccess.error) return resultUserSpaceAccess; // space created but not connected, try again
return result;
};

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

try {
const body: SpaceDataInput = await request.json();

const body: SpaceCreationInput = await request.json();
const result = await processAndGetOrCreateSpace(supabasePromise, body);
return createApiResponse(request, result);
} catch (e: unknown) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
ALTER TYPE public."AgentType" ADD VALUE IF NOT EXISTS 'anonymous';

CREATE OR REPLACE FUNCTION public.get_space_anonymous_email(platform public."Platform", space_id BIGINT) RETURNS character varying LANGUAGE sql IMMUTABLE AS $$
SELECT concat(lower(platform::text), '-', space_id, '-anon@database.discoursegraphs.com')
$$;

CREATE OR REPLACE FUNCTION public.after_delete_space() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
DELETE FROM auth.users WHERE email = public.get_space_anonymous_email(OLD.platform, OLD.id);
DELETE FROM public."PlatformAccount"
WHERE platform = OLD.platform
AND account_local_id = public.get_space_anonymous_email(OLD.platform, OLD.id);
RETURN NEW;
END;
$$;

CREATE TRIGGER on_delete_space_trigger AFTER DELETE ON public."Space" FOR EACH ROW EXECUTE FUNCTION public.after_delete_space();
3 changes: 2 additions & 1 deletion packages/database/supabase/schemas/account.sql
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
CREATE TYPE public."AgentType" AS ENUM (
'person',
'organization',
'automated_agent'
'automated_agent',
'anonymous'
);

ALTER TYPE public."AgentType" OWNER TO postgres;
Expand Down
18 changes: 18 additions & 0 deletions packages/database/supabase/schemas/space.sql
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,21 @@ ALTER TABLE public."Space" OWNER TO "postgres";
GRANT ALL ON TABLE public."Space" TO anon;
GRANT ALL ON TABLE public."Space" TO authenticated;
GRANT ALL ON TABLE public."Space" TO service_role;

CREATE OR REPLACE FUNCTION public.get_space_anonymous_email(platform public."Platform", space_id BIGINT) RETURNS character varying LANGUAGE sql IMMUTABLE AS $$
SELECT concat(lower(platform), '-', space_id, '-anon@database.discoursegraphs.com')
$$;


-- TODO: on delete trigger anonymous user
CREATE OR REPLACE FUNCTION public.after_delete_space() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
DELETE FROM auth.users WHERE email = public.get_space_anonymous_email(OLD.platform, OLD.id);
DELETE FROM public."PlatformAccount"
WHERE platform = OLD.platform
AND account_local_id = public.get_space_anonymous_email(OLD.platform, OLD.id);
RETURN NEW;
END;
$$;

CREATE TRIGGER on_delete_space_trigger AFTER DELETE ON public."Space" FOR EACH ROW EXECUTE FUNCTION public.after_delete_space();
11 changes: 9 additions & 2 deletions packages/database/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,13 @@ export type Database = {
uid_to_sync: string
}[]
}
get_space_anonymous_email: {
Args: {
platform: Database["public"]["Enums"]["Platform"]
space_id: number
}
Returns: string
}
match_content_embeddings: {
Args: {
query_embedding: string
Expand Down Expand Up @@ -696,7 +703,7 @@ export type Database = {
}
Enums: {
AgentIdentifierType: "email" | "orcid"
AgentType: "person" | "organization" | "automated_agent"
AgentType: "person" | "organization" | "automated_agent" | "anonymous"
EmbeddingName:
| "openai_text_embedding_ada2_1536"
| "openai_text_embedding_3_small_512"
Expand Down Expand Up @@ -922,7 +929,7 @@ export const Constants = {
public: {
Enums: {
AgentIdentifierType: ["email", "orcid"],
AgentType: ["person", "organization", "automated_agent"],
AgentType: ["person", "organization", "automated_agent", "anonymous"],
EmbeddingName: [
"openai_text_embedding_ada2_1536",
"openai_text_embedding_3_small_512",
Expand Down
Loading