Make profile endpoint read-only cache, reject writes
cgen-8fb9fd52f1584fc59072bb8d66bb2372
This commit is contained in:
parent
dc8e814746
commit
b6e28f2fa3
1 changed files with 126 additions and 45 deletions
|
|
@ -1,6 +1,29 @@
|
||||||
|
/**
|
||||||
|
* CRITICAL: Profile Sync from Foundation (READ-ONLY)
|
||||||
|
*
|
||||||
|
* This endpoint syncs Foundation passport data to local cache.
|
||||||
|
* It does NOT write arbitrary profile data.
|
||||||
|
*
|
||||||
|
* Architecture:
|
||||||
|
* - aethex.foundation = SSOT (single source of truth)
|
||||||
|
* - aethex.dev = Read-only cache of Foundation passports
|
||||||
|
* - All profile mutations must go through Foundation APIs
|
||||||
|
*
|
||||||
|
* Accepted operations:
|
||||||
|
* - POST to sync/validate passport on login
|
||||||
|
*
|
||||||
|
* Rejected operations:
|
||||||
|
* - Direct writes to profile fields (username, email, etc.)
|
||||||
|
* - Mutations that bypass Foundation
|
||||||
|
* - Updates to cached data outside of Foundation sync
|
||||||
|
*/
|
||||||
|
|
||||||
import type { VercelRequest, VercelResponse } from "@vercel/node";
|
import type { VercelRequest, VercelResponse } from "@vercel/node";
|
||||||
import { getAdminClient } from "../_supabase.js";
|
import { getAdminClient } from "../_supabase.js";
|
||||||
|
|
||||||
|
const FOUNDATION_URL =
|
||||||
|
process.env.VITE_FOUNDATION_URL || "https://aethex.foundation";
|
||||||
|
|
||||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
if (req.method !== "POST")
|
if (req.method !== "POST")
|
||||||
return res.status(405).json({ error: "Method not allowed" });
|
return res.status(405).json({ error: "Method not allowed" });
|
||||||
|
|
@ -11,58 +34,116 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
try {
|
try {
|
||||||
const admin = getAdminClient();
|
const admin = getAdminClient();
|
||||||
|
|
||||||
const tryUpsert = async (payload: any) => {
|
// ============================================================
|
||||||
const resp = await admin
|
// CRITICAL: REJECT DIRECT WRITES TO PROFILE
|
||||||
.from("user_profiles")
|
// ============================================================
|
||||||
.upsert(payload, { onConflict: "id" as any })
|
// This endpoint now only validates/syncs passports from Foundation.
|
||||||
.select()
|
// Do NOT write arbitrary profile data to local cache.
|
||||||
.single();
|
//
|
||||||
return resp as any;
|
// If profile data needs to be updated:
|
||||||
};
|
// 1. User updates on Foundation (aethex.foundation)
|
||||||
|
// 2. Foundation validates and persists changes
|
||||||
|
// 3. aethex.dev syncs on next login via auth/callback.ts
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
let username = profile?.username;
|
// Check if this is an attempt to write profile fields
|
||||||
let attempt = await tryUpsert({ id, ...profile, username });
|
if (profile && Object.keys(profile).length > 0) {
|
||||||
|
// Only allowed field is to mark profile as "ensured" for onboarding
|
||||||
|
const allowedFields = ["profile_completed", "onboarding_step"];
|
||||||
|
const attemptedFields = Object.keys(profile);
|
||||||
|
const forbiddenFields = attemptedFields.filter(
|
||||||
|
(f) => !allowedFields.includes(f),
|
||||||
|
);
|
||||||
|
|
||||||
const normalizeError = (err: any) => {
|
if (forbiddenFields.length > 0) {
|
||||||
if (!err) return null;
|
console.warn(
|
||||||
if (typeof err === "string") return { message: err };
|
"[Passport Security] Rejected attempt to write forbidden fields:",
|
||||||
if (typeof err === "object" && Object.keys(err).length === 0) return null;
|
forbiddenFields,
|
||||||
return err;
|
"for user:",
|
||||||
};
|
id,
|
||||||
|
);
|
||||||
|
|
||||||
let error = normalizeError(attempt.error);
|
return res.status(403).json({
|
||||||
if (error) {
|
error: `Cannot modify profile fields directly. All passport mutations must go through Foundation (${FOUNDATION_URL}).`,
|
||||||
const message: string = (error as any).message || "";
|
forbidden_fields: forbiddenFields,
|
||||||
const code: string = (error as any).code || "";
|
instruction:
|
||||||
|
"To update your profile, log in to aethex.foundation and make changes there. Changes sync to aethex.dev on next login.",
|
||||||
if (
|
|
||||||
code === "23505" ||
|
|
||||||
message.includes("duplicate key") ||
|
|
||||||
message.includes("username")
|
|
||||||
) {
|
|
||||||
const suffix = Math.random().toString(36).slice(2, 6);
|
|
||||||
const newUsername = `${String(username || "user").slice(0, 20)}_${suffix}`;
|
|
||||||
attempt = await tryUpsert({ id, ...profile, username: newUsername });
|
|
||||||
error = normalizeError(attempt.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
if (
|
|
||||||
(error as any).code === "23503" ||
|
|
||||||
(error as any).message?.includes("foreign key")
|
|
||||||
) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error:
|
|
||||||
"User does not exist in authentication system. Please sign out and sign back in, then retry onboarding.",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return res
|
|
||||||
.status(500)
|
|
||||||
.json({ error: (error as any).message || "Unknown error" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json(attempt.data || {});
|
// ============================================================
|
||||||
|
// Only sync/validate existing passport from cache
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// Fetch user from cache to validate exists
|
||||||
|
const { data: existingUser, error: fetchError } = await admin
|
||||||
|
.from("user_profiles")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (fetchError || !existingUser) {
|
||||||
|
console.error(
|
||||||
|
"[Passport Ensure] User not found in cache (not synced from Foundation yet):",
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
return res.status(400).json({
|
||||||
|
error:
|
||||||
|
"User profile not found. Please sign out and sign back in to sync with Foundation.",
|
||||||
|
user_id: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate passport was synced from Foundation
|
||||||
|
if (!existingUser.foundation_synced_at) {
|
||||||
|
console.warn(
|
||||||
|
"[Passport Ensure] User exists but not synced from Foundation:",
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
return res.status(400).json({
|
||||||
|
error:
|
||||||
|
"Passport not synchronized from Foundation. Please sign out and sign back in.",
|
||||||
|
user_id: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate cache is still fresh
|
||||||
|
const cacheValidUntil = new Date(existingUser.cache_valid_until);
|
||||||
|
if (new Date() > cacheValidUntil) {
|
||||||
|
console.warn(
|
||||||
|
"[Passport Ensure] Cache expired for user:",
|
||||||
|
id,
|
||||||
|
"| Need to refresh from Foundation",
|
||||||
|
);
|
||||||
|
|
||||||
|
// In production, would fetch fresh from Foundation API here
|
||||||
|
// For now, return warning that cache is stale
|
||||||
|
return res.status(400).json({
|
||||||
|
error:
|
||||||
|
"Passport cache expired. Please sign out and sign back in to refresh.",
|
||||||
|
user_id: id,
|
||||||
|
cache_expired_at: cacheValidUntil.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Passport Ensure] User passport validated:", id);
|
||||||
|
|
||||||
|
// Return validated cached passport (read-only view)
|
||||||
|
return res.json({
|
||||||
|
id: existingUser.id,
|
||||||
|
email: existingUser.email,
|
||||||
|
username: existingUser.username,
|
||||||
|
full_name: existingUser.full_name,
|
||||||
|
avatar_url: existingUser.avatar_url,
|
||||||
|
profile_completed: existingUser.profile_completed,
|
||||||
|
synced_from_foundation: existingUser.foundation_synced_at,
|
||||||
|
cache_valid_until: existingUser.cache_valid_until,
|
||||||
|
// Never expose local write capability
|
||||||
|
_note:
|
||||||
|
"This is read-only cache from Foundation. To modify, update at: " +
|
||||||
|
FOUNDATION_URL,
|
||||||
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (/SUPABASE_/.test(String(e?.message || ""))) {
|
if (/SUPABASE_/.test(String(e?.message || ""))) {
|
||||||
return res
|
return res
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue