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 { getAdminClient } from "../_supabase.js";
|
||||
|
||||
const FOUNDATION_URL =
|
||||
process.env.VITE_FOUNDATION_URL || "https://aethex.foundation";
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
if (req.method !== "POST")
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
|
|
@ -11,58 +34,116 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|||
try {
|
||||
const admin = getAdminClient();
|
||||
|
||||
const tryUpsert = async (payload: any) => {
|
||||
const resp = await admin
|
||||
.from("user_profiles")
|
||||
.upsert(payload, { onConflict: "id" as any })
|
||||
.select()
|
||||
.single();
|
||||
return resp as any;
|
||||
};
|
||||
// ============================================================
|
||||
// CRITICAL: REJECT DIRECT WRITES TO PROFILE
|
||||
// ============================================================
|
||||
// This endpoint now only validates/syncs passports from Foundation.
|
||||
// Do NOT write arbitrary profile data to local cache.
|
||||
//
|
||||
// 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;
|
||||
let attempt = await tryUpsert({ id, ...profile, username });
|
||||
// Check if this is an attempt to write profile fields
|
||||
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 (!err) return null;
|
||||
if (typeof err === "string") return { message: err };
|
||||
if (typeof err === "object" && Object.keys(err).length === 0) return null;
|
||||
return err;
|
||||
};
|
||||
if (forbiddenFields.length > 0) {
|
||||
console.warn(
|
||||
"[Passport Security] Rejected attempt to write forbidden fields:",
|
||||
forbiddenFields,
|
||||
"for user:",
|
||||
id,
|
||||
);
|
||||
|
||||
let error = normalizeError(attempt.error);
|
||||
if (error) {
|
||||
const message: string = (error as any).message || "";
|
||||
const code: string = (error as any).code || "";
|
||||
|
||||
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(403).json({
|
||||
error: `Cannot modify profile fields directly. All passport mutations must go through Foundation (${FOUNDATION_URL}).`,
|
||||
forbidden_fields: forbiddenFields,
|
||||
instruction:
|
||||
"To update your profile, log in to aethex.foundation and make changes there. Changes sync to aethex.dev on next login.",
|
||||
});
|
||||
}
|
||||
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) {
|
||||
if (/SUPABASE_/.test(String(e?.message || ""))) {
|
||||
return res
|
||||
|
|
|
|||
Loading…
Reference in a new issue