diff --git a/.gitignore b/.gitignore index 80bd4648..21d45685 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ publish output # generated prisma client apps/server/prisma/generated/ +apps/server-new/prisma/generated/ # Logs logs diff --git a/apps/server-new/.env.example b/apps/server-new/.env.example new file mode 100644 index 00000000..ecd0561f --- /dev/null +++ b/apps/server-new/.env.example @@ -0,0 +1,16 @@ +# Server Configuration +PORT=3030 + +# Database +DATABASE_URL=file:./dev.db + +# Privy Configuration +PRIVY_APP_ID=your_privy_app_id +PRIVY_APP_SECRET=your_privy_app_secret + +# Hypergraph Configuration +HYPERGRAPH_CHAIN=geo-testnet +HYPERGRAPH_RPC_URL= + +# Honeycomb Configuration +HONEYCOMB_API_KEY= diff --git a/apps/server-new/CLAUDE.md b/apps/server-new/CLAUDE.md new file mode 100644 index 00000000..a9fec626 --- /dev/null +++ b/apps/server-new/CLAUDE.md @@ -0,0 +1,191 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +### Package Management +**Important**: Use pnpm for package management. +- `pnpm install` - Install dependencies +- `pnpm add ` - Add new dependency +- `pnpm remove ` - Remove dependency + +### Build and Run +- `pnpm dev` - Start development server with hot reload ⚠️ **DO NOT USE** - Never run dev mode during development +- `pnpm start` - Run the application in production mode +- `pnpm build` - Build the project for production (outputs to ./dist) + +**IMPORTANT**: Never run `pnpm dev` during development work. The development server should only be started by the user manually when they want to test the application. Use tests instead of running the dev server. + +### Code Quality +- `pnpm typecheck` - Run TypeScript type checking without emitting files +- `pnpm lint` - Run Biome on all TypeScript/JavaScript files +- `pnpm lint:fix` - Run Biome with automatic fixes on all files + +### Testing +- `pnpm test` - Run all tests once +- `pnpm test:watch` - Run tests in watch mode +- Uses Vitest with @effect/vitest for Effect-aware testing +- Test files: `test/**/*.test.ts` and `src/**/*.test.ts` + +### Database +- `pnpm prisma generate` - Generate Prisma client +- `pnpm prisma migrate dev` - Run database migrations in development +- `pnpm prisma studio` - Open Prisma Studio GUI + +**CRITICAL DEVELOPMENT RULE**: After EVERY file change, you MUST: +1. Run `pnpm lint:fix` immediately +2. Run `pnpm typecheck` immediately +3. Fix ALL lint errors and type errors before proceeding +4. Do NOT continue development until both commands pass without errors + +This is non-negotiable and applies to every single file modification. + +## Project Architecture + +### Technology Stack +- **Runtime**: Node.js with tsx for development +- **Language**: TypeScript with ES2022 target +- **Framework**: Effect Platform HTTP API +- **Database**: SQLite with Prisma ORM +- **Authentication**: Privy for external auth, custom session tokens for internal + +### Code Style +- Uses Biome for linting and formatting (monorepo configuration) +- Line width: 120 characters, 2-space indentation +- Single quotes for JavaScript/TypeScript + +### TypeScript Configuration +- Strict mode enabled +- Effect patterns preferred (Effect.fn over Effect.gen) +- No emit configuration (build handled by tsup) +- Path aliases configured: `server-new/*` maps to `./src/*` + +### Project Structure +- `src/` - Source code directory + - `config/` - Configuration modules + - `http/` - HTTP API definitions and handlers + - `services/` - Business logic services + - `domain/` - Domain models (Effect Schema) +- `prisma/` - Database schema and migrations +- `test/` - Test files +- `specs/` - Feature specifications +- `patterns/` - Implementation patterns documentation + +## Development Workflow - Spec-Driven Development + +This project follows a **spec-driven development** approach where every feature is thoroughly specified before implementation. + +**CRITICAL RULE: NEVER IMPLEMENT WITHOUT FOLLOWING THE COMPLETE SPEC FLOW** + +### Mandatory Workflow Steps + +**AUTHORIZATION PROTOCOL**: Before proceeding to any phase (2-5), you MUST: +1. Present the completed work from the current phase +2. Explicitly ask for user authorization to proceed +3. Wait for clear user approval before continuing +4. NEVER assume permission or proceed automatically + +### Phase-by-Phase Process + +**Phase 1**: Create `instructions.md` (initial requirements capture) +- Create feature folder and capture user requirements +- Document user stories, acceptance criteria, constraints + +**Phase 2**: Derive `requirements.md` from instructions - **REQUIRES USER APPROVAL** +- Structured analysis of functional/non-functional requirements +- STOP and ask for authorization before proceeding to Phase 3 + +**Phase 3**: Create `design.md` from requirements - **REQUIRES USER APPROVAL** +- Technical design and implementation strategy +- STOP and ask for authorization before proceeding to Phase 4 + +**Phase 4**: Generate `plan.md` from design - **REQUIRES USER APPROVAL** +- Implementation roadmap and task breakdown +- STOP and ask for authorization before proceeding to Phase 5 + +**Phase 5**: Execute implementation - **REQUIRES USER APPROVAL** +- Follow the plan exactly as specified +- NEVER start implementation without explicit user approval + +## Effect TypeScript Development Patterns + +### Core Principles +- **Type Safety First**: Never use `any` or type assertions - prefer explicit types +- **Effect Patterns**: Use Effect's composable abstractions (prefer Effect.fn) +- **Early Returns**: Prefer early returns over deep nesting +- **Input Validation**: Validate inputs at system boundaries with Effect Schema +- **Resource Safety**: Use Effect's resource management for automatic cleanup + +### Effect-Specific Patterns + +#### Sequential Operations (Effect.fn preferred) +```typescript +// Use Effect.fn for sequential operations +const program = Effect.fn(function* () { + const user = yield* getUser(id) + const profile = yield* getProfile(user.profileId) + return { user, profile } +}) +``` + +#### Error Handling +```typescript +// Use Data.TaggedError for custom errors +class UserNotFound extends Data.TaggedError("UserNotFound")<{ + readonly id: string +}> {} + +// Use Effect.tryPromise for Promise integration +const fetchUser = (id: string) => + Effect.tryPromise({ + try: () => prisma.user.findUniqueOrThrow({ where: { id } }), + catch: () => new UserNotFound({ id }) + }) +``` + +#### Testing with @effect/vitest + +**Use @effect/vitest for Effect code:** +- Import pattern: `import { assert, describe, it } from "@effect/vitest"` +- Test pattern: `it.effect("description", () => Effect.fn(function*() { ... }))` +- **FORBIDDEN**: Never use `expect` from vitest in Effect tests - use `assert` methods + +#### Correct it.effect Pattern + +```typescript +import { assert, describe, it } from "@effect/vitest" +import { Effect } from "effect" + +describe("UserService", () => { + it.effect("should fetch user successfully", () => + Effect.fn(function* () { + const user = yield* fetchUser("123") + + // Use assert methods, NOT expect + assert.strictEqual(user.id, "123") + assert.deepStrictEqual(user.profile, expectedProfile) + assert.isTrue(user.active) + })) +}) +``` + +## Implementation Patterns + +The project includes comprehensive pattern documentation for future reference and consistency: + +### Pattern Directory +**Location**: `patterns/` +- **Purpose**: Detailed documentation of all implementation patterns used in the project +- **Usage**: Reference material for maintaining consistency and best practices + +### Available Patterns +- **http-api.md**: HTTP API definition and implementation patterns +- **layer-composition.md**: Layer-based dependency injection patterns +- **generic-testing.md**: General testing patterns with @effect/vitest + +## Notes +- This is an Effect Platform HTTP API migration of the original Express server +- Focus on type safety, observability, and error handling +- WebSocket functionality excluded (to be migrated separately) +- Uses hardcoded port configuration (no portfinder) \ No newline at end of file diff --git a/apps/server-new/package.json b/apps/server-new/package.json new file mode 100644 index 00000000..1d0ef98a --- /dev/null +++ b/apps/server-new/package.json @@ -0,0 +1,37 @@ +{ + "name": "server-new", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch ./src/index.ts", + "start": "node ./dist/index.js", + "build": "tsup", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "lint": "biome check", + "lint:fix": "biome check --write --unsafe", + "prisma": "prisma", + "prebuild": "prisma generate" + }, + "dependencies": { + "@effect/opentelemetry": "^0.56.0", + "@effect/platform": "^0.90.0", + "@effect/platform-node": "^0.94.0", + "@graphprotocol/hypergraph": "workspace:*", + "@prisma/client": "^6.7.0", + "@privy-io/server-auth": "^1.26.0", + "cors": "^2.8.5", + "effect": "^3.17.3" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/node": "^24.1.0", + "prisma": "^6.7.0", + "tsup": "^8.4.0", + "tsx": "^4.19.0", + "typescript": "^5.8.3", + "vitest": "^3.2.4" + } +} diff --git a/apps/server-new/patterns/README.md b/apps/server-new/patterns/README.md new file mode 100644 index 00000000..6fec1556 --- /dev/null +++ b/apps/server-new/patterns/README.md @@ -0,0 +1,19 @@ +# Implementation Patterns + +This directory contains detailed documentation of implementation patterns used throughout the project. These patterns provide reusable solutions and best practices for Effect TypeScript development. + +## Patterns + +- **[http-api.md](./http-api.md)** - HTTP API definition and implementation patterns using Effect platform +- **[layer-composition.md](./layer-composition.md)** - Layer-based dependency injection and service composition patterns +- **[generic-testing.md](./generic-testing.md)** - General testing patterns with @effect/vitest and Effect ecosystem + +## Usage + +Each pattern document includes: +- Core concepts and principles +- Code examples from the implementation +- Best practices and guidelines +- Common pitfalls to avoid + +These patterns serve as reference material for future development and help maintain consistency across the codebase. \ No newline at end of file diff --git a/apps/server-new/patterns/generic-testing.md b/apps/server-new/patterns/generic-testing.md new file mode 100644 index 00000000..da848211 --- /dev/null +++ b/apps/server-new/patterns/generic-testing.md @@ -0,0 +1,365 @@ +# Generic Testing Patterns + +This document describes general testing patterns used in the project with @effect/vitest and Effect ecosystem testing approaches. + +## Core Testing Framework Pattern + +### 1. @effect/vitest Integration + +```typescript +import { assert, describe, it } from "@effect/vitest" +import { Effect } from "effect" + +describe("Feature Name", () => { + it.effect("should do something", () => + Effect.gen(function* () { + const result = yield* someEffectOperation() + assert.strictEqual(result, expectedValue) + }) + ) +}) +``` + +**Key Elements:** +- **Import from @effect/vitest**: `assert`, `describe`, `it` for Effect-aware testing +- **it.effect()**: Special test function for Effect-based tests +- **Effect.gen()**: Generator-based Effect composition +- **assert methods**: Use `assert.*` instead of `expect` for Effect tests + +### 2. Effect Test Structure Pattern + +```typescript +it.effect("descriptive test name", () => + Effect.gen(function* () { + // Arrange: Set up test data/state + const input = "test data" + + // Act: Perform the operation + const result = yield* operationUnderTest(input) + + // Assert: Verify the outcome + assert.strictEqual(result, "expected output") + }) +) +``` + +**Structure Benefits:** +- **Effect Composition**: Natural Effect chaining with generators +- **Error Handling**: Automatic Effect error propagation +- **Type Safety**: Full TypeScript integration with Effect types + +## Service Mocking Pattern + +### 1. Mock Service Creation + +```typescript +export const createMockConsole = () => { + const messages: Array = [] + + // Unsafe implementation (plain functions) + const unsafeConsole: Console.UnsafeConsole = { + log: (...args: ReadonlyArray) => { + messages.push(args.join(" ")) + }, + error: (...args: ReadonlyArray) => { + messages.push(`error: ${args.join(" ")}`) + }, + // ... all console methods + } + + // Effect wrapper (Effect-based interface) + const mockConsole: Console.Console = { + [Console.TypeId]: Console.TypeId, + log: (...args: ReadonlyArray) => + Effect.sync(() => unsafeConsole.log(...args)), + error: (...args: ReadonlyArray) => + Effect.sync(() => unsafeConsole.error(...args)), + // ... all console methods + unsafe: unsafeConsole + } + + return { mockConsole, messages } +} +``` + +**Mock Service Pattern:** +1. **State Capture**: Array/object to capture calls and data +2. **Dual Interface**: Both unsafe and Effect-based implementations +3. **Type Safety**: Implement complete service interface +4. **Test Utilities**: Return both mock and captured data + +### 2. Service Interface Implementation + +```typescript +const mockConsole: Console.Console = { + [Console.TypeId]: Console.TypeId, // ← Type identifier + log: (...args) => Effect.sync(() => ...), // ← Effect wrapper + unsafe: unsafeConsole // ← Direct access +} +``` + +**Implementation Requirements:** +- **Complete Interface**: Implement every method from service interface +- **Type Identifier**: Include service type ID for runtime identification +- **Effect Integration**: Wrap unsafe operations in `Effect.sync()` +- **Unsafe Access**: Provide direct access for performance-critical operations + +## Test Data Management Pattern + +### 1. Captured Data Pattern + +```typescript +export const createMockConsole = () => { + const messages: Array = [] // ← Captured data + + const unsafeConsole = { + log: (...args) => { + messages.push(args.join(" ")) // ← Capture call + } + } + + return { mockConsole, messages } // ← Return both mock and data +} +``` + +**Data Capture Benefits:** +- **Inspection**: Tests can verify what was called +- **Debugging**: Easy to see what happened during test execution +- **Assertions**: Test can assert on captured data + +### 2. Test State Management + +```typescript +describe("Console Testing", () => { + const { mockConsole, messages } = createMockConsole() + + it.effect("should capture log messages", () => + Effect.gen(function* () { + yield* Console.log("test message").pipe( + Effect.provide(Console.setConsole(mockConsole)) + ) + + assert.strictEqual(messages.length, 1) + assert.strictEqual(messages[0], "test message") + }) + ) +}) +``` + +**State Management Principles:** +- **Per-Test State**: Each test gets clean mock state +- **Service Provision**: Provide mock via Effect service system +- **Assertion Access**: Test code can inspect captured state + +## Effect Service Testing Pattern + +### 1. Service Provision in Tests + +```typescript +it.effect("should use provided service", () => + Effect.gen(function* () { + yield* Console.log("test") + }).pipe( + Effect.provide(Console.setConsole(mockConsole)) // ← Provide mock service + ) +) +``` + +**Service Provision Methods:** +- **Effect.provide()**: Provide service implementation to Effect +- **Layer-based**: Use layers for complex service dependencies +- **Direct provision**: Simple service replacement + +### 2. Service Replacement Pattern + +```typescript +// Replace default service with mock +Effect.provide(Console.setConsole(mockConsole)) + +// Replace default with custom implementation +Effect.provide(Logger.replace(Logger.defaultLogger, testLogger)) + +// Add service instance +Effect.provide(Logger.add(Logger.defaultLogger)) +``` + +## Assertion Patterns + +### 1. Effect-Specific Assertions + +```typescript +import { assert } from "@effect/vitest" + +// Value assertions +assert.strictEqual(actual, expected) +assert.deepStrictEqual(actualObject, expectedObject) + +// Boolean assertions +assert.isTrue(condition) +assert.isFalse(condition) + +// Existence assertions +assert.isDefined(value) +assert.isUndefined(value) +``` + +**Assertion Guidelines:** +- **Use assert, not expect**: @effect/vitest provides assert methods +- **Type-safe**: Assertions work with Effect type system +- **Clear Messages**: Provide descriptive failure messages + +### 2. Error Testing Pattern + +```typescript +it.effect("should handle errors", () => + Effect.gen(function* () { + const result = yield* Effect.flip(failingOperation()) + assert.isTrue(result instanceof ExpectedError) + }) +) +``` + +**Error Testing Approaches:** +- **Effect.flip()**: Convert failure to success for testing +- **Effect.either()**: Get Either for pattern matching +- **Try/Catch with Effects**: Use Effect error handling patterns + +## Test Organization Patterns + +### 1. Describe Block Structure + +```typescript +describe("Feature/Module Name", () => { + // Setup shared across tests + const sharedResource = createSharedResource() + + describe("specific functionality", () => { + // Nested describe for grouping related tests + + it.effect("should handle normal case", () => ...) + it.effect("should handle error case", () => ...) + }) +}) +``` + +**Organization Benefits:** +- **Logical Grouping**: Related tests grouped together +- **Shared Setup**: Common resources defined once +- **Clear Hierarchy**: Easy to understand test structure + +### 2. Test Naming Convention + +```typescript +// ✅ Good: Descriptive and specific +it.effect("GET /healthz returns 200 status with success message", () => ...) +it.effect("should capture console messages in test environment", () => ...) + +// ❌ Poor: Vague or implementation-focused +it.effect("test endpoint", () => ...) +it.effect("should work", () => ...) +``` + +**Naming Guidelines:** +- **Behavior-focused**: Describe what the system should do +- **Specific**: Include key details (HTTP method, expected outcome) +- **Action + Result**: What action produces what result + +## Test Utility Patterns + +### 1. Factory Functions for Test Resources + +```typescript +export const createMockConsole = () => { + // Resource creation logic + return { mockConsole, messages } +} + +export const createTestData = () => { + return { + validUser: { id: 1, name: "Test User" }, + invalidUser: { id: -1, name: "" } + } +} +``` + +**Factory Benefits:** +- **Reusability**: Same setup across multiple tests +- **Consistency**: Standardized test data/mocks +- **Encapsulation**: Hide complex setup logic + +### 2. Test Helper Functions + +```typescript +const waitFor = (condition: () => boolean, timeout = 1000) => + Effect.gen(function* () { + const start = Date.now() + while (!condition() && Date.now() - start < timeout) { + yield* Effect.sleep("10 millis") + } + if (!condition()) { + yield* Effect.fail(new Error("Condition not met within timeout")) + } + }) +``` + +## Environment-Specific Testing + +### 1. Test vs Production Separation + +```typescript +// Test environment detection +const isTest = process.env.NODE_ENV === "test" + +// Test-specific configuration +const testConfig = { + port: 0, // Random port + logLevel: "silent", // Reduce test output + timeout: 5000 // Shorter timeouts +} +``` + +### 2. Resource Cleanup Pattern + +```typescript +describe("Resource Tests", () => { + let resource: SomeResource + + beforeEach(() => { + resource = createResource() + }) + + afterEach(() => { + resource.cleanup() + }) + + it.effect("should use resource", () => + Effect.gen(function* () { + yield* useResource(resource) + }) + ) +}) +``` + +## Best Practices + +### 1. Test Independence +- **No Shared State**: Each test should be independent +- **Clean Mocks**: Reset mocks between tests +- **Isolated Resources**: Tests shouldn't affect each other + +### 2. Effect Integration +- **Use it.effect()**: For any test that uses Effect operations +- **Effect.gen()**: For readable async test code +- **Service provision**: Use Effect service system for dependencies + +### 3. Assertion Quality +- **Specific Assertions**: Test exact values, not just truthiness +- **Multiple Assertions**: Verify all important aspects +- **Good Error Messages**: Make test failures easy to understand + +### 4. Test Coverage +- **Happy Path**: Test normal successful operations +- **Error Cases**: Test failure scenarios +- **Edge Cases**: Test boundary conditions and unusual inputs + +This testing approach provides reliable, maintainable tests that integrate well with the Effect ecosystem while maintaining excellent error handling and type safety. \ No newline at end of file diff --git a/apps/server-new/patterns/http-api.md b/apps/server-new/patterns/http-api.md new file mode 100644 index 00000000..667c1ed7 --- /dev/null +++ b/apps/server-new/patterns/http-api.md @@ -0,0 +1,233 @@ +# HTTP API Patterns + +This document describes the HTTP API implementation patterns used in this project with Effect's platform abstractions. + +## Core Pattern: Declarative API Definition + +### 1. Three-Layer API Structure + +```typescript +// Layer 1: Endpoint Definition +const statusEndpoint = HttpApiEndpoint + .get("status", "/healthz") + .addSuccess(Schema.String) + +// Layer 2: Group Definition +const healthGroup = HttpApiGroup + .make("Health") + .add(statusEndpoint) + +// Layer 3: API Definition +const todosApi = HttpApi + .make("TodosApi") + .add(healthGroup) +``` + +**Key Principles:** +- **Separation of Concerns**: Endpoints, groups, and APIs are defined separately +- **Composability**: Groups can contain multiple endpoints, APIs can contain multiple groups +- **Type Safety**: Schema definitions ensure request/response type safety +- **Declarative**: API structure is defined, not implemented + +### 2. Endpoint Definition Pattern + +```typescript +const statusEndpoint = HttpApiEndpoint + .get("status", "/healthz") // HTTP method and path + .addSuccess(Schema.String) // Response schema +``` + +**Pattern Elements:** +- **Method + Name + Path**: `get("status", "/healthz")` +- **Response Schema**: `.addSuccess(Schema.String)` for type-safe responses +- **Extensible**: Can add `.setPayload()`, `.setHeaders()`, `.addError()` as needed + +### 3. Group Organization Pattern + +```typescript +const healthGroup = HttpApiGroup + .make("Health") // Group name + .add(statusEndpoint) // Add endpoints +``` + +**Benefits:** +- **Logical Grouping**: Related endpoints grouped together +- **Namespace Organization**: Clear API structure +- **Handler Grouping**: Implementations grouped by API groups + +### 4. API Composition Pattern + +```typescript +const todosApi = HttpApi + .make("TodosApi") // API name + .add(healthGroup) // Add groups +``` + +**Scalability:** +- **Multiple Groups**: Can add todos, users, auth groups +- **Single Source of Truth**: Complete API definition in one place +- **Client Generation**: Same definition can generate typed clients + +## Implementation Pattern: Handler Definition + +### 1. Group Handler Implementation + +```typescript +export const healthLive = HttpApiBuilder.group( + todosApi, // API reference + "Health", // Group name + (handlers) => // Handler factory + handlers.handle( + "status", // Endpoint name + () => Effect.succeed("Server is running successfully") + ) +) +``` + +**Pattern Elements:** +- **API Reference**: Links handler to specific API definition +- **Group Name**: Must match the group name in API definition +- **Handler Factory**: Function that receives handlers object +- **Effect-based**: All handlers return Effects for composability + +### 2. Handler Function Pattern + +```typescript +handlers.handle( + "status", // Endpoint name (must match) + () => Effect.succeed("...") // Handler implementation +) +``` + +**Key Aspects:** +- **Name Matching**: Handler name must match endpoint name +- **Effect Return**: Always return an Effect for error handling +- **Pure Functions**: Handlers should be pure (no side effects) +- **Type Safety**: Return type must match endpoint schema + +## Server Configuration Pattern + +### 1. Layer Composition for Server + +```typescript +// API Implementation Layer +const apiLive = HttpApiBuilder.api(todosApi).pipe( + Layer.provide(healthLive) // Provide handler implementations +) + +// Server Layer +export const serverLive = HttpApiBuilder.serve().pipe( + Layer.provide(apiLive), // Provide API implementation + HttpServer.withLogAddress, // Add address logging + Layer.provide(BunHttpServer.layer({ port: 3000 })) // Platform server +) +``` + +**Layer Stack (bottom to top):** +1. **Platform Layer**: `BunHttpServer.layer()` - Physical server +2. **Logging Layer**: `HttpServer.withLogAddress` - Address logging +3. **API Layer**: `apiLive` - API implementation with handlers +4. **Server Layer**: `HttpApiBuilder.serve()` - HTTP service + +### 2. Platform Abstraction Pattern + +```typescript +// Production: Bun Runtime +Layer.provide(BunHttpServer.layer({ port: 3000 })) + +// Testing: Node.js Runtime +Layer.provide(NodeHttpServer.layer(createServer, { port: 0 })) +``` + +**Benefits:** +- **Runtime Independence**: Same API works on different runtimes +- **Test Isolation**: Different server config for testing +- **Performance**: Use optimal runtime for each environment + +## Application Entry Pattern + +### 1. Simple Launch Pattern + +```typescript +if (import.meta.main) { + Layer.launch(serverLive).pipe(BunRuntime.runMain) +} +``` + +**Pattern Elements:** +- **Entry Guard**: `import.meta.main` prevents execution when imported +- **Layer Launch**: `Layer.launch()` starts the server layer +- **Runtime Integration**: `.pipe(BunRuntime.runMain)` for Bun runtime + +### 2. Layer-Based Architecture + +```typescript +// Dependency flow: +BunRuntime.runMain +├── Layer.launch(serverLive) + ├── HttpApiBuilder.serve() + ├── HttpServer.withLogAddress + ├── apiLive + │ └── healthLive (handlers) + └── BunHttpServer.layer() +``` + +**Advantages:** +- **Dependency Injection**: Automatic service resolution +- **Resource Management**: Automatic cleanup on shutdown +- **Testability**: Easy to swap layers for testing + +## Schema Integration Pattern + +### 1. Type-Safe Responses + +```typescript +.addSuccess(Schema.String) // Response will be string +``` + +**Type Flow:** +1. Schema defines the response type +2. Handler must return matching type +3. Client receives typed response +4. Automatic serialization/deserialization + +### 2. Future Extensions + +```typescript +// Request payload +.setPayload(Schema.Struct({ name: Schema.String })) + +// Error responses +.addError(UserNotFound, { status: 404 }) + +// URL parameters +.setPath(Schema.Struct({ id: Schema.NumberFromString })) +``` + +## Best Practices + +### 1. Naming Conventions +- **Endpoints**: Descriptive names (`status`, `getUser`, `createTodo`) +- **Groups**: Noun-based (`Health`, `Users`, `Todos`) +- **APIs**: Project-based (`TodosApi`, `UserManagementApi`) + +### 2. File Organization +``` +src/http/ +├── api.ts # API definitions only +├── handlers/ # Handler implementations +│ └── health.ts # Group-specific handlers +└── server.ts # Server configuration +``` + +### 3. Separation of Concerns +- **api.ts**: Pure definitions, no implementation +- **handlers/*.ts**: Implementation logic, one file per group +- **server.ts**: Layer composition and configuration + +### 4. Type Safety +- Always use Schema for request/response types +- Let TypeScript infer handler types from endpoint schemas +- No `any` types in HTTP layer + +This pattern provides a scalable, type-safe, and testable HTTP API architecture using Effect's declarative approach. \ No newline at end of file diff --git a/apps/server-new/patterns/layer-composition.md b/apps/server-new/patterns/layer-composition.md new file mode 100644 index 00000000..3f82a2c1 --- /dev/null +++ b/apps/server-new/patterns/layer-composition.md @@ -0,0 +1,428 @@ +# Layer Composition Patterns + +This document describes the Layer composition patterns used for dependency injection, service provision, and resource management in the Effect ecosystem. + +## Core Pattern: Layer-Based Architecture + +### 1. Layer Dependency Flow + +```typescript +// Production Server Layer Stack +export const serverLive = HttpApiBuilder.serve().pipe( + Layer.provide(apiLive), // ← API implementation + HttpServer.withLogAddress, // ← Logging middleware + Layer.provide(BunHttpServer.layer({ port: 3000 })) // ← Platform server +) + +// API Layer depends on handlers +const apiLive = HttpApiBuilder.api(todosApi).pipe( + Layer.provide(healthLive) // ← Handler implementations +) +``` + +**Dependency Graph:** +``` +serverLive +├── HttpApiBuilder.serve() (top layer) +├── HttpServer.withLogAddress (middleware) +├── apiLive (API implementation) +│ └── healthLive (handlers) +└── BunHttpServer.layer() (platform) +``` + +### 2. Layer Types and Purposes + +| Layer Type | Purpose | Example | +|------------|---------|---------| +| **Platform Layer** | Physical runtime/server | `BunHttpServer.layer()` | +| **Service Layer** | Business logic | `healthLive` handlers | +| **Infrastructure Layer** | Cross-cutting concerns | `HttpServer.withLogAddress` | +| **Composition Layer** | Orchestration | `HttpApiBuilder.serve()` | + +## Provision Patterns + +### 1. Layer.provide() - Dependency Replacement + +```typescript +const apiLive = HttpApiBuilder.api(todosApi).pipe( + Layer.provide(healthLive) // Provides handlers to API +) +``` + +**Usage:** +- **Replaces Dependencies**: API needs handlers, we provide them +- **Bottom-Up**: Lower layers provide services to upper layers +- **Type Safety**: Compiler ensures all dependencies are satisfied + +### 2. Layer.provideMerge() - Service Extension + +```typescript +const testServerWithMockConsole = testServerLive.pipe( + Layer.provide(Logger.add(Logger.defaultLogger)), + Layer.provide(Console.setConsole(mockConsole)), + Layer.provideMerge(FetchHttpClient.layer) // ← Merge additional service +) +``` + +**When to Use:** +- **Add Services**: When you need to add services without replacing existing ones +- **Testing**: Add test-specific services (HTTP client, mock services) +- **Enhancement**: Extend functionality without breaking existing dependencies + +### 3. Middleware Pattern with Layers + +```typescript +export const serverLive = HttpApiBuilder.serve().pipe( + Layer.provide(apiLive), // Core functionality + HttpServer.withLogAddress, // ← Middleware: adds logging + Layer.provide(BunHttpServer.layer({ port: 3000 })) +) +``` + +**Middleware Characteristics:** +- **Non-intrusive**: Doesn't change core API behavior +- **Composable**: Can stack multiple middleware +- **Order-dependent**: Middleware order matters + +## Environment-Specific Layer Patterns + +### 1. Production vs Test Layer Separation + +```typescript +// Production Layer (src/http/server.ts) +export const serverLive = HttpApiBuilder.serve().pipe( + Layer.provide(apiLive), + HttpServer.withLogAddress, + Layer.provide(BunHttpServer.layer({ port: 3000 })) // ← Fixed port +) + +// Test Layer (test/utils/testServer.ts) +export const testServerLive = HttpApiBuilder.serve().pipe( + Layer.provide(apiLive), // ← Same API implementation + HttpServer.withLogAddress, // ← Same logging + Layer.provide(NodeHttpServer.layer(createServer, { port: 0 })) // ← Random port +) +``` + +**Pattern Benefits:** +- **Same Logic**: Core API logic identical between environments +- **Different Infrastructure**: Platform-specific implementations +- **Test Isolation**: Random ports prevent test conflicts + +### 2. Service Configuration Pattern + +```typescript +// Service Creation +const mockConsole = createMockConsole() + +// Service Provision +const testServerWithMockConsole = testServerLive.pipe( + Layer.provide(Logger.add(Logger.defaultLogger)), // Add logger + Layer.provide(Console.setConsole(mockConsole)), // Replace console + Layer.provideMerge(FetchHttpClient.layer) // Add HTTP client +) +``` + +**Configuration Strategies:** +- **Add**: `Logger.add()` - Add new service instances +- **Replace**: `Console.setConsole()` - Replace default implementations +- **Merge**: `Layer.provideMerge()` - Extend service availability + +## Service Factory Pattern + +### 1. Factory Function for Test Services + +```typescript +export const createTestHttpServer = () => { + // Create services + const { mockConsole, messages } = createMockConsole() + + // Compose layer + const testServerWithMockConsole = testServerLive.pipe( + Layer.provide(Logger.add(Logger.defaultLogger)), + Layer.provide(Console.setConsole(mockConsole)), + Layer.provideMerge(FetchHttpClient.layer) + ) + + // Utility functions + const getServerUrl = () => { /* extract URL from logs */ } + + // Return composed services + return { + testServerLayer: testServerWithMockConsole, + getServerUrl, + messages + } +} +``` + +**Factory Pattern Benefits:** +- **Encapsulation**: Hides service creation complexity +- **Reusability**: Same factory for all HTTP tests +- **Consistency**: Standardized test server configuration +- **Flexibility**: Returns both layer and utilities + +### 2. Service Composition in Factories + +```typescript +// Inside createTestHttpServer() +const testServerWithMockConsole = testServerLive.pipe( + Layer.provide(Logger.add(Logger.defaultLogger)), // ← Service 1 + Layer.provide(Console.setConsole(mockConsole)), // ← Service 2 + Layer.provideMerge(FetchHttpClient.layer) // ← Service 3 +) +``` + +**Composition Order:** +1. **Base Layer**: `testServerLive` (server + API) +2. **Logging Services**: Logger and Console for testing +3. **HTTP Client**: FetchHttpClient for making requests + +## Application Launch Pattern + +### 1. Layer.launch() Pattern + +```typescript +// Application Entry Point +if (import.meta.main) { + Layer.launch(serverLive).pipe(BunRuntime.runMain) +} +``` + +**Launch Process:** +1. **Layer.launch()**: Starts the layer and keeps it running +2. **BunRuntime.runMain**: Integrates with Bun runtime lifecycle +3. **Resource Management**: Automatic cleanup on process termination + +### 2. Runtime Integration Pattern + +```typescript +// Production: Bun Runtime +Layer.launch(serverLive).pipe(BunRuntime.runMain) + +// Test: Effect Runtime with layer() helper +layer(testServerLayer)((it) => { + // Tests run with layer active +}) +``` + +**Runtime Differences:** +- **Production**: Long-running process with BunRuntime +- **Testing**: Scoped execution with automatic cleanup + +## Configuration-Driven Layer Patterns + +### 1. Layer.unwrapEffect() with Configuration + +```typescript +import { Config, Effect, Layer } from "effect" + +// Configuration definition +export const serverPortConfig = Config.port("PORT").pipe( + Config.withDefault(3000) +) + +// Layer that depends on configuration +export const serverLive = Layer.unwrapEffect( + Effect.gen(function* () { + const port = yield* serverPortConfig // ← Resolve config first + return HttpApiBuilder.serve().pipe( + Layer.provide(apiLive), + HttpServer.withLogAddress, + Layer.provide(BunHttpServer.layer({ port })) // ← Use resolved port + ) + }) +) +``` + +**Layer.unwrapEffect() Pattern:** +- **Configuration Resolution**: Resolves Effect-based configuration during layer creation +- **Dynamic Layer Construction**: Creates layers based on runtime configuration +- **Type Safety**: Full Effect type checking for configuration errors +- **Fail-Fast**: Configuration errors surface during layer initialization + +### 2. Configuration Flow with unwrapEffect + +``` +Environment Variable → Config.port() → Layer.unwrapEffect → Server Layer + ↓ ↓ ↓ ↓ + PORT=8080 Validation [1-65535] Effect.gen BunHttpServer + ↓ ↓ + Config.withDefault(3000) port value +``` + +**Flow Characteristics:** +1. **Environment Reading**: `Config.port("PORT")` reads and validates PORT +2. **Default Application**: `Config.withDefault(3000)` provides fallback +3. **Effect Resolution**: `Layer.unwrapEffect` resolves configuration Effect +4. **Layer Construction**: Dynamic layer creation with resolved values + +### 3. Configuration Error Handling + +```typescript +// Configuration errors propagate through Effect system +const serverLive = Layer.unwrapEffect( + Effect.gen(function* () { + const port = yield* serverPortConfig // ← Can fail with ConfigError + return HttpApiBuilder.serve().pipe(/* ... */) + }) +) + +// Error types from Config.port() +type ConfigErrors = + | ConfigError.InvalidData // PORT=invalid + | ConfigError.InvalidData // PORT=70000 (out of range) +``` + +**Error Handling Benefits:** +- **Fail-Fast**: Invalid configuration prevents server startup +- **Type-Safe Errors**: ConfigError types are known at compile time +- **Clear Messages**: Effect's Config API provides descriptive error messages +- **Effect Integration**: Errors flow naturally through Effect error system + +### 4. Advanced Configuration Patterns + +```typescript +// Multiple configuration values +const serverConfig = Effect.gen(function* () { + const port = yield* Config.port("PORT").pipe(Config.withDefault(3000)) + const host = yield* Config.string("HOST").pipe(Config.withDefault("0.0.0.0")) + return { port, host } +}) + +// Configuration-dependent layer with multiple values +export const serverLive = Layer.unwrapEffect( + Effect.gen(function* () { + const { port, host } = yield* serverConfig + return HttpApiBuilder.serve().pipe( + Layer.provide(apiLive), + HttpServer.withLogAddress, + Layer.provide(BunHttpServer.layer({ port, hostname: host })) + ) + }) +) +``` + +## Advanced Layer Patterns + +### 1. Platform Abstraction Layer + +```typescript +// Production (configurable port) +export const serverLive = Layer.unwrapEffect( + Effect.gen(function* () { + const port = yield* serverPortConfig + return HttpApiBuilder.serve().pipe( + Layer.provide(apiLive), + HttpServer.withLogAddress, + Layer.provide(BunHttpServer.layer({ port })) // ← Dynamic port + ) + }) +) + +// Testing (fixed behavior) +Layer.provide(NodeHttpServer.layer(createServer, { port: 0 })) +``` + +**Abstraction Benefits:** +- **Same Interface**: Both provide HTTP server capability +- **Different Implementations**: Production configurable, testing fixed +- **Environment Separation**: Clear distinction between runtime environments + +### 2. Service Replacement Pattern + +```typescript +// Default console → Mock console +Layer.provide(Console.setConsole(mockConsole)) + +// Default logger → Test logger +Layer.provide(Logger.add(Logger.defaultLogger)) +``` + +**Replacement Use Cases:** +- **Testing**: Replace I/O services with mocks +- **Configuration**: Replace default with environment-specific +- **Debugging**: Replace with instrumented versions + +## Layer Composition Best Practices + +### 1. Dependency Direction +```typescript +// ✅ Correct: Dependencies flow upward +const serverLive = HttpApiBuilder.serve().pipe( + Layer.provide(apiLive), // Server depends on API + Layer.provide(BunHttpServer.layer()) // Server depends on platform +) + +// ❌ Incorrect: Circular dependencies +const apiLive = HttpApiBuilder.api(todosApi).pipe( + Layer.provide(serverLive) // API cannot depend on server +) +``` + +### 2. Layer Naming Conventions +- **`*Live`**: Concrete implementations (`healthLive`, `apiLive`) +- **`*Layer`**: Infrastructure layers (`testServerLayer`) +- **`*Mock`**: Test doubles (`mockConsole`) + +### 3. File Organization +``` +src/ +├── http/ +│ ├── server.ts # Production layer composition +│ └── handlers/ # Service implementations +└── index.ts # Application launch + +test/utils/ +├── testServer.ts # Test layer composition +└── httpTestUtils.ts # Test service factories +``` + +### 4. Layer Composition Principles +- **Single Responsibility**: Each layer has one purpose +- **Composability**: Layers can be combined in different ways +- **Testability**: Easy to substitute test implementations +- **Resource Safety**: Automatic cleanup and error handling +- **Configuration-Driven**: Use Layer.unwrapEffect for runtime configuration +- **Fail-Fast Configuration**: Invalid configuration prevents layer initialization + +### 5. Layer.unwrapEffect Best Practices + +```typescript +// ✅ Good: Simple configuration resolution +export const serverLive = Layer.unwrapEffect( + Effect.gen(function* () { + const port = yield* serverPortConfig + return HttpApiBuilder.serve().pipe(/* ... */) + }) +) + +// ✅ Good: Multiple configuration values +export const serverLive = Layer.unwrapEffect( + Effect.gen(function* () { + const config = yield* Effect.all({ + port: serverPortConfig, + host: serverHostConfig + }) + return HttpApiBuilder.serve().pipe(/* ... */) + }) +) + +// ❌ Avoid: Complex logic in unwrapEffect +export const serverLive = Layer.unwrapEffect( + Effect.gen(function* () { + const port = yield* serverPortConfig + // Avoid heavy computation or complex business logic here + const processedData = yield* heavyProcessing(port) + return HttpApiBuilder.serve().pipe(/* ... */) + }) +) +``` + +**unwrapEffect Guidelines:** +- **Configuration Only**: Use for resolving configuration values +- **Keep Simple**: Avoid complex business logic in unwrapEffect +- **Error Handling**: Let configuration errors bubble up naturally +- **Type Safety**: Trust Effect's configuration validation + +This layer-based approach provides powerful dependency injection, clear separation of concerns, excellent testability, and flexible configuration management while maintaining type safety throughout the application. \ No newline at end of file diff --git a/apps/server-new/prisma/migrations/20241111122738_init/migration.sql b/apps/server-new/prisma/migrations/20241111122738_init/migration.sql new file mode 100644 index 00000000..3eb1ca6a --- /dev/null +++ b/apps/server-new/prisma/migrations/20241111122738_init/migration.sql @@ -0,0 +1,31 @@ +-- CreateTable +CREATE TABLE "SpaceEvent" ( + "id" TEXT NOT NULL PRIMARY KEY, + "event" TEXT NOT NULL, + "spaceId" TEXT NOT NULL, + CONSTRAINT "SpaceEvent_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Space" ( + "id" TEXT NOT NULL PRIMARY KEY +); + +-- CreateTable +CREATE TABLE "Account" ( + "id" TEXT NOT NULL PRIMARY KEY +); + +-- CreateTable +CREATE TABLE "_AccountToSpace" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + CONSTRAINT "_AccountToSpace_A_fkey" FOREIGN KEY ("A") REFERENCES "Account" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "_AccountToSpace_B_fkey" FOREIGN KEY ("B") REFERENCES "Space" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "_AccountToSpace_AB_unique" ON "_AccountToSpace"("A", "B"); + +-- CreateIndex +CREATE INDEX "_AccountToSpace_B_index" ON "_AccountToSpace"("B"); diff --git a/apps/server-new/prisma/migrations/20241113083927_introduce_counter_and_state_to_space_events/migration.sql b/apps/server-new/prisma/migrations/20241113083927_introduce_counter_and_state_to_space_events/migration.sql new file mode 100644 index 00000000..40c7bd15 --- /dev/null +++ b/apps/server-new/prisma/migrations/20241113083927_introduce_counter_and_state_to_space_events/migration.sql @@ -0,0 +1,25 @@ +/* + Warnings: + + - Added the required column `counter` to the `SpaceEvent` table without a default value. This is not possible if the table is not empty. + - Added the required column `state` to the `SpaceEvent` table without a default value. This is not possible if the table is not empty. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_SpaceEvent" ( + "id" TEXT NOT NULL PRIMARY KEY, + "event" TEXT NOT NULL, + "state" TEXT NOT NULL, + "counter" INTEGER NOT NULL, + "spaceId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "SpaceEvent_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_SpaceEvent" ("event", "id", "spaceId") SELECT "event", "id", "spaceId" FROM "SpaceEvent"; +DROP TABLE "SpaceEvent"; +ALTER TABLE "new_SpaceEvent" RENAME TO "SpaceEvent"; +CREATE UNIQUE INDEX "SpaceEvent_spaceId_counter_key" ON "SpaceEvent"("spaceId", "counter"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/apps/server-new/prisma/migrations/20241114170708_add_invitation/migration.sql b/apps/server-new/prisma/migrations/20241114170708_add_invitation/migration.sql new file mode 100644 index 00000000..9c67be40 --- /dev/null +++ b/apps/server-new/prisma/migrations/20241114170708_add_invitation/migration.sql @@ -0,0 +1,9 @@ +-- CreateTable +CREATE TABLE "Invitation" ( + "id" TEXT NOT NULL PRIMARY KEY, + "spaceId" TEXT NOT NULL, + "accountAddress" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Invitation_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Invitation_accountAddress_fkey" FOREIGN KEY ("accountAddress") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); diff --git a/apps/server-new/prisma/migrations/20241116161206_extend_invitation/migration.sql b/apps/server-new/prisma/migrations/20241116161206_extend_invitation/migration.sql new file mode 100644 index 00000000..c7ad34dc --- /dev/null +++ b/apps/server-new/prisma/migrations/20241116161206_extend_invitation/migration.sql @@ -0,0 +1,24 @@ +/* + Warnings: + + - Added the required column `inviteeAccountAddress` to the `Invitation` table without a default value. This is not possible if the table is not empty. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Invitation" ( + "id" TEXT NOT NULL PRIMARY KEY, + "spaceId" TEXT NOT NULL, + "accountAddress" TEXT NOT NULL, + "inviteeAccountAddress" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Invitation_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Invitation_accountAddress_fkey" FOREIGN KEY ("accountAddress") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_Invitation" ("accountAddress", "createdAt", "id", "spaceId") SELECT "accountAddress", "createdAt", "id", "spaceId" FROM "Invitation"; +DROP TABLE "Invitation"; +ALTER TABLE "new_Invitation" RENAME TO "Invitation"; +CREATE UNIQUE INDEX "Invitation_spaceId_inviteeAccountAddress_key" ON "Invitation"("spaceId", "inviteeAccountAddress"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/apps/server-new/prisma/migrations/20241117202441_add_key/migration.sql b/apps/server-new/prisma/migrations/20241117202441_add_key/migration.sql new file mode 100644 index 00000000..40ce36bf --- /dev/null +++ b/apps/server-new/prisma/migrations/20241117202441_add_key/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "SpaceKey" ( + "id" TEXT NOT NULL PRIMARY KEY, + "spaceId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "SpaceKey_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "SpaceKeyBox" ( + "id" TEXT NOT NULL PRIMARY KEY, + "spaceKeyId" TEXT NOT NULL, + "accountAddress" TEXT NOT NULL, + "ciphertext" TEXT NOT NULL, + "nonce" TEXT NOT NULL, + "authorPublicKey" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "SpaceKeyBox_spaceKeyId_fkey" FOREIGN KEY ("spaceKeyId") REFERENCES "SpaceKey" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "SpaceKeyBox_accountAddress_fkey" FOREIGN KEY ("accountAddress") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); diff --git a/apps/server-new/prisma/migrations/20241122132737_add_update/migration.sql b/apps/server-new/prisma/migrations/20241122132737_add_update/migration.sql new file mode 100644 index 00000000..b7d98cf3 --- /dev/null +++ b/apps/server-new/prisma/migrations/20241122132737_add_update/migration.sql @@ -0,0 +1,9 @@ +-- CreateTable +CREATE TABLE "Update" ( + "spaceId" TEXT NOT NULL, + "clock" INTEGER NOT NULL, + "content" BLOB NOT NULL, + + PRIMARY KEY ("spaceId", "clock"), + CONSTRAINT "Update_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); diff --git a/apps/server-new/prisma/migrations/20241128184625_add_identity/migration.sql b/apps/server-new/prisma/migrations/20241128184625_add_identity/migration.sql new file mode 100644 index 00000000..0b475fc2 --- /dev/null +++ b/apps/server-new/prisma/migrations/20241128184625_add_identity/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "Identity" ( + "accountAddress" TEXT NOT NULL, + "ciphertext" TEXT NOT NULL, + "nonce" TEXT NOT NULL, + "signaturePublicKey" TEXT NOT NULL, + "encryptionPublicKey" TEXT NOT NULL, + "accountProof" TEXT NOT NULL, + "keyProof" TEXT NOT NULL, + + PRIMARY KEY ("accountAddress", "nonce"), + CONSTRAINT "Identity_accountAddress_fkey" FOREIGN KEY ("accountAddress") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); diff --git a/apps/server-new/prisma/migrations/20241212164639_add_session_nonce_token/migration.sql b/apps/server-new/prisma/migrations/20241212164639_add_session_nonce_token/migration.sql new file mode 100644 index 00000000..2a501af8 --- /dev/null +++ b/apps/server-new/prisma/migrations/20241212164639_add_session_nonce_token/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Account" ADD COLUMN "sessionNonce" TEXT; +ALTER TABLE "Account" ADD COLUMN "sessionToken" TEXT; +ALTER TABLE "Account" ADD COLUMN "sessionTokenExpires" DATETIME; diff --git a/apps/server-new/prisma/migrations/20241212184317_add_account_token_index/migration.sql b/apps/server-new/prisma/migrations/20241212184317_add_account_token_index/migration.sql new file mode 100644 index 00000000..a72cc7b4 --- /dev/null +++ b/apps/server-new/prisma/migrations/20241212184317_add_account_token_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "Account_sessionToken_idx" ON "Account"("sessionToken"); diff --git a/apps/server-new/prisma/migrations/20250129220359_add_update_signature/migration.sql b/apps/server-new/prisma/migrations/20250129220359_add_update_signature/migration.sql new file mode 100644 index 00000000..b4b7a68e --- /dev/null +++ b/apps/server-new/prisma/migrations/20250129220359_add_update_signature/migration.sql @@ -0,0 +1,30 @@ +/* + Warnings: + + - Added the required column `accountAddress` to the `Update` table without a default value. This is not possible if the table is not empty. + - Added the required column `updateId` to the `Update` table without a default value. This is not possible if the table is not empty. + - Added the required column `signatureHex` to the `Update` table without a default value. This is not possible if the table is not empty. + - Added the required column `signatureRecovery` to the `Update` table without a default value. This is not possible if the table is not empty. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Update" ( + "spaceId" TEXT NOT NULL, + "clock" INTEGER NOT NULL, + "content" BLOB NOT NULL, + "accountAddress" TEXT NOT NULL, + "signatureHex" TEXT NOT NULL, + "signatureRecovery" INTEGER NOT NULL, + "updateId" TEXT NOT NULL, + + PRIMARY KEY ("spaceId", "clock"), + CONSTRAINT "Update_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Update_accountAddress_fkey" FOREIGN KEY ("accountAddress") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_Update" ("clock", "content", "spaceId") SELECT "clock", "content", "spaceId" FROM "Update"; +DROP TABLE "Update"; +ALTER TABLE "new_Update" RENAME TO "Update"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/apps/server-new/prisma/migrations/20250428155829_add_inboxes/migration.sql b/apps/server-new/prisma/migrations/20250428155829_add_inboxes/migration.sql new file mode 100644 index 00000000..1e7419f0 --- /dev/null +++ b/apps/server-new/prisma/migrations/20250428155829_add_inboxes/migration.sql @@ -0,0 +1,50 @@ +-- CreateTable +CREATE TABLE "SpaceInbox" ( + "id" TEXT NOT NULL PRIMARY KEY, + "spaceId" TEXT NOT NULL, + "isPublic" BOOLEAN NOT NULL, + "authPolicy" TEXT NOT NULL, + "encryptionPublicKey" TEXT NOT NULL, + "encryptedSecretKey" TEXT NOT NULL, + "spaceEventId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "SpaceInbox_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "SpaceInbox_spaceEventId_fkey" FOREIGN KEY ("spaceEventId") REFERENCES "SpaceEvent" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "SpaceInboxMessage" ( + "id" TEXT NOT NULL PRIMARY KEY, + "spaceInboxId" TEXT NOT NULL, + "ciphertext" TEXT NOT NULL, + "signatureHex" TEXT, + "signatureRecovery" INTEGER, + "authorAccountAddress" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "SpaceInboxMessage_spaceInboxId_fkey" FOREIGN KEY ("spaceInboxId") REFERENCES "SpaceInbox" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AccountInbox" ( + "id" TEXT NOT NULL PRIMARY KEY, + "accountAddress" TEXT NOT NULL, + "isPublic" BOOLEAN NOT NULL, + "authPolicy" TEXT NOT NULL, + "encryptionPublicKey" TEXT NOT NULL, + "signatureHex" TEXT NOT NULL, + "signatureRecovery" INTEGER NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "AccountInbox_accountAddress_fkey" FOREIGN KEY ("accountAddress") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AccountInboxMessage" ( + "id" TEXT NOT NULL PRIMARY KEY, + "accountInboxId" TEXT NOT NULL, + "ciphertext" TEXT NOT NULL, + "signatureHex" TEXT, + "signatureRecovery" INTEGER, + "authorAccountAddress" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "AccountInboxMessage_accountInboxId_fkey" FOREIGN KEY ("accountInboxId") REFERENCES "AccountInbox" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); diff --git a/apps/server-new/prisma/migrations/20250611173316_introduce_connect/migration.sql b/apps/server-new/prisma/migrations/20250611173316_introduce_connect/migration.sql new file mode 100644 index 00000000..35cd3579 --- /dev/null +++ b/apps/server-new/prisma/migrations/20250611173316_introduce_connect/migration.sql @@ -0,0 +1,191 @@ +/* + Warnings: + + - You are about to drop the `Identity` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `_AccountToSpace` table. If the table is not empty, all the data it contains will be lost. + - The primary key for the `Account` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `id` on the `Account` table. All the data in the column will be lost. + - You are about to drop the column `sessionNonce` on the `Account` table. All the data in the column will be lost. + - You are about to drop the column `sessionToken` on the `Account` table. All the data in the column will be lost. + - You are about to drop the column `sessionTokenExpires` on the `Account` table. All the data in the column will be lost. + - Added the required column `address` to the `Account` table without a default value. This is not possible if the table is not empty. + - Added the required column `connectAccountProof` to the `Account` table without a default value. This is not possible if the table is not empty. + - Added the required column `connectAddress` to the `Account` table without a default value. This is not possible if the table is not empty. + - Added the required column `connectCiphertext` to the `Account` table without a default value. This is not possible if the table is not empty. + - Added the required column `connectEncryptionPublicKey` to the `Account` table without a default value. This is not possible if the table is not empty. + - Added the required column `connectKeyProof` to the `Account` table without a default value. This is not possible if the table is not empty. + - Added the required column `connectNonce` to the `Account` table without a default value. This is not possible if the table is not empty. + - Added the required column `connectSignaturePublicKey` to the `Account` table without a default value. This is not possible if the table is not empty. + - Added the required column `infoAuthorAddress` to the `Space` table without a default value. This is not possible if the table is not empty. + - Added the required column `infoContent` to the `Space` table without a default value. This is not possible if the table is not empty. + - Added the required column `infoSignatureHex` to the `Space` table without a default value. This is not possible if the table is not empty. + - Added the required column `infoSignatureRecovery` to the `Space` table without a default value. This is not possible if the table is not empty. + - Added the required column `name` to the `Space` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropIndex +DROP INDEX "_AccountToSpace_B_index"; + +-- DropIndex +DROP INDEX "_AccountToSpace_AB_unique"; + +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "Identity"; +PRAGMA foreign_keys=on; + +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "_AccountToSpace"; +PRAGMA foreign_keys=on; + +-- CreateTable +CREATE TABLE "AppIdentity" ( + "address" TEXT NOT NULL PRIMARY KEY, + "ciphertext" TEXT NOT NULL, + "nonce" TEXT NOT NULL, + "signaturePublicKey" TEXT NOT NULL, + "encryptionPublicKey" TEXT NOT NULL, + "accountProof" TEXT NOT NULL, + "keyProof" TEXT NOT NULL, + "accountAddress" TEXT NOT NULL, + "appId" TEXT NOT NULL, + "sessionToken" TEXT NOT NULL, + "sessionTokenExpires" DATETIME NOT NULL, + CONSTRAINT "AppIdentity_accountAddress_fkey" FOREIGN KEY ("accountAddress") REFERENCES "Account" ("address") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "InvitationTargetApp" ( + "id" TEXT NOT NULL PRIMARY KEY, + "invitationId" TEXT NOT NULL, + CONSTRAINT "InvitationTargetApp_invitationId_fkey" FOREIGN KEY ("invitationId") REFERENCES "Invitation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "_space-members" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + CONSTRAINT "_space-members_A_fkey" FOREIGN KEY ("A") REFERENCES "Account" ("address") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "_space-members_B_fkey" FOREIGN KEY ("B") REFERENCES "Space" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "_AppIdentityToSpace" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + CONSTRAINT "_AppIdentityToSpace_A_fkey" FOREIGN KEY ("A") REFERENCES "AppIdentity" ("address") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "_AppIdentityToSpace_B_fkey" FOREIGN KEY ("B") REFERENCES "Space" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Account" ( + "address" TEXT NOT NULL PRIMARY KEY, + "connectAddress" TEXT NOT NULL, + "connectCiphertext" TEXT NOT NULL, + "connectNonce" TEXT NOT NULL, + "connectSignaturePublicKey" TEXT NOT NULL, + "connectEncryptionPublicKey" TEXT NOT NULL, + "connectAccountProof" TEXT NOT NULL, + "connectKeyProof" TEXT NOT NULL +); +DROP TABLE "Account"; +ALTER TABLE "new_Account" RENAME TO "Account"; +CREATE UNIQUE INDEX "Account_connectAddress_key" ON "Account"("connectAddress"); +CREATE TABLE "new_AccountInbox" ( + "id" TEXT NOT NULL PRIMARY KEY, + "accountAddress" TEXT NOT NULL, + "isPublic" BOOLEAN NOT NULL, + "authPolicy" TEXT NOT NULL, + "encryptionPublicKey" TEXT NOT NULL, + "signatureHex" TEXT NOT NULL, + "signatureRecovery" INTEGER NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "AccountInbox_accountAddress_fkey" FOREIGN KEY ("accountAddress") REFERENCES "Account" ("address") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_AccountInbox" ("accountAddress", "authPolicy", "createdAt", "encryptionPublicKey", "id", "isPublic", "signatureHex", "signatureRecovery") SELECT "accountAddress", "authPolicy", "createdAt", "encryptionPublicKey", "id", "isPublic", "signatureHex", "signatureRecovery" FROM "AccountInbox"; +DROP TABLE "AccountInbox"; +ALTER TABLE "new_AccountInbox" RENAME TO "AccountInbox"; +CREATE TABLE "new_Invitation" ( + "id" TEXT NOT NULL PRIMARY KEY, + "spaceId" TEXT NOT NULL, + "accountAddress" TEXT NOT NULL, + "inviteeAccountAddress" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Invitation_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Invitation_accountAddress_fkey" FOREIGN KEY ("accountAddress") REFERENCES "Account" ("address") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_Invitation" ("accountAddress", "createdAt", "id", "inviteeAccountAddress", "spaceId") SELECT "accountAddress", "createdAt", "id", "inviteeAccountAddress", "spaceId" FROM "Invitation"; +DROP TABLE "Invitation"; +ALTER TABLE "new_Invitation" RENAME TO "Invitation"; +CREATE UNIQUE INDEX "Invitation_spaceId_inviteeAccountAddress_key" ON "Invitation"("spaceId", "inviteeAccountAddress"); +CREATE TABLE "new_Space" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "infoContent" BLOB NOT NULL, + "infoAuthorAddress" TEXT NOT NULL, + "infoSignatureHex" TEXT NOT NULL, + "infoSignatureRecovery" INTEGER NOT NULL, + CONSTRAINT "Space_infoAuthorAddress_fkey" FOREIGN KEY ("infoAuthorAddress") REFERENCES "Account" ("address") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_Space" ("id") SELECT "id" FROM "Space"; +DROP TABLE "Space"; +ALTER TABLE "new_Space" RENAME TO "Space"; +CREATE TABLE "new_SpaceKeyBox" ( + "id" TEXT NOT NULL PRIMARY KEY, + "spaceKeyId" TEXT NOT NULL, + "ciphertext" TEXT NOT NULL, + "nonce" TEXT NOT NULL, + "authorPublicKey" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "accountAddress" TEXT NOT NULL, + "appIdentityAddress" TEXT, + CONSTRAINT "SpaceKeyBox_spaceKeyId_fkey" FOREIGN KEY ("spaceKeyId") REFERENCES "SpaceKey" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "SpaceKeyBox_accountAddress_fkey" FOREIGN KEY ("accountAddress") REFERENCES "Account" ("address") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "SpaceKeyBox_appIdentityAddress_fkey" FOREIGN KEY ("appIdentityAddress") REFERENCES "AppIdentity" ("address") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_SpaceKeyBox" ("accountAddress", "authorPublicKey", "ciphertext", "createdAt", "id", "nonce", "spaceKeyId") SELECT "accountAddress", "authorPublicKey", "ciphertext", "createdAt", "id", "nonce", "spaceKeyId" FROM "SpaceKeyBox"; +DROP TABLE "SpaceKeyBox"; +ALTER TABLE "new_SpaceKeyBox" RENAME TO "SpaceKeyBox"; +CREATE UNIQUE INDEX "SpaceKeyBox_spaceKeyId_nonce_key" ON "SpaceKeyBox"("spaceKeyId", "nonce"); +CREATE TABLE "new_Update" ( + "spaceId" TEXT NOT NULL, + "clock" INTEGER NOT NULL, + "content" BLOB NOT NULL, + "accountAddress" TEXT NOT NULL, + "signatureHex" TEXT NOT NULL, + "signatureRecovery" INTEGER NOT NULL, + "updateId" TEXT NOT NULL, + + PRIMARY KEY ("spaceId", "clock"), + CONSTRAINT "Update_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Update_accountAddress_fkey" FOREIGN KEY ("accountAddress") REFERENCES "Account" ("address") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_Update" ("accountAddress", "clock", "content", "signatureHex", "signatureRecovery", "spaceId", "updateId") SELECT "accountAddress", "clock", "content", "signatureHex", "signatureRecovery", "spaceId", "updateId" FROM "Update"; +DROP TABLE "Update"; +ALTER TABLE "new_Update" RENAME TO "Update"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE INDEX "AppIdentity_sessionToken_idx" ON "AppIdentity"("sessionToken"); + +-- CreateIndex +CREATE UNIQUE INDEX "AppIdentity_accountAddress_appId_key" ON "AppIdentity"("accountAddress", "appId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AppIdentity_accountAddress_nonce_key" ON "AppIdentity"("accountAddress", "nonce"); + +-- CreateIndex +CREATE UNIQUE INDEX "_space-members_AB_unique" ON "_space-members"("A", "B"); + +-- CreateIndex +CREATE INDEX "_space-members_B_index" ON "_space-members"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "_AppIdentityToSpace_AB_unique" ON "_AppIdentityToSpace"("A", "B"); + +-- CreateIndex +CREATE INDEX "_AppIdentityToSpace_B_index" ON "_AppIdentityToSpace"("B"); diff --git a/apps/server-new/prisma/migrations/20250620005807_add_connect_signer_address/migration.sql b/apps/server-new/prisma/migrations/20250620005807_add_connect_signer_address/migration.sql new file mode 100644 index 00000000..61bca61a --- /dev/null +++ b/apps/server-new/prisma/migrations/20250620005807_add_connect_signer_address/migration.sql @@ -0,0 +1,26 @@ +/* + Warnings: + + - Added the required column `connectSignerAddress` to the `Account` table without a default value. This is not possible if the table is not empty. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Account" ( + "address" TEXT NOT NULL PRIMARY KEY, + "connectAddress" TEXT NOT NULL, + "connectCiphertext" TEXT NOT NULL, + "connectNonce" TEXT NOT NULL, + "connectSignaturePublicKey" TEXT NOT NULL, + "connectEncryptionPublicKey" TEXT NOT NULL, + "connectAccountProof" TEXT NOT NULL, + "connectKeyProof" TEXT NOT NULL, + "connectSignerAddress" TEXT NOT NULL +); +INSERT INTO "new_Account" ("address", "connectAccountProof", "connectAddress", "connectCiphertext", "connectEncryptionPublicKey", "connectKeyProof", "connectNonce", "connectSignaturePublicKey") SELECT "address", "connectAccountProof", "connectAddress", "connectCiphertext", "connectEncryptionPublicKey", "connectKeyProof", "connectNonce", "connectSignaturePublicKey" FROM "Account"; +DROP TABLE "Account"; +ALTER TABLE "new_Account" RENAME TO "Account"; +CREATE UNIQUE INDEX "Account_connectAddress_key" ON "Account"("connectAddress"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/apps/server-new/prisma/migrations/20250627185421_remove_app_identity_nonce/migration.sql b/apps/server-new/prisma/migrations/20250627185421_remove_app_identity_nonce/migration.sql new file mode 100644 index 00000000..b5aed7b7 --- /dev/null +++ b/apps/server-new/prisma/migrations/20250627185421_remove_app_identity_nonce/migration.sql @@ -0,0 +1,29 @@ +/* + Warnings: + + - You are about to drop the column `nonce` on the `AppIdentity` table. All the data in the column will be lost. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_AppIdentity" ( + "address" TEXT NOT NULL PRIMARY KEY, + "ciphertext" TEXT NOT NULL, + "signaturePublicKey" TEXT NOT NULL, + "encryptionPublicKey" TEXT NOT NULL, + "accountProof" TEXT NOT NULL, + "keyProof" TEXT NOT NULL, + "accountAddress" TEXT NOT NULL, + "appId" TEXT NOT NULL, + "sessionToken" TEXT NOT NULL, + "sessionTokenExpires" DATETIME NOT NULL, + CONSTRAINT "AppIdentity_accountAddress_fkey" FOREIGN KEY ("accountAddress") REFERENCES "Account" ("address") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_AppIdentity" ("accountAddress", "accountProof", "address", "appId", "ciphertext", "encryptionPublicKey", "keyProof", "sessionToken", "sessionTokenExpires", "signaturePublicKey") SELECT "accountAddress", "accountProof", "address", "appId", "ciphertext", "encryptionPublicKey", "keyProof", "sessionToken", "sessionTokenExpires", "signaturePublicKey" FROM "AppIdentity"; +DROP TABLE "AppIdentity"; +ALTER TABLE "new_AppIdentity" RENAME TO "AppIdentity"; +CREATE INDEX "AppIdentity_sessionToken_idx" ON "AppIdentity"("sessionToken"); +CREATE UNIQUE INDEX "AppIdentity_accountAddress_appId_key" ON "AppIdentity"("accountAddress", "appId"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/apps/server-new/prisma/migrations/migration_lock.toml b/apps/server-new/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..2a5a4441 --- /dev/null +++ b/apps/server-new/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/apps/server-new/prisma/schema.prisma b/apps/server-new/prisma/schema.prisma new file mode 100644 index 00000000..d5f629e2 --- /dev/null +++ b/apps/server-new/prisma/schema.prisma @@ -0,0 +1,189 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client" + output = "generated/client" + moduleFormat = "esm" + binaryTargets = ["native", "linux-musl-openssl-3.0.x", "linux-musl-arm64-openssl-3.0.x"] // linux needed for the deployment +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model SpaceEvent { + id String @id + event String + state String + counter Int + space Space @relation(fields: [spaceId], references: [id]) + spaceId String + createdAt DateTime @default(now()) + inboxes SpaceInbox[] + + @@unique([spaceId, counter]) +} + +model Space { + id String @id + events SpaceEvent[] + members Account[] @relation("space-members") + invitations Invitation[] + keys SpaceKey[] + updates Update[] + inboxes SpaceInbox[] + appIdentities AppIdentity[] + name String // TODO: remove this field and use infoContent instead + infoContent Bytes + infoAuthor Account @relation(fields: [infoAuthorAddress], references: [address]) + infoAuthorAddress String + infoSignatureHex String + infoSignatureRecovery Int +} + +model SpaceKey { + id String @id + space Space @relation(fields: [spaceId], references: [id]) + spaceId String + createdAt DateTime @default(now()) + keyBoxes SpaceKeyBox[] +} + +model SpaceKeyBox { + id String @id + spaceKey SpaceKey @relation(fields: [spaceKeyId], references: [id]) + spaceKeyId String + ciphertext String + nonce String + authorPublicKey String + createdAt DateTime @default(now()) + account Account @relation(fields: [accountAddress], references: [address]) + accountAddress String + appIdentity AppIdentity? @relation(fields: [appIdentityAddress], references: [address]) + appIdentityAddress String? + + @@unique([spaceKeyId, nonce]) +} + +model SpaceInbox { + id String @id + space Space @relation(fields: [spaceId], references: [id]) + spaceId String + isPublic Boolean + authPolicy String + encryptionPublicKey String + encryptedSecretKey String + spaceEvent SpaceEvent @relation(fields: [spaceEventId], references: [id]) + spaceEventId String + messages SpaceInboxMessage[] + createdAt DateTime @default(now()) +} + +model SpaceInboxMessage { + id String @id @default(uuid(4)) + spaceInbox SpaceInbox @relation(fields: [spaceInboxId], references: [id]) + spaceInboxId String + ciphertext String + signatureHex String? + signatureRecovery Int? + authorAccountAddress String? + createdAt DateTime @default(now()) +} + +model Account { + address String @id + spaces Space[] @relation("space-members") + invitations Invitation[] + appIdentities AppIdentity[] + + updates Update[] + inboxes AccountInbox[] + connectAddress String @unique + connectCiphertext String + connectNonce String + connectSignaturePublicKey String + connectEncryptionPublicKey String + connectAccountProof String + connectKeyProof String + infoAuthor Space[] + spaceKeyBoxes SpaceKeyBox[] + connectSignerAddress String +} + +model AppIdentity { + address String @id + ciphertext String + signaturePublicKey String + encryptionPublicKey String + accountProof String + keyProof String + account Account @relation(fields: [accountAddress], references: [address]) + accountAddress String + spaces Space[] + spaceKeyBoxes SpaceKeyBox[] + appId String + sessionToken String + sessionTokenExpires DateTime + + @@unique([accountAddress, appId]) + @@index([sessionToken]) +} + +model AccountInbox { + id String @id + account Account @relation(fields: [accountAddress], references: [address]) + accountAddress String + isPublic Boolean + authPolicy String + encryptionPublicKey String + signatureHex String + signatureRecovery Int + messages AccountInboxMessage[] + createdAt DateTime @default(now()) +} + +model AccountInboxMessage { + id String @id @default(uuid(7)) + accountInbox AccountInbox @relation(fields: [accountInboxId], references: [id]) + accountInboxId String + ciphertext String + signatureHex String? + signatureRecovery Int? + authorAccountAddress String? + createdAt DateTime @default(now()) +} + +model Invitation { + id String @id + space Space @relation(fields: [spaceId], references: [id]) + spaceId String + account Account @relation(fields: [accountAddress], references: [address]) + accountAddress String + inviteeAccountAddress String + createdAt DateTime @default(now()) + targetApps InvitationTargetApp[] + + @@unique([spaceId, inviteeAccountAddress]) +} + +model InvitationTargetApp { + id String @id + invitation Invitation @relation(fields: [invitationId], references: [id]) + invitationId String +} + +model Update { + space Space @relation(fields: [spaceId], references: [id]) + spaceId String + clock Int + content Bytes + account Account @relation(fields: [accountAddress], references: [address]) + accountAddress String + signatureHex String + signatureRecovery Int + updateId String + + @@id([spaceId, clock]) +} diff --git a/apps/server-new/setupTests.ts b/apps/server-new/setupTests.ts new file mode 100644 index 00000000..908ec455 --- /dev/null +++ b/apps/server-new/setupTests.ts @@ -0,0 +1 @@ +import '@effect/vitest'; diff --git a/apps/server-new/specs/README.md b/apps/server-new/specs/README.md new file mode 100644 index 00000000..99164d04 --- /dev/null +++ b/apps/server-new/specs/README.md @@ -0,0 +1,20 @@ +# Specifications + +This directory contains feature specifications following our spec-driven development approach. + +## Structure + +Each feature has its own directory containing: + +- `instructions.md` - Initial feature requirements and user stories +- `requirements.md` - Detailed structured requirements +- `design.md` - Technical design and implementation strategy +- `plan.md` - Implementation plan and progress tracking + +## Workflow + +1. **Specification Phase**: Create feature folder and document requirements/design +2. **Implementation Phase**: Follow design specifications and track progress +3. **Validation Phase**: Ensure implementation matches specifications + +## Features diff --git a/apps/server-new/src/config/database.ts b/apps/server-new/src/config/database.ts new file mode 100644 index 00000000..f0647257 --- /dev/null +++ b/apps/server-new/src/config/database.ts @@ -0,0 +1,6 @@ +import { Config } from 'effect'; + +/** + * Database configuration + */ +export const databaseUrlConfig = Config.string('DATABASE_URL').pipe(Config.withDefault('file:./dev.db')); diff --git a/apps/server-new/src/config/honeycomb.ts b/apps/server-new/src/config/honeycomb.ts new file mode 100644 index 00000000..b12d90c7 --- /dev/null +++ b/apps/server-new/src/config/honeycomb.ts @@ -0,0 +1,6 @@ +import { Config } from 'effect'; + +/** + * Honeycomb configuration + */ +export const honeycombApiKeyConfig = Config.redacted('HONEYCOMB_API_KEY').pipe(Config.option); diff --git a/apps/server-new/src/config/privy.ts b/apps/server-new/src/config/privy.ts new file mode 100644 index 00000000..8641703f --- /dev/null +++ b/apps/server-new/src/config/privy.ts @@ -0,0 +1,15 @@ +import { Config } from 'effect'; + +/** + * Privy configuration + */ +export const privyAppIdConfig = Config.string('PRIVY_APP_ID'); +export const privyAppSecretConfig = Config.redacted('PRIVY_APP_SECRET'); + +/** + * Load and validate Privy configuration + */ +export const privyConfig = Config.all({ + appId: privyAppIdConfig, + appSecret: privyAppSecretConfig, +}); diff --git a/apps/server-new/src/config/server.ts b/apps/server-new/src/config/server.ts new file mode 100644 index 00000000..5b2f3bb3 --- /dev/null +++ b/apps/server-new/src/config/server.ts @@ -0,0 +1,19 @@ +import { Config, Effect } from 'effect'; + +/** + * Server configuration + */ +export const serverPortConfig = Config.number('PORT').pipe(Config.withDefault(3030)); + +export const hypergraphChainConfig = Config.string('HYPERGRAPH_CHAIN').pipe(Config.withDefault('geo-testnet')); + +export const hypergraphRpcUrlConfig = Config.string('HYPERGRAPH_RPC_URL').pipe(Config.option); + +/** + * Load all server configuration + */ +export const serverConfig = Effect.all({ + port: serverPortConfig, + hypergraphChain: hypergraphChainConfig, + hypergraphRpcUrl: hypergraphRpcUrlConfig, +}); diff --git a/apps/server-new/src/domain/models.ts b/apps/server-new/src/domain/models.ts new file mode 100644 index 00000000..a6600160 --- /dev/null +++ b/apps/server-new/src/domain/models.ts @@ -0,0 +1,174 @@ +import { Messages, SignatureWithRecovery } from '@graphprotocol/hypergraph'; +import { Schema } from 'effect'; + +/** + * Re-export existing schemas from Hypergraph Messages + */ +export { SignatureWithRecovery }; +export const KeyBox = Messages.KeyBox; +export const KeyBoxWithKeyId = Messages.KeyBoxWithKeyId; +export const IdentityKeyBox = Messages.IdentityKeyBox; +export const SignedUpdate = Messages.SignedUpdate; +export const Updates = Messages.Updates; +export const InboxMessage = Messages.InboxMessage; + +/** + * Inbox auth policy (from Hypergraph) + */ +export const InboxSenderAuthPolicy = Schema.Literal('requires_auth', 'anonymous', 'optional_auth'); + +/** + * Database entity schemas (Prisma-based) + */ +export const Account = Schema.Struct({ + address: Schema.String, + connectAddress: Schema.String, + connectCiphertext: Schema.String, + connectNonce: Schema.String, + connectSignaturePublicKey: Schema.String, + connectEncryptionPublicKey: Schema.String, + connectAccountProof: Schema.String, + connectKeyProof: Schema.String, + connectSignerAddress: Schema.String, +}); + +export const AppIdentity = Schema.Struct({ + address: Schema.String, + ciphertext: Schema.String, + signaturePublicKey: Schema.String, + encryptionPublicKey: Schema.String, + accountProof: Schema.String, + keyProof: Schema.String, + accountAddress: Schema.String, + appId: Schema.String, + sessionToken: Schema.String, + sessionTokenExpires: Schema.DateFromSelf, +}); + +export const Space = Schema.Struct({ + id: Schema.String, + name: Schema.String, + infoContent: Schema.Uint8Array, + infoAuthorAddress: Schema.String, + infoSignatureHex: Schema.String, + infoSignatureRecovery: Schema.Number, +}); + +export const SpaceEvent = Schema.Struct({ + id: Schema.String, + event: Schema.String, + state: Schema.String, + counter: Schema.Number, + spaceId: Schema.String, + createdAt: Schema.DateFromSelf, +}); + +export const SpaceKey = Schema.Struct({ + id: Schema.String, + spaceId: Schema.String, + createdAt: Schema.DateFromSelf, +}); + +export const SpaceKeyBox = Schema.Struct({ + id: Schema.String, + spaceKeyId: Schema.String, + ciphertext: Schema.String, + nonce: Schema.String, + authorPublicKey: Schema.String, + accountAddress: Schema.String, + appIdentityAddress: Schema.optional(Schema.String), + createdAt: Schema.DateFromSelf, +}); + +export const Update = Schema.Struct({ + spaceId: Schema.String, + clock: Schema.Number, + content: Schema.Uint8Array, + accountAddress: Schema.String, + signatureHex: Schema.String, + signatureRecovery: Schema.Number, + updateId: Schema.String, +}); + +export const SpaceInbox = Schema.Struct({ + id: Schema.String, + spaceId: Schema.String, + isPublic: Schema.Boolean, + authPolicy: InboxSenderAuthPolicy, + encryptionPublicKey: Schema.String, + encryptedSecretKey: Schema.String, + spaceEventId: Schema.String, + createdAt: Schema.DateFromSelf, +}); + +export const SpaceInboxMessage = Schema.Struct({ + id: Schema.String, + spaceInboxId: Schema.String, + ciphertext: Schema.String, + signatureHex: Schema.optional(Schema.String), + signatureRecovery: Schema.optional(Schema.Number), + authorAccountAddress: Schema.optional(Schema.String), + createdAt: Schema.DateFromSelf, +}); + +export const AccountInbox = Schema.Struct({ + id: Schema.String, + accountAddress: Schema.String, + isPublic: Schema.Boolean, + authPolicy: InboxSenderAuthPolicy, + encryptionPublicKey: Schema.String, + signatureHex: Schema.String, + signatureRecovery: Schema.Number, + createdAt: Schema.DateFromSelf, +}); + +export const AccountInboxMessage = Schema.Struct({ + id: Schema.String, + accountInboxId: Schema.String, + ciphertext: Schema.String, + signatureHex: Schema.optional(Schema.String), + signatureRecovery: Schema.optional(Schema.Number), + authorAccountAddress: Schema.optional(Schema.String), + createdAt: Schema.DateFromSelf, +}); + +export const Invitation = Schema.Struct({ + id: Schema.String, + spaceId: Schema.String, + accountAddress: Schema.String, + inviteeAccountAddress: Schema.String, + createdAt: Schema.DateFromSelf, +}); + +export const InvitationTargetApp = Schema.Struct({ + id: Schema.String, + invitationId: Schema.String, +}); + +/** + * API response schemas + */ +export const SpaceInboxPublic = Schema.Struct({ + id: Schema.String, + spaceId: Schema.String, + isPublic: Schema.Boolean, + authPolicy: InboxSenderAuthPolicy, + encryptionPublicKey: Schema.String, +}); + +export const AccountInboxPublic = Schema.Struct({ + id: Schema.String, + accountAddress: Schema.String, + isPublic: Schema.Boolean, + authPolicy: InboxSenderAuthPolicy, + encryptionPublicKey: Schema.String, +}); + +export const PublicIdentity = Schema.Struct({ + accountAddress: Schema.String, + signaturePublicKey: Schema.String, + encryptionPublicKey: Schema.String, + accountProof: Schema.String, + keyProof: Schema.String, + appId: Schema.optional(Schema.String), +}); diff --git a/apps/server-new/src/http/api.ts b/apps/server-new/src/http/api.ts new file mode 100644 index 00000000..577a5c4d --- /dev/null +++ b/apps/server-new/src/http/api.ts @@ -0,0 +1,208 @@ +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from '@effect/platform'; +import { Messages } from '@graphprotocol/hypergraph'; +import { Schema } from 'effect'; +import * as Models from '../domain/models.js'; +import * as Errors from './errors.js'; + +/** + * Path Parameters + */ +export const appId = HttpApiSchema.param('appId', Schema.String); +export const spaceId = HttpApiSchema.param('spaceId', Schema.String); +export const inboxId = HttpApiSchema.param('inboxId', Schema.String); +export const accountAddress = HttpApiSchema.param('accountAddress', Schema.String); + +/** + * API Request/Response Schemas + */ +export class AppIdentityInfo extends Schema.Class('AppIdentityInfo')({ + appId: Schema.String, + address: Schema.String, +}) {} + +export class SpaceKeyBoxInfo extends Schema.Class('SpaceKeyBoxInfo')({ + id: Schema.String, + ciphertext: Schema.String, + nonce: Schema.String, + authorPublicKey: Schema.String, +}) {} + +export class SpaceInfo extends Schema.Class('SpaceInfo')({ + id: Schema.String, + infoContent: Schema.String, + infoAuthorAddress: Schema.String, + infoSignatureHex: Schema.String, + infoSignatureRecovery: Schema.Number, + name: Schema.String, + appIdentities: Schema.Array(AppIdentityInfo), + keyBoxes: Schema.Array(SpaceKeyBoxInfo), +}) {} + +export class ConnectSpacesResponse extends Schema.Class('ConnectSpacesResponse')({ + spaces: Schema.Array(SpaceInfo), +}) {} + +export class SpaceCreationResponse extends Schema.Class('SpaceCreationResponse')({ + space: Schema.Struct({ + id: Schema.String, + }), +}) {} + +export class AppIdentityResponse extends Schema.Class('AppIdentityResponse')({ + appIdentity: Models.AppIdentity, +}) {} + +export class ConnectIdentityQuery extends Schema.Class('ConnectIdentityQuery')({ + accountAddress: Schema.String, +}) {} + +export class IdentityQuery extends Schema.Class('IdentityQuery')({ + accountAddress: Schema.String, + signaturePublicKey: Schema.optional(Schema.String), + appId: Schema.optional(Schema.String), +}) {} + +/** + * Health endpoints + */ +export const statusEndpoint = HttpApiEndpoint.get('status')`/`.addSuccess(Schema.String); + +export const healthGroup = HttpApiGroup.make('Health').add(statusEndpoint); + +/** + * Connect API endpoints (Privy authentication) + */ +export const getConnectSpacesEndpoint = HttpApiEndpoint.get('getConnectSpaces')`/connect/spaces` + .addSuccess(ConnectSpacesResponse) + .addError(Errors.AuthenticationError, { status: 401 }) + .addError(Errors.PrivyConfigError, { status: 500 }); + +export const postConnectSpacesEndpoint = HttpApiEndpoint.post('postConnectSpaces')`/connect/spaces` + .setPayload(Messages.RequestConnectCreateSpaceEvent) + // .addSuccess(SpaceCreationResponse) + .addError(Errors.AuthenticationError, { status: 401 }) + .addError(Errors.ValidationError, { status: 400 }) + .addError(Errors.PrivyConfigError, { status: 500 }); + +export const postConnectAddAppIdentityToSpacesEndpoint = HttpApiEndpoint.post( + 'postConnectAddAppIdentityToSpaces', +)`/connect/add-app-identity-to-spaces` + .setPayload(Messages.RequestConnectAddAppIdentityToSpaces) + // .addSuccess(Schema.Struct({ space: Schema.Unknown })) + .addError(Errors.AuthenticationError, { status: 401 }) + .addError(Errors.ValidationError, { status: 400 }); + +export const postConnectIdentityEndpoint = HttpApiEndpoint.post('postConnectIdentity')`/connect/identity` + .setPayload(Messages.RequestConnectCreateIdentity) + // .addSuccess(Messages.ResponseConnectCreateIdentity) + .addError(Errors.AuthenticationError, { status: 401 }) + .addError(Errors.ResourceAlreadyExistsError, { status: 400 }) + .addError(Errors.OwnershipProofError, { status: 401 }); + +export const getConnectIdentityEncryptedEndpoint = HttpApiEndpoint.get( + 'getConnectIdentityEncrypted', +)`/connect/identity/encrypted` + // .addSuccess(Messages.ResponseIdentityEncrypted) + .addError(Errors.AuthenticationError, { status: 401 }); + +export const getConnectAppIdentityEndpoint = HttpApiEndpoint.get( + 'getConnectAppIdentity', +)`/connect/app-identity/${appId}` + // .addSuccess(AppIdentityResponse) + .addError(Errors.AuthenticationError, { status: 401 }) + .addError(Errors.ResourceNotFoundError, { status: 404 }); + +export const postConnectAppIdentityEndpoint = HttpApiEndpoint.post('postConnectAppIdentity')`/connect/app-identity` + .setPayload(Messages.RequestConnectCreateAppIdentity) + // .addSuccess(AppIdentityResponse) + .addError(Errors.AuthenticationError, { status: 401 }) + .addError(Errors.OwnershipProofError, { status: 401 }); + +export const connectGroup = HttpApiGroup.make('Connect') + .add(getConnectSpacesEndpoint) + .add(postConnectSpacesEndpoint) + .add(postConnectAddAppIdentityToSpacesEndpoint) + .add(postConnectIdentityEndpoint) + .add(getConnectIdentityEncryptedEndpoint) + .add(getConnectAppIdentityEndpoint) + .add(postConnectAppIdentityEndpoint); + +/** + * Identity endpoints + */ +export const getWhoamiEndpoint = HttpApiEndpoint.get('getWhoami')`/whoami` + .addSuccess(Schema.String) + .addError(Errors.AuthenticationError, { status: 401 }); + +export const getConnectIdentityEndpoint = HttpApiEndpoint.get('getConnectIdentity')`/connect/identity` + .setUrlParams(ConnectIdentityQuery) + // .addSuccess(Messages.ResponseIdentity) + .addError(Errors.ResourceNotFoundError, { status: 404 }); + +export const getIdentityEndpoint = HttpApiEndpoint.get('getIdentity')`/identity` + .setUrlParams(IdentityQuery) + // .addSuccess(Messages.ResponseIdentity) + .addError(Errors.ValidationError, { status: 400 }) + .addError(Errors.ResourceNotFoundError, { status: 404 }); + +export const identityGroup = HttpApiGroup.make('Identity') + .add(getWhoamiEndpoint) + .add(getConnectIdentityEndpoint) + .add(getIdentityEndpoint); + +/** + * Inbox endpoints + */ +export const getSpaceInboxesEndpoint = HttpApiEndpoint.get('getSpaceInboxes')`/spaces/${spaceId}/inboxes` + // .addSuccess(Messages.ResponseListSpaceInboxesPublic) + .addError(Errors.DatabaseError, { status: 500 }); + +export const getSpaceInboxEndpoint = HttpApiEndpoint.get('getSpaceInbox')`/spaces/${spaceId}/inboxes/${inboxId}` + // .addSuccess(Messages.ResponseSpaceInboxPublic) + .addError(Errors.DatabaseError, { status: 500 }); + +export const postSpaceInboxMessageEndpoint = HttpApiEndpoint.post( + 'postSpaceInboxMessage', +)`/spaces/${spaceId}/inboxes/${inboxId}/messages` + .setPayload(Messages.RequestCreateSpaceInboxMessage) + .addSuccess(Schema.Void) + .addError(Errors.ValidationError, { status: 400 }) + .addError(Errors.AuthorizationError, { status: 403 }) + .addError(Errors.ResourceNotFoundError, { status: 404 }); + +export const getAccountInboxesEndpoint = HttpApiEndpoint.get('getAccountInboxes')`/accounts/${accountAddress}/inboxes` + // .addSuccess(Messages.ResponseListAccountInboxesPublic) + .addError(Errors.DatabaseError, { status: 500 }); + +export const getAccountInboxEndpoint = HttpApiEndpoint.get( + 'getAccountInbox', +)`/accounts/${accountAddress}/inboxes/${inboxId}` + // .addSuccess(Messages.ResponseAccountInboxPublic) + .addError(Errors.DatabaseError, { status: 500 }); + +export const postAccountInboxMessageEndpoint = HttpApiEndpoint.post( + 'postAccountInboxMessage', +)`/accounts/${accountAddress}/inboxes/${inboxId}/messages` + .setPayload(Messages.RequestCreateAccountInboxMessage) + .addSuccess(Schema.Void) + .addError(Errors.ValidationError, { status: 400 }) + .addError(Errors.AuthorizationError, { status: 403 }) + .addError(Errors.ResourceNotFoundError, { status: 404 }); + +export const inboxGroup = HttpApiGroup.make('Inbox') + .add(getSpaceInboxesEndpoint) + .add(getSpaceInboxEndpoint) + .add(postSpaceInboxMessageEndpoint) + .add(getAccountInboxesEndpoint) + .add(getAccountInboxEndpoint) + .add(postAccountInboxMessageEndpoint); + +/** + * Main API definition + */ +export const hypergraphApi = HttpApi.make('HypergraphApi') + .add(healthGroup) + .add(connectGroup) + .add(identityGroup) + .add(inboxGroup) + .addError(Errors.ResourceNotFoundError, { status: 500 }); diff --git a/apps/server-new/src/http/errors.ts b/apps/server-new/src/http/errors.ts new file mode 100644 index 00000000..dc425e07 --- /dev/null +++ b/apps/server-new/src/http/errors.ts @@ -0,0 +1,105 @@ +import { Schema } from 'effect'; + +/** + * Authentication-related errors + */ +export class AuthenticationError extends Schema.TaggedError()('AuthenticationError', { + message: Schema.String, +}) {} + +export class AuthorizationError extends Schema.TaggedError()('AuthorizationError', { + message: Schema.String, + accountAddress: Schema.optional(Schema.String), +}) {} + +export class InvalidTokenError extends Schema.TaggedError()('InvalidTokenError', { + tokenType: Schema.Literal('privy', 'session'), +}) {} + +export class TokenExpiredError extends Schema.TaggedError()('TokenExpiredError', { + tokenType: Schema.Literal('session'), +}) {} + +/** + * Resource-related errors + */ +export class ResourceNotFoundError extends Schema.TaggedError()('ResourceNotFoundError', { + resource: Schema.String, + id: Schema.String, +}) {} + +export class ResourceAlreadyExistsError extends Schema.TaggedError()( + 'ResourceAlreadyExistsError', + { + resource: Schema.String, + id: Schema.String, + }, +) {} + +/** + * Validation errors + */ +export class ValidationError extends Schema.TaggedError()('ValidationError', { + field: Schema.String, + message: Schema.String, +}) {} + +export class InvalidSignatureError extends Schema.TaggedError()('InvalidSignatureError', { + context: Schema.String, +}) {} + +export class OwnershipProofError extends Schema.TaggedError()('OwnershipProofError', { + accountAddress: Schema.String, + reason: Schema.String, +}) {} + +/** + * External service errors + */ +export class PrivyConfigError extends Schema.TaggedError()('PrivyConfigError', { + message: Schema.String, +}) {} + +export class PrivyTokenError extends Schema.TaggedError()('PrivyTokenError', { + message: Schema.String, +}) {} + +/** + * Database errors + */ +export class DatabaseError extends Schema.TaggedError()('DatabaseError', { + operation: Schema.String, + cause: Schema.Unknown, +}) {} + +export class TransactionError extends Schema.TaggedError()('TransactionError', { + message: Schema.String, + cause: Schema.Unknown, +}) {} + +/** + * Business logic errors + */ +export class InsufficientPermissionsError extends Schema.TaggedError()( + 'InsufficientPermissionsError', + { + resource: Schema.String, + requiredRole: Schema.String, + currentRole: Schema.optional(Schema.String), + }, +) {} + +export class InboxPolicyViolationError extends Schema.TaggedError()( + 'InboxPolicyViolationError', + { + inboxId: Schema.String, + authPolicy: Schema.String, + violation: Schema.String, + }, +) {} + +export class SpaceEventError extends Schema.TaggedError()('SpaceEventError', { + spaceId: Schema.String, + eventType: Schema.String, + reason: Schema.String, +}) {} diff --git a/apps/server-new/src/http/handlers.ts b/apps/server-new/src/http/handlers.ts new file mode 100644 index 00000000..dfe7e604 --- /dev/null +++ b/apps/server-new/src/http/handlers.ts @@ -0,0 +1,166 @@ +import { HttpApiBuilder } from '@effect/platform'; +import { Effect, Layer } from 'effect'; +import * as Api from './api.js'; +import * as Errors from './errors.js'; + +/** + * Health Group Handlers + */ +const HealthGroupLive = HttpApiBuilder.group(Api.hypergraphApi, 'Health', (handlers) => { + return handlers.handle('status', () => Effect.succeed('OK')); +}); + +/** + * Connect Group Handlers + */ +const ConnectGroupLive = HttpApiBuilder.group(Api.hypergraphApi, 'Connect', (handlers) => { + return handlers + .handle( + 'getConnectSpaces', + Effect.fn(function* ({ request }) { + yield* Effect.logInfo('Getting connect spaces'); + return { spaces: [] }; + }), + ) + .handle( + 'postConnectSpaces', + Effect.fn(function* ({ payload }) { + yield* Effect.logInfo('Creating space', payload); + yield* new Errors.ResourceNotFoundError({ + resource: 'postConnectSpaces', + id: 'postConnectSpaces', + }); + }), + ) + .handle( + 'postConnectAddAppIdentityToSpaces', + Effect.fn(function* ({ payload }) { + yield* Effect.logInfo('Adding app identity to spaces', payload); + yield* new Errors.ResourceNotFoundError({ + resource: 'postConnectAddAppIdentityToSpaces', + id: 'postConnectAddAppIdentityToSpaces', + }); + }), + ) + .handle( + 'postConnectIdentity', + Effect.fn(function* ({ payload }) { + yield* Effect.logInfo('Creating connect identity', payload); + yield* new Errors.ResourceNotFoundError({ resource: 'postConnectIdentity', id: 'postConnectIdentity' }); + }), + ) + .handle( + 'getConnectIdentityEncrypted', + Effect.fn(function* ({ request }) { + yield* Effect.logInfo('Getting encrypted identity'); + yield* new Errors.ResourceNotFoundError({ + resource: 'getConnectIdentityEncrypted', + id: 'getConnectIdentityEncrypted', + }); + }), + ) + .handle( + 'getConnectAppIdentity', + Effect.fn(function* ({ path: { appId } }) { + yield* Effect.logInfo(`Getting app identity for appId: ${appId}`); + yield* new Errors.ResourceNotFoundError({ resource: 'getConnectAppIdentity', id: 'getConnectAppIdentity' }); + }), + ) + .handle( + 'postConnectAppIdentity', + Effect.fn(function* ({ payload }) { + yield* Effect.logInfo('Creating app identity', payload); + yield* new Errors.ResourceNotFoundError({ resource: 'postConnectAppIdentity', id: 'postConnectAppIdentity' }); + }), + ); +}); + +/** + * Identity Group Handlers + */ +const IdentityGroupLive = HttpApiBuilder.group(Api.hypergraphApi, 'Identity', (handlers) => { + return handlers + .handle( + 'getWhoami', + Effect.fn(function* () { + yield* Effect.log('Getting whoami'); + yield* Effect.sleep('1 second').pipe(Effect.withSpan('sleeping')); + yield* Effect.sleep('2 second').pipe(Effect.withSpan('sleeping again')); + return 'Hypergraph Server v3'; + }), + ) + .handle( + 'getConnectIdentity', + Effect.fn(function* ({ urlParams }) { + yield* Effect.logInfo('Getting connect identity', urlParams); + yield* new Errors.ResourceNotFoundError({ resource: 'Identity', id: 'connect' }); + }), + ) + .handle( + 'getIdentity', + Effect.fn(function* ({ urlParams }) { + yield* Effect.logInfo('Getting identity', urlParams); + yield* new Errors.ResourceNotFoundError({ resource: 'Identity', id: 'general' }); + }), + ); +}); + +/** + * Inbox Group Handlers + */ +const InboxGroupLive = HttpApiBuilder.group(Api.hypergraphApi, 'Inbox', (handlers) => { + return handlers + .handle( + 'getSpaceInboxes', + Effect.fn(function* ({ path: { spaceId } }) { + yield* Effect.logInfo(`Getting space inboxes: ${spaceId}`); + yield* new Errors.ResourceNotFoundError({ + resource: 'getSpaceInboxes', + id: 'getSpaceInboxes', + }); + }), + ) + .handle( + 'getSpaceInbox', + Effect.fn(function* ({ path: { spaceId, inboxId } }) { + yield* Effect.logInfo(`Getting space inbox: ${spaceId}/${inboxId}`); + yield* new Errors.ResourceNotFoundError({ resource: 'SpaceInbox', id: inboxId }); + }), + ) + .handle( + 'postSpaceInboxMessage', + Effect.fn(function* ({ path: { spaceId, inboxId }, payload }) { + yield* Effect.logInfo(`Posting message to space inbox: ${spaceId}/${inboxId}`, payload); + return { success: true }; + }), + ) + .handle( + 'getAccountInboxes', + Effect.fn(function* ({ path: { accountAddress } }) { + yield* Effect.logInfo(`Getting account inboxes: ${accountAddress}`); + yield* new Errors.ResourceNotFoundError({ + resource: 'getAccountInboxes', + id: 'getAccountInboxes', + }); + }), + ) + .handle( + 'getAccountInbox', + Effect.fn(function* ({ path: { accountAddress, inboxId } }) { + yield* Effect.logInfo(`Getting account inbox: ${accountAddress}/${inboxId}`); + yield* new Errors.ResourceNotFoundError({ resource: 'AccountInbox', id: inboxId }); + }), + ) + .handle( + 'postAccountInboxMessage', + Effect.fn(function* ({ path: { accountAddress, inboxId }, payload }) { + yield* Effect.logInfo(`Posting message to account inbox: ${accountAddress}/${inboxId}`, payload); + return { success: true }; + }), + ); +}); + +/** + * All handlers combined + */ +export const HandlersLive = Layer.mergeAll(HealthGroupLive, ConnectGroupLive, IdentityGroupLive, InboxGroupLive); diff --git a/apps/server-new/src/index.ts b/apps/server-new/src/index.ts new file mode 100644 index 00000000..1a7bdb7b --- /dev/null +++ b/apps/server-new/src/index.ts @@ -0,0 +1,36 @@ +import * as Otlp from '@effect/opentelemetry/Otlp'; +import { FetchHttpClient, PlatformConfigProvider } from '@effect/platform'; +import { NodeContext, NodeRuntime } from '@effect/platform-node'; +import { Effect, Layer, Logger, Option, Redacted } from 'effect'; +import * as Config from './config/honeycomb.ts'; +import { server } from './server.ts'; + +const Observability = Layer.unwrapEffect( + Effect.gen(function* () { + const apiKey = yield* Config.honeycombApiKeyConfig; + if (Option.isNone(apiKey)) { + return Layer.empty; + } + + return Otlp.layer({ + baseUrl: 'https://api.honeycomb.io', + headers: { + 'x-honeycomb-team': Redacted.value(apiKey.value), + }, + resource: { + serviceName: 'hypergraph-server', + }, + }).pipe(Layer.provide(FetchHttpClient.layer)); + }), +); + +const layer = server.pipe( + Layer.provide(Logger.structured), + Layer.provide(Observability), + Layer.provide(PlatformConfigProvider.layerDotEnvAdd('.env')), + Layer.provide(NodeContext.layer), +); + +NodeRuntime.runMain(Layer.launch(layer), { + disablePrettyLogger: true, +}); diff --git a/apps/server-new/src/server.ts b/apps/server-new/src/server.ts new file mode 100644 index 00000000..15dbb93e --- /dev/null +++ b/apps/server-new/src/server.ts @@ -0,0 +1,20 @@ +import { HttpApiBuilder, HttpServer } from '@effect/platform'; +import { NodeHttpServer } from '@effect/platform-node'; +import { Effect, Layer } from 'effect'; +import { createServer } from 'node:http'; +import { serverPortConfig } from './config/server.ts'; +import { hypergraphApi } from './http/api.ts'; +import { HandlersLive } from './http/handlers.ts'; + +const apiLive = HttpApiBuilder.api(hypergraphApi).pipe(Layer.provide(HandlersLive)); + +export const server = Layer.unwrapEffect( + Effect.gen(function* () { + const port = yield* serverPortConfig; + return HttpApiBuilder.serve().pipe( + Layer.provide(apiLive), + HttpServer.withLogAddress, + Layer.provide(NodeHttpServer.layer(createServer, { port })), + ); + }), +); diff --git a/apps/server-new/src/services/auth.ts b/apps/server-new/src/services/auth.ts new file mode 100644 index 00000000..cca4a68d --- /dev/null +++ b/apps/server-new/src/services/auth.ts @@ -0,0 +1,61 @@ +import { PrivyClient } from '@privy-io/server-auth'; +import { Context, Effect, Layer, Redacted } from 'effect'; +import * as Config from '../config/privy.js'; + +/** + * Auth service interface + */ +export interface AuthService { + readonly privy: PrivyClient; + readonly verifyAuthToken: (token: string) => Effect.Effect<{ userId: string }, Error>; + readonly verifySessionToken: (token: string) => Effect.Effect<{ address: string }, Error>; +} + +/** + * Auth service tag + */ +export const AuthService = Context.GenericTag('AuthService'); + +/** + * Auth service implementation + */ +export const makeAuthService = Effect.fn(function* () { + const config = yield* Config.privyConfig; + const privy = new PrivyClient(config.appId, Redacted.value(config.appSecret)); + + const verifyAuthToken = Effect.fn(function* (token: string) { + const user = yield* Effect.tryPromise({ + try: () => privy.getUser({ idToken: token }), + catch: (error) => new Error(`Failed to verify auth token: ${error}`), + }); + + if (!user) { + yield* Effect.fail(new Error('User not found')); + } + + return { userId: user.id }; + }); + + const verifySessionToken = Effect.fn(function* (_token: string) { + // TODO: Implement session token verification logic + // This would typically involve: + // 1. Decoding the JWT token + // 2. Verifying the signature + // 3. Checking expiration + // 4. Extracting the address + + // For now, return a placeholder + return { address: 'placeholder' }; + }); + + return { + privy, + verifyAuthToken, + verifySessionToken, + } as const; +}); + +/** + * Auth service layer + */ +export const AuthServiceLive = Layer.effect(AuthService, makeAuthService()); diff --git a/apps/server-new/src/services/database.ts b/apps/server-new/src/services/database.ts new file mode 100644 index 00000000..5948d962 --- /dev/null +++ b/apps/server-new/src/services/database.ts @@ -0,0 +1,64 @@ +import { PrismaClient } from '@prisma/client'; +import { Context, Effect, Layer } from 'effect'; + +/** + * Database service interface + */ +export interface DatabaseService { + readonly client: PrismaClient; +} + +/** + * Database service tag + */ +export const DatabaseService = Context.GenericTag('DatabaseService'); + +/** + * Database service implementation + */ +export const makeDatabaseService = Effect.fn(function* () { + const client = new PrismaClient(); + + // Connect to database + yield* Effect.tryPromise({ + try: () => client.$connect(), + catch: (error) => new Error(`Failed to connect to database: ${error}`), + }); + + return { + client, + } as const; +}); + +/** + * Database service layer + */ +export const DatabaseServiceLive = Layer.effect(DatabaseService, makeDatabaseService()); + +/** + * Database service layer with resource management + */ +export const DatabaseServiceLiveWithCleanup = Layer.scoped( + DatabaseService, + Effect.fn(function* () { + const client = new PrismaClient(); + + // Connect to database + yield* Effect.tryPromise({ + try: () => client.$connect(), + catch: (error) => new Error(`Failed to connect to database: ${error}`), + }); + + // Register cleanup + yield* Effect.addFinalizer(() => + Effect.tryPromise({ + try: () => client.$disconnect(), + catch: (error) => new Error(`Failed to disconnect from database: ${error}`), + }).pipe(Effect.ignore), + ); + + return { + client, + } as const; + })(), +); diff --git a/apps/server-new/tsconfig.app.json b/apps/server-new/tsconfig.app.json new file mode 100644 index 00000000..706602e6 --- /dev/null +++ b/apps/server-new/tsconfig.app.json @@ -0,0 +1,29 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "tsup.config.ts"], + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + "composite": false, + "incremental": false, + "declaration": false, + "declarationMap": false, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "exactOptionalPropertyTypes": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + } +} \ No newline at end of file diff --git a/apps/server-new/tsconfig.json b/apps/server-new/tsconfig.json new file mode 100644 index 00000000..706602e6 --- /dev/null +++ b/apps/server-new/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "tsup.config.ts"], + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + "composite": false, + "incremental": false, + "declaration": false, + "declarationMap": false, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "exactOptionalPropertyTypes": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + } +} \ No newline at end of file diff --git a/apps/server-new/tsconfig.node.json b/apps/server-new/tsconfig.node.json new file mode 100644 index 00000000..d462d771 --- /dev/null +++ b/apps/server-new/tsconfig.node.json @@ -0,0 +1,29 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["vitest.config.ts"], + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + "composite": false, + "incremental": false, + "declaration": false, + "declarationMap": false, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "exactOptionalPropertyTypes": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + } +} \ No newline at end of file diff --git a/apps/server-new/tsup.config.ts b/apps/server-new/tsup.config.ts new file mode 100644 index 00000000..2ca0402c --- /dev/null +++ b/apps/server-new/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + target: 'node22', + clean: true, + sourcemap: true, + minify: false, + splitting: false, + dts: false, + external: ['@prisma/client'], +}); diff --git a/apps/server-new/vitest.config.ts b/apps/server-new/vitest.config.ts new file mode 100644 index 00000000..60051f09 --- /dev/null +++ b/apps/server-new/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: false, + include: ['test/**/*.test.ts', 'src/**/*.test.ts'], + setupFiles: ['./setupTests.ts'], + testTimeout: 30000, + }, +}); diff --git a/apps/server/api-docs/api-summary.md b/apps/server/api-docs/api-summary.md new file mode 100644 index 00000000..9a6dff6e --- /dev/null +++ b/apps/server/api-docs/api-summary.md @@ -0,0 +1,81 @@ +# API Routes Summary + +## HTTP Endpoints + +### Health Check +- **GET /** - Server status check + +### Connect API (Privy Authentication) +- **GET /connect/spaces** - List spaces for authenticated account +- **POST /connect/spaces** - Create new space +- **POST /connect/add-app-identity-to-spaces** - Add app identity to spaces +- **POST /connect/identity** - Create connect identity +- **GET /connect/identity/encrypted** - Get encrypted identity data +- **GET /connect/app-identity/:appId** - Get app identity by ID +- **POST /connect/app-identity** - Create app identity + +### Identity API +- **GET /whoami** - Get current account from session token +- **GET /connect/identity** - Get public connect identity (public) +- **GET /identity** - Get identity by public key or app ID (public) + +### Inbox API (Public) +- **GET /spaces/:spaceId/inboxes** - List public space inboxes +- **GET /spaces/:spaceId/inboxes/:inboxId** - Get space inbox details +- **POST /spaces/:spaceId/inboxes/:inboxId/messages** - Post to space inbox +- **GET /accounts/:accountAddress/inboxes** - List public account inboxes +- **GET /accounts/:accountAddress/inboxes/:inboxId** - Get account inbox details +- **POST /accounts/:accountAddress/inboxes/:inboxId/messages** - Post to account inbox + +## WebSocket Endpoints + +### Connection +- **ws://?token=[sessionToken]** - Establish WebSocket connection + +### Message Types +- **subscribe-space** - Subscribe to space updates +- **list-spaces** - List accessible spaces +- **list-invitations** - List pending invitations +- **create-space-event** - Create new space +- **create-invitation-event** - Invite to space +- **accept-invitation-event** - Accept invitation +- **create-space-inbox-event** - Create space inbox +- **create-account-inbox** - Create account inbox +- **get-latest-space-inbox-messages** - Get recent inbox messages +- **get-latest-account-inbox-messages** - Get recent account messages +- **get-account-inboxes** - List account inboxes +- **create-update** - Create CRDT update + +### Broadcast Events +- **space-event** - Space event notification +- **updates-notification** - CRDT updates +- **space-inbox-message** - New inbox message +- **account-inbox-message** - New account message +- **account-inbox** - Account inbox created + +## Authentication Methods + +1. **Privy ID Token**: Used by Connect app endpoints + - Header: `privy-id-token` + - Validates signer permissions + +2. **Session Token**: Used by app identities + - Header: `Authorization: Bearer ` + - 30-day expiry + +3. **Public Endpoints**: No authentication required + - Identity lookups + - Public inbox access + +## Common Response Formats + +### Success +- 200 OK with JSON response +- Empty object `{}` for successful writes + +### Errors +- 400 Bad Request - Invalid parameters +- 401 Unauthorized - Authentication failed +- 403 Forbidden - Insufficient permissions +- 404 Not Found - Resource not found +- 500 Internal Server Error - Server error \ No newline at end of file diff --git a/apps/server/api-docs/domain-model-overview.md b/apps/server/api-docs/domain-model-overview.md new file mode 100644 index 00000000..cffcfff6 --- /dev/null +++ b/apps/server/api-docs/domain-model-overview.md @@ -0,0 +1,212 @@ +# Domain Model Overview + +## Core Entities + +### Account +Central user entity representing an identity in the system. +```prisma +model Account { + address String @id + spaces Space[] @relation("space-members") + invitations Invitation[] + appIdentities AppIdentity[] + updates Update[] + inboxes AccountInbox[] + connectAddress String @unique + connectCiphertext String + connectNonce String + connectSignaturePublicKey String + connectEncryptionPublicKey String + connectAccountProof String + connectKeyProof String + connectSignerAddress String + spaceKeyBoxes SpaceKeyBox[] + infoAuthor Space[] +} +``` + +### Space +Collaborative workspace containing members, events, and data. +```prisma +model Space { + id String @id + events SpaceEvent[] + members Account[] @relation("space-members") + invitations Invitation[] + keys SpaceKey[] + updates Update[] + inboxes SpaceInbox[] + appIdentities AppIdentity[] + name String + infoContent Bytes + infoAuthorAddress String + infoSignatureHex String + infoSignatureRecovery Int +} +``` + +### AppIdentity +Application-specific identity linked to an account. +```prisma +model AppIdentity { + address String @id + ciphertext String + signaturePublicKey String + encryptionPublicKey String + accountProof String + keyProof String + accountAddress String + spaces Space[] + spaceKeyBoxes SpaceKeyBox[] + appId String + sessionToken String + sessionTokenExpires DateTime + @@unique([accountAddress, appId]) +} +``` + +### SpaceEvent +Append-only log of events within a space. +```prisma +model SpaceEvent { + id String @id + event String + state String + counter Int + spaceId String + createdAt DateTime @default(now()) + inboxes SpaceInbox[] + @@unique([spaceId, counter]) +} +``` + +### SpaceKey & SpaceKeyBox +Encryption key management for spaces. +```prisma +model SpaceKey { + id String @id + spaceId String + createdAt DateTime @default(now()) + keyBoxes SpaceKeyBox[] +} + +model SpaceKeyBox { + id String @id + spaceKeyId String + ciphertext String + nonce String + authorPublicKey String + accountAddress String + appIdentityAddress String? + @@unique([spaceKeyId, nonce]) +} +``` + +### Update +CRDT updates for collaborative editing. +```prisma +model Update { + spaceId String + clock Int + content Bytes + accountAddress String + signatureHex String + signatureRecovery Int + updateId String + @@id([spaceId, clock]) +} +``` + +### Inbox System +Message queuing for spaces and accounts. + +#### SpaceInbox & SpaceInboxMessage +```prisma +model SpaceInbox { + id String @id + spaceId String + isPublic Boolean + authPolicy String + encryptionPublicKey String + encryptedSecretKey String + spaceEventId String + messages SpaceInboxMessage[] + createdAt DateTime @default(now()) +} + +model SpaceInboxMessage { + id String @id @default(uuid(4)) + spaceInboxId String + ciphertext String + signatureHex String? + signatureRecovery Int? + authorAccountAddress String? + createdAt DateTime @default(now()) +} +``` + +#### AccountInbox & AccountInboxMessage +```prisma +model AccountInbox { + id String @id + accountAddress String + isPublic Boolean + authPolicy String + encryptionPublicKey String + signatureHex String + signatureRecovery Int + messages AccountInboxMessage[] + createdAt DateTime @default(now()) +} + +model AccountInboxMessage { + id String @id @default(uuid(7)) + accountInboxId String + ciphertext String + signatureHex String? + signatureRecovery Int? + authorAccountAddress String? + createdAt DateTime @default(now()) +} +``` + +### Invitation System +```prisma +model Invitation { + id String @id + spaceId String + accountAddress String + inviteeAccountAddress String + createdAt DateTime @default(now()) + targetApps InvitationTargetApp[] + @@unique([spaceId, inviteeAccountAddress]) +} + +model InvitationTargetApp { + id String @id + invitationId String +} +``` + +## Key Relationships + +1. **Account ↔ Space**: Many-to-many through membership +2. **Account ↔ AppIdentity**: One-to-many, unique per app +3. **Space ↔ SpaceEvent**: One-to-many, append-only log +4. **Space ↔ Update**: One-to-many, CRDT updates +5. **SpaceKey ↔ SpaceKeyBox**: One-to-many, encrypted for each member +6. **Inbox ↔ Message**: One-to-many for both Space and Account inboxes + +## Authentication & Authorization + +1. **Connect Identity**: Primary identity with signature/encryption keys +2. **App Identity**: App-specific identity with session tokens +3. **Session Tokens**: 30-day expiry for app authentication +4. **Privy Integration**: External auth provider for Connect app + +## Security Features + +1. **E2E Encryption**: All sensitive data encrypted client-side +2. **Key Rotation**: New keys generated when members removed +3. **Signature Verification**: All events/messages signed +4. **Ownership Proofs**: Cryptographic proofs for identity claims \ No newline at end of file diff --git a/apps/server/api-docs/get-accounts-inbox.md b/apps/server/api-docs/get-accounts-inbox.md new file mode 100644 index 00000000..09b3ef31 --- /dev/null +++ b/apps/server/api-docs/get-accounts-inbox.md @@ -0,0 +1,60 @@ +# GET /accounts/:accountAddress/inboxes/:inboxId + +## Overview +Retrieves details for a specific public inbox belonging to an account. + +## HTTP Method +GET + +## Route +`/accounts/:accountAddress/inboxes/:inboxId` + +## Authentication +None required (public endpoint) + +## Request Parameters +- `accountAddress`: The account address (URL parameter) +- `inboxId`: The inbox ID (URL parameter) + +## Request Headers +None + +## Request Body +None + +## Response +### Success Response (200 OK) +Schema: `Messages.ResponseAccountInboxPublic` +```json +{ + "inbox": { + "id": "string", + "accountAddress": "string", + "isPublic": true, + "authPolicy": "string", // "requires_auth" | "anonymous" | "optional_auth" + "encryptionPublicKey": "string" + } +} +``` + +### Error Responses +- 500 Internal Server Error: Database or server error + +## Domain Model +### AccountInbox +- `id`: string (primary key) +- `accountAddress`: string (foreign key to Account) +- `isPublic`: boolean +- `authPolicy`: string +- `encryptionPublicKey`: string +- `signatureHex`: string +- `signatureRecovery`: integer + +## Implementation Details +- No authentication required (public endpoint) +- Uses `getAccountInbox` handler to fetch specific inbox +- Returns public inbox details if found +- Note: Handler should verify inbox is public before returning + +## Dependencies +- `getAccountInbox`: Fetches specific inbox from database \ No newline at end of file diff --git a/apps/server/api-docs/get-accounts-inboxes.md b/apps/server/api-docs/get-accounts-inboxes.md new file mode 100644 index 00000000..0b6644c0 --- /dev/null +++ b/apps/server/api-docs/get-accounts-inboxes.md @@ -0,0 +1,66 @@ +# GET /accounts/:accountAddress/inboxes + +## Overview +Lists all public inboxes for a specific account. + +## HTTP Method +GET + +## Route +`/accounts/:accountAddress/inboxes` + +## Authentication +None required (public endpoint) + +## Request Parameters +- `accountAddress`: The account address (URL parameter) + +## Request Headers +None + +## Request Body +None + +## Response +### Success Response (200 OK) +Schema: `Messages.ResponseListAccountInboxesPublic` +```json +{ + "inboxes": [ + { + "id": "string", + "accountAddress": "string", + "isPublic": true, + "authPolicy": "string", // "requires_auth" | "anonymous" | "optional_auth" + "encryptionPublicKey": "string" + } + ] +} +``` + +### Error Responses +- 500 Internal Server Error: Database or server error + +## Domain Model +### AccountInbox +- `id`: string (primary key) +- `accountAddress`: string (foreign key to Account) +- `isPublic`: boolean +- `authPolicy`: string +- `encryptionPublicKey`: string +- `signatureHex`: string +- `signatureRecovery`: integer + +### Account +- `address`: string (primary key) +- `inboxes`: AccountInbox[] relation + +## Implementation Details +- No authentication required (public endpoint) +- Uses `listPublicAccountInboxes` handler to: + - Query all inboxes for the account + - Filter to only public inboxes (isPublic = true) +- Returns list of public inbox metadata + +## Dependencies +- `listPublicAccountInboxes`: Fetches public inboxes from database \ No newline at end of file diff --git a/apps/server/api-docs/get-connect-app-identity.md b/apps/server/api-docs/get-connect-app-identity.md new file mode 100644 index 00000000..c01c2e2f --- /dev/null +++ b/apps/server/api-docs/get-connect-app-identity.md @@ -0,0 +1,69 @@ +# GET /connect/app-identity/:appId + +## Overview +Retrieves app identity information for a specific app ID and authenticated account. + +## HTTP Method +GET + +## Route +`/connect/app-identity/:appId` + +## Authentication +Required - Privy ID token authentication + +## Request Parameters +- `appId`: The application ID (URL parameter) + +## Request Headers +- `privy-id-token`: Privy authentication token (required) +- `account-address`: The account address (required) + +## Request Body +None + +## Response +### Success Response (200 OK) +```json +{ + "appIdentity": { + "address": "string", + "appId": "string", + "accountAddress": "string", + "sessionToken": "string", + "sessionTokenExpires": "datetime", + // Additional fields from AppIdentity model + } +} +``` + +### Error Responses +- 401 Unauthorized: Invalid authentication or insufficient permissions +- 404 Not Found: App identity not found +- 500 Internal Server Error: Missing Privy configuration + +## Domain Model +### AppIdentity +- `address`: string (primary key) +- `appId`: string +- `accountAddress`: string (foreign key to Account) +- `ciphertext`: string +- `signaturePublicKey`: string +- `encryptionPublicKey`: string +- `accountProof`: string +- `keyProof`: string +- `sessionToken`: string +- `sessionTokenExpires`: datetime + +## Implementation Details +- Validates Privy token to get signer address +- Verifies signer has permission for the specified account +- Uses `findAppIdentity` handler to search for app identity by: + - Account address + - App ID +- Returns app identity if found, 404 if not found + +## Dependencies +- `getAddressByPrivyToken`: Validates Privy token +- `isSignerForAccount`: Verifies signer permissions +- `findAppIdentity`: Searches for app identity in database \ No newline at end of file diff --git a/apps/server/api-docs/get-connect-identity-encrypted.md b/apps/server/api-docs/get-connect-identity-encrypted.md new file mode 100644 index 00000000..d30444e4 --- /dev/null +++ b/apps/server/api-docs/get-connect-identity-encrypted.md @@ -0,0 +1,63 @@ +# GET /connect/identity/encrypted + +## Overview +Retrieves the encrypted identity data for an authenticated account. + +## HTTP Method +GET + +## Route +`/connect/identity/encrypted` + +## Authentication +Required - Privy ID token authentication + +## Request Parameters +None + +## Request Headers +- `privy-id-token`: Privy authentication token (required) +- `account-address`: The account address to retrieve identity for (required) + +## Request Body +None + +## Response +### Success Response (200 OK) +Schema: `Messages.ResponseIdentityEncrypted` +```json +{ + "keyBox": { + "accountAddress": "string", + "ciphertext": "string", + "nonce": "string", + "signer": "string" + } +} +``` + +### Error Responses +- 401 Unauthorized: Invalid authentication or insufficient permissions +- 500 Internal Server Error: Missing Privy configuration + +## Domain Model +### Account +- `address`: string (primary key) +- `connectCiphertext`: string +- `connectNonce`: string +- `connectSignerAddress`: string + +## Implementation Details +- Validates Privy token to get signer address +- Verifies signer has permission for the specified account +- Uses `getConnectIdentity` handler to fetch encrypted identity data +- Returns encrypted keyBox with: + - Account address + - Encrypted ciphertext + - Nonce for decryption + - Signer address + +## Dependencies +- `getAddressByPrivyToken`: Validates Privy token +- `isSignerForAccount`: Verifies signer permissions +- `getConnectIdentity`: Fetches identity from database \ No newline at end of file diff --git a/apps/server/api-docs/get-connect-identity.md b/apps/server/api-docs/get-connect-identity.md new file mode 100644 index 00000000..013c3a81 --- /dev/null +++ b/apps/server/api-docs/get-connect-identity.md @@ -0,0 +1,63 @@ +# GET /connect/identity + +## Overview +Retrieves public identity information for a given account address. + +## HTTP Method +GET + +## Route +`/connect/identity` + +## Authentication +None required (public endpoint) + +## Request Parameters +- `accountAddress`: The account address to look up (query parameter, required) + +## Request Headers +None + +## Request Body +None + +## Response +### Success Response (200 OK) +Schema: `Messages.ResponseIdentity` +```json +{ + "accountAddress": "string", + "signaturePublicKey": "string", + "encryptionPublicKey": "string", + "accountProof": "string", + "keyProof": "string" +} +``` + +### Error Responses +- 400 Bad Request: Missing accountAddress parameter +- 404 Not Found: Identity not found + Schema: `Messages.ResponseIdentityNotFoundError` + ```json + { + "accountAddress": "string" + } + ``` + +## Domain Model +### Account +- `address`: string (primary key) +- `connectSignaturePublicKey`: string +- `connectEncryptionPublicKey`: string +- `connectAccountProof`: string +- `connectKeyProof`: string + +## Implementation Details +- No authentication required (public endpoint) +- Validates required accountAddress query parameter +- Uses `getConnectIdentity` handler to fetch identity +- Returns public keys and proofs (no encrypted data) +- Returns 404 with specific error format if identity not found + +## Dependencies +- `getConnectIdentity`: Fetches identity from database \ No newline at end of file diff --git a/apps/server/api-docs/get-connect-spaces.md b/apps/server/api-docs/get-connect-spaces.md new file mode 100644 index 00000000..8481079c --- /dev/null +++ b/apps/server/api-docs/get-connect-spaces.md @@ -0,0 +1,103 @@ +# GET /connect/spaces + +## Overview +Retrieves all spaces associated with an authenticated account, including space information, app identities, and key boxes. + +## HTTP Method +GET + +## Route +`/connect/spaces` + +## Authentication +Required - Privy ID token authentication + +## Request Parameters +None + +## Request Headers +- `privy-id-token`: Privy authentication token (required) +- `account-address`: The account address to retrieve spaces for (required) + +## Request Body +None + +## Response +### Success Response (200 OK) +```json +{ + "spaces": [ + { + "id": "string", + "infoContent": "hex string", + "infoAuthorAddress": "string", + "infoSignatureHex": "string", + "infoSignatureRecovery": "number", + "name": "string", + "appIdentities": [ + { + "appId": "string", + "address": "string" + } + ], + "keyBoxes": [ + { + "id": "string", + "ciphertext": "string", + "nonce": "string", + "authorPublicKey": "string" + } + ] + } + ] +} +``` + +### Error Responses +- 401 Unauthorized: Invalid or missing authentication +- 500 Internal Server Error: Missing Privy configuration + +## Domain Model +### Space +- `id`: string (primary key) +- `name`: string +- `infoContent`: bytes +- `infoAuthorAddress`: string (foreign key to Account) +- `infoSignatureHex`: string +- `infoSignatureRecovery`: integer +- `events`: SpaceEvent[] relation +- `members`: Account[] relation +- `keys`: SpaceKey[] relation +- `appIdentities`: AppIdentity[] relation + +### SpaceKey +- `id`: string (primary key) +- `spaceId`: string (foreign key to Space) +- `keyBoxes`: SpaceKeyBox[] relation + +### SpaceKeyBox +- `id`: string (primary key) +- `spaceKeyId`: string (foreign key to SpaceKey) +- `ciphertext`: string +- `nonce`: string +- `authorPublicKey`: string +- `accountAddress`: string (foreign key to Account) + +### AppIdentity +- `appId`: string +- `address`: string (primary key) +- `accountAddress`: string (foreign key to Account) + +## Implementation Details +- Uses `getAddressByPrivyToken` to validate the Privy token and get signer address +- Uses `isSignerForAccount` to verify the signer has permission for the account +- Uses `listSpacesByAccount` handler to fetch spaces +- Converts space data to response format, including: + - Converting `infoContent` from bytes to hex string + - Filtering key boxes to only include those with content + - Mapping app identities to simplified format + +## Dependencies +- `getAddressByPrivyToken`: Validates Privy token +- `isSignerForAccount`: Verifies signer permissions +- `listSpacesByAccount`: Fetches spaces from database \ No newline at end of file diff --git a/apps/server/api-docs/get-identity.md b/apps/server/api-docs/get-identity.md new file mode 100644 index 00000000..d3254b3d --- /dev/null +++ b/apps/server/api-docs/get-identity.md @@ -0,0 +1,82 @@ +# GET /identity + +## Overview +Retrieves public identity information for either a Connect identity or App identity based on provided parameters. + +## HTTP Method +GET + +## Route +`/identity` + +## Authentication +None required (public endpoint) + +## Request Parameters +Query parameters: +- `accountAddress`: The account address (required) +- `signaturePublicKey`: The signature public key (optional, mutually exclusive with appId) +- `appId`: The application ID (optional, mutually exclusive with signaturePublicKey) + +Note: Either `signaturePublicKey` OR `appId` must be provided, but not both. + +## Request Headers +None + +## Request Body +None + +## Response +### Success Response (200 OK) +Schema: `Messages.ResponseIdentity` +```json +{ + "accountAddress": "string", + "signaturePublicKey": "string", + "encryptionPublicKey": "string", + "accountProof": "string", + "keyProof": "string", + "appId": "string" // Optional, only present for app identities +} +``` + +### Error Responses +- 400 Bad Request: + - Missing accountAddress + - Missing both signaturePublicKey and appId +- 404 Not Found: Identity not found + Schema: `Messages.ResponseIdentityNotFoundError` + ```json + { + "accountAddress": "string" + } + ``` + +## Domain Model +### Account (Connect Identity) +- `address`: string (primary key) +- `connectSignaturePublicKey`: string +- `connectEncryptionPublicKey`: string +- `connectAccountProof`: string +- `connectKeyProof`: string + +### AppIdentity +- `address`: string (primary key) +- `appId`: string +- `accountAddress`: string (foreign key to Account) +- `signaturePublicKey`: string +- `encryptionPublicKey`: string +- `accountProof`: string +- `keyProof`: string + +## Implementation Details +- No authentication required (public endpoint) +- Validates required parameters +- Uses `getAppOrConnectIdentity` handler which: + - If signaturePublicKey provided: searches for matching identity + - If appId provided: searches for app identity with that appId +- Returns unified identity response format +- App identities include optional `appId` field in response + +## Dependencies +- `getAppOrConnectIdentity`: Flexible identity lookup handler \ No newline at end of file diff --git a/apps/server/api-docs/get-root.md b/apps/server/api-docs/get-root.md new file mode 100644 index 00000000..c4d76368 --- /dev/null +++ b/apps/server/api-docs/get-root.md @@ -0,0 +1,40 @@ +# GET / + +## Overview +Health check endpoint that returns server status and version information. + +## HTTP Method +GET + +## Route +`/` + +## Authentication +None required + +## Request Parameters +None + +## Request Headers +None + +## Request Body +None + +## Response +### Success Response (200 OK) +``` +Server is running (v0.0.14) +``` + +## Domain Model +This endpoint does not interact with any domain models. + +## Implementation Details +- Simple health check endpoint +- Returns plain text response +- Hardcoded version string in the response +- No database queries or complex logic + +## Error Handling +This endpoint does not have specific error handling. \ No newline at end of file diff --git a/apps/server/api-docs/get-spaces-inbox.md b/apps/server/api-docs/get-spaces-inbox.md new file mode 100644 index 00000000..01988479 --- /dev/null +++ b/apps/server/api-docs/get-spaces-inbox.md @@ -0,0 +1,60 @@ +# GET /spaces/:spaceId/inboxes/:inboxId + +## Overview +Retrieves details for a specific public inbox within a space. + +## HTTP Method +GET + +## Route +`/spaces/:spaceId/inboxes/:inboxId` + +## Authentication +None required (public endpoint) + +## Request Parameters +- `spaceId`: The space ID (URL parameter) +- `inboxId`: The inbox ID (URL parameter) + +## Request Headers +None + +## Request Body +None + +## Response +### Success Response (200 OK) +Schema: `Messages.ResponseSpaceInboxPublic` +```json +{ + "inbox": { + "id": "string", + "spaceId": "string", + "isPublic": true, + "authPolicy": "string", // "requires_auth" | "anonymous" | "optional_auth" + "encryptionPublicKey": "string" + } +} +``` + +### Error Responses +- 500 Internal Server Error: Database or server error + +## Domain Model +### SpaceInbox +- `id`: string (primary key) +- `spaceId`: string (foreign key to Space) +- `isPublic`: boolean +- `authPolicy`: string +- `encryptionPublicKey`: string +- `encryptedSecretKey`: string +- `spaceEventId`: string (foreign key to SpaceEvent) + +## Implementation Details +- No authentication required (public endpoint) +- Uses `getSpaceInbox` handler to fetch specific inbox +- Returns public inbox details if found +- Note: Handler should verify inbox is public before returning + +## Dependencies +- `getSpaceInbox`: Fetches specific inbox from database \ No newline at end of file diff --git a/apps/server/api-docs/get-spaces-inboxes.md b/apps/server/api-docs/get-spaces-inboxes.md new file mode 100644 index 00000000..2f3ceec7 --- /dev/null +++ b/apps/server/api-docs/get-spaces-inboxes.md @@ -0,0 +1,66 @@ +# GET /spaces/:spaceId/inboxes + +## Overview +Lists all public inboxes for a specific space. + +## HTTP Method +GET + +## Route +`/spaces/:spaceId/inboxes` + +## Authentication +None required (public endpoint) + +## Request Parameters +- `spaceId`: The space ID (URL parameter) + +## Request Headers +None + +## Request Body +None + +## Response +### Success Response (200 OK) +Schema: `Messages.ResponseListSpaceInboxesPublic` +```json +{ + "inboxes": [ + { + "id": "string", + "spaceId": "string", + "isPublic": true, + "authPolicy": "string", // "requires_auth" | "anonymous" | "optional_auth" + "encryptionPublicKey": "string" + } + ] +} +``` + +### Error Responses +- 500 Internal Server Error: Database or server error + +## Domain Model +### SpaceInbox +- `id`: string (primary key) +- `spaceId`: string (foreign key to Space) +- `isPublic`: boolean +- `authPolicy`: string +- `encryptionPublicKey`: string +- `encryptedSecretKey`: string +- `spaceEventId`: string (foreign key to SpaceEvent) + +### Space +- `id`: string (primary key) +- `inboxes`: SpaceInbox[] relation + +## Implementation Details +- No authentication required (public endpoint) +- Uses `listPublicSpaceInboxes` handler to: + - Query all inboxes for the space + - Filter to only public inboxes (isPublic = true) +- Returns list of public inbox metadata + +## Dependencies +- `listPublicSpaceInboxes`: Fetches public inboxes from database \ No newline at end of file diff --git a/apps/server/api-docs/get-whoami.md b/apps/server/api-docs/get-whoami.md new file mode 100644 index 00000000..8dbdb030 --- /dev/null +++ b/apps/server/api-docs/get-whoami.md @@ -0,0 +1,49 @@ +# GET /whoami + +## Overview +Returns the account address associated with the current session token. + +## HTTP Method +GET + +## Route +`/whoami` + +## Authentication +Required - Bearer token authentication (session token) + +## Request Parameters +None + +## Request Headers +- `Authorization`: Bearer token (required) - Format: `Bearer ` + +## Request Body +None + +## Response +### Success Response (200 OK) +``` + +``` +Returns the account address as plain text. + +### Error Responses +- 401 Unauthorized: Invalid or missing session token + +## Domain Model +### AppIdentity +- `sessionToken`: string (indexed) +- `accountAddress`: string (foreign key to Account) +- `sessionTokenExpires`: datetime + +## Implementation Details +- Extracts session token from Authorization header +- Uses `getAppIdentityBySessionToken` handler to: + - Look up app identity by session token + - Verify token is not expired + - Return associated account address +- Returns account address as plain text response + +## Dependencies +- `getAppIdentityBySessionToken`: Validates session token and retrieves account \ No newline at end of file diff --git a/apps/server/api-docs/post-accounts-inbox-messages.md b/apps/server/api-docs/post-accounts-inbox-messages.md new file mode 100644 index 00000000..38bc692d --- /dev/null +++ b/apps/server/api-docs/post-accounts-inbox-messages.md @@ -0,0 +1,85 @@ +# POST /accounts/:accountAddress/inboxes/:inboxId/messages + +## Overview +Posts a new message to an account inbox. Authentication requirements depend on the inbox's auth policy. + +## HTTP Method +POST + +## Route +`/accounts/:accountAddress/inboxes/:inboxId/messages` + +## Authentication +Depends on inbox auth policy: +- `requires_auth`: Signature and account address required +- `anonymous`: No authentication allowed +- `optional_auth`: Authentication optional + +## Request Parameters +- `accountAddress`: The account address (URL parameter) +- `inboxId`: The inbox ID (URL parameter) + +## Request Headers +- `Content-Type`: application/json + +## Request Body +Schema: `Messages.RequestCreateAccountInboxMessage` +```json +{ + "ciphertext": "string", + "signature": { + "hex": "string", + "recovery": "number" + }, // Optional based on auth policy + "authorAccountAddress": "string" // Optional based on auth policy +} +``` + +## Response +### Success Response (200 OK) +```json +{} +``` +Empty object on success. Message is also broadcast via WebSocket. + +### Error Responses +- 400 Bad Request: Invalid authentication for inbox policy +- 403 Forbidden: Not authorized to post to inbox +- 404 Not Found: Inbox not found +- 500 Internal Server Error: Server error + +## Domain Model +### AccountInbox +- `id`: string (primary key) +- `accountAddress`: string (foreign key to Account) +- `authPolicy`: string ("requires_auth" | "anonymous" | "optional_auth") +- `messages`: AccountInboxMessage[] relation + +### AccountInboxMessage +- `id`: string (auto-generated UUID) +- `accountInboxId`: string (foreign key to AccountInbox) +- `ciphertext`: string +- `signatureHex`: string (optional) +- `signatureRecovery`: integer (optional) +- `authorAccountAddress`: string (optional) +- `createdAt`: datetime + +## Implementation Details +- Fetches inbox to check auth policy +- Validates authentication based on policy: + - `requires_auth`: Both signature and authorAccountAddress required + - `anonymous`: Neither allowed + - `optional_auth`: Both must be provided together or neither +- If authenticated: + - Recovers public key from signature using `Inboxes.recoverAccountInboxMessageSigner` + - Verifies public key belongs to claimed account via `getAppOrConnectIdentity` +- Creates message using `createAccountInboxMessage` +- Broadcasts message to WebSocket subscribers via `broadcastAccountInboxMessage` + +## Dependencies +- `getAccountInbox`: Fetches inbox configuration +- `Inboxes.recoverAccountInboxMessageSigner`: Recovers signer from signature +- `getAppOrConnectIdentity`: Verifies identity ownership +- `createAccountInboxMessage`: Persists message +- `broadcastAccountInboxMessage`: WebSocket broadcast +- `Schema.decodeUnknownSync`: Validates request body \ No newline at end of file diff --git a/apps/server/api-docs/post-connect-add-app-identity-to-spaces.md b/apps/server/api-docs/post-connect-add-app-identity-to-spaces.md new file mode 100644 index 00000000..53676c9d --- /dev/null +++ b/apps/server/api-docs/post-connect-add-app-identity-to-spaces.md @@ -0,0 +1,79 @@ +# POST /connect/add-app-identity-to-spaces + +## Overview +Adds an app identity to multiple spaces, granting the app access to those spaces. + +## HTTP Method +POST + +## Route +`/connect/add-app-identity-to-spaces` + +## Authentication +Required - Privy ID token authentication + +## Request Parameters +None + +## Request Headers +- `privy-id-token`: Privy authentication token (required) +- `Content-Type`: application/json + +## Request Body +Schema: `Messages.RequestConnectAddAppIdentityToSpaces` +```json +{ + "accountAddress": "string", + "appIdentityAddress": "string", + "spacesInput": [ + { + "spaceId": "string", + // Additional space-specific data + } + ] +} +``` + +## Response +### Success Response (200 OK) +```json +{ + "space": { + // Space object or array of spaces + } +} +``` + +### Error Responses +- 401 Unauthorized: Invalid authentication or insufficient permissions +- 500 Internal Server Error: Missing Privy configuration + +## Domain Model +### AppIdentity +- `address`: string (primary key) +- `accountAddress`: string (foreign key to Account) +- `appId`: string +- `spaces`: Space[] relation (many-to-many) + +### Space +- `id`: string (primary key) +- `appIdentities`: AppIdentity[] relation (many-to-many) + +### Account +- `address`: string (primary key) +- `appIdentities`: AppIdentity[] relation + +## Implementation Details +- Validates Privy token to get signer address +- Verifies signer has permission for the specified account +- Uses `addAppIdentityToSpaces` handler to: + - Verify the app identity belongs to the account + - Add the app identity to each specified space + - Update the many-to-many relationship +- Returns updated space information + +## Dependencies +- `getAddressByPrivyToken`: Validates Privy token +- `isSignerForAccount`: Verifies signer permissions +- `addAppIdentityToSpaces`: Updates space-app identity relationships +- `Schema.decodeUnknownSync`: Validates request body schema \ No newline at end of file diff --git a/apps/server/api-docs/post-connect-app-identity.md b/apps/server/api-docs/post-connect-app-identity.md new file mode 100644 index 00000000..4a499cd7 --- /dev/null +++ b/apps/server/api-docs/post-connect-app-identity.md @@ -0,0 +1,88 @@ +# POST /connect/app-identity + +## Overview +Creates a new app identity for an account with session token generation and ownership verification. + +## HTTP Method +POST + +## Route +`/connect/app-identity` + +## Authentication +Required - Privy ID token authentication + +## Request Parameters +None + +## Request Headers +- `privy-id-token`: Privy authentication token (required) +- `Content-Type`: application/json + +## Request Body +Schema: `Messages.RequestConnectCreateAppIdentity` +```json +{ + "accountAddress": "string", + "appId": "string", + "address": "string", + "ciphertext": "string", + "signaturePublicKey": "string", + "encryptionPublicKey": "string", + "accountProof": "string", + "keyProof": "string" +} +``` + +## Response +### Success Response (200 OK) +```json +{ + "appIdentity": { + "address": "string", + "appId": "string", + "accountAddress": "string", + "sessionToken": "string", + "sessionTokenExpires": "datetime", + // Additional fields + } +} +``` + +### Error Responses +- 401 Unauthorized: Invalid authentication or ownership proof +- 500 Internal Server Error: Missing Privy configuration + +## Domain Model +### AppIdentity +- `address`: string (primary key) +- `appId`: string +- `accountAddress`: string (foreign key to Account) +- `ciphertext`: string +- `signaturePublicKey`: string +- `encryptionPublicKey`: string +- `accountProof`: string +- `keyProof`: string +- `sessionToken`: string +- `sessionTokenExpires`: datetime +- Unique constraint: [accountAddress, appId] + +## Implementation Details +- Validates Privy token and verifies signer permissions +- Verifies ownership proof using `Identity.verifyIdentityOwnership` +- Generates: + - Random 32-byte session token (as hex string) + - Session expiration date (30 days from creation) +- Uses `createAppIdentity` handler to: + - Create the app identity record + - Store encrypted data and public keys + - Save session token for future authentication +- Returns created app identity with session token + +## Dependencies +- `getAddressByPrivyToken`: Validates Privy token +- `isSignerForAccount`: Verifies signer permissions +- `Identity.verifyIdentityOwnership`: Validates ownership proofs +- `createAppIdentity`: Creates app identity record +- `bytesToHex`, `randomBytes`: For session token generation +- `Schema.decodeUnknownSync`: Validates request body schema \ No newline at end of file diff --git a/apps/server/api-docs/post-connect-identity.md b/apps/server/api-docs/post-connect-identity.md new file mode 100644 index 00000000..cca73b3f --- /dev/null +++ b/apps/server/api-docs/post-connect-identity.md @@ -0,0 +1,89 @@ +# POST /connect/identity + +## Overview +Creates a new identity for an account with encryption and signature keys. Includes ownership verification. + +## HTTP Method +POST + +## Route +`/connect/identity` + +## Authentication +Required - Privy ID token authentication + +## Request Parameters +None + +## Request Headers +- `privy-id-token`: Privy authentication token (required) +- `Content-Type`: application/json + +## Request Body +Schema: `Messages.RequestConnectCreateIdentity` +```json +{ + "keyBox": { + "accountAddress": "string", + "signer": "string", + "ciphertext": "string", + "nonce": "string" + }, + "signaturePublicKey": "string", + "encryptionPublicKey": "string", + "accountProof": "string", + "keyProof": "string" +} +``` + +## Response +### Success Response (200 OK) +Schema: `Messages.ResponseConnectCreateIdentity` +```json +{ + "success": true +} +``` + +### Error Response (400 Bad Request) +Schema: `Messages.ResponseIdentityExistsError` +```json +{ + "accountAddress": "string" +} +``` + +### Other Error Responses +- 401 Unauthorized: Invalid authentication or ownership proof +- 500 Internal Server Error: Missing Privy configuration + +## Domain Model +### Account +- `address`: string (primary key) +- `connectAddress`: string (unique) +- `connectCiphertext`: string +- `connectNonce`: string +- `connectSignaturePublicKey`: string +- `connectEncryptionPublicKey`: string +- `connectAccountProof`: string +- `connectKeyProof`: string +- `connectSignerAddress`: string + +## Implementation Details +- Validates Privy token and ensures it matches the signer in the keyBox +- Verifies ownership proof using `Identity.verifyIdentityOwnership`: + - Validates account ownership + - Validates signature public key + - Validates proofs against the blockchain +- Uses `createIdentity` handler to: + - Create or update the Account record + - Store encrypted identity data + - Store public keys and proofs +- Returns success or specific error for existing identity + +## Dependencies +- `getAddressByPrivyToken`: Validates Privy token +- `Identity.verifyIdentityOwnership`: Validates ownership proofs +- `createIdentity`: Creates identity record +- `Schema.decodeUnknownSync`: Validates request body schema +- Chain configuration (CHAIN, RPC_URL) for blockchain verification \ No newline at end of file diff --git a/apps/server/api-docs/post-connect-spaces.md b/apps/server/api-docs/post-connect-spaces.md new file mode 100644 index 00000000..dcfdbd57 --- /dev/null +++ b/apps/server/api-docs/post-connect-spaces.md @@ -0,0 +1,102 @@ +# POST /connect/spaces + +## Overview +Creates a new space with initial configuration, including encryption keys and space information. + +## HTTP Method +POST + +## Route +`/connect/spaces` + +## Authentication +Required - Privy ID token authentication + +## Request Parameters +None + +## Request Headers +- `privy-id-token`: Privy authentication token (required) +- `Content-Type`: application/json + +## Request Body +Schema: `Messages.RequestConnectCreateSpaceEvent` +```json +{ + "accountAddress": "string", + "event": { + // SpaceEvent object + }, + "keyBox": { + // KeyBox object + }, + "infoContent": "hex string", + "infoSignature": { + "hex": "string", + "recovery": "number" + }, + "name": "string" +} +``` + +## Response +### Success Response (200 OK) +```json +{ + "space": { + "id": "string", + // Space object + } +} +``` + +### Error Responses +- 401 Unauthorized: Invalid authentication or insufficient permissions +- 500 Internal Server Error: Missing Privy configuration + +## Domain Model +### Space +- `id`: string (primary key) +- `name`: string +- `infoContent`: bytes +- `infoAuthorAddress`: string (foreign key to Account) +- `infoSignatureHex`: string +- `infoSignatureRecovery`: integer +- `events`: SpaceEvent[] relation +- `members`: Account[] relation +- `keys`: SpaceKey[] relation + +### SpaceEvent +- `id`: string (primary key) +- `event`: string (serialized event data) +- `state`: string +- `counter`: integer +- `spaceId`: string (foreign key to Space) + +### SpaceKey +- `id`: string (primary key) +- `spaceId`: string (foreign key to Space) +- `keyBoxes`: SpaceKeyBox[] relation + +### SpaceKeyBox +- `ciphertext`: string +- `nonce`: string +- `authorPublicKey`: string +- `accountAddress`: string (foreign key to Account) + +## Implementation Details +- Validates Privy token to get signer address +- Verifies signer has permission for the specified account +- Converts hex info content to bytes +- Uses `createSpace` handler to: + - Create the space record + - Store the initial space event + - Create encryption key boxes +- Returns the created space object + +## Dependencies +- `getAddressByPrivyToken`: Validates Privy token +- `isSignerForAccount`: Verifies signer permissions +- `createSpace`: Creates space and related records +- `Schema.decodeUnknownSync`: Validates request body schema +- `Utils.hexToBytes`: Converts hex strings to byte arrays \ No newline at end of file diff --git a/apps/server/api-docs/post-spaces-inbox-messages.md b/apps/server/api-docs/post-spaces-inbox-messages.md new file mode 100644 index 00000000..619f77cc --- /dev/null +++ b/apps/server/api-docs/post-spaces-inbox-messages.md @@ -0,0 +1,85 @@ +# POST /spaces/:spaceId/inboxes/:inboxId/messages + +## Overview +Posts a new message to a space inbox. Authentication requirements depend on the inbox's auth policy. + +## HTTP Method +POST + +## Route +`/spaces/:spaceId/inboxes/:inboxId/messages` + +## Authentication +Depends on inbox auth policy: +- `requires_auth`: Signature and account address required +- `anonymous`: No authentication allowed +- `optional_auth`: Authentication optional + +## Request Parameters +- `spaceId`: The space ID (URL parameter) +- `inboxId`: The inbox ID (URL parameter) + +## Request Headers +- `Content-Type`: application/json + +## Request Body +Schema: `Messages.RequestCreateSpaceInboxMessage` +```json +{ + "ciphertext": "string", + "signature": { + "hex": "string", + "recovery": "number" + }, // Optional based on auth policy + "authorAccountAddress": "string" // Optional based on auth policy +} +``` + +## Response +### Success Response (200 OK) +```json +{} +``` +Empty object on success. Message is also broadcast via WebSocket. + +### Error Responses +- 400 Bad Request: Invalid authentication for inbox policy +- 403 Forbidden: Not authorized to post to inbox +- 404 Not Found: Inbox not found +- 500 Internal Server Error: Server error + +## Domain Model +### SpaceInbox +- `id`: string (primary key) +- `spaceId`: string (foreign key to Space) +- `authPolicy`: string ("requires_auth" | "anonymous" | "optional_auth") +- `messages`: SpaceInboxMessage[] relation + +### SpaceInboxMessage +- `id`: string (auto-generated UUID) +- `spaceInboxId`: string (foreign key to SpaceInbox) +- `ciphertext`: string +- `signatureHex`: string (optional) +- `signatureRecovery`: integer (optional) +- `authorAccountAddress`: string (optional) +- `createdAt`: datetime + +## Implementation Details +- Fetches inbox to check auth policy +- Validates authentication based on policy: + - `requires_auth`: Both signature and authorAccountAddress required + - `anonymous`: Neither allowed + - `optional_auth`: Both must be provided together or neither +- If authenticated: + - Recovers public key from signature using `Inboxes.recoverSpaceInboxMessageSigner` + - Verifies public key belongs to claimed account via `getAppOrConnectIdentity` +- Creates message using `createSpaceInboxMessage` +- Broadcasts message to WebSocket subscribers via `broadcastSpaceInboxMessage` + +## Dependencies +- `getSpaceInbox`: Fetches inbox configuration +- `Inboxes.recoverSpaceInboxMessageSigner`: Recovers signer from signature +- `getAppOrConnectIdentity`: Verifies identity ownership +- `createSpaceInboxMessage`: Persists message +- `broadcastSpaceInboxMessage`: WebSocket broadcast +- `Schema.decodeUnknownSync`: Validates request body \ No newline at end of file diff --git a/apps/server/api-docs/websocket-connection.md b/apps/server/api-docs/websocket-connection.md new file mode 100644 index 00000000..d045da1e --- /dev/null +++ b/apps/server/api-docs/websocket-connection.md @@ -0,0 +1,224 @@ +# WebSocket Connection + +## Overview +WebSocket endpoint for real-time communication between clients and server. Handles space subscriptions, updates, events, and inbox messages. + +## Connection URL +`ws://[host]:[port]/?token=[sessionToken]` + +## Authentication +Required - Session token as query parameter + +## Connection Process +1. Client connects with session token in query parameter +2. Server validates token via `getAppIdentityBySessionToken` +3. If valid, connection established with: + - `accountAddress`: Associated account + - `appIdentityAddress`: App identity address + - `subscribedSpaces`: Empty set (populated via subscriptions) +4. If invalid, connection closed + +## WebSocket Message Types + +### Client to Server Messages +All messages use `Messages.RequestMessage` schema, serialized/deserialized via `Messages.serialize/deserialize`. + +#### subscribe-space +Subscribe to updates for a specific space. +```json +{ + "type": "subscribe-space", + "id": "spaceId" +} +``` +Response: `ResponseSpace` message with full space data + +#### list-spaces +List all spaces accessible by the app identity. +```json +{ + "type": "list-spaces" +} +``` +Response: `ResponseListSpaces` with array of spaces + +#### list-invitations +List all invitations for the account. +```json +{ + "type": "list-invitations" +} +``` +Response: `ResponseListInvitations` with invitations + +#### create-space-event +Create a new space with initial event. +```json +{ + "type": "create-space-event", + "event": { /* SpaceEvent */ }, + "keyBox": { /* KeyBox */ }, + "name": "string" +} +``` +Response: `ResponseSpace` with created space + +#### create-invitation-event +Create an invitation to a space. +```json +{ + "type": "create-invitation-event", + "spaceId": "string", + "event": { /* SpaceEvent */ }, + "keyBoxes": [ /* KeyBox[] */ ] +} +``` +Response: `ResponseSpace` and broadcasts to invitee + +#### accept-invitation-event +Accept a space invitation. +```json +{ + "type": "accept-invitation-event", + "spaceId": "string", + "event": { /* SpaceEvent */ } +} +``` +Response: `ResponseSpace` and broadcasts event + +#### create-space-inbox-event +Create an inbox for a space. +```json +{ + "type": "create-space-inbox-event", + "spaceId": "string", + "event": { /* SpaceEvent */ } +} +``` +Response: `ResponseSpace` and broadcasts event + +#### create-account-inbox +Create an inbox for the account. +```json +{ + "type": "create-account-inbox", + "accountAddress": "string", + "id": "string", + "isPublic": boolean, + "authPolicy": "string", + "encryptionPublicKey": "string", + "signature": { "hex": "string", "recovery": number } +} +``` +Broadcasts to other clients of same account + +#### get-latest-space-inbox-messages +Retrieve recent messages from a space inbox. +```json +{ + "type": "get-latest-space-inbox-messages", + "spaceId": "string", + "inboxId": "string", + "since": "datetime" // Optional +} +``` +Response: `ResponseSpaceInboxMessages` + +#### get-latest-account-inbox-messages +Retrieve recent messages from an account inbox. +```json +{ + "type": "get-latest-account-inbox-messages", + "inboxId": "string", + "since": "datetime" // Optional +} +``` +Response: `ResponseAccountInboxMessages` + +#### get-account-inboxes +List all inboxes for the account. +```json +{ + "type": "get-account-inboxes" +} +``` +Response: `ResponseAccountInboxes` + +#### create-update +Create a CRDT update for a space. +```json +{ + "type": "create-update", + "accountAddress": "string", + "spaceId": "string", + "update": "string", // Serialized update + "signature": { "hex": "string", "recovery": number }, + "updateId": "string" +} +``` +Response: `ResponseUpdateConfirmed` and broadcasts to subscribers + +### Server to Client Messages + +#### space-event (broadcast) +```json +{ + "type": "space-event", + "spaceId": "string", + "event": { /* SpaceEvent */ } +} +``` + +#### updates-notification (broadcast) +```json +{ + "type": "updates-notification", + "spaceId": "string", + "updates": { + "updates": [ /* Update[] */ ], + "firstUpdateClock": number, + "lastUpdateClock": number + } +} +``` + +#### space-inbox-message (broadcast) +```json +{ + "type": "space-inbox-message", + "spaceId": "string", + "inboxId": "string", + "message": { /* InboxMessage */ } +} +``` + +#### account-inbox-message (broadcast) +```json +{ + "type": "account-inbox-message", + "accountAddress": "string", + "inboxId": "string", + "message": { /* InboxMessage */ } +} +``` + +#### account-inbox (broadcast) +```json +{ + "type": "account-inbox", + "inbox": { /* AccountInboxPublic */ } +} +``` + +## Domain Models +See individual route documentation for detailed model descriptions. + +## Broadcasting Rules +- **Space events/updates**: Broadcast to all clients subscribed to the space +- **Account inbox messages**: Broadcast to all clients with same account address +- **Invitations**: Broadcast to invitee's connected clients + +## Error Handling +- Invalid messages are logged but don't close connection +- Authentication failures close the connection immediately +- Database errors are logged, client may not receive response \ No newline at end of file diff --git a/package.json b/package.json index a56eb759..ae54eafe 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@babel/core": "^7.28.0", "@biomejs/biome": "^2.1.2", "@changesets/cli": "^2.29.5", + "@effect/vitest": "^0.25.0", "@graphprotocol/grc-20": "^0.21.6", "babel-plugin-annotate-pure-calls": "^0.5.0", "glob": "^11.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f1df0c7..a058bd8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@changesets/cli': specifier: ^2.29.5 version: 2.29.5 + '@effect/vitest': + specifier: ^0.25.0 + version: 0.25.0(effect@3.17.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.1.0)(jiti@2.5.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.1)(terser@5.39.1)(tsx@4.20.3)(yaml@2.7.0)) '@graphprotocol/grc-20': specifier: ^0.21.6 version: 0.21.6(bufferutil@4.0.9)(graphql@16.11.0)(ox@0.6.7(typescript@5.8.3)(zod@3.25.51))(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.51) @@ -406,7 +409,7 @@ importers: version: 7.1.2(graphql@16.11.0) isomorphic-ws: specifier: ^5.0.0 - version: 5.0.0(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + version: 5.0.0(ws@8.18.2(bufferutil@4.0.9)(utf-8-validate@5.0.10)) lucide-react: specifier: ^0.508.0 version: 0.508.0(react@19.1.0) @@ -573,6 +576,55 @@ importers: specifier: ^5.8.3 version: 5.8.3 + apps/server-new: + dependencies: + '@effect/opentelemetry': + specifier: ^0.56.0 + version: 0.56.0(@effect/platform@0.90.0(effect@3.17.3))(@opentelemetry/semantic-conventions@1.34.0)(effect@3.17.3) + '@effect/platform': + specifier: ^0.90.0 + version: 0.90.0(effect@3.17.3) + '@effect/platform-node': + specifier: ^0.94.0 + version: 0.94.0(@effect/cluster@0.37.2(@effect/platform@0.90.0(effect@3.17.3))(@effect/rpc@0.61.4(@effect/platform@0.90.0(effect@3.17.3))(effect@3.17.3))(@effect/sql@0.44.0(@effect/experimental@0.54.2(@effect/platform@0.90.0(effect@3.17.3))(effect@3.17.3))(@effect/platform@0.90.0(effect@3.17.3))(effect@3.17.3))(@effect/workflow@0.1.2(effect@3.17.3))(effect@3.17.3))(@effect/platform@0.90.0(effect@3.17.3))(@effect/rpc@0.61.4(@effect/platform@0.90.0(effect@3.17.3))(effect@3.17.3))(@effect/sql@0.44.0(@effect/experimental@0.54.2(@effect/platform@0.90.0(effect@3.17.3))(effect@3.17.3))(@effect/platform@0.90.0(effect@3.17.3))(effect@3.17.3))(bufferutil@4.0.9)(effect@3.17.3)(utf-8-validate@5.0.10) + '@graphprotocol/hypergraph': + specifier: workspace:* + version: link:../../packages/hypergraph/publish + '@prisma/client': + specifier: ^6.7.0 + version: 6.7.0(prisma@6.7.0(typescript@5.8.3))(typescript@5.8.3) + '@privy-io/server-auth': + specifier: ^1.26.0 + version: 1.26.0(bufferutil@4.0.9)(encoding@0.1.13)(ethers@6.13.5(bufferutil@4.0.9)(utf-8-validate@5.0.10))(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.30.6(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.51)) + cors: + specifier: ^2.8.5 + version: 2.8.5 + effect: + specifier: ^3.17.3 + version: 3.17.3 + devDependencies: + '@types/cors': + specifier: ^2.8.17 + version: 2.8.17 + '@types/node': + specifier: ^24.1.0 + version: 24.1.0 + prisma: + specifier: ^6.7.0 + version: 6.7.0(typescript@5.8.3) + tsup: + specifier: ^8.4.0 + version: 8.5.0(@swc/core@1.11.24(@swc/helpers@0.5.17))(jiti@2.5.1)(postcss@8.5.6)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.7.0) + tsx: + specifier: ^4.19.0 + version: 4.20.3 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.1.0)(jiti@2.5.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.1)(terser@5.39.1)(tsx@4.20.3)(yaml@2.7.0) + apps/typesync: dependencies: '@graphprotocol/grc-20': @@ -689,7 +741,7 @@ importers: version: 4.6.1(@babel/core@7.28.0)(encoding@0.1.13)(graphql-sock@1.0.1(graphql@16.11.0))(graphql@16.11.0) '@tanstack/router-plugin': specifier: ^1.129.5 - version: 1.129.5(@tanstack/react-router@1.129.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.0.4(@types/node@24.1.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.1)(tsx@4.20.3)(yaml@2.7.0))(webpack@5.99.8(@swc/core@1.11.24(@swc/helpers@0.5.17))(esbuild@0.25.2)) + version: 1.129.5(@tanstack/react-router@1.129.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.0.4(@types/node@24.1.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.1)(tsx@4.20.3)(yaml@2.7.0))(webpack@5.99.8(@swc/core@1.11.24(@swc/helpers@0.5.17))) '@types/node': specifier: ^24.1.0 version: 24.1.0 @@ -2656,6 +2708,35 @@ packages: resolution: {integrity: sha512-OuFjNT9CiKwW6yTrkBeUoO6Qrl/thLk7x4oYU73MD9kXUEMwQJQ7eCr+nZj74axofLHGJpsyrxvSzGN75pEk1Q==} hasBin: true + '@effect/opentelemetry@0.56.0': + resolution: {integrity: sha512-WRqSnhF5bTMXlyC7q7fH7k+J2cbh7JRD+o2Vpj/H7AtAhdKmPilYMj9eudTyPcM14tgw555mnNRgTS7v6TCp3Q==} + peerDependencies: + '@effect/platform': ^0.90.0 + '@opentelemetry/api': ^1.9 + '@opentelemetry/resources': ^2.0.0 + '@opentelemetry/sdk-logs': ^0.203.0 + '@opentelemetry/sdk-metrics': ^2.0.0 + '@opentelemetry/sdk-trace-base': ^2.0.0 + '@opentelemetry/sdk-trace-node': ^2.0.0 + '@opentelemetry/sdk-trace-web': ^2.0.0 + '@opentelemetry/semantic-conventions': ^1.33.0 + effect: ^3.17.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/resources': + optional: true + '@opentelemetry/sdk-logs': + optional: true + '@opentelemetry/sdk-metrics': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + '@opentelemetry/sdk-trace-node': + optional: true + '@opentelemetry/sdk-trace-web': + optional: true + '@effect/platform-node-shared@0.47.0': resolution: {integrity: sha512-ITsvT1Upphnf5Iq6gkUef4oy/ivoJkl8grtIuVkNE38I3EC57A/00anDXlwSgUd7i4pRT+KX5ypcc1/TsehCeg==} peerDependencies: @@ -16430,6 +16511,12 @@ snapshots: '@effect/language-service@0.31.2': {} + '@effect/opentelemetry@0.56.0(@effect/platform@0.90.0(effect@3.17.3))(@opentelemetry/semantic-conventions@1.34.0)(effect@3.17.3)': + dependencies: + '@effect/platform': 0.90.0(effect@3.17.3) + '@opentelemetry/semantic-conventions': 1.34.0 + effect: 3.17.3 + '@effect/platform-node-shared@0.47.0(@effect/cluster@0.37.2(@effect/platform@0.90.0(effect@3.17.3))(@effect/rpc@0.61.4(@effect/platform@0.90.0(effect@3.17.3))(effect@3.17.3))(@effect/sql@0.44.0(@effect/experimental@0.51.1(@effect/platform@0.90.0(effect@3.17.3))(effect@3.17.3))(@effect/platform@0.90.0(effect@3.17.3))(effect@3.17.3))(@effect/workflow@0.1.2(effect@3.17.3))(effect@3.17.3))(@effect/platform@0.90.0(effect@3.17.3))(@effect/rpc@0.61.4(@effect/platform@0.90.0(effect@3.17.3))(effect@3.17.3))(@effect/sql@0.44.0(@effect/experimental@0.51.1(@effect/platform@0.90.0(effect@3.17.3))(effect@3.17.3))(@effect/platform@0.90.0(effect@3.17.3))(effect@3.17.3))(bufferutil@4.0.9)(effect@3.17.3)(utf-8-validate@5.0.10)': dependencies: '@effect/cluster': 0.37.2(@effect/platform@0.90.0(effect@3.17.3))(@effect/rpc@0.61.4(@effect/platform@0.90.0(effect@3.17.3))(effect@3.17.3))(@effect/sql@0.44.0(@effect/experimental@0.51.1(@effect/platform@0.90.0(effect@3.17.3))(effect@3.17.3))(@effect/platform@0.90.0(effect@3.17.3))(effect@3.17.3))(@effect/workflow@0.1.2(effect@3.17.3))(effect@3.17.3) @@ -18516,6 +18603,29 @@ snapshots: - typescript - utf-8-validate + '@privy-io/server-auth@1.26.0(bufferutil@4.0.9)(encoding@0.1.13)(ethers@6.13.5(bufferutil@4.0.9)(utf-8-validate@5.0.10))(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.30.6(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.51))': + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@privy-io/public-api': 2.32.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) + '@solana/web3.js': 1.98.2(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) + canonicalize: 2.1.0 + dotenv: 16.4.7 + jose: 4.15.9 + node-fetch-native: 1.6.4 + redaxios: 0.5.1 + svix: 1.66.0(encoding@0.1.13) + ts-case-convert: 2.1.0 + type-fest: 3.13.1 + optionalDependencies: + ethers: 6.13.5(bufferutil@4.0.9)(utf-8-validate@5.0.10) + viem: 2.30.6(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.51) + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + '@quansync/fs@0.1.3': dependencies: quansync: 0.2.10 @@ -20162,7 +20272,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.129.5(@tanstack/react-router@1.129.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.0.4(@types/node@24.1.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.1)(tsx@4.20.3)(yaml@2.7.0))(webpack@5.99.8(@swc/core@1.11.24(@swc/helpers@0.5.17))(esbuild@0.25.2))': + '@tanstack/router-plugin@1.129.5(@tanstack/react-router@1.129.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@7.0.4(@types/node@24.1.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.1)(tsx@4.20.3)(yaml@2.7.0))(webpack@5.99.8(@swc/core@1.11.24(@swc/helpers@0.5.17)))': dependencies: '@babel/core': 7.28.0 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.0) @@ -20181,7 +20291,7 @@ snapshots: optionalDependencies: '@tanstack/react-router': 1.129.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) vite: 7.0.4(@types/node@24.1.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.1)(tsx@4.20.3)(yaml@2.7.0) - webpack: 5.99.8(@swc/core@1.11.24(@swc/helpers@0.5.17))(esbuild@0.25.2) + webpack: 5.99.8(@swc/core@1.11.24(@swc/helpers@0.5.17)) transitivePeerDependencies: - supports-color @@ -24965,10 +25075,6 @@ snapshots: dependencies: ws: 7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10) - isomorphic-ws@5.0.0(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)): - dependencies: - ws: 8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) - isomorphic-ws@5.0.0(ws@8.18.2(bufferutil@4.0.9)(utf-8-validate@5.0.10)): dependencies: ws: 8.18.2(bufferutil@4.0.9)(utf-8-validate@5.0.10) @@ -28909,17 +29015,16 @@ snapshots: term-size@2.2.1: {} - terser-webpack-plugin@5.3.14(@swc/core@1.11.24(@swc/helpers@0.5.17))(esbuild@0.25.2)(webpack@5.99.8(@swc/core@1.11.24(@swc/helpers@0.5.17))(esbuild@0.25.2)): + terser-webpack-plugin@5.3.14(@swc/core@1.11.24(@swc/helpers@0.5.17))(webpack@5.99.8(@swc/core@1.11.24(@swc/helpers@0.5.17))): dependencies: '@jridgewell/trace-mapping': 0.3.29 jest-worker: 27.5.1 schema-utils: 4.3.2 serialize-javascript: 6.0.2 terser: 5.39.1 - webpack: 5.99.8(@swc/core@1.11.24(@swc/helpers@0.5.17))(esbuild@0.25.2) + webpack: 5.99.8(@swc/core@1.11.24(@swc/helpers@0.5.17)) optionalDependencies: '@swc/core': 1.11.24(@swc/helpers@0.5.17) - esbuild: 0.25.2 optional: true terser-webpack-plugin@5.3.14(webpack@5.99.8): @@ -29918,7 +30023,7 @@ snapshots: - esbuild - uglify-js - webpack@5.99.8(@swc/core@1.11.24(@swc/helpers@0.5.17))(esbuild@0.25.2): + webpack@5.99.8(@swc/core@1.11.24(@swc/helpers@0.5.17)): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 @@ -29941,7 +30046,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.2 tapable: 2.2.1 - terser-webpack-plugin: 5.3.14(@swc/core@1.11.24(@swc/helpers@0.5.17))(esbuild@0.25.2)(webpack@5.99.8(@swc/core@1.11.24(@swc/helpers@0.5.17))(esbuild@0.25.2)) + terser-webpack-plugin: 5.3.14(@swc/core@1.11.24(@swc/helpers@0.5.17))(webpack@5.99.8(@swc/core@1.11.24(@swc/helpers@0.5.17))) watchpack: 2.4.2 webpack-sources: 3.2.3 transitivePeerDependencies: