cairn work

This commit is contained in:
Michael Freno
2026-01-21 01:56:40 -05:00
parent 0d006e8260
commit 5fc082178c
12 changed files with 1716 additions and 4 deletions

View File

@@ -11,6 +11,8 @@ import { postHistoryRouter } from "./routers/post-history";
import { infillRouter } from "./routers/infill";
import { accountRouter } from "./routers/account";
import { downloadsRouter } from "./routers/downloads";
import { remoteDbRouter } from "./routers/remote-db";
import { appleNotificationsRouter } from "./routers/apple-notifications";
import { createTRPCRouter, createTRPCContext } from "./utils";
import type { H3Event } from "h3";
@@ -27,7 +29,9 @@ export const appRouter = createTRPCRouter({
postHistory: postHistoryRouter,
infill: infillRouter,
account: accountRouter,
downloads: downloadsRouter
downloads: downloadsRouter,
remoteDb: remoteDbRouter,
appleNotifications: appleNotificationsRouter
});
export type AppRouter = typeof appRouter;

View File

@@ -0,0 +1,34 @@
import { describe, it, expect, vi } from "vitest";
import { appRouter } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/utils";
vi.mock("~/server/apple-notification", () => ({
verifyAppleNotification: async () => ({
notification_type: "consent-revoked",
sub: "apple-sub",
email: "test@apple.com",
event_time: Date.now()
})
}));
vi.mock("~/server/apple-notification-store", () => ({
storeAppleNotificationUser: async () => undefined
}));
vi.mock("~/server/session-helpers", () => ({
getAuthSession: async () => ({ userId: "admin", isAdmin: true })
}));
describe("apple notification router", () => {
it("verifies and stores notifications", async () => {
const caller = appRouter.createCaller(
await createTRPCContext({ nativeEvent: { node: { req: {} } } } as any)
);
const result = await caller.appleNotifications.verifyAndStore.mutate({
signedPayload: "test"
});
expect(result.success).toBe(true);
});
});

View File

@@ -0,0 +1,14 @@
import { createTRPCRouter, adminProcedure } from "../utils";
import { z } from "zod";
import { verifyAppleNotification } from "~/server/apple-notification";
import { storeAppleNotificationUser } from "~/server/apple-notification-store";
export const appleNotificationsRouter = createTRPCRouter({
verifyAndStore: adminProcedure
.input(z.record(z.unknown()))
.mutation(async ({ input }) => {
const notification = await verifyAppleNotification(input);
await storeAppleNotificationUser(notification);
return { success: true };
})
});

View File

@@ -0,0 +1,33 @@
import { describe, it, expect, vi } from "vitest";
import { appRouter } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/utils";
vi.mock("~/server/database", () => ({
CairnConnectionFactory: () => ({
execute: async () => ({ rows: [{ id: "1", email: "test@cairn.app" }] })
})
}));
vi.mock("~/server/cache", () => ({
cache: {
get: async () => null,
set: async () => undefined
}
}));
vi.mock("~/server/session-helpers", () => ({
getAuthSession: async () => ({ userId: "admin", isAdmin: true })
}));
describe("remoteDb router", () => {
it("returns users from remote database", async () => {
const caller = appRouter.createCaller(
await createTRPCContext({ nativeEvent: { node: { req: {} } } } as any)
);
const result = await caller.remoteDb.getUsers.query({ limit: 1, offset: 0 });
expect(result.users.length).toBe(1);
expect(result.users[0].email).toBe("test@cairn.app");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -4,11 +4,13 @@ import { getCookie } from "vinxi/http";
import { logVisit, enrichAnalyticsEntry } from "~/server/analytics";
import { getRequestIP } from "vinxi/http";
import { getAuthSession } from "~/server/session-helpers";
import { verifyCairnToken } from "~/server/cairn-auth";
export type Context = {
event: APIEvent;
userId: string | null;
isAdmin: boolean;
cairnUserId: string | null;
};
async function createContextInner(event: APIEvent): Promise<Context> {
@@ -37,6 +39,22 @@ async function createContextInner(event: APIEvent): Promise<Context> {
undefined;
const ipAddress = getRequestIP(event.nativeEvent) || undefined;
const sessionId = getCookie(event.nativeEvent, "session_id") || undefined;
const authHeader =
event.request?.headers?.get("authorization") ||
req.headers?.authorization ||
req.headers?.Authorization ||
null;
let cairnUserId: string | null = null;
if (authHeader && authHeader.startsWith("Bearer ")) {
const token = authHeader.replace("Bearer ", "").trim();
try {
const payload = await verifyCairnToken(token);
cairnUserId = payload.sub;
} catch (error) {
console.error("Cairn JWT verification failed:", error);
}
}
// Don't log the performance logging endpoint itself to avoid circular tracking
if (!path.includes("analytics.logPerformance")) {
@@ -56,7 +74,8 @@ async function createContextInner(event: APIEvent): Promise<Context> {
return {
event,
userId,
isAdmin
isAdmin,
cairnUserId
};
}
@@ -96,5 +115,21 @@ const enforceUserIsAdmin = t.middleware(({ ctx, next }) => {
});
});
const enforceCairnUser = t.middleware(({ ctx, next }) => {
if (!ctx.cairnUserId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Cairn authentication required"
});
}
return next({
ctx: {
...ctx,
cairnUserId: ctx.cairnUserId
}
});
});
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
export const adminProcedure = t.procedure.use(enforceUserIsAdmin);
export const cairnProcedure = t.procedure.use(enforceCairnUser);

View File

@@ -0,0 +1,82 @@
import { ConnectionFactory } from "~/server/utils";
import type { AppleNotification } from "~/server/apple-notification";
import { TRPCError } from "@trpc/server";
import { linkProvider } from "~/server/provider-helpers";
export async function storeAppleNotificationUser(
notification: AppleNotification
): Promise<void> {
const conn = ConnectionFactory();
const { sub, email } = notification;
if (!sub) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Missing user identifier"
});
}
const existingByApple = await conn.execute({
sql: "SELECT * FROM User WHERE apple_user_string = ?",
args: [sub]
});
if (existingByApple.rows.length > 0) {
await conn.execute({
sql: "UPDATE User SET email = COALESCE(?, email), provider = ?, apple_user_string = ? WHERE id = ?",
args: [email ?? null, "apple", sub, (existingByApple.rows[0] as any).id]
});
await ensureAppleProvider((existingByApple.rows[0] as any).id, sub, email);
return;
}
if (email) {
const existingByEmail = await conn.execute({
sql: "SELECT * FROM User WHERE email = ?",
args: [email]
});
if (existingByEmail.rows.length > 0) {
const userId = (existingByEmail.rows[0] as any).id as string;
await conn.execute({
sql: "UPDATE User SET provider = ?, apple_user_string = ? WHERE id = ?",
args: ["apple", sub, userId]
});
await ensureAppleProvider(userId, sub, email);
return;
}
}
const userId = crypto.randomUUID();
await conn.execute({
sql: "INSERT INTO User (id, email, email_verified, provider, apple_user_string) VALUES (?, ?, ?, ?, ?)",
args: [userId, email ?? null, email ? 1 : 0, "apple", sub]
});
await ensureAppleProvider(userId, sub, email ?? undefined);
}
async function ensureAppleProvider(
userId: string,
sub: string,
email?: string
) {
try {
await linkProvider(
userId,
"apple",
{
providerUserId: sub,
email: email
},
{
sendEmail: false
}
);
} catch (error) {
if (error instanceof Error && error.message.includes("already linked")) {
return;
}
throw error;
}
}

View File

@@ -0,0 +1,34 @@
import { createRemoteJWKSet, jwtVerify } from "jose";
const APPLE_JWKS = new URL("https://appleid.apple.com/auth/keys");
const appleJwks = createRemoteJWKSet(APPLE_JWKS);
export type AppleNotification = {
notification_type: string;
sub: string;
email?: string;
event_time: number;
payload?: Record<string, unknown>;
};
export async function verifyAppleNotification(
payload: Record<string, unknown>
): Promise<AppleNotification> {
const signedPayload = payload.signedPayload;
if (!signedPayload || typeof signedPayload !== "string") {
throw new Error("Missing signedPayload");
}
const { payload: decoded } = await jwtVerify(signedPayload, appleJwks, {
issuer: "https://appleid.apple.com"
});
return {
notification_type: decoded.notification_type as string,
sub: decoded.sub as string,
email: decoded.email as string | undefined,
event_time: decoded.event_time as number,
payload: decoded as Record<string, unknown>
};
}

23
src/server/cairn-auth.ts Normal file
View File

@@ -0,0 +1,23 @@
import { jwtVerify } from "jose";
import { env } from "~/env/server";
export type CairnAuthPayload = {
sub: string;
exp?: number;
iat?: number;
};
export async function verifyCairnToken(
token: string
): Promise<CairnAuthPayload> {
const secret = new TextEncoder().encode(env.CAIRN_JWT_SECRET);
const { payload } = await jwtVerify(token, secret, {
algorithms: ["HS256"]
});
return {
sub: payload.sub as string,
exp: payload.exp as number | undefined,
iat: payload.iat as number | undefined
};
}

View File

@@ -14,6 +14,7 @@ import {
let mainDBConnection: ReturnType<typeof createClient> | null = null;
let lineageDBConnection: ReturnType<typeof createClient> | null = null;
let cairnDBConnection: ReturnType<typeof createClient> | null = null;
export function ConnectionFactory() {
if (!mainDBConnection) {
@@ -37,6 +38,17 @@ export function LineageConnectionFactory() {
return lineageDBConnection;
}
export function CairnConnectionFactory() {
if (!cairnDBConnection) {
const config = {
url: env.CAIRN_DB_URL,
authToken: env.CAIRN_DB_TOKEN
};
cairnDBConnection = createClient(config);
}
return cairnDBConnection;
}
export async function LineageDBInit() {
const turso = createAPIClient({
org: "mikefreno",