Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
384 changes: 384 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ import type { KnipConfig } from "knip";
* @see https://knip.dev/overview/configuration
*/
const knipConfig: KnipConfig = {
entry: ["src/server.ts", "src/scripts/**/*.ts"],
entry: ["src/server.ts", "src/scripts/**/*.ts", "src/**/*.test.ts"],
project: ["src/**/*.ts"],
ignore: ["src/lib/config/drizzle.config.ts"],
ignoreDependencies: ["drizzle-kit"],
// Honor `@knipignore` JSDoc tags on intentionally-retained exports
// (boot-time providers, public auth types) with no in-repo importer
tags: ["-knipignore"],
};

export default knipConfig;
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,26 @@
"db:migrate": "bun --env-file .env.local drizzle-kit migrate --config src/lib/config/drizzle.config.ts",
"db:push": "bun --env-file .env.local drizzle-kit push --config src/lib/config/drizzle.config.ts",
"db:studio": "bun --env-file .env.local drizzle-kit studio --config src/lib/config/drizzle.config.ts",
"graphql:generate": "GRAPHILE_ENV=development bun run --env-file .env.local src/scripts/generateGraphqlSchema.ts",
"prepare": "husky",
"knip": "knip-bun"
},
"dependencies": {
"@elysiajs/cors": "^1.4.1",
"@elysiajs/graphql-yoga": "^1.4.0",
"@graphile/simplify-inflection": "^8.0.0",
"@graphql-yoga/plugin-disable-introspection": "^2.19.0",
"@omnidotdev/providers": "github:omnidotdev/providers#49f2fc4",
"drizzle-orm": "^0.45.1",
"elysia": "^1.4.21",
"elysia-rate-limit": "^4.5.0",
"grafast": "^1.0.2",
"graphql": "^16.12.0",
"graphql-yoga": "^5.18.0",
"jose": "^6.1.3",
"pg": "^8.16.3",
"postgraphile": "^5.0.3",
"postgraphile-plugin-connection-filter": "^3.0.1",
"unleash-client": "^6.9.6"
},
"devDependencies": {
Expand Down
154 changes: 154 additions & 0 deletions src/generated/graphql/schema.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""
The root query type which gives access points into the data universe.
"""
type Query implements Node {
"""
Exposes the root query type nested one level down. This is helpful for Relay 1
which can only query top level fields if they are in a particular form.
"""
query: Query!

"""
The root query type must be a `Node` to work well with Relay 1 mutations. This just resolves to `query`.
"""
id: ID!

"""
Fetches an object given its globally unique `ID`.
"""
node(
"""
The globally unique `ID`.
"""
id: ID!
): Node

"""
The currently authenticated user. Returns null if not authenticated.
"""
observer: Observer
}

"""
An object with a globally unique `ID`.
"""
interface Node {
"""
A globally unique identifier. Can be used in various places throughout the system to identify this single value.
"""
id: ID!
}

"""
The root mutation type which contains root level fields which mutate data.
"""
type Mutation {
updatePreferences(
"""
The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields.
"""
input: UpdatePreferencesInput!
): UserPreferences!
createGatewaySession: GatewaySession!
pushMemories(
"""
The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields.
"""
input: [PushMemoryInput!]!
): PushMemoriesResult!
deleteMemory(gatewayMemoryId: String!): Boolean!
updateMemory(gatewayMemoryId: String!, pinned: Boolean): Memory!
}

type Observer {
id: ID!
email: String
name: String
avatarUrl: String
subscription: BillingSubscription
preferences: UserPreferences
memories(category: String, limit: Int): [Memory!]!
memoriesSince(since: String!, deviceId: String!): MemorySyncPayload!
}

type BillingSubscription {
id: ID!
plan: Plan!
status: SubscriptionStatus!
creditsRemaining: Int
}

enum Plan {
FREE
PRO
TEAM
}

enum SubscriptionStatus {
ACTIVE
CANCELED
PAST_DUE
}

type UserPreferences {
id: ID!
defaultPersona: String!
theme: String!
voiceEnabled: Boolean!
}

input UpdatePreferencesInput {
defaultPersona: String
theme: String
voiceEnabled: Boolean
}

type GatewaySession {
sessionId: String!
websocketUrl: String!
expiresAt: String!
}

type Memory {
id: ID!
gatewayMemoryId: String!
category: String!
content: String!
contentHash: String!
tags: String!
pinned: Boolean!
accessCount: Int!
sourceSessionId: String
sourceChannel: String
originDeviceId: String
createdAt: String!
updatedAt: String!
deletedAt: String
}

type MemorySyncPayload {
memories: [Memory!]!
cursor: String!
hasMore: Boolean!
}

input PushMemoryInput {
gatewayMemoryId: String!
category: String!
content: String!
tags: String
pinned: Boolean
accessCount: Int
sourceSessionId: String
sourceChannel: String
originDeviceId: String
createdAt: String!
updatedAt: String!
deletedAt: String
}

type PushMemoriesResult {
pushed: Int!
updated: Int!
duplicates: Int!
}
30 changes: 30 additions & 0 deletions src/lib/auth/jwt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from "bun:test";

import { extractBearerToken } from "./jwt";

describe("extractBearerToken", () => {
it("extracts the token from a well-formed Bearer header", () => {
expect(extractBearerToken("Bearer abc.def.ghi")).toBe("abc.def.ghi");
});

it("returns null when the header is missing", () => {
expect(extractBearerToken(null)).toBeNull();
});

it("returns null for a non-Bearer scheme", () => {
expect(extractBearerToken("Basic abc.def.ghi")).toBeNull();
});

it("is case-sensitive on the scheme", () => {
expect(extractBearerToken("bearer abc")).toBeNull();
});

it("returns an empty string when the scheme has no token", () => {
// "Bearer " with nothing after it slices to ""; downstream verification rejects it
expect(extractBearerToken("Bearer ")).toBe("");
});

it("preserves tokens containing spaces after the scheme", () => {
expect(extractBearerToken("Bearer a b c")).toBe("a b c");
});
});
33 changes: 33 additions & 0 deletions src/lib/config/graphile.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { PgSimplifyInflectionPreset } from "@graphile/simplify-inflection";
import { makePgService } from "postgraphile/adaptors/pg";
import { PostGraphileAmberPreset } from "postgraphile/presets/amber";
import { PostGraphileConnectionFilterPreset } from "postgraphile-plugin-connection-filter";
import BeaconPlugin from "../graphql/plugins/beacon.plugin";
import OmitTablesPlugin from "../graphql/plugins/omitTables.plugin";
import { env } from "./env";

/**
* Graphile preset. The base schema is generated database-first from the
* Postgres tables; beacon's custom sync/viewer behavior is layered on via
* BeaconPlugin (never a hand-written executable schema).
* @see https://postgraphile.org
*/
const graphilePreset: GraphileConfig.Preset = {
extends: [
PostGraphileAmberPreset,
PgSimplifyInflectionPreset,
PostGraphileConnectionFilterPreset,
],
plugins: [OmitTablesPlugin, BeaconPlugin],
schema: {
retryOnInitFail: env.nodeEnv === "production",
sortExport: true,
pgForbidSetofFunctionsToReturnNull: true,
connectionFilterAllowNullInput: true,
connectionFilterAllowEmptyObjectInput: true,
},
pgServices: [makePgService({ connectionString: env.databaseUrl })],
grafast: { explain: env.nodeEnv === "development" },
};

export default graphilePreset;
4 changes: 2 additions & 2 deletions src/lib/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";

const pool = new Pool({
export const pgPool = new Pool({
connectionString: process.env.DATABASE_URL,
});

export const db = drizzle(pool, { schema });
export const db = drizzle(pgPool, { schema });

export * from "./schema";
46 changes: 34 additions & 12 deletions src/lib/graphql/context.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,48 @@
import { eq } from "drizzle-orm";
import { createWithPgClient } from "postgraphile/adaptors/pg";
import { extractBearerToken, verifyToken } from "../auth/jwt";
import { db, users } from "../db";

export interface GraphQLContext {
observer: {
id: string;
email: string | null;
name: string | null;
avatarUrl: string | null;
identityProviderId: string;
} | null;
import { db, pgPool, users } from "../db";

export interface Observer {
id: string;
email: string | null;
name: string | null;
avatarUrl: string | null;
identityProviderId: string;
}

interface GraphQLContext {
observer: Observer | null;
/** Drizzle client, read by custom Grafast plans via `context().get("db")`. */
db: typeof db;
/** Postgres client factory required by Postgraphile's pg plans. */
withPgClient: ReturnType<typeof createWithPgClient>;
}

// Grafast context augmentation (read within plan resolvers).
// See https://grafast.org/grafast/step-library/standard-steps/context
declare global {
namespace Grafast {
interface Context {
observer: Observer | null;
db: typeof db;
}
}
}

const withPgClient = createWithPgClient({ pool: pgPool });

export async function createContext(request: Request): Promise<GraphQLContext> {
const authHeader = request.headers.get("authorization");
const token = extractBearerToken(authHeader);

if (!token) {
return { observer: null };
return { observer: null, db, withPgClient };
}

const payload = await verifyToken(token);
if (!payload) {
return { observer: null };
return { observer: null, db, withPgClient };
}

// Find or create user
Expand Down Expand Up @@ -52,5 +72,7 @@ export async function createContext(request: Request): Promise<GraphQLContext> {
avatarUrl: user.avatarUrl ?? null,
identityProviderId: payload.sub,
},
db,
withPgClient,
};
}
Loading
Loading