Merge pull request #887 from oblien/ship

ship sdk, intial scale panel
This commit is contained in:
Hydra de lerne
2026-09-15 20:06:20 +03:00
committed by GitHub
1800 changed files with 88618 additions and 34493 deletions
+71 -8
View File
@@ -66,9 +66,27 @@ jobs:
exit 1
fi
test:
name: Test
test_suites:
name: Tests (${{ matrix.suite }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- suite: API 1/2
command: bun run --cwd apps/api test --shard=1/2
- suite: API 2/2
command: bun run --cwd apps/api test --shard=2/2
- suite: Database
command: bun run --cwd packages/db test
# Both suites rebuild the same native bundle. Keep them sequential on
# their own runner; the other packages do not share that filesystem.
- suite: SDK and CLI
command: bun run test --filter=@repo/sdk --filter=@repo/cli --log-order=stream
# Exclusions keep new workspace test scripts included automatically.
- suite: Other packages
command: bun run test --filter=!@repo/api --filter=!@repo/db --filter=!@repo/sdk --filter=!@repo/cli --log-order=stream
steps:
- name: Checkout
uses: actions/checkout@v7
@@ -89,13 +107,58 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
# Runs `turbo run test` → vitest across every package that defines a test
# script (@repo/core, @repo/adapters, @repo/db [PGlite — no external DB],
# apps/api, apps/dashboard). Packages resolve to src, so no build needed.
# apps/api excludes test/e2e/** here — those need a daemon and run in the
# e2e-docker job in release-gate.yml, where they gate the publish.
# Run every workspace test, with the large API suite split across runners.
# Direct Vitest runs and Turbo's stream mode expose progress immediately.
# API test/e2e/** still runs in release-gate.yml against a real daemon.
- name: Run tests
run: bun run test
run: ${{ matrix.command }}
# Preserve the existing required "Test" check. A failed, skipped, or canceled
# matrix must fail this check; no individual shard can make the PR green.
test:
name: Test
needs: test_suites
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Require every test suite to pass
env:
TEST_SUITES_RESULT: ${{ needs.test_suites.result }}
run: |
if [ "$TEST_SUITES_RESULT" != "success" ]; then
echo "::error::Test suites finished with status: $TEST_SUITES_RESULT"
exit 1
fi
docs:
name: Documentation
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "22"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build public SDK and CLI
run: bun run build:sdk
- name: Check documentation and public examples
run: bun run docs:check
- name: Build documentation website
run: bun run --cwd apps/web build
# The webmail server's own suite, which nothing else runs.
#
+25
View File
@@ -63,6 +63,13 @@ jobs:
- name: Typecheck apps/api
run: bun run --cwd apps/api lint
- name: Typecheck SDK and shared platform
run: |
bun run --cwd packages/contracts lint
bun run --cwd packages/platform lint
bun run --cwd packages/sdk lint
bun run --cwd apps/cli lint
# `turbo run test` across every package with a test script. Includes the two
# migration suites in packages/db: migrate-chain (the chain applied to a
# POPULATED database, plus a self-check proving it can fail) and
@@ -71,6 +78,24 @@ jobs:
- name: Run tests
run: bun run test
sdk-package:
name: Installed SDK (Node ${{ matrix.node }})
runs-on: ubuntu-latest
strategy:
matrix:
node: ['22', '24']
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: .bun-version
- run: bun install --frozen-lockfile
- run: bun run --cwd packages/openship build
- run: bun run --cwd packages/openship test:package
# Rollback and restore against a REAL Docker daemon. This is the only job that proves
# those paths work at all; every other test in the repo mocks the runtime. It lived in
# CI, where it ran alongside the release it claimed to gate — it is here now so a
+11 -30
View File
@@ -4,7 +4,7 @@ on:
push:
tags:
- 'v*.*.*'
# Manual run publishes ONLY the CLI to npm (current version, no tag / installers).
# Manual run publishes ONLY the SDK/CLI package to npm (current version, no tag / installers).
workflow_dispatch: {}
concurrency:
@@ -542,7 +542,7 @@ jobs:
retention-days: 7
publish-npm:
name: Publish CLI to npm
name: Publish Openship to npm
runs-on: ubuntu-24.04
# npm is the ONE immutable artifact — a published version can never be
# replaced. So it must publish LAST, only after every other job in the
@@ -603,8 +603,8 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build CLI
run: bun run --cwd apps/cli build
- name: Build SDK and CLI package
run: bun run --cwd packages/openship build
# Gate: run the ACTUAL built bundle across node/bun/shebang/no-node and
# boot the real server. This is the last check before the IMMUTABLE npm
@@ -614,8 +614,11 @@ jobs:
- name: Release smoke — built CLI launches + server boots
run: SMOKE_SKIP_BUILD=1 bash apps/cli/scripts/release-smoke.sh
- name: Verify packed SDK outside the workspace
run: bun run --cwd packages/openship test:package
- name: Publish to npm (OIDC trusted publishing — no token)
working-directory: apps/cli
working-directory: packages/openship
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
@@ -630,31 +633,9 @@ jobs:
echo "::notice::${NAME}@${VERSION} already on npm — skipping publish (re-run of an existing release)."
exit 0
fi
# tsup bundles the @repo/* workspace packages into dist/, so they must
# NOT appear in the published manifest — a `workspace:*` specifier is
# uninstallable off the monorepo (bun/npm: "@repo/… failed to resolve").
# Strip EVERY workspace dependency generically: hard-coding the list
# once shipped a broken 0.4.1 that kept @repo/adapters. This can never
# silently miss a newly-added @repo/* again.
node -e '
const fs = require("fs");
const p = JSON.parse(fs.readFileSync("package.json", "utf8"));
const stripped = [];
for (const k of Object.keys(p.dependencies || {})) {
if (String(p.dependencies[k]).startsWith("workspace:")) {
delete p.dependencies[k];
stripped.push(k);
}
}
fs.writeFileSync("package.json", JSON.stringify(p, null, 2) + "\n");
console.log("stripped workspace deps:", stripped.join(", ") || "(none)");
'
# Fail loudly if any workspace: specifier somehow survives — never ship
# an uninstallable package.
if grep -q '"workspace:' package.json; then
echo "::error::a workspace:* dependency survived the strip — refusing to publish an uninstallable package."
exit 1
fi
# The distribution owns an installable manifest. Never rewrite a
# source workspace manifest during publication.
bun run check
# Prereleases (v1.2.3-rc.1) publish to the `next` dist-tag, not `latest`.
if [[ "${TAG}" == *-* ]]; then
npm publish --access public --tag next
+3
View File
@@ -22,8 +22,10 @@
"@hono/node-ws": "^1.3.1",
"@hono/typebox-validator": "^0.3.0",
"@repo/adapters": "workspace:*",
"@repo/contracts": "workspace:*",
"@repo/core": "workspace:*",
"@repo/db": "workspace:*",
"@repo/platform": "workspace:*",
"@sinclair/typebox": "^0.34.48",
"better-auth": "^1.5.4",
"bullmq": "^5.70.4",
@@ -39,6 +41,7 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@repo/sdk": "workspace:*",
"@types/node": "^22.13.0",
"@types/nodemailer": "^8.0.1",
"tsup": "^8.5.1",
+3 -1
View File
@@ -37,6 +37,8 @@
* core/ ← @repo/core source (workspace dep)
* db/ ← @repo/db source + drizzle/ migrations
* adapters/ ← @repo/adapters source (workspace dep)
* contracts/ ← shared SDK/API contracts
* platform/ ← shared authorization/application operations
*
* Workspace packages are copied verbatim and referenced via `file:`
* paths in api/package.json. They are not on npm — shipping the
@@ -66,7 +68,7 @@ const PACKAGES_DIR = join(REPO_ROOT, "packages");
* (db-email, ui, onboarding) aren't included — they're either dashboard-
* only or webmail-only.
*/
const API_WORKSPACE_DEPS = ["core", "db", "adapters"] as const;
const API_WORKSPACE_DEPS = ["core", "db", "adapters", "contracts", "platform"] as const;
/**
* Output directory. Defaults to `apps/api/release-dist/` (the canonical
+67
View File
@@ -0,0 +1,67 @@
/** Read-only release checks. Does not create tokens, checkouts, or resources. */
import { runtimeTarget } from "@repo/core";
import { OblienBillingApi } from "@repo/platform/engine/lib/oblien-billing-api";
import { OBLIEN_WEBHOOK_EVENTS, oblienWebhookUrl } from "@repo/platform/engine/lib/oblien-webhook-config";
const results: Array<{ check: string; ok: boolean; detail?: string }> = [];
const record = (check: string, ok: boolean, detail?: string) => results.push({ check, ok, ...(detail ? { detail } : {}) });
const clientId = process.env.OBLIEN_CLIENT_ID;
const clientSecret = process.env.OBLIEN_CLIENT_SECRET;
const apiBase = process.env.OBLIEN_API_URL ?? "https://api.oblien.com";
record("Cloud mode", process.env.CLOUD_MODE === "true");
record("Oblien credentials configured", Boolean(clientId && clientSecret));
record("Webhook secret configured", Boolean(process.env.OBLIEN_WEBHOOK_SECRET));
record("Subscription purchases enabled", process.env.BILLING_ENABLED === "true");
record("Credit purchases enabled", process.env.BILLING_TOPUPS_ENABLED === "true");
const billing = new OblienBillingApi({ clientId, clientSecret, baseUrl: apiBase });
const checks = await Promise.allSettled([
(async () => {
const catalog = await billing.getCatalog();
const plans = catalog.plans.filter((plan) => plan.priceMonthly !== null);
record("Provider catalog", plans.length > 0 && [...catalog.plans, ...catalog.creditPacks].every((item) => item.currency.toUpperCase() === "USD"),
`${plans.length} priced plans, ${catalog.creditPacks.length} credit packs`);
})(),
(async () => {
const defaults = await billing.getDefaults();
record("Finite automatic namespace policy", defaults.autoApply && defaults.quotaLimit !== null && defaults.onOverdraftAction === "stop_workspaces",
`autoApply=${defaults.autoApply}, quotaLimit=${defaults.quotaLimit}, action=${defaults.onOverdraftAction}`);
})(),
(async () => {
const callback = oblienWebhookUrl(process.env.OBLIEN_WEBHOOK_URL, runtimeTarget.api);
if (!clientId || !clientSecret) throw new Error("Oblien credentials are missing");
const response = await fetch(`${apiBase.replace(/\/+$/, "")}/webhooks`, {
headers: { "X-Client-ID": clientId, "X-Client-Secret": clientSecret },
redirect: "error", signal: AbortSignal.timeout(15_000),
});
if (!response.ok) throw new Error(`Webhook registry HTTP ${response.status}`);
const body = await response.json() as { success: boolean; webhooks?: Array<{ url: string; active: boolean; namespace?: string | null; events: string[]; secret?: string | null }> };
const webhook = body.success && body.webhooks?.find((item) => item.url === callback && !item.namespace && item.active);
const missing = OBLIEN_WEBHOOK_EVENTS.filter((event) => !webhook || !webhook.events.includes(event));
record("Account-wide signed billing webhook", Boolean(webhook && webhook.secret && missing.length === 0),
webhook ? `Missing events: ${missing.join(", ") || "none"}` : "No active account-wide webhook matches the configured callback");
})(),
(async () => {
if (!clientId || !clientSecret) throw new Error("Oblien credentials are missing");
const response = await fetch(`${apiBase.replace(/\/+$/, "")}/namespaces?limit=1`, {
headers: { "X-Client-ID": clientId, "X-Client-Secret": clientSecret },
redirect: "error", signal: AbortSignal.timeout(15_000),
});
if (!response.ok) throw new Error(`Namespace registry HTTP ${response.status}`);
const body = await response.json() as { success: boolean; data?: Array<{ slug: string }> };
// The probe is read-only even on a new account with no namespace yet.
const namespace = body.success && body.data?.[0]?.slug || "openship-billing-readiness";
await billing.getSubscription(namespace);
record("Namespace subscription API (SDK 2.3)", true, "Authenticated namespace-bound response verified");
})(),
]);
checks.forEach((result, index) => {
if (result.status === "rejected") {
// Never serialize provider bodies, headers, credentials, or webhook secrets.
const error = result.reason;
record(["Provider catalog", "Namespace default policy", "Webhook registration", "Namespace subscription API (SDK 2.3)"][index]!, false,
error instanceof Error ? error.message : "Read failed");
}
});
console.log(JSON.stringify({ readOnly: true, checks: results, passed: results.every((result) => result.ok) }, null, 2));
process.exitCode = results.every((result) => result.ok) ? 0 : 1;
+1 -1
View File
@@ -20,7 +20,7 @@
import type Stripe from "stripe";
import { PLAN_IDS, PRICING, resolveStripePriceId, type PlanTierId } from "@repo/core";
import { stripe } from "../src/lib/stripe-client";
import { stripe } from "@repo/platform/engine/lib/stripe-client";
const INVOCATION = "bun --cwd apps/api scripts/promo-code.ts";
/** Stamped on everything this CLI creates, so hand-made codes stay tellable. */
+25 -65
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import { env, trustedOrigins } from "./config/env";
import { env, trustedOrigins } from "@repo/platform/engine/config/env";
import { handleApiError } from "./middleware/error-handler";
import { authRouteLimiter } from "./middleware/rate-limiter";
import { clientIpMiddleware } from "./middleware/client-ip";
@@ -10,14 +10,13 @@ import { forceMcpConsent } from "./middleware/mcp-consent";
import { originGuard } from "./middleware/origin-guard";
import { migrationGuard } from "./middleware/migration-guard";
import { initPlatform } from "@repo/adapters";
import { validatePlanPriceIds } from "@repo/core";
import { resolvePlatformConfig } from "./lib/controller-helpers";
import { runWithRequestStore } from "./lib/request-store";
import { resolvePlatformConfig } from "@repo/platform/engine/lib/platform-config";
import { runWithRequestStore } from "@repo/platform/engine/lib/request-store";
import { runWithCallSource } from "./lib/call-source";
import { sanitizeRequestLogLine } from "./lib/request-log-redaction";
import { authRoutes } from "./modules/auth/auth.routes";
import { auth } from "./lib/auth";
import { auth } from "@repo/platform/engine/lib/auth";
import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from "better-auth/plugins";
import {
MCP_RESOURCE_PATHS,
@@ -44,7 +43,7 @@ import { billingPlansRoutes } from "./modules/billing/billing.routes";
import { webhookRoutes } from "./modules/webhooks/webhook.routes";
import { healthRoutes } from "./modules/health/health.routes";
import { githubRoutes } from "./modules/github";
import * as githubAuth from "./modules/github/github.auth";
import * as githubAuth from "@repo/platform/engine/modules/github/github.auth";
import { settingsRoutes } from "./modules/settings/settings.routes";
import { tokenRoutes } from "./modules/tokens/token.routes";
import { mcpRoutes } from "./modules/mcp/mcp.routes";
@@ -55,14 +54,14 @@ import { backupRoutes } from "./modules/backups/backup.routes";
import { auditRoutes } from "./modules/audit/audit.routes";
import { permissionsRoutes } from "./modules/permissions/permissions.routes";
import { backupDestinationRoutes } from "./modules/backup-destinations/destination.routes";
import { reconcileAllSchedules } from "./modules/backups/triggers/cron";
import { reconcileJobs } from "./modules/jobs/job.service";
import { scheduleBillingAnniversary } from "./modules/billing/billing-anniversary.cron";
import { ensureOblienWebhook } from "./lib/openship-cloud";
import { ensureOblienDefaultQuota } from "./modules/billing/billing-oblien-quota";
import { backfillWebhookSecrets } from "./modules/github/github.service";
import { backupOrchestrator } from "./modules/backups/backup.orchestrator";
import { getJobRunner } from "./lib/job-runner";
import { reconcileAllSchedules } from "@repo/platform/engine/modules/backups/triggers/cron";
import { reconcileJobs } from "@repo/platform/engine/modules/jobs/job.service";
import { scheduleBillingAnniversary } from "@repo/platform/engine/modules/billing/billing-anniversary.cron";
import { ensureOblienWebhook } from "@repo/platform/engine/lib/openship-cloud";
import { ensureOblienDefaultQuota } from "@repo/platform/engine/modules/billing/billing-oblien-quota";
import { backfillWebhookSecrets } from "@repo/platform/engine/modules/github/github.service";
import { backupOrchestrator } from "@repo/platform/engine/modules/backups/backup.orchestrator";
import { getJobRunner } from "@repo/platform/engine/lib/job-runner/index";
import { repos } from "@repo/db";
/* ---------- Initialize platform (runtime + infra + system) ---------- */
@@ -387,7 +386,7 @@ if (env.CLOUD_MODE) {
// interrupted run. Self-hosted only (migrations don't run on the SaaS); the
// dynamic import keeps the SSH/runtime chain out of the cloud boot path.
if (!env.CLOUD_MODE) {
const { migrationOrchestrator } = await import("./modules/migration/migration.orchestrator");
const { migrationOrchestrator } = await import("@repo/platform/engine/modules/migration/migration.orchestrator");
await migrationOrchestrator.recoverInterruptedMigrations();
}
@@ -415,34 +414,23 @@ if (env.CLOUD_MODE) {
.catch((err) => console.warn("[boot] failStaleRunning failed:", err));
}
// Hourly billing-period rollover — re-arms Oblien quota for orgs
// whose current_period_end has passed (safety net for paid orgs
// whose Stripe webhook lagged, and the primary mechanism for
// free-tier orgs).
// Refresh entitlement mirrors every five minutes; Oblien owns renewals.
void scheduleBillingAnniversary().catch((err) =>
console.warn("[boot] scheduleBillingAnniversary failed:", err),
);
// Register the Oblien billing webhook (credits usage/low/depleted + quota
// threshold). Idempotent + self-gating on CLOUD_MODE; without it Oblien
// never calls our receiver.
// Register signed payment, entitlement, and credit notifications.
void ensureOblienWebhook().catch((err) =>
console.warn("[boot] ensureOblienWebhook failed:", err),
);
// Account-wide default credit ceiling, auto-applied by Oblien to any namespace
// created without an explicit setQuota. Backstop only — the spend path asserts
// the real ceiling — but it makes the free tier, not "unlimited", the failure
// mode of a forgotten quota push. Self-gating on CLOUD_MODE.
// Validate onboarding policy without modifying provider quotas or grants.
void ensureOblienDefaultQuota().catch((err) =>
console.warn("[boot] ensureOblienDefaultQuota failed:", err),
);
// Drain orgs that have no Oblien namespace recorded. Every org predates
// namespace persistence (the column was read in eleven places and written in
// none), so until this sweep finishes their credit quotas and resource
// ceilings do not exist on Oblien's side. Bounded per boot.
void import("./modules/billing/billing-namespace.provision")
// Retry incomplete namespace onboarding, bounded per boot.
void import("@repo/platform/engine/modules/billing/billing-namespace.provision")
.then(({ backfillOrgNamespaces }) => backfillOrgNamespaces())
.then((stats) => {
if (stats.done > 0 || stats.failed > 0) {
@@ -453,38 +441,10 @@ if (env.CLOUD_MODE) {
})
.catch((err) => console.warn("[boot] backfillOrgNamespaces failed:", err));
// Every PUBLISHED price must have a real Stripe price id in the environment.
// Now that the pricing catalog states actual prices, a missing id is a
// customer-visible failure: the plan card shows $39 and checkout 503s. This
// check already existed but had NO caller in either mode — wired here.
//
// Loud, not fatal: refusing to boot the whole SaaS over an unset price id
// would trade a broken checkout button for a total outage, and checkout
// already fails closed on its own (503 BILLING_NOT_CONFIGURED at the point of
// use, plus BILLING_ENABLED defaults off). Self-hosted logs it as information
// — it never sells anything.
// A live campaign must match its Stripe coupon, or the page advertises a
// discount the customer won't get. Only reaches Stripe when a campaign is
// actually running, so the common case costs nothing.
void import("./modules/billing/billing.service")
.then(({ verifyCampaigns }) => verifyCampaigns())
.then((problems) => {
for (const p of problems) console.error(`[boot] pricing campaign: ${p}`);
})
.catch((err) => console.warn("[boot] verifyCampaigns failed:", err));
{
const { missing } = validatePlanPriceIds();
if (missing.length > 0) {
const detail = missing.join(", ");
if (env.CLOUD_MODE) {
console.error(
`[boot] FATAL: published prices with no Stripe price id configured: ${detail}. Set those env vars or unpublish the price in packages/core/src/pricing/pricing.json.`,
);
} else {
console.log(`[boot] billing not configured (self-hosted, expected): ${detail}`);
}
}
if (env.CLOUD_MODE) {
void import("@repo/platform/engine/modules/billing/billing-catalog")
.then(({ getCloudBillingCatalog }) => getCloudBillingCatalog({ fresh: true }))
.catch((error) => console.error("[boot] Oblien billing catalog unavailable:", error));
}
// Self-hosted only: backfill per-project GitHub webhook secrets for
@@ -509,7 +469,7 @@ if (env.CLOUD_MODE) {
// dispatches them to per-channel workers (email/webhook/in_app/slack).
// Lightweight in-process timer — fine for the cluster sizes we target.
{
const { startNotificationRunner } = await import("./lib/notification-workers");
const { startNotificationRunner } = await import("@repo/platform/engine/lib/notification-workers");
startNotificationRunner();
console.log("[boot] notification runner started");
}
@@ -523,7 +483,7 @@ if (env.CLOUD_MODE) {
// stay as-is (some are cloud); new self-hosted boot work belongs here.
{
const { registerStartupHooks } = await import("./lib/startup/register");
const { runStartupHooks } = await import("./lib/startup");
const { runStartupHooks } = await import("@repo/platform/engine/lib/startup/index");
registerStartupHooks();
await runStartupHooks();
}
+14 -8
View File
@@ -7,12 +7,12 @@ import {
} from "@repo/adapters";
import { isDevWatchReload } from "@repo/db";
import { app } from "./app";
import { cloudRuntimeTarget, cloudRuntimeTargetId, env, runtimeTargetId } from "./config/env";
import { getAuthMode } from "./lib/auth-mode";
import { edgeBuildSpec, pinnedEdgeImage } from "./lib/edge-image";
import { cloudRuntimeTarget, cloudRuntimeTargetId, env, runtimeTargetId } from "@repo/platform/engine/config/env";
import { getAuthMode } from "@repo/platform/engine/lib/auth-mode";
import { edgeBuildSpec, pinnedEdgeImage } from "@repo/platform/engine/lib/edge-image";
import { reportHostChannelAtBoot } from "./lib/host-channel-banner";
import { mailBuildSpec, pinnedMailImage } from "./lib/mail-image";
import { getJobRunner } from "./lib/job-runner";
import { mailBuildSpec, pinnedMailImage } from "@repo/platform/engine/lib/mail-image";
import { getJobRunner } from "@repo/platform/engine/lib/job-runner/index";
import { enforceRouteScanAtBoot } from "./lib/route-scanner";
import { attachTunnelingLifecycle, type TunnelingLifecycle } from "./modules/tunneling";
@@ -169,7 +169,7 @@ async function shutdown(signal: NodeJS.Signals): Promise<void> {
// boot anyway, and the OS reclaims the sockets when we exit.
if (!fastReload) {
try {
const { stopAllTunnels } = await import("./lib/ssh-tunnel-manager");
const { stopAllTunnels } = await import("@repo/platform/engine/lib/ssh-tunnel-manager");
await stopAllTunnels();
} catch (err) {
console.warn("[shutdown] port-forward close failed:", err);
@@ -182,7 +182,7 @@ async function shutdown(signal: NodeJS.Signals): Promise<void> {
// the tunnels: the successor's first poll tick re-subscribes.
try {
const { stopAllContainerEventWatchers } = await import(
"./modules/monitoring/container-events"
"@repo/platform/engine/modules/monitoring/container-events"
);
await stopAllContainerEventWatchers();
} catch (err) {
@@ -209,6 +209,12 @@ async function shutdown(signal: NodeJS.Signals): Promise<void> {
// Close the DB after the HTTP server and jobs (both use it) have drained.
// For embedded PGlite this frees the single-instance lock so the next start
// opens the data dir cleanly instead of racing a not-yet-released lock.
try {
const { closeDeviceFlows } = await import("@repo/platform/engine/modules/github/github.local-auth");
await closeDeviceFlows();
} catch (err) {
console.warn("[shutdown] GitHub device authorization close failed:", err);
}
try {
const { closeDb } = await import("@repo/db");
await closeDb();
@@ -222,7 +228,7 @@ async function shutdown(signal: NodeJS.Signals): Promise<void> {
// daemonized process that would otherwise linger on the remote host past
// this process's exit. Bounded internally, so it can't outrun the deadline.
try {
const { sshManager } = await import("./lib/ssh-manager");
const { sshManager } = await import("@repo/platform/engine/lib/ssh-manager");
await sshManager.destroy();
} catch (err) {
console.warn("[shutdown] ssh pool close failed:", err);
+2 -55
View File
@@ -18,63 +18,10 @@
*/
import type { Context } from "hono";
import { repos } from "@repo/db";
export { audit, type AuditContext, type AuditEventInput } from "@repo/platform/engine/lib/audit-emitter";
import type { AuditContext } from "@repo/platform/engine/lib/audit-emitter";
import { resolveCallClientId, resolveCallSource, type AuditSource } from "./call-source";
export interface AuditContext {
organizationId: string;
actorUserId?: string | null;
ipAddress?: string | null;
userAgent?: string | null;
/** Where the action came in from. Filled by `auditContextFrom`. */
source?: AuditSource | null;
/** Which client of that surface — `oauth:<clientId>` / `pat:<tokenId>`. Only
* MCP dispatch sets it; see call-source.ts. */
sourceClientId?: string | null;
}
export interface AuditEventInput {
eventType: string;
resourceType?: string | null;
resourceId?: string | null;
before?: unknown;
after?: unknown;
/** Overrides the context's source. For emitters with no request to read
* (crons, Better Auth hooks, webhook deliveries). */
source?: AuditSource | null;
/** Overrides the context's client id. For the MCP endpoint itself, which knows
* the calling client before any sub-request has carried the signed header. */
sourceClientId?: string | null;
}
export const audit = {
/** Awaited write. See module header. */
async record(ctx: AuditContext, event: AuditEventInput): Promise<void> {
try {
await repos.auditEvent.create({
organizationId: ctx.organizationId,
actorUserId: ctx.actorUserId ?? null,
eventType: event.eventType,
resourceType: event.resourceType ?? null,
resourceId: event.resourceId ?? null,
before: (event.before ?? null) as never,
after: (event.after ?? null) as never,
ipAddress: ctx.ipAddress ?? null,
userAgent: ctx.userAgent ?? null,
source: event.source ?? ctx.source ?? null,
sourceClientId: event.sourceClientId ?? ctx.sourceClientId ?? null,
});
} catch (err) {
console.error("[audit] failed to record event", event.eventType, err);
}
},
/** Fire-and-forget. Errors are swallowed by `record`. See module header. */
recordAsync(ctx: AuditContext, event: AuditEventInput): void {
void this.record(ctx, event);
},
};
export function auditContextFrom(
c: Context,
organizationId: string,
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Context } from "hono";
import { PAT_PREFIX } from "./pat";
import { PAT_PREFIX } from "@repo/platform/engine/lib/pat";
/**
* Parse a `Authorization: Bearer <token>` header. Single source of truth for
+5 -27
View File
@@ -19,18 +19,12 @@
* under one user, "mcp" alone can't tell you which one to revoke.
*/
import { AsyncLocalStorage } from "node:async_hooks";
import { runWithOperationSource, setOperationSource, isAuditSource, isAuditClientId, type AuditSource } from "@repo/platform/engine/lib/operation-source";
export { AUDIT_SOURCES, isAuditSource, isAuditClientId, ambientCallSource, type AuditSource } from "@repo/platform/engine/lib/operation-source";
import { randomBytes, timingSafeEqual } from "node:crypto";
import type { Context } from "hono";
import { getRequestContext } from "./request-context";
export const AUDIT_SOURCES = ["dashboard", "mcp", "cli", "api", "webhook", "system"] as const;
export type AuditSource = (typeof AUDIT_SOURCES)[number];
export function isAuditSource(value: unknown): value is AuditSource {
return typeof value === "string" && (AUDIT_SOURCES as readonly string[]).includes(value);
}
const CALL_SOURCE_HEADER = "x-openship-call-source";
const CALL_CLIENT_HEADER = "x-openship-call-client";
@@ -40,14 +34,6 @@ const CALL_CLIENT_HEADER = "x-openship-call-client";
* because the value is persisted on audit_event and rendered in the audit UI —
* the nonce proves it came from us, not that we assembled it from something sane.
*/
const CLIENT_ID_PATTERN = /^(?:oauth|pat):[A-Za-z0-9_.\-]{1,128}$/;
/** True for a well-formed source-client id. Also the query-param validator for
* the audit filter, so what can be stored and what can be filtered on agree. */
export function isAuditClientId(value: unknown): value is string {
return typeof value === "string" && CLIENT_ID_PATTERN.test(value);
}
/**
* Process-local secret. Regenerated on every boot: an in-flight forged header
* from a previous process is worthless, and there is nothing to leak or rotate.
@@ -106,7 +92,7 @@ function trustedClaim(c: Context): AuditSource | null {
*/
export function resolveCallClientId(c: Context): string | null {
const claimed = signedPayload(c.req.header(CALL_CLIENT_HEADER));
return claimed && CLIENT_ID_PATTERN.test(claimed) ? claimed : null;
return isAuditClientId(claimed) ? claimed : null;
}
/**
@@ -128,8 +114,7 @@ export function resolveCallSource(c: Context): AuditSource {
const claim = trustedClaim(c);
const resolved = claim ?? derive(c);
// Share the best answer with emitters that run outside the handler chain.
const holder = ambient.getStore();
if (holder) holder.value = resolved;
setOperationSource(resolved);
return resolved;
}
@@ -171,14 +156,7 @@ function fromHeaders(c: Context): AuditSource {
// seed is header-derived (enough to separate a browser from a token) and gets
// upgraded in place the moment a handler calls resolveCallSource.
const ambient = new AsyncLocalStorage<{ value: AuditSource }>();
/** Seed the per-request ambient source. Call once, in a global middleware. */
export function runWithCallSource<T>(c: Context, fn: () => T): T {
return ambient.run({ value: trustedClaim(c) ?? fromHeaders(c) }, fn);
}
/** The current request's source, or null outside a request (crons, boot). */
export function ambientCallSource(): AuditSource | null {
return ambient.getStore()?.value ?? null;
return runWithOperationSource(trustedClaim(c) ?? fromHeaders(c), fn);
}
+4 -4
View File
@@ -12,9 +12,9 @@
import { randomUUID, randomBytes, createHash, timingSafeEqual } from "node:crypto";
import { db, schema, repos, eq } from "@repo/db";
import { encrypt } from "./encryption";
import { provisionUser } from "./provision-user";
import { cloudRuntimeTarget, env } from "../config/env";
import { encrypt } from "@repo/platform/engine/lib/encryption";
import { provisionUser } from "@repo/platform/engine/lib/provision-user";
import { cloudRuntimeTarget, env } from "@repo/platform/engine/config/env";
import { safeErrorMessage } from "@repo/core";
export interface CloudUser {
@@ -85,7 +85,7 @@ async function storeCloudSession(userId: string, cloudSessionToken: string): Pro
cloudSessionToken: encrypted,
});
}
const { invalidateCloudCaches } = await import("./cloud/session");
const { invalidateCloudCaches } = await import("@repo/platform/engine/lib/cloud/session");
await invalidateCloudCaches(userId);
}
-170
View File
@@ -1,170 +0,0 @@
/**
* Cloud route re-application on edit.
*
* Editing a route (domain/port) for a cloud project must re-apply the live
* route, not just persist the DB row. Cloud routing is a runtime concern — the
* public route is established by the deploy call via Oblien's page / workspace
* primitives, NOT by the `CloudInfraProvider` routing stub (which only receives
* `{domain,tls,targetUrl}` and has no page slug / workspace id). So this helper
* re-runs those same primitives against the project's active deployment handle.
*
* Works on the SaaS and on a local instance orchestrating a cloud deploy: the
* org-scoped token comes from `getOrgCloudToken` either way (same path the
* deploy runtime uses). No edgeProxy — a cloud project is internal to Oblien
* (page or workspace), so the edgeProxy ownership-verification handshake is not
* involved.
*
* KNOWN LIMITATION: the workspace SDK has no clean per-domain teardown
* primitive, so removing an old *managed* (`*.opsh.io`) subdomain on a *dynamic*
* cloud project can't be re-applied on edit — it clears on the next
* redeploy/destroy. Custom-domain teardown (static pages) and every apply path
* do re-apply. `removeCloudProjectRoute` logs the unsupported case rather than
* swallowing it.
*/
import { Oblien, PAGE_CONTAINER_PREFIX } from "@repo/adapters";
import { repos } from "@repo/db";
import { safeErrorMessage, SYSTEM } from "@repo/core";
import { getOrgCloudToken } from "./cloud/client";
/** Minimal project shape needed to locate the cloud handle. */
export interface CloudRouteProject {
id: string;
organizationId: string;
cloudWorkspaceId: string | null;
activeDeploymentId: string | null;
}
export interface CloudRouteInput {
/** Full hostname — `slug.opsh.io` (managed) or `app.example.com` (custom). */
hostname: string;
/** Target port on the workspace (dynamic projects). Ignored for static pages. */
port?: number;
isCustomDomain: boolean;
}
interface CloudHandle {
client: Oblien;
/** `page:{slug}` for a static page, or the workspace id for a dynamic project. */
containerId: string;
}
/**
* Resolve the org-scoped Oblien client + the active deployment's cloud handle.
* Returns null (caller no-ops) when the project isn't cloud, has no active
* deployment/container, or no org member has linked Openship Cloud.
*/
async function resolveCloudHandle(project: CloudRouteProject): Promise<CloudHandle | null> {
if (!project.cloudWorkspaceId || !project.activeDeploymentId) return null;
const deployment = await repos.deployment.findById(project.activeDeploymentId);
const containerId = deployment?.containerId;
if (!containerId) return null;
const tok = await getOrgCloudToken(project.organizationId);
if (!tok) return null;
return { client: new Oblien({ token: tok.token }), containerId };
}
function managedSlugFromHostname(hostname: string): string {
// A cloud project's managed subdomain is always slug.<CLOUD_DOMAIN> (Oblien's
// opsh.io) — NOT the self-hosted HOST_DOMAIN. Use the cloud constant so this
// matches the deploy path (which exposes on the same domain).
const base = `.${SYSTEM.DOMAINS.CLOUD_DOMAIN.toLowerCase()}`;
const normalized = hostname.trim().toLowerCase();
return normalized.endsWith(base) ? normalized.slice(0, -base.length) : normalized;
}
/**
* (Re)apply a single route for a cloud project via its runtime primitives.
* Best-effort: never throws — a failure logs and leaves the DB write intact
* (the next deploy re-establishes the route).
*/
export async function reapplyCloudProjectRoute(
project: CloudRouteProject,
input: CloudRouteInput,
): Promise<void> {
const handle = await resolveCloudHandle(project);
if (!handle) return;
const { client, containerId } = handle;
try {
if (containerId.startsWith(PAGE_CONTAINER_PREFIX)) {
// Static page: the free *.opsh.io subdomain IS the page slug (set at
// create time), so only a custom domain needs an explicit attach.
if (input.isCustomDomain) {
await client.pages.connectDomain(containerId.slice(PAGE_CONTAINER_PREFIX.length), {
domain: input.hostname,
});
}
return;
}
// Dynamic workspace.
const ws = client.workspace(containerId);
if (input.isCustomDomain) {
// KNOWN LIMITATION (multi-port): network.update replaces ingress_ports, so
// applying several custom-domain routes on ONE workspace one-at-a-time
// leaves only the last port's ingress open. Managed (*.opsh.io) routes
// don't hit this — publicAccess.expose below is additive per port, which is
// the path multi-port apps like Convex use by default. Multi-port CUSTOM
// domains on cloud need a live-Oblien fix to accumulate ingress_ports.
if (input.port) await ws.network.update({ ingress_ports: [input.port] });
await ws.domains.connect({
domain: input.hostname,
...(input.port ? { port: input.port } : {}),
});
return;
}
if (!input.port) {
console.warn(
`[CLOUD-ROUTE] Skipping managed expose for ${input.hostname} — no target port resolved.`,
);
return;
}
await ws.publicAccess.expose({
port: input.port,
domain: SYSTEM.DOMAINS.CLOUD_DOMAIN,
slug: managedSlugFromHostname(input.hostname),
});
} catch (err) {
console.error(
`[CLOUD-ROUTE] Failed to re-apply route ${input.hostname}:`,
safeErrorMessage(err),
);
}
}
/**
* Tear down a cloud route removed on edit. Best-effort. Static-page custom
* domains disconnect cleanly; dynamic-workspace routes have no per-domain
* teardown primitive (see file header) — logged, not silently dropped.
*/
export async function removeCloudProjectRoute(
project: CloudRouteProject,
input: { hostname: string; isCustomDomain: boolean },
): Promise<void> {
const handle = await resolveCloudHandle(project);
if (!handle) return;
const { client, containerId } = handle;
try {
if (containerId.startsWith(PAGE_CONTAINER_PREFIX)) {
if (input.isCustomDomain) {
await client.pages.disconnectDomain(containerId.slice(PAGE_CONTAINER_PREFIX.length));
}
return;
}
console.warn(
`[CLOUD-ROUTE] Workspace route teardown for ${input.hostname} is not supported by the cloud SDK; it clears on redeploy/destroy.`,
);
} catch (err) {
console.error(
`[CLOUD-ROUTE] Failed to remove route ${input.hostname}:`,
safeErrorMessage(err),
);
}
}
+14 -19
View File
@@ -1,3 +1,5 @@
import { resolveProjectAuthority, type ProjectSource } from "@repo/platform/engine/lib/cloud/project-authority";
export { resolveProjectAuthority, type ProjectSource } from "@repo/platform/engine/lib/cloud/project-authority";
/**
* Project source-routing — the single place that answers "is this project id a
* LOCAL project (served from the local DB) or a CLOUD project (canonical on the
@@ -29,13 +31,12 @@
* • resolveProjectSource / proxyToSaaS — the underlying primitives.
*/
import type { Context, Next } from "hono";
import { CLOUD_UNREACHABLE_CODE } from "@repo/core";
import { AppError, CLOUD_UNREACHABLE_CODE } from "@repo/core";
import { authorization } from "@repo/platform/engine/lib/authorization";
import { repos } from "@repo/db";
import { env } from "../../config";
import { env } from "@repo/platform/engine/config/index";
import { getRequestContext } from "../request-context";
import { cloudFetchAsOrgOwner, resolveOrgCloudUserId } from "./transport";
export type ProjectSource = "local" | "cloud";
import { cloudFetchAsOrgOwner, resolveOrgCloudUserId } from "@repo/platform/engine/lib/cloud/transport";
const SOURCE_HEADER = "X-Project-Source";
@@ -48,20 +49,8 @@ export async function resolveProjectSource(
projectId: string,
organizationId: string,
): Promise<ProjectSource | "not-found"> {
// On the SaaS we ARE the canonical store — never proxy.
if (env.CLOUD_MODE) return "local";
const hint = c.req.header(SOURCE_HEADER)?.toLowerCase();
if (hint === "cloud") return "cloud";
if (hint === "local") return "local";
const local = await repos.project.findById(projectId).catch(() => null);
if (local) return "local";
// No local row — it's a cloud project iff the org has a cloud link to proxy
// through. No link → genuinely not found (IDOR-safe: same 404 as a foreign id).
const ownerUserId = await resolveOrgCloudUserId(organizationId).catch(() => null);
return ownerUserId ? "cloud" : "not-found";
return resolveProjectAuthority(projectId, organizationId, hint === "cloud" || hint === "local" ? hint : undefined);
}
/**
@@ -80,6 +69,9 @@ export async function proxyToSaaS(
organizationId: string,
opts?: { path?: string; body?: string },
): Promise<Response> {
if (getRequestContext(c).scopeMode === "fixed") {
throw new AppError("This cloud link has no tenant mapping. Connect directly with the cloud organizationId.", 409, "CLOUD_SCOPE_UNAVAILABLE");
}
const url = new URL(c.req.url);
const path = opts?.path ?? `${url.pathname}${url.search}`;
const method = c.req.method.toUpperCase();
@@ -241,7 +233,10 @@ export async function cloudProjectProxyByQuery(c: Context, next: Next): Promise<
if (env.CLOUD_MODE) return next();
const projectId = c.req.query("projectId");
if (!projectId) return next(); // org-wide request — nothing project-specific to proxy
const organizationId = getRequestContext(c).organizationId;
const context = await authorization.authorize(getRequestContext(c), { resourceType: "project", resourceId: projectId, action: "read" });
c.set("scopedOrganizationId", context.organizationId);
c.set("ctx", { ...context, hono: c });
const organizationId = context.organizationId;
const source = await resolveProjectSource(c, projectId, organizationId);
if (source === "cloud") return proxyToSaaS(c, organizationId);
return next();
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { COMPOSE_SENTINEL, isArtifactRef, isRealContainerRef, usableRef } from "./container-ref";
import { COMPOSE_SENTINEL, isArtifactRef, isRealContainerRef, usableRef } from "@repo/platform/engine/lib/container-ref";
/**
* `deployment.container_id` and `*.image_ref` are polymorphic columns: each can
+5 -244
View File
@@ -1,173 +1,9 @@
/**
* Shared controller helpers — small primitives used across Hono handlers.
*
* Auth identity lives in RequestContext (see `lib/request-context.ts`).
* Controllers read it via `getRequestContext(c)`; services take `ctx`
* (or specific fields) as parameters. No identity shims live here.
*
* ─── LINT RULES (controllers + services) ─────────────────────────────────────
*
* These patterns are FORBIDDEN in new code — enforced by review:
*
* 1. `memberships[0].organizationId` (or any first-membership picking)
* OUTSIDE the canonical resolver. The active org is already in
* `ctx.organizationId` — reach for that instead. Picking the first
* membership is a "wrong tenant" vulnerability waiting to happen.
*
* 2. Services / repos taking `(userId: string, organizationId: string)`
* positionally. Take `ctx: RequestContext` instead, so the call site
* can't swap the two strings and so adding role / sessionKind /
* traceId checks later doesn't fan out a signature change to every
* caller. Services that need ONLY one id as a DB key may keep the
* single positional (see request-context.ts JSDoc for the rule).
*
* 3. New helpers taking `c: Context` purely to extract ctx. Take
* `ctx: RequestContext` directly — that proves the helper runs
* post-auth and lets unit tests skip the Hono harness. Middleware
* and route-level wiring are allowed to take `c` (they ARE the
* Hono surface); services are not.
*
* 4. Local `*BackgroundCtx` / leaf-synth helpers that wrap
* `buildBackgroundContext`. There is exactly ONE synth helper —
* `buildBackgroundContext` in `lib/request-context.ts`. Call it at
* the entry point (webhook handler, cron, install callback) and
* pass ctx down; never synthesize at a leaf.
*
* 5. Direct `c.get("user")` / `c.get("activeOrganizationId")` reads in
* handler code. Use `getRequestContext(c).userId` /
* `.organizationId`. Only the canonical builders may read these
* raw — see the exception list below.
*
* 6. Re-introducing `getUserId(c)` / `getActiveOrganizationId(c)`.
* Both shims were removed; `getRequestContext(c)` is the only
* reader. Re-adding them is a regression.
*
* Allowed exceptions (these layers populate or precede ctx):
* - `middleware/auth.ts` — builds the RequestContext (`c.set("ctx", …)`)
* from Better Auth's session + the active-org resolver.
* - `middleware/active-organization.ts:resolveActiveOrganizationId` —
* the canonical memberships → org resolver that feeds authMiddleware.
* Its outputs become `ctx.organizationId`; nothing downstream should
* re-run this logic. Plus its `requireRole` middleware reads
* `c.get("activeOrganizationId")` as a documented fallback distinct
* from `ctx.organizationId` (which can be rebound by permission.assert).
* - `lib/permission.ts:resolveRequestScopeOrg` — the pre-ctx scope
* resolver that reads header + session-active for list/create routes.
* - `lib/permission.ts:assert` — mutates ctx via the `ctx.hono` escape
* hatch to rebind `c.var.ctx` to the scoped-org variant; takes
* `RequestContext` (not `c`).
* - `lib/route-permission.ts:requirePermission` — Hono middleware. Reads
* route params from `c` directly; uses `getRequestContext(c)` for
* identity.
* - WebSocket upgrade handlers (`terminal.controller.ts`,
* `service-terminal.controller.ts`) — bypass Hono's auth middleware
* by design. They re-derive identity via Better Auth's getSession +
* `resolveActiveOrganizationId`. Each must comment "not ctx-scoped:
* WebSocket upgrade path".
* - GitHub webhook + install-callback paths (`github/github.webhook.ts`,
* `cloud/cloud-github.service.ts`) — no per-request ctx; resolve
* org from the webhook payload, fall back via `memberships[0]?…?? "org_<userId>"`
* for unattributable installs (each carries a FOLLOW-UP comment).
* - `lib/cloud-session-auth.ts` + cloud Bearer routes
* (`cloud-saas.controller.ts`) — read `c.get("user")` / `c.get("session")`
* populated by the Bearer middleware, not by authMiddleware. The
* SaaS surface doesn't run authMiddleware on Bearer routes.
* - `modules/system/setup.controller.ts` (bootstrap path) — runs under
* internalAuth pre-onboarding, before any org exists.
* - `buildBackgroundContext` itself in `lib/request-context.ts` — the
* ONE synth helper. Callers pass userId+orgId explicitly; the helper
* never looks them up.
*/
/** HTTP parameter/mode adapters. Resource policy lives in the platform engine. */
import type { Context } from "hono";
import {
type PlatformTarget,
type PlatformConfig,
} from "@repo/adapters";
import { env } from "../config/env";
import { isOblienConfigured } from "./platform-mode";
import { resolveAcmeProviderOptions } from "./acme-config";
// Re-export the platform accessor so existing callers that do
// `import { platform } from "@/lib/controller-helpers"` keep working
// without changing every site.
export { getPlatform as platform } from "@repo/adapters";
/**
* Assert a resource belongs to the caller's active organization. Throws
* a 404-shaped error if it doesn't, to avoid leaking existence across
* orgs (404, not 403 — IDOR-safe). NULL `organizationId` fails closed.
*/
import { ForbiddenError, NotFoundError } from "@repo/core";
export function assertResourceInOrg<T extends { organizationId?: string | null }>(
resource: T | null | undefined,
resourceLabel: string,
organizationId: string,
resourceId?: string,
): asserts resource is T {
if (!resource || resource.organizationId !== organizationId) {
throw new NotFoundError(resourceLabel, resourceId);
}
}
/**
* Refuse a runtime action on the Openship control-plane self-app.
*
* The self-app IS the process serving the request, so "stop it" is a request to
* kill the thing that would report the result. Every mutating surface needs the
* same answer for the same reason — pausing the PROJECT stops the api container,
* deleting its `postgres` SERVICE drops the control plane's own database, and its
* DEPLOYMENT row is an adopt over a CLI-supervised host process, so rolling it
* back or pinning it would detach the live app. Read paths (status, logs, shell,
* container info) are deliberately NOT gated: showing the operator that state is
* the reason the self-app is linked at all.
*
* One definition, because it had grown three — a project copy, a services copy,
* and a deployments wrapper — and they had already drifted: one of them told
* operators to run `openship restart`, which is not a command (`restart` exists
* only under `deployment` and `service`).
*/
/**
* Is this project Openship itself?
*
* The boolean behind {@link assertNotControlPlane}, split out because not every
* caller wants a throw: the migration adopt path needs to RECOGNIZE the control
* plane's own containers so it can leave them out of a user's project (#584), and
* refusing there would fail the user's whole migration over a container they never
* selected. Same one definition either way — `appTemplateId` is stamped by
* `ensureControlPlaneApp` and is the only durable marker (the NAME is operator-
* visible text, and the slug differs between the self-app and the deploy project).
*/
export function isControlPlaneProject(
project: { appTemplateId?: string | null } | null | undefined,
): boolean {
return project?.appTemplateId === "openship";
}
export function assertNotControlPlane(
project: { appTemplateId?: string | null } | null | undefined,
): void {
if (isControlPlaneProject(project)) {
throw new ForbiddenError(
"The Openship control plane manages its own runtime — manage it with the CLI on the host " +
"(`openship up`, `openship stop`, `openship update`), not from the dashboard.",
);
}
}
/**
* The same policy for callers holding only a project id — a deployment row, a
* `projectId` route param.
*
* Exists so those callers have ONE shape instead of each resolving the project
* itself: that per-module resolve is what grew into two divergent copies of the
* check. Callers that already hold the project must use {@link assertNotControlPlane}
* directly rather than re-fetching it here.
*/
export async function assertNotControlPlaneById(projectId: string): Promise<void> {
assertNotControlPlane(await repos.project.findById(projectId));
}
import { env } from "@repo/platform/engine/config/env";
import { resolvePlatformConfig } from "@repo/platform/engine/lib/platform-config";
export { platform, resolvePlatformConfig } from "@repo/platform/engine/lib/platform-config";
export * from "@repo/platform/engine/lib/resource-access";
/** Extract and validate a required route parameter */
export function param(c: Context, name: string): string {
@@ -176,26 +12,6 @@ export function param(c: Context, name: string): string {
return val;
}
/**
* Returns true when the server row exists AND belongs to the caller's
* active organization. Mail / branding / admin controllers use this to
* short-circuit with 404 for cross-tenant access attempts BEFORE making
* SSH or HTTP calls against the server.
*
* NotFoundError-shaped 404 (not 403) is correct here — exposing
* "exists but not yours" is itself a cross-tenant existence leak.
*/
import type { RequestContext } from "./request-context";
import { repos } from "@repo/db";
export async function isServerInOrg(
ctx: RequestContext,
serverId: string,
): Promise<boolean> {
const server = await repos.server.getInOrganization(serverId, ctx.organizationId);
return server != null;
}
/**
* Local-only route guard. Returns a 404 Response when CLOUD_MODE is on,
* or `null` when the route may proceed.
@@ -234,58 +50,3 @@ export function assertDesktop(c: Context): Response | null {
}
return null;
}
// ─── Platform resolution ─────────────────────────────────────────────────────
/**
* Resolve the deployment target from environment config.
*
* CLOUD_MODE (SaaS hosting) and DEPLOY_MODE=cloud (Oblien runtime) both
* need the cloud platform adapter, so either triggers the cloud config.
* Auth/billing concerns are gated separately by CLOUD_MODE alone.
*
* Priority:
* 1. CLOUD_MODE=true or DEPLOY_MODE=cloud → "cloud" (Oblien runtime)
* 2. DEPLOY_MODE=desktop → "desktop"
* 3. Default → "selfhosted" with docker or bare runtime
*/
export function resolvePlatformConfig(): PlatformConfig {
if (isOblienConfigured()) {
return {
target: "cloud",
cloudClientId: env.OBLIEN_CLIENT_ID,
cloudClientSecret: env.OBLIEN_CLIENT_SECRET,
allowHostBuild: !env.CLOUD_MODE,
};
}
if (env.DEPLOY_MODE === "desktop") {
return { target: "desktop" };
}
// Self-hosted: docker or bare
return {
target: "selfhosted",
runtime: env.DEPLOY_MODE === "bare" ? "bare" : "docker",
nginx: resolveAcmeProviderOptions(),
};
}
// ─── Project access ──────────────────────────────────────────────────────────
// Access-control model:
// - Route-level `requirePermission` middleware loads the resource and
// verifies org membership before the controller runs.
// - For list/create endpoints, the org is resolved from the
// X-Organization-Id header (or the session default cookie).
// - Service layers receive `organizationId` directly from controllers
// and use `assertResourceInOrg(...)` for defense-in-depth.
//
// For a user-scoped access check, use `permission.assert(getRequestContext(c), {...})` or
// `assertResourceInOrg(resource, ...)`.
//
// Note: permission.assert takes RequestContext (not raw c) because it
// declares its identity needs in its signature. The Hono escape hatch
// (ctx.hono) is used internally for the side effects (rebind ctx,
// stash scopedOrganizationId).
+10 -10
View File
@@ -14,18 +14,18 @@
import { withTimeout } from "@repo/core";
import { clearAuthModeCache } from "./auth-mode";
import { clearBoxOwningOrgCache } from "./box-org";
import { clearAllCacheStores } from "./cache-store";
import { clearHostControlCache, syncHostControlOverride } from "./host-control";
import { clearAuthModeCache } from "@repo/platform/engine/lib/auth-mode";
import { clearBoxOwningOrgCache } from "@repo/platform/engine/lib/box-org";
import { clearAllCacheStores } from "@repo/platform/engine/lib/cache-store/index";
import { clearHostControlCache, syncHostControlOverride } from "@repo/platform/engine/lib/host-control";
import { invalidateLocalUserCache } from "./local-user";
import { invalidateInstanceTransportCache, invalidatePlatformTransport } from "./mail";
import { invalidateInstanceTransportCache, invalidatePlatformTransport } from "@repo/platform/engine/lib/mail";
import { invalidateMcpSigningKeyCache } from "./mcp-oidc-keys";
import { clearProductModeCache } from "./product-mode";
import { invalidateSelfAppPublicUrl } from "./public-url";
import { sshManager } from "./ssh-manager";
import { clearMailPortReachabilityCache } from "../modules/mail/mail-port-reachability.service";
import { clearServiceVolumeSizeCache } from "../modules/services/service.service";
import { clearProductModeCache } from "@repo/platform/engine/lib/product-mode";
import { invalidateSelfAppPublicUrl } from "@repo/platform/engine/lib/public-url";
import { sshManager } from "@repo/platform/engine/lib/ssh-manager";
import { clearMailPortReachabilityCache } from "@repo/platform/engine/modules/mail/mail-port-reachability.service";
import { clearServiceVolumeSizeCache } from "@repo/platform/engine/modules/services/service.service";
type RefreshFailure = { name: string; error: unknown };
const ASYNC_RECONCILE_TIMEOUT_MS = 10_000;
@@ -9,14 +9,14 @@ vi.mock("@repo/adapters", () => ({
buildImage: vi.fn(async () => {}),
imageExistsLocally: vi.fn(async () => false),
}));
vi.mock("./edge-image", () => ({ edgeBuildSpec: vi.fn() }));
vi.mock("./mail-image", () => ({ mailBuildSpec: vi.fn() }));
vi.mock("@repo/platform/engine/lib/edge-image", () => ({ edgeBuildSpec: vi.fn() }));
vi.mock("@repo/platform/engine/lib/mail-image", () => ({ mailBuildSpec: vi.fn() }));
import { buildImage, imageExistsLocally } from "@repo/adapters";
import { deliverManagedImage } from "./deliver-managed-image";
import { edgeBuildSpec } from "./edge-image";
import { mailBuildSpec } from "./mail-image";
import { deliverManagedImage } from "@repo/platform/engine/lib/deliver-managed-image";
import { edgeBuildSpec } from "@repo/platform/engine/lib/edge-image";
import { mailBuildSpec } from "@repo/platform/engine/lib/mail-image";
const SPEC = { context: "/repo", dockerfile: "apps/edge/Dockerfile" };
const onLog = () => {};
@@ -50,7 +50,7 @@ vi.mock("./controller-helpers", () => ({
platform: () => ({ target: h.baseTarget, runtime: { name: "docker" } }),
}));
vi.mock("./ssh-manager", () => ({
vi.mock("@repo/platform/engine/lib/ssh-manager", () => ({
sshManager: {
acquire: async () => ({
readFile: async (path: string) => {
@@ -67,11 +67,11 @@ vi.mock("./ssh-manager", () => ({
}),
}));
vi.mock("./provision-lock", () => ({
vi.mock("@repo/platform/engine/lib/provision-lock", () => ({
createProvisionLock: () => ({ run: (f: () => unknown) => f() }),
}));
vi.mock("./cloud/client", () => ({ cloudClient: {}, getOrgCloudToken: async () => null }));
vi.mock("./cloud/transport", () => ({ resolveOrgCloudUserId: async () => null }));
vi.mock("@repo/platform/engine/lib/cloud/client", () => ({ cloudClient: {}, getOrgCloudToken: async () => null }));
vi.mock("@repo/platform/engine/lib/cloud/transport", () => ({ resolveOrgCloudUserId: async () => null }));
vi.mock("@repo/db", () => ({
repos: {
server: {
@@ -95,7 +95,7 @@ vi.mock("@repo/db", () => ({
},
}));
const mod = await import("./deployment-runtime");
const mod = await import("@repo/platform/engine/lib/deployment-runtime");
const read = (meta: Record<string, unknown>) =>
mod.resolveDeploymentRuntimeForRead({ meta, organizationId: "org1" } as never);
@@ -198,3 +198,12 @@ describe("resolveDeploymentRuntimeForRead — reaches the deploy's host, without
expect(h.platformCalls).toBe(0);
});
});
// The application seams moved with the shared engine.
vi.mock("@repo/platform/engine/lib/platform-config", () => ({
platform: () => ({ target: h.baseTarget, runtime: { name: "docker" } }),
}));
vi.mock("@repo/platform/engine/lib/resource-access", () => ({
platform: () => ({ target: h.baseTarget, runtime: { name: "docker" } }),
}));
@@ -49,7 +49,7 @@ vi.mock("@repo/adapters", async (importOriginal) => ({
createHostExecutor: () => h.hostExecutor(),
}));
vi.mock("./startup/self-server", () => ({
vi.mock("@repo/platform/engine/lib/startup/self-server", () => ({
findLocalServer: async () => {
h.findCalls++;
if (h.findRejects) throw new Error("db unavailable");
@@ -80,20 +80,20 @@ vi.mock("@repo/db", () => ({
// The row IS this box; keyed off the flag so the test doesn't depend on loopback
// resolution or env.
vi.mock("./box-org", () => ({
vi.mock("@repo/platform/engine/lib/box-org", () => ({
isLocalHostRow: async (row: { isLocal?: boolean }) => Boolean(row?.isLocal),
}));
vi.mock("./ssh-manager", () => ({
vi.mock("@repo/platform/engine/lib/ssh-manager", () => ({
sshManager: { acquire: h.acquire, acquireHostChannel: h.acquireHostChannel },
buildSshConfig: async () => ({ host: "127.0.0.1", port: 22, username: "root" }),
}));
vi.mock("./provision-lock", () => ({
vi.mock("@repo/platform/engine/lib/provision-lock", () => ({
createProvisionLock: (name: string) => ({ name, run: (f: () => unknown) => f() }),
}));
const { resolveTargetPlatform } = await import("./deployment-runtime");
const { resolveTargetPlatform } = await import("@repo/platform/engine/lib/deployment-runtime");
const { HostChannelUnavailableError } = await import("@repo/adapters");
const last = () => h.configs[h.configs.length - 1] as Record<string, unknown>;
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { canonicalEdgeTarget, isCloudEdgeHost, isNonPublicHost } from "./edge-target";
import { canonicalEdgeTarget, isCloudEdgeHost, isNonPublicHost } from "@repo/platform/engine/lib/edge-target";
/**
* `isNonPublicHost` is the guard that stops a free `.opsh.io` route from being
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import { buildHelperScript } from "./relay";
import { buildHelperScript } from "@repo/platform/engine/lib/git-forwarding/relay";
const execFileAsync = promisify(execFile);
+1 -1
View File
@@ -10,7 +10,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
const h = vi.hoisted(() => ({ env: { CLOUD_MODE: false, DEPLOY_MODE: "docker" as string } }));
vi.mock("../config/env", () => ({ env: h.env }));
vi.mock("@repo/platform/engine/config/env", () => ({ env: h.env }));
// Only the probe is stubbed: the impact copy the banner prints is shared (#490), and a
// test that asserted against a mocked copy would pass while the real lines said anything.
vi.mock("@repo/adapters", async (importOriginal) => ({
+1 -1
View File
@@ -7,7 +7,7 @@ import {
HOST_CHANNEL_UNAFFECTED,
wrapText,
} from "@repo/core";
import { env } from "../config/env";
import { env } from "@repo/platform/engine/config/env";
/**
* Boot-time diagnosis of the container→host SSH channel (#490).
+10 -1
View File
@@ -34,7 +34,7 @@ vi.mock("@repo/db", () => ({
// @repo/adapters is NOT mocked: the point is that syncHostControlOverride mutates
// the REAL adapters override that hostControlDisabled reads.
const { resolveHostControlEnabled, syncHostControlOverride, clearHostControlCache } = await import(
"./host-control"
"@repo/platform/engine/lib/host-control"
);
const { hostControlDisabled, setHostControlOverride } = await import("@repo/adapters");
@@ -125,3 +125,12 @@ describe("syncHostControlOverride — pushes the disabled-polarity override into
getSpy.mockRestore();
});
});
// The application seams moved with the shared engine.
vi.mock("@repo/platform/engine/lib/platform-config", () => ({
resolvePlatformConfig: () => ({ target: state.target }),
}));
vi.mock("@repo/platform/engine/lib/resource-access", () => ({
resolvePlatformConfig: () => ({ target: state.target }),
}));
+1 -1
View File
@@ -8,7 +8,7 @@ import {
normalizeTargetHostId,
normalizeTargetMachineId,
resolveHostPortTargetIdentity,
} from "./host-port-target";
} from "@repo/platform/engine/lib/host-port-target";
function executorWithFiles(files: Record<string, string | Error>): CommandExecutor {
return {
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { parseImageRef } from "./image-registry";
import { parseImageRef } from "@repo/platform/engine/lib/image-registry";
describe("parseImageRef", () => {
it("Docker Hub namespaced repo → registry-1.docker.io, tag preserved", () => {
@@ -0,0 +1 @@
export { instanceAuthorization } from "@repo/platform/engine/lib/instance-authorization";
+2 -15
View File
@@ -6,22 +6,9 @@
*/
import type { Context, Next } from "hono";
import { withAdvisoryLock } from "@repo/db";
import { isValidInvitationId } from "@repo/core";
import { withKeyedMutex } from "./provision-lock";
function lifecycleLockKey(invitationId: string): string {
return `invitation-lifecycle:${invitationId}`;
}
/** Serialize in-process and across API replicas sharing Postgres. */
export function withInvitationLifecycleLock<T>(
invitationId: string,
run: () => Promise<T>,
): Promise<T> {
const key = lifecycleLockKey(invitationId);
return withKeyedMutex(key, () => withAdvisoryLock(key, run));
}
import { withInvitationLifecycleLock } from "@repo/platform/engine/lib/invitation-lifecycle-lock";
export { withInvitationLifecycleLock } from "@repo/platform/engine/lib/invitation-lifecycle-lock";
/**
* Wrap Better Auth's accept/reject/cancel handlers. Invalid request bodies are
@@ -32,21 +32,21 @@ vi.mock("@repo/db", () => ({
// The row IS this box; keyed off the flag so the test doesn't depend on loopback
// resolution or env.
vi.mock("./box-org", () => ({
vi.mock("@repo/platform/engine/lib/box-org", () => ({
isLocalHostRow: async (row: { isLocal?: boolean }) => Boolean(row?.isLocal),
}));
vi.mock("./ssh-manager", () => ({
vi.mock("@repo/platform/engine/lib/ssh-manager", () => ({
sshManager: { acquire: h.acquire },
buildSshConfig: async () => ({ host: "127.0.0.1", port: 22, username: "root" }),
}));
vi.mock("./provision-lock", () => ({
vi.mock("@repo/platform/engine/lib/provision-lock", () => ({
createProvisionLock: () => ({ run: (f: () => unknown) => f() }),
}));
const { resolvePlannedTargetTopology, resolveServerExecutor, hostChannelDeployNotice } =
await import("./deployment-runtime");
await import("@repo/platform/engine/lib/deployment-runtime");
const { HostChannelUnavailableError } = await import("@repo/adapters");
const resolve = () => resolveServerExecutor("srv-local", "org1");
+1 -1
View File
@@ -12,7 +12,7 @@
import { randomUUID } from "node:crypto";
import { repos } from "@repo/db";
import { provisionUser } from "./provision-user";
import { provisionUser } from "@repo/platform/engine/lib/provision-user";
export const LOCAL_EMAIL = "local@openship.local";
+2 -2
View File
@@ -3,8 +3,8 @@ import { join } from "node:path";
import { afterEach, describe, it, expect } from "vitest";
import { APP_VERSION } from "./app-version";
import { mailBuildSpec, pinnedMailImage } from "./mail-image";
import { APP_VERSION } from "@repo/platform/engine/lib/app-version";
import { mailBuildSpec, pinnedMailImage } from "@repo/platform/engine/lib/mail-image";
const MAIL_DOCKERFILE = join("apps", "email", "Dockerfile");
const saved = { ...process.env };
@@ -12,19 +12,19 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
const deregister = vi.fn(async () => ({ ok: true as const, removed: true }));
vi.mock("./cloud/client", () => ({
vi.mock("@repo/platform/engine/lib/cloud/client", () => ({
cloudClient: () => ({ edgeProxy: { deregister } }),
}));
// The suffix comes from SYSTEM.DOMAINS.CLOUD_DOMAIN; stub the predicate pair so
// the test doesn't depend on env-resolved routing config.
vi.mock("./public-endpoints", () => ({
vi.mock("@repo/platform/engine/lib/public-endpoints", () => ({
isCloudManagedHostname: (h: string) => h.endsWith(".opsh.io"),
managedHostnameToSlug: (h: string) =>
h.endsWith(".opsh.io") ? h.slice(0, -".opsh.io".length) : undefined,
}));
const { releaseManagedHostnames } = await import("./managed-edge-proxy");
const { releaseManagedHostnames } = await import("@repo/platform/engine/lib/managed-edge-proxy");
describe("releaseManagedHostnames", () => {
beforeEach(() => deregister.mockClear());
+1 -1
View File
@@ -4,7 +4,7 @@ import { join } from "node:path";
import { afterAll, describe, expect, it } from "vitest";
import { devSourceTag } from "./managed-images";
import { devSourceTag } from "@repo/platform/engine/lib/managed-images";
const roots: string[] = [];
function makeComponent(files: Record<string, string>): { context: string; subdir: string } {
+1 -1
View File
@@ -18,7 +18,7 @@
* hardcoded to a domain.
*/
import { requestPublicOrigin } from "./public-url";
import { requestPublicOrigin } from "@repo/platform/engine/lib/public-url";
/** Path the MCP JSON-RPC endpoint is mounted at (`app.route("/api/mcp", …)`). */
export const MCP_RESOURCE_PATH = "/api/mcp";
+1 -1
View File
@@ -20,7 +20,7 @@
*/
import { createHmac, timingSafeEqual } from "node:crypto";
import { env } from "../config/env";
import { env } from "@repo/platform/engine/config/env";
/** Claims we put on an MCP access token. */
export interface McpAccessTokenClaims {
-346
View File
@@ -1,346 +0,0 @@
/**
* Openship Cloud - namespace provisioning + token minting.
*
* Runs on the SaaS API (CLOUD_MODE=true) only. Local instances
* call POST /api/cloud/token to get a namespace-scoped token,
* then use `new Oblien({ token })` to drive the full pipeline
* themselves (workspaces.create, build, deploy - everything).
*
* **Namespace identity = organization id**, NOT user id. This makes
* the namespace atomic per team: owner rotation doesn't move the
* namespace, every team member resolves the same one, and there's
* no `resolveCloudOwner` indirection in the SaaS controllers.
*
* Two responsibilities:
* 1. ensureNamespace(orgId) - create-if-not-exists, cached
* 2. issueNamespaceToken(orgId) - mint a scoped token for the namespace
*/
import { Oblien } from "@repo/adapters";
import { repos } from "@repo/db";
import { env } from "../config/env";
import { DEFAULT_PLAN_TIER, safeErrorMessage, type PlanTierId } from "@repo/core";
import { cacheStore } from "./cache-store";
import { getOblienClient } from "./oblien-client";
import { setQuotaForTier } from "../modules/billing/billing-oblien-quota";
// ─── Oblien client ──────────────────────────────────────────────────────────
/**
* Re-exported from the leaf `oblien-client` module, which is where it now lives.
* Keeping the name importable from here means the five consumers that only ever
* wanted a client did not have to move — while `billing-oblien-quota`, the one
* module that was on the other side of the cycle, imports the leaf DIRECTLY. That
* asymmetry is the whole point: if it came back through this file, the cycle would
* re-form and the static import below would be the thing that breaks.
*/
export { getOblienClient } from "./oblien-client";
// ─── Webhook registration ────────────────────────────────────────────────────
/**
* The billing events our receiver (`/api/billing/oblien-webhook`) handles.
* Account-wide (no `namespace` scope) so one webhook covers every org's
* namespace — Oblien caps accounts at 10 webhooks, so we keep exactly one.
*/
const OBLIEN_WEBHOOK_EVENTS = [
"credits.usage",
"credits.low",
"credits.depleted",
"namespace.quota.threshold",
// DELIBERATELY NOT `namespace.suspended` / `namespace.restored`. Oblien's billing
// docs list them, but the SDK's `WebhookEvent` union at 2.2.45 stops at
// `namespace.quota.threshold` — they belong to the newer billing plane this
// client can't speak. Subscribing would at best be a no-op and at worst make
// `webhooks.update` reject the whole event set, taking the credits events down
// with it. Suspension is detected by polling instead
// (`reconcileOblienEntitlement`), which is what Oblien's own docs recommend
// anyway: "delivery is best-effort … reconcile via the entitlement endpoint."
// Add them here the moment the SDK's union does.
] as const;
/**
* Ensure our billing webhook is registered with Oblien. Idempotent: if a
* webhook already targets our URL we refresh its events + secret + active flag
* (self-heals a rotated secret or an event-set change); otherwise we create it.
* No-op + warn when the secret or public URL isn't configured — without both
* the receiver can't verify deliveries or even be reached. CLOUD_MODE only.
*/
export async function ensureOblienWebhook(): Promise<void> {
if (!env.CLOUD_MODE) return;
const secret = env.OBLIEN_WEBHOOK_SECRET;
const base = env.OPENSHIP_PUBLIC_URL?.trim();
if (!secret) {
console.warn(
"[oblien] OBLIEN_WEBHOOK_SECRET unset — skipping webhook registration (deliveries would be unverifiable)",
);
return;
}
if (!base) {
console.warn(
"[oblien] OPENSHIP_PUBLIC_URL unset — skipping webhook registration (no public URL for Oblien to reach)",
);
return;
}
const url = `${base.replace(/\/+$/, "")}/api/billing/oblien-webhook`;
const events = [...OBLIEN_WEBHOOK_EVENTS];
try {
const client = getOblienClient();
const { webhooks } = await client.webhooks.list();
const existing = webhooks.find((w) => w.url === url);
if (existing) {
await client.webhooks.update(existing.id, { events, secret, active: true });
console.log(`[oblien] webhook ${existing.id} refreshed → ${url}`);
return;
}
const created = await client.webhooks.create({
url,
events,
secret,
description: "Openship billing (credits + quota)",
});
console.log(`[oblien] webhook ${created.webhook?.id ?? "?"} registered → ${url}`);
} catch (err) {
// Non-fatal at boot — the receiver still works once a webhook exists;
// log loudly so operators notice a persistent failure.
console.error(`[oblien] webhook registration failed: ${safeErrorMessage(err)}`);
}
}
// ─── Namespace management ────────────────────────────────────────────────────
// Org ↔ namespace is effectively immutable — 1h TTL is generous and
// self-heals on miss anyway via Oblien's idempotent `namespaces.ensure`.
const NAMESPACE_CACHE_TTL_S = 60 * 60;
/**
* Org id → namespace slug. For solo users (orgId = `org_<userId>`)
* this strips the prefix → `os-<userId>` — keeping pre-multi-tenant
* namespaces stable. Team orgs get `os-<orgId>` directly.
*/
function namespaceSlugForOrg(orgId: string): string {
const stripped = orgId.startsWith("org_") ? orgId.slice(4) : orgId;
return `os-${stripped.toLowerCase().replace(/[^a-z0-9-]+/g, "-")}`;
}
/**
* Ensure an Oblien namespace exists for an org, and PERSIST the slug.
*
* Resolution order is DB → cache → Oblien, and the write order is DB before
* cache. Both directions matter:
*
* - Reading the DB first means the namespace survives a cache eviction and a
* restart. It previously lived in `cacheStore` ONLY, so `organization
* .oblien_namespace` stayed NULL forever — and every consumer of that column
* opens with `if (!org.oblienNamespace) return`. The whole entitlement path
* (setQuota, resource_limits, credit top-ups, the `credits.usage` webhook
* match) was therefore a silent no-op.
* - Writing the DB BEFORE the cache means a failed DB write retries on the next
* call instead of being masked by a cache hit for the TTL.
*
* Idempotent via Oblien's own `namespaces.ensure`, so adopting a namespace that
* already exists is the normal path, not an error.
*/
export async function ensureNamespace(organizationId: string): Promise<string> {
const existing = await repos.organization
.findById(organizationId)
.catch(() => null);
if (existing?.oblienNamespace) return existing.oblienNamespace;
const store = await cacheStore<string>("oblien-namespaces");
const cached = await store.get(organizationId);
if (cached) {
// Cache hit with no DB row: a previous run persisted only to the cache.
// Heal the row rather than leaving the column NULL.
await repos.organization
.setOblienNamespace(organizationId, cached)
.catch(() => {});
return cached;
}
const client = getOblienClient();
const slug = namespaceSlugForOrg(organizationId);
const ensured = await client.namespaces.ensure({
name: `Openship ${organizationId}`,
slug,
});
const namespace = ensured.data.slug || slug;
await repos.organization.setOblienNamespace(organizationId, namespace);
await store.set(organizationId, namespace, NAMESPACE_CACHE_TTL_S);
return namespace;
}
/**
* Cache namespace recording that an org's Oblien ceiling has been asserted.
*
* This is a `cacheStore`, not a module-level `Set`, and the difference is not
* cosmetic:
*
* - MULTI-REPLICA. The SaaS runs several API replicas. A per-process Set means
* each replica asserts separately, so the memo did roughly nothing for the
* N-1 replicas that had not seen the org yet. Backed by Redis this is shared,
* which is correct: the ceiling lives on Oblien, so if ANY replica pushed it,
* it is pushed.
* - BOUNDED. A Set accumulates one entry per org for the life of the process and
* is never swept — a slow leak that grows with the tenant count. `cacheStore`
* evicts (`maxSize`) and expires.
* - SELF-HEALING. A permanent memo means a ceiling that drifted (a failed
* upgrade webhook, a manual edit on Oblien) is only repaired by the hourly
* reconciler. With a TTL the spend path re-asserts on its own.
*
* It also matches how `ensureNamespace` above already memoizes the namespace slug,
* so there is one idiom in this file rather than two.
*/
const QUOTA_ASSERTED_NS = "oblien-quota-asserted";
/**
* How long an assertion is trusted.
*
* Deliberately the reconciler's cadence (hourly). Shorter would push redundant
* writes onto the analytics fan-out — `collectCloud` issues one namespace token
* PER DOMAIN, in parallel, so a 10-domain project would otherwise pay 20 Oblien
* writes to open a geo panel. Longer would leave the spend path trusting a
* ceiling nothing has re-checked since before the last drift sweep.
*/
const QUOTA_ASSERTED_TTL_S = 3600;
async function quotaAssertedStore() {
return cacheStore<number>(QUOTA_ASSERTED_NS, { maxSize: 10_000 });
}
/**
* Namespace for RUNNING A WORKLOAD — guarantees the ceiling exists before the
* caller can spend anything. Use this, never bare `ensureNamespace`, anywhere a
* namespace is about to become compute.
*
* THE HOLE THIS CLOSES. `ensureNamespace` returns early the moment
* `organization.oblien_namespace` is set, and nothing in it touches quota. Org
* creation does pair the two (`provisionOrgNamespace`), but that call is
* fire-and-forget in `auth.ts` — deliberately, so a slow Oblien can't fail
* signup. So when it failed, the first deploy called bare `ensureNamespace`,
* which CREATED and RECORDED the namespace with no quota at all. From then on
* every later call hit the early return, the boot backfill skipped the org
* (it has a namespace, so it looks done), and the org ran fully metered with no
* credit ceiling and no resource ceiling. Free, unlimited compute, indefinitely.
*
* FAILS CLOSED, and that is the point: if the ceiling cannot be asserted we do
* not hand back a namespace to spend against. `setQuotaForTier` throws on an
* Oblien error and this deliberately does not catch it — a deploy that fails
* loudly is recoverable, an uncapped tenant is not. (Enterprise is the one tier
* with `monthlyCredits === null`; `setQuotaForTier` returns early for it, which
* is the negotiated-contract case, not a bypass.)
*
* `setQuotaForTier` is a STATIC import. It used to be a dynamic one, purely to
* dodge a cycle (`billing-oblien-quota` reached back here for the client) — that
* cycle is gone now the client lives in the `oblien-client` leaf, so the dependency
* is declared honestly at the top of the file where a reader can see it.
*/
export async function ensureNamespaceWithQuota(organizationId: string): Promise<string> {
const namespace = await ensureNamespace(organizationId);
const store = await quotaAssertedStore();
if (await store.get(organizationId)) return namespace;
const org = await repos.organization.findById(organizationId).catch(() => null);
await setQuotaForTier(organizationId, (org?.planTierId as PlanTierId) ?? DEFAULT_PLAN_TIER);
// Recorded only after the push actually succeeded — a throw above must leave the
// org unmarked so the next attempt retries instead of trusting a failed write.
await store.set(organizationId, Date.now(), QUOTA_ASSERTED_TTL_S);
return namespace;
}
/** Test seam: forget every recorded assertion. */
export async function __resetQuotaAssertedForTests(): Promise<void> {
const store = await quotaAssertedStore();
await store.invalidateByPrefix("");
}
// ─── Token minting ───────────────────────────────────────────────────────────
export interface NamespaceTokenResult {
token: string;
namespace: string;
expiresAt: string;
}
export interface NamespaceClientResult {
/** Oblien SDK instance bound to a namespace-scoped token for this org. */
client: Oblien;
/**
* Namespace slug for this org. Pass this on every Oblien create-shape
* call that accepts a `namespace` field (pages.create, edgeProxy.create,
* edgeTunnel.create, workspace.create, tokens.create) so Oblien can
* cross-check that the resource belongs to the token's namespace.
* Non-create methods identify the resource by id/slug — namespace
* isn't an input param, the token scope is the only gate.
*/
namespace: string;
}
/**
* Issue a namespace-scoped Oblien token for an org. The token gives
* full access to the org's namespace — create workspaces, manage
* lifecycle, deploy, analytics, edge proxies, pages. Local instances
* construct `new Oblien({ token })` and run the full pipeline.
*
* TTL: 30 minutes (covers build + deploy + some buffer).
*/
export async function issueNamespaceToken(organizationId: string): Promise<NamespaceTokenResult> {
const client = getOblienClient();
// `ensureNamespaceWithQuota`, NOT bare `ensureNamespace`. This token is full
// namespace authority — create workspaces, deploy, run — so the ceiling has to
// be on Oblien before it leaves this function.
const namespace = await ensureNamespaceWithQuota(organizationId);
try {
const result = await client.tokens.create({
scope: "namespace",
namespace,
ttl: 1800,
});
return {
token: result.token,
namespace,
expiresAt: result.expiresAt,
};
} catch (err: unknown) {
console.error("Oblien SDK token issuance error", err);
const message = safeErrorMessage(err);
throw new Error(`Failed to issue Oblien namespace token for ${namespace}: ${message}`);
}
}
/**
* Canonical "I need to call Oblien for this org" entry point.
*
* Returns BOTH the namespace-scoped client AND the namespace slug, so
* callers can pass `namespace` explicitly on Oblien's create-shape
* methods. Oblien validates that the resource being created lives in
* the namespace the token authenticates — without the explicit param
* the create methods accept any namespace the token is allowed in
* (today that's exactly one — but defense in depth).
*
* Non-create methods (disable / enable / delete / list / update by id
* or slug, analytics by domain) don't accept namespace as input — the
* token scope is the only gate there. Oblien rejects cross-namespace
* mutations with 403/404 server-side post-fix.
*
* Replaces the duplicated ad-hoc `getNamespaceClient` helpers that
* lived inside each cloud-* service file (those discarded the
* namespace slug, defeating the explicit pass-through).
*/
export async function getNamespaceClient(
organizationId: string,
): Promise<NamespaceClientResult> {
const { token, namespace } = await issueNamespaceToken(organizationId);
return { client: new Oblien({ token }), namespace };
}
@@ -13,7 +13,7 @@ import {
openshipFileExists,
readOpenshipFile,
writeOpenshipFile,
} from "./openship-server-store";
} from "@repo/platform/engine/lib/openship-server-store";
/**
* Privilege for the server state store.
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import type { CommandExecutor } from "@repo/adapters";
import type { DatabaseDump } from "@repo/db";
import { readProjectSnapshot } from "./openship-manifest";
import { readProjectSnapshot } from "@repo/platform/engine/lib/openship-manifest";
/** Minimal executor stub: `readOpenshipFile` runs `cat …` via exec — return the
* canned payload for that, ignore the mkdir/other calls. */
+21
View File
@@ -0,0 +1,21 @@
import type { Context } from "hono";
import { freezeContext, type ExecutionContext, type OperationResult } from "@repo/platform";
import { getRequestContext } from "./request-context";
import { resolveCallSource, resolveCallClientId } from "./call-source";
export function operationContext(c: Context): ExecutionContext {
return freezeContext({ ...freezeContext(getRequestContext(c)), source: resolveCallSource(c), sourceClientId: resolveCallClientId(c) });
}
export function applyOperationContext(c: Context, context: ExecutionContext): void {
c.set("scopedOrganizationId", context.organizationId);
c.set("ctx", { ...context, hono: c });
c.set("operationAuditRecorded", true);
c.set("operationContextApplied", true);
}
export async function operationData<T>(c: Context, work: Promise<OperationResult<T>>): Promise<T> {
const { context, data } = await work;
applyOperationContext(c, context);
return data;
}
+35
View File
@@ -0,0 +1,35 @@
import type { Context } from "hono";
import type { OperationResult } from "@repo/platform";
import type { DeploymentEvent } from "@repo/contracts";
import { streamSSE } from "./sse";
import { operationData } from "./operation-context";
/** Authorize/open before headers, then close the shared source on HTTP disconnect. */
export async function operationEvents(
c: Context,
open: (signal: AbortSignal) => Promise<OperationResult<AsyncIterable<DeploymentEvent>>>,
) {
const abort = new AbortController();
const requestSignal = c.req.raw?.signal;
const disconnected = () => abort.abort();
requestSignal?.addEventListener("abort", disconnected, { once: true });
if (requestSignal?.aborted) disconnected();
try {
const source = await operationData(c, open(abort.signal));
return streamSSE(c, async stream => {
stream.onAbort(disconnected);
try {
for await (const event of source) await stream.writeSSE(event);
} catch (error) {
if (!abort.signal.aborted) throw error;
} finally {
disconnected();
requestSignal?.removeEventListener("abort", disconnected);
}
});
} catch (error) {
disconnected();
requestSignal?.removeEventListener("abort", disconnected);
throw error;
}
}
+27 -570
View File
@@ -1,584 +1,41 @@
/**
* Permission resolver — the SINGLE SOURCE OF TRUTH for access decisions.
*
* Design (post-refactor):
*
* 1. Resources own their own scope. Every resource has `organization_id`.
* Access is decided by: load resource → read its org_id → check the
* caller's membership in that org.
*
* 2. There is no "active organization" mutating as a side effect of GETs.
* The org context for list/create endpoints comes EXPLICITLY from the
* request, in this priority order:
* 1. X-Organization-Id header (set by API clients + dashboard JS)
* 2. Session's "default org" cookie (UX fallback)
* 3. (future) API key's bound org
*
* 3. The `member(user_id, organization_id, role)` table is THE relation.
* Every access decision hinges on a membership lookup against the
* resource's org. Resources do NOT carry user_id for access — that's
* what audit_event is for.
*
* 4. Detail endpoints derive org from the resource. Auto-switch is gone.
*
* Resource inheritance for restricted-role grants: domain/deployment/
* service/env_var → project; backup_run/backup_restore → backup_destination;
* build_session → project (via deployment).
*
* Throws `NotFoundError` (404) on deny — IDOR-safe, never confirms the
* existence of resources the caller isn't permitted to see.
*/
/** HTTP compatibility adapter over the shared application permission policy. */
import type { Context } from "hono";
import { NotFoundError, ORG_SINGLETON_RESOURCE_TYPES } from "@repo/core";
import { repos } from "@repo/db";
import type { Permission, ResourceType } from "@repo/db";
import { getRequestContext, withScopedOrg, type RequestContext } from "./request-context";
import { grantSourceFor, type GrantSource } from "./grant-source";
import { env } from "../config";
import { resolveOrgCloudUserId } from "./cloud/transport";
import { type PermissionInput } from "@repo/platform";
import type { RequestContext } from "./request-context";
import { authorization, checkPermission, checkPermissionOnResource } from "@repo/platform/engine/lib/authorization";
export { authorization, checkPermission, checkPermissionOnResource } from "@repo/platform/engine/lib/authorization";
/**
* Resources that exist exactly once per org and carry no resource id in the URL —
* their routes assert `resourceId: "*"` and the org comes from request scope.
*
* Sourced from @repo/core so the route middleware, the wildcard arm below, the
* grant picker, and the MCP tool filter cannot disagree about which types are
* feature-shaped. `route-permission.ts` re-exports this for its existing
* importers; defining it there instead would make this module import from it and
* cycle.
*/
export const ORG_SINGLETON_RESOURCES: ReadonlySet<string> = new Set<string>(
ORG_SINGLETON_RESOURCE_TYPES,
);
export {
ORG_SINGLETON_RESOURCES,
PROJECT_ROOTED,
permitsAction,
roleAllowsResourceType,
type CheckedResourceType,
type PermissionInput,
} from "@repo/platform";
/** Resource types accepted by permission.check — includes leaves. */
export type CheckedResourceType =
| ResourceType
| "deployment"
| "domain"
| "service"
| "env_var"
| "backup_run"
| "backup_restore"
| "build_session";
export interface PermissionInput {
resourceType: CheckedResourceType;
resourceId: string;
action: Permission;
/**
* Set to `"list"` for endpoints that operate on a COLLECTION (list, create-
* in-org) rather than a specific resource. The org comes from the request
* scope (header/cookie) instead of being derived from a resource.
*
* For singletons like billing/audit, pass resourceId="*" and omit scope.
*/
scope?: "list";
/** Set by the dedicated project-create route so the "own projects" scope can
* allow creation without allowing other collection-write routes (ensure/
* scan/import) that could touch existing projects. */
projectCreate?: boolean;
}
/* ------------------------------------------------------------------ */
/* Resource → org resolution */
/* ------------------------------------------------------------------ */
interface ResolvedResource {
orgId: string;
rootType: ResourceType;
rootId: string;
}
async function loadRootOrgId(
type: ResourceType,
id: string,
): Promise<string | null> {
switch (type) {
case "project": {
const p = await repos.project.findById(id);
return p?.organizationId ?? null;
}
case "server": {
const s = await repos.server.get(id).catch(() => null);
return s?.organizationId ?? null;
}
case "mail_server": {
// Mail-server rows are keyed by server.id; the org id lives on server.
const s = await repos.server.get(id).catch(() => null);
return s?.organizationId ?? null;
}
case "backup_destination": {
const d = await repos.backupDestination.findById(id);
return d?.organizationId ?? null;
}
case "billing":
case "audit":
// Org-singletons — the id IS the org id (or "*" for list scope).
// List scope is handled upstream; here we just accept the org id.
return id === "*" ? null : id;
default:
return null;
}
}
/**
* Walk from a (possibly leaf) resource to its grantable root and return
* the org_id that owns it. Returns null if the resource doesn't exist.
*/
async function resolveResourceOrg(
resourceType: CheckedResourceType,
resourceId: string,
): Promise<ResolvedResource | null> {
// A ROOT type resolves directly. There used to be a GRANTABLE_ROOTS membership
// test in front of this, but it was inert: every type it listed WITHOUT a
// `loadRootOrgId` case resolved to null anyway, and the leaf switch below
// returns null for those same types via its default arm. The root cases and the
// leaf cases are disjoint, so trying the root first and falling through on null
// is equivalent — and costs no extra query, since a leaf type hits
// `loadRootOrgId`'s default arm without touching the DB.
const rootOrgId = await loadRootOrgId(resourceType as ResourceType, resourceId);
if (rootOrgId) {
return { orgId: rootOrgId, rootType: resourceType as ResourceType, rootId: resourceId };
}
switch (resourceType) {
case "deployment": {
const dep = await repos.deployment.findById(resourceId);
if (!dep?.projectId) return null;
const orgId = await loadRootOrgId("project", dep.projectId);
return orgId ? { orgId, rootType: "project", rootId: dep.projectId } : null;
}
case "domain": {
const d = await repos.domain.findById(resourceId);
if (!d?.projectId) return null;
const orgId = await loadRootOrgId("project", d.projectId);
return orgId ? { orgId, rootType: "project", rootId: d.projectId } : null;
}
case "service": {
const s = await repos.service.findById(resourceId);
if (!s?.projectId) return null;
const orgId = await loadRootOrgId("project", s.projectId);
return orgId ? { orgId, rootType: "project", rootId: s.projectId } : null;
}
case "env_var": {
// env_var.id → project.id → project.organizationId. Resolves so
// restricted members with a project write-grant can mutate that
// project's env vars (matches the header docstring's promise that
// env_var inherits its grantable root from project).
const ev = await repos.project.findEnvVarById(resourceId).catch(() => null);
if (!ev?.projectId) return null;
const orgId = await loadRootOrgId("project", ev.projectId);
return orgId ? { orgId, rootType: "project", rootId: ev.projectId } : null;
}
case "backup_policy": {
const policy = await repos.backupPolicy.findById(resourceId).catch(() => null);
if (!policy?.destinationId) return null;
const orgId = await loadRootOrgId("backup_destination", policy.destinationId);
return orgId
? { orgId, rootType: "backup_destination", rootId: policy.destinationId }
: null;
}
case "backup_run": {
const run = await repos.backupRun.findById(resourceId).catch(() => null);
if (!run?.destinationId) return null;
const orgId = await loadRootOrgId("backup_destination", run.destinationId);
return orgId
? { orgId, rootType: "backup_destination", rootId: run.destinationId }
: null;
}
case "backup_restore": {
const r = await repos.backupRestore.findById(resourceId).catch(() => null);
if (!r?.destinationId) return null;
const orgId = await loadRootOrgId("backup_destination", r.destinationId);
return orgId
? { orgId, rootType: "backup_destination", rootId: r.destinationId }
: null;
}
case "build_session": {
const bs = await repos.deployment.findBuildSession(resourceId).catch(() => null);
if (!bs?.deploymentId) return null;
const dep = await repos.deployment.findById(bs.deploymentId);
if (!dep?.projectId) return null;
const orgId = await loadRootOrgId("project", dep.projectId);
return orgId ? { orgId, rootType: "project", rootId: dep.projectId } : null;
}
default:
return null;
}
}
/* ------------------------------------------------------------------ */
/* Request scope resolution (for list/create endpoints) */
/* ------------------------------------------------------------------ */
/**
* Resolve the org context for list/create endpoints. Priority:
* 1. X-Organization-Id header (explicit, authoritative)
* 2. session.activeOrganizationId (cookie's stored default — UX fallback)
* 3. null (caller must specify)
*
* Returns the org id or null if nothing is set.
*/
/** Lists/creates establish HTTP scope from an explicit header, then the session fallback. */
export function resolveRequestScopeOrg(c: Context): string | null {
const header =
c.req.header("X-Organization-Id") ?? c.req.header("x-organization-id");
const header = c.req.header("X-Organization-Id") ?? c.req.header("x-organization-id");
if (header && header.trim()) return header.trim();
const sessionOrgId = c.get("activeOrganizationId");
if (typeof sessionOrgId === "string" && sessionOrgId.trim()) {
return sessionOrgId;
}
return null;
return typeof sessionOrgId === "string" && sessionOrgId.trim() ? sessionOrgId : null;
}
/**
* Project-rooted resource types — the ones that, when absent from the local DB,
* may be a CLOUD project (canonical on the SaaS) rather than genuinely missing.
*/
export const PROJECT_ROOTED: ReadonlySet<CheckedResourceType> = new Set([
"project",
"deployment",
"domain",
"service",
"env_var",
"build_session",
]);
/**
* Cloud fallback for the org lookup in `assert`: when a project-rooted resource
* has no local row, it may live on the SaaS. Return the caller's scope org IFF
* that org has a cloud link to proxy through; otherwise null (→ 404, IDOR-safe).
*
* The role check in `checkPermission` then runs against this org: owner/admin/
* member pass; `restricted` passes only with an explicit per-project grant on
* the cloud project id (see the cloud fallback in the restricted arm). The SaaS
* remains the authoritative per-project gate; a bogus id still 404s once proxied.
*/
async function resolveCloudFallbackOrg(
resourceType: CheckedResourceType,
scopeOrg: string | null,
): Promise<string | null> {
if (env.CLOUD_MODE) return null; // the SaaS IS canonical — no upstream to fall back to
if (!PROJECT_ROOTED.has(resourceType)) return null;
if (!scopeOrg) return null;
const ownerUserId = await resolveOrgCloudUserId(scopeOrg).catch(() => null);
return ownerUserId ? scopeOrg : null;
}
/* ------------------------------------------------------------------ */
/* Public API */
/* ------------------------------------------------------------------ */
/**
* Resource-type policy for the non-restricted roles (owner/admin/member) —
* pure, no DB. Owner: everything. Admin: all but billing. Member: all but
* billing/audit. The single source of truth used by both `checkPermission`
* (per call) and the MCP tool-list filter (per listing), so "can call" and
* "is listed" can't drift. Restricted is grant-based — handled by the caller.
*/
export function roleAllowsResourceType(
role: "owner" | "admin" | "member",
resourceType: CheckedResourceType,
): boolean {
if (role === "owner") return true;
if (role === "admin") return resourceType !== "billing";
return resourceType !== "billing" && resourceType !== "audit";
}
/**
* Pure resolver — userId + orgId in, boolean out. Used in places where
* a Hono context isn't available (background jobs, hooks).
*
* For resource-detail input, the CALLER is responsible for already having
* verified that organizationId matches the resource's org. Prefer `assert()`
* with a context — it does the verification for you.
*/
export async function checkPermission(
userId: string,
organizationId: string,
input: PermissionInput,
opts?: {
/** Force a role regardless of membership — scoped tokens pass "restricted". */
roleOverride?: "owner" | "admin" | "member" | "restricted";
/** Where to read grants from — the token's grants for a scoped PAT. */
grants?: GrantSource;
},
): Promise<boolean> {
const member = await repos.member.find(organizationId, userId);
if (!member) return false;
const role =
opts?.roleOverride ??
((member.role ?? "member") as "owner" | "admin" | "member" | "restricted");
// Non-restricted roles (owner/admin/member): the resource-type policy lives in
// `roleAllowsResourceType` so it's the single source shared with the MCP
// tool-list filter (no drift between "can call" and "is listed").
if (role !== "restricted") {
return roleAllowsResourceType(role, input.resourceType);
}
// Restricted: only explicit grants.
const source = opts?.grants ?? repos.resourceGrant;
// Collection-level project actions (resourceId "*") authorized by a project
// "*" grant — read directly, since resolveResourceOrg can't resolve "*". This
// ONLY grants the "create" capability's two abilities; every other "*" action
// falls through to the existing (deny) behavior below, so no other scope
// changes. The "create" verb is collection-only: it never satisfies a
// per-resource read/write/admin check (the switch below + specific-over-
// wildcard fallback), so a create-only grant can't reach existing projects.
if (input.resourceType === "project" && input.resourceId === "*") {
const wildcard = await source.findForResource(organizationId, userId, "project", "*");
if (wildcard) {
// CREATE: only on the dedicated create route, only with a create-capable
// grant. Other collection-write routes (ensure/scan/import) can touch
// existing projects, so they stay denied for a create-only grant.
if (
input.action === "write" &&
input.projectCreate === true &&
wildcard.permissions.includes("create")
) {
return true;
}
// LIST: a create-capable grant may list; the caller filters results to the
// grant's concrete (self-created) project ids, so it sees only its own.
if (input.action === "read" && wildcard.permissions.includes("create")) {
return true;
}
}
}
// ── Collection / wildcard arm ──────────────────────────────────────────────
// Every assertion at resourceId "*" — a `:list` scope, a `collection: true`
// write, or an org-singleton route — is authorized by a WILDCARD grant on the
// asserted type, and by nothing else.
//
// This SUBSUMES the org-singleton-only arm that used to live here: a singleton's
// only id IS "*", so that was this same rule with a narrower type set. Widening
// it to every type fixes the absurdity that a `{server,"*",read}` grant could
// read every server BY ID (the grant lookup below matches `resource_id = $id OR
// resource_id = '*'`) while 404ing on enumerating them.
//
// TERMINAL on purpose: `resolveResourceOrg` cannot resolve "*" for ANY type —
// `loadRootOrgId` returns null for it in every case — so the per-resource arm
// below is a guaranteed deny at "*". Returning here says that out loud and
// avoids a second, pointless grant query.
//
// POSITION IS LOAD-BEARING:
// • AFTER the {project,"*",create} arm above, because `permitsAction` is false
// for "create" on every action. Running first — terminal — would deny both
// abilities that arm exists to allow. Placed after, a create-only grant
// still cannot reach ensure/scan/import: those fall through to here and are
// denied, exactly as before.
// • BEFORE the per-resource arm, for the terminality reason above.
//
// Keyed on the id, not `input.scope`: every caller that sets `scope: "list"`
// also passes resourceId "*" (see route-permission's isList and collection
// branches), and one predicate cannot disagree with itself.
//
// The org is already resolved by the caller (`resolveInputOrg` → request scope,
// pinned to the token's bound org for a scoped principal — see the unbound
// rejection in middleware/auth.ts), and membership in it is asserted above, so
// reading the grant directly adds no new trust input.
if (input.resourceId === "*") {
const wildcard = await source.findForResource(
organizationId,
userId,
input.resourceType as ResourceType,
"*",
);
return wildcard ? permitsAction(wildcard.permissions, input.action) : false;
}
let root = await resolveResourceOrg(input.resourceType, input.resourceId);
if (!root) {
// A `project` with no local row is a CLOUD project (canonical on the
// SaaS). `assert` only reaches here with a resolved `organizationId` when
// the cloud fallback fired (the org is cloud-linked), so honor a grant
// keyed by the cloud project id itself. Scoped to the directly-granted
// `project` type — cloud sub-resources can't be resolved to their parent
// locally, and are covered by the project-level grant on their routes.
if (!env.CLOUD_MODE && input.resourceType === "project" && input.resourceId !== "*") {
root = { orgId: organizationId, rootType: "project", rootId: input.resourceId };
} else {
return false;
}
}
const grant = await source.findForResource(
organizationId,
userId,
root.rootType,
root.rootId,
);
if (!grant) return false;
return permitsAction(grant.permissions, input.action);
}
/**
* Does a grant's permission array authorize `action`?
*
* Cumulative by design — read ⇐ read|write|admin, write ⇐ write|admin — which is
* what lets the dashboard render the three levels as a lossless view of the
* underlying arrays (mcp-access-templates.ts).
*
* The single definition shared by the wildcard arm, the per-resource arm, and the
* MCP tool filter (`filterToolsForPrincipal`), so "can call" and "is listed"
* cannot drift — the same reason `roleAllowsResourceType` is exported. Exhaustive
* switch: adding a new Permission value without updating this fails the build via
* the `never` check.
*/
export function permitsAction(permissions: readonly Permission[], action: Permission): boolean {
switch (action) {
case "read":
return permissions.some((p) => p === "read" || p === "write" || p === "admin");
case "write":
return permissions.some((p) => p === "write" || p === "admin");
case "admin":
return permissions.includes("admin");
case "create":
// "create" is a collection-only capability (handled by the project "*" arm);
// it is never a per-resource action, so it grants nothing on a specific id.
return false;
default: {
const _exhaustive: never = action;
return false;
}
}
}
/**
* Resolve the org an authz check for `input` runs against: `scopeOrg` for the arms
* that have no resource to resolve (list scope / org-singletons at resourceId "*"),
* else the resource's OWN org — with the cloud-project fallback, since a project
* with no local row may be a CLOUD project canonical on the SaaS.
*
* `scopeOrg` is supplied by the caller, and the difference between the two callers
* is the point: `assert` ESTABLISHES request scope (from `X-Organization-Id`), while
* `checkPermissionOnResource` runs afterwards and CONSUMES the scope `assert`
* already resolved (`ctx.organizationId`). Everything downstream of that one choice
* is shared, so use-time and mint-time org resolution can never drift.
*/
async function resolveInputOrg(
input: PermissionInput,
scopeOrg: string | null,
): Promise<string | null> {
if (input.scope === "list" || input.resourceId === "*") return scopeOrg;
const resource = await resolveResourceOrg(input.resourceType, input.resourceId);
return resource?.orgId ?? (await resolveCloudFallbackOrg(input.resourceType, scopeOrg));
}
/**
* A scoped PAT is evaluated as a `restricted` principal whose grants come from
* the token, so even an owner's scoped token can't exceed the token's grants.
* Shared by `assert` + `checkPermissionOnResource`.
*/
function permissionOpts(ctx: RequestContext) {
return ctx.tokenScope
? { roleOverride: "restricted" as const, grants: grantSourceFor(ctx) }
: undefined;
}
/**
* Like `assert` but returns a boolean and has NO request-scope side effects —
* it resolves the resource's OWN org (as `assert` does) and checks the caller's
* access against THAT org, rather than trusting the caller's active org.
*
* Token-mint validation MUST use this, not `checkPermission(userId,
* ctx.organizationId, …)`: the latter resolves the minter's role in their OWN
* org and (for a non-restricted role) returns `roleAllowsResourceType` WITHOUT
* verifying the granted resource belongs to that org — so a grant naming another
* org's resource id would be accepted at mint (privilege escalation, SaaS audit).
* This makes mint-time acceptance consistent with `assert`'s use-time check.
*
* The arms with no resource to resolve — list scope and org-singletons
* (`resourceId: "*"`) — take their authority from the caller's ROLE in an org, so
* WHICH org is the whole decision. It is `ctx.organizationId`, never the raw
* `X-Organization-Id` header, for two reasons:
*
* - Every caller runs AFTER `assert` (via routePermission) has resolved the
* request's authoritative org and rebound ctx to it, and then reads its actual
* DATA from `ctx.organizationId`. Re-deriving from the header would gate on one
* org what the handler goes on to do in another — e.g. `canRunJob` on
* `/projects/:id/…` checked `{job,"*",write}` against the header while the
* project resolved to a different org.
* - A mint path writes the binding to `ctx.organizationId` (MCP consent picks the
* org explicitly — see `mintContextFor`), so a caller-chosen header could name a
* different org: a member of the target org gets a `billing`/`audit` grant
* validated against an org they happen to own. GHSA-qv27-39pc-qw9f finding 1.
*
* Consequence: this never touches `ctx.hono`, so it also holds for a background ctx.
*/
export async function checkPermissionOnResource(
ctx: RequestContext,
input: PermissionInput,
): Promise<boolean> {
const organizationId = await resolveInputOrg(input, ctx.organizationId);
if (!organizationId) return false;
return checkPermission(ctx.userId, organizationId, input, permissionOpts(ctx));
}
/**
* Assert version — throws 404 on deny so out-of-permission resources
* don't leak existence via 403s. The IDOR-safe pattern.
*
* Derives org from the resource (detail endpoints) or the request scope
* (list/create endpoints), then runs the role check.
*
* Takes RequestContext (not raw Hono Context) so the caller's intent
* is explicit in the signature — the function declares it needs an
* authenticated user + an active org. The Hono escape hatch on ctx
* (`ctx.hono`) is used for the side effects below.
*
* SIDE EFFECTS on success:
* - `ctx.hono.set("scopedOrganizationId", orgId)` for legacy readers
* of the stash variable (read directly via `c.get`, no helper).
* - Rebinds `ctx.hono.var.ctx` to the scoped-org variant so any later
* `getRequestContext(c)` in the same request returns
* `organizationId === scoped`, not the session-active org.
*
* The passed `ctx` local is NOT mutated — it's a value copy. Callers
* that want the scoped ctx after this returns must re-read it via
* `getRequestContext(c)`.
* Preserve legacy HTTP resource-derived scope. Native views use the shared
* authorize() directly with a fixed tenant. All policy lives in @repo/platform;
* only reading headers and rebinding the request belong here.
*/
export async function assert(ctx: RequestContext, input: PermissionInput): Promise<void> {
const c = ctx.hono;
// Resolve the resource's OWN org + gate on role. This is where request scope is
// ESTABLISHED, so the list/singleton arms read the header here (and only here) —
// every later check in the request consumes the org this rebinds ctx to. Shared
// with checkPermissionOnResource so mint-time acceptance and use-time enforcement
// can't drift. On deny we throw NotFoundError (not 403) so out-of-permission
// resources don't leak existence — the IDOR-safe pattern.
const organizationId = await resolveInputOrg(input, resolveRequestScopeOrg(c));
if (!organizationId) {
throw new NotFoundError(input.resourceType, input.resourceId);
}
const allowed = await checkPermission(ctx.userId, organizationId, input, permissionOpts(ctx));
if (!allowed) {
throw new NotFoundError(input.resourceType, input.resourceId);
}
c.set("scopedOrganizationId", organizationId);
// Rebind ctx.organizationId so service-layer code reading
// getRequestContext(c).organizationId automatically sees the
// resource-scoped tenant (not the session's stale active-org). This
// is the WHOLE point of routing services through ctx: a member of
// org A acting on a project owned by org B (via a grant or admin
// role) sees ctx.organizationId === B for the rest of this request.
if (ctx.organizationId !== organizationId) {
c.set("ctx" as never, withScopedOrg(ctx, organizationId));
}
if (!c)
throw new Error(
"permission.assert requires an HTTP request; use authorization.authorize for native operations",
);
const authorized = await authorization.authorize(ctx, input, resolveRequestScopeOrg(c));
c.set("scopedOrganizationId", authorized.organizationId);
c.set("ctx", { ...authorized, hono: c });
}
export const permission = {
checkPermission,
assert,
resolveRequestScopeOrg,
};
export const permission = { checkPermission, assert, resolveRequestScopeOrg };
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { buildMinutePeriod } from "./plan-guard";
import { buildMinutePeriod } from "@repo/platform/engine/lib/plan-guard";
/**
* The build-minute window is the boundary a customer is refused on, so it gets
+2 -2
View File
@@ -19,7 +19,7 @@ const h = vi.hoisted(() => ({
settings: null as unknown,
}));
vi.mock("../config/env", () => ({ env: h.env }));
vi.mock("@repo/platform/engine/config/env", () => ({ env: h.env }));
vi.mock("@repo/db", () => ({
repos: {
instanceSettings: {
@@ -31,7 +31,7 @@ vi.mock("@repo/db", () => ({
},
}));
import { clearProductModeCache, isProductMode, resolveProductMode } from "./product-mode";
import { clearProductModeCache, isProductMode, resolveProductMode } from "@repo/platform/engine/lib/product-mode";
beforeEach(() => {
h.env.CLOUD_MODE = false;
@@ -9,7 +9,7 @@ vi.mock("@repo/db", () => ({
withAdvisoryLock: async (_key: string, run: () => Promise<unknown>) => run(),
}));
import { withLiveProjectRuntimeMutation, withProjectRuntimeLock } from "./project-runtime-lock";
import { withLiveProjectRuntimeMutation, withProjectRuntimeLock } from "@repo/platform/engine/lib/project-runtime-lock";
describe("project runtime lock", () => {
beforeEach(() => {
@@ -1,12 +1,12 @@
import { describe, expect, it } from "vitest";
import { parseServiceHostPort, parseServicePort } from "./deployable-service";
import { parseComposePort } from "../modules/migration/docker-reconcile";
import { parseServiceHostPort, parseServicePort } from "@repo/platform/engine/lib/deployable-service";
import { parseComposePort } from "@repo/platform/engine/modules/migration/docker-reconcile";
import {
buildProjectServiceUpstream,
describeCandidatePorts,
pickProjectPortOwner,
resolveProjectServiceUpstream,
} from "./project-service-upstream";
} from "@repo/platform/engine/lib/project-service-upstream";
/**
* The adopted stack from #618. postgres comes FIRST, and nothing is `exposed` —
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { PromptRegistry } from "./prompt-gateway";
import { PromptRegistry } from "@repo/platform/engine/lib/prompt-gateway";
describe("PromptRegistry", () => {
it("resolves the awaiting promise with the chosen action", async () => {
+2 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { storedPublicEndpointsNeedCloud } from "./public-endpoints";
import { getRoutingBaseDomain } from "./routing-domains";
import { storedPublicEndpointsNeedCloud } from "@repo/platform/engine/lib/public-endpoints";
import { getRoutingBaseDomain } from "@repo/platform/engine/lib/routing-domains";
// The Cloud gate must classify by the HOSTNAME's physical truth, not a bare
// `domainType` string. Regression for: removing a migrated custom-domain route
+2 -2
View File
@@ -13,8 +13,8 @@
*/
import IORedis from "ioredis";
import { env, REDIS_REQUIRED } from "../../config/env";
import { isRedisReachable } from "../../lib/redis";
import { env, REDIS_REQUIRED } from "@repo/platform/engine/config/env";
import { isRedisReachable } from "@repo/platform/engine/lib/redis";
import { MemoryRateLimitStore } from "./memory-store";
import { RedisRateLimitStore } from "./redis-store";
import { getPolicy, type PolicyId } from "./policies";
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { isConnectionLoss } from "./remote-state";
import { isConnectionLoss } from "@repo/platform/engine/lib/remote-state";
describe("isConnectionLoss", () => {
it("recognizes a serialized ssh2 exec-request rejection", () => {
+21 -92
View File
@@ -1,15 +1,16 @@
import type { Context } from "hono";
import type {
ContextRole,
ContextUser,
CredentialRestrictions,
ExecutionContext,
PrincipalKind,
SessionKind,
} from "@repo/platform";
export type RequestContextRole = "owner" | "admin" | "member" | "restricted";
export type SessionKind = "cookie" | "bearer" | "zero-auth";
/** Which kind of bearer credential authenticated this request, if any. */
export type PrincipalKind = "pat" | "oauth";
export interface RequestContextUser {
id: string;
email: string;
name: string | null;
}
export type RequestContextRole = ContextRole;
export type RequestContextUser = ContextUser;
export type { SessionKind, PrincipalKind };
/**
* Request-scoped context object. ONE source of truth for who the caller
@@ -25,50 +26,9 @@ export interface RequestContextUser {
* Do NOT extend this with feature flags, project-id, deployment-id, etc.
* Resource scoping comes from path params + assertResourceInOrg, not ctx.
*/
export interface RequestContext {
userId: string;
user: RequestContextUser;
// The active org for THIS request. Resolved by authMiddleware via
// resolveActiveOrganizationId. After permission.assert succeeds for a
// resource-bound route, this is REPLACED with the scoped org id so
// services automatically see the right tenant.
organizationId: string;
role: RequestContextRole;
membershipId: string;
sessionId: string;
sessionKind: SessionKind;
/**
* For a bearer request, WHICH credential: a personal access token or an OAuth
* (MCP) token. Null for cookie / zero-auth. This is identity, not feature
* state — it's what lets the audit log say an action came from an AI assistant
* rather than a script, since both arrive as `sessionKind: "bearer"`.
*/
principalKind?: PrincipalKind | null;
/**
* Present ONLY for a scoped personal access token. When set, the caller is a
* scoped-token principal: permission checks force `restricted` behavior and
* source grants from the token (personal_access_token_grant) instead of the
* user's member grants. Absent for sessions and unscoped tokens.
*/
tokenScope?: { tokenId: string } | null;
clientIp: string | null;
userAgent: string | null;
traceId: string;
// Escape hatch for the rare case where a CONTROLLER needs the raw
// Hono context (streaming responses, raw body access, mid-handler
// `c.set` for downstream middleware). Services MUST NOT take this —
// services take `ctx: RequestContext` and read fields off it. Reading
// typed fields off ctx is always preferred over `c.get("user")` /
// `c.get("activeOrganizationId")`, which are now reachable only
// through this escape hatch.
hono: Context;
export interface RequestContext extends ExecutionContext {
/** HTTP compatibility only. Shared/native operations use ExecutionContext. */
readonly hono?: Context;
}
/**
@@ -100,6 +60,8 @@ export interface BuildRequestContextInput {
sessionKind: SessionKind;
principalKind?: PrincipalKind | null;
tokenScope?: { tokenId: string } | null;
credential?: CredentialRestrictions | null;
scopeMode?: "fixed" | "resource";
clientIp: string | null;
userAgent: string | null;
traceId: string;
@@ -117,6 +79,8 @@ export function buildRequestContext(input: BuildRequestContextInput): RequestCon
sessionKind: input.sessionKind,
principalKind: input.principalKind ?? null,
tokenScope: input.tokenScope ?? null,
credential: input.credential ?? null,
scopeMode: input.scopeMode ?? "resource",
clientIp: input.clientIp,
userAgent: input.userAgent,
traceId: input.traceId,
@@ -124,46 +88,11 @@ export function buildRequestContext(input: BuildRequestContextInput): RequestCon
};
}
/** Internal helper used by permission.assert to replace ctx.organizationId
* with the scoped org id after permission resolution. */
/** Compatibility helper for internal organization selection. Application
* operations use the shared authorizer's resolved context instead. */
export function withScopedOrg(ctx: RequestContext, scopedOrganizationId: string): RequestContext {
if (ctx.organizationId === scopedOrganizationId) return ctx;
return { ...ctx, organizationId: scopedOrganizationId };
}
/**
* Build a RequestContext for BACKGROUND tasks that have no Hono request
* (webhook deliveries, crons, queue workers, install-callback handlers).
*
* Callers MUST already know which user + org they're acting on behalf of —
* this helper does NOT resolve org from memberships[0] or any other
* lookup. If you don't know the org, you have a routing bug.
*
* The returned ctx has the same shape as a request-built one EXCEPT
* `hono` is a getter that throws — background work has no Hono ctx and
* any caller reaching for it is doing something wrong.
*/
export function buildBackgroundContext(opts: {
userId: string;
organizationId: string;
role?: RequestContextRole;
membershipId?: string;
traceId?: string;
label?: string; // operator-facing label for traces: "webhook:github", "cron:anniversary"
}): RequestContext {
return {
userId: opts.userId,
user: { id: opts.userId, email: "", name: null },
organizationId: opts.organizationId,
role: opts.role ?? "owner",
membershipId: opts.membershipId ?? `bg_${opts.userId}_${opts.organizationId}`,
sessionId: opts.label ? `bg:${opts.label}` : "background",
sessionKind: "bearer" as const,
clientIp: null,
userAgent: opts.label ? `openship-bg:${opts.label}` : "openship-bg",
traceId: opts.traceId ?? `bg_${Math.random().toString(36).slice(2)}`,
get hono(): Context {
throw new Error("buildBackgroundContext: background ctx has no Hono request");
},
};
}
export { buildBackgroundContext } from "@repo/platform/engine/lib/background-context";
+15 -6
View File
@@ -4,11 +4,11 @@ const reserveObserved = vi.hoisted(() => vi.fn());
const prepareTarget = vi.hoisted(() => vi.fn());
const convergeTarget = vi.hoisted(() => vi.fn());
const withTargetLock = vi.hoisted(() => vi.fn(async (_target, run) => run()));
vi.mock("../modules/deployments/observed-host-port-claims", async (importOriginal) => ({
...(await importOriginal<typeof import("../modules/deployments/observed-host-port-claims")>()),
vi.mock("@repo/platform/engine/modules/deployments/observed-host-port-claims", async (importOriginal) => ({
...(await importOriginal<typeof import("@repo/platform/engine/modules/deployments/observed-host-port-claims")>()),
reserveObservedLoopbackPublishes: reserveObserved,
}));
vi.mock("../modules/deployments/pinned-host-ports", () => ({
vi.mock("@repo/platform/engine/modules/deployments/pinned-host-ports", () => ({
convergeTargetHostPortClaimsUnlocked: convergeTarget,
prepareTargetPinnedHostPorts: prepareTarget,
withHostPortTargetLock: withTargetLock,
@@ -17,16 +17,16 @@ vi.mock("../modules/deployments/pinned-host-ports", () => ({
vi.mock("./controller-helpers", () => ({
platform: () => ({ routing: { removeRoute: vi.fn() } }),
}));
vi.mock("./deployment-runtime", () => ({
vi.mock("@repo/platform/engine/lib/deployment-runtime", () => ({
disposePlatform: vi.fn(),
resolveDeploymentPlatform: vi.fn(),
}));
vi.mock("./cloud-route.service", () => ({
vi.mock("@repo/platform/engine/lib/cloud-route.service", () => ({
reapplyCloudProjectRoute: vi.fn(),
removeCloudProjectRoute: vi.fn(),
}));
import { reconcileProjectRoutes } from "./route-apply.service";
import { reconcileProjectRoutes } from "@repo/platform/engine/lib/route-apply.service";
const target = { targetKey: "local" as const, legacyTargetKeys: [], stable: true };
const edgeProxy = { listLoopbackUpstreamPortsStrict: vi.fn(async () => new Set<number>()) };
@@ -301,3 +301,12 @@ describe("reconcileProjectRoutes host-port ownership gate", () => {
});
});
});
// The application seams moved with the shared engine.
vi.mock("@repo/platform/engine/lib/platform-config", () => ({
platform: () => ({ routing: { removeRoute: vi.fn() } }),
}));
vi.mock("@repo/platform/engine/lib/resource-access", () => ({
platform: () => ({ routing: { removeRoute: vi.fn() } }),
}));
+38 -7
View File
@@ -34,7 +34,7 @@ import {
canUseGitHubRepo,
checkSourceTier,
type SourceTier,
} from "../modules/github/github-access";
} from "@repo/platform/engine/modules/github/github-access";
import type { PolicyId } from "./rate-limit/policies";
/* ------------------------------------------------------------------ */
@@ -364,8 +364,20 @@ export interface PermissionSpec {
* 1. `body` declares `projectId` as REQUIRED — the auto-wired validator runs
* right after this middleware, so a missing id is a 400 before the handler.
* 2. The handler asserts on that id before doing any work.
* Use `"query"` for GET collections: this middleware requires and authorizes
* the `projectId` query parameter itself before the handler runs.
*/
collectionProject?: boolean;
collectionProject?: boolean | "query";
/** The shared application operation emits this mutation's audit event for every transport. */
auditHandledByOperation?: boolean;
/**
* This adapter delegates every call to a shared authorized operation. Use for
* body/session-derived targets, where a wildcard pre-check would reject an
* otherwise valid exact resource grant. Authentication and request validation
* remain HTTP middleware; the operation resolves and authorizes the target.
* A successful adapter must apply its returned operation context.
*/
authorizationHandledByOperation?: boolean;
/**
* Restrict this route to self-hosted instances. The secure router mounts the
* `localOnly` middleware ahead of auth, so a request in CLOUD_MODE gets a 404
@@ -498,7 +510,11 @@ export function requirePermission(spec: PermissionSpec): MiddlewareHandler {
const ghTarget = githubReadTarget(parsed, c);
if (ghTarget) {
if (spec.authorizationHandledByOperation) {
// The operation receives the authenticated context and performs the same
// target authorization as a native call, before invoking retained services.
leafId = "*";
} else if (ghTarget) {
// Authorize against the caller's ACTUAL GitHub grant width instead of
// the unsatisfiable {github,"*"} singleton check — see githubReadTarget.
// `canUseGitHubRepo` gates membership itself and short-circuits to allow
@@ -558,12 +574,22 @@ export function requirePermission(spec: PermissionSpec): MiddlewareHandler {
leafId = ghTarget.key;
} else if (spec.collectionProject) {
// The body names the target project and the handler asserts on it — see
if (spec.collectionProject === "query") {
// GET collections carry the same explicit project scope in the query.
// Enforce it here; a missing id must never become a wildcard list.
const projectId = c.req.query("projectId");
if (!projectId?.trim()) return c.json({ error: "projectId query parameter required" }, 400);
await permission.assert(getRequestContext(c), {
resourceType: "project", resourceId: projectId,
action: parsed.isList ? "read" : parsed.action as Action,
});
}
// A body names the target project and the handler asserts on it — see
// PermissionSpec.collectionProject for why the `"*"` pre-check is skipped
// rather than kept as belt-and-braces. `leafId` stays "*" so the audit
// record below is byte-identical to the collection branch's.
leafId = "*";
} else if (parsed.isList) {
} else if (parsed.isList || (spec.collection && parsed.root !== parsed.leaf)) {
if (parsed.root !== parsed.leaf) {
// A nested collection belongs to the concrete parent named in the URL.
// Authorizing `{service,"*"}` here made project-scoped tokens unable to
@@ -583,7 +609,7 @@ export function requirePermission(spec: PermissionSpec): MiddlewareHandler {
await permission.assert(getRequestContext(c), {
resourceType: parsed.root,
resourceId: parentId,
action: "read",
action: parsed.isList ? "read" : parsed.action as Action,
});
leafId = "*";
} else {
@@ -682,11 +708,16 @@ export function requirePermission(spec: PermissionSpec): MiddlewareHandler {
// Run the handler.
await next();
if (spec.authorizationHandledByOperation && c.res.status < 400 && !c.get("operationContextApplied"))
throw new Error("An operation-authorized route did not apply its authorized context");
// After handler success: emit an audit event for write/admin/list-
// -with-side-effects. Read/list are typically too noisy to log unless
// the route opts in (TODO: per-route auditOnRead flag).
const action = parsed.action;
if (action === "write" || action === "admin") {
// A cloud proxy may finish before reaching a migrated operation. Only skip
// this emitter when that operation actually recorded this invocation.
if ((!spec.auditHandledByOperation || !c.get("operationAuditRecorded")) && (action === "write" || action === "admin")) {
const status = c.res.status;
if (status >= 200 && status < 400) {
// For CREATE flows, the handler stamps the new id via
+2 -34
View File
@@ -13,43 +13,11 @@
* that (re)connects after the run finished still gets the terminal snapshot.
*/
import { EventEmitter } from "node:events";
import type { RunBus } from "@repo/platform/engine/lib/run-bus";
export { createRunBus, type RunBus } from "@repo/platform/engine/lib/run-bus";
import type { Context } from "hono";
import { streamSSE } from "./sse";
export interface RunBus<E> {
/** Emit to every subscriber; close the channel after a terminal event. */
publish(id: string, event: E): void;
/** Attach a listener; returns an unsubscribe fn. */
subscribe(id: string, listener: (event: E) => void): () => void;
}
/**
* A per-id event topic. `isFinal` decides when the channel closes — after a
* terminal event, listeners are removed on the next tick (so pending writes
* flush first). `maxListeners` allows for multiple dashboard tabs on one run.
*/
export function createRunBus<E>(
isFinal: (event: E) => boolean,
maxListeners = 32,
): RunBus<E> {
const emitter = new EventEmitter();
emitter.setMaxListeners(maxListeners);
return {
publish(id, event) {
emitter.emit(id, event);
if (isFinal(event)) {
setImmediate(() => emitter.removeAllListeners(id));
}
},
subscribe(id, listener) {
const wrapped = (event: E) => listener(event);
emitter.on(id, wrapped);
return () => emitter.off(id, wrapped);
},
};
}
/**
* Stream a run channel over SSE. `E` must carry a `type` (used as the SSE event
* name). The caller builds the snapshot event, and — when the run is already
+1 -1
View File
@@ -11,7 +11,7 @@
* Gracefully no-ops when the screenshot service is not configured.
*/
import { env } from "../config/env";
import { env } from "@repo/platform/engine/config/env";
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -15,11 +15,11 @@
*/
import { randomBytes } from "node:crypto";
import { env } from "../config/env";
import { env } from "@repo/platform/engine/config/env";
import type { RuntimeAdapter, ShellSession } from "@repo/adapters";
import { disposeRuntime } from "./deployment-runtime";
import { disposeRuntime } from "@repo/platform/engine/lib/deployment-runtime";
import type { TerminalExitReason } from "@repo/db";
import type { RequestContext } from "./request-context";
import type { ExecutionContext as RequestContext } from "@repo/platform";
// ─── Tickets ────────────────────────────────────────────────────────────────
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Context } from "hono";
import { setSignedCookie } from "hono/cookie";
import { env } from "../config/env";
import { COOKIE_PREFIX } from "./auth";
import { env } from "@repo/platform/engine/config/env";
import { COOKIE_PREFIX } from "@repo/platform/engine/lib/auth";
/**
* Stamp the response with a signed Better Auth session cookie. Shared by every
@@ -1,4 +1,4 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
/**
* buildSshConfig is the single choke point every SSH connection funnels
@@ -22,11 +22,11 @@ vi.mock("@repo/adapters", async () => ({
hostChannelHealth: vi.fn(),
probeTcp: vi.fn(),
}));
vi.mock("./box-org", () => ({ isLocalHostRow: vi.fn() }));
vi.mock("@repo/platform/engine/lib/box-org", () => ({ isLocalHostRow: vi.fn() }));
// The path allowlist is tested on its own (ssh-key-path); here we only need to
// know WHETHER the path branch runs, so make it an identity + assert on the read.
vi.mock("./ssh-key-path", () => ({
vi.mock("@repo/platform/engine/lib/ssh-key-path", () => ({
resolveSafeSshKeyPath: vi.fn((p: string) => p),
operatorSshKeyRoots: vi.fn(() => []),
}));
@@ -39,15 +39,43 @@ vi.mock("node:fs", async (importOriginal) => ({
readFileSync: (...args: unknown[]) => readFileSync(...args),
}));
import { buildSshConfig } from "./ssh-manager";
import { buildSshConfig } from "@repo/platform/engine/lib/ssh-manager";
// REAL encryption — the whole point is that a stored enc1: value round-trips.
import { encryptSecretField } from "./credential-encryption";
import { encryptSecretField } from "@repo/platform/engine/lib/credential-encryption";
const base = { sshHost: "10.0.0.1", sshAuthMethod: "key" as const };
beforeEach(() => {
readFileSync.mockClear();
});
afterEach(() => vi.unstubAllEnvs());
describe("native SSH host policy", () => {
it("refuses ambient agent/config and host key files before reading any credentials", async () => {
vi.stubEnv("OPENSHIP_NATIVE", "true");
vi.stubEnv("OPENSHIP_NATIVE_ALLOW_HOST_EXECUTION", "false");
for (const settings of [
{ ...base, sshKeyPath: "/root/.ssh/id_ed25519" },
{ ...base, sshAuthMethod: "agent" },
]) await expect(buildSshConfig(settings)).rejects.toMatchObject({ code: "HOST_EXECUTION_DISABLED" });
expect(readFileSync).not.toHaveBeenCalled();
});
it("allows explicit remote passwords and pasted keys without host access", async () => {
vi.stubEnv("OPENSHIP_NATIVE", "true");
vi.stubEnv("OPENSHIP_NATIVE_ALLOW_HOST_EXECUTION", "false");
expect(await buildSshConfig({ ...base, sshPrivateKey: "EXPLICIT-KEY", sshKeyPath: "/root/.ssh/id_ed25519" }))
.toMatchObject({ privateKey: "EXPLICIT-KEY" });
expect(await buildSshConfig({ ...base, sshAuthMethod: "password", sshPassword: "EXPLICIT-PASSWORD" }))
.toMatchObject({ password: "EXPLICIT-PASSWORD" });
expect(readFileSync).not.toHaveBeenCalled();
});
it("retains host-key access when the owning application explicitly enables it", async () => {
vi.stubEnv("OPENSHIP_NATIVE", "true");
vi.stubEnv("OPENSHIP_NATIVE_ALLOW_HOST_EXECUTION", "true");
expect(await buildSshConfig({ ...base, sshKeyPath: "/root/.ssh/id_ed25519" })).toMatchObject({ privateKey: "FILE-ON-HOST-KEY" });
expect(readFileSync).toHaveBeenCalledOnce();
});
});
describe("buildSshConfig — pasted/uploaded key material", () => {
it("decrypts stored material into privateKey and never reads a file", async () => {
@@ -49,11 +49,11 @@ vi.mock("@repo/adapters", async () => ({
// isLocalHostRow decides "is this row THIS box". Keyed off the fixture flag so the
// test doesn't depend on env/loopback resolution.
vi.mock("./box-org", () => ({
vi.mock("@repo/platform/engine/lib/box-org", () => ({
isLocalHostRow: vi.fn(async (row: { isLocal?: boolean }) => Boolean(row?.isLocal)),
}));
import { sshManager } from "./ssh-manager";
import { sshManager } from "@repo/platform/engine/lib/ssh-manager";
/** Reach into the pool — there's no public accessor, and the whole point is to
* assert the cache state that the leak was a symptom of. */
-156
View File
@@ -1,156 +0,0 @@
/**
* Live port-forward tunnels — Desktop-only.
*
* RAM-only registry of open forwards (a remote server port → localhost on the
* user's machine). The durable config lives in `server_tunnels`
* (`repos.serverTunnel`); this manager owns the live sockets. Modeled on
* terminal-session-manager: a module-singleton Map, no per-process persistence
* (the sockets die with the process; `auto_start` rows are re-opened at boot by
* the startup hook below).
*
* Retain fix: `tunnelForward()` opens local sockets over the pooled SSH
* connection but never `retain()`s it, so an idle (no-traffic) tunnel could
* have its SSH connection idle-dropped out from under it. This manager holds a
* `retain()` for each live tunnel and `release()`s on stop, pinning the
* connection for the tunnel's whole lifetime.
*/
import { repos } from "@repo/db";
import { tunnelForward, type ForwardHandle } from "./ssh-tunnel";
import { sshManager } from "./ssh-manager";
import { registerStartupHook } from "./startup";
interface LiveTunnel {
tunnelId: string;
serverId: string;
remoteHost: string;
remotePort: number;
handle: ForwardHandle;
}
export interface TunnelStatus {
tunnelId: string;
serverId: string;
remoteHost: string;
remotePort: number;
localPort: number;
activeConnections: number;
}
const live = new Map<string, LiveTunnel>();
function toStatus(t: LiveTunnel): TunnelStatus {
return {
tunnelId: t.tunnelId,
serverId: t.serverId,
remoteHost: t.remoteHost,
remotePort: t.remotePort,
localPort: t.handle.localPort,
activeConnections: t.handle.activeConnections,
};
}
/**
* Start (or return the already-running) tunnel for a config row. The pooled
* SSH connection is `retain()`ed before forwarding and released on the error
* path, so a failed start never leaks a hold.
*/
export async function startTunnel(args: {
tunnelId: string;
serverId: string;
remotePort: number;
remoteHost?: string;
preferredPort?: number;
}): Promise<TunnelStatus> {
const existing = live.get(args.tunnelId);
if (existing) return toStatus(existing);
const remoteHost = args.remoteHost ?? "127.0.0.1";
// Pin the pooled SSH connection for the tunnel's lifetime.
sshManager.retain(args.serverId);
let handle: ForwardHandle;
try {
handle = await tunnelForward(args.serverId, args.remotePort, {
remoteHost,
preferredPort: args.preferredPort ?? args.remotePort,
});
} catch (err) {
sshManager.release(args.serverId);
throw err;
}
const t: LiveTunnel = {
tunnelId: args.tunnelId,
serverId: args.serverId,
remoteHost,
remotePort: args.remotePort,
handle,
};
live.set(args.tunnelId, t);
return toStatus(t);
}
/** Stop a live tunnel. Idempotent — a no-op if it isn't running. */
export async function stopTunnel(tunnelId: string): Promise<void> {
const t = live.get(tunnelId);
if (!t) return;
// Delete first so a concurrent stop can't double-release the SSH hold.
live.delete(tunnelId);
try {
await t.handle.close();
} finally {
sshManager.release(t.serverId);
}
}
/** Status of one live tunnel, or null if it isn't running. */
export function getTunnelStatus(tunnelId: string): TunnelStatus | null {
const t = live.get(tunnelId);
return t ? toStatus(t) : null;
}
/** Status of every live tunnel for a server. */
export function listTunnelStatus(serverId: string): TunnelStatus[] {
const out: TunnelStatus[] = [];
for (const t of live.values()) {
if (t.serverId === serverId) out.push(toStatus(t));
}
return out;
}
/** Close every live tunnel — graceful shutdown. */
export async function stopAllTunnels(): Promise<void> {
const ids = [...live.keys()];
await Promise.all(ids.map((id) => stopTunnel(id).catch(() => {})));
}
/**
* Register the desktop boot hook that re-opens every saved auto-start tunnel.
*
* Desktop-only (`modes: ["desktop"]`); the startup registry no-ops it under
* any other target. Each tunnel is started in the background (fire-and-forget,
* per-tunnel catch) so an unreachable server can't stall API boot — the hook
* returns as soon as the starts are dispatched.
*/
export function registerTunnelAutostart(): void {
registerStartupHook({
id: "tunnels:autostart",
modes: ["desktop"],
run: async () => {
const rows = await repos.serverTunnel.listAutoStart();
if (rows.length === 0) return;
console.log(`[startup] re-opening ${rows.length} port-forward tunnel(s)`);
for (const row of rows) {
void startTunnel({
tunnelId: row.id,
serverId: row.serverId,
remotePort: row.remotePort,
remoteHost: row.remoteHost,
preferredPort: row.localPort ?? row.remotePort,
}).catch((err) =>
console.warn(`[startup] tunnel ${row.id} failed to open:`, err),
);
}
},
});
}
@@ -18,10 +18,10 @@
*/
import { repos, type DnsCredential } from "@repo/db";
import { registerStartupHook } from "./index";
import { registerStartupHook } from "@repo/platform/engine/lib/startup/index";
import { safeErrorMessage } from "@repo/core";
import { decryptSecretField, encryptSecretField } from "../credential-encryption";
import { decryptSecretField, encryptSecretField } from "@repo/platform/engine/lib/credential-encryption";
/** What the DNS provider entry calls its secret field, per CREDENTIAL_PROVIDERS. */
const CLOUDFLARE_SECRET_FIELD = "apiToken";
+3 -3
View File
@@ -29,9 +29,9 @@
import { safeErrorMessage } from "@repo/core";
import { repos } from "@repo/db";
import { registerStartupHook } from "./index";
import { readApiVersion } from "../release-resolver";
import { scanInstanceContainers } from "../../modules/system/server-containers.service";
import { registerStartupHook } from "@repo/platform/engine/lib/startup/index";
import { readApiVersion } from "@repo/platform/engine/lib/release-resolver";
import { scanInstanceContainers } from "@repo/platform/engine/modules/system/server-containers.service";
export function registerInfraReconcile(): void {
registerStartupHook({
+4 -4
View File
@@ -6,12 +6,12 @@
* order is deterministic and not dependent on incidental module-load order.
* Add new feature hooks here.
*/
import { registerTunnelAutostart } from "../ssh-tunnel-manager";
import { registerTunnelAutostart } from "@repo/platform/engine/lib/ssh-tunnel-manager";
import { registerSelfAdoptReconcile } from "./self-deploy";
import { registerSelfServerReconcile } from "./self-server";
import { registerSelfServerReconcile } from "@repo/platform/engine/lib/startup/self-server";
import { registerInfraReconcile } from "./infra-reconcile";
import { registerAppServiceRowReconcile } from "../../modules/services/service.service";
import { registerCustomCommandRestoreBackfill } from "../../modules/backups/restore-command-backfill";
import { registerAppServiceRowReconcile } from "@repo/platform/engine/modules/services/service.service";
import { registerCustomCommandRestoreBackfill } from "@repo/platform/engine/modules/backups/restore-command-backfill";
import { registerCredentialBackfill } from "./credential-backfill";
export function registerStartupHooks(): void {
@@ -23,24 +23,24 @@ const h = vi.hoisted(() => ({
}));
vi.mock("./self-edge", () => ({ ensureSelfEdgeInfra: async () => h.infra }));
vi.mock("./self-services", () => ({ linkSelfAppServices: vi.fn(async () => {}) }));
vi.mock("./index", () => ({ registerStartupHook: vi.fn() }));
vi.mock("../../modules/deployments/build.service", () => ({ createQueuedDeployment: vi.fn() }));
vi.mock("../../modules/deployments/deployment-lifecycle", () => ({ onSuccess: vi.fn() }));
vi.mock("../../modules/domains/project-route.service", () => ({
vi.mock("@repo/platform/engine/lib/startup/self-services", () => ({ linkSelfAppServices: vi.fn(async () => {}) }));
vi.mock("@repo/platform/engine/lib/startup/index", () => ({ registerStartupHook: vi.fn() }));
vi.mock("@repo/platform/engine/modules/deployments/build.service", () => ({ createQueuedDeployment: vi.fn() }));
vi.mock("@repo/platform/engine/modules/deployments/deployment-lifecycle", () => ({ onSuccess: vi.fn() }));
vi.mock("@repo/platform/engine/modules/domains/project-route.service", () => ({
reapplyProjectLiveRoutes: h.reapply,
}));
vi.mock("../domain-ssl", () => ({
vi.mock("@repo/platform/engine/lib/domain-ssl", () => ({
manageDomainSsl: vi.fn(async () => ({ verified: false, reason: "challenge failed" })),
tlsIssuedElsewhere: () => null,
describeTlsIssuedElsewhere: () => "",
}));
vi.mock("../public-url", () => ({ refreshSelfAppPublicUrl: vi.fn(async () => {}) }));
vi.mock("@repo/platform/engine/lib/public-url", () => ({ refreshSelfAppPublicUrl: vi.fn(async () => {}) }));
vi.mock("@repo/adapters", () => ({
BareRuntime: class {},
foreignProxyOnEdge: async () => h.foreignProxy,
}));
vi.mock("../ssh-manager", () => ({
vi.mock("@repo/platform/engine/lib/ssh-manager", () => ({
sshManager: { withHostExecutor: async (fn: (e: unknown) => unknown) => fn({}) },
}));
vi.mock("@repo/db", () => ({
@@ -58,7 +58,7 @@ import {
createSetupSession,
updateComponentProgress,
subscribeSetupSession,
} from "../../modules/system/setup-session";
} from "@repo/platform/engine/modules/system/setup-session";
/** Records every step event so the returned payload and the wizard's stream can be
* compared — the bug was one of them carrying the diagnosis and the other not. */
+11 -11
View File
@@ -29,19 +29,19 @@
import { repos, type Project, type Deployment } from "@repo/db";
import { BareRuntime } from "@repo/adapters";
import { safeErrorMessage, UNLIMITED_RESOURCES } from "@repo/core";
import { env } from "../../config/env";
import { registerStartupHook } from "./index";
import { env } from "@repo/platform/engine/config/env";
import { registerStartupHook } from "@repo/platform/engine/lib/startup/index";
import { ensureSelfEdgeInfra, type SelfEdgeOptions } from "./self-edge";
import { linkSelfAppServices } from "./self-services";
import { linkSelfAppServices } from "@repo/platform/engine/lib/startup/self-services";
import {
createQueuedDeployment,
type DeploymentConfigSnapshot,
} from "../../modules/deployments/build.service";
import { onSuccess } from "../../modules/deployments/deployment-lifecycle";
import type { DeploymentMeta } from "../deployment-runtime";
import { reapplyProjectLiveRoutes } from "../../modules/domains/project-route.service";
import { describeTlsIssuedElsewhere, manageDomainSsl, tlsIssuedElsewhere } from "../domain-ssl";
import { refreshSelfAppPublicUrl } from "../public-url";
} from "@repo/platform/engine/modules/deployments/build.service";
import { onSuccess } from "@repo/platform/engine/modules/deployments/deployment-lifecycle";
import type { DeploymentMeta } from "@repo/platform/engine/lib/deployment-runtime";
import { reapplyProjectLiveRoutes } from "@repo/platform/engine/modules/domains/project-route.service";
import { describeTlsIssuedElsewhere, manageDomainSsl, tlsIssuedElsewhere } from "@repo/platform/engine/lib/domain-ssl";
import { refreshSelfAppPublicUrl } from "@repo/platform/engine/lib/public-url";
const APP_SLUG = "openship";
const APP_TEMPLATE_ID = "openship";
@@ -200,7 +200,7 @@ async function foreignProxyBlocksEdge(
): Promise<{ blocked: boolean; owner?: string; detail?: string }> {
try {
const { foreignProxyOnEdge } = await import("@repo/adapters");
const { sshManager } = await import("../ssh-manager");
const { sshManager } = await import("@repo/platform/engine/lib/ssh-manager");
// Probe the HOST's :80/:443, not the api container's netns — the host channel is
// LocalExecutor bare, SSH→host when containerized (OPENSHIP_HOST_SSH_*). Pooled,
// so there's nothing to dispose (see withHostExecutor).
@@ -461,7 +461,7 @@ export function registerSelfAdoptReconcile(): void {
if (isLinuxRoot()) {
try {
const { recoverInterruptedTakeover } = await import("@repo/adapters");
const { sshManager } = await import("../ssh-manager");
const { sshManager } = await import("@repo/platform/engine/lib/ssh-manager");
// Recover takeover on the HOST (local bare, SSH→host containerized).
await sshManager.withHostExecutor((exec) =>
recoverInterruptedTakeover(exec, (e) => console.log(`[self-deploy] ${e.message}`)),
+1 -1
View File
@@ -47,7 +47,7 @@ vi.mock("@repo/adapters", async () => {
// The build-only APPLY (build the edge from source onto the local daemon before
// bring-up) has its own unit tests — here it's a no-op so these cases stay about
// the halt-and-report contract, not the deliver pipeline.
vi.mock("../deliver-managed-image", () => ({
vi.mock("@repo/platform/engine/lib/deliver-managed-image", () => ({
deliverManagedImage: vi.fn(async () => ({ delivered: false })),
}));
+4 -4
View File
@@ -14,9 +14,9 @@
* elsewhere.
*/
import { env } from "../../config/env";
import { pinnedEdgeImage, withPinnedEdgeImage } from "../edge-image";
import { resolveAcmeProviderOptions } from "../acme-config";
import { env } from "@repo/platform/engine/config/env";
import { pinnedEdgeImage, withPinnedEdgeImage } from "@repo/platform/engine/lib/edge-image";
import { resolveAcmeProviderOptions } from "@repo/platform/engine/lib/acme-config";
export interface SelfEdgeInfraProgress {
onLog?: (message: string, level?: "info" | "warn" | "error") => void;
@@ -137,7 +137,7 @@ async function runEnsure(
// Lazy, like @repo/adapters above: deliver pulls in the deploy runtime (db, ssh,
// dockerode), which must stay off the boot path on the topologies that skip early.
const { deliverManagedImage } = await import("../deliver-managed-image");
const { deliverManagedImage } = await import("@repo/platform/engine/lib/deliver-managed-image");
// Stage-B APPLY, build-only: this host IS the target, so build the edge from our
// source onto the local daemon before either bring-up path pulls the pinned tag.
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import type { DockerContainerSummary } from "@repo/adapters";
import { findOwnStack, portSpecs } from "./self-services";
import { findOwnStack, portSpecs } from "@repo/platform/engine/lib/startup/self-services";
const container = (
over: Partial<DockerContainerSummary> & { id: string },
+1 -1
View File
@@ -3,7 +3,7 @@ import {
hasSourceBuildRecipe,
isStaticService,
resolveSubAppRecipe,
} from "./deployable-service";
} from "@repo/platform/engine/lib/deployable-service";
/**
* A static sub-app with NO build command must be deployable — WITHOUT loosening
+3 -3
View File
@@ -23,11 +23,11 @@
*/
import { randomBytes } from "node:crypto";
import { env } from "../config/env";
import { env } from "@repo/platform/engine/config/env";
import type { ShellSession } from "@repo/adapters";
import type { TerminalExitReason } from "@repo/db";
import type { RequestContext } from "./request-context";
import { sshManager } from "./ssh-manager";
import type { ExecutionContext as RequestContext } from "@repo/platform";
import { sshManager } from "@repo/platform/engine/lib/ssh-manager";
// ─── Tickets ────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -5,7 +5,7 @@ import {
resolveRouteStrategy,
resolveUpstreamUrl,
usesHostLoopbackUpstream,
} from "./upstream-url";
} from "@repo/platform/engine/lib/upstream-url";
/** A docker-shaped runtime whose live inspect we control. */
function dockerRuntime(opts: {
@@ -47,12 +47,12 @@ vi.mock("@repo/adapters", async () => {
vi.mock("./controller-helpers", () => ({ platform: () => ({ target: "selfhosted" }) }));
vi.mock("@repo/db", () => ({ repos: { service: { listByDeployment: async () => [] } } }));
vi.mock("./cloud/client", () => ({ cloudClient: {}, getOrgCloudToken: async () => null }));
vi.mock("./cloud/transport", () => ({ resolveOrgCloudUserId: async () => null }));
vi.mock("./ssh-manager", () => ({ buildSshConfig: async () => null, sshManager: {} }));
vi.mock("./provision-lock", () => ({ createProvisionLock: () => ({}) }));
vi.mock("./box-org", () => ({ isLocalHostRow: async () => true }));
vi.mock("./acme-config", () => ({ resolveAcmeProviderOptions: () => ({}) }));
vi.mock("@repo/platform/engine/lib/cloud/client", () => ({ cloudClient: {}, getOrgCloudToken: async () => null }));
vi.mock("@repo/platform/engine/lib/cloud/transport", () => ({ resolveOrgCloudUserId: async () => null }));
vi.mock("@repo/platform/engine/lib/ssh-manager", () => ({ buildSshConfig: async () => null, sshManager: {} }));
vi.mock("@repo/platform/engine/lib/provision-lock", () => ({ createProvisionLock: () => ({}) }));
vi.mock("@repo/platform/engine/lib/box-org", () => ({ isLocalHostRow: async () => true }));
vi.mock("@repo/platform/engine/lib/acme-config", () => ({ resolveAcmeProviderOptions: () => ({}) }));
const dep = { meta: {}, organizationId: "org_1" };
@@ -63,7 +63,7 @@ describe("withDeploymentRuntime", () => {
});
it("returns the action's value and disposes the transport", async () => {
const { withDeploymentRuntime } = await import("./deployment-runtime");
const { withDeploymentRuntime } = await import("@repo/platform/engine/lib/deployment-runtime");
await expect(withDeploymentRuntime(dep, async () => "logs")).resolves.toBe("logs");
@@ -72,7 +72,7 @@ describe("withDeploymentRuntime", () => {
});
it("disposes the transport when the action throws", async () => {
const { withDeploymentRuntime } = await import("./deployment-runtime");
const { withDeploymentRuntime } = await import("@repo/platform/engine/lib/deployment-runtime");
await expect(
withDeploymentRuntime(dep, async () => {
@@ -84,7 +84,7 @@ describe("withDeploymentRuntime", () => {
});
it("maps a refused SSH key to 503 HOST_UNREACHABLE, keeping the reason", async () => {
const { withDeploymentRuntime } = await import("./deployment-runtime");
const { withDeploymentRuntime } = await import("@repo/platform/engine/lib/deployment-runtime");
const reason =
"SSH key authentication failed for root@65.109.55.23. Check the username, private key, " +
"passphrase, or whether the server accepts this key. (All configured authentication methods failed)";
@@ -105,7 +105,7 @@ describe("withDeploymentRuntime", () => {
["Channel open failure: open failed"],
["Command timed out after 30000ms"],
])("maps transport failure %j to 503", async (message) => {
const { withDeploymentRuntime } = await import("./deployment-runtime");
const { withDeploymentRuntime } = await import("@repo/platform/engine/lib/deployment-runtime");
const err = await withDeploymentRuntime(dep, async () => {
throw new Error(message);
@@ -115,7 +115,7 @@ describe("withDeploymentRuntime", () => {
});
it("leaves an ordinary failure alone — no invented 503", async () => {
const { withDeploymentRuntime } = await import("./deployment-runtime");
const { withDeploymentRuntime } = await import("@repo/platform/engine/lib/deployment-runtime");
const err = await withDeploymentRuntime(dep, async () => {
throw new Error("(HTTP code 404) no such container: abc123");
@@ -132,14 +132,19 @@ describe("deploymentContainerIds", () => {
vi.doMock("@repo/db", () => ({
repos: { service: { listByDeployment: async () => [{ containerId: "svc-a" }, { containerId: null }] } },
}));
const { deploymentContainerIds } = await import("./deployment-runtime");
const { deploymentContainerIds } = await import("@repo/platform/engine/lib/deployment-runtime");
expect(await deploymentContainerIds({ id: "dep_1", containerId: "app" })).toEqual(["svc-a"]);
vi.resetModules();
vi.doMock("@repo/db", () => ({ repos: { service: { listByDeployment: async () => [] } } }));
const fresh = await import("./deployment-runtime");
const fresh = await import("@repo/platform/engine/lib/deployment-runtime");
expect(await fresh.deploymentContainerIds({ id: "dep_1", containerId: "app" })).toEqual(["app"]);
expect(await fresh.deploymentContainerIds({ id: "dep_1", containerId: null })).toEqual([]);
});
});
// The application seams moved with the shared engine.
vi.mock("@repo/platform/engine/lib/platform-config", () => ({ platform: () => ({ target: "selfhosted" }) }));
vi.mock("@repo/platform/engine/lib/resource-access", () => ({ platform: () => ({ target: "selfhosted" }) }));
+26 -4
View File
@@ -1,12 +1,13 @@
import type { Context, Next } from "hono";
import { randomUUID } from "node:crypto";
import { repos } from "@repo/db";
import { auth } from "../lib/auth";
import { env, trustedOrigins } from "../config/env";
import { SDK_SCOPE_HEADER, ValidationError } from "@repo/contracts";
import { auth } from "@repo/platform/engine/lib/auth";
import { env, trustedOrigins } from "@repo/platform/engine/config/env";
import { ensureLocalUser } from "../lib/local-user";
import { resolveActiveOrganizationId } from "./active-organization";
import { zeroAuthAllowed } from "./zero-auth-guard";
import { hashPatToken } from "../lib/pat";
import { hashPatToken } from "@repo/platform/engine/lib/pat";
import { isPatToken, parseBearerToken } from "../lib/bearer";
import {
buildRequestContext,
@@ -125,6 +126,7 @@ async function finishBearer(
boundOrg: string | null,
patScope: { tokenId: string; scoped: boolean } | undefined,
principalKind: PrincipalKind,
readOnly: boolean,
): Promise<Response | typeof PAT_HANDLED> {
const applied = await applyAuthedRequest(
c,
@@ -133,6 +135,7 @@ async function finishBearer(
"bearer",
patScope,
principalKind,
{ organizationId: boundOrg, readOnly },
);
if (!applied) {
return c.json({ error: "Invalid or expired access token", code: "INVALID_TOKEN" }, 401);
@@ -302,6 +305,7 @@ async function tryBearerAuth(
resolved.organizationId,
patScope,
resolved.kind,
resolved.readOnly,
);
}
@@ -410,8 +414,24 @@ async function applyAuthedRequest(
sessionKind: SessionKind,
patScope?: { tokenId: string; scoped: boolean },
principalKind?: PrincipalKind,
credential?: { organizationId: string | null; readOnly: boolean },
): Promise<boolean> {
const orgId = await resolveActiveOrganizationId(user.id, session?.activeOrganizationId ?? null);
const scopeHeader = c.req.header(SDK_SCOPE_HEADER)?.trim().toLowerCase();
if (scopeHeader !== undefined && scopeHeader !== "fixed") {
throw new ValidationError(`${SDK_SCOPE_HEADER} must be 'fixed' when provided`);
}
const fixedScope = scopeHeader === "fixed";
const requestedOrg = c.req.header("X-Organization-Id")?.trim();
if (fixedScope && !requestedOrg) {
throw new ValidationError("X-Organization-Id is required for fixed organization scope");
}
// A credential binding is not a UX default. If that membership disappeared,
// fail authentication instead of falling back to another organization.
const orgId =
credential?.organizationId ??
(fixedScope && requestedOrg
? requestedOrg
: await resolveActiveOrganizationId(user.id, session?.activeOrganizationId ?? null));
if (!orgId) return false;
const membership = await repos.member.find(orgId, user.id);
@@ -450,6 +470,8 @@ async function applyAuthedRequest(
sessionKind,
principalKind: principalKind ?? null,
tokenScope: patScope?.scoped ? { tokenId: patScope.tokenId } : null,
credential: credential ?? null,
scopeMode: fixedScope ? "fixed" : "resource",
clientIp,
userAgent,
traceId: randomUUID(),
@@ -1,5 +1,5 @@
import type { Context, Next } from "hono";
import { auth } from "../lib/auth";
import { auth } from "@repo/platform/engine/lib/auth";
import { repos } from "@repo/db";
/**
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Context, Next } from "hono";
import { env } from "../config/env";
import { env } from "@repo/platform/engine/config/env";
import { isLoopbackPeer, peerAddress } from "./loopback-peer";
declare module "hono" {
+4
View File
@@ -1,6 +1,7 @@
import type { Context } from "hono";
import { ZodError } from "zod";
import { AppError } from "@repo/core";
import { OperationError } from "@repo/contracts";
import { redactSensitiveRequestPath } from "../lib/request-log-redaction";
/**
@@ -56,6 +57,9 @@ export function handleApiError(err: unknown, c: Context) {
if (statusCode >= 500) console.error(`[API ERROR] ${requestTag(c)}`, err);
return c.json(
{
// Only application failures explicitly carrying public recovery data may
// add fields. Provider errors never expose their arbitrary object graph.
...(err instanceof OperationError ? err.details : {}),
error: message,
code,
// A plan refusal carries structured detail the client needs to be
+4 -22
View File
@@ -27,29 +27,12 @@
*/
import type { Context, Next } from "hono";
import { ForbiddenError } from "@repo/core";
import { db, schema, eq } from "@repo/db";
import { instanceAuthorization } from "../lib/instance-authorization";
import { getRequestContext, type RequestContext } from "../lib/request-context";
const DENIED = "Requires an instance administrator";
/** True when this principal is an admin OF THE INSTANCE (not of any org). */
async function isInstanceAdmin(ctx: RequestContext): Promise<boolean> {
// A scoped token must never carry instance-takeover capability, whoever owns
// it — a narrowly-granted PAT reaching a whole-instance export would defeat
// the point of scoping. Unscoped PATs (how the CLI authenticates) still pass.
if (ctx.tokenScope) return false;
const [row] = await db
.select({ role: schema.user.role })
.from(schema.user)
.where(eq(schema.user.id, ctx.userId))
.limit(1);
return row?.role === "admin";
}
/**
* Route middleware: 403 unless the caller is an instance administrator.
*
@@ -66,7 +49,8 @@ export function requireInstanceAdmin() {
return c.json({ error: "Unauthorized" }, 401);
}
if (!(await isInstanceAdmin(ctx))) {
const action = c.req.method === "GET" || c.req.method === "HEAD" ? "read" : "write";
if (!(await instanceAuthorization.allows(ctx, action))) {
return c.json({ error: DENIED, code: "INSUFFICIENT_INSTANCE_ROLE" }, 403);
}
@@ -80,7 +64,5 @@ export function requireInstanceAdmin() {
* middleware. Throws ForbiddenError (403).
*/
export async function assertInstanceAdmin(ctx: RequestContext): Promise<void> {
if (!(await isInstanceAdmin(ctx))) {
throw new ForbiddenError(DENIED);
}
await instanceAuthorization.assert(ctx);
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { timingSafeEqual } from "node:crypto";
import type { Context, Next } from "hono";
import { env } from "../config";
import { env } from "@repo/platform/engine/config/index";
import { isLoopbackRequest, peerAddress } from "./loopback-peer";
/**
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Context, Next } from "hono";
import { env } from "../config";
import { env } from "@repo/platform/engine/config/index";
/**
* Middleware that restricts a route to self-hosted instances only.
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Context, Next } from "hono";
import { auth } from "../lib/auth";
import { auth } from "@repo/platform/engine/lib/auth";
import { isAllowedMcpResource, publicOriginFor, publicRequestUrl } from "../lib/mcp-resource";
/**
+1 -1
View File
@@ -27,7 +27,7 @@
*/
import type { MiddlewareHandler } from "hono";
import { env } from "../config/env";
import { env } from "@repo/platform/engine/config/env";
import { isMigrationInProgress } from "../modules/system/migration/migration-lock";
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Context, Next } from "hono";
import { trustedOrigins } from "../config/env";
import { trustedOrigins } from "@repo/platform/engine/config/env";
/**
* CSRF defence via Origin-header check.
+1 -1
View File
@@ -29,7 +29,7 @@ import { isLoopbackPeer, peerAddress } from "./loopback-peer";
import { rateLimit, type PolicyId } from "../lib/rate-limit";
import { POLICIES } from "../lib/rate-limit/policies";
import { getRequestContext } from "../lib/request-context";
import { env } from "../config";
import { env } from "@repo/platform/engine/config/index";
function resolveSubjectId(c: Context, subject: "ip" | "user" | "org" | "global"): string | null {
if (subject === "global") return "global";
+2 -2
View File
@@ -1,6 +1,6 @@
import type { Context } from "hono";
import { env } from "../config/env";
import { getAuthMode } from "../lib/auth-mode";
import { env } from "@repo/platform/engine/config/env";
import { getAuthMode } from "@repo/platform/engine/lib/auth-mode";
import { isLoopbackRequest, peerAddress } from "./loopback-peer";
/**
@@ -1,296 +1,56 @@
/**
* Analytics controller - handlers for analytics + usage + stats endpoints.
*/
/** HTTP envelopes over shared analytics operations and owned usage streams. */
import type { Context } from "hono";
import { streamSSE } from "../../lib/sse";
import { getPlatformKernel } from "@repo/platform/engine/lib/platform";
import { operationContext, operationData } from "../../lib/operation-context";
import { operationEvents } from "../../lib/operation-stream";
import { param } from "../../lib/controller-helpers";
import { getRequestContext } from "../../lib/request-context";
import { sshManager } from "../../lib/ssh-manager";
import { repos } from "@repo/db";
import * as analyticsService from "./analytics.service";
import * as geoService from "./geo.service";
import { collectProjectUsage, openProjectUsageSampler } from "../monitoring/project-usage";
import { getProjectUsageHistory } from "../monitoring/usage-history";
import { fetchMgmt } from "../../lib/project-analytics";
import { scrapeServerIfStale } from "../system/analytics-scraper";
import { permission } from "../../lib/permission";
import { assertResourceInOrg } from "../../lib/controller-helpers";
import { pushProjectAnalyticsConfig } from "./analytics-config.service";
import { resolveProjectPushTarget } from "../route-rules/route-rule.service";
import type { TAnalyticsQuery, TUsageQuery, TUsageStreamQuery } from "./analytics.schema";
// ─── Request analytics ───────────────────────────────────────────────────────
const analytics = () => getPlatformKernel().analytics;
const projectId = (c: Context) => c.req.query("projectId") ?? "";
const range = (c: Context) => ({ from: c.req.query("from"), to: c.req.query("to"), domain: c.req.query("domain") });
/** GET /analytics - cumulative summary */
export async function summary(c: Context) {
const ctx = getRequestContext(c);
const { projectId, domain } = c.req.query() as unknown as TAnalyticsQuery;
// Slice the single fetch+compute overview (last 24h) to just its summary.
const data = (await analyticsService.getAnalyticsOverview(ctx, projectId, undefined, undefined, domain)).summary;
return c.json({ data });
return c.json({ data: await operationData(c, analytics().summary(operationContext(c), projectId(c), { domain: c.req.query("domain") })) });
}
/** GET /analytics/periods - time-series periods */
export async function periods(c: Context) {
const ctx = getRequestContext(c);
const { projectId, from, to, domain } = c.req.query() as unknown as TAnalyticsQuery;
// Slice the single fetch+compute overview to just its time-series periods.
const data = (await analyticsService.getAnalyticsOverview(ctx, projectId, from, to, domain)).periods;
return c.json({ data });
return c.json({ data: await operationData(c, analytics().periods(operationContext(c), projectId(c), range(c))) });
}
/**
* GET /analytics/overview - summary + periods together, from ONE underlying
* traffic fetch. The dashboard reads this so a project view makes a single
* cloud round-trip instead of two (separate /summary + /periods).
*
* `domain` scopes the numbers to a single tracked domain (multi-domain
* projects); omitted, it aggregates every domain like before.
*/
export async function overview(c: Context) {
const ctx = getRequestContext(c);
const { projectId, from, to, domain } = c.req.query() as unknown as TAnalyticsQuery;
const data = await analyticsService.getAnalyticsOverview(ctx, projectId, from, to, domain);
return c.json({ data });
return c.json({ data: await operationData(c, analytics().overview(operationContext(c), projectId(c), range(c))) });
}
// ─── Deployment stats ────────────────────────────────────────────────────────
/**
* GET /analytics/geo - visitor geography + daily rollup for a project.
*
* Mode-agnostic to the caller: self-hosted reads the scraped archive plus a live
* edge tail, cloud reads through to Oblien. Both return the same shape, so the
* country map has one contract.
*/
export async function projectGeo(c: Context) {
const ctx = getRequestContext(c);
const { projectId, from, to, domain } = c.req.query() as unknown as TAnalyticsQuery;
const data = await geoService.getProjectGeo(ctx, projectId, from, to, domain);
return c.json({ data });
return c.json({ data: await operationData(c, analytics().geo(operationContext(c), projectId(c), range(c))) });
}
/**
* POST /analytics/paths-collection - turn per-path aggregation on or off.
*
* Persists first, then pushes to the edge. That order matters: the shared dict is RAM and
* is re-pushed from the row on every route apply, so a push that fails is corrected by the
* next apply — whereas a push that succeeded against an unsaved row would be silently
* reverted by that same apply.
*/
export async function setPathsCollection(c: Context) {
const ctx = getRequestContext(c);
// Path param, not ?projectId= — the project:write route resolver already asserted
// write on this id from the URL, so the assert below is defense-in-depth, not the gate.
const id = c.req.param("projectId") ?? "";
await permission.assert(ctx, { resourceType: "project", resourceId: id, action: "write" });
const project = await repos.project.findById(id);
assertResourceInOrg(project, "Project", ctx.organizationId, id);
const body = await c.req.json().catch(() => ({}));
const enabled = body?.enabled === true;
await repos.project.update(id, { collectPaths: enabled });
const target = await resolveProjectPushTarget(id);
if (target) {
await pushProjectAnalyticsConfig(id, target.serverId).catch(() => {});
}
return c.json({ data: { enabled } });
}
/** GET /analytics/deployments - deployment success/fail/avg build stats */
export async function deploymentStats(c: Context) {
const ctx = getRequestContext(c);
const { projectId } = c.req.query() as unknown as TAnalyticsQuery;
const data = await analyticsService.getDeploymentStats(ctx, projectId);
return c.json({ data });
return c.json({ data: await operationData(c, analytics().deploymentStats(operationContext(c), projectId(c))) });
}
// ─── Resource usage ──────────────────────────────────────────────────────────
/**
* GET /analytics/usage - current resource usage, flat ResourceUsage shape.
*
* Backed by the same collector as /analytics/resources and returning its `overall`,
* so on a compose project this is now the WHOLE stack rather than whichever service
* happened to own `deployment.containerId`. Kept as its own route because existing
* callers expect the flat shape, not the per-service envelope.
*/
export async function usage(c: Context) {
const ctx = getRequestContext(c);
const { projectId } = c.req.query() as unknown as TUsageQuery;
const collected = await collectProjectUsage(ctx, projectId);
// Null rather than zeros when unmeasurable — the previous contract for "no active
// deployment" was also null, and zeros would read as a genuinely idle app.
return c.json({ data: collected.supported ? collected.overall : null });
return c.json({ data: await operationData(c, analytics().usage(operationContext(c), projectId(c))) });
}
/** GET /analytics/container - container info (status, IP, uptime) */
export async function containerInfo(c: Context) {
const ctx = getRequestContext(c);
const { projectId } = c.req.query() as unknown as TUsageQuery;
const data = await analyticsService.getContainerInfo(ctx, projectId);
return c.json({ data });
return c.json({ data: await operationData(c, analytics().containerInfo(operationContext(c), projectId(c))) });
}
/**
* GET /analytics/resources - one-shot project resource usage (overall + per-service).
*/
export async function resources(c: Context) {
const ctx = getRequestContext(c);
const { projectId } = c.req.query() as unknown as TUsageQuery;
const data = await collectProjectUsage(ctx, projectId);
return c.json({ data });
return c.json({ data: await operationData(c, analytics().resources(operationContext(c), projectId(c))) });
}
export async function setPathsCollection(c: Context) {
return c.json({ data: await operationData(c, analytics().setPathsCollection(operationContext(c), param(c, "projectId"), await c.req.json())) });
}
/**
* GET /analytics/usage/history - resource usage over time.
*
* `serviceKey` omitted = All (the per-bucket sum across services). The live stream
* next door answers "right now"; this answers "was memory climbing before the OOM".
*/
export async function usageHistory(c: Context) {
const ctx = getRequestContext(c);
const { projectId, from, to, serviceKey } = c.req.query() as unknown as TUsageQuery & {
serviceKey?: string;
};
const data = await getProjectUsageHistory(ctx, projectId, { from, to, serviceKey });
return c.json({ data });
return c.json({ data: await operationData(c, analytics().usageHistory(operationContext(c), projectId(c), { from: c.req.query("from"), to: c.req.query("to"), serviceKey: c.req.query("serviceKey") })) });
}
/**
* GET /analytics/usage/stream - SSE stream of real-time resource usage.
*
* Emits the WHOLE project each tick — overall totals plus one entry per service —
* so the card and the per-service dots share a single connection and can never
* disagree about which tick they're showing. It used to stream one container
* (`deployment.containerId`), which on a compose project is just the primary
* service.
*
* The runtime is resolved ONCE for the life of the stream (see
* openProjectUsageSampler) instead of per tick, and via the read-only resolver so
* a polled read never contends on the provision lock.
*/
export async function usageStream(c: Context) {
const ctx = getRequestContext(c);
const { projectId } = c.req.query() as unknown as TUsageStreamQuery;
const sampler = await openProjectUsageSampler(ctx, projectId);
if ("error" in sampler) return c.json({ error: sampler.error }, 404);
const { serverId, sample, close } = sampler;
return streamSSE(c, async (sseStream) => {
if (serverId) sshManager.retain(serverId);
const intervalMs = 5_000;
const ac = new AbortController();
sseStream.onAbort(() => ac.abort());
try {
while (!ac.signal.aborted) {
try {
await sseStream.writeSSE({ event: "usage", data: JSON.stringify(await sample()) });
} catch {
if (ac.signal.aborted) break;
await sseStream.writeSSE({
event: "error",
data: JSON.stringify({ error: "Failed to fetch usage" }),
});
}
// Abort-aware sleep - resolves immediately on disconnect
await new Promise<void>((resolve) => {
if (ac.signal.aborted) return resolve();
const timer = setTimeout(resolve, intervalMs);
ac.signal.addEventListener("abort", () => { clearTimeout(timer); resolve(); }, { once: true });
});
}
} finally {
await close();
if (serverId) sshManager.release(serverId);
}
});
return operationEvents(c, signal => analytics().openUsageStream(operationContext(c), projectId(c), { signal }));
}
// ─── Dashboard ───────────────────────────────────────────────────────────────
/** GET /analytics/dashboard - overview stats for the active org's dashboard */
export async function dashboard(c: Context) {
const ctx = getRequestContext(c);
const data = await analyticsService.getDashboardStats(ctx);
return c.json({ data });
return c.json({ data: await operationData(c, analytics().dashboard(operationContext(c))) });
}
// ─── Server analytics (OpenResty scraped data) ───────────────────────────────
/**
* GET /analytics/server/:serverId - persisted minute-bucket analytics.
* Query: ?domain=&from=&to= (ISO timestamps or epoch minutes)
*/
export async function serverAnalytics(c: Context) {
const serverId = param(c, "serverId");
const domain = c.req.query("domain");
if (!domain) return c.json({ error: "domain query param is required" }, 400);
// Viewing analytics IS what drives a scrape — no background interval. Fire
// and forget (self-throttled); this read returns current DB rows and the
// fresh buckets land for the next read/refresh while the page stays open.
void scrapeServerIfStale(serverId);
const now = Math.floor(Date.now() / 60_000);
const fromParam = c.req.query("from");
const toParam = c.req.query("to");
const fromMinute = fromParam
? (fromParam.includes("-") ? Math.floor(new Date(fromParam).getTime() / 60_000) : Number(fromParam))
: now - 60;
const toMinute = toParam
? (toParam.includes("-") ? Math.floor(new Date(toParam).getTime() / 60_000) : Number(toParam))
: now;
const buckets = await repos.analytics.queryBuckets({
serverId,
domain,
fromMinute,
toMinute,
});
return c.json({ data: buckets });
return c.json({ data: await operationData(c, analytics().serverBuckets(operationContext(c), param(c, "serverId"), { domain: c.req.query("domain") ?? "", from: c.req.query("from"), to: c.req.query("to") })) });
}
/**
* GET /analytics/server/:serverId/geo - daily geo aggregates from DB.
* Query: ?domain=&day=YYYYMMDD
*/
export async function serverGeo(c: Context) {
const serverId = param(c, "serverId");
const domain = c.req.query("domain");
if (!domain) return c.json({ error: "domain query param is required" }, 400);
// On-demand scrape (self-throttled, deduped with serverAnalytics).
void scrapeServerIfStale(serverId);
const day = c.req.query("day") ?? new Date().toISOString().slice(0, 10).replace(/-/g, "");
const geo = await repos.analytics.queryGeo({ serverId, domain, day });
return c.json({ data: geo ?? { countries: {} } });
return c.json({ data: await operationData(c, analytics().serverGeo(operationContext(c), param(c, "serverId"), { domain: c.req.query("domain") ?? "", day: c.req.query("day") })) });
}
/**
* GET /analytics/server/:serverId/live - proxy live analytics from the
* management API on the server (via SSH). Returns real-time data that
* hasn't been scraped to DB yet.
* Query: ?domain=
*/
export async function serverAnalyticsLive(c: Context) {
const serverId = param(c, "serverId");
const domain = c.req.query("domain");
if (!domain) return c.json({ error: "domain query param is required" }, 400);
const data = await fetchMgmt(serverId, `/analytics/totals?domain=${encodeURIComponent(domain)}`);
if (!data) {
return c.json({ error: "Failed to reach server management API" }, 502);
}
return c.json({ data });
return c.json({ data: await operationData(c, analytics().serverLive(operationContext(c), param(c, "serverId"), { domain: c.req.query("domain") ?? "" })) });
}
@@ -6,6 +6,7 @@
*/
import { Hono } from "hono";
import { AnalyticsProjectSchemas } from "@repo/contracts";
import { secureRouter } from "../../lib/secure-router";
import { cloudProjectProxy, cloudProjectProxyByQuery } from "../../lib/cloud/project-router";
import * as ctrl from "./analytics.controller";
@@ -34,7 +35,7 @@ r.get("/geo", { tag: "analytics:read", mcp: { description: "Visitor geography fo
that resolver reads a URL param. As a query param it fell through to the else-branch's
`:id` lookup and 400'd "Missing route param :id". `cloudProjectProxy` keys off the same
`:projectId`, so cloud projects still proxy to the SaaS. */
r.post("/paths-collection/:projectId", { tag: "project:write", ids: { project: "projectId" }, mcp: { description: "Turn per-path request aggregation (Top Paths) on or off for a project." } }, cloudProjectProxy, ctrl.setPathsCollection);
r.post("/paths-collection/:projectId", { tag: "project:write", body: AnalyticsProjectSchemas.setPathsCollection.input, auditHandledByOperation: true, ids: { project: "projectId" }, mcp: { description: "Turn per-path request aggregation (Top Paths) on or off for a project." } }, cloudProjectProxy, ctrl.setPathsCollection);
/* ─── Deployment stats ─────────────────────────────────────────────────── */
r.get("/deployments", { tag: "analytics:read", mcp: { description: "Deployment statistics (frequency, success rate, durations)." } }, cloudProjectProxyByQuery, ctrl.deploymentStats);
@@ -19,21 +19,21 @@ const h = vi.hoisted(() => ({
custom: [] as unknown[],
}));
vi.mock("./catalog-source", () => ({
vi.mock("@repo/platform/engine/modules/apps/catalog-source", () => ({
getRuntimeCatalog: () => h.runtime,
listOrgCustomApps: async () => h.custom,
getTemplateForOrg: async () => undefined,
}));
vi.mock("@repo/db", () => ({ repos: {} }));
vi.mock("../projects/project-crud.service", () => ({ createProject: vi.fn() }));
vi.mock("../services/service.service", () => ({
vi.mock("@repo/platform/engine/modules/projects/project-crud.service", () => ({ createProject: vi.fn() }));
vi.mock("@repo/platform/engine/modules/services/service.service", () => ({
createService: vi.fn(),
updateService: vi.fn(),
setServiceEnvVars: vi.fn(),
}));
const { getAppCatalog } = await import("./app-install.service");
const { getAppCatalog } = await import("@repo/platform/engine/modules/apps/app-install.service");
const ctx = { organizationId: "org1" } as RequestContext;
@@ -26,6 +26,7 @@ r.get(
"/",
{
tag: "project:write",
auditHandledByOperation: true,
mcp: { description: "Get an installed app's resolved connection details (URLs + generated keys)." },
},
cloudProjectProxy,
@@ -18,7 +18,7 @@ const h = vi.hoisted(() => ({
rows: {} as Record<string, { id: string; isLocal: boolean } | undefined>,
}));
vi.mock("./catalog-source", () => ({
vi.mock("@repo/platform/engine/modules/apps/catalog-source", () => ({
getTemplateForOrg: async (_org: string, id: string) => ({
id,
minResources: { memoryMb: 2048 },
@@ -41,11 +41,11 @@ vi.mock("@repo/db", async (importOriginal) => ({
// The same predicate the deploy path uses; keyed off the flag here so the test
// doesn't depend on loopback resolution or env.
vi.mock("../../lib/box-org", () => ({
vi.mock("@repo/platform/engine/lib/box-org", () => ({
isLocalHostRow: async (row: { isLocal?: boolean }) => Boolean(row?.isLocal),
}));
vi.mock("../../lib/host-capacity", () => ({
vi.mock("@repo/platform/engine/lib/host-capacity", () => ({
getTrustedHostCapacity: async (
serverId: string | undefined,
_org: string,
@@ -56,7 +56,7 @@ vi.mock("../../lib/host-capacity", () => ({
},
}));
const { getAppHostFit } = await import("./app-install.service");
const { getAppHostFit } = await import("@repo/platform/engine/modules/apps/app-install.service");
const ctx = { userId: "u1", organizationId: "org1" } as never;
const fit = (target: { deployTarget?: string; serverId?: string }) =>
@@ -10,7 +10,7 @@ import { Hono } from "hono";
import { secureRouter } from "../../lib/secure-router";
import { cloudProjectProxy } from "../../lib/cloud/project-router";
import * as ctrl from "./app.controller";
import { AppSettingsPatchBody } from "./app.schema";
import { AppSettingsPatchBody } from "@repo/contracts";
const r = secureRouter(new Hono(), {
module: "apps",
@@ -25,7 +25,7 @@ r.get(
);
r.patch(
"/",
{ tag: "project:write", body: AppSettingsPatchBody, mcp: { description: "Update an installed app's curated settings (safe env merge)." } },
{ tag: "project:write", body: AppSettingsPatchBody, auditHandledByOperation: true, mcp: { description: "Update an installed app's curated settings (safe env merge)." } },
cloudProjectProxy,
ctrl.patchSettings,
);
+22 -121
View File
@@ -1,144 +1,45 @@
/**
* Apps controller — the one-click app catalog + installer.
*/
/** HTTP paths and envelopes over shared catalog, installer, and project operations. */
import type { Context } from "hono";
import { AppError } from "@repo/core";
import { getRequestContext } from "../../lib/request-context";
import type { InstallAppInput } from "@repo/contracts";
import { getPlatformKernel } from "@repo/platform/engine/lib/platform";
import { param } from "../../lib/controller-helpers";
import {
getAppCatalog,
getAppHostFit,
installApp,
findOpenAppDraft,
type InstallAppRoute,
} from "./app-install.service";
import { getTemplateForOrg } from "./catalog-source";
import { saveCustomApp, listCustomApps, deleteCustomApp } from "./custom-app.service";
import {
getAppProjectSettings,
updateAppProjectSettings,
getAppConnectionView,
type AppSettingChange,
} from "./app-settings.service";
import { operationContext, operationData } from "../../lib/operation-context";
/** GET /api/apps/catalog — the installable app catalog for the Create-App UI
* (curated + this org's custom apps). */
export async function catalog(c: Context) {
const ctx = getRequestContext(c);
return c.json({ data: await getAppCatalog(ctx) });
return c.json({ data: await operationData(c, getPlatformKernel().apps.listCatalog(operationContext(c))) });
}
/**
* GET /api/apps/catalog/:id — the full resolved template for one app (curated or
* this org's custom app), so the wizard opens it without a redeploy. Static
* config metadata only — no secrets (those are minted at install).
*
* Also reports this org's OPEN (never-deployed) draft of the app, because an
* install request for the same name adopts that draft: the wizard has to show the
* draft's stored configuration rather than template defaults, or Install quietly
* changes what the operator set up last time.
*/
export async function catalogEntry(c: Context) {
const ctx = getRequestContext(c);
const template = await getTemplateForOrg(ctx.organizationId, param(c, "id"));
if (!template) return c.json({ error: "Unknown app" }, 404);
return c.json({ data: template, draft: await findOpenAppDraft(ctx, template.id) });
const result = await operationData(c, getPlatformKernel().apps.getCatalogEntry(operationContext(c), param(c, "id")));
return c.json({ data: result.template, draft: result.draft });
}
/**
* GET /api/apps/catalog/:id/host-fit — does the chosen destination meet what this
* app declares it needs? Advisory: the wizard shows the shortfall next to the
* destination picker, and deploy preflight is what actually refuses. Query:
* `deployTarget` (server|cloud) and `serverId`. There is no "local": whether the
* destination is this box is derived from the server row, not claimed by the caller.
*/
export async function hostFit(c: Context) {
const ctx = getRequestContext(c);
return c.json({
data: await getAppHostFit(ctx, param(c, "id"), {
deployTarget: c.req.query("deployTarget") || undefined,
serverId: c.req.query("serverId") || undefined,
}),
});
return c.json({ data: await operationData(c, getPlatformKernel().apps.hostFit(operationContext(c), param(c, "id"), {
deployTarget: c.req.query("deployTarget") || undefined, serverId: c.req.query("serverId") || undefined,
})) });
}
/** POST /api/apps/custom — validate + store an uploaded app JSON as a per-org
* (unverified) custom app. Returns its id; then it appears in the catalog. */
export async function addCustom(c: Context) {
const ctx = getRequestContext(c);
const raw = await c.req.json<unknown>().catch(() => null);
if (raw == null || typeof raw !== "object") {
return c.json({ error: "Upload a JSON app definition." }, 400);
}
try {
return c.json({ data: await saveCustomApp(ctx, raw) });
} catch (err) {
return c.json({ error: err instanceof Error ? err.message : "Invalid app definition." }, 400);
}
const body = await c.req.json().catch(() => null);
if (body == null || typeof body !== "object") return c.json({ error: "Upload a JSON app definition." }, 400);
return c.json({ data: await operationData(c, getPlatformKernel().apps.saveCustom(operationContext(c), body)) });
}
/** GET /api/apps/custom — this org's custom apps. */
export async function listCustom(c: Context) {
const ctx = getRequestContext(c);
return c.json({ data: await listCustomApps(ctx) });
return c.json({ data: await operationData(c, getPlatformKernel().apps.listCustom(operationContext(c))) });
}
/** DELETE /api/apps/custom/:appId — remove a custom app from this org's catalog. */
export async function removeCustom(c: Context) {
const ctx = getRequestContext(c);
await deleteCustomApp(ctx, param(c, "appId"));
return c.json({ data: { ok: true } });
return c.json({ data: await operationData(c, getPlatformKernel().apps.removeCustom(operationContext(c), param(c, "appId"))) });
}
/** POST /api/apps — install an app from the catalog. */
export async function install(c: Context) {
const ctx = getRequestContext(c);
type InstallBody = {
templateId?: string;
name?: string;
config?: Record<string, string>;
routes?: InstallAppRoute[];
};
const body = await c.req.json<InstallBody>().catch((): InstallBody => ({}));
if (!body.templateId) {
return c.json({ error: "templateId is required" }, 400);
}
try {
const result = await installApp(ctx, {
templateId: body.templateId,
name: body.name,
config: body.config,
routes: body.routes,
});
return c.json({ data: result });
} catch (err) {
// A typed failure carries its own status + wire code (e.g. the free-domain
// CLOUD_REQUIRED_* 403 the dashboard maps back to a connect prompt) — let the
// central handler serialize it instead of flattening it to a bare 400.
if (err instanceof AppError) throw err;
const message = err instanceof Error ? err.message : "Failed to install app";
return c.json({ error: message }, 400);
}
const body = await c.req.json<InstallAppInput>().catch(() => null);
if (!body?.templateId) return c.json({ error: "templateId is required" }, 400);
return c.json({ data: await operationData(c, getPlatformKernel().apps.install(operationContext(c), body)) });
}
/** GET /api/projects/:id/app-settings — curated settings schema + current values. */
export async function getSettings(c: Context) {
const ctx = getRequestContext(c);
return c.json({ data: await getAppProjectSettings(ctx, param(c, "id")) });
return c.json({ data: await operationData(c, getPlatformKernel().projects.getAppSettings(operationContext(c), param(c, "id"))) });
}
/** PATCH /api/projects/:id/app-settings — update curated settings (safe env merge). */
export async function patchSettings(c: Context) {
const ctx = getRequestContext(c);
type Body = { changes?: AppSettingChange[] };
const body = await c.req.json<Body>().catch((): Body => ({}));
const changes = Array.isArray(body.changes) ? body.changes : [];
return c.json({ data: await updateAppProjectSettings(ctx, param(c, "id"), changes) });
const body = await c.req.json().catch(() => ({}));
return c.json({ data: await operationData(c, getPlatformKernel().projects.updateAppSettings(operationContext(c), param(c, "id"), body)) });
}
/** GET /api/projects/:id/app-connection — resolved connection details (URLs + keys). */
export async function getConnection(c: Context) {
const ctx = getRequestContext(c);
return c.json({ data: await getAppConnectionView(ctx, param(c, "id")) });
return c.json({ data: await operationData(c, getPlatformKernel().projects.getAppConnection(operationContext(c), param(c, "id"))) });
}
+4 -1
View File
@@ -8,7 +8,7 @@
import { Hono } from "hono";
import { secureRouter } from "../../lib/secure-router";
import * as ctrl from "./app.controller";
import { InstallAppBody, AddCustomAppBody } from "./app.schema";
import { InstallAppBody, AddCustomAppBody } from "@repo/contracts";
const r = secureRouter(new Hono(), {
module: "apps",
@@ -56,6 +56,7 @@ r.post(
tag: "project:write",
collection: true,
body: AddCustomAppBody,
auditHandledByOperation: true,
mcp: {
description:
"Add a custom app from an uploaded JSON definition (stored per-org, unverified).",
@@ -73,6 +74,7 @@ r.delete(
// Org scoping happens in the handler, exactly like the POST above.
collection: true,
mcp: { description: "Remove a custom app from this org's catalog." },
auditHandledByOperation: true,
},
ctrl.removeCustom,
);
@@ -83,6 +85,7 @@ r.post(
collection: true,
projectCreate: true,
body: InstallAppBody,
auditHandledByOperation: true,
mcp: {
description:
"Install an app from the catalog as a project (or return a flow route for wizard apps). Public hostnames come ONLY from `routes` — omit it and the app installs port-only (no domain is invented).",
@@ -5,8 +5,8 @@
* other jobs land on).
*/
import { getJobRunner } from "../../lib/job-runner";
import { pruneAuditEvents } from "./audit-prune";
import { getJobRunner } from "@repo/platform/engine/lib/job-runner/index";
import { pruneAuditEvents } from "@repo/platform/engine/modules/audit/audit-prune";
const AUDIT_PRUNE_JOB_ID = "audit:retention-prune";
const AUDIT_PRUNE_CRON = "17 3 * * *";
+19 -368
View File
@@ -1,375 +1,26 @@
/**
* Audit log API — mounted at /api/audit.
*
* GET /api/audit list events for the active organization
* GET /api/audit/facets filter options + per-tab counts for the UI
* GET /api/audit/settings recording switch + retention
* PATCH /api/audit/settings change them
*
* Filters on the list: `category` (expanded to its event types through the
* shared taxonomy — a category is not a column), `eventType`, `actorUserId`,
* `source`, `sourceClientId` (one MCP client, not just "an assistant"),
* `resourceType`, `resourceId`, `from`/`to`, and `q`.
*
* `q` is deliberately more than an `event_type LIKE`: rows store ids, so
* searching "api-gateway" resolves the term against project/server/domain names
* FIRST and passes the matching ids down as extra id predicates. Without that,
* the only searchable text in an audit row is the event type itself.
*
* All requests are scoped by the caller's active organization. `audit` is an
* org-singleton resource, so the route tags below resolve to exactly the
* `{audit, "*", read|write}` assertion the handlers used to make by hand:
* owners/admins allowed, members denied outright, restricted principals gated
* through an explicit grant.
*/
import { Hono } from "hono";
import type { Context } from "hono";
import { repos } from "@repo/db";
import {
AUDIT_CATEGORIES,
categoryForAuditEvent,
eventTypesForCategory,
isAuditCategoryId,
} from "@repo/core";
/** HTTP query/envelope adapters over the shared organization audit operations. */
import { Hono, type Context } from "hono";
import { AuditSettingsInput } from "@repo/contracts";
import { getPlatformKernel } from "@repo/platform/engine/lib/platform";
import { secureRouter } from "../../lib/secure-router";
import { getRequestContext } from "../../lib/request-context";
import { checkPermissionOnResource } from "../../lib/permission";
import { audit, auditContextFrom } from "../../lib/audit";
import { isAuditClientId, isAuditSource } from "../../lib/call-source";
import { operationContext, operationData } from "../../lib/operation-context";
const r = secureRouter(new Hono(), { module: "audit", basePath: "/api/audit" });
/** Retention windows the UI offers. Anything else is rejected. */
const RETENTION_CHOICES = [7, 30, 90, 180, 365] as const;
function parseDate(raw: string | undefined): Date | undefined {
if (!raw) return undefined;
const ms = Date.parse(raw);
return Number.isNaN(ms) ? undefined : new Date(ms);
}
/**
* Ids of named resources matching a free-text term, so `q` can find rows that
* only ever stored an opaque id. Capped per type; a term matching thousands of
* projects degrades to "matches the first 200", which is preferable to a
* predicate list long enough to slow the query down.
*/
async function resolveSearchResourceIds(organizationId: string, term: string): Promise<string[]> {
const [projects, servers, domains] = await Promise.all([
repos.project.searchIdsByName(organizationId, term).catch(() => []),
repos.server.searchIdsByName(organizationId, term).catch(() => []),
repos.domain.searchIdsByHostname(organizationId, term).catch(() => []),
]);
return Array.from(new Set([...projects, ...servers, ...domains]));
}
/** The filter set shared by the list and the facet counts. */
async function filtersFromQuery(c: Context, organizationId: string) {
const category = c.req.query("category");
const eventType = c.req.query("eventType");
const source = c.req.query("source");
const sourceClientId = c.req.query("sourceClientId");
const q = c.req.query("q")?.trim();
return {
eventType: eventType || undefined,
// An unknown category yields an empty list, which the repo ignores — the
// request degrades to unfiltered rather than 400-ing on a stale bookmark.
eventTypes:
category && category !== "all" && isAuditCategoryId(category)
? eventTypesForCategory(category)
: undefined,
actorUserId: c.req.query("actorUserId") || undefined,
resourceType: c.req.query("resourceType") || undefined,
resourceId: c.req.query("resourceId") || undefined,
source: source && isAuditSource(source) ? source : undefined,
// Shape-checked with the same predicate the writer uses, so a filter can only
// name something the column could hold. A malformed value degrades to
// unfiltered, matching how an unknown category behaves above.
sourceClientId: isAuditClientId(sourceClientId) ? sourceClientId : undefined,
from: parseDate(c.req.query("from")),
to: parseDate(c.req.query("to")),
q: q || undefined,
qResourceIds: q ? await resolveSearchResourceIds(organizationId, q) : undefined,
const audit = () => getPlatformKernel().audit;
function query(c: Context) {
const raw = c.req.query();
return { ...raw,
limit: raw.limit === undefined ? undefined : Math.min(Number(raw.limit), 200),
perPage: raw.perPage === undefined ? undefined : Math.min(Number(raw.perPage), 200),
page: raw.page === undefined ? undefined : Number(raw.page),
};
}
type AuditRow = Awaited<ReturnType<typeof repos.auditEvent.listByOrganization>>["rows"][number];
/**
* Attach `resourceName` to a page of rows: one batched lookup per resource type
* present, never one per row. Failures leave the name null — the UI falls back
* to a generic noun, which is worse than a name and much better than a 500.
*/
async function attachResourceNames(rows: AuditRow[]): Promise<Map<string, string>> {
const byType = new Map<string, Set<string>>();
for (const row of rows) {
if (!row.resourceType || !row.resourceId || row.resourceId === "*") continue;
const bucket = byType.get(row.resourceType) ?? new Set<string>();
bucket.add(row.resourceId);
byType.set(row.resourceType, bucket);
}
const names = new Map<string, string>();
const key = (type: string, id: string) => `${type}:${id}`;
await Promise.all(
Array.from(byType.entries()).map(async ([type, idSet]) => {
const ids = Array.from(idSet);
try {
switch (type) {
case "project": {
for (const r of await repos.project.listNamesByIds(ids)) names.set(key(type, r.id), r.name);
break;
}
case "server": {
for (const r of await repos.server.listNamesByIds(ids)) names.set(key(type, r.id), r.name);
break;
}
case "service": {
for (const r of await repos.service.listNamesByIds(ids)) names.set(key(type, r.id), r.name);
break;
}
case "domain": {
for (const r of await repos.domain.listByIds(ids)) names.set(key(type, r.id), r.hostname);
break;
}
case "job": {
for (const r of await repos.job.listNamesByIds(ids)) names.set(key(type, r.id), r.name);
break;
}
default:
break;
}
} catch (err) {
console.warn(`[audit] could not resolve ${type} names`, err);
}
}),
);
return names;
}
/**
* Names for `source_client_id` values — `oauth:<clientId>` → the registered MCP
* app's name, `pat:<tokenId>` → the token's name.
*
* Two batched lookups at most, in parallel, same as the resource resolver above.
* An unresolvable id (client deleted, token revoked and pruned) stays nameless
* and the UI falls back to the raw id: a row attributed to something that no
* longer exists is still evidence, and dropping it would be worse.
*/
async function resolveClientNames(ids: string[]): Promise<Map<string, string>> {
const names = new Map<string, string>();
if (ids.length === 0) return names;
const oauthIds: string[] = [];
const patIds: string[] = [];
for (const id of ids) {
if (id.startsWith("oauth:")) oauthIds.push(id.slice("oauth:".length));
else if (id.startsWith("pat:")) patIds.push(id.slice("pat:".length));
}
const [apps, tokens] = await Promise.all([
oauthIds.length ? repos.oauth.listApplicationsByClientIds(oauthIds).catch(() => []) : [],
patIds.length ? repos.personalAccessToken.listNamesByIds(patIds).catch(() => []) : [],
]);
for (const a of apps) names.set(`oauth:${a.clientId}`, a.name);
for (const t of tokens) names.set(`pat:${t.id}`, t.name);
return names;
}
r.get("/", { tag: "audit:read" }, async (c: Context) => {
const ctx = getRequestContext(c);
const cursor = c.req.query("cursor");
const limit = Math.min(Number(c.req.query("limit") ?? 50), 200);
const page = Number(c.req.query("page") ?? 1);
const perPage = Math.min(Number(c.req.query("perPage") ?? 50), 200);
const filters = await filtersFromQuery(c, ctx.organizationId);
// Cursor mode is recommended for any consumer that streams pages —
// it survives concurrent writes (no shifted rows). Page/perPage is
// the dashboard's "Showing N of M" fallback.
const result =
cursor !== undefined
? await repos.auditEvent.listByOrganization(ctx.organizationId, { ...filters, cursor, limit })
: await repos.auditEvent.listByOrganization(ctx.organizationId, { ...filters, page, perPage });
// Enrich rows with actor (name/email) via a SINGLE batched user lookup.
// Without this, the dashboard would either show raw actorUserId strings
// or fan out one /api/user/:id per row — explicit N+1 we avoid here by
// collecting the unique ids and joining client-side in a Map.
const actorIds = Array.from(
new Set(result.rows.map((r) => r.actorUserId).filter((id): id is string => !!id)),
);
const clientIds = Array.from(
new Set(result.rows.map((r) => r.sourceClientId).filter((id): id is string => !!id)),
);
const [actors, resourceNames, clientNames] = await Promise.all([
repos.user.findManyByIds(actorIds),
attachResourceNames(result.rows),
resolveClientNames(clientIds),
]);
const actorById = new Map(actors.map((u) => [u.id, { id: u.id, email: u.email, name: u.name }]));
const enrichedRows = result.rows.map((row) => ({
...row,
actor: row.actorUserId ? actorById.get(row.actorUserId) ?? null : null,
resourceName:
row.resourceType && row.resourceId
? resourceNames.get(`${row.resourceType}:${row.resourceId}`) ?? null
: null,
// "Claude Desktop", not "oauth:4f2a…" — the actor a reader cares about when
// the human in the row only authorized the agent months ago.
sourceClientName: row.sourceClientId ? clientNames.get(row.sourceClientId) ?? null : null,
}));
if ("pageInfo" in result) {
return c.json({ data: enrichedRows, pageInfo: result.pageInfo });
}
return c.json({
data: enrichedRows,
total: result.total,
page: result.page,
perPage: result.perPage,
});
r.get("/", { tag: "audit:read" }, async c => {
const { items, ...rest } = await operationData(c, audit().list(operationContext(c), query(c)));
return c.json({ data: items, ...rest });
});
/**
* Everything the filter bar needs, in one request.
*
* Each facet is counted with the OTHER filters applied but not its own —
* otherwise selecting "MCP" would show every other source as 0 and the user
* could never leave the choice they just made.
*/
r.get("/facets", { tag: "audit:read" }, async (c: Context) => {
const ctx = getRequestContext(c);
const orgId = ctx.organizationId;
const filters = await filtersFromQuery(c, orgId);
const { eventTypes, source, sourceClientId, ...shared } = filters;
const [byEventType, bySource, byClient, actorIds, settings, canManage] = await Promise.all([
repos.auditEvent.countByEventType(orgId, { ...shared, source, sourceClientId }),
repos.auditEvent.countBySource(orgId, { ...shared, eventTypes, sourceClientId }),
// Counted without its own filter, like every other facet — picking one agent
// must not zero out the others and trap the filter on that choice.
repos.auditEvent.countBySourceClient(orgId, { ...shared, eventTypes, source }),
repos.auditEvent.distinctActors(orgId, { from: filters.from, to: filters.to }),
repos.auditSettings.get(orgId),
checkPermissionOnResource(ctx, { resourceType: "audit", resourceId: "*", action: "write" }),
]);
const categoryCounts = new Map<string, number>(AUDIT_CATEGORIES.map((cat) => [cat.id, 0]));
let total = 0;
// Event types with no catalog entry (a new emitter, an old row) are counted in
// the total but in no tab, so "All" always adds up to at least the tabs.
for (const { eventType, count } of byEventType) {
total += count;
const category = categoryForAuditEvent(eventType);
if (category) categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + count);
}
const [actors, clientNames] = await Promise.all([
repos.user.findManyByIds(actorIds),
resolveClientNames(byClient.map((row) => row.sourceClientId)),
]);
return c.json({
total,
categories: AUDIT_CATEGORIES.map((cat) => ({
id: cat.id,
label: cat.label,
description: cat.description,
count: categoryCounts.get(cat.id) ?? 0,
})),
sources: bySource.map((row) => ({ source: row.source, count: row.count })),
clients: byClient.map((row) => ({
id: row.sourceClientId,
name: clientNames.get(row.sourceClientId) ?? null,
count: row.count,
})),
actors: actors.map((u) => ({ id: u.id, name: u.name, email: u.email, image: u.image })),
settings,
canManage,
});
});
r.get("/settings", { tag: "audit:read" }, async (c: Context) => {
const ctx = getRequestContext(c);
const settings = await repos.auditSettings.get(ctx.organizationId);
const canManage = await checkPermissionOnResource(ctx, {
resourceType: "audit",
resourceId: "*",
action: "write",
});
return c.json({ ...settings, canManage });
});
r.patch("/settings", { tag: "audit:write" }, async (c: Context) => {
const ctx = getRequestContext(c);
const orgId = ctx.organizationId;
const body = await c.req.json().catch(() => ({}));
const patch: { enabled?: boolean; retentionDays?: number } = {};
if (typeof body.enabled === "boolean") patch.enabled = body.enabled;
if (body.retentionDays !== undefined) {
const days = Number(body.retentionDays);
if (!RETENTION_CHOICES.includes(days as (typeof RETENTION_CHOICES)[number])) {
return c.json({ error: `retentionDays must be one of ${RETENTION_CHOICES.join(", ")}` }, 400);
}
patch.retentionDays = days;
}
if (Object.keys(patch).length === 0) return c.json({ error: "Nothing to update" }, 400);
const current = await repos.auditSettings.get(orgId);
const auditCtx = auditContextFrom(c, orgId, ctx.userId);
const turningOff = patch.enabled === false && current.enabled;
const turningOn = patch.enabled === true && !current.enabled;
const retentionChanged =
patch.retentionDays !== undefined && patch.retentionDays !== current.retentionDays;
const recordRetention = () =>
audit.record(auditCtx, {
eventType: "audit.retention_changed",
resourceType: "audit",
resourceId: "*",
before: { retentionDays: current.retentionDays },
after: { retentionDays: patch.retentionDays },
});
// Order matters. Recording is what we are switching off, so the rows describing
// this change have to be written while it is still on — after the flip the
// repo-level gate would drop them and the log would end with no explanation.
// (This is also why the tag's auto-emitted `audit:write` row can't stand in for
// these: requirePermission emits it after the handler, i.e. after the flip.)
if (turningOff) {
await audit.record(auditCtx, {
eventType: "audit.disabled",
resourceType: "audit",
resourceId: "*",
before: { enabled: true },
after: { enabled: false },
});
}
if (retentionChanged && current.enabled) await recordRetention();
const settings = await repos.auditSettings.upsert(orgId, patch);
if (turningOn) {
await audit.record(auditCtx, {
eventType: "audit.enabled",
resourceType: "audit",
resourceId: "*",
before: { enabled: false },
after: { enabled: true },
});
}
// Recording was off before this request: the row is only writable now, and only
// if this same patch turned it back on.
if (retentionChanged && !current.enabled) await recordRetention();
return c.json({ ...settings, canManage: true });
});
r.get("/facets", { tag: "audit:read" }, async c => c.json(await operationData(c, audit().facets(operationContext(c), query(c)))));
r.get("/settings", { tag: "audit:read" }, async c => c.json(await operationData(c, audit().getSettings(operationContext(c)))));
r.patch("/settings", { tag: "audit:write", body: AuditSettingsInput, auditHandledByOperation: true }, async c =>
c.json(await operationData(c, audit().updateSettings(operationContext(c), await c.req.json()))));
export const auditRoutes = r.hono;
+3 -3
View File
@@ -25,14 +25,14 @@
*/
import type { Context } from "hono";
import { auth, isSaasDeployment } from "../../lib/auth";
import { auth, isSaasDeployment } from "@repo/platform/engine/lib/auth";
import { repos } from "@repo/db";
import {
invitationAccountCreationMode,
resolveInvitationClaim,
} from "../../lib/invitation-claim";
} from "@repo/platform/engine/lib/invitation-claim";
import { setSessionCookie } from "../../lib/session-cookie";
import { localDashboardUrl } from "../../config/env";
import { localDashboardUrl } from "@repo/platform/engine/config/env";
import { alignLoopbackOrigin } from "@repo/core";
// ─── HTML result page ────────────────────────────────────────────────────────
+13 -14
View File
@@ -13,10 +13,11 @@
import { Hono } from "hono";
import { db, eq, repos, schema } from "@repo/db";
import { env } from "../../config/env";
import { auth, isSaasDeployment } from "../../lib/auth";
import { env } from "@repo/platform/engine/config/env";
import { auth, isSaasDeployment } from "@repo/platform/engine/lib/auth";
import { normalizeMcpRedirectUri } from "../../lib/oauth-redirect";
import { invitationLifecycleMiddleware } from "../../lib/invitation-lifecycle-lock";
import { authMiddleware } from "../../middleware/auth";
import * as organizationController from "./organization.controller";
import { internalAuth } from "../../middleware/internal-auth";
import { isLoopbackRequest } from "../../middleware/loopback-peer";
import * as ctrl from "./auth.controller";
@@ -38,17 +39,15 @@ if (env.DEPLOY_MODE === "desktop") {
// lookup requires a session, which a brand-new invitee cannot have yet.
authRoutes.get("/invitation-preview/:id", ctrl.invitationPreview);
// Better Auth's invitation lifecycle is a read + write + (for acceptance)
// membership insert rather than one database transaction. Serialize every
// terminal mutation by invitation id so accept cannot cross cancel/reject, and
// use the same lock as token-bound account creation.
for (const path of [
"/organization/accept-invitation",
"/organization/reject-invitation",
"/organization/cancel-invitation",
]) {
authRoutes.on("POST", path, invitationLifecycleMiddleware);
}
// Compatibility URLs use the same authorized operations as the SDK. The shared
// invitation service owns the serialization boundary.
authRoutes.post("/organization/invite-member", authMiddleware, organizationController.inviteMember);
authRoutes.post("/organization/accept-invitation", authMiddleware, organizationController.acceptInvitation);
authRoutes.post("/organization/reject-invitation", authMiddleware, organizationController.rejectInvitation);
authRoutes.post("/organization/cancel-invitation", authMiddleware, organizationController.cancelInvitation);
authRoutes.post("/organization/update-member-role", authMiddleware, organizationController.updateMemberRole);
authRoutes.post("/organization/remove-member", authMiddleware, organizationController.removeMember);
authRoutes.post("/organization/leave", authMiddleware, organizationController.leaveOrganization);
// Invite-only sign-up guard (runs BEFORE the Better Auth catch-all). SaaS keeps
// open public signup. On self-host the ONLY Better Auth signup allowed is the
@@ -2,9 +2,9 @@ import type { Context } from "hono";
import { bodyLimit } from "hono/body-limit";
import { hashPassword } from "better-auth/crypto";
import { repos } from "@repo/db";
import { isSaasDeployment } from "../../lib/auth";
import { resolveInvitationClaim } from "../../lib/invitation-claim";
import { createInvitedUserWithCredential } from "../../lib/invitation-signup";
import { isSaasDeployment } from "@repo/platform/engine/lib/auth";
import { resolveInvitationClaim } from "@repo/platform/engine/lib/invitation-claim";
import { createInvitedUserWithCredential } from "@repo/platform/engine/lib/invitation-signup";
export const INVITATION_SIGNUP_BODY_MAX_BYTES = 8 * 1024;
@@ -23,7 +23,7 @@
*/
import { repos } from "@repo/db";
import { auth } from "../../lib/auth";
import { auth } from "@repo/platform/engine/lib/auth";
import { isAllowedMcpResource, publicOriginFor, resolveTokenAudience } from "../../lib/mcp-resource";
import { signMcpAccessToken } from "../../lib/mcp-token";
import { signRs256Jwt } from "../../lib/mcp-oidc-keys";

Some files were not shown because too many files have changed in this diff Show More