6 Commits

Author SHA1 Message Date
-Puter
bf2300a6be feat: same-origin auth via reverse proxy and secure cookies
Route /api/auth through the same origin in both dev and prod so
cookies stay first-party and the browser and React Router SSR share
one auth surface.

- Vite dev server proxies /api/auth to the Convex HTTP site.
- Better Auth uses secure cookies when the site URL is https.
- Add docs/auth-proxy.md documenting the required production ingress
  (Caddy/Traefik) and Convex SITE_URL / CONVEX_SITE_URL setup.
2026-07-29 19:32:26 +05:30
-Puter
0657037c84 feat: make Rivet endpoint optional for self-hosted runtime
RIVET_ENDPOINT and RIVET_PUBLIC_ENDPOINT are now optional in the agent
env schema; empty strings are coerced to undefined. The agent runtime
only passes an endpoint override to the Rivet client when one is set,
so the client falls back to its default (suitable for self-hosted
Rivet where no explicit endpoint is required).
2026-07-29 19:32:22 +05:30
-Puter
64a783b445 build: allow importing .ts extensions in Convex tsconfig
Enable allowImportingTsExtensions in the Convex tsconfig so the
backend code that uses explicit .ts import specifiers type-checks
cleanly.
2026-07-29 19:32:18 +05:30
-Puter
ed28943e7a fix: drop .ts extensions from primitives imports
Use extensionless import specifiers in project.ts and work.ts so the
modules resolve under Node ESM and bundler resolution, consistent with
allowImportingTsExtensions on the consuming packages.
2026-07-29 19:32:15 +05:30
-Puter
9e148489f0 chore: stop hiding repos/ in VS Code file explorer
Drop the files.exclude rule for repos/**. The directory is still
excluded from auto-imports and file watching, but no longer hidden
from the explorer.
2026-07-29 19:32:10 +05:30
-Puter
25f86d94cc chore: broaden .env ignore patterns
Add .env.* to .gitignore so per-environment secrets like
.env.staging and .env.production are never tracked, alongside the
existing .env and .env*.local rules.
2026-07-29 19:32:05 +05:30
10 changed files with 103 additions and 14 deletions

1
.gitignore vendored
View File

@@ -54,3 +54,4 @@ coverage
.cache
tmp
temp
.env.*

View File

@@ -1,9 +1,6 @@
{
"typescript.preferences.autoImportFileExcludePatterns": ["repos/**"],
"javascript.preferences.autoImportFileExcludePatterns": ["repos/**"],
"files.exclude": {
"repos/**": true
},
"files.watcherExclude": {
"repos/**": true
},

View File

@@ -7,14 +7,17 @@ import { defineConfig } from "vite-plus";
export default defineConfig({
envDir: path.resolve(import.meta.dirname, "../.."),
plugins: [tailwindcss(), reactRouter()],
ssr: {
noExternal: true,
},
resolve: {
dedupe: ["convex", "react", "react-dom"],
tsconfigPaths: true,
},
server: {
allowedHosts: true,
proxy: {
"/api/auth": {
changeOrigin: true,
target: "https://befitting-dalmatian-161.convex.site",
},
},
},
});

78
docs/auth-proxy.md Normal file
View File

@@ -0,0 +1,78 @@
# Auth Proxy — Production Ingress Requirement
The application uses **same-origin authentication**: the browser and React Router SSR both hit
`/api/auth/*` on the public application domain. This keeps cookies first-party, avoids
cross-origin credentials, and gives development and production the same API surface.
## Required production route
At the public application domain, route:
| Prefix | Target |
|---|---|
| `/api/auth/*` | Convex HTTP site |
| `/*` | React Router frontend |
### Caddy
```caddy
zopu.example.com {
handle /api/auth/* {
reverse_proxy https://befitting-dalmatian-161.convex.site {
header_up Host befitting-dalmatian-161.convex.site
}
}
handle {
reverse_proxy frontend:3000
}
}
```
### Dokploy / Traefik
Create a higher-priority path router for `/api/auth` that forwards to the external Convex
site URL. Ensure it:
- Preserves the original browser `Cookie` header
- Passes `Set-Cookie` responses back (rewrite domain if Convex emits an explicit one)
- Preserves the original method and body
- Forwards `X-Forwarded-Host` and `X-Forwarded-Proto`
- Passes the full path unchanged (e.g. `/api/auth/convex/token`)
- Does not cache auth responses
- Allows OAuth callback routes under the same prefix
## Convex environment
The Convex deployment `SITE_URL` must match the public origin users visit, not the Convex
site URL:
```bash
# Production
npx convex env set SITE_URL 'https://zopu.example.com'
# Local
npx convex env set SITE_URL 'http://100.101.157.28:5173'
# Staging
npx convex env set SITE_URL 'https://zopu.cheaptricks.puter.wtf'
```
This value populates Better Auth's `trustedOrigins`. The Convex deployment also uses
`CONVEX_SITE_URL` as the internal `baseURL` for route registration; that value is the HTTP
site URL (e.g. `https://befitting-dalmatian-161.convex.site`).
## Why same-origin
1. **First-party cookies.** No `SameSite=None`, no third-party cookie restrictions.
2. **SSR consistency.** The React Router server uses the same auth surface the browser
sees; no cross-host credential translation.
3. **No CORS complexity.** The browser talks only to its own origin.
4. **The reverse proxy handles the cross-host hop server-side.**
## Related
- [Better Auth: Trusted Origins](https://github.com/better-auth/better-auth/blob/main/docs/content/docs/reference/security.mdx)
- `apps/web/vite.config.ts` — Vite dev proxy (`/api/auth` → Convex site)
- `packages/auth/src/web/auth-client.ts``window.location.origin`
- `apps/web/src/lib/auth.server.ts` — SSR token loader via request origin

View File

@@ -80,9 +80,10 @@ export const executeAgentOsAttempt = async (
decodeWorkAttemptExecutionInput(rawInput)
);
const env = parseAgentEnv(process.env);
const endpoint = env.RIVET_PUBLIC_ENDPOINT ?? env.RIVET_ENDPOINT;
const client = createClient<typeof runtimeRegistry>({
disableMetadataLookup: true,
endpoint: env.RIVET_PUBLIC_ENDPOINT ?? env.RIVET_ENDPOINT,
...(endpoint ? { endpoint } : {}),
});
const vm = client.workspace.getOrCreate([input.workspaceKey], {
params: { token: env.RIVET_WORKSPACE_TOKEN },
@@ -243,9 +244,10 @@ export const cancelAgentOsAttempt = async (
attemptId: string
) => {
const env = parseAgentEnv(process.env);
const endpoint = env.RIVET_PUBLIC_ENDPOINT ?? env.RIVET_ENDPOINT;
const client = createClient<typeof runtimeRegistry>({
disableMetadataLookup: true,
endpoint: env.RIVET_PUBLIC_ENDPOINT ?? env.RIVET_ENDPOINT,
...(endpoint ? { endpoint } : {}),
});
await client.workspace
.getOrCreate([workspaceKey], {

View File

@@ -17,6 +17,7 @@ export const authComponent = createClient<DataModel>(components.betterAuth);
const createAuth = (ctx: GenericCtx<DataModel>) =>
betterAuth({
advanced: { useSecureCookies: siteUrl.startsWith("https://") },
baseURL: env.CONVEX_SITE_URL,
database: authComponent.adapter(ctx),
emailAndPassword: {

View File

@@ -6,6 +6,7 @@
"compilerOptions": {
/* These settings are not required by Convex and can be modified. */
"allowJs": true,
"allowImportingTsExtensions": true,
"strict": true,
"moduleResolution": "Bundler",
"jsx": "react-jsx",

View File

@@ -14,8 +14,14 @@ const agentEnvSchema = z.object({
GITEA_TOKEN: z.string().min(1).optional(),
GITEA_URL: z.url().default("https://git.openputer.com"),
OPENROUTER_API_KEY: z.string().min(1).optional(),
RIVET_ENDPOINT: z.url(),
RIVET_PUBLIC_ENDPOINT: z.url().optional(),
RIVET_ENDPOINT: z.preprocess(
(value) => (value === "" ? undefined : value),
z.url().optional()
),
RIVET_PUBLIC_ENDPOINT: z.preprocess(
(value) => (value === "" ? undefined : value),
z.url().optional()
),
RIVET_WORKSPACE_TOKEN: z.string().min(32),
ZOPU_SOURCE_REPOSITORY: z.string().min(1).default("/opt/zopu-source"),
});

View File

@@ -1,9 +1,9 @@
/* eslint-disable max-classes-per-file -- deep Effect v4 domain module: branded schemas, tagged errors, and context services */
import { Context, Effect, Layer, Schema } from "effect";
import { OrganizationId, ProjectId, TimestampMs } from "./signal.ts";
import { OrganizationId, ProjectId, TimestampMs } from "./signal";
export type { OrganizationId, ProjectId } from "./signal.ts";
export type { OrganizationId, ProjectId } from "./signal";
// ---------------------------------------------------------------------------
// Domain constants

View File

@@ -1,7 +1,7 @@
import { Array as EffectArray, Effect, Order, Schema } from "effect";
export { WorkStatus } from "./work-lifecycle.ts";
export type { WorkStatus as WorkLifecycleStatus } from "./work-lifecycle.ts";
export { WorkStatus } from "./work-lifecycle";
export type { WorkStatus as WorkLifecycleStatus } from "./work-lifecycle";
const MeaningfulString = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, {