auth-user-metadata.ts132 lines · main
1/**
2 * User metadata service — public and private JSON blobs per user.
3 *
4 * Public metadata is readable from both frontend and backend.
5 * Private metadata is backend-only (never exposed to the client SDK).
6 *
7 * One row per user in `_briven_auth_user_metadata`; created lazily on
8 * first write so existing users don't need a backfill migration.
9 */
10
11import { runInProjectDatabase } from '../db/data-plane.js';
12
13export interface UserMetadata {
14 publicMetadata: Record<string, unknown>;
15 privateMetadata: Record<string, unknown>;
16}
17
18/**
19 * Get a user's metadata. Returns empty objects when no row exists yet.
20 */
21export async function getUserMetadata(
22 projectId: string,
23 userId: string,
24): Promise<UserMetadata> {
25 const rows = await runInProjectDatabase<
26 Array<{ public_metadata: unknown; private_metadata: unknown }>
27 >(projectId, async (tx) =>
28 tx.unsafe(
29 `SELECT public_metadata, private_metadata
30 FROM "_briven_auth_user_metadata"
31 WHERE user_id = $1
32 LIMIT 1`,
33 [userId] as never,
34 ) as never,
35 );
36 const row = rows[0];
37 if (!row) return { publicMetadata: {}, privateMetadata: {} };
38 return {
39 publicMetadata: (row.public_metadata as Record<string, unknown>) ?? {},
40 privateMetadata: (row.private_metadata as Record<string, unknown>) ?? {},
41 };
42}
43
44/**
45 * Get ONLY the public metadata for a user. Safe to expose via the
46 * unauthenticated / lightly-authenticated frontend surface.
47 */
48export async function getUserPublicMetadata(
49 projectId: string,
50 userId: string,
51): Promise<Record<string, unknown>> {
52 const rows = await runInProjectDatabase<Array<{ public_metadata: unknown }>>(
53 projectId,
54 async (tx) =>
55 tx.unsafe(
56 `SELECT public_metadata
57 FROM "_briven_auth_user_metadata"
58 WHERE user_id = $1
59 LIMIT 1`,
60 [userId] as never,
61 ) as never,
62 );
63 const row = rows[0];
64 return (row?.public_metadata as Record<string, unknown>) ?? {};
65}
66
67/**
68 * Set (replace) a user's metadata. Creates the row if absent.
69 * `publicMeta` and `privateMeta` are merged with existing values when
70 * `merge = true` (default), or replaced wholesale when `merge = false`.
71 */
72export async function setUserMetadata(
73 projectId: string,
74 userId: string,
75 patch: {
76 publicMetadata?: Record<string, unknown>;
77 privateMetadata?: Record<string, unknown>;
78 },
79 opts: { merge?: boolean } = {},
80): Promise<UserMetadata> {
81 const merge = opts.merge ?? true;
82
83 return runInProjectDatabase<UserMetadata>(projectId, async (tx) => {
84 // Probe for existing row.
85 const existing = await tx.unsafe(
86 `SELECT public_metadata, private_metadata
87 FROM "_briven_auth_user_metadata"
88 WHERE user_id = $1
89 LIMIT 1`,
90 [userId] as never,
91 );
92
93 const current = (existing as Array<{ public_metadata: unknown; private_metadata: unknown }>)[0];
94
95 const nextPublic = merge
96 ? { ...((current?.public_metadata as Record<string, unknown>) ?? {}), ...(patch.publicMetadata ?? {}) }
97 : (patch.publicMetadata ?? (current?.public_metadata as Record<string, unknown>) ?? {});
98
99 const nextPrivate = merge
100 ? { ...((current?.private_metadata as Record<string, unknown>) ?? {}), ...(patch.privateMetadata ?? {}) }
101 : (patch.privateMetadata ?? (current?.private_metadata as Record<string, unknown>) ?? {});
102
103 if (current) {
104 await tx.unsafe(
105 `UPDATE "_briven_auth_user_metadata"
106 SET public_metadata = $2::jsonb, private_metadata = $3::jsonb, updated_at = now()
107 WHERE user_id = $1`,
108 [userId, JSON.stringify(nextPublic), JSON.stringify(nextPrivate)] as never,
109 );
110 } else {
111 await tx.unsafe(
112 `INSERT INTO "_briven_auth_user_metadata" (id, user_id, public_metadata, private_metadata, created_at, updated_at)
113 VALUES (gen_random_uuid()::text, $1, $2::jsonb, $3::jsonb, now(), now())`,
114 [userId, JSON.stringify(nextPublic), JSON.stringify(nextPrivate)] as never,
115 );
116 }
117
118 return { publicMetadata: nextPublic, privateMetadata: nextPrivate };
119 });
120}
121
122/**
123 * Delete a user's metadata row. Idempotent — no-op if no row exists.
124 */
125export async function deleteUserMetadata(projectId: string, userId: string): Promise<void> {
126 await runInProjectDatabase(projectId, async (tx) => {
127 await tx.unsafe(
128 `DELETE FROM "_briven_auth_user_metadata" WHERE user_id = $1`,
129 [userId] as never,
130 );
131 });
132}