feat(staging): switch to local postgres
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
create schema if not exists auth authorization schema_owner;
|
||||
|
||||
grant usage on schema public to anon, authenticated, service_role;
|
||||
grant usage on schema auth to anon, authenticated, service_role;
|
||||
revoke all on schema auth from public;
|
||||
|
||||
create table if not exists auth.users (
|
||||
id uuid primary key,
|
||||
email text not null,
|
||||
raw_user_meta_data jsonb not null default '{}'::jsonb,
|
||||
email_confirmed_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create unique index if not exists auth_users_email_canonical_key
|
||||
on auth.users (lower(btrim(email)));
|
||||
|
||||
create or replace function auth.uid()
|
||||
returns uuid
|
||||
language sql
|
||||
stable
|
||||
as $$
|
||||
select nullif(current_setting('request.jwt.claim.sub', true), '')::uuid
|
||||
$$;
|
||||
|
||||
create or replace function auth.jwt()
|
||||
returns jsonb
|
||||
language sql
|
||||
stable
|
||||
as $$
|
||||
select jsonb_build_object(
|
||||
'sub', nullif(current_setting('request.jwt.claim.sub', true), ''),
|
||||
'email', nullif(current_setting('request.jwt.claim.email', true), '')
|
||||
)
|
||||
$$;
|
||||
|
||||
revoke all on table auth.users from public, anon, authenticated, service_role;
|
||||
grant select, insert, update, delete on table auth.users to service_role;
|
||||
revoke all on function auth.uid() from public, anon;
|
||||
revoke all on function auth.jwt() from public, anon;
|
||||
grant execute on function auth.uid() to authenticated, service_role;
|
||||
grant execute on function auth.jwt() to authenticated, service_role;
|
||||
@@ -0,0 +1,65 @@
|
||||
create or replace function identity.sync_user_to_business_auth()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = pg_catalog, auth
|
||||
as $$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
delete from auth.users where id = old.id;
|
||||
return old;
|
||||
end if;
|
||||
|
||||
insert into auth.users (
|
||||
id,
|
||||
email,
|
||||
raw_user_meta_data,
|
||||
email_confirmed_at,
|
||||
created_at,
|
||||
updated_at
|
||||
) values (
|
||||
new.id,
|
||||
new.email,
|
||||
jsonb_build_object('full_name', new.name),
|
||||
case when new.email_verified then coalesce(new.email_verified_at, now()) end,
|
||||
new.created_at,
|
||||
new.updated_at
|
||||
)
|
||||
on conflict (id) do update set
|
||||
email = excluded.email,
|
||||
raw_user_meta_data = excluded.raw_user_meta_data,
|
||||
email_confirmed_at = excluded.email_confirmed_at,
|
||||
updated_at = excluded.updated_at;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists identity_user_business_auth_sync on identity.users;
|
||||
create trigger identity_user_business_auth_sync
|
||||
after insert or update or delete on identity.users
|
||||
for each row execute function identity.sync_user_to_business_auth();
|
||||
|
||||
insert into auth.users (
|
||||
id,
|
||||
email,
|
||||
raw_user_meta_data,
|
||||
email_confirmed_at,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
select
|
||||
id,
|
||||
email,
|
||||
jsonb_build_object('full_name', name),
|
||||
case when email_verified then coalesce(email_verified_at, now()) end,
|
||||
created_at,
|
||||
updated_at
|
||||
from identity.users
|
||||
on conflict (id) do update set
|
||||
email = excluded.email,
|
||||
raw_user_meta_data = excluded.raw_user_meta_data,
|
||||
email_confirmed_at = excluded.email_confirmed_at,
|
||||
updated_at = excluded.updated_at;
|
||||
|
||||
revoke all on function identity.sync_user_to_business_auth() from public;
|
||||
@@ -9,34 +9,56 @@ const migrationFilenamePattern = /^\d{14}_[a-z0-9_]+\.sql$/;
|
||||
|
||||
class SafeMigrationError extends Error {}
|
||||
|
||||
async function loadMigrationFiles(migrationsDirectory) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(migrationsDirectory, { withFileTypes: true });
|
||||
} catch {
|
||||
throw new SafeMigrationError("unable to read migrations directory");
|
||||
async function loadMigrationFiles(migrationsDirectories) {
|
||||
const directories = Array.isArray(migrationsDirectories)
|
||||
? migrationsDirectories
|
||||
: [migrationsDirectories];
|
||||
const entriesByDirectory = [];
|
||||
for (const migrationsDirectory of directories) {
|
||||
try {
|
||||
entriesByDirectory.push({
|
||||
migrationsDirectory,
|
||||
entries: await readdir(migrationsDirectory, { withFileTypes: true }),
|
||||
});
|
||||
} catch {
|
||||
throw new SafeMigrationError("unable to read migrations directory");
|
||||
}
|
||||
}
|
||||
|
||||
const malformedSqlEntry = entries.find(
|
||||
(entry) =>
|
||||
entry.isFile() &&
|
||||
entry.name.endsWith(".sql") &&
|
||||
!migrationFilenamePattern.test(entry.name),
|
||||
);
|
||||
const malformedSqlEntry = entriesByDirectory
|
||||
.flatMap(({ entries }) => entries)
|
||||
.find(
|
||||
(entry) =>
|
||||
entry.isFile() &&
|
||||
entry.name.endsWith(".sql") &&
|
||||
!migrationFilenamePattern.test(entry.name),
|
||||
);
|
||||
if (malformedSqlEntry) {
|
||||
throw new SafeMigrationError(
|
||||
`invalid migration filename: ${malformedSqlEntry.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
const migrationEntries = entriesByDirectory.flatMap(
|
||||
({ migrationsDirectory, entries }) =>
|
||||
entries
|
||||
.filter(
|
||||
(entry) => entry.isFile() && migrationFilenamePattern.test(entry.name),
|
||||
)
|
||||
.map((entry) => ({ migrationsDirectory, filename: entry.name })),
|
||||
);
|
||||
const duplicate = migrationEntries.find(
|
||||
(entry, index) =>
|
||||
migrationEntries.findIndex((candidate) => candidate.filename === entry.filename) !== index,
|
||||
);
|
||||
if (duplicate) {
|
||||
throw new SafeMigrationError(`duplicate migration filename: ${duplicate.filename}`);
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
entries
|
||||
.filter(
|
||||
(entry) => entry.isFile() && migrationFilenamePattern.test(entry.name),
|
||||
)
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
.map(async (filename) => {
|
||||
migrationEntries
|
||||
.sort((left, right) => left.filename.localeCompare(right.filename))
|
||||
.map(async ({ migrationsDirectory, filename }) => {
|
||||
const bytes = await readFile(resolve(migrationsDirectory, filename));
|
||||
return {
|
||||
filename,
|
||||
@@ -76,10 +98,11 @@ function assertLedgerFilesPresent(ledger, files) {
|
||||
export async function runMigrations({
|
||||
connectionString,
|
||||
migrationsDirectory,
|
||||
migrationsDirectories,
|
||||
logger = console,
|
||||
check = false,
|
||||
}) {
|
||||
const files = await loadMigrationFiles(migrationsDirectory);
|
||||
const files = await loadMigrationFiles(migrationsDirectories ?? migrationsDirectory);
|
||||
const client = new Client({ connectionString });
|
||||
let locked = false;
|
||||
|
||||
@@ -193,11 +216,21 @@ if (invokedPath === import.meta.url) {
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../db/migrations",
|
||||
);
|
||||
const supabaseCompatibilityDirectory = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../supabase/migrations",
|
||||
);
|
||||
try {
|
||||
const status = await runMigrations({
|
||||
connectionString: requireSchemaDatabaseUrl(process.env),
|
||||
migrationsDirectory:
|
||||
process.env.MIGRATIONS_DIRECTORY?.trim() || defaultDirectory,
|
||||
...(process.env.MIGRATIONS_DIRECTORY?.trim()
|
||||
? { migrationsDirectory: process.env.MIGRATIONS_DIRECTORY.trim() }
|
||||
: {
|
||||
migrationsDirectories: [
|
||||
defaultDirectory,
|
||||
supabaseCompatibilityDirectory,
|
||||
],
|
||||
}),
|
||||
check: process.argv.slice(2).includes("--check"),
|
||||
});
|
||||
process.exitCode = status;
|
||||
|
||||
@@ -3,6 +3,8 @@ import { redirect } from "next/navigation";
|
||||
import { isAdminEmail } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminLayout({ children }: { children: ReactNode }) {
|
||||
if (process.env.NODE_ENV === "development" && process.env.ENABLE_ADMIN_PREVIEW === "1") return children;
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function GET() {
|
||||
// an older case. A concurrently created case simply appears on refresh.
|
||||
const { data: profile, error } = await supabase
|
||||
.from("profiles")
|
||||
.select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset")
|
||||
.select("credits,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_time,active_birth_time,birth_time_status,rectification_case_id")
|
||||
.eq("id", userId)
|
||||
.single();
|
||||
|
||||
@@ -72,6 +72,8 @@ export async function GET() {
|
||||
return NextResponse.json({
|
||||
user: { id: user.id, email: user.email ?? null },
|
||||
credits: profile.credits,
|
||||
profile,
|
||||
authProvider: process.env.AUTH_PROVIDER?.trim() === "self-hosted" ? "self-hosted" : "supabase",
|
||||
isAdmin: isAdminEmail(user.email),
|
||||
rectificationPriceCredits,
|
||||
hasConfirmedBirthTime: profile.birth_time_status === "confirmed"
|
||||
|
||||
@@ -64,10 +64,19 @@ export async function GET() {
|
||||
process.env.RECTIFICATION_V3_MIGRATIONS_READY?.trim().toLowerCase() === "true";
|
||||
const creationPolicy = conversationalRectificationCreationPolicyFromEnvironment();
|
||||
const truthSourceIdentity = getTruthSourceRuntimeIdentity();
|
||||
const selfHosted = process.env.AUTH_PROVIDER?.trim() === "self-hosted";
|
||||
const databaseChecks: Record<string, Check> = selfHosted
|
||||
? {
|
||||
localBusinessDatabase: envCheck(["APP_DATABASE_URL", "ADMIN_DATABASE_URL"]),
|
||||
localIdentityDatabase: envCheck(["IDENTITY_DATABASE_URL"]),
|
||||
}
|
||||
: {
|
||||
supabasePublicConfig: envCheck(["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]),
|
||||
supabaseServiceRole: envCheck(["SUPABASE_SERVICE_ROLE_KEY"]),
|
||||
};
|
||||
const checks = {
|
||||
web: { status: "ok" } satisfies Check,
|
||||
supabasePublicConfig: envCheck(["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]),
|
||||
supabaseServiceRole: envCheck(["SUPABASE_SERVICE_ROLE_KEY"]),
|
||||
...databaseChecks,
|
||||
modelProvider: anyEnvCheck(["LLM_MODELS_JSON", "OPENAI_API_KEY", "LLM_API_KEY", "DEEPSEEK_API_KEY"]),
|
||||
jyotishApi: await jyotishApiCheck(),
|
||||
researchTruthSource: {
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
import { chatSessionWriteSchema } from "@/lib/chat-session-write-contract";
|
||||
import {
|
||||
chatSessionModelPatchSchema,
|
||||
chatSessionWriteSchema,
|
||||
} from "@/lib/chat-session-write-contract";
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const parsed = chatSessionWriteSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 });
|
||||
const payload = await request.json().catch(() => null);
|
||||
const fullWrite = chatSessionWriteSchema.safeParse(payload);
|
||||
const modelPatch = chatSessionModelPatchSchema.safeParse(payload);
|
||||
let values: Record<string, unknown>;
|
||||
if (fullWrite.success) {
|
||||
values = fullWrite.data;
|
||||
} else if (modelPatch.success) {
|
||||
values = modelPatch.data;
|
||||
} else {
|
||||
return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 });
|
||||
}
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
const { data, error } = await supabase
|
||||
.from("chat_sessions")
|
||||
.update(parsed.data)
|
||||
.update(values)
|
||||
.eq("id", id)
|
||||
.eq("user_id", user.id)
|
||||
.select("id")
|
||||
|
||||
@@ -3,6 +3,26 @@ import { chatSessionCreateSchema } from "@/lib/chat-session-write-contract";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
const { data, error } = await supabase
|
||||
.from("chat_sessions")
|
||||
.select("id,title,theme,model_id,messages,session_type,rectification_case_id,updated_at")
|
||||
.eq("user_id", user.id)
|
||||
.order("updated_at", { ascending: false });
|
||||
if (error) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
|
||||
return NextResponse.json({ sessions: data ?? [] });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
|
||||
+51
-55
@@ -89,6 +89,7 @@ import {
|
||||
resolveSessionModelId,
|
||||
type PublicLanguageModelCatalog,
|
||||
} from "@/lib/public-models";
|
||||
import { selfHostedOtpActions } from "@/modules/identity/client";
|
||||
import { createBrowserSupabaseClient } from "@/lib/supabase/client";
|
||||
|
||||
const BirthTimeRectification = dynamic(
|
||||
@@ -158,6 +159,8 @@ type BirthPlace = { label: string; lat: number; lon: number; tz: number };
|
||||
type Account = {
|
||||
user: { id: string; email: string | null };
|
||||
credits: number;
|
||||
profile: unknown;
|
||||
authProvider: "supabase" | "self-hosted";
|
||||
isAdmin: boolean;
|
||||
rectificationPriceCredits: number;
|
||||
hasConfirmedBirthTime: boolean;
|
||||
@@ -738,6 +741,32 @@ async function fetchModelCatalog(signal?: AbortSignal) {
|
||||
return parsePublicModelCatalog(payload);
|
||||
}
|
||||
|
||||
async function fetchSessions(signal?: AbortSignal) {
|
||||
const response = await fetch("/api/sessions", { signal, cache: "no-store" });
|
||||
if (response.status === 401) {
|
||||
window.location.assign("/login");
|
||||
throw new Error("请先登录");
|
||||
}
|
||||
const payload = await response.json().catch(() => null) as { sessions?: unknown; error?: string } | null;
|
||||
if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取聊天记录"));
|
||||
return payload?.sessions ?? [];
|
||||
}
|
||||
|
||||
async function patchSessionModel(sessionId: string, modelId: string, signal?: AbortSignal) {
|
||||
const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
signal,
|
||||
body: JSON.stringify({ model_id: modelId }),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
return {
|
||||
found: response.ok,
|
||||
error: response.ok ? null : payloadMessage(payload, "模型选择暂时无法同步"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [profile, setProfile] = useState<Profile>(emptyProfile);
|
||||
const [profileDraft, setProfileDraft] = useState<Profile>(emptyProfile);
|
||||
@@ -1075,6 +1104,8 @@ export default function Home() {
|
||||
setAccount({
|
||||
user: { id: "preview-user", email: "preview@local.test" },
|
||||
credits: 8,
|
||||
profile: previewProfile,
|
||||
authProvider: "self-hosted",
|
||||
isAdmin: false,
|
||||
rectificationPriceCredits: 1,
|
||||
hasConfirmedBirthTime: previewProfile.birthTimeStatus === "confirmed",
|
||||
@@ -1101,16 +1132,7 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
|
||||
const supabase = createBrowserSupabaseClient();
|
||||
const { data: authData, error: authError } = await supabase.auth.getSession();
|
||||
if (authError) throw authError;
|
||||
if (controller.signal.aborted) return;
|
||||
if (!authData.session) {
|
||||
window.location.assign("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
const [nextAccount, modelCatalogResult] = await Promise.all([
|
||||
const [nextAccount, modelCatalogResult, sessionsPayload] = await Promise.all([
|
||||
fetchAccount(controller.signal),
|
||||
fetchModelCatalog(controller.signal)
|
||||
.then((catalog) => ({ catalog, unavailable: false }))
|
||||
@@ -1118,50 +1140,30 @@ export default function Home() {
|
||||
if (caught instanceof Error && caught.name === "AbortError") throw caught;
|
||||
return { catalog: null, unavailable: true };
|
||||
}),
|
||||
fetchSessions(controller.signal),
|
||||
]);
|
||||
const nextModelCatalog = modelCatalogResult.catalog;
|
||||
const [profileResult, sessionsResult] = await Promise.all([
|
||||
supabase
|
||||
.from("profiles")
|
||||
.select("name,birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code")
|
||||
.eq("id", nextAccount.user.id)
|
||||
.abortSignal(controller.signal)
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from("chat_sessions")
|
||||
.select("id,title,theme,model_id,messages,session_type,rectification_case_id,updated_at")
|
||||
.abortSignal(controller.signal)
|
||||
.order("updated_at", { ascending: false }),
|
||||
]);
|
||||
|
||||
if (profileResult.error) throw profileResult.error;
|
||||
if (sessionsResult.error) throw sessionsResult.error;
|
||||
|
||||
const parsedSessions = readSessions(sessionsResult.data, nextModelCatalog);
|
||||
const parsedSessions = readSessions(sessionsPayload, nextModelCatalog);
|
||||
let nextSessions = parsedSessions.sessions;
|
||||
if (nextSessions.length === 0) {
|
||||
if (controller.signal.aborted) return;
|
||||
const initialSession = createSession(nextModelCatalog?.defaultModelId ?? "");
|
||||
const { error } = await supabase
|
||||
.from("chat_sessions")
|
||||
.insert({
|
||||
id: initialSession.id,
|
||||
user_id: nextAccount.user.id,
|
||||
if (initialSession.modelId) {
|
||||
await writeChatSession(initialSession.id, {
|
||||
title: initialSession.title,
|
||||
theme: initialSession.theme,
|
||||
model_id: initialSession.modelId || null,
|
||||
model_id: initialSession.modelId,
|
||||
messages: initialSession.messages,
|
||||
session_type: initialSession.sessionType,
|
||||
rectification_case_id: initialSession.rectificationCaseId,
|
||||
updated_at: new Date(initialSession.updatedAt).toISOString(),
|
||||
})
|
||||
.abortSignal(controller.signal);
|
||||
if (error) throw error;
|
||||
}, "create");
|
||||
}
|
||||
nextSessions = [initialSession];
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
const nextProfile = readProfile(profileResult.data);
|
||||
const nextProfile = readProfile(nextAccount.profile);
|
||||
setAccount(nextAccount);
|
||||
setModelCatalog(nextModelCatalog);
|
||||
setProfile(nextProfile);
|
||||
@@ -1178,13 +1180,9 @@ export default function Home() {
|
||||
setAccountError("");
|
||||
|
||||
if (nextModelCatalog && parsedSessions.fallbackSessionIds.length > 0) {
|
||||
const { error } = await supabase
|
||||
.from("chat_sessions")
|
||||
.update({ model_id: nextModelCatalog.defaultModelId })
|
||||
.eq("user_id", nextAccount.user.id)
|
||||
.in("id", parsedSessions.fallbackSessionIds)
|
||||
.abortSignal(controller.signal);
|
||||
if (error && !controller.signal.aborted) {
|
||||
const results = await Promise.all(parsedSessions.fallbackSessionIds.map((sessionId) =>
|
||||
patchSessionModel(sessionId, nextModelCatalog.defaultModelId, controller.signal)));
|
||||
if (results.some((result) => result.error) && !controller.signal.aborted) {
|
||||
setComposerNotice("已在当前页面切换为默认模型,但云端同步失败;刷新后可能需要重新选择。");
|
||||
}
|
||||
}
|
||||
@@ -1491,14 +1489,8 @@ export default function Home() {
|
||||
if (process.env.NODE_ENV === "development" && uiPreview.current) {
|
||||
return { found: true, error: null };
|
||||
}
|
||||
const { data, error } = await createBrowserSupabaseClient()
|
||||
.from("chat_sessions")
|
||||
.update(values)
|
||||
.eq("id", sessionId)
|
||||
.eq("user_id", ownerId)
|
||||
.select("id")
|
||||
.maybeSingle();
|
||||
return { found: Boolean(data), error: error?.message ?? null };
|
||||
void ownerId;
|
||||
return patchSessionModel(sessionId, values.model_id);
|
||||
},
|
||||
userId,
|
||||
nextSession.id,
|
||||
@@ -1834,8 +1826,12 @@ export default function Home() {
|
||||
setSigningOut(true);
|
||||
setAccountError("");
|
||||
try {
|
||||
const { error } = await createBrowserSupabaseClient().auth.signOut();
|
||||
if (error) throw error;
|
||||
if (account?.authProvider === "self-hosted") {
|
||||
await selfHostedOtpActions.signOut();
|
||||
} else {
|
||||
const { error } = await createBrowserSupabaseClient().auth.signOut();
|
||||
if (error) throw error;
|
||||
}
|
||||
window.location.assign("/login");
|
||||
} catch (caught) {
|
||||
const message = caught instanceof Error ? caught.message : "退出失败";
|
||||
|
||||
@@ -27,6 +27,10 @@ export const chatSessionCreateSchema = chatSessionWriteSchema.extend({
|
||||
id: z.string().uuid(),
|
||||
}).strict();
|
||||
|
||||
export const chatSessionModelPatchSchema = z.object({
|
||||
model_id: z.string().trim().min(1).max(64),
|
||||
}).strict();
|
||||
|
||||
export type ChatSessionWrite = Readonly<{
|
||||
title: string;
|
||||
theme: "career" | "marriage" | "wealth" | "timing" | "general";
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
import { Pool, type PoolClient } from "pg";
|
||||
|
||||
type LocalIdentity = Readonly<{ id: string; email: string | null }> | null;
|
||||
export type LocalDatabaseRole = "authenticated" | "service_role";
|
||||
type PostgresError = Error & { code?: string };
|
||||
type QueryError = Readonly<{ message: string; code?: string }>;
|
||||
type QueryResult = Readonly<{
|
||||
data: unknown;
|
||||
error: QueryError | null;
|
||||
count?: number | null;
|
||||
}>;
|
||||
|
||||
type Filter =
|
||||
| Readonly<{ kind: "eq"; column: string; value: unknown }>
|
||||
| Readonly<{ kind: "in"; column: string; value: readonly unknown[] }>
|
||||
| Readonly<{ kind: "is"; column: string; value: unknown }>
|
||||
| Readonly<{ kind: "notContains"; column: string; value: unknown }>;
|
||||
|
||||
type Mutation =
|
||||
| Readonly<{ kind: "insert"; rows: readonly Record<string, unknown>[] }>
|
||||
| Readonly<{ kind: "update"; values: Record<string, unknown> }>
|
||||
| Readonly<{ kind: "upsert"; rows: readonly Record<string, unknown>[]; conflict: readonly string[] }>
|
||||
| Readonly<{ kind: "delete"; exactCount: boolean }>;
|
||||
|
||||
const identifierPattern = /^[a-z_][a-z0-9_]*$/;
|
||||
|
||||
function identifier(value: string): string {
|
||||
const normalized = value.trim();
|
||||
if (!identifierPattern.test(normalized)) throw new Error("unsafe database identifier");
|
||||
return `"${normalized}"`;
|
||||
}
|
||||
|
||||
function queryError(error: unknown): QueryError {
|
||||
const value = error as PostgresError;
|
||||
return {
|
||||
message: value instanceof Error ? value.message : "database request failed",
|
||||
...(typeof value?.code === "string" ? { code: value.code } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function records(value: Record<string, unknown> | readonly Record<string, unknown>[]) {
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
const poolGlobal = globalThis as typeof globalThis & {
|
||||
jyotishaLocalDataPools?: Map<string, Pool>;
|
||||
};
|
||||
|
||||
function localDataPool(connectionString: string): Pool {
|
||||
poolGlobal.jyotishaLocalDataPools ??= new Map();
|
||||
let pool = poolGlobal.jyotishaLocalDataPools.get(connectionString);
|
||||
if (!pool) {
|
||||
pool = new Pool({
|
||||
connectionString,
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30_000,
|
||||
connectionTimeoutMillis: 5_000,
|
||||
allowExitOnIdle: true,
|
||||
application_name: "jyotisha-business",
|
||||
});
|
||||
poolGlobal.jyotishaLocalDataPools.set(connectionString, pool);
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
async function inBusinessTransaction<T>(
|
||||
pool: Pool,
|
||||
identity: LocalIdentity,
|
||||
role: LocalDatabaseRole,
|
||||
run: (client: PoolClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("begin");
|
||||
await client.query(`set local role ${role}`);
|
||||
await client.query(
|
||||
"select set_config('request.jwt.claim.sub', $1, true), set_config('request.jwt.claim.email', $2, true)",
|
||||
[identity?.id ?? "", identity?.email ?? ""],
|
||||
);
|
||||
const result = await run(client);
|
||||
await client.query("commit");
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query("rollback").catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function columnTypes(
|
||||
client: PoolClient,
|
||||
table: string,
|
||||
): Promise<Map<string, string>> {
|
||||
const result = await client.query<{ column_name: string; udt_name: string }>(
|
||||
`
|
||||
select column_name, udt_name
|
||||
from information_schema.columns
|
||||
where table_schema = 'public' and table_name = $1
|
||||
`,
|
||||
[table],
|
||||
);
|
||||
return new Map(result.rows.map((row) => [row.column_name, row.udt_name]));
|
||||
}
|
||||
|
||||
function databaseValue(type: string | undefined, value: unknown): unknown {
|
||||
if ((type === "json" || type === "jsonb") && value !== null) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
|
||||
private selectedColumns: string[] | null = null;
|
||||
private mutation: Mutation | null = null;
|
||||
private readonly filters: Filter[] = [];
|
||||
private ordering: Readonly<{ column: string; ascending: boolean }> | null = null;
|
||||
private rowLimit: number | null = null;
|
||||
private abort: AbortSignal | null = null;
|
||||
private cardinality: "many" | "single" | "maybeSingle" = "many";
|
||||
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
private readonly identity: LocalIdentity,
|
||||
private readonly role: LocalDatabaseRole,
|
||||
private readonly table: string,
|
||||
) {
|
||||
identifier(table);
|
||||
}
|
||||
|
||||
select(columns = "*") {
|
||||
this.selectedColumns = columns === "*"
|
||||
? ["*"]
|
||||
: columns.split(",").map((column) => column.trim()).filter(Boolean);
|
||||
for (const column of this.selectedColumns) {
|
||||
if (column !== "*") identifier(column);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
insert(value: Record<string, unknown> | readonly Record<string, unknown>[]) {
|
||||
this.mutation = { kind: "insert", rows: records(value) };
|
||||
return this;
|
||||
}
|
||||
|
||||
upsert(
|
||||
value: Record<string, unknown> | readonly Record<string, unknown>[],
|
||||
options: { onConflict: string },
|
||||
) {
|
||||
const conflict = options.onConflict.split(",").map((column) => column.trim());
|
||||
conflict.forEach(identifier);
|
||||
this.mutation = { kind: "upsert", rows: records(value), conflict };
|
||||
return this;
|
||||
}
|
||||
|
||||
update(values: Record<string, unknown>) {
|
||||
this.mutation = { kind: "update", values };
|
||||
return this;
|
||||
}
|
||||
|
||||
delete(options?: { count?: string }) {
|
||||
this.mutation = { kind: "delete", exactCount: options?.count === "exact" };
|
||||
return this;
|
||||
}
|
||||
|
||||
eq(column: string, value: unknown) {
|
||||
identifier(column);
|
||||
this.filters.push({ kind: "eq", column, value });
|
||||
return this;
|
||||
}
|
||||
|
||||
in(column: string, value: readonly unknown[]) {
|
||||
identifier(column);
|
||||
this.filters.push({ kind: "in", column, value });
|
||||
return this;
|
||||
}
|
||||
|
||||
is(column: string, value: unknown) {
|
||||
identifier(column);
|
||||
this.filters.push({ kind: "is", column, value });
|
||||
return this;
|
||||
}
|
||||
|
||||
not(column: string, operator: string, value: unknown) {
|
||||
identifier(column);
|
||||
if (operator !== "cs") throw new Error("unsupported not filter");
|
||||
this.filters.push({ kind: "notContains", column, value });
|
||||
return this;
|
||||
}
|
||||
|
||||
order(column: string, options: { ascending?: boolean } = {}) {
|
||||
identifier(column);
|
||||
this.ordering = { column, ascending: options.ascending !== false };
|
||||
return this;
|
||||
}
|
||||
|
||||
limit(value: number) {
|
||||
if (!Number.isSafeInteger(value) || value < 0) throw new Error("invalid row limit");
|
||||
this.rowLimit = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
abortSignal(signal: AbortSignal) {
|
||||
this.abort = signal;
|
||||
return this;
|
||||
}
|
||||
|
||||
single() {
|
||||
this.cardinality = "single";
|
||||
return this.execute();
|
||||
}
|
||||
|
||||
maybeSingle() {
|
||||
this.cardinality = "maybeSingle";
|
||||
return this.execute();
|
||||
}
|
||||
|
||||
then<TResult1 = QueryResult, TResult2 = never>(
|
||||
onfulfilled?: ((value: QueryResult) => TResult1 | PromiseLike<TResult1>) | null,
|
||||
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
||||
): PromiseLike<TResult1 | TResult2> {
|
||||
return this.execute().then(onfulfilled, onrejected);
|
||||
}
|
||||
|
||||
private returningClause(): string {
|
||||
if (!this.selectedColumns) return "";
|
||||
return ` returning ${this.selectedColumns.map((column) => column === "*" ? "*" : identifier(column)).join(", ")}`;
|
||||
}
|
||||
|
||||
private filterClause(parameters: unknown[], types: Map<string, string>): string {
|
||||
if (this.filters.length === 0) return "";
|
||||
const parts = this.filters.map((filter) => {
|
||||
const column = identifier(filter.column);
|
||||
if (filter.kind === "is") {
|
||||
if (filter.value === null) return `${column} is null`;
|
||||
if (filter.value === true) return `${column} is true`;
|
||||
if (filter.value === false) return `${column} is false`;
|
||||
throw new Error("unsupported is filter");
|
||||
}
|
||||
if (filter.kind === "in") {
|
||||
if (filter.value.length === 0) return "false";
|
||||
const placeholders = filter.value.map((value) => {
|
||||
parameters.push(databaseValue(types.get(filter.column), value));
|
||||
return `$${parameters.length}`;
|
||||
});
|
||||
return `${column} in (${placeholders.join(", ")})`;
|
||||
}
|
||||
if (filter.kind === "notContains") {
|
||||
parameters.push(databaseValue(types.get(filter.column), filter.value));
|
||||
return `not (${column} @> $${parameters.length})`;
|
||||
}
|
||||
parameters.push(databaseValue(types.get(filter.column), filter.value));
|
||||
return `${column} = $${parameters.length}`;
|
||||
});
|
||||
return ` where ${parts.join(" and ")}`;
|
||||
}
|
||||
|
||||
private async execute(): Promise<QueryResult> {
|
||||
if (this.abort?.aborted) {
|
||||
return { data: null, error: { message: "AbortError" } };
|
||||
}
|
||||
try {
|
||||
return await inBusinessTransaction(this.pool, this.identity, this.role, async (client) => {
|
||||
const types = await columnTypes(client, this.table);
|
||||
const parameters: unknown[] = [];
|
||||
let sql: string;
|
||||
|
||||
if (!this.mutation) {
|
||||
const selected = (this.selectedColumns ?? ["*"])
|
||||
.map((column) => column === "*" ? "*" : identifier(column))
|
||||
.join(", ");
|
||||
sql = `select ${selected} from public.${identifier(this.table)}`;
|
||||
sql += this.filterClause(parameters, types);
|
||||
if (this.ordering) {
|
||||
sql += ` order by ${identifier(this.ordering.column)} ${this.ordering.ascending ? "asc" : "desc"}`;
|
||||
}
|
||||
if (this.rowLimit !== null) sql += ` limit ${this.rowLimit}`;
|
||||
} else if (this.mutation.kind === "insert" || this.mutation.kind === "upsert") {
|
||||
const rows = this.mutation.rows;
|
||||
if (rows.length === 0) return { data: this.selectedColumns ? [] : null, error: null };
|
||||
const columns = Object.keys(rows[0] ?? {});
|
||||
if (columns.length === 0 || rows.some((row) => Object.keys(row).join("\0") !== columns.join("\0"))) {
|
||||
throw new Error("inconsistent insert rows");
|
||||
}
|
||||
columns.forEach(identifier);
|
||||
const valueGroups = rows.map((row) => `(${columns.map((column) => {
|
||||
parameters.push(databaseValue(types.get(column), row[column]));
|
||||
return `$${parameters.length}`;
|
||||
}).join(", ")})`);
|
||||
sql = `insert into public.${identifier(this.table)} (${columns.map(identifier).join(", ")}) values ${valueGroups.join(", ")}`;
|
||||
if (this.mutation.kind === "upsert") {
|
||||
const updates = columns.filter((column) => !this.mutation || this.mutation.kind !== "upsert" || !this.mutation.conflict.includes(column));
|
||||
sql += ` on conflict (${this.mutation.conflict.map(identifier).join(", ")}) do ${updates.length === 0
|
||||
? "nothing"
|
||||
: `update set ${updates.map((column) => `${identifier(column)} = excluded.${identifier(column)}`).join(", ")}`}`;
|
||||
}
|
||||
sql += this.returningClause();
|
||||
} else if (this.mutation.kind === "update") {
|
||||
const columns = Object.keys(this.mutation.values);
|
||||
if (columns.length === 0) throw new Error("empty update");
|
||||
const assignments = columns.map((column) => {
|
||||
identifier(column);
|
||||
parameters.push(databaseValue(types.get(column), this.mutation && this.mutation.kind === "update" ? this.mutation.values[column] : null));
|
||||
return `${identifier(column)} = $${parameters.length}`;
|
||||
});
|
||||
sql = `update public.${identifier(this.table)} set ${assignments.join(", ")}`;
|
||||
sql += this.filterClause(parameters, types);
|
||||
sql += this.returningClause();
|
||||
} else {
|
||||
sql = `delete from public.${identifier(this.table)}`;
|
||||
sql += this.filterClause(parameters, types);
|
||||
sql += this.returningClause();
|
||||
}
|
||||
|
||||
const result = await client.query(sql, parameters);
|
||||
const rows = result.rows;
|
||||
let data: unknown = this.selectedColumns ? rows : null;
|
||||
if (this.cardinality !== "many") {
|
||||
if (rows.length > 1 || (this.cardinality === "single" && rows.length !== 1)) {
|
||||
return { data: null, error: { code: "PGRST116", message: "unexpected row count" } };
|
||||
}
|
||||
data = rows[0] ?? null;
|
||||
}
|
||||
return {
|
||||
data,
|
||||
error: null,
|
||||
...(this.mutation?.kind === "delete" && this.mutation.exactCount
|
||||
? { count: result.rowCount ?? 0 }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
return { data: null, error: queryError(error), count: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type FunctionMetadata = Readonly<{
|
||||
proretset: boolean;
|
||||
return_type: string;
|
||||
argument_names: string[] | null;
|
||||
argument_types: string[];
|
||||
}>;
|
||||
|
||||
async function functionMetadata(
|
||||
client: PoolClient,
|
||||
functionName: string,
|
||||
argumentNames: readonly string[],
|
||||
): Promise<FunctionMetadata> {
|
||||
const result = await client.query<FunctionMetadata>(
|
||||
`
|
||||
select
|
||||
p.proretset,
|
||||
format_type(p.prorettype, null) as return_type,
|
||||
p.proargnames as argument_names,
|
||||
array(
|
||||
select format_type(argument_type, null)
|
||||
from unnest(p.proargtypes) argument_type
|
||||
) as argument_types
|
||||
from pg_proc p
|
||||
join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public'
|
||||
and p.proname = $1
|
||||
and $2::text[] <@ coalesce(p.proargnames, '{}'::text[])
|
||||
order by cardinality(p.proargtypes) asc
|
||||
limit 1
|
||||
`,
|
||||
[functionName, argumentNames],
|
||||
);
|
||||
const metadata = result.rows[0];
|
||||
if (!metadata) throw new Error("database function not found");
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function castType(value: string): string {
|
||||
if (!/^[a-z0-9_ .\[\]]+$/.test(value)) throw new Error("unsafe database type");
|
||||
return value;
|
||||
}
|
||||
|
||||
export class LocalPostgresDataClient {
|
||||
readonly auth: Readonly<{
|
||||
getUser: () => Promise<Readonly<{
|
||||
data: { user: LocalIdentity };
|
||||
error: null;
|
||||
}>>;
|
||||
}>;
|
||||
|
||||
private readonly pool: Pool;
|
||||
|
||||
constructor(
|
||||
connectionString: string,
|
||||
private readonly identity: LocalIdentity,
|
||||
private readonly role: LocalDatabaseRole,
|
||||
) {
|
||||
if (role !== "authenticated" && role !== "service_role") {
|
||||
throw new Error("unsupported database role");
|
||||
}
|
||||
this.pool = localDataPool(connectionString);
|
||||
this.auth = {
|
||||
getUser: async () => ({ data: { user: this.identity }, error: null }),
|
||||
};
|
||||
}
|
||||
|
||||
from(table: string) {
|
||||
return new LocalPostgresQueryBuilder(this.pool, this.identity, this.role, table);
|
||||
}
|
||||
|
||||
async rpc(functionName: string, args: Readonly<Record<string, unknown>> = {}) {
|
||||
try {
|
||||
identifier(functionName);
|
||||
return await inBusinessTransaction(this.pool, this.identity, this.role, async (client) => {
|
||||
const names = Object.keys(args);
|
||||
names.forEach(identifier);
|
||||
const metadata = await functionMetadata(client, functionName, names);
|
||||
const typeByName = new Map(
|
||||
(metadata.argument_names ?? []).map((name, index) => [name, metadata.argument_types[index]]),
|
||||
);
|
||||
const parameters = names.map((name) =>
|
||||
databaseValue(typeByName.get(name), args[name]));
|
||||
const call = names.map((name, index) =>
|
||||
`${identifier(name)} => $${index + 1}::${castType(typeByName.get(name) ?? "text")}`,
|
||||
).join(", ");
|
||||
const sql = metadata.proretset
|
||||
? `select * from public.${identifier(functionName)}(${call})`
|
||||
: `select public.${identifier(functionName)}(${call}) as value`;
|
||||
const result = await client.query(sql, parameters);
|
||||
return {
|
||||
data: metadata.proretset ? result.rows : result.rows[0]?.value ?? null,
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
return { data: null, error: queryError(error) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createLocalPostgresDataClient(
|
||||
connectionString: string,
|
||||
identity: LocalIdentity = null,
|
||||
role: LocalDatabaseRole = "authenticated",
|
||||
) {
|
||||
return new LocalPostgresDataClient(connectionString, identity, role);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
export {
|
||||
LocalPostgresDataClient,
|
||||
createLocalPostgresDataClient,
|
||||
type LocalDatabaseRole,
|
||||
} from "./local-postgres-client-core";
|
||||
@@ -1,12 +1,21 @@
|
||||
import "server-only";
|
||||
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||
import { createLocalPostgresDataClient } from "@/lib/db/local-postgres-client";
|
||||
import { readDatabaseUrl } from "@/lib/db/config";
|
||||
import {
|
||||
getSupabaseUrl,
|
||||
SupabaseConfigurationError,
|
||||
} from "./config";
|
||||
|
||||
export function createAdminSupabaseClient() {
|
||||
if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") {
|
||||
return createLocalPostgresDataClient(
|
||||
readDatabaseUrl(process.env, "ADMIN_DATABASE_URL"),
|
||||
null,
|
||||
"service_role",
|
||||
) as unknown as SupabaseClient;
|
||||
}
|
||||
const url = getSupabaseUrl();
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!serviceRoleKey) {
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
import "server-only";
|
||||
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { createLocalPostgresDataClient } from "@/lib/db/local-postgres-client";
|
||||
import { readDatabaseUrl } from "@/lib/db/config";
|
||||
import { getIdentityAuthServices } from "@/modules/identity/auth";
|
||||
import { readIdentitySession } from "@/modules/identity/session";
|
||||
import { readSelfHostedIdentityConfig } from "@/modules/identity/config";
|
||||
import { resolveIdentitySurface } from "@/modules/identity/host";
|
||||
import { getSupabasePublicConfig } from "./config";
|
||||
|
||||
export async function createServerSupabaseClient() {
|
||||
if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") {
|
||||
const requestHeaders = new Headers(await headers());
|
||||
const services = getIdentityAuthServices();
|
||||
const surface = resolveIdentitySurface(
|
||||
requestHeaders.get("host"),
|
||||
readSelfHostedIdentityConfig(process.env),
|
||||
);
|
||||
const auth = surface === "admin" ? services.admin : services.user;
|
||||
const session = await readIdentitySession(auth.api, requestHeaders);
|
||||
return createLocalPostgresDataClient(
|
||||
readDatabaseUrl(process.env, "APP_DATABASE_URL"),
|
||||
session ? { id: session.user.id, email: session.user.email } : null,
|
||||
) as unknown as SupabaseClient;
|
||||
}
|
||||
const { url, anonKey } = getSupabasePublicConfig();
|
||||
const cookieStore = await cookies();
|
||||
|
||||
|
||||
@@ -19,11 +19,13 @@ export interface SelfHostedOtpClient {
|
||||
otp: string;
|
||||
}): Promise<OtpClientResult>;
|
||||
};
|
||||
signOut?(): Promise<OtpClientResult>;
|
||||
}
|
||||
|
||||
export interface SelfHostedOtpActions {
|
||||
send(email: string): Promise<void>;
|
||||
verify(email: string, otp: string): Promise<void>;
|
||||
signOut(): Promise<void>;
|
||||
}
|
||||
|
||||
export function createSelfHostedOtpActions(
|
||||
@@ -48,6 +50,11 @@ export function createSelfHostedOtpActions(
|
||||
throw new Error("验证码错误或已过期,请重新获取");
|
||||
}
|
||||
},
|
||||
async signOut() {
|
||||
if (!client.signOut) throw new Error("退出失败,请稍后再试");
|
||||
const result = await client.signOut();
|
||||
if (result.error) throw new Error("退出失败,请稍后再试");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import { createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts";
|
||||
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
|
||||
|
||||
const runnerPath = fileURLToPath(
|
||||
new URL("../scripts/db-migrate.mjs", import.meta.url),
|
||||
);
|
||||
|
||||
test("local PostgreSQL applies the reviewed business schema and serves authenticated business calls", async () => {
|
||||
const fixture = startPostgresFixture();
|
||||
const schemaUrl = fixture.connectionUrl(
|
||||
"schema_owner",
|
||||
"schema-owner-test-password",
|
||||
);
|
||||
|
||||
try {
|
||||
const migration = spawnSync(process.execPath, [runnerPath], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
SCHEMA_DATABASE_URL: schemaUrl,
|
||||
},
|
||||
});
|
||||
assert.equal(migration.status, 0, migration.stderr);
|
||||
assert.match(migration.stdout, /applied 20260715000000_account_credits\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260721150000_align_conversational_finance_domain\.sql/);
|
||||
|
||||
assert.equal(
|
||||
fixture.psql(`
|
||||
select string_agg(tablename, ',' order by tablename)
|
||||
from pg_tables
|
||||
where schemaname = 'public'
|
||||
`),
|
||||
[
|
||||
"birth_time_rectification_action_receipts",
|
||||
"birth_time_rectification_billing",
|
||||
"birth_time_rectification_cases",
|
||||
"birth_time_rectification_dynamic_state",
|
||||
"birth_time_rectification_event_evidence",
|
||||
"birth_time_rectification_handoff_attach_receipts",
|
||||
"birth_time_rectification_handoff_settlements",
|
||||
"birth_time_rectification_question_handoffs",
|
||||
"birth_time_rectification_scoring_jobs",
|
||||
"birth_time_rectification_turns",
|
||||
"chart_profiles",
|
||||
"chat_sessions",
|
||||
"consultation_requests",
|
||||
"credit_request_cancellations",
|
||||
"credit_transactions",
|
||||
"profiles",
|
||||
"redemption_codes",
|
||||
"synastry_reports",
|
||||
].join(","),
|
||||
);
|
||||
|
||||
fixture.psqlAs(
|
||||
"identity_runtime",
|
||||
"identity-runtime-test-password",
|
||||
`
|
||||
insert into identity.users (name, email, email_verified, email_verified_at)
|
||||
values ('Local User', 'local-user@example.com', true, now())
|
||||
`,
|
||||
);
|
||||
const userId = fixture.psql(
|
||||
"select id from identity.users where email = 'local-user@example.com'",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select email from auth.users where id = '${userId}'`),
|
||||
"local-user@example.com",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select email || ':' || credits from public.profiles where id = '${userId}'`),
|
||||
"local-user@example.com:0",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psqlAs(
|
||||
"app_runtime",
|
||||
"app-runtime-test-password",
|
||||
`set role authenticated;
|
||||
select set_config('request.jwt.claim.sub', '${userId}', true);
|
||||
select email from public.profiles where id = '${userId}'`,
|
||||
),
|
||||
`SET\n${userId}\nlocal-user@example.com`,
|
||||
);
|
||||
|
||||
const local = createLocalPostgresDataClient(
|
||||
fixture.connectionUrl("app_runtime", "app-runtime-test-password"),
|
||||
{ id: userId, email: "local-user@example.com" },
|
||||
);
|
||||
const profile = await local.from("profiles")
|
||||
.select("id,email,credits")
|
||||
.eq("id", userId)
|
||||
.single();
|
||||
assert.equal(profile.error, null);
|
||||
assert.deepEqual(profile.data, {
|
||||
id: userId,
|
||||
email: "local-user@example.com",
|
||||
credits: 0,
|
||||
});
|
||||
const admin = createLocalPostgresDataClient(
|
||||
fixture.connectionUrl("admin_runtime", "admin-runtime-test-password"),
|
||||
null,
|
||||
"service_role",
|
||||
);
|
||||
const adminProfile = await admin.from("profiles")
|
||||
.select("id")
|
||||
.eq("id", userId)
|
||||
.single();
|
||||
assert.equal(adminProfile.error, null);
|
||||
assert.deepEqual(adminProfile.data, { id: userId });
|
||||
|
||||
const sessionId = "11111111-1111-4111-8111-111111111111";
|
||||
const inserted = await local.from("chat_sessions").insert({
|
||||
id: sessionId,
|
||||
user_id: userId,
|
||||
title: "Local conversation",
|
||||
theme: "general",
|
||||
model_id: "test-model",
|
||||
messages: [],
|
||||
session_type: "consultation",
|
||||
rectification_case_id: null,
|
||||
updated_at: new Date().toISOString(),
|
||||
}).select("id").single();
|
||||
assert.equal(inserted.error, null);
|
||||
assert.deepEqual(inserted.data, { id: sessionId });
|
||||
|
||||
fixture.psql(`
|
||||
insert into public.redemption_codes (code_hash, code_mask, credits)
|
||||
values ('${"a".repeat(64)}', 'JYOTISH-****-TEST', 3)
|
||||
`);
|
||||
const redeemed = await local.rpc("redeem_code", {
|
||||
p_code_hash: "a".repeat(64),
|
||||
});
|
||||
assert.equal(redeemed.error, null);
|
||||
assert.deepEqual(redeemed.data, [{ success: true, credits: 3, error_code: null }]);
|
||||
} finally {
|
||||
fixture.stop();
|
||||
}
|
||||
});
|
||||
@@ -33,6 +33,14 @@ test("database roles have no cluster privileges", () => {
|
||||
"schema_owner:f:f:f:f:f:f",
|
||||
].join("\n"),
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`
|
||||
select rolcanlogin || ':' || rolbypassrls
|
||||
from pg_roles
|
||||
where rolname = 'service_role'
|
||||
`),
|
||||
"f:true",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(
|
||||
"select nspowner::regrole::text from pg_namespace where nspname = 'public'",
|
||||
|
||||
@@ -156,7 +156,7 @@ test("server compose defaults to local images without removing either build", ()
|
||||
}
|
||||
});
|
||||
|
||||
test("staging Caddy isolates the public and identity-only admin hosts", () => {
|
||||
test("staging Caddy isolates the business admin surface from the public host", () => {
|
||||
const caddy = readFileSync(
|
||||
new URL("../../deploy/Caddyfile.staging", import.meta.url),
|
||||
"utf8",
|
||||
@@ -168,7 +168,9 @@ test("staging Caddy isolates the public and identity-only admin hosts", () => {
|
||||
/\{\$ADMIN_SITE_ADDRESS:https:\/\/admin\.staging\.jyotisha\.chat\}/,
|
||||
);
|
||||
assert.match(caddy, /reverse_proxy web:3000/);
|
||||
assert.match(caddy, /@identity path \/login \/api\/auth\/\*/);
|
||||
assert.match(caddy, /@adminPaths path \/admin \/admin\/\* \/api\/admin\/\*/);
|
||||
assert.match(caddy, /redir @adminRoot \/admin\/codes 302/);
|
||||
assert.match(caddy, /@adminSurface path \/login \/admin \/admin\/\* \/api\/admin\/\* \/api\/auth\/\*/);
|
||||
assert.match(caddy, /respond "Not found" 404/);
|
||||
assert.doesNotMatch(caddy, /www\.jyotisha\.chat/);
|
||||
});
|
||||
@@ -234,15 +236,19 @@ test("staging env validator rejects selector drift, duplicates, and unsafe permi
|
||||
"CADDYFILE_PATH=./Caddyfile.staging",
|
||||
"SITE_ADDRESS=https://staging.jyotisha.chat",
|
||||
"ADMIN_SITE_ADDRESS=https://admin.staging.jyotisha.chat",
|
||||
"AUTH_PROVIDER=supabase",
|
||||
"AUTH_PROVIDER=self-hosted",
|
||||
"SELF_HOSTED_IDENTITY_ENABLED=true",
|
||||
"AUTH_USER_ORIGIN=https://staging.jyotisha.chat",
|
||||
"AUTH_ADMIN_ORIGIN=https://admin.staging.jyotisha.chat",
|
||||
"IDENTITY_DATABASE_URL=postgresql://identity_runtime:identity-runtime-test-password@postgres:5432/jyotisha",
|
||||
"APP_DATABASE_URL=postgresql://app_runtime:app-runtime-test-password@postgres:5432/jyotisha",
|
||||
"ADMIN_DATABASE_URL=postgresql://admin_runtime:admin-runtime-test-password@postgres:5432/jyotisha",
|
||||
"BETTER_AUTH_USER_SECRET=user-secret-that-is-at-least-32-bytes-long",
|
||||
"BETTER_AUTH_ADMIN_SECRET=admin-secret-that-is-at-least-32-bytes-long",
|
||||
"RESEND_API_KEY=re_test_key_that_must_not_be_printed",
|
||||
"RESEND_FROM_EMAIL=Jyotisha Staging <login@staging.jyotisha.chat>",
|
||||
"ADMIN_EMAILS=admin@example.com",
|
||||
"JYOTISH_DYNAMIC_RECTIFICATION_TOKEN=dynamic-token-that-is-at-least-32-bytes",
|
||||
];
|
||||
const run = () =>
|
||||
spawnSync("bash", [validator, envFile], { encoding: "utf8" });
|
||||
@@ -325,7 +331,7 @@ test("staging env validator rejects selector drift, duplicates, and unsafe permi
|
||||
writeEnv(
|
||||
validSelectors.map((line) =>
|
||||
line.startsWith("AUTH_PROVIDER=")
|
||||
? "AUTH_PROVIDER=self-hosted"
|
||||
? "AUTH_PROVIDER=supabase"
|
||||
: line,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -72,6 +72,7 @@ test("quality gate validates relevant changes once and publishes a digest manife
|
||||
assert.match(workflow, /node frontend\/scripts\/staging-image-manifest\.mjs/);
|
||||
assert.match(workflow, /name: staging-image-manifest-\$\{\{ github\.sha \}\}/);
|
||||
assert.match(workflow, /uses: actions\/upload-artifact@v4/);
|
||||
assert.doesNotMatch(workflow, /STAGING_SUPABASE|NEXT_PUBLIC_SUPABASE/);
|
||||
assert.doesNotMatch(workflow, /(?:^|:)latest$/m);
|
||||
});
|
||||
|
||||
@@ -337,6 +338,11 @@ test("manual migration uses only PostgreSQL and the digest-pinned migrator", ()
|
||||
assert.match(runner, /docker pull "\$WEB_IMAGE"/);
|
||||
assert.match(runner, /up -d --no-build --pull never --wait postgres/);
|
||||
assert.match(runner, /-f deploy\/docker-compose\.postgres\.yml/);
|
||||
assertOrder(runner, [
|
||||
"up -d --no-build --pull never --wait postgres",
|
||||
"002-ensure-business-compatibility-roles.sql",
|
||||
"--profile migration run --rm migrator",
|
||||
]);
|
||||
assert.match(runner, /--profile migration run --rm migrator/);
|
||||
assert.match(runner, /select filename from migration\.schema_migrations order by filename/);
|
||||
assert.doesNotMatch(runner, /docker-compose\.server\.yml/);
|
||||
|
||||
Reference in New Issue
Block a user