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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/deploy-prd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ jobs:
# api Worker (apps/backend/src/worker/env.ts)
CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }}
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
# Hazel sign-in (Clerk remains available for existing sessions during cutover).
AUTH_GITHUB_CLIENT_ID: ${{ vars.AUTH_GITHUB_CLIENT_ID || secrets.AUTH_GITHUB_CLIENT_ID }}
AUTH_GITHUB_CLIENT_SECRET: ${{ secrets.AUTH_GITHUB_CLIENT_SECRET }}
AUTH_GOOGLE_CLIENT_ID: ${{ vars.AUTH_GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_CLIENT_ID }}
AUTH_GOOGLE_CLIENT_SECRET: ${{ secrets.AUTH_GOOGLE_CLIENT_SECRET }}
AUTH_BINDING_KEY: ${{ secrets.AUTH_BINDING_KEY }}
AUTH_TRANSACTION_KEY: ${{ secrets.AUTH_TRANSACTION_KEY }}
AUTH_ACCESS_TOKEN_KID: ${{ vars.AUTH_ACCESS_TOKEN_KID || secrets.AUTH_ACCESS_TOKEN_KID }}
AUTH_ACCESS_TOKEN_PRIVATE_JWK: ${{ secrets.AUTH_ACCESS_TOKEN_PRIVATE_JWK }}
AUTH_PASSKEY_RP_ID: ${{ vars.AUTH_PASSKEY_RP_ID }}
AUTH_TRUSTED_ORIGINS: ${{ vars.AUTH_TRUSTED_ORIGINS }}
CLERK_WEBHOOK_SECRET: ${{ secrets.CLERK_WEBHOOK_SECRET }}
COOKIE_DOMAIN: ${{ vars.COOKIE_DOMAIN }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/tauri-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ jobs:
VITE_BACKEND_URL: ${{ vars.VITE_BACKEND_URL }}
VITE_CLUSTER_URL: ${{ vars.VITE_CLUSTER_URL }}
VITE_ELECTRIC_URL: ${{ vars.VITE_ELECTRIC_URL }}
VITE_CLERK_PUBLISHABLE_KEY: ${{ vars.VITE_CLERK_PUBLISHABLE_KEY }}
VITE_R2_PUBLIC_URL: ${{ vars.VITE_R2_PUBLIC_URL }}
with:
projectPath: apps/desktop
Expand Down
7 changes: 7 additions & 0 deletions apps/actors/alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* `ACTOR_DO` -> `ActorHandler` mapping (alchemy matches the live class by binding name).
*/
import { HazelStack, hazelWorkerProps, stageProps } from "@hazel/infra/cloudflare"
import { derived, merge, optionalPlain } from "@hazel/infra/env"
import * as Cloudflare from "alchemy/Cloudflare"
import { Effect } from "effect"

Expand All @@ -25,6 +26,12 @@ export default Effect.gen(function* () {
ACTOR_KV: actorKv,
NODE_ENV: stack.stage.kind === "dev" ? "development" : "production",
RIVET_PUBLIC_ENDPOINT: stack.urls.rivet,
...(yield* merge(
// Issuer and JWKS of the access tokens users connect with; also where bot
// tokens are validated. A plain-HTTP dev API needs the key set inline.
derived("API_BASE_URL", stack.urls.api),
optionalPlain("AUTH_ACCESS_TOKEN_JWKS"),
)),
},
observability: {
enabled: true,
Expand Down
13 changes: 13 additions & 0 deletions apps/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ CLERK_SECRET_KEY=sk_test_your_clerk_secret_here
CLERK_PUBLISHABLE_KEY=pk_test_your_clerk_publishable_here
CLERK_WEBHOOK_SECRET=whsec_your_clerk_webhook_secret_here

# Hazel sign-in (replacing Clerk). Off until AUTH_GITHUB_CLIENT_ID is set.
# GitHub OAuth App callback URL: {API_BASE_URL}/auth/github/callback
# Google OAuth client redirect URI: {API_BASE_URL}/auth/google/callback
# Generate the keys with: bun run --cwd packages/auth generate-keys
# AUTH_GITHUB_CLIENT_ID=
# AUTH_GITHUB_CLIENT_SECRET=
# AUTH_GOOGLE_CLIENT_ID=
# AUTH_GOOGLE_CLIENT_SECRET=
# AUTH_BINDING_KEY=
# AUTH_TRANSACTION_KEY=
# AUTH_ACCESS_TOKEN_KID=
# AUTH_ACCESS_TOKEN_PRIVATE_JWK=

# S3-Compatible Storage (e.g., Cloudflare R2)
# Bun auto-reads these S3_* env vars (or AWS_* as fallback)
# Get these from your S3-compatible storage provider dashboard
Expand Down
1 change: 1 addition & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@hazel/infra": "workspace:*",
"@hazel/integrations": "workspace:*",
"@hazel/schema": "workspace:*",
"@yielded/auth": "0.1.0-beta.38",
"alchemy": "catalog:alchemy",
"aws4fetch": "^1.0.20",
"dfx": "^1.1.0",
Expand Down
38 changes: 35 additions & 3 deletions apps/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
ConnectInviteRepo,
ConnectParticipantRepo,
CustomEmojiRepo,
InvitationRepo,
ChannelWebhookRepo,
GitHubSubscriptionRepo,
IntegrationConnectionRepo,
Expand All @@ -48,6 +49,8 @@ import { HazelApi } from "./api"
import { HttpApiRoutes } from "./http"
import { AttachmentPolicy } from "./policies/attachment-policy"
import { BotPolicy } from "./policies/bot-policy"
import { InvitationPolicy } from "./policies/invitation-policy"
import { Invitations } from "./services/invitations"
import { ChannelMemberPolicy } from "./policies/channel-member-policy"
import { ChannelPolicy } from "./policies/channel-policy"
import { ChannelSectionPolicy } from "./policies/channel-section-policy"
Expand Down Expand Up @@ -76,6 +79,8 @@ import { MockDataGenerator } from "./services/mock-data-generator"
import { OAuthBearerAuth } from "./services/oauth-bearer-auth"
import { ObjectStorage } from "./services/object-storage"
import { OAuthProviderRegistry } from "./services/oauth"
import { HazelAuthInstance, HazelAuthRoutes, HazelAuthServicesLive } from "./services/hazel-auth"
import { HazelSession } from "./services/hazel-session"
import { SessionManager } from "./services/session-manager"
import { WebhookBotService } from "./services/webhook-bot-service"
import { BotGatewayService } from "./services/bot-gateway-service"
Expand Down Expand Up @@ -103,11 +108,18 @@ const RpcRoute = RpcServer.layerHttp({
protocol: "http",
}).pipe(Layer.provide(RpcSerialization.layerNdjson), Layer.provide(RpcServerLive))

export const AllRoutes = Layer.mergeAll(HttpApiRoutes, HealthRouter, DocsRoute, RpcRoute).pipe(
export const AllRoutes = Layer.mergeAll(
HttpApiRoutes,
HealthRouter,
DocsRoute,
RpcRoute,
HazelAuthRoutes,
).pipe(
Layer.provide(
HttpRouter.cors({
allowedOrigins: [
"http://localhost:3000",
"http://localhost:3100",
"http://localhost:5173",
"https://app.hazel.sh",
"tauri://localhost",
Expand All @@ -119,6 +131,16 @@ export const AllRoutes = Layer.mergeAll(HttpApiRoutes, HealthRouter, DocsRoute,
),
)

/**
* Hazel's own sign-in: the configured instance, its auth services, and the session lookup
* the RPC and HttpApi middleware use. Inert until `AUTH_GITHUB_CLIENT_ID` is set. Needs
* `AuthSqlConnection` (and `AuthSqlDirect` on Workers) from the entry point.
*/
export const HazelAuthLive = HazelSession.layer.pipe(
Layer.provideMerge(HazelAuthServicesLive),
Layer.provideMerge(HazelAuthInstance.layer),
)

export const RepoLive = Layer.mergeAll(
MessageRepo.layer,
ChannelRepo.layer,
Expand Down Expand Up @@ -151,6 +173,7 @@ export const RepoLive = Layer.mergeAll(
BotCommandRepo.layer,
BotInstallationRepo.layer,
CustomEmojiRepo.layer,
InvitationRepo.layer,
)

export const PolicyLive = Layer.mergeAll(
Expand All @@ -174,6 +197,7 @@ export const PolicyLive = Layer.mergeAll(
RssSubscriptionPolicy.layer,
BotPolicy.layer,
CustomEmojiPolicy.layer,
InvitationPolicy.layer,
)

/**
Expand All @@ -199,10 +223,18 @@ export const AppServicesLive = Layer.mergeAll(
WebhookBotService.layer,
ChannelAccessSyncService.layer,
ConnectConversationService.layer,
Invitations.layer,
// SessionManager.layer includes BackendAuth.layer via dependencies
SessionManager.layer,
OAuthBearerAuth.layer,
).pipe(Layer.provideMerge(FetchHttpClient.layer))

/** `CurrentUser` resolution for authenticated routes. Requires `Database` from the platform. */
export const AppAuthorizationLive = AuthorizationLive.pipe(Layer.provideMerge(SessionManager.layer))
/**
* `CurrentUser` resolution for authenticated routes, and Hazel's sign-in services. Only the
* HTTP entry points build this; the Durable Objects never authenticate. Requires `Database`
* and the auth SQL connection from the platform.
*/
export const AppAuthorizationLive = AuthorizationLive.pipe(
Layer.provideMerge(SessionManager.layer),
Layer.provideMerge(HazelAuthLive),
)
13 changes: 12 additions & 1 deletion apps/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
* `worker.ts`. See infra/cloudflare-migration-plan.md.
*/
import { BunHttpServer, BunRuntime, BunSocket } from "@effect/platform-bun"
import { layerPool as authSqlPool } from "@hazel/auth/server"
import { Redis, RedisResultPersistenceLive } from "@hazel/effect-bun"
import { createTracingLayer } from "@hazel/effect-bun/Telemetry"
import { Config, ConfigProvider, Layer } from "effect"
import { Config, ConfigProvider, Effect, Layer } from "effect"
import { HttpMiddleware, HttpRouter } from "effect/http"
import { AllRoutes, AppAuthorizationLive, AppServicesLive, HazelApi } from "./app"
import { InviteMailer } from "./services/invite-mailer"
import { BotGatewayTransport } from "./services/bot-gateway-transport"
import { DiscordGatewayBackgroundLive } from "./services/chat-sync/discord-gateway-service"
import { EnvVars } from "./lib/env-vars"
import { DatabaseLive } from "./services/database"
import { MessageOutboxDispatcher } from "./services/message-outbox-dispatcher"
import { RateLimiterRedisLive } from "./services/rate-limiter-redis"
Expand All @@ -24,16 +27,24 @@ const TracerLive = createTracingLayer("api")
// ResultPersistence layer for session caching (uses Redis backing)
const PersistenceLive = RedisResultPersistenceLive.pipe(Layer.provide(Redis.Default))

/** Auth storage's own Postgres pool (Effect SQL; the rest of the backend uses Drizzle). */
const AuthSqlLive = Layer.unwrap(
Effect.map(Effect.service(EnvVars), (env) => authSqlPool(env.DATABASE_URL, { ssl: !env.IS_DEV })),
).pipe(Layer.provide(EnvVars.layer))

/**
* Bun's platform services: a pooled database, Redis-backed caches and rate limits, and bot gateway
* events appended to the Durable Streams server the Bun bot gateway reads.
*/
const PlatformLive = Layer.mergeAll(
DatabaseLive,
AuthSqlLive,
PersistenceLive,
Redis.Default,
RateLimiterRedisLive,
BotGatewayTransport.layerDurableStreams,
// No email service on Bun: invite links are logged.
InviteMailer.layerLog,
)

/** Long-running loops; on Cloudflare these are Durable Objects driven by crons. */
Expand Down
27 changes: 27 additions & 0 deletions apps/backend/src/policies/invitation-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ErrorUtils } from "@hazel/domain"
import type { OrganizationId } from "@hazel/schema"
import { Context, Effect, Layer } from "effect"
import { withAnnotatedScope } from "../lib/policy-utils"
import { OrgResolver } from "../services/org-resolver"

/** Only an organization's admins and owners invite, list and revoke its invitations. */
export class InvitationPolicy extends Context.Service<InvitationPolicy>()("InvitationPolicy/Policy", {
make: Effect.gen(function* () {
const policyEntity = "Invitation" as const
const orgResolver = yield* OrgResolver

const canManage = (organizationId: OrganizationId, action: "create" | "select" | "delete") =>
ErrorUtils.refailUnauthorized(
policyEntity,
action,
)(
withAnnotatedScope((scope) =>
orgResolver.requireAdminOrOwner(organizationId, scope, policyEntity, action),
),
)

return { canManage } as const
}),
}) {
static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(OrgResolver.layer))
}
16 changes: 16 additions & 0 deletions apps/backend/src/rpc/handlers/invitations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { InvitationRpcs } from "@hazel/domain/rpc"
import { Effect } from "effect"
import { Invitations } from "../../services/invitations"

export const InvitationRpcLive = InvitationRpcs.toLayer(
Effect.gen(function* () {
const invitations = yield* Invitations
return {
"invitation.create": invitations.create,
"invitation.list": invitations.list,
"invitation.revoke": invitations.revoke,
"invitation.preview": invitations.preview,
"invitation.accept": invitations.accept,
}
}),
)
Loading
Loading