mirror of
https://github.com/oblien/openship.git
synced 2026-10-02 07:44:35 +08:00
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
@@ -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,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";
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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,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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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() } }),
|
||||
}));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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}`)),
|
||||
|
||||
@@ -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 })),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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" }) }));
|
||||
|
||||
@@ -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,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" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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,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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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"))) });
|
||||
}
|
||||
|
||||
@@ -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 * * *";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user