cairn work
This commit is contained in:
6
src/env/server.ts
vendored
6
src/env/server.ts
vendored
@@ -32,7 +32,8 @@ const serverEnvSchema = z.object({
|
|||||||
INFILL_BEARER_TOKEN: z.string().min(1),
|
INFILL_BEARER_TOKEN: z.string().min(1),
|
||||||
REDIS_URL: z.string().min(1),
|
REDIS_URL: z.string().min(1),
|
||||||
CAIRN_DB_URL: z.string().min(1),
|
CAIRN_DB_URL: z.string().min(1),
|
||||||
CAIRN_DB_TOKEN: z.string().min(1)
|
CAIRN_DB_TOKEN: z.string().min(1),
|
||||||
|
CAIRN_JWT_SECRET: z.string().min(1)
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ServerEnv = z.infer<typeof serverEnvSchema>;
|
export type ServerEnv = z.infer<typeof serverEnvSchema>;
|
||||||
@@ -137,7 +138,8 @@ export const getMissingEnvVars = (): string[] => {
|
|||||||
"VITE_WEBSOCKET",
|
"VITE_WEBSOCKET",
|
||||||
"REDIS_URL",
|
"REDIS_URL",
|
||||||
"CAIRN_DB_URL",
|
"CAIRN_DB_URL",
|
||||||
"CAIRN_DB_TOKEN"
|
"CAIRN_DB_TOKEN",
|
||||||
|
"CAIRN_JWT_SECRET"
|
||||||
];
|
];
|
||||||
|
|
||||||
return requiredServerVars.filter((varName) => isMissingEnvVar(varName));
|
return requiredServerVars.filter((varName) => isMissingEnvVar(varName));
|
||||||
|
|||||||
25
src/routes/auth/apple/notifications.ts
Normal file
25
src/routes/auth/apple/notifications.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
|
import { verifyAppleNotification } from "~/server/apple-notification";
|
||||||
|
import { storeAppleNotificationUser } from "~/server/apple-notification-store";
|
||||||
|
|
||||||
|
export async function POST(event: APIEvent) {
|
||||||
|
const contentType = event.request.headers.get("content-type") || "";
|
||||||
|
|
||||||
|
if (!contentType.includes("application/json")) {
|
||||||
|
return new Response("Unsupported content type", { status: 415 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await event.request.json();
|
||||||
|
const notification = await verifyAppleNotification(payload);
|
||||||
|
await storeAppleNotificationUser(notification);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Apple notification error:", error);
|
||||||
|
return new Response("Notification processing failed", { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import { postHistoryRouter } from "./routers/post-history";
|
|||||||
import { infillRouter } from "./routers/infill";
|
import { infillRouter } from "./routers/infill";
|
||||||
import { accountRouter } from "./routers/account";
|
import { accountRouter } from "./routers/account";
|
||||||
import { downloadsRouter } from "./routers/downloads";
|
import { downloadsRouter } from "./routers/downloads";
|
||||||
|
import { remoteDbRouter } from "./routers/remote-db";
|
||||||
|
import { appleNotificationsRouter } from "./routers/apple-notifications";
|
||||||
import { createTRPCRouter, createTRPCContext } from "./utils";
|
import { createTRPCRouter, createTRPCContext } from "./utils";
|
||||||
import type { H3Event } from "h3";
|
import type { H3Event } from "h3";
|
||||||
|
|
||||||
@@ -27,7 +29,9 @@ export const appRouter = createTRPCRouter({
|
|||||||
postHistory: postHistoryRouter,
|
postHistory: postHistoryRouter,
|
||||||
infill: infillRouter,
|
infill: infillRouter,
|
||||||
account: accountRouter,
|
account: accountRouter,
|
||||||
downloads: downloadsRouter
|
downloads: downloadsRouter,
|
||||||
|
remoteDb: remoteDbRouter,
|
||||||
|
appleNotifications: appleNotificationsRouter
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
34
src/server/api/routers/apple-notifications.test.ts
Normal file
34
src/server/api/routers/apple-notifications.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
14
src/server/api/routers/apple-notifications.ts
Normal file
14
src/server/api/routers/apple-notifications.ts
Normal 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 };
|
||||||
|
})
|
||||||
|
});
|
||||||
33
src/server/api/routers/remote-db.test.ts
Normal file
33
src/server/api/routers/remote-db.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
1414
src/server/api/routers/remote-db.ts
Normal file
1414
src/server/api/routers/remote-db.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,11 +4,13 @@ import { getCookie } from "vinxi/http";
|
|||||||
import { logVisit, enrichAnalyticsEntry } from "~/server/analytics";
|
import { logVisit, enrichAnalyticsEntry } from "~/server/analytics";
|
||||||
import { getRequestIP } from "vinxi/http";
|
import { getRequestIP } from "vinxi/http";
|
||||||
import { getAuthSession } from "~/server/session-helpers";
|
import { getAuthSession } from "~/server/session-helpers";
|
||||||
|
import { verifyCairnToken } from "~/server/cairn-auth";
|
||||||
|
|
||||||
export type Context = {
|
export type Context = {
|
||||||
event: APIEvent;
|
event: APIEvent;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
cairnUserId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function createContextInner(event: APIEvent): Promise<Context> {
|
async function createContextInner(event: APIEvent): Promise<Context> {
|
||||||
@@ -37,6 +39,22 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
|||||||
undefined;
|
undefined;
|
||||||
const ipAddress = getRequestIP(event.nativeEvent) || undefined;
|
const ipAddress = getRequestIP(event.nativeEvent) || undefined;
|
||||||
const sessionId = getCookie(event.nativeEvent, "session_id") || 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
|
// Don't log the performance logging endpoint itself to avoid circular tracking
|
||||||
if (!path.includes("analytics.logPerformance")) {
|
if (!path.includes("analytics.logPerformance")) {
|
||||||
@@ -56,7 +74,8 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
|||||||
return {
|
return {
|
||||||
event,
|
event,
|
||||||
userId,
|
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 protectedProcedure = t.procedure.use(enforceUserIsAuthed);
|
||||||
export const adminProcedure = t.procedure.use(enforceUserIsAdmin);
|
export const adminProcedure = t.procedure.use(enforceUserIsAdmin);
|
||||||
|
export const cairnProcedure = t.procedure.use(enforceCairnUser);
|
||||||
|
|||||||
82
src/server/apple-notification-store.ts
Normal file
82
src/server/apple-notification-store.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/server/apple-notification.ts
Normal file
34
src/server/apple-notification.ts
Normal 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
23
src/server/cairn-auth.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
|
|
||||||
let mainDBConnection: ReturnType<typeof createClient> | null = null;
|
let mainDBConnection: ReturnType<typeof createClient> | null = null;
|
||||||
let lineageDBConnection: ReturnType<typeof createClient> | null = null;
|
let lineageDBConnection: ReturnType<typeof createClient> | null = null;
|
||||||
|
let cairnDBConnection: ReturnType<typeof createClient> | null = null;
|
||||||
|
|
||||||
export function ConnectionFactory() {
|
export function ConnectionFactory() {
|
||||||
if (!mainDBConnection) {
|
if (!mainDBConnection) {
|
||||||
@@ -37,6 +38,17 @@ export function LineageConnectionFactory() {
|
|||||||
return lineageDBConnection;
|
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() {
|
export async function LineageDBInit() {
|
||||||
const turso = createAPIClient({
|
const turso = createAPIClient({
|
||||||
org: "mikefreno",
|
org: "mikefreno",
|
||||||
|
|||||||
Reference in New Issue
Block a user