diff --git a/.changeset/icy-poems-wear.md b/.changeset/icy-poems-wear.md new file mode 100644 index 0000000..476ff39 --- /dev/null +++ b/.changeset/icy-poems-wear.md @@ -0,0 +1,6 @@ +--- +'@tern-secure/backend': patch +'@tern-secure/nextjs': patch +--- + +feat: add Postgres sync functions and update package configurations diff --git a/packages/auth/src/instance/ternsecure.ts b/packages/auth/src/instance/ternsecure.ts index dff236d..886f1bd 100644 --- a/packages/auth/src/instance/ternsecure.ts +++ b/packages/auth/src/instance/ternsecure.ts @@ -152,7 +152,6 @@ export class TernSecureAuth implements TernSecureAuthInterface { } this.#options = this.#initOptions(options); - console.debug('[TernSecureAuth] Loading with options:', this.#options); if (this.#options.sdkMetadata) { TernSecureAuth.sdkMetadata = this.#options.sdkMetadata; diff --git a/packages/auth/src/ui/components/sign-up/SignUpStart.tsx b/packages/auth/src/ui/components/sign-up/SignUpStart.tsx index 47eb1cf..8447e87 100644 --- a/packages/auth/src/ui/components/sign-up/SignUpStart.tsx +++ b/packages/auth/src/ui/components/sign-up/SignUpStart.tsx @@ -2,8 +2,7 @@ import { useTernSecure } from '@tern-secure/shared/react'; import type { SignUpFormValues } from '@tern-secure/types'; import { cn } from '../../../lib/utils'; -import { useTernSecureOptions } from '../../ctx'; -import { useAuthSignUp, useSignUpContext } from '../../ctx'; +import { useAuthSignUp, useSignUpContext,useTernSecureOptions } from '../../ctx'; import { Alert, AlertDescription, diff --git a/packages/backend/functions/package.json b/packages/backend/functions/package.json new file mode 100644 index 0000000..6499c7b --- /dev/null +++ b/packages/backend/functions/package.json @@ -0,0 +1,5 @@ +{ + "main": "../dist/functions/index.js", + "module": "../dist/functions/index.mjs", + "types": "../dist/functions/index.d.ts" +} diff --git a/packages/backend/package.json b/packages/backend/package.json index b5b97e2..d84095e 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -81,6 +81,16 @@ "default": "./dist/jwt/index.js" } }, + "./functions": { + "import": { + "types": "./dist/functions/index.d.ts", + "default": "./dist/functions/index.mjs" + }, + "require": { + "types": "./dist/functions/index.d.ts", + "default": "./dist/functions/index.js" + } + }, "./package.json": "./package.json" }, "main": "./dist/index.js", @@ -96,7 +106,6 @@ "dependencies": { "@tern-secure/shared": "workspace:*", "@tern-secure/types": "workspace:*", - "@upstash/redis": "^1.35.2", "cookie": "1.0.2", "jose": "^5.10.0", "tslib": "catalog:repo" @@ -107,7 +116,9 @@ "vitest-environment-miniflare": "2.14.4" }, "peerDependencies": { - "firebase-admin": "catalog:peer-firebase" + "@upstash/redis": "^1.35.2", + "firebase-admin": "catalog:peer-firebase", + "firebase-functions": "^6.1.2" }, "engines": { "node": ">=20" diff --git a/packages/backend/src/functions/index.ts b/packages/backend/src/functions/index.ts new file mode 100644 index 0000000..58e1d41 --- /dev/null +++ b/packages/backend/src/functions/index.ts @@ -0,0 +1,179 @@ +import type { UserRecord } from 'firebase-admin/auth'; +import * as functions from 'firebase-functions/v1'; + +export interface PostgresSyncOptions { + /** + * A function that executes a SQL query. + * Compatible with 'pg' Pool.query or Client.query. + * You can pass `pool.query.bind(pool)` here. + */ + query: (text: string, params?: any[]) => Promise; + + /** + * The name of the table to insert users into. + * @example 'users' + * @example 'public.profiles' + */ + tableName: string; + + /** + * Map Firebase UserRecord fields to your database columns. + * Key: Firebase field (e.g., 'uid', 'email', 'displayName', 'photoURL') + * Value: Database column name + */ + fieldMapping: Partial>; + + /** + * Optional: Add extra static values or computed values. + * @example { role: 'user', created_at: new Date() } + */ + extraFields?: (user: UserRecord) => Record; + + /** + * Optional: Callback to run after successful sync + */ + onSuccess?: (user: UserRecord) => Promise; + + /** + * Optional: Callback to run on error + */ + onError?: (error: any, user: UserRecord) => Promise; +} + +/** + * Creates a Firebase Authentication Trigger that syncs new users to a Postgres database. + * + * @example + * export const syncUser = createPostgresSync({ + * query: pool.query.bind(pool), + * tableName: 'users', + * fieldMapping: { + * uid: 'id', + * email: 'email', + * displayName: 'full_name' + * } + * }); + */ +export const createPostgresSync = (options: PostgresSyncOptions) => { + return functions.auth.user().onCreate(async (user) => { + const { query, tableName, fieldMapping, extraFields, onSuccess, onError } = options; + + try { + const columns: string[] = []; + const values: any[] = []; + const placeholders: string[] = []; + + // Handle mapped fields + Object.entries(fieldMapping).forEach(([userField, dbColumn]) => { + if (dbColumn) { + columns.push(dbColumn); + values.push((user as any)[userField]); + placeholders.push(`$${values.length}`); + } + }); + + // Handle extra fields + if (extraFields) { + const extras = extraFields(user); + Object.entries(extras).forEach(([column, value]) => { + columns.push(column); + values.push(value); + placeholders.push(`$${values.length}`); + }); + } + + if (columns.length === 0) { + console.warn('createPostgresSync: No fields mapped for insertion.'); + return; + } + + const queryText = ` + INSERT INTO ${tableName} (${columns.join(', ')}) + VALUES (${placeholders.join(', ')}) + ON CONFLICT DO NOTHING + `; + + await query(queryText, values); + + if (onSuccess) { + await onSuccess(user); + } + } catch (error) { + console.error('createPostgresSync: Failed to sync user', error); + if (onError) { + await onError(error, user); + } else { + throw error; + } + } + }); +}; + +export interface GenericSyncOptions { + /** + * Map Firebase UserRecord fields to your database columns/fields. + * Key: Firebase field (e.g., 'uid', 'email') + * Value: Your database field name + */ + fieldMapping: Partial>; + + /** + * Optional: Add extra static values or computed values. + */ + extraFields?: (user: UserRecord) => Record; + + /** + * Function to save the mapped data to your database. + * Receives a plain object with the mapped keys and values. + */ + syncFn: (data: Record, user: UserRecord) => Promise; + + /** + * Optional: Callback to run on error + */ + onError?: (error: any, user: UserRecord) => Promise; +} + +/** + * Creates a generic Firebase Authentication Trigger for syncing users to any database (Prisma, Drizzle, Mongo, etc). + * + * @example + * export const syncUser = createGenericSync({ + * fieldMapping: { uid: 'id', email: 'email' }, + * syncFn: async (data) => { + * await prisma.user.create({ data }); + * } + * }); + */ +export const createGenericSync = (options: GenericSyncOptions) => { + return functions.auth.user().onCreate(async (user) => { + const { fieldMapping, extraFields, syncFn, onError } = options; + + try { + const data: Record = {}; + + // Handle mapped fields + Object.entries(fieldMapping).forEach(([userField, dbField]) => { + if (dbField) { + data[dbField] = (user as any)[userField]; + } + }); + + // Handle extra fields + if (extraFields) { + const extras = extraFields(user); + Object.assign(data, extras); + } + + await syncFn(data, user); + + } catch (error) { + console.error('createGenericSync: Failed to sync user', error); + if (onError) { + await onError(error, user); + } else { + throw error; + } + } + }); +}; diff --git a/packages/backend/tsup.config.ts b/packages/backend/tsup.config.ts index 4257e6a..29c3ef3 100644 --- a/packages/backend/tsup.config.ts +++ b/packages/backend/tsup.config.ts @@ -5,7 +5,7 @@ import { runAfterLast } from '../../scripts/utils'; import { name, version } from "./package.json"; const config: Options = { - entry: ['src/index.ts', 'src/admin/index.ts', 'src/auth/index.ts', 'src/jwt/index.ts', 'src/app-check/index.ts'], + entry: ['src/index.ts', 'src/admin/index.ts', 'src/auth/index.ts', 'src/jwt/index.ts', 'src/app-check/index.ts', 'src/functions/index.ts'], onSuccess: `cpy 'src/runtime/**/*.{mjs,js,cjs}' dist/runtime`, bundle: true, sourcemap: true, diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index d88a175..0c7ac2f 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -77,7 +77,6 @@ "tslib": "catalog:repo" }, "peerDependencies": { - "@upstash/redis": "^1.35.2", "firebase": "catalog:peer-firebase", "next": "^13.5.7 || ^14.2.25 || ^15.2.3 || ^16", "react": "catalog:peer-react", diff --git a/packages/nextjs/src/utils/redis.ts b/packages/nextjs/src/utils/redis.ts deleted file mode 100644 index 2cd64b3..0000000 --- a/packages/nextjs/src/utils/redis.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Redis } from "@upstash/redis" - -export const redis = new Redis({ - url: process.env.KV_REST_API_URL, - token: process.env.KV_REST_API_TOKEN, -}) - -export interface DisabledUserRecord { - uid: string - email: string - disabledTime: string -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc25072..d29649f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -303,6 +303,9 @@ importers: firebase-admin: specifier: catalog:peer-firebase version: 12.7.0 + firebase-functions: + specifier: ^6.1.2 + version: 6.6.0(firebase-admin@12.7.0) jose: specifier: ^5.10.0 version: 5.10.0 @@ -334,9 +337,6 @@ importers: '@tern-secure/types': specifier: workspace:* version: link:../types - '@upstash/redis': - specifier: ^1.35.2 - version: 1.35.7 firebase: specifier: catalog:peer-firebase version: 12.6.0 @@ -4390,6 +4390,13 @@ packages: resolution: {integrity: sha512-raFIrOyTqREbyXsNkSHyciQLfv8AUZazehPaQS1lZBSCDYW74FYXU0nQZa3qHI4K+hawohlDbywZ4+qce9YNxA==} engines: {node: '>=14'} + firebase-functions@6.6.0: + resolution: {integrity: sha512-wwfo6JF+N7HUExVs5gUFgkgVGHDEog9O+qtouh7IuJWk8TBQ+KwXEgRiXbatSj7EbTu3/yYnHuzh3XExbfF6wQ==} + engines: {node: '>=14.10.0'} + hasBin: true + peerDependencies: + firebase-admin: ^11.10.0 || ^12.0.0 || ^13.0.0 + firebase@12.6.0: resolution: {integrity: sha512-8ZD1Gcv916Qp8/nsFH2+QMIrfX/76ti6cJwxQUENLXXnKlOX/IJZaU2Y3bdYf5r1mbownrQKfnWtrt+MVgdwLA==} @@ -11535,6 +11542,17 @@ snapshots: - encoding - supports-color + firebase-functions@6.6.0(firebase-admin@12.7.0): + dependencies: + '@types/cors': 2.8.19 + '@types/express': 4.17.25 + cors: 2.8.5 + express: 4.21.2 + firebase-admin: 12.7.0 + protobufjs: 7.5.4 + transitivePeerDependencies: + - supports-color + firebase@12.6.0: dependencies: '@firebase/ai': 2.6.0(@firebase/app-types@0.9.3)(@firebase/app@0.14.6)