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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,8 @@ HUB_STATIC_DIR=../web/dist
# EMBED_MODEL=text-embedding-3-small
# EMBED_API_KEY=

# Optional artifacts plane (@corbits/artifacts). Must be the same Postgres
# cluster as the hub control plane (hard FKs into public.tenant/principal).
# Leave unset to boot without artifact persistence.
# ARTIFACTS_DATABASE_URL=postgres://workbench:workbench@localhost:5432/workbench

1 change: 1 addition & 0 deletions apps/hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"dependencies": {
"@corbits/agent-directory": "workspace:*",
"@corbits/approvals": "workspace:*",
"@corbits/artifacts": "github:corbitsdev/corbits-artifacts#81049ed24a64e927498c7238bda6ffa66b63d2ab",
"@corbits/chat": "workspace:*",
"@corbits/commands": "workspace:*",
"@corbits/folded-runs": "workspace:*",
Expand Down
131 changes: 131 additions & 0 deletions apps/hub/src/artifact-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { describe, expect, test } from "bun:test";
import type { RequireGrant, TenantEnv } from "@intx/hub-api";
import { Hono } from "hono";

import {
createArtifactRoutes,
type ArtifactRoutesStore,
type ArtifactListPage,
} from "./artifact-routes";
import type { SerializedArtifact } from "@corbits/artifacts";

function listItem(id: string): ArtifactListPage["data"][number] {
return {
id,
kind: "document",
title: `Title ${id}`,
source: { origin: "manual" },
version: 1,
ownerPrincipalId: null,
ownerName: null,
archivedAt: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
};
}

function detail(id: string): SerializedArtifact {
return {
...listItem(id),
content: `body of ${id}`,
};
}

function memoryStore(seed: {
listByTenant: Record<string, ArtifactListPage["data"]>;
details: Record<string, { tenantId: string; row: SerializedArtifact }>;
}): ArtifactRoutesStore {
return {
async list(tenantId, _opts) {
const data = seed.listByTenant[tenantId] ?? [];
return { data, nextCursor: null };
},
async get(tenantId, artifactId) {
const hit = seed.details[artifactId];
if (hit === undefined || hit.tenantId !== tenantId) return null;
return hit.row;
},
};
}

/** Pass-through grant middleware for route unit tests (authz is hub-owned). */
const allowAll: RequireGrant = () => async (_c, next) => {
await next();
};

function appWith(
store: ArtifactRoutesStore,
tenantId: string,
): Hono<TenantEnv> {
const routes = createArtifactRoutes({ store, requireGrant: allowAll });
const outer = new Hono<TenantEnv>();
outer.use("*", async (c, next) => {
c.set("tenant", { id: tenantId } as TenantEnv["Variables"]["tenant"]);
c.set("principal", {
id: "principal_test",
} as TenantEnv["Variables"]["principal"]);
await next();
});
outer.route("/api/tenants/:tenantId/artifacts", routes);
return outer;
}

describe("createArtifactRoutes", () => {
test("lists artifacts for the tenant (happy path)", async () => {
const store = memoryStore({
listByTenant: {
tenant_a: [listItem("art_1"), listItem("art_2")],
},
details: {},
});
const app = appWith(store, "tenant_a");
const res = await app.request("/api/tenants/tenant_a/artifacts");
expect(res.status).toBe(200);
const body = (await res.json()) as ArtifactListPage;
expect(body.data).toHaveLength(2);
expect(body.data[0]?.id).toBe("art_1");
expect(body.nextCursor).toBeNull();
});

test("empty list returns data: []", async () => {
const store = memoryStore({ listByTenant: {}, details: {} });
const app = appWith(store, "tenant_empty");
const res = await app.request("/api/tenants/tenant_empty/artifacts");
expect(res.status).toBe(200);
const body = (await res.json()) as ArtifactListPage;
expect(body.data).toEqual([]);
});

test("get returns the artifact body for the owning tenant", async () => {
const row = detail("art_9");
const store = memoryStore({
listByTenant: {},
details: { art_9: { tenantId: "tenant_a", row } },
});
const app = appWith(store, "tenant_a");
const res = await app.request("/api/tenants/tenant_a/artifacts/art_9");
expect(res.status).toBe(200);
const body = (await res.json()) as SerializedArtifact;
expect(body.id).toBe("art_9");
expect(body.content).toBe("body of art_9");
});

test("get returns 404 for a missing id", async () => {
const store = memoryStore({ listByTenant: {}, details: {} });
const app = appWith(store, "tenant_a");
const res = await app.request("/api/tenants/tenant_a/artifacts/missing");
expect(res.status).toBe(404);
});

test("get returns 404 when the artifact belongs to another tenant", async () => {
const row = detail("art_x");
const store = memoryStore({
listByTenant: {},
details: { art_x: { tenantId: "tenant_b", row } },
});
// Request as tenant_a — store enforces tenant match.
const app = appWith(store, "tenant_a");
const res = await app.request("/api/tenants/tenant_a/artifacts/art_x");
expect(res.status).toBe(404);
});
});
115 changes: 115 additions & 0 deletions apps/hub/src/artifact-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* Tenant-scoped Library L2 HTTP surface over the mounted `@corbits/artifacts`
* engine. List (newest-first, paginated) and get-by-id only — upload/search
* UI stays on later tickets.
*
* Authz uses the existing `asset` resource family so Library grants keep
* working without inventing a parallel vocabulary.
*
* The store is injected so tests can exercise happy/empty/cross-tenant
* without a live Postgres.
*/
import {
anonymousIdentity,
getArtifact,
listArtifacts,
serializeArtifact,
serializeArtifactListItem,
type ArtifactDb,
type SerializedArtifact,
type SerializedArtifactListItem,
} from "@corbits/artifacts";
import type { RequireGrant, TenantEnv } from "@intx/hub-api";
import { Hono } from "hono";

const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 100;

export type ArtifactListPage = {
readonly data: readonly SerializedArtifactListItem[];
readonly nextCursor: string | null;
};

/** Minimal port the routes need — production wraps the engine db. */
export type ArtifactRoutesStore = {
list(
tenantId: string,
opts: { limit: number; cursor: string | null },
): Promise<ArtifactListPage>;
get(tenantId: string, artifactId: string): Promise<SerializedArtifact | null>;
};

export type CreateArtifactRoutesDeps = {
store: ArtifactRoutesStore;
requireGrant: RequireGrant;
};

function parseLimit(raw: string | undefined): number {
if (raw === undefined || raw === "") return DEFAULT_LIMIT;
const n = Number.parseInt(raw, 10);
if (!Number.isFinite(n) || n < 1) return DEFAULT_LIMIT;
return Math.min(n, MAX_LIMIT);
}

function parseCursor(
raw: string | undefined,
): { at: string; id: string } | undefined {
if (raw === undefined || raw === "") return undefined;
const sep = raw.lastIndexOf("__");
if (sep <= 0 || sep === raw.length - 2) return undefined;
const at = raw.slice(0, sep);
const id = raw.slice(sep + 2);
if (!at || !id) return undefined;
return { at, id };
}

/** Production store over an artifacts engine db handle. */
export function createArtifactDbStore(db: ArtifactDb): ArtifactRoutesStore {
return {
async list(tenantId, opts) {
const cursor = parseCursor(opts.cursor ?? undefined);
const result = await listArtifacts(db, anonymousIdentity, tenantId, {
limit: opts.limit,
...(cursor !== undefined ? { cursor } : {}),
});
return {
data: result.rows.map(serializeArtifactListItem),
nextCursor: result.nextCursor,
};
},
async get(tenantId, artifactId) {
const row = await getArtifact(db, artifactId);
if (row === null || row.tenantId !== tenantId) return null;
return serializeArtifact(row);
},
};
}

export function createArtifactRoutes(
deps: CreateArtifactRoutesDeps,
): Hono<TenantEnv> {
const app = new Hono<TenantEnv>();

app.get("/", deps.requireGrant("asset:*", "read"), async (c) => {
const tenant = c.get("tenant");
const limit = parseLimit(c.req.query("limit"));
const cursor = c.req.query("cursor") ?? null;
const page = await deps.store.list(tenant.id, { limit, cursor });
return c.json(page);
});

app.get("/:artifactId", deps.requireGrant("asset:*", "read"), async (c) => {
const tenant = c.get("tenant");
const artifactId = c.req.param("artifactId");
const row = await deps.store.get(tenant.id, artifactId);
if (row === null) {
return c.json(
{ error: { code: "not_found", message: "Artifact not found" } },
404,
);
}
return c.json(row);
});

return app;
}
60 changes: 60 additions & 0 deletions apps/hub/src/artifacts-mount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Hub-side artifacts engine mount — the host's own analog of
* `@corbits/dock`'s `mountArtifacts`. `@corbits/artifacts` (git pin)
* persists artifacts + immutable version history in Postgres; its
* `artifact`/`artifact_version` tables carry hard FKs into the host's own
* `public.tenant` / `public.principal` tables, so the engine MUST point at
* the same Postgres cluster as this hub's control plane.
*
* Degrades cleanly when unconfigured: `ARTIFACTS_DATABASE_URL` unset
* (and no explicit `databaseUrl` passed) means "no artifacts
* persistence", logged once at boot, never thrown — same contract as
* the dock mount.
*
* This module lands the mount + factory only. Tenant-scoped HTTP
* list/get routes live in `artifact-routes.ts` and are registered from
* the hub composition root when the mount succeeds.
*/
import { getLogger } from "@intx/log";
import {
createArtifactDb,
runArtifactMigrations,
type ArtifactDb,
} from "@corbits/artifacts";

const log = getLogger(["hub", "artifacts-mount"]);

export type MountArtifactsOptions = {
/** Defaults to `process.env.ARTIFACTS_DATABASE_URL`. */
databaseUrl?: string;
};

/**
* Handle returned by a successful mount. The `db` is the engine's own
* drizzle handle (the same shape dock's `mountArtifacts` exposes) so a
* later routes module can build the persist/find/search/read surface on
* top of it without re-deriving the connection.
*/
export type ArtifactsMountHandle = {
db: ArtifactDb;
};

export async function mountArtifacts(
options: MountArtifactsOptions = {},
): Promise<ArtifactsMountHandle | undefined> {
const databaseUrl =
options.databaseUrl ?? process.env["ARTIFACTS_DATABASE_URL"];
if (!databaseUrl) {
log.info(
"ARTIFACTS_DATABASE_URL not set — artifacts will not be persisted",
);
return undefined;
}

const { db } = createArtifactDb(databaseUrl);
await runArtifactMigrations(db);
log.info(
"Artifacts engine mounted — artifacts persist as versioned rows by kind",
);
return { db };
}
38 changes: 38 additions & 0 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ import { createEchoRoutes } from "@workbench/echo";
import { createGitWorkflowPusher } from "@workbench/hub-client";
import { createOnboardingRoutes } from "@workbench/onboarding";
import { mountMemory } from "./memory-mount";
import { mountArtifacts } from "./artifacts-mount";
import { createArtifactDbStore, createArtifactRoutes } from "./artifact-routes";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { type Context, type Next } from "hono";
Expand Down Expand Up @@ -490,6 +492,42 @@ export async function createHub(config: HubConfig) {

app.route("/api/onboarding", createOnboardingRoutes(onboardingDeps));

// Artifacts engine: mounts `@corbits/artifacts` against the same
// Postgres cluster as this hub's control plane (its
// `artifact`/`artifact_version` tables FK into `public.tenant` /
// `public.principal`). Degrades to a no-op when
// `ARTIFACTS_DATABASE_URL` is unset. When mounted, tenant-scoped
// list + get routes serve Library L2 under `/artifacts`.
//
// The mount runs migrations against the configured DB; if the URL is
// present but points at an unreachable/invalid cluster the migration
// would otherwise throw and take the whole hub down at boot. We catch
// that here so the hub comes up in a degraded (no-artifacts) mode and
// surfaces the failure as a warning rather than a crash.
let artifactsHandle: Awaited<ReturnType<typeof mountArtifacts>>;
try {
artifactsHandle = await mountArtifacts();
} catch (error) {
log.warn(
`Artifacts mount failed — continuing without artifacts persistence: ${error}`,
);
artifactsHandle = undefined;
}
if (artifactsHandle !== undefined) {
app.route(
`${TENANT_PREFIX}/artifacts`,
createArtifactRoutes({
store: createArtifactDbStore(artifactsHandle.db),
requireGrant: createRequireGrant({
grantStore: chatGrantStore,
conditionRegistry: chatConditionRegistry,
}),
}),
);
} else {
log.info("Artifacts handle unavailable (degraded mode)");
}

// Tells the signed-out screen which OAuth buttons to draw, without
// exposing the credentials themselves — just which providers a full
// pair was configured for. No session or tenant is required to ask,
Expand Down
3 changes: 2 additions & 1 deletion apps/hub/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["bun"]
"types": ["bun"],
"customConditions": ["bun"]
},
"include": ["src", "test"]
}
Loading
Loading