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
203 changes: 44 additions & 159 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,183 +7,68 @@ package follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Until 1.0, a minor bump may contain a breaking change; breaking changes are
always called out under their own heading.

## [Unreleased]
## [0.2.0] — 2026-09-25

### Added

- Run-scoped `POST /artifacts`, `POST /artifacts/binary`, and
`PATCH /artifacts/:id` (`mountWorkflowArtifacts`) accept an optional
`metadata` field, matching the tenant routes' semantics exactly: omitted on
a revise carries the prior version's metadata forward, an explicit `null`
clears it, and any other value must be a JSON object or the request is
`400`. `artifact_create` and `artifact_write` in `ARTIFACT_TOOL_DEFINITIONS`
gain a matching optional `metadata` object parameter, and the sidecar
bundle forwards it to the route unchanged. `source` (`{ origin: "workflow",
runId }`) and `generatedBy` stay server-stamped from the resolved run
scope — never read from `metadata` or any other body field.
- `artifact_version.content_sha256` (text, nullable), added by the new
`0005_version_content_digest` migration and mirrored onto
`artifact.content_sha256` the same way `metadata` already mirrors. Computed
at write time — sha256 (hex) over the UTF-8 bytes of `content` for text and
URL artifacts, or over the uploaded bytes for a blob-backed file artifact's
version 1, since its `content` column is a store-specific pointer, not the
bytes. A metadata/title-only revise (content omitted, so it carries
forward) carries the digest forward unchanged. Returned as `contentSha256`
on `GET /api/artifacts/:id`, `GET /api/artifacts/:id/versions/:version`,
`GET /api/artifacts/:id/versions`, and the `POST /api/artifacts/:id/versions`
response. Existing rows serialize `contentSha256: null` — written before
digests existed, and never backfilled.
- `expectedVersion` (positive integer, optional) on
`POST /api/artifacts/:id/versions`. Checked under the same `SELECT ... FOR
UPDATE` that guards the version bump: a mismatch answers `409
{"error":"Version conflict","currentVersion":N}` and writes nothing.
Omitting it is today's unconditional-write behavior. Lets a caller bind a
revise to the exact version it last read — for example, a human approval on
specific content — instead of silently overwriting a change it never saw.
- `ARTIFACT_UPLOAD_POLICY` accepts packaged archives — `application/gzip` /
`application/x-gzip` (`.tar.gz`, `.tgz`, `.gz`) and `application/x-tar`
(`.tar`) — so consumers storing packaged builds are no longer refused with
415. An archive mints kind `file`, is never inline-previewable (the
download path's inline allow-list is `application/pdf` only), and is still
subject to the existing `MAX_UPLOAD_BYTES` per-file ceiling.
- `GET /api/artifacts/:id/versions/:version` — one version including its
content, reusing `getArtifactVersion`, the same read authorization as
`GET /api/artifacts/:id`, and the same response shape. A malformed or
sub-1 version is `400`; an unknown version collapses into the same `404
Artifact not found` every other single-artifact failure mode does.
- `GET /api/artifacts/:id/download?version=N` — pins the download to that
version's content for the data-URL and downloadable-text conventions,
where content really is per-version; omitting `version` is unchanged. Same
`400`/`404` rules as the new versions route, plus one more: a blob-backed
upload's `ContentStore` reference lives on the artifact row's own
`source`, never per-version, so `?version=N` for one is `400 "Uploaded
file content is not versioned"` unless `N` names the current version —
rather than silently answering with today's blob under an older version's
name.
- `artifact_version.metadata` (jsonb, nullable) and
`artifact_version.parent_version_ids` (text[], nullable), added by the new
`0004_version_metadata` migration. `metadata` is opaque to the package —
stored and returned as-is on every version read — and `artifact.metadata`
mirrors the current version's value the same way `title`/`content`/
`version` already do. `parentVersionIds` is explicit lineage set by the
writer and is never inferred from version order or carried forward between
versions. `createArtifact`, `writeArtifactVersion`, and
`findOrVersionArtifact` all accept optional `metadata` and
`parentVersionIds`; `getArtifactVersion` and `listArtifactVersions` return
both fields alongside each version.
- `findOrVersionArtifact(db, args)` — the atomic primitive behind "find an
artifact by title, create it if absent, add a version if present." The
schema's only uniqueness is `(artifactId, version)`; nothing constrains
`(tenantId, title, kind)`, so that common pattern raced between the read
and the write when hand-rolled outside the package. This closes the race
with a transaction-scoped advisory lock keyed on `(tenantId, kind, title)`:
concurrent callers for the same triple always converge on one artifact —
the first to acquire the lock creates it, every other caller revises the
row the first one just committed. A uniqueness constraint on
`(tenant_id, title, kind)` was considered instead but rejected:
`createArtifact` is a public, unconditional insert used directly by the
import route, uploads, and `artifact_link_file`, and a shared title across
independent creates on those paths is normal, not a bug a schema
constraint should forbid. See the find-or-version notes in
CONTRIBUTING.md.
- `POST /api/artifacts/:id/versions` revises an uploaded file with new bytes
when sent as `multipart/form-data` with one `file` field and an optional
`expectedVersion`. The bytes go through the configured `ContentStore` and
upload policy (`415` for a refused type, `413` over `MAX_UPLOAD_BYTES`),
and are stored only after the version check passes. The title carries
forward; the version downloads under the new file's name.
`reviseFileArtifact` is the underlying function.
- Upload bytes are versioned. Each version records its own content
reference in `artifact_version.source` (added by `0004_version_source`,
which backfills existing versions), so `GET .../download?version=N` and
`GET .../versions/:version` return that version's file after a revision.

### Changed

- `@intx/agent` is an optional peer. Only `@corbits/artifacts/sidecar-bundle`
imports it, so a host that mounts the routes alone need not install it.
- `arktype` is a regular dependency (`^2.2.3`) instead of a peer, so hosts no
longer install it themselves. The exported query schemas are still arktype
types; a host on another arktype version gets its own copy alongside.
- Minimum `@intx/*` is now **0.3.0**. (`@intx/*` lines before 0.3.0 do not
install — older lines pin the unpublished `@intx/*@0.0.0` or ship raw
TypeScript.)
- The repository root **is** the `@corbits/artifacts` package. The previous
`packages/artifacts` workspace nesting is gone so
`bun add github:corbitsdev/corbits-artifacts` installs cleanly. Bun consumers
resolve TypeScript sources via the `bun` export condition; Node consumers
continue to use the built `dist/` from `npm pack` / a published release.
- `createArtifactRoutes` takes an optional
`onArtifactCreated(tx, row, scope)` hook, run inside the same transaction
as artifact creation (once per row, so once on `POST /artifacts` and once
per file on `POST /artifacts/upload`). This is
the seam a host uses to provision grants for the row it just made — for
example, a `creator`-origin grant on `artifact:<id>` for `write` and
`archive`. Defaults to a no-op, so existing hosts are unaffected.
`examples/reference-host` now wires a real one (`grantOwnership`) against
Interchange's own `grant` table, and its default `requireGrant` is the
platform's real `createRequireGrant` over that table rather than a
default-allow stub — see CONTRIBUTING.md's "Grant provisioning" section.
- Single-artifact write routes (`POST .../versions`, `POST .../archive`,
`POST .../unarchive`) now resolve existence/tenant/skill-draft (the same
check `loadScoped` does) BEFORE running `requireGrant`, not after. A real,
resource-specific grant evaluator has no existence check of its own — it
denies a ghost id or another tenant's artifact with the same `403` it would
give for a real row the caller lacks permission on, which a default-allow
stub can never surface. This restores the documented "a caller who cannot
see the artifact gets 404" guarantee for write routes running a real grant
check, matching what already held for reads.
imports it.
- `arktype` is a regular dependency instead of a peer.
- `@intx/db` is a new peer. The minimum `@intx/*` is **0.4.0**.

### Breaking

- `mountArtifacts(app, opts)` is replaced by `createArtifactRoutes(deps)`,
which returns a `Hono<TenantEnv>` sub-app the host mounts with
`app.route(...)` instead of mutating the host app. `MountArtifactsOpts` is
renamed `CreateArtifactRoutesDeps`; the options are unchanged.
- `POST /artifacts` and `POST /artifacts/upload` now require
`requireGrant("artifact:*", "create")`, as hub-api's `createGrantRoutes`
requires `create` on `grant:*`. A host must grant its principals `create`
on `artifact:*` for them to keep creating artifacts. An unauthenticated
caller of these two routes now gets `{ "error": "Forbidden" }` instead of
`{ "error": "Tenant not accessible" }`.
`app.route(...)`. `MountArtifactsOpts` is renamed `CreateArtifactRoutesDeps`.
- `mountWorkflowArtifacts(app, opts)` is replaced by
`createWorkflowArtifactRoutes(deps)`, a sub-app the host mounts at
`/api/workflow-artifacts`. `MountWorkflowArtifactsOpts` is renamed
`CreateWorkflowArtifactRoutesDeps`.
- `POST /artifacts` and `POST /artifacts/upload` require
`requireGrant("artifact:*", "create")`. On upgrade from 0.1.0, the
migrations grant `create` on `artifact:*` to every principal that has
already created an artifact in its tenant, in the host's `grant` table.
New principals need the grant from the host. An unauthenticated caller now gets
`{ "error": "Forbidden" }` instead of `{ "error": "Tenant not accessible" }`.
- `runArtifactMigrations(config, { schema })` takes the same arguments as
Interchange's `runMigrations`: a `DBConfig` and the host schema holding
`tenant` and `principal`. It applies the SQL files shipped under
`migrations/`, all idempotent, with no ledger. The `adopt` option,
`tenant` and `principal`. It applies the idempotent SQL files shipped under
`migrations/` with no ledger. The `adopt` option,
`RunArtifactMigrationsOptions`, `MigrationChecksumError` and
`MigrationAdoptError` are removed, and the `artifacts.migrations` ledger
table is dropped on the next boot.
`MigrationAdoptError` are removed.
- The first 0.2.0 boot drops the `artifacts.migrations` ledger, so a
database cannot go back to 0.1.0. Do not run 0.1.0 and 0.2.0 replicas
against the same database.
- The drizzle tables (`artifact`, `artifactVersion`, `upload`,
`mailAttachmentRef`) are no longer exported from the package entry. Hosts
reach artifacts through the routes and functions; `ARTIFACTS_SCHEMA` and the
`mailAttachmentRef`) are no longer exported. `ARTIFACTS_SCHEMA` and the
`*Row` types stay public.
- The tenant routes take `Hono<TenantEnv>`, read the host-provided tenant and
principal context natively, and require the host's Interchange `RequireGrant`
middleware. The `resolvePrincipal`, `isAdmin`, and `identity` options and the
`Identity` / `anonymousIdentity` exports are not part of the package surface.
- Serialized artifact rows expose `ownerPrincipalId` without an `ownerName`.
Artifact lists no longer accept `creatorKind`.
- **Cross-tenant tool reads are removed, intentionally, not just undocumented.**
`readArtifact` / `readArtifactChunk` no longer take a `tenantId` override;
tool reads are always confined to `scope.tenantId`. The prior override read
through `Identity.ownerIsMemberOfTenant`, a membership policy this package
invented and owned — exactly what this PR removes. It is not replaced by a
grant check because there is no platform primitive to replace it with:
Interchange's `GrantStore` resolves a principal's grants within one tenant
(a principal is itself a row scoped to one tenant), so "grant readable
across tenants" does not exist to check. Reintroducing cross-tenant reads
here would mean this package inventing a second, bespoke cross-tenant
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`,
- `SKILL_DRAFT_KIND` is removed. `skill-draft` is an ordinary `kind`.
- `web_site` handling is removed: content is stored as given, `readArtifact`
no longer takes `path`, and every `WEB_SITE_*` constant and web-site
helper and type is no longer exported.
- Mail attachment references are removed: the
`/instances/:instanceId/mail-attachments` routes, `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.
`MAX_MAIL_ATTACHMENTS_PER_MAIL` and `MailAttachmentRefRow`.
`0003_drop_mail_attachment_ref` drops the table and its rows.
- `windowContent` is no longer exported.
- Node 24 or newer is required (0.1.0 accepted Node 22).

## [0.1.0] — first release

Expand Down Expand Up @@ -214,5 +99,5 @@ new; the list below is what the surface consists of rather than what changed.
(`@intx/*` 0.1.2 does not install — its deps pin the unpublished
`@intx/*@0.0.0` — and ships raw TypeScript.)

[Unreleased]: https://github.com/corbitsdev/corbits-artifacts
[0.2.0]: https://github.com/corbitsdev/corbits-artifacts
[0.1.0]: https://github.com/corbitsdev/corbits-artifacts
21 changes: 16 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ docker run -d --name corbits-artifact-pg -p 5457:5432 \
export ALLOW_DESTRUCTIVE_ARTIFACT_TESTS=1

bun run typecheck
bun run test # unit, integration and reference-host acceptance
bun run test # unit
bun run test:e2e # real-Postgres and reference-host acceptance
bun run build # dist/ (JS + .d.ts)
```

Expand All @@ -37,18 +38,18 @@ couple of migration cases. Those paths are fail-closed: set
ephemeral database (`artifact_core`, or any name ending in `_test`). Without both, the
suite throws before mutating.

End-to-end suites live in `tests/`. `tests/lib/db-harness.ts` creates a fresh
End-to-end suites live in `e2e/`. `e2e/helpers.ts` creates a fresh
`artifact_<random>_test` database per suite on the `ARTIFACT_DATABASE_URL` server,
applies Interchange's `runMigrations` and `runArtifactMigrations`, and drops it
afterwards. `artifactApp` mounts `createArtifactRoutes` for a seeded tenant
principal, authorized by the platform's real `createRequireGrant` over the
database's `grant` table. `bun run test` runs `src/` and `tests/`.
database's `grant` table. `bun run test` runs `src/`; `bun run test:e2e` runs `e2e/`.

## The reference host is the acceptance suite, not a demo

`examples/reference-host` mounts the package on a real `@intx/hub-api` app against a
live Postgres. `tests/reference-host.test.ts` asserts the end-to-end scenarios against
it as part of `bun run test`; the example imports `@corbits/artifacts`, which the
live Postgres. `e2e/reference-host.test.ts` asserts the end-to-end scenarios against
it as part of `bun run test:e2e`; the example imports `@corbits/artifacts`, which the
root `tsconfig.json` maps to `src/`. CI's Node consumer smoke test covers the built
`dist/` a consumer installs.

Expand Down Expand Up @@ -80,6 +81,9 @@ boot, so every statement must be idempotent (`IF NOT EXISTS`, `IF EXISTS`). Ther
is no ledger: a schema change is a new file whose statements are safe to re-run,
never an edit that assumes it runs once.

Because every file re-runs on every boot, a data backfill must be cheap once it has
run: guard it so an already-migrated database does no work beyond a quick check.

`schema.ts` and `migrations/` must agree — every query goes through the drizzle
table objects, and the route suites fail when a column they write is missing.
Change one, change the other, in the same commit.
Expand Down Expand Up @@ -393,3 +397,10 @@ authenticated `tenant`/`principal` on the request context; the host's
- **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.

## Commit messages

Commit subjects and PR titles follow [Conventional Commits](https://www.conventionalcommits.org): `feat`, `fix`, `refactor`, `test`, `docs`, `build`, `ci`, `perf`, and `chore(release): x.y.z` for releases.
Add `!` only for public API breaks: removed or renamed exports, changed signatures, newly required params. Peer and dependency range changes are `build(deps):` with no `!`.
Keep subjects imperative, lowercase after the colon, 72 characters or less, and free of ticket IDs.
Every PR links its issue with a `Closes <issue id>` line in the PR body.
Loading
Loading