Skip to content
Open
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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,25 @@ always called out under their own heading.
authorization concept on top of the platform's — the failure mode this PR
exists to remove. If a real need for it surfaces, it belongs in
Interchange's grant model, not a per-package workaround.
- `SKILL_DRAFT_KIND` is removed. `skill-draft` is no longer a reserved kind:
create, list, find-by-title and every read treat it like any other `kind`.
- `web_site` handling is removed: `web_site` content is no longer normalized
on write, `readArtifact` no longer takes `path` or returns a site summary,
`artifact_read_chunk` no longer refuses it, and the sidecar's
`artifact_read` no longer forwards `path`. `WEB_SITE_KIND`,
`WEB_SITE_MAX_FILES`, `WEB_SITE_MAX_PATH_LENGTH`, `WEB_SITE_MAX_TOTAL_BYTES`,
`WebSiteContentError`, `normalizeWebSiteContent`, `normalizeWebSitePath`,
`parseWebSiteContentJson`, `serializeWebSiteContent`,
`summarizeWebSiteContent`, `WebSiteContent` and `WebSiteReadSummary` are no
longer exported.
- Mail attachment references are removed: `POST` and `GET
/instances/:instanceId/mail-attachments`, `saveMailAttachmentRefs`,
`listMailAttachmentRefs`, `MAIL_ATTACHABLE_KINDS`,
`MailAttachmentKindError`, `MAX_MAIL_ATTACHMENT_BYTES`,
`MAX_MAIL_ATTACHMENTS_PER_MAIL` and `MailAttachmentRefRow`. The new
`0003_drop_mail_attachment_ref` migration drops the `mail_attachment_ref`
table and its rows.
- `windowContent` is no longer exported; it is internal to the tool reads.

## [0.1.0] — first release

Expand Down
25 changes: 9 additions & 16 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,7 @@ How the package is put together and why. Mount options and snippets are in the

### Where the routes are served

The core registers root-relative paths (`/artifacts*`,
`/instances/:id/mail-attachments`) and takes no base path, so the *mount point*
The core registers root-relative paths (`/artifacts*`) and takes no base path, so the *mount point*
is the host's decision. The convention every `@corbits/*-core` package
documents, and every example here demonstrates, is **`/api`** — the same prefix
Interchange serves its own routes under (`app.route("/api/me", …)`,
Expand All @@ -112,8 +111,7 @@ app.route("/api", createArtifactRoutes({ db, contentStore, requireGrant }));
```

which serves `/api/artifacts`, `/api/artifacts/:id`,
`/api/artifacts/:id/versions`, `/api/artifacts/:id/download`, and
`/api/instances/:instanceId/mail-attachments`. Returning a sub-app rather than
`/api/artifacts/:id/versions`, and `/api/artifacts/:id/download`. Returning a sub-app rather than
taking a base path keeps the factory free of a configurable base path.

Everything else it needs arrives through `deps` or the host's request context.
Expand Down Expand Up @@ -231,15 +229,12 @@ which store is installed.
| `download.ts` | One download path over the three storage conventions. |
| `content-store.ts` | The two shipped `ContentStore` implementations. |
| `tools.ts` | Agent-facing tool definitions and windowed artifact reads (caller tenant only). |
| `web-site.ts` | The `web-site` kind's content encoding and validation. |
| `mail-attachments.ts` | Artifact↔message associations. |
| `ports.ts` | The `ContentStore` type and the shared `ResolvedPrincipal` shape. |
| `schema.ts` / `migrations.ts` | The four tables, and the DDL that creates them. |
| `schema.ts` / `migrations.ts` | The three tables, and the DDL that creates them. |

### Data model

Four physical tables — `artifact`, `artifact_version`, `upload`,
`mail_attachment_ref`.
Three physical tables — `artifact`, `artifact_version`, `upload`.

**Hard control-plane foreign keys, by design.** `tenant_id` is `NOT NULL` and
references the host's `tenant(id)` (`ON DELETE CASCADE` — a deleted tenant takes its
Expand All @@ -251,7 +246,7 @@ have run before `runArtifactMigrations`. The internal key —
`artifact_version.artifact_id` — cascades with its artifact.

**Cheap row-local CHECKs.** `artifact.version` and `artifact_version.version`
must be ≥ 1; `upload.size` and `mail_attachment_ref.size` must be ≥ 0. These are
must be ≥ 1; `upload.size` must be ≥ 0. These are
single-column constraints — free at write time.

**Principal↔tenant alignment is host-owned.** The package FKs each column into
Expand Down Expand Up @@ -327,9 +322,7 @@ for every possible way to write an artifact.

**`upload` is never a standalone resource.** There is no `POST /uploads`; every
upload eagerly mints its artifact, and the row is reachable only through
`source.upload.id`. `mail_attachment_ref` carries no bytes at all — the file
already *is* an artifact, and the ref only records which artifacts rode with
which message.
`source.upload.id`.

The list index is `(tenant_id, updated_at, id)`. The `id` is the list's
tie-break and must be *in* the index, or the keyset cursor's row-value
Expand Down Expand Up @@ -370,7 +363,7 @@ host schema's `tenant` / `principal` (see the data model).

### Boundaries

Owned by this package: the four tables and their migrations; the HTTP surface,
Owned by this package: the three tables and their migrations; the HTTP surface,
its validation and its status codes; the version and archive semantics; the
upload **gate** (`createFileArtifact` takes `policy` as a required argument and
refuses anything outside it before the `ContentStore` is touched); and the
Expand All @@ -397,6 +390,6 @@ authenticated `tenant`/`principal` on the request context; the host's
zero-dependency default, not a recommendation at scale; a large corpus wants a
`ContentStore` over object storage.
- **List paging caps at 100** rows (default 20).
- **One 404 covers four causes** for a resolved caller — never minted,
malformed, a `skill-draft`, or another tenant's. Distinguishing them would be
- **One 404 covers three causes** for a resolved caller — never minted,
malformed, or another tenant's. Distinguishing them would be
an existence oracle. Expect no more detail than that from the API.
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Returns a `Hono<TenantEnv>` sub-app the host mounts with `app.route`, alongside
| --- | --- | --- |
| `db` | `ArtifactDb` | Artifacts are stored there. `createArtifactDb` opens a handle for a host with none; a hub that already has one passes it through. |
| `contentStore` | `ContentStore` | Blob storage for file bytes. `InlineContentStore` (exported by this package) fits a minimal host; bring your own store for object storage. |
| `requireGrant` | `RequireGrant` | The host's grant middleware factory. This package implements no ownership or membership policy of its own. Creating an artifact requires `create` on `artifact:*`; revising or archiving one requires `write` or `archive` on `artifact:<id>`. Recording mail-attachment references needs only a principal. |
| `requireGrant` | `RequireGrant` | The host's grant middleware factory. This package implements no ownership or membership policy of its own. Creating an artifact requires `create` on `artifact:*`; revising or archiving one requires `write` or `archive` on `artifact:<id>`. |
| `countSegments` | `ArtifactCountSegments` (optional) | Named predicates over `ArtifactListRow` for `GET /artifacts/counts` (e.g. bucket by `kind`). The taxonomy is entirely host-owned; omitted, the route still answers with the tenant-wide `all` total. |
| `onArtifactCreated` | `(tx, row, scope) => Promise<void>` (optional) | Runs inside the transaction that creates each artifact. This is where the host mints grants for the new row, e.g. `write` and `archive` on `artifact:<id>` for its creator. The package mints none itself. |
| `decorate` | `(tenantId, rows) => Promise<void>` (optional) | Adds display-only fields to serialized rows on the way out (provenance labels, host joins). It must never change which rows are returned or who may see them. |
Expand Down Expand Up @@ -118,6 +118,15 @@ export function buildAssistant(sources: readonly InferencePreference[]) {

When the host deploys this agent definition, it must bind the agent's `hub` credential to the agent's hub token. The tools send every request through that credential, so without the binding they cannot reach the hub.

## Upgrading from 0.1.0

### Breaking

- The `skill-draft` kind is no longer reserved. It is an ordinary `kind` string, created, listed and read like any other.
- The `web_site` kind has no special handling: its content is stored as given, and `artifact_read` no longer takes `path` or returns a site summary. The `web-site` exports (`WEB_SITE_KIND`, `WebSiteContentError`, `normalizeWebSiteContent` and the rest) are removed.
- `/instances/:instanceId/mail-attachments`, `saveMailAttachmentRefs`, `listMailAttachmentRefs` and the other mail-attachment exports are removed, and `runArtifactMigrations` drops the `mail_attachment_ref` table.
- `windowContent` is no longer exported.

## Contributing

See [CONTRIBUTING.md](./CONTRIBUTING.md).
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions examples/reference-host/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export async function createReferenceHost(): Promise<ReferenceHost> {
}
await runArtifactMigrations(config, { schema: "public" });
await db.execute(
sql`TRUNCATE TABLE "artifacts"."artifact", "artifacts"."artifact_version", "artifacts"."upload", "artifacts"."mail_attachment_ref" CASCADE`,
sql`TRUNCATE TABLE "artifacts"."artifact", "artifacts"."artifact_version", "artifacts"."upload" CASCADE`,
);
await db.execute(sql`DELETE FROM "principal" WHERE "tenant_id" IN
(SELECT "id" FROM "tenant" WHERE "slug" = 'reference')`);
Expand Down Expand Up @@ -304,7 +304,7 @@ export async function createReferenceHost(): Promise<ReferenceHost> {
});
// Mounted @corbits/* modules serve under `/api`, matching Interchange's
// own convention (`app.route("/api/me", …)`). The core registers its
// routes root-relative (`/artifacts*`, `/instances/:id/mail-attachments`).
// routes root-relative (`/artifacts*`).
// The host wraps them in its own `api` sub-app so the principal middleware
// below stays scoped to `/api`. Served paths: `/api/artifacts*` — no `/v1`
// segment, no vendor prefix.
Expand Down
24 changes: 0 additions & 24 deletions migrations/0001_artifacts.sql
Original file line number Diff line number Diff line change
Expand Up @@ -57,27 +57,3 @@ CREATE TABLE IF NOT EXISTS "artifacts"."upload" (
CREATE INDEX IF NOT EXISTS "upload_tenant_idx" ON "artifacts"."upload" ("tenant_id");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "upload_principal_idx" ON "artifacts"."upload" ("principal_id");
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "artifacts"."mail_attachment_ref" (
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text,
"tenant_id" text NOT NULL REFERENCES "public"."tenant"("id") ON DELETE CASCADE,
"principal_id" text REFERENCES "public"."principal"("id") ON DELETE SET NULL,
"instance_id" text NOT NULL,
"mail_id" text NOT NULL,
"artifact_id" text NOT NULL,
"name" text NOT NULL,
"mime_type" text NOT NULL,
"size" integer NOT NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
CONSTRAINT "mail_attachment_ref_mail_id_artifact_id" UNIQUE ("mail_id", "artifact_id"),
CONSTRAINT "mail_attachment_ref_size_gte_0" CHECK ("size" >= 0)
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "mail_attachment_ref_instance_idx"
ON "artifacts"."mail_attachment_ref" ("instance_id");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "mail_attachment_ref_tenant_idx"
ON "artifacts"."mail_attachment_ref" ("tenant_id");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "mail_attachment_ref_principal_idx"
ON "artifacts"."mail_attachment_ref" ("principal_id");
1 change: 1 addition & 0 deletions migrations/0003_drop_mail_attachment_ref.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS "artifacts"."mail_attachment_ref";
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
}
},
"devDependencies": {
"@corbits/artifacts-0.1.0": "npm:@corbits/artifacts@0.1.0",
"@intx/types": "0.4.0",
"@types/bun": "1.1.14",
"@types/node": "22.10.5",
Expand Down
35 changes: 2 additions & 33 deletions src/artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
writeArtifactVersion,
} from "./artifacts.js";
import { artifact, artifactVersion } from "./schema.js";
import { seedArtifact, seedSkillDraft, SCOPE, testDb } from "./test-helpers.js";
import { seedArtifact, SCOPE, testDb } from "./test-helpers.js";

describe("create", () => {
test("writes version 1 eagerly, so a pinned read of v1 resolves immediately", async () => {
Expand All @@ -37,22 +37,6 @@ describe("create", () => {
});
});

test("refuses to mint a skill-draft", async () => {
const db = await testDb();
await expect(
db.transaction((tx) =>
createArtifact(tx, {
scope: SCOPE,
ownerPrincipalId: null,
kind: "skill-draft",
title: "x",
content: "y",
source: { origin: "agent" },
}),
),
).rejects.toThrow(/skill-draft/);
});

test("a failure after the bytes are written rolls the whole artifact back", async () => {
const db = await testDb();
await expect(
Expand All @@ -72,18 +56,6 @@ describe("create", () => {
const rows = await db.select().from(artifactVersion);
expect(rows.length).toBe(0);
});

test("normalizes web_site content through its schema", async () => {
const db = await testDb();
const row = await seedArtifact(db, {
kind: "web_site",
content: JSON.stringify({ files: { "/index.html": "<p>hi</p>" } }),
});
expect(JSON.parse(row.content)).toEqual({
entry: "index.html",
files: { "index.html": "<p>hi</p>" },
});
});
});

describe("versioning", () => {
Expand Down Expand Up @@ -209,15 +181,12 @@ describe("find by title", () => {
expect((await findArtifactByTitle(db, "acme", "Report"))?.artifactId).toBe(older.id);
});

test("never returns an archived or skill-draft artifact", async () => {
test("never returns an archived artifact", async () => {
const db = await testDb();
const row = await seedArtifact(db, { title: "Hidden" });
await setArtifactArchived(db, row, true);
await seedSkillDraft(db, "Scratch");

expect(await findArtifactByTitle(db, "acme", "Hidden")).toBeNull();
expect(await findArtifactByTitle(db, "acme", "Scratch")).toBeNull();
expect(await findArtifactByTitle(db, "acme", "Report", "skill-draft")).toBeNull();
});

test("honors a kind filter", async () => {
Expand Down
Loading
Loading