From 47442bc743009e2d44178d73a7e4b0cec83df7e8 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 25 Sep 2026 19:41:26 -0700 Subject: [PATCH 1/2] build: switch to oxfmt and oxlint --- .github/workflows/test.yml | 2 + .oxfmtrc.json | 4 ++ .oxlintrc.json | 6 +++ bun.lock | 84 ++++++++++++++++++++++++++++++++++++++ e2e/tools.test.ts | 2 +- package.json | 21 ++++++---- src/content-store.ts | 2 +- 7 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 .oxfmtrc.json create mode 100644 .oxlintrc.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 81c3db5..2b7beb4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,6 +38,8 @@ jobs: bun-version: 1.3.14 - run: bun install --frozen-lockfile + - run: bun run lint + - run: bun run format:check - name: typecheck run: bun run typecheck diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..9af2565 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,4 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 80 +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..0cd4329 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,6 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "categories": { + "correctness": "error" + } +} diff --git a/bun.lock b/bun.lock index 5d49e49..d26328d 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,8 @@ "drizzle-orm": "0.45.2", "hono": "4.12.32", "hono-openapi": "1.2.0", + "oxfmt": "0.70.0", + "oxlint": "1.85.0", "postgres": "3.4.9", "typescript": "5.7.2", }, @@ -126,6 +128,82 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.70.0", "", { "os": "android", "cpu": "arm" }, "sha512-Xd7YO4/T2axEj6FTLcj4Why3mTBqFMg+x24xtorT4Lb2+1g82090GH0a/4U1m0pABGYiix2bq1pqkYrmV3f0Sw=="], + + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.70.0", "", { "os": "android", "cpu": "arm64" }, "sha512-x9rlMYyKXdgKdYyUJzGsK1ZV8P4di/J32ipzcS6Jet6p9r9UAh28neXIMtdlSaJJycdi61Z4YkcLKLpk8ueFjg=="], + + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.70.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IUTUPvrBVYy7POh4stXzRdz4IVC/1QSaviCWoyenSlOhGu0X9j5K07vCTM9biLjAA2Zs31l0Rj5vvRpj9n95wA=="], + + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.70.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-vw745q870oTd6J517O24asoX4/E+eK0nxYIFoedSLqgJ+nI5En7+ZS82iZSHZ69zevQrnOXiyHP01dA+t8xD8w=="], + + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.70.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-NO14EgSM9dFkcg+MfGPxvsKqXYs9LKaxPrOKXpv1R0rLokGGFDcCq6dBMq18dE4wlpFOovX0UZY2uh1P30O7QA=="], + + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.70.0", "", { "os": "linux", "cpu": "arm" }, "sha512-139OEhHarj9CYoJ/i9gXlPv4KLBGtLj2toseOWYFf09QwlhklaZk+wW3aOvlqoeZtuayvkoSNVWra7WJ21s3VQ=="], + + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.70.0", "", { "os": "linux", "cpu": "arm" }, "sha512-GEh2PY3IWTE0M24eNhTduountANSbWyDmMnzFSQE/nGg/bjPugbUgiGuFu+xdqcQSd/HKSwH80/F2yVVD48yhA=="], + + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.70.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-En5i+UJmZSPxuSf47F2Hl5YOzKB0bicQLnGQkeTCMQ35cWLtbrSwACJKfiLqRZrk05DwSnsJkhBRaM3OURtIaA=="], + + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.70.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WWOoV5W9Im3flVwOVrWn/2DUlOF8v5vcCip+kcNuaMpulRCh6nzzt1Su2vcL2F908YJIXNV3HvegbBHuyLwKHg=="], + + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.70.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-YUouneIqW+5n7aE8xx/zeZ6/utr/KH7oykcGoFyd8Uz8uh591T1oKlnoWA3BsRq/ZR42oY1w4MUYvS/0e/MQOA=="], + + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.70.0", "", { "os": "linux", "cpu": "none" }, "sha512-iEnMf21S5aGVa4hViDGY8sAQ/AHyCu2JPyrQF8P06wtHhSkD1YJBeT4m/KiGewgf7+a5XCYSCRIPcRQa1xwEoQ=="], + + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.70.0", "", { "os": "linux", "cpu": "none" }, "sha512-91Sdniaj20fQzyMeCxMDzTP4c9s4RB8dGQ308xHhDR0n6U7+1Xq7N9klE7mfXq8iV3lRmIGSXi5X23Hn/0XX/g=="], + + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.70.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-uUV30M6E+2TKKGMaKiwfeL4RZrviHXlUxsrYJ/jFBb+1EZy+pnFT+hF73eeWdzh5NqPOAnw0iiMAIqjqiLZPFg=="], + + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.70.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ivMcX6kNDPhqtbOaBt/ItFlLlTlXNHLgRuNmxP6Na6UuYXRT10llpJcAPbGeRgjjb3Qzv4jwPp3fB0hui50WNQ=="], + + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.70.0", "", { "os": "linux", "cpu": "x64" }, "sha512-w+S+fERxYmlZSyZlJK/U292FjyBoH8cCEj21/tYJX6atX5kNSn+HDkhlFQKT2zcMwUW0uAtUL/bOrlwJZwqRdA=="], + + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.70.0", "", { "os": "none", "cpu": "arm64" }, "sha512-Zlom1Xkx257R8bk4ZI4zJsrGno2opknz1+5v5baka3nn4FPyvNSdh8JUL4CdN1S1OWRMvJ9UJQ+RfIqGGCUEfA=="], + + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.70.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-FQgPW5R17vzt7cgrJ8eG/dqX00o2xHsqFeLfw4xzA9FRHpN/DjFo9YDonvIIXGxiEuS9F/jZPnGO+KHNKuCo4Q=="], + + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.70.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-ZfZublNhZ+XBndMiXhkiLlPE+XyGRDa0CweeTL6t1fZypfCh1LTg7e5CvnOeTBunq15MskOcRempumSPGAaCQA=="], + + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.70.0", "", { "os": "win32", "cpu": "x64" }, "sha512-HlIZEn+WzLQL0DszNzldiRl/DPRCX5R0Vkt6qeUPR1YHwy52hZZo4x6HoTOVmKRP2wUiwPGtKsihNY/f8KRaBg=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.85.0", "", { "os": "android", "cpu": "arm" }, "sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.85.0", "", { "os": "android", "cpu": "arm64" }, "sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.85.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.85.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.85.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.85.0", "", { "os": "linux", "cpu": "arm" }, "sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.85.0", "", { "os": "linux", "cpu": "arm" }, "sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.85.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.85.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.85.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.85.0", "", { "os": "linux", "cpu": "none" }, "sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.85.0", "", { "os": "linux", "cpu": "none" }, "sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.85.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.85.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.85.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.85.0", "", { "os": "none", "cpu": "arm64" }, "sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.85.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.85.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.85.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA=="], + "@standard-community/standard-json": ["@standard-community/standard-json@0.3.5", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "@types/json-schema": "^7.0.15", "@valibot/to-json-schema": "^1.3.0", "arktype": "^2.1.20", "effect": "^3.16.8", "quansync": "^0.2.11", "sury": "^10.0.0", "typebox": "^1.0.17", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.5" }, "optionalPeers": ["@valibot/to-json-schema", "arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-to-json-schema"] }, "sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA=="], "@standard-community/standard-openapi": ["@standard-community/standard-openapi@0.2.9", "", { "peerDependencies": { "@standard-community/standard-json": "^0.3.5", "@standard-schema/spec": "^1.0.0", "arktype": "^2.1.20", "effect": "^3.17.14", "openapi-types": "^12.1.3", "sury": "^10.0.0", "typebox": "^1.0.0", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-openapi": "^4" }, "optionalPeers": ["arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-openapi"] }, "sha512-htj+yldvN1XncyZi4rehbf9kLbu8os2Ke/rfqoZHCMHuw34kiF3LP/yQPdA0tQ940y8nDq3Iou8R3wG+AGGyvg=="], @@ -318,6 +396,10 @@ "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + "oxfmt": ["oxfmt@0.70.0", "", { "dependencies": { "tinypool": "2.1.2" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.70.0", "@oxfmt/binding-android-arm64": "0.70.0", "@oxfmt/binding-darwin-arm64": "0.70.0", "@oxfmt/binding-darwin-x64": "0.70.0", "@oxfmt/binding-freebsd-x64": "0.70.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.70.0", "@oxfmt/binding-linux-arm-musleabihf": "0.70.0", "@oxfmt/binding-linux-arm64-gnu": "0.70.0", "@oxfmt/binding-linux-arm64-musl": "0.70.0", "@oxfmt/binding-linux-ppc64-gnu": "0.70.0", "@oxfmt/binding-linux-riscv64-gnu": "0.70.0", "@oxfmt/binding-linux-riscv64-musl": "0.70.0", "@oxfmt/binding-linux-s390x-gnu": "0.70.0", "@oxfmt/binding-linux-x64-gnu": "0.70.0", "@oxfmt/binding-linux-x64-musl": "0.70.0", "@oxfmt/binding-openharmony-arm64": "0.70.0", "@oxfmt/binding-win32-arm64-msvc": "0.70.0", "@oxfmt/binding-win32-ia32-msvc": "0.70.0", "@oxfmt/binding-win32-x64-msvc": "0.70.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-IsHxZ4y0wQLLMhnrJblBJgZsLDzfULrJnAw5j/QqsTlMa/m3AqsbToi+W71uhBGaqlqq/PbbjvHc09TJwdv3Tw=="], + + "oxlint": ["oxlint@1.85.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.85.0", "@oxlint/binding-android-arm64": "1.85.0", "@oxlint/binding-darwin-arm64": "1.85.0", "@oxlint/binding-darwin-x64": "1.85.0", "@oxlint/binding-freebsd-x64": "1.85.0", "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", "@oxlint/binding-linux-arm-musleabihf": "1.85.0", "@oxlint/binding-linux-arm64-gnu": "1.85.0", "@oxlint/binding-linux-arm64-musl": "1.85.0", "@oxlint/binding-linux-ppc64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-musl": "1.85.0", "@oxlint/binding-linux-s390x-gnu": "1.85.0", "@oxlint/binding-linux-x64-gnu": "1.85.0", "@oxlint/binding-linux-x64-musl": "1.85.0", "@oxlint/binding-openharmony-arm64": "1.85.0", "@oxlint/binding-win32-arm64-msvc": "1.85.0", "@oxlint/binding-win32-ia32-msvc": "1.85.0", "@oxlint/binding-win32-x64-msvc": "1.85.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg=="], + "p-map": ["p-map@7.0.6", "", {}, "sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg=="], "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], @@ -368,6 +450,8 @@ "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], + "tinypool": ["tinypool@2.1.2", "", {}, "sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww=="], + "to-buffer": ["to-buffer@1.2.2", "", { "dependencies": { "isarray": "^2.0.5", "safe-buffer": "^5.2.1", "typed-array-buffer": "^1.0.3" } }, "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw=="], "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], diff --git a/e2e/tools.test.ts b/e2e/tools.test.ts index facb137..bc9853f 100644 --- a/e2e/tools.test.ts +++ b/e2e/tools.test.ts @@ -59,7 +59,7 @@ describe("read windowing", () => { test("walking the continuation offsets reads the whole content exactly once", async () => { const content = "abcdefghij".repeat(2000); const db = await testDb(); - const row = await seedArtifact(db, { content }); + await seedArtifact(db, { content }); let offset = 0; let assembled = ""; for (let guard = 0; guard < 100; guard += 1) { diff --git a/package.json b/package.json index a75a13a..e67afd5 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,10 @@ "prepack": "bun run build", "test": "bun test src", "test:e2e": "bun test e2e", - "test:coverage": "bun test --coverage src e2e" + "test:coverage": "bun test --coverage src e2e", + "lint": "oxlint", + "format": "oxfmt", + "format:check": "oxfmt --check" }, "dependencies": { "@hono/standard-validator": "^0.2.3", @@ -81,20 +84,22 @@ }, "devDependencies": { "@corbits/artifacts-0.1.0": "npm:@corbits/artifacts@0.1.0", + "@intx/agent": "0.4.0", + "@intx/authz": "0.4.0", + "@intx/db": "0.4.0", + "@intx/hub-api": "0.4.0", + "@intx/hub-common": "0.4.0", + "@intx/hub-sessions": "0.4.0", "@intx/types": "0.4.0", "@types/bun": "1.1.14", "@types/node": "22.10.5", "drizzle-orm": "0.45.2", "hono": "4.12.32", "hono-openapi": "1.2.0", + "oxfmt": "0.70.0", + "oxlint": "1.85.0", "postgres": "3.4.9", - "typescript": "5.7.2", - "@intx/hub-api": "0.4.0", - "@intx/authz": "0.4.0", - "@intx/agent": "0.4.0", - "@intx/db": "0.4.0", - "@intx/hub-common": "0.4.0", - "@intx/hub-sessions": "0.4.0" + "typescript": "5.7.2" }, "overrides": { "drizzle-orm": "0.45.2" diff --git a/src/content-store.ts b/src/content-store.ts index 69f3390..70af2a5 100644 --- a/src/content-store.ts +++ b/src/content-store.ts @@ -1,7 +1,7 @@ import "./arktype.js"; import { type } from "arktype"; import { and, eq } from "drizzle-orm"; -import type { ArtifactDb, ArtifactTx } from "./db.js"; +import type { ArtifactTx } from "./db.js"; import { upload } from "./schema.js"; import type { ResolvedPrincipal, From f12a6d56a9d6ad37311b7a6b54d1d3546a178213 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 25 Sep 2026 20:11:26 -0700 Subject: [PATCH 2/2] style: format with oxfmt --- CONTRIBUTING.md | 42 +-- bunfig.toml | 6 +- e2e/agent-token-mount.test.ts | 85 +++-- e2e/artifacts.test.ts | 81 +++-- e2e/content-store.test.ts | 82 ++++- e2e/file-round-trip.test.ts | 36 +- e2e/list.test.ts | 66 ++-- e2e/migrations.test.ts | 5 +- e2e/mount.test.ts | 478 +++++++++++++++++++++------ e2e/reference-host.test.ts | 199 ++++++++--- e2e/tools.test.ts | 75 +++-- e2e/upgrade-from-0.1.0.test.ts | 43 ++- e2e/upload-policy.test.ts | 9 +- e2e/uploads.test.ts | 115 ++++--- e2e/workflow-mount.test.ts | 93 ++++-- examples/reference-host/src/index.ts | 39 ++- package.json | 84 ++--- src/artifacts.ts | 67 ++-- src/content-store.ts | 17 +- src/download.ts | 9 +- src/index.ts | 12 +- src/migrations.ts | 8 +- src/mount.ts | 158 +++++++-- src/schema.ts | 16 +- src/sidecar-bundle.test.ts | 4 +- src/sidecar-bundle.ts | 83 +++-- src/tools.ts | 29 +- src/uploads.ts | 39 ++- src/workflow-mount.ts | 53 ++- 29 files changed, 1508 insertions(+), 525 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 54bf22d..274310e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,7 +91,7 @@ Change one, change the other, in the same commit. ## Pull requests - Keep commits focused, and keep the diff to the change you are describing. -- Explain *why* in the commit message; the code already says what. +- Explain _why_ in the commit message; the code already says what. - CI must be green: typecheck, unit + integration, build, reference-host acceptance, and a Node consumer smoke test that installs the packed tarball. - Contributions are accepted under the repository's LGPL-2.1-only licence. @@ -103,7 +103,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*`) 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", …)`, @@ -130,7 +130,7 @@ composes beneath Interchange auth + tenant middleware. The host places full and never invents a second principal resolution path. Three options have no sensible default — `db`, `contentStore`, `requireGrant` — -and the rest degrade a *feature*, never safety, when omitted. The README's option +and the rest degrade a _feature_, never safety, when omitted. The README's option tables are the reference; what matters architecturally is that optional seams **fail closed**: no `decorate` means no decoration. @@ -175,8 +175,8 @@ only handing it the row and the scope that made it. ### Row decoration `decorate`'s display-only status is a contract, not a convention: it may add -fields to rows on their way out and must never affect *what* is returned or -*who* may see it. Joining a host's workflow tables inside this package would +fields to rows on their way out and must never affect _what_ is returned or +_who_ may see it. Joining a host's workflow tables inside this package would couple it to a schema it must not know, so the host supplies the decorator. Clients that need an owner display name resolve `ownerPrincipalId` themselves; this package never ships directory names on the wire. @@ -225,16 +225,16 @@ which store is installed. ### Modules -| File | Role | -| --- | --- | -| `mount.ts` | HTTP surface: parsing, validation, status codes; reads `TenantEnv` principal; wires host `requireGrant`. | -| `artifacts.ts` | The core domain — create, revise, find-or-version, list, get, archive, serialize. | -| `uploads.ts` | `createFileArtifact`, the MIME policies, and the size caps. | -| `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). | -| `ports.ts` | The `ContentStore` type and the shared `ResolvedPrincipal` shape. | -| `schema.ts` / `migrations.ts` | The three tables, and the DDL that creates them. | +| File | Role | +| ----------------------------- | -------------------------------------------------------------------------------------------------------- | +| `mount.ts` | HTTP surface: parsing, validation, status codes; reads `TenantEnv` principal; wires host `requireGrant`. | +| `artifacts.ts` | The core domain — create, revise, find-or-version, list, get, archive, serialize. | +| `uploads.ts` | `createFileArtifact`, the MIME policies, and the size caps. | +| `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). | +| `ports.ts` | The `ContentStore` type and the shared `ResolvedPrincipal` shape. | +| `schema.ts` / `migrations.ts` | The three tables, and the DDL that creates them. | ### Data model @@ -265,7 +265,7 @@ never plants a cross-tenant principal. Operators cleaning legacy rows before the the migration fails with an explicit message if null `tenant_id` rows remain. **`kind` is free-form text, not a pg enum,** validated at the application edge. -New kinds cost no migration. What is *not* free-form is the import allowlist: +New kinds cost no migration. What is _not_ free-form is the import allowlist: `POST /api/artifacts` may only mint `link` or `document`, so an untrusted caller cannot stamp a file-shaped, downloadable kind onto a row whose content is a URL or a pasted body. @@ -297,8 +297,8 @@ every one of those paths — a coworker uploading `report.pdf` twice, or two agents each linking a file named `notes.md`, are not bugs. A hard `UNIQUE(tenant_id, title, kind)` constraint would reject those ordinary inserts outright, not just gate on a one-time backfill of legacy -duplicates. Uniqueness on that triple is a property of the *find-or-version -pattern specifically*, not an invariant of the table, so it does not belong +duplicates. Uniqueness on that triple is a property of the _find-or-version +pattern specifically_, not an invariant of the table, so it does not belong in the schema — it belongs exactly where it now lives, inside the one code path that promises it. @@ -320,7 +320,7 @@ callers for a different tenant, kind, or title never contend with each other. This guarantee holds only for callers that go through `findOrVersionArtifact`; a caller that instead calls `createArtifact` directly is unconstrained by design, as above, and a caller that hand-rolls -its own find-then-create against a *different* lock is not serialized +its own find-then-create against a _different_ lock is not serialized against this one — the primitive closes the race for its own call path, not for every possible way to write an artifact. @@ -329,7 +329,7 @@ upload eagerly mints its artifact, and the row is reachable only through `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 +tie-break and must be _in_ the index, or the keyset cursor's row-value comparison falls out of the index condition into a filter and drags a sort behind it. @@ -381,7 +381,7 @@ authenticated `tenant`/`principal` on the request context; the host's - **No file parsing, ever.** No PDF parser, no spreadsheet parser, no text extractor, and none is planned. An extractor is a heavyweight, fast-moving - native dependency, and what the extracted text is *for* is the host's product. + native dependency, and what the extracted text is _for_ is the host's product. The contract the package offers a parsing host instead is **parse before you store**: `createFileArtifact` is the only way a file becomes an artifact, so a host that parses first and fails leaves nothing orphaned. diff --git a/bunfig.toml b/bunfig.toml index a63b96e..575f612 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -16,4 +16,8 @@ coverageThreshold = 0.8 # schema.ts is pure declarations — its "functions" are drizzle index/column # callbacks the coverage instrumentation cannot attribute, not logic. -coveragePathIgnorePatterns = ["src/schema.ts", "e2e/helpers.ts", "e2e/fixtures.ts"] +coveragePathIgnorePatterns = [ + "src/schema.ts", + "e2e/helpers.ts", + "e2e/fixtures.ts", +] diff --git a/e2e/agent-token-mount.test.ts b/e2e/agent-token-mount.test.ts index 2ea6160..5b94977 100644 --- a/e2e/agent-token-mount.test.ts +++ b/e2e/agent-token-mount.test.ts @@ -18,7 +18,9 @@ const RUN_SCOPE: ResolvedWorkflowRunScope = { const ADDRESS = "run-1@acme"; const AGENT_TOKEN = "agent-token"; -function agentTokenAuth(overrides: Partial = {}): AgentTokenAuth { +function agentTokenAuth( + overrides: Partial = {}, +): AgentTokenAuth { return { verify: (ctx) => { const c = ctx as { req: { header(name: string): string | undefined } }; @@ -52,7 +54,9 @@ describe("agent-token authentication", () => { test("an agent bearer authenticates and scopes to the run's tenant", async () => { const db = await testDb(); const app = host(db); - const res = await app.request("/artifacts/recent", { headers: agentHeaders }); + const res = await app.request("/artifacts/recent", { + headers: agentHeaders, + }); expect(res.status).toBe(200); }); @@ -60,9 +64,13 @@ describe("agent-token authentication", () => { const db = await testDb(); const app = host( db, - agentTokenAuth({ verify: () => ({ tenantId: "other", definitionId: "def-1" }) }), + agentTokenAuth({ + verify: () => ({ tenantId: "other", definitionId: "def-1" }), + }), ); - const res = await app.request("/artifacts/recent", { headers: agentHeaders }); + const res = await app.request("/artifacts/recent", { + headers: agentHeaders, + }); expect(res.status).toBe(401); }); @@ -79,7 +87,10 @@ describe("agent-token authentication", () => { const db = await testDb(); const app = host(db); const res = await app.request("/artifacts/recent", { - headers: { authorization: "Bearer sidecar-token", "x-workflow-run-address": ADDRESS }, + headers: { + authorization: "Bearer sidecar-token", + "x-workflow-run-address": ADDRESS, + }, }); expect(res.status).toBe(200); }); @@ -93,16 +104,26 @@ describe("the routes the artifact tools call", () => { const created = await app.request("/artifacts", { method: "POST", headers: { "content-type": "application/json", ...agentHeaders }, - body: JSON.stringify({ title: "Notes", kind: "document", content: "first" }), + body: JSON.stringify({ + title: "Notes", + kind: "document", + content: "first", + }), }); expect(created.status).toBe(201); - const { data: artifact } = (await created.json()) as { data: { id: string } }; + const { data: artifact } = (await created.json()) as { + data: { id: string }; + }; - const listed = await app.request("/artifacts?kind=document", { headers: agentHeaders }); + const listed = await app.request("/artifacts?kind=document", { + headers: agentHeaders, + }); const listedBody = (await listed.json()) as { data: Array<{ id: string }> }; expect(listedBody.data.map((row) => row.id)).toContain(artifact.id); - const found = await app.request("/artifacts/find?title=Notes", { headers: agentHeaders }); + const found = await app.request("/artifacts/find?title=Notes", { + headers: agentHeaders, + }); expect((await found.json()) as unknown).toEqual({ data: { artifactId: artifact.id, version: 1 }, }); @@ -113,23 +134,37 @@ describe("the routes the artifact tools call", () => { body: JSON.stringify({ content: "second" }), }); expect(revised.status).toBe(200); - expect((await revised.json()) as { data: { id: string; version: number } }).toEqual({ + expect( + (await revised.json()) as { data: { id: string; version: number } }, + ).toEqual({ data: { id: artifact.id, version: 2 }, }); - const read = await app.request(`/artifacts/${artifact.id}/read`, { headers: agentHeaders }); - const readBody = (await read.json()) as { data: { content: string } }; - expect(readBody.data.content).toBe("second"); - - const pinned = await app.request(`/artifacts/${artifact.id}/read?version=1`, { + const read = await app.request(`/artifacts/${artifact.id}/read`, { headers: agentHeaders, }); - expect(((await pinned.json()) as { data: { content: string } }).data.content).toBe("first"); + const readBody = (await read.json()) as { data: { content: string } }; + expect(readBody.data.content).toBe("second"); - const chunk = await app.request(`/artifacts/${artifact.id}/chunk?offset=0&limit=3`, { - headers: agentHeaders, - }); - expect(((await chunk.json()) as { data: { content: string } }).data.content).toBe("sec"); + const pinned = await app.request( + `/artifacts/${artifact.id}/read?version=1`, + { + headers: agentHeaders, + }, + ); + expect( + ((await pinned.json()) as { data: { content: string } }).data.content, + ).toBe("first"); + + const chunk = await app.request( + `/artifacts/${artifact.id}/chunk?offset=0&limit=3`, + { + headers: agentHeaders, + }, + ); + expect( + ((await chunk.json()) as { data: { content: string } }).data.content, + ).toBe("sec"); }); test("links a workspace file without moving any bytes", async () => { @@ -138,7 +173,11 @@ describe("the routes the artifact tools call", () => { const linked = await app.request("/artifacts/link-file", { method: "POST", headers: { "content-type": "application/json", ...agentHeaders }, - body: JSON.stringify({ title: "Report", kind: "document", path: "out/report.md" }), + body: JSON.stringify({ + title: "Report", + kind: "document", + path: "out/report.md", + }), }); expect(linked.status).toBe(201); }); @@ -147,7 +186,9 @@ describe("the routes the artifact tools call", () => { const db = await testDb(); const foreign = await seedArtifact(db, { tenantId: "other" }); const app = host(db); - const read = await app.request(`/artifacts/${foreign.id}/read`, { headers: agentHeaders }); + const read = await app.request(`/artifacts/${foreign.id}/read`, { + headers: agentHeaders, + }); expect(read.status).toBe(404); }); }); diff --git a/e2e/artifacts.test.ts b/e2e/artifacts.test.ts index 72fca62..4de10ec 100644 --- a/e2e/artifacts.test.ts +++ b/e2e/artifacts.test.ts @@ -61,7 +61,6 @@ describe("create", () => { }); describe("versioning", () => { - test("rejects oversize revise fields", async () => { const db = await testDb(); const row = await seedArtifact(db); @@ -86,9 +85,21 @@ describe("versioning", () => { const row = await seedArtifact(db, { content: "base" }); const results = await Promise.all([ - writeArtifactVersion(db, { scope: SCOPE, artifactId: row.id, content: "a" }), - writeArtifactVersion(db, { scope: SCOPE, artifactId: row.id, content: "b" }), - writeArtifactVersion(db, { scope: SCOPE, artifactId: row.id, content: "c" }), + writeArtifactVersion(db, { + scope: SCOPE, + artifactId: row.id, + content: "a", + }), + writeArtifactVersion(db, { + scope: SCOPE, + artifactId: row.id, + content: "b", + }), + writeArtifactVersion(db, { + scope: SCOPE, + artifactId: row.id, + content: "c", + }), ]); expect(results.map((r) => r.version).sort()).toEqual([2, 3, 4]); @@ -109,7 +120,6 @@ describe("versioning", () => { }); describe("archive", () => { - test("returned archivedAt matches durable DB state", async () => { const db = await testDb(); const row = await seedArtifact(db); @@ -183,7 +193,9 @@ describe("find by title", () => { content: "touched", }); - expect((await findArtifactByTitle(db, "acme", "Report"))?.artifactId).toBe(older.id); + expect((await findArtifactByTitle(db, "acme", "Report"))?.artifactId).toBe( + older.id, + ); }); test("never returns an archived artifact", async () => { @@ -198,9 +210,9 @@ describe("find by title", () => { const db = await testDb(); await seedArtifact(db, { title: "Same", kind: "document" }); const csv = await seedArtifact(db, { title: "Same", kind: "csv-export" }); - expect((await findArtifactByTitle(db, "acme", "Same", "csv-export"))?.artifactId).toBe( - csv.id, - ); + expect( + (await findArtifactByTitle(db, "acme", "Same", "csv-export"))?.artifactId, + ).toBe(csv.id); }); }); @@ -239,7 +251,10 @@ describe("find-or-version", () => { expect(result.artifact.version).toBe(2); expect(result.artifact.content).toBe("v2"); - const rows = await db.select().from(artifact).where(eq(artifact.tenantId, "acme")); + const rows = await db + .select() + .from(artifact) + .where(eq(artifact.tenantId, "acme")); expect(rows.length).toBe(1); }); @@ -257,7 +272,10 @@ describe("find-or-version", () => { }); expect(result.outcome).toBe("created"); - const rows = await db.select().from(artifact).where(eq(artifact.title, "Report")); + const rows = await db + .select() + .from(artifact) + .where(eq(artifact.title, "Report")); expect(rows.length).toBe(2); }); @@ -306,10 +324,18 @@ describe("find-or-version", () => { ]); expect(first.artifact.id).toBe(second.artifact.id); - expect([first.outcome, second.outcome].sort()).toEqual(["created", "revised"]); - expect([first.artifact.version, second.artifact.version].sort()).toEqual([1, 2]); + expect([first.outcome, second.outcome].sort()).toEqual([ + "created", + "revised", + ]); + expect([first.artifact.version, second.artifact.version].sort()).toEqual([ + 1, 2, + ]); - const rows = await db.select().from(artifact).where(eq(artifact.tenantId, "acme")); + const rows = await db + .select() + .from(artifact) + .where(eq(artifact.tenantId, "acme")); expect(rows.length).toBe(1); const versions = await db .select() @@ -342,12 +368,17 @@ describe("find-or-version", () => { const artifactIds = new Set(results.map((r) => r.artifact.id)); expect(artifactIds.size).toBe(1); expect(results.filter((r) => r.outcome === "created").length).toBe(1); - expect(results.filter((r) => r.outcome === "revised").length).toBe(callerCount - 1); - expect(results.map((r) => r.artifact.version).sort((a, b) => a - b)).toEqual([ - 1, 2, 3, 4, 5, - ]); + expect(results.filter((r) => r.outcome === "revised").length).toBe( + callerCount - 1, + ); + expect( + results.map((r) => r.artifact.version).sort((a, b) => a - b), + ).toEqual([1, 2, 3, 4, 5]); - const rows = await db.select().from(artifact).where(eq(artifact.tenantId, "acme")); + const rows = await db + .select() + .from(artifact) + .where(eq(artifact.tenantId, "acme")); expect(rows.length).toBe(1); const versions = await db .select() @@ -392,7 +423,11 @@ describe("version history isolation", () => { const db = await testDb(); const a = await seedArtifact(db, { title: "A" }); const b = await seedArtifact(db, { title: "B" }); - await writeArtifactVersion(db, { scope: SCOPE, artifactId: a.id, content: "a2" }); + await writeArtifactVersion(db, { + scope: SCOPE, + artifactId: a.id, + content: "a2", + }); const bRows = await db .select() @@ -530,7 +565,10 @@ describe("version metadata and lineage", () => { title: "x", content: "y", source: { origin: "manual" }, - metadata: ["not", "an", "object"] as unknown as Record, + metadata: ["not", "an", "object"] as unknown as Record< + string, + unknown + >, }), ), ).rejects.toBeInstanceOf(ArtifactValidationError); @@ -609,4 +647,3 @@ describe("content digest", () => { expect(pinned?.contentSha256).toBeNull(); }); }); - diff --git a/e2e/content-store.test.ts b/e2e/content-store.test.ts index 10d612c..b34a579 100644 --- a/e2e/content-store.test.ts +++ b/e2e/content-store.test.ts @@ -57,7 +57,8 @@ for (const [name, store] of BACKENDS) { expect(normalizeSource(row.source).origin).toBe("imported"); const result = await resolveDownload(db, store, row, false); - if ("status" in result) throw new Error(`unexpected failure: ${result.error}`); + if ("status" in result) + throw new Error(`unexpected failure: ${result.error}`); expect(result.mimeType).toBe("image/png"); expect(result.filename).toBe("logo.png"); expect(result.disposition).toBe("attachment"); @@ -66,11 +67,18 @@ for (const [name, store] of BACKENDS) { test("a PDF is an attachment by default and inline only when asked", async () => { const db = await testDb(); - const row = await storeFile(db, store, "deck.pdf", "application/pdf", PDF); + const row = await storeFile( + db, + store, + "deck.pdf", + "application/pdf", + PDF, + ); const attached = await resolveDownload(db, store, row, false); const inlined = await resolveDownload(db, store, row, true); - if ("status" in attached || "status" in inlined) throw new Error("unexpected failure"); + if ("status" in attached || "status" in inlined) + throw new Error("unexpected failure"); expect(attached.disposition).toBe("attachment"); expect(inlined.disposition).toBe("inline"); }); @@ -85,7 +93,13 @@ for (const [name, store] of BACKENDS) { test("a filename that would break Content-Disposition is sanitized", async () => { const db = await testDb(); - const row = await storeFile(db, store, 'ev"il\nname.png', "image/png", PNG); + const row = await storeFile( + db, + store, + 'ev"il\nname.png', + "image/png", + PNG, + ); const result = await resolveDownload(db, store, row, false); if ("status" in result) throw new Error("unexpected failure"); expect(result.filename).not.toContain('"'); @@ -94,7 +108,13 @@ for (const [name, store] of BACKENDS) { test("version 1 is written for a file artifact too", async () => { const db = await testDb(); - const row = await storeFile(db, store, "a.txt", "text/plain", new Uint8Array([1])); + const row = await storeFile( + db, + store, + "a.txt", + "text/plain", + new Uint8Array([1]), + ); expect(row.version).toBe(1); }); }); @@ -103,7 +123,13 @@ for (const [name, store] of BACKENDS) { describe("InlineContentStore specifics", () => { test("keeps the artifact's text content empty and points at an upload row", async () => { const db = await testDb(); - const row = await storeFile(db, InlineContentStore, "a.png", "image/png", PNG); + const row = await storeFile( + db, + InlineContentStore, + "a.png", + "image/png", + PNG, + ); expect(row.content).toBe(""); const ref = uploadRefFromSource(row.source); @@ -117,17 +143,34 @@ describe("InlineContentStore specifics", () => { test("refuses to resolve an upload owned by another tenant", async () => { const db = await testDb(); - const row = await storeFile(db, InlineContentStore, "a.png", "image/png", PNG); + const row = await storeFile( + db, + InlineContentStore, + "a.png", + "image/png", + PNG, + ); const foreign = { ...row, tenantId: "other" }; expect(await InlineContentStore.get(db, foreign)).toBeNull(); - const result = await resolveDownload(db, InlineContentStore, foreign, false); + const result = await resolveDownload( + db, + InlineContentStore, + foreign, + false, + ); expect(result).toEqual({ status: 404, error: "Upload not found" }); }); test("a dangling upload reference is a 404, never a fall-through", async () => { const db = await testDb(); - const row = await storeFile(db, InlineContentStore, "a.png", "image/png", PNG); + const row = await storeFile( + db, + InlineContentStore, + "a.png", + "image/png", + PNG, + ); await db.delete(upload); expect(await resolveDownload(db, InlineContentStore, row, false)).toEqual({ @@ -140,7 +183,13 @@ describe("InlineContentStore specifics", () => { describe("DataUrlContentStore specifics", () => { test("carries the bytes inline with no side-table row", async () => { const db = await testDb(); - const row = await storeFile(db, DataUrlContentStore, "a.png", "image/png", PNG); + const row = await storeFile( + db, + DataUrlContentStore, + "a.png", + "image/png", + PNG, + ); expect(row.content.startsWith("data:image/png;base64,")).toBe(true); expect((await db.select().from(upload)).length).toBe(0); @@ -196,7 +245,11 @@ describe("download convention precedence", () => { test("an untitled csv-export still gets a usable filename", async () => { const db = await testDb(); - const row = await seedArtifact(db, { kind: "csv-export", title: ".csv", content: "x" }); + const row = await seedArtifact(db, { + kind: "csv-export", + title: ".csv", + content: "x", + }); const result = await resolveDownload(db, InlineContentStore, row, false); if ("status" in result) throw new Error("unexpected failure"); expect(result.filename).toBe("export.csv"); @@ -233,7 +286,9 @@ describe("helpers", () => { test("decodeDataUrl rejects anything that is not a base64 data URL", () => { expect(decodeDataUrl("hello")).toBeNull(); expect(decodeDataUrl("data:text/plain,hello")).toBeNull(); - expect(decodeDataUrl("data:text/plain;base64,aGk=")?.mimeType).toBe("text/plain"); + expect(decodeDataUrl("data:text/plain;base64,aGk=")?.mimeType).toBe( + "text/plain", + ); }); test("uploadRefFromSource rejects a malformed or absent reference", () => { @@ -242,7 +297,8 @@ describe("helpers", () => { expect(uploadRefFromSource({ upload: "nope" })).toBeNull(); expect(uploadRefFromSource({ upload: { id: "u1" } })).toBeNull(); expect( - uploadRefFromSource({ upload: { filename: "a", mimeType: "text/plain" } })?.size, + uploadRefFromSource({ upload: { filename: "a", mimeType: "text/plain" } }) + ?.size, ).toBe(0); }); }); diff --git a/e2e/file-round-trip.test.ts b/e2e/file-round-trip.test.ts index 2f1584b..fb6672f 100644 --- a/e2e/file-round-trip.test.ts +++ b/e2e/file-round-trip.test.ts @@ -36,13 +36,23 @@ async function download(id: string, version?: number): Promise { describe("upload, version, download on a filesystem ContentStore", () => { test("each version downloads its own bytes", async () => { - const v1Bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x00, 0xff, 0x10, 0x80]); + const v1Bytes = new Uint8Array([ + 0x25, 0x50, 0x44, 0x46, 0x00, 0xff, 0x10, 0x80, + ]); const v3Bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x01, 0x02]); const form = new FormData(); - form.append("files", new File([v1Bytes], "report.pdf", { type: "application/pdf" })); - const uploaded = await app.request("/api/artifacts/upload", { method: "POST", body: form }); + form.append( + "files", + new File([v1Bytes], "report.pdf", { type: "application/pdf" }), + ); + const uploaded = await app.request("/api/artifacts/upload", { + method: "POST", + body: form, + }); expect(uploaded.status).toBe(201); - const { artifacts } = (await uploaded.json()) as { artifacts: { id: string }[] }; + const { artifacts } = (await uploaded.json()) as { + artifacts: { id: string }[]; + }; const id = artifacts[0]!.id; const renamed = await app.request(`/api/artifacts/${id}/versions`, { @@ -53,7 +63,10 @@ describe("upload, version, download on a filesystem ContentStore", () => { expect(renamed.status).toBe(200); const revise = new FormData(); - revise.append("file", new File([v3Bytes], "report.pdf", { type: "application/pdf" })); + revise.append( + "file", + new File([v3Bytes], "report.pdf", { type: "application/pdf" }), + ); const revised = await app.request(`/api/artifacts/${id}/versions`, { method: "POST", body: revise, @@ -76,15 +89,22 @@ describe("upload, version, download on a filesystem ContentStore", () => { for (const version of [1, 3]) { const res = await app.request(`/api/artifacts/${id}/versions/${version}`); expect(res.status).toBe(200); - sources.push(((await res.json()) as { artifact: { source: unknown } }).artifact.source); + sources.push( + ((await res.json()) as { artifact: { source: unknown } }).artifact + .source, + ); } expect(sources[0]).toMatchObject({ upload: { size: v1Bytes.length } }); expect(sources[1]).toMatchObject({ upload: { size: v3Bytes.length } }); - const storedFiles = async () => (await readdir(dir, { recursive: true })).length; + const storedFiles = async () => + (await readdir(dir, { recursive: true })).length; const before = await storedFiles(); const stale = new FormData(); - stale.append("file", new File([v3Bytes], "report.pdf", { type: "application/pdf" })); + stale.append( + "file", + new File([v3Bytes], "report.pdf", { type: "application/pdf" }), + ); stale.append("expectedVersion", "1"); const conflict = await app.request(`/api/artifacts/${id}/versions`, { method: "POST", diff --git a/e2e/list.test.ts b/e2e/list.test.ts index 6719d7b..8cf7a15 100644 --- a/e2e/list.test.ts +++ b/e2e/list.test.ts @@ -36,7 +36,11 @@ describe("list projection", () => { const page = await listArtifacts(db, "acme", {}); expect(page.rows.map((r) => r.id)).toEqual([seeded.id]); - const row = page.rows[0] as { id: string; content?: string; title?: string }; + const row = page.rows[0] as { + id: string; + content?: string; + title?: string; + }; // The list query must not select the body column at all. expect("content" in row).toBe(false); expect(row.content).toBeUndefined(); @@ -76,12 +80,15 @@ describe("list filters", () => { test("search matches title or content and escapes ILIKE metacharacters", async () => { const db = await testDb(); await seedArtifact(db, { title: "Quarterly", content: "nothing" }); - await seedArtifact(db, { title: "Other", content: "mentions quarterly plans" }); + await seedArtifact(db, { + title: "Other", + content: "mentions quarterly plans", + }); await seedArtifact(db, { title: "100%", content: "literal" }); - expect((await listArtifacts(db, "acme", { query: "quarter" })).rows.length).toBe( - 2, - ); + expect( + (await listArtifacts(db, "acme", { query: "quarter" })).rows.length, + ).toBe(2); const percent = await listArtifacts(db, "acme", { query: "%" }); expect(percent.rows.map((r) => r.title)).toEqual(["100%"]); }); @@ -122,10 +129,15 @@ describe("list filters", () => { const db = await testDb(); const csv = await seedArtifact(db, { title: "Export", kind: "csv-export" }); await seedArtifact(db, { title: "Doc" }); - const theirs = await seedArtifact(db, { title: "Bot", ownerPrincipalId: "agent-9" }); + const theirs = await seedArtifact(db, { + title: "Bot", + ownerPrincipalId: "agent-9", + }); expect( - (await listArtifacts(db, "acme", { kind: "csv-export" })).rows.map((r) => r.id), + (await listArtifacts(db, "acme", { kind: "csv-export" })).rows.map( + (r) => r.id, + ), ).toEqual([csv.id]); expect( ( @@ -148,11 +160,12 @@ describe("list paging", () => { const seen: string[] = []; let cursor: string | null = null; for (let page = 0; page < 5; page += 1) { - const result: Awaited> = await listArtifacts( - db, - "acme", - parseQuery({ limit: "2", ...(cursor ? { cursor } : {}) }), - ); + const result: Awaited> = + await listArtifacts( + db, + "acme", + parseQuery({ limit: "2", ...(cursor ? { cursor } : {}) }), + ); seen.push(...result.rows.map((r) => r.id)); cursor = result.nextCursor; if (cursor === null) break; @@ -183,7 +196,11 @@ describe("list paging", () => { }); test("a malformed cursor is rejected by the query schema", () => { - for (const cursor of ["garbage", "not-a-date__abc", `${new Date().toISOString()}__`]) { + for (const cursor of [ + "garbage", + "not-a-date__abc", + `${new Date().toISOString()}__`, + ]) { expect(ListArtifactsQuery({ cursor })).toBeInstanceOf(type.errors); } }); @@ -198,7 +215,11 @@ describe("list paging", () => { FROM generate_series(1, ${MAX_LIST_LIMIT + 5}) AS i `); - const huge = await listArtifacts(db, "acme", parseQuery({ limit: "10000" })); + const huge = await listArtifacts( + db, + "acme", + parseQuery({ limit: "10000" }), + ); expect(huge.rows.length).toBe(MAX_LIST_LIMIT); expect(huge.nextCursor).not.toBeNull(); @@ -206,7 +227,11 @@ describe("list paging", () => { expect(zero.rows.length).toBe(1); // A non-numeric `?limit=` must take the default, not collapse to one row. - const garbage = await listArtifacts(db, "acme", parseQuery({ limit: "abc" })); + const garbage = await listArtifacts( + db, + "acme", + parseQuery({ limit: "abc" }), + ); expect(garbage.rows.length).toBe(DEFAULT_LIST_LIMIT); // An absent limit defaults at the schema. @@ -228,11 +253,12 @@ describe("list paging", () => { const seen: string[] = []; let cursor: string | null = null; for (let page = 0; page < 6; page += 1) { - const result: Awaited> = await listArtifacts( - db, - "acme", - parseQuery({ limit: "1", ...(cursor ? { cursor } : {}) }), - ); + const result: Awaited> = + await listArtifacts( + db, + "acme", + parseQuery({ limit: "1", ...(cursor ? { cursor } : {}) }), + ); seen.push(...result.rows.map((r) => r.id)); cursor = result.nextCursor; if (cursor === null) break; diff --git a/e2e/migrations.test.ts b/e2e/migrations.test.ts index 1606d58..e8f95ed 100644 --- a/e2e/migrations.test.ts +++ b/e2e/migrations.test.ts @@ -58,7 +58,10 @@ describe("runArtifactMigrations", () => { DROP COLUMN "metadata", DROP COLUMN "parent_version_ids", DROP COLUMN "content_sha256" `); await runArtifactMigrations(testDb.config, { schema: "public" }); - const columns = await testDb.db.execute<{ column: string; type: string }>(sql` + const columns = await testDb.db.execute<{ + column: string; + type: string; + }>(sql` SELECT table_name || '.' || column_name AS column, udt_name AS type FROM information_schema.columns WHERE table_schema = 'artifacts' diff --git a/e2e/mount.test.ts b/e2e/mount.test.ts index da1e64b..c3fab91 100644 --- a/e2e/mount.test.ts +++ b/e2e/mount.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from "bun:test"; import { sql } from "drizzle-orm"; import { Hono } from "hono"; -import { createRequireGrant, type RequireGrant, type TenantEnv } from "@intx/hub-api"; +import { + createRequireGrant, + type RequireGrant, + type TenantEnv, +} from "@intx/hub-api"; import { createInMemoryGrantStore } from "@intx/authz"; import type { GrantRule } from "@intx/types/authz"; import { createArtifactRoutes } from "../src/mount.js"; @@ -26,7 +30,10 @@ import { testDb } from "./helpers.js"; /** Places tenant/principal on the context the way a real host's session * middleware does, without pinning it to any one `requireGrant` wiring. */ -function withPrincipal(app: Hono, principal: ResolvedPrincipal | null) { +function withPrincipal( + app: Hono, + principal: ResolvedPrincipal | null, +) { app.use("*", async (c, next) => { if (principal !== null) { const now = new Date(0); @@ -57,7 +64,10 @@ function withPrincipal(app: Hono, principal: ResolvedPrincipal | null /** A grant minted for exactly one resource/action/principal — deny is simply * not minting the matching one, which is how the real store discriminates. */ -function grantRule(over: Partial & Pick): GrantRule { +function grantRule( + over: Partial & + Pick, +): GrantRule { return { id: `grant-${over.resource}-${over.action}-${over.principalId}`, effect: "allow", @@ -149,7 +159,11 @@ describe("POST /artifacts", () => { const res = await app.request( "/artifacts", - json({ mode: "url", title: " Docs ", content: "https://example.com/a" }), + json({ + mode: "url", + title: " Docs ", + content: "https://example.com/a", + }), ); expect(res.status).toBe(201); const body = (await res.json()) as { artifact: Record }; @@ -168,7 +182,10 @@ describe("POST /artifacts", () => { json({ mode: "text", title: "Notes", content: "body" }), ); const body = (await res.json()) as { artifact: Record }; - expect(body.artifact).toMatchObject({ kind: "document", source: { origin: "manual" } }); + expect(body.artifact).toMatchObject({ + kind: "document", + source: { origin: "manual" }, + }); }); test("rejects a non-http URL, a non-URL, and an empty field", async () => { @@ -194,7 +211,12 @@ describe("POST /artifacts", () => { const db = await testDb(); const res = await host(db).request( "/artifacts", - json({ mode: "text", title: "Notes", content: "body", metadata: { kind: "run", stage: "draft" } }), + json({ + mode: "text", + title: "Notes", + content: "body", + metadata: { kind: "run", stage: "draft" }, + }), ); expect(res.status).toBe(201); const body = (await res.json()) as { artifact: Record }; @@ -353,7 +375,10 @@ describe("GET /artifacts", () => { title: "From a run", source: { origin: "workflow", runId: "run-1" }, }); - await seedArtifact(db, { title: "Hand written", source: { origin: "manual" } }); + await seedArtifact(db, { + title: "Hand written", + source: { origin: "manual" }, + }); // A realistic host decorator: it joins on an id it finds in `source`, and // has nothing to say about a row that carries none. @@ -363,7 +388,8 @@ describe("GET /artifacts", () => { for (const row of rows) { const runId = row.source.runId; const name = typeof runId === "string" ? runs.get(runId) : undefined; - if (name !== undefined) (row as Record).sessionName = name; + if (name !== undefined) + (row as Record).sessionName = name; } }, }); @@ -382,7 +408,9 @@ describe("GET /artifacts", () => { const db = await testDb(); const app = host(db); expect((await app.request("/artifacts?cursor=garbage")).status).toBe(400); - expect((await app.request("/artifacts?createdAfter=nonsense")).status).toBe(400); + expect((await app.request("/artifacts?createdAfter=nonsense")).status).toBe( + 400, + ); }); test("a caller-supplied tenant cannot widen the resolved scope", async () => { @@ -403,7 +431,9 @@ describe("GET /artifacts/:id", () => { const res = await host(db).request(`/artifacts/${row.id}`); expect(res.status).toBe(200); - const body = (await res.json()) as { artifact: { archivedAt: string | null } }; + const body = (await res.json()) as { + artifact: { archivedAt: string | null }; + }; expect(body.artifact.archivedAt).not.toBeNull(); }); @@ -416,7 +446,8 @@ describe("GET /artifacts/:id", () => { const db = await testDb(); const app = host(db); expect( - (await app.request("/artifacts/00000000-0000-0000-0000-000000000000")).status, + (await app.request("/artifacts/00000000-0000-0000-0000-000000000000")) + .status, ).toBe(404); const foreign = await seedArtifact(db, { tenantId: "other" }); @@ -489,8 +520,11 @@ describe("every way of not getting an artifact is indistinguishable", () => { const db = await testDb(); const foreign = await seedArtifact(db, { tenantId: "other" }); expect( - (await host(db).request(`/artifacts/${foreign.id}/archive`, { method: "POST" })) - .status, + ( + await host(db).request(`/artifacts/${foreign.id}/archive`, { + method: "POST", + }) + ).status, ).toBe(404); const rows = await db.execute<{ archived_at: Date | null }>( sql`SELECT "archived_at" FROM "artifacts"."artifact" WHERE "id" = ${foreign.id}`, @@ -505,10 +539,18 @@ describe("every way of not getting an artifact is indistinguishable", () => { const db = await testDb(); const real = await seedArtifact(db); const app = host(db, { principal: null }); - for (const id of [real.id, "00000000-0000-4000-8000-000000000000", "not-a-uuid"]) { + for (const id of [ + real.id, + "00000000-0000-4000-8000-000000000000", + "not-a-uuid", + ]) { for (const [path, init] of detailRoutes(id)) { const res = await app.request(path, init); - expect({ id, path: path.replace(id, ":id"), status: res.status }).toEqual({ + expect({ + id, + path: path.replace(id, ":id"), + status: res.status, + }).toEqual({ id, path: path.replace(id, ":id"), status: 403, @@ -557,7 +599,10 @@ describe("versions", () => { const app = host(db); const row = await seedArtifact(db, { title: "Draft", content: "v1" }); - const res = await app.request(`/artifacts/${row.id}/versions`, json({ content: "v2" })); + const res = await app.request( + `/artifacts/${row.id}/versions`, + json({ content: "v2" }), + ); expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ version: 2, title: "Draft" }); @@ -582,7 +627,9 @@ describe("versions", () => { expect(page1.nextCursor).toBe("2"); const page2 = (await ( - await app.request(`/artifacts/${row.id}/versions?limit=2&cursor=${page1.nextCursor}`) + await app.request( + `/artifacts/${row.id}/versions?limit=2&cursor=${page1.nextCursor}`, + ) ).json()) as { versions: { version: number }[]; nextCursor: string | null }; expect(page2.versions.map((v) => v.version)).toEqual([1]); expect(page2.nextCursor).toBeNull(); @@ -595,7 +642,10 @@ describe("versions", () => { const res = await app.request( `/artifacts/${row.id}/versions`, - json({ content: "v2", metadata: { kind: "run", supersededByNodeId: null } }), + json({ + content: "v2", + metadata: { kind: "run", supersededByNodeId: null }, + }), ); expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ @@ -608,19 +658,34 @@ describe("versions", () => { const db = await testDb(); const app = host(db); const row = await seedArtifact(db, { title: "Draft", content: "v1" }); - await app.request(`/artifacts/${row.id}/versions`, json({ metadata: { kind: "run" } })); + await app.request( + `/artifacts/${row.id}/versions`, + json({ metadata: { kind: "run" } }), + ); - const res = await app.request(`/artifacts/${row.id}/versions`, json({ content: "v3" })); - expect(await res.json()).toMatchObject({ version: 3, metadata: { kind: "run" } }); + const res = await app.request( + `/artifacts/${row.id}/versions`, + json({ content: "v3" }), + ); + expect(await res.json()).toMatchObject({ + version: 3, + metadata: { kind: "run" }, + }); }); test("revise with an explicit null metadata clears it", async () => { const db = await testDb(); const app = host(db); const row = await seedArtifact(db, { title: "Draft", content: "v1" }); - await app.request(`/artifacts/${row.id}/versions`, json({ metadata: { kind: "run" } })); + await app.request( + `/artifacts/${row.id}/versions`, + json({ metadata: { kind: "run" } }), + ); - const res = await app.request(`/artifacts/${row.id}/versions`, json({ metadata: null })); + const res = await app.request( + `/artifacts/${row.id}/versions`, + json({ metadata: null }), + ); expect(await res.json()).toMatchObject({ version: 3, metadata: null }); }); @@ -629,9 +694,15 @@ describe("versions", () => { const app = host(db); const row = await seedArtifact(db, { title: "Draft", content: "v1" }); - const res = await app.request(`/artifacts/${row.id}/versions`, json({ metadata: { stage: "final" } })); + const res = await app.request( + `/artifacts/${row.id}/versions`, + json({ metadata: { stage: "final" } }), + ); expect(res.status).toBe(200); - expect(await res.json()).toMatchObject({ version: 2, metadata: { stage: "final" } }); + expect(await res.json()).toMatchObject({ + version: 2, + metadata: { stage: "final" }, + }); }); test("revise rejects a metadata value that is not a JSON object", async () => { @@ -660,7 +731,10 @@ describe("versions", () => { test("a body with neither title nor content is 400", async () => { const db = await testDb(); const row = await seedArtifact(db); - const res = await host(db).request(`/artifacts/${row.id}/versions`, json({})); + const res = await host(db).request( + `/artifacts/${row.id}/versions`, + json({}), + ); expect(res.status).toBe(400); }); @@ -687,7 +761,10 @@ describe("versions", () => { json({ content: "v2", expectedVersion: 5 }), ); expect(res.status).toBe(409); - expect(await res.json()).toEqual({ error: "Version conflict", currentVersion: 1 }); + expect(await res.json()).toEqual({ + error: "Version conflict", + currentVersion: 1, + }); const history = (await ( await app.request(`/artifacts/${row.id}/versions`) @@ -700,7 +777,10 @@ describe("versions", () => { const app = host(db); const row = await seedArtifact(db, { title: "Draft", content: "v1" }); - const res = await app.request(`/artifacts/${row.id}/versions`, json({ content: "v2" })); + const res = await app.request( + `/artifacts/${row.id}/versions`, + json({ content: "v2" }), + ); expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ version: 2 }); }); @@ -721,7 +801,10 @@ describe("versions", () => { const db = await testDb(); const row = await seedArtifact(db); await setArtifactArchived(db, row, true); - const res = await host(db).request(`/artifacts/${row.id}/versions`, json({ content: "x" })); + const res = await host(db).request( + `/artifacts/${row.id}/versions`, + json({ content: "x" }), + ); expect(res.status).toBe(404); }); }); @@ -731,21 +814,42 @@ describe("GET /artifacts/:id/versions/:version", () => { const db = await testDb(); const app = host(db); const row = await seedArtifact(db, { title: "Draft", content: "v1" }); - await app.request(`/artifacts/${row.id}/versions`, json({ title: "Final", content: "v2" })); + await app.request( + `/artifacts/${row.id}/versions`, + json({ title: "Final", content: "v2" }), + ); const res = await app.request(`/artifacts/${row.id}/versions/1`); expect(res.status).toBe(200); const body = (await res.json()) as { - artifact: { version: number; title: string; content: string; contentSha256: string | null }; + artifact: { + version: number; + title: string; + content: string; + contentSha256: string | null; + }; }; - expect(body.artifact).toMatchObject({ version: 1, title: "Draft", content: "v1" }); + expect(body.artifact).toMatchObject({ + version: 1, + title: "Draft", + content: "v1", + }); expect(body.artifact.contentSha256).toBe(sha256Hex("v1")); const current = await app.request(`/artifacts/${row.id}/versions/2`); const currentBody = (await current.json()) as { - artifact: { version: number; title: string; content: string; contentSha256: string | null }; + artifact: { + version: number; + title: string; + content: string; + contentSha256: string | null; + }; }; - expect(currentBody.artifact).toMatchObject({ version: 2, title: "Final", content: "v2" }); + expect(currentBody.artifact).toMatchObject({ + version: 2, + title: "Final", + content: "v2", + }); expect(currentBody.artifact.contentSha256).toBe(sha256Hex("v2")); }); @@ -777,7 +881,9 @@ describe("GET /artifacts/:id/versions/:version", () => { test("is 403 for an unauthenticated caller", async () => { const db = await testDb(); const row = await seedArtifact(db); - const res = await host(db, { principal: null }).request(`/artifacts/${row.id}/versions/1`); + const res = await host(db, { principal: null }).request( + `/artifacts/${row.id}/versions/1`, + ); expect(res.status).toBe(403); }); }); @@ -850,7 +956,10 @@ describe("archive authorization", () => { */ describe("authorization through the real platform grant evaluator", () => { const OWNER: ResolvedPrincipal = SCOPE; - const NON_OWNER: ResolvedPrincipal = { tenantId: SCOPE.tenantId, principalId: "someone-else" }; + const NON_OWNER: ResolvedPrincipal = { + tenantId: SCOPE.tenantId, + principalId: "someone-else", + }; function hostWithGrants( db: ArtifactDb, @@ -875,7 +984,11 @@ describe("authorization through the real platform grant evaluator", () => { const db = await testDb(); const row = await seedArtifact(db, { content: "v1" }); const grants = [ - grantRule({ resource: `artifact:${row.id}`, action: "write", principalId: OWNER.principalId }), + grantRule({ + resource: `artifact:${row.id}`, + action: "write", + principalId: OWNER.principalId, + }), ]; const ownerRes = await hostWithGrants(db, OWNER, grants).request( @@ -901,7 +1014,11 @@ describe("authorization through the real platform grant evaluator", () => { test("creating needs a create grant on artifact:*; without one, nothing is written", async () => { const db = await testDb(); const grants = [ - grantRule({ resource: "artifact:*", action: "create", principalId: OWNER.principalId }), + grantRule({ + resource: "artifact:*", + action: "create", + principalId: OWNER.principalId, + }), ]; const body = { mode: "text", title: "Gated", content: "body" }; const form = () => { @@ -914,9 +1031,15 @@ describe("authorization through the real platform grant evaluator", () => { const ungranted = hostWithGrants(db, NON_OWNER, grants); expect((await granted.request("/artifacts", json(body))).status).toBe(201); - expect((await granted.request("/artifacts/upload", form())).status).toBe(201); - expect((await ungranted.request("/artifacts", json(body))).status).toBe(403); - expect((await ungranted.request("/artifacts/upload", form())).status).toBe(403); + expect((await granted.request("/artifacts/upload", form())).status).toBe( + 201, + ); + expect((await ungranted.request("/artifacts", json(body))).status).toBe( + 403, + ); + expect((await ungranted.request("/artifacts/upload", form())).status).toBe( + 403, + ); const rows = await listArtifacts(db, SCOPE.tenantId, {}); expect(rows.rows.length).toBe(2); @@ -927,12 +1050,17 @@ describe("authorization through the real platform grant evaluator", () => { const row = await seedArtifact(db); // The owner can write, but was never granted archive. const grants = [ - grantRule({ resource: `artifact:${row.id}`, action: "write", principalId: OWNER.principalId }), + grantRule({ + resource: `artifact:${row.id}`, + action: "write", + principalId: OWNER.principalId, + }), ]; const app = hostWithGrants(db, OWNER, grants); expect( - (await app.request(`/artifacts/${row.id}/archive`, { method: "POST" })).status, + (await app.request(`/artifacts/${row.id}/archive`, { method: "POST" })) + .status, ).toBe(403); const [current] = await db.execute<{ archived_at: Date | null }>( sql`SELECT "archived_at" FROM "artifacts"."artifact" WHERE "id" = ${row.id}`, @@ -945,7 +1073,8 @@ describe("authorization through the real platform grant evaluator", () => { const row = await seedArtifact(db); const app = hostWithGrants(db, OWNER, []); expect( - (await app.request(`/artifacts/${row.id}/archive`, { method: "POST" })).status, + (await app.request(`/artifacts/${row.id}/archive`, { method: "POST" })) + .status, ).toBe(403); }); @@ -967,7 +1096,10 @@ describe("authorization through the real platform grant evaluator", () => { ["ghost id", "00000000-0000-4000-8000-000000000000"], ["cross-tenant", foreign.id], ] as [string, string][]) { - const res = await app.request(`/artifacts/${id}/versions`, json({ content: "x" })); + const res = await app.request( + `/artifacts/${id}/versions`, + json({ content: "x" }), + ); expect({ cause, status: res.status, body: await res.json() }).toEqual({ cause, status: 404, @@ -1027,7 +1159,10 @@ describe("onArtifactCreated: the host's grant-provisioning seam", () => { }), ); - await app.request("/artifacts", json({ mode: "text", title: "Orphan?", content: "body" })); + await app.request( + "/artifacts", + json({ mode: "text", title: "Orphan?", content: "body" }), + ); const rows = await listArtifacts(db, SCOPE.tenantId, {}); expect(rows.rows.length).toBe(0); }); @@ -1050,7 +1185,10 @@ describe("onArtifactCreated: the host's grant-provisioning seam", () => { const form = new FormData(); form.append("files", new File(["a"], "a.txt", { type: "text/plain" })); form.append("files", new File(["b"], "b.txt", { type: "text/plain" })); - const res = await app.request("/artifacts/upload", { method: "POST", body: form }); + const res = await app.request("/artifacts/upload", { + method: "POST", + body: form, + }); const body = (await res.json()) as { artifacts: { id: string }[] }; expect(ids.sort()).toEqual(body.artifacts.map((a) => a.id).sort()); }); @@ -1067,21 +1205,32 @@ describe("POST /artifacts/upload", () => { test("mints one artifact per file and serves the bytes back", async () => { const db = await testDb(); const app = host(db); - const png = new File([new Uint8Array([1, 2, 3])], "logo.png", { type: "image/png" }); + const png = new File([new Uint8Array([1, 2, 3])], "logo.png", { + type: "image/png", + }); const txt = new File(["hello"], "notes.txt", { type: "text/plain" }); - const res = await app.request("/artifacts/upload", form([png, txt], "Import")); + const res = await app.request( + "/artifacts/upload", + form([png, txt], "Import"), + ); expect(res.status).toBe(201); - const body = (await res.json()) as { artifacts: { id: string; kind: string }[] }; + const body = (await res.json()) as { + artifacts: { id: string; kind: string }[]; + }; expect(body.artifacts.map((a) => a.kind)).toEqual(["image", "file"]); - const download = await app.request(`/artifacts/${body.artifacts[0]!.id}/download`); + const download = await app.request( + `/artifacts/${body.artifacts[0]!.id}/download`, + ); expect(download.headers.get("content-type")).toBe("image/png"); expect(download.headers.get("x-content-type-options")).toBe("nosniff"); expect(download.headers.get("content-disposition")).toBe( 'attachment; filename="logo.png"', ); - expect(new Uint8Array(await download.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3])); + expect(new Uint8Array(await download.arrayBuffer())).toEqual( + new Uint8Array([1, 2, 3]), + ); }); test("rejects an empty body, an unsupported type, and too many files", async () => { @@ -1102,7 +1251,9 @@ describe("POST /artifacts/upload", () => { { length: MAX_UPLOAD_FILE_COUNT + 1 }, (_v, i) => new File(["a"], `f${i}.txt`, { type: "text/plain" }), ); - expect((await app.request("/artifacts/upload", form(many))).status).toBe(413); + expect((await app.request("/artifacts/upload", form(many))).status).toBe( + 413, + ); }); test("one rejected file aborts the whole batch — no partial import", async () => { @@ -1111,8 +1262,12 @@ describe("POST /artifacts/upload", () => { const ok = new File(["a"], "good.txt", { type: "text/plain" }); const bad = new File(["b"], "bad.svg", { type: "image/svg+xml" }); - expect((await app.request("/artifacts/upload", form([ok, bad]))).status).toBe(415); - const list = (await (await app.request("/artifacts")).json()) as { artifacts: unknown[] }; + expect( + (await app.request("/artifacts/upload", form([ok, bad]))).status, + ).toBe(415); + const list = (await (await app.request("/artifacts")).json()) as { + artifacts: unknown[]; + }; expect(list.artifacts.length).toBe(0); }); @@ -1129,13 +1284,21 @@ describe("POST /artifacts/upload", () => { const db = await testDb(); const app = host(db); - const over = new File([bulk(MAX_UPLOAD_BYTES + 1)], "huge.txt", { type: "text/plain" }); + const over = new File([bulk(MAX_UPLOAD_BYTES + 1)], "huge.txt", { + type: "text/plain", + }); const rejected = await app.request("/artifacts/upload", form([over])); expect(rejected.status).toBe(413); - expect(((await rejected.json()) as { error: string }).error).toContain("huge.txt"); + expect(((await rejected.json()) as { error: string }).error).toContain( + "huge.txt", + ); - const atLimit = new File([bulk(MAX_UPLOAD_BYTES)], "exact.txt", { type: "text/plain" }); - expect((await app.request("/artifacts/upload", form([atLimit]))).status).toBe(201); + const atLimit = new File([bulk(MAX_UPLOAD_BYTES)], "exact.txt", { + type: "text/plain", + }); + expect( + (await app.request("/artifacts/upload", form([atLimit]))).status, + ).toBe(201); }); test("a batch over the 100MB aggregate is 413 even though every file is legal", async () => { @@ -1154,9 +1317,13 @@ describe("POST /artifacts/upload", () => { const res = await app.request("/artifacts/upload", form(files)); expect(res.status).toBe(413); - expect(((await res.json()) as { error: string }).error).toContain("aggregate"); + expect(((await res.json()) as { error: string }).error).toContain( + "aggregate", + ); - const list = (await (await app.request("/artifacts")).json()) as { artifacts: unknown[] }; + const list = (await (await app.request("/artifacts")).json()) as { + artifacts: unknown[]; + }; expect(list.artifacts.length).toBe(0); }); @@ -1170,7 +1337,9 @@ describe("POST /artifacts/upload", () => { ); const res = await app.request("/artifacts/upload", form(files)); expect(res.status).toBe(201); - expect(((await res.json()) as { artifacts: unknown[] }).artifacts.length).toBe(files.length); + expect( + ((await res.json()) as { artifacts: unknown[] }).artifacts.length, + ).toBe(files.length); }, 60_000); }); @@ -1204,7 +1373,9 @@ describe("download over HTTP", () => { const id = created.artifacts[0]!.id; expect( - (await app.request(`/artifacts/${id}/download`)).headers.get("content-disposition"), + (await app.request(`/artifacts/${id}/download`)).headers.get( + "content-disposition", + ), ).toStartWith("attachment;"); expect( (await app.request(`/artifacts/${id}/download?inline=1`)).headers.get( @@ -1216,15 +1387,24 @@ describe("download over HTTP", () => { test("a non-downloadable kind is 400", async () => { const db = await testDb(); const row = await seedArtifact(db, { kind: "document" }); - expect((await host(db).request(`/artifacts/${row.id}/download`)).status).toBe(400); + expect( + (await host(db).request(`/artifacts/${row.id}/download`)).status, + ).toBe(400); }); test("?version=N downloads that version's content; omitted downloads current", async () => { const db = await testDb(); const app = host(db); - const row = await seedArtifact(db, { kind: "csv-export", title: "Keywords", content: "a,b" }); + const row = await seedArtifact(db, { + kind: "csv-export", + title: "Keywords", + content: "a,b", + }); // The revise route trims content (TrimmedNonEmpty), same as create. - await app.request(`/artifacts/${row.id}/versions`, json({ content: "c,d" })); + await app.request( + `/artifacts/${row.id}/versions`, + json({ content: "c,d" }), + ); const v1 = await app.request(`/artifacts/${row.id}/download?version=1`); expect(await v1.text()).toBe("a,b"); @@ -1236,14 +1416,18 @@ describe("download over HTTP", () => { test("download with an unknown ?version is 404", async () => { const db = await testDb(); const row = await seedArtifact(db, { kind: "csv-export" }); - const res = await host(db).request(`/artifacts/${row.id}/download?version=99`); + const res = await host(db).request( + `/artifacts/${row.id}/download?version=99`, + ); expect(res.status).toBe(404); }); test("download with a non-integer ?version is 400", async () => { const db = await testDb(); const row = await seedArtifact(db, { kind: "csv-export" }); - const res = await host(db).request(`/artifacts/${row.id}/download?version=0`); + const res = await host(db).request( + `/artifacts/${row.id}/download?version=0`, + ); expect(res.status).toBe(400); }); @@ -1259,16 +1443,26 @@ describe("download over HTTP", () => { ).json()) as { artifacts: { id: string }[] }; const id = created.artifacts[0]!.id; const revise = new FormData(); - revise.append("file", new File([second], "chart-v2.png", { type: "image/png" })); + revise.append( + "file", + new File([second], "chart-v2.png", { type: "image/png" }), + ); const revised = await app.request(`/artifacts/${id}/versions`, { method: "POST", body: revise, }); expect(revised.status).toBe(200); - expect(await revised.json()).toMatchObject({ version: 2, title: "chart.png" }); + expect(await revised.json()).toMatchObject({ + version: 2, + title: "chart.png", + }); const bytesOf = async (query: string) => - new Uint8Array(await (await app.request(`/artifacts/${id}/download${query}`)).arrayBuffer()); + new Uint8Array( + await ( + await app.request(`/artifacts/${id}/download${query}`) + ).arrayBuffer(), + ); expect(await bytesOf("?version=1")).toEqual(first); expect(await bytesOf("?version=2")).toEqual(second); expect(await bytesOf("")).toEqual(second); @@ -1278,15 +1472,23 @@ describe("download over HTTP", () => { const db = await testDb(); const app = host(db); const data = new FormData(); - data.append("files", new File([new Uint8Array([1])], "a.png", { type: "image/png" })); + data.append( + "files", + new File([new Uint8Array([1])], "a.png", { type: "image/png" }), + ); const created = (await ( await app.request("/artifacts/upload", { method: "POST", body: data }) ).json()) as { artifacts: { id: string }[] }; const id = created.artifacts[0]!.id; const revise = new FormData(); - revise.append("file", new File([new Uint8Array([2])], "b.png", { type: "image/png" })); + revise.append( + "file", + new File([new Uint8Array([2])], "b.png", { type: "image/png" }), + ); const body = new Request("http://x", { method: "POST", body: revise }); - const contentType = body.headers.get("content-type")!.replace("multipart/form-data", "Multipart/Form-Data"); + const contentType = body.headers + .get("content-type")! + .replace("multipart/form-data", "Multipart/Form-Data"); const revised = await app.request(`/artifacts/${id}/versions`, { method: "POST", headers: { "content-type": contentType }, @@ -1302,8 +1504,12 @@ describe("download over HTTP", () => { const reviseWith = (id: string, file: File, expectedVersion?: string) => { const form = new FormData(); form.append("file", file); - if (expectedVersion !== undefined) form.append("expectedVersion", expectedVersion); - return app.request(`/artifacts/${id}/versions`, { method: "POST", body: form }); + if (expectedVersion !== undefined) + form.append("expectedVersion", expectedVersion); + return app.request(`/artifacts/${id}/versions`, { + method: "POST", + body: form, + }); }; const png = new File([new Uint8Array([1])], "a.png", { type: "image/png" }); @@ -1316,7 +1522,9 @@ describe("download over HTTP", () => { await app.request("/artifacts/upload", { method: "POST", body: data }) ).json()) as { artifacts: { id: string }[] }; const id = created.artifacts[0]!.id; - const pdf = new File([new Uint8Array([2])], "a.pdf", { type: "application/pdf" }); + const pdf = new File([new Uint8Array([2])], "a.pdf", { + type: "application/pdf", + }); expect((await reviseWith(id, pdf)).status).toBe(400); expect((await reviseWith(id, png, "7")).status).toBe(409); expect((await reviseWith(id, png, "zero")).status).toBe(400); @@ -1337,7 +1545,9 @@ describe("download over HTTP", () => { ).json()) as { artifacts: { id: string; version: number }[] }; const { id, version } = created.artifacts[0]!; - const res = await app.request(`/artifacts/${id}/download?version=${version}`); + const res = await app.request( + `/artifacts/${id}/download?version=${version}`, + ); expect(res.status).toBe(200); expect(new Uint8Array(await res.arrayBuffer())).toEqual(bytes); }); @@ -1384,11 +1594,15 @@ describe("post-commit side effects never turn a committed write into a 500", () const row = await seedArtifact(db, { title: "Put away" }); const app = host(db, exploding); - const archived = await app.request(`/artifacts/${row.id}/archive`, { method: "POST" }); + const archived = await app.request(`/artifacts/${row.id}/archive`, { + method: "POST", + }); expect(archived.status).toBe(200); expect(((await archived.json()) as any).artifact.archivedAt).not.toBeNull(); - const restored = await app.request(`/artifacts/${row.id}/unarchive`, { method: "POST" }); + const restored = await app.request(`/artifacts/${row.id}/unarchive`, { + method: "POST", + }); expect(restored.status).toBe(200); expect(((await restored.json()) as any).artifact.archivedAt).toBeNull(); }); @@ -1433,24 +1647,36 @@ describe("no-principal response: every route matches the cross-core rule", () => const app = host(db, { principal: null }); expect( - (await app.request("/artifacts", json({ mode: "text", title: "t", content: "b" }))) - .status, + ( + await app.request( + "/artifacts", + json({ mode: "text", title: "t", content: "b" }), + ) + ).status, ).toBe(403); const form = new FormData(); form.append("file", new File(["hi"], "a.txt", { type: "text/plain" })); expect( - (await app.request("/artifacts/upload", { method: "POST", body: form })).status, + (await app.request("/artifacts/upload", { method: "POST", body: form })) + .status, ).toBe(403); expect( - (await app.request(`/artifacts/${row.id}/versions`, json({ content: "x" }))).status, + ( + await app.request( + `/artifacts/${row.id}/versions`, + json({ content: "x" }), + ) + ).status, ).toBe(403); expect( - (await app.request(`/artifacts/${row.id}/archive`, { method: "POST" })).status, + (await app.request(`/artifacts/${row.id}/archive`, { method: "POST" })) + .status, ).toBe(403); expect( - (await app.request(`/artifacts/${row.id}/unarchive`, { method: "POST" })).status, + (await app.request(`/artifacts/${row.id}/unarchive`, { method: "POST" })) + .status, ).toBe(403); }); @@ -1460,7 +1686,10 @@ describe("no-principal response: every route matches the cross-core rule", () => const db = await testDb(); const before = await listArtifacts(db, SCOPE.tenantId, {}); const app = host(db, { principal: null }); - await app.request("/artifacts", json({ mode: "text", title: "ghost", content: "b" })); + await app.request( + "/artifacts", + json({ mode: "text", title: "ghost", content: "b" }), + ); const after = await listArtifacts(db, SCOPE.tenantId, {}); expect(after.rows.length).toBe(before.rows.length); }); @@ -1473,7 +1702,9 @@ describe("hardening regressions", () => { const revise = (app: Hono) => app.request(`/artifacts/${row.id}/versions`, json({ content: "hijack" })); - expect((await revise(host(db, { authorize: () => false }))).status).toBe(403); + expect((await revise(host(db, { authorize: () => false }))).status).toBe( + 403, + ); const checks: { resource: string; action: string }[] = []; const allowed = host(db, { @@ -1483,7 +1714,9 @@ describe("hardening regressions", () => { }, }); expect((await revise(allowed)).status).toBe(200); - expect(checks).toEqual([{ resource: `artifact:${row.id}`, action: "write" }]); + expect(checks).toEqual([ + { resource: `artifact:${row.id}`, action: "write" }, + ]); }); test("kind must agree with mode on import", async () => { @@ -1496,7 +1729,12 @@ describe("hardening regressions", () => { expect(linkAsText.status).toBe(400); const docAsUrl = await app.request( "/artifacts", - json({ mode: "url", title: "t", content: "https://x.example", kind: "document" }), + json({ + mode: "url", + title: "t", + content: "https://x.example", + kind: "document", + }), ); expect(docAsUrl.status).toBe(400); }); @@ -1507,7 +1745,10 @@ describe("hardening regressions", () => { const form = new FormData(); form.append("file", new File(["x"], "a.txt", { type: "text/plain" })); form.append("comment", "stray text field"); - const res = await app.request("/artifacts/upload", { method: "POST", body: form }); + const res = await app.request("/artifacts/upload", { + method: "POST", + body: form, + }); expect(res.status).toBe(201); const { artifacts } = (await res.json()) as any; expect(artifacts[0].source.generatedBy).toBeUndefined(); @@ -1516,7 +1757,8 @@ describe("hardening regressions", () => { over.append("file", new File(["x"], "a.txt", { type: "text/plain" })); over.append("generatedBy", "x".repeat(201)); expect( - (await app.request("/artifacts/upload", { method: "POST", body: over })).status, + (await app.request("/artifacts/upload", { method: "POST", body: over })) + .status, ).toBe(400); }); @@ -1524,8 +1766,14 @@ describe("hardening regressions", () => { const db = await testDb(); const app = host(db); const form = new FormData(); - form.append("file", new File(["pdf bytes"], "résumé—final.pdf", { type: "application/pdf" })); - const up = await app.request("/artifacts/upload", { method: "POST", body: form }); + form.append( + "file", + new File(["pdf bytes"], "résumé—final.pdf", { type: "application/pdf" }), + ); + const up = await app.request("/artifacts/upload", { + method: "POST", + body: form, + }); expect(up.status).toBe(201); const { artifacts } = (await up.json()) as any; @@ -1551,10 +1799,20 @@ describe("hardening regressions", () => { const app = host(db, { contentStore: store }); const row = await seedArtifact(db, { kind: "file", - source: { origin: "imported", upload: { id: "u-1", filename: "view.bin", mimeType: "application/octet-stream", size: 3 } }, + source: { + origin: "imported", + upload: { + id: "u-1", + filename: "view.bin", + mimeType: "application/octet-stream", + size: 3, + }, + }, }); const res = await app.request(`/artifacts/${row.id}/download`); - expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3])); + expect(new Uint8Array(await res.arrayBuffer())).toEqual( + new Uint8Array([1, 2, 3]), + ); }); }); @@ -1595,7 +1853,9 @@ describe("GET /artifacts/counts", () => { ); await seedArtifact(db, { tenantId: "other" }); await setArtifactArchived(db, await seedArtifact(db), true); - const app = host(db, { countSegments: { document: (row) => row.kind === "document" } }); + const app = host(db, { + countSegments: { document: (row) => row.kind === "document" }, + }); const res = await app.request("/artifacts/counts"); expect(await res.json()).toEqual({ all: 120, document: 120 }); @@ -1626,15 +1886,23 @@ describe("GET /artifacts/:id/preview", () => { const db = await testDb(); const app = host(db); const form = new FormData(); - form.append("file", new File(["

hi

"], "page.html", { type: "text/html" })); - const up = await app.request("/artifacts/upload", { method: "POST", body: form }); + form.append( + "file", + new File(["

hi

"], "page.html", { type: "text/html" }), + ); + const up = await app.request("/artifacts/upload", { + method: "POST", + body: form, + }); const { artifacts } = (await up.json()) as { artifacts: { id: string }[] }; const res = await app.request(`/artifacts/${artifacts[0]!.id}/preview`); expect(res.status).toBe(200); expect(await res.text()).toBe("

hi

"); expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8"); - expect(res.headers.get("content-security-policy")).toContain("sandbox allow-scripts"); + expect(res.headers.get("content-security-policy")).toContain( + "sandbox allow-scripts", + ); expect(res.headers.get("x-content-type-options")).toBe("nosniff"); expect(res.headers.get("x-frame-options")).toBeNull(); }); @@ -1642,7 +1910,10 @@ describe("GET /artifacts/:id/preview", () => { test("415s a non-HTML artifact instead of serving it", async () => { const db = await testDb(); const app = host(db); - const row = await seedArtifact(db, { kind: "document", content: "plain text" }); + const row = await seedArtifact(db, { + kind: "document", + content: "plain text", + }); const res = await app.request(`/artifacts/${row.id}/preview`); expect(res.status).toBe(415); @@ -1658,7 +1929,10 @@ describe("GET /artifacts/:id/preview", () => { test("404s another tenant's artifact", async () => { const db = await testDb(); const app = host(db); - const foreign = await seedArtifact(db, { tenantId: "other", kind: "document" }); + const foreign = await seedArtifact(db, { + tenantId: "other", + kind: "document", + }); const res = await app.request(`/artifacts/${foreign.id}/preview`); expect(res.status).toBe(404); }); diff --git a/e2e/reference-host.test.ts b/e2e/reference-host.test.ts index 5315da1..d75249e 100644 --- a/e2e/reference-host.test.ts +++ b/e2e/reference-host.test.ts @@ -17,7 +17,10 @@ import { UnsupportedUploadTypeError, type ContentStore, } from "@corbits/artifacts"; -import { createReferenceHost, type ReferenceHost } from "../examples/reference-host/src/index.js"; +import { + createReferenceHost, + type ReferenceHost, +} from "../examples/reference-host/src/index.js"; let host: ReferenceHost; const json = async (res: Response): Promise => (await res.json()) as T; @@ -49,10 +52,16 @@ describe("import a URL, read it back, revise it, read the history", () => { test("a URL import creates the artifact at version 1", async () => { const res = await host.request( "/api/artifacts", - postJson({ mode: "url", title: "Launch plan", content: "https://example.com/plan" }), + postJson({ + mode: "url", + title: "Launch plan", + content: "https://example.com/plan", + }), ); expect(res.status).toBe(201); - const created = await json<{ artifact: { id: string; version: number } }>(res); + const created = await json<{ artifact: { id: string; version: number } }>( + res, + ); expect(created.artifact.version).toBe(1); artifactId = created.artifact.id; }); @@ -67,13 +76,16 @@ describe("import a URL, read it back, revise it, read the history", () => { test("revising bumps to version 2 and version 1 keeps its original title", async () => { const revised = await host.request( `/api/artifacts/${artifactId}/versions`, - postJson({ title: "Launch plan v2", content: "https://example.com/plan-2" }), + postJson({ + title: "Launch plan v2", + content: "https://example.com/plan-2", + }), ); expect((await json<{ version: number }>(revised)).version).toBe(2); - const history = await json<{ versions: { version: number; title: string }[] }>( - await host.request(`/api/artifacts/${artifactId}/versions`), - ); + const history = await json<{ + versions: { version: number; title: string }[]; + }>(await host.request(`/api/artifacts/${artifactId}/versions`)); expect(history.versions.map((v) => v.version)).toEqual([2, 1]); expect(history.versions[1]!.title).toBe("Launch plan"); }); @@ -84,7 +96,9 @@ describe("import a URL, read it back, revise it, read the history", () => { postJson({ content: "https://example.com/plan-3", expectedVersion: 1 }), ); expect(conflict.status).toBe(409); - expect(await json<{ error: string; currentVersion: number }>(conflict)).toEqual({ + expect( + await json<{ error: string; currentVersion: number }>(conflict), + ).toEqual({ error: "Version conflict", currentVersion: 2, }); @@ -96,9 +110,9 @@ describe("import a URL, read it back, revise it, read the history", () => { }); test("GET /versions/:version serves version 1's own content, unaffected by the revision", async () => { - const v1 = await json<{ artifact: { version: number; title: string; content: string } }>( - await host.request(`/api/artifacts/${artifactId}/versions/1`), - ); + const v1 = await json<{ + artifact: { version: number; title: string; content: string }; + }>(await host.request(`/api/artifacts/${artifactId}/versions/1`)); expect(v1.artifact).toMatchObject({ version: 1, title: "Launch plan", @@ -108,12 +122,19 @@ describe("import a URL, read it back, revise it, read the history", () => { const v2 = await json<{ artifact: { version: number; content: string } }>( await host.request(`/api/artifacts/${artifactId}/versions/2`), ); - expect(v2.artifact).toMatchObject({ version: 2, content: "https://example.com/plan-2" }); + expect(v2.artifact).toMatchObject({ + version: 2, + content: "https://example.com/plan-2", + }); }); test("GET /versions/:version is 404 for an unknown version and 400 for a malformed one", async () => { - expect((await host.request(`/api/artifacts/${artifactId}/versions/99`)).status).toBe(404); - expect((await host.request(`/api/artifacts/${artifactId}/versions/0`)).status).toBe(400); + expect( + (await host.request(`/api/artifacts/${artifactId}/versions/99`)).status, + ).toBe(404); + expect( + (await host.request(`/api/artifacts/${artifactId}/versions/0`)).status, + ).toBe(400); }); }); @@ -141,9 +162,15 @@ describe.each<[string, ContentStore]>([ app = host.buildApp(store); const form = new FormData(); form.append("files", new File([PNG], "chart.png", { type: "image/png" })); - form.append("files", new File([PDF], "deck.pdf", { type: "application/pdf" })); + form.append( + "files", + new File([PDF], "deck.pdf", { type: "application/pdf" }), + ); form.append("generatedBy", "Reference host"); - const res = await app.request("/api/artifacts/upload", { method: "POST", body: form }); + const res = await app.request("/api/artifacts/upload", { + method: "POST", + body: form, + }); expect(res.status).toBe(201); uploaded = (await json<{ artifacts: Uploaded[] }>(res)).artifacts; }); @@ -161,7 +188,9 @@ describe.each<[string, ContentStore]>([ const png = await app.request(`/api/artifacts/${uploaded[0]!.id}/download`); expect(png.headers.get("content-type")).toBe("image/png"); expect(png.headers.get("x-content-type-options")).toBe("nosniff"); - expect(png.headers.get("content-disposition")).toBe('attachment; filename="chart.png"'); + expect(png.headers.get("content-disposition")).toBe( + 'attachment; filename="chart.png"', + ); expect(new Uint8Array(await png.arrayBuffer())).toEqual(PNG); }); @@ -169,7 +198,9 @@ describe.each<[string, ContentStore]>([ const id = uploaded[1]!.id; const attached = await app.request(`/api/artifacts/${id}/download`); const inline = await app.request(`/api/artifacts/${id}/download?inline=1`); - expect(attached.headers.get("content-disposition")).toStartWith("attachment;"); + expect(attached.headers.get("content-disposition")).toStartWith( + "attachment;", + ); expect(inline.headers.get("content-disposition")).toStartWith("inline;"); }); }); @@ -183,19 +214,29 @@ describe("download an older version's content over HTTP (DataUrlContentStore)", beforeAll(async () => { app = host.buildApp(DataUrlContentStore); const form = new FormData(); - form.append("files", new File([ORIGINAL], "chart.png", { type: "image/png" })); + form.append( + "files", + new File([ORIGINAL], "chart.png", { type: "image/png" }), + ); const uploaded = await json<{ artifacts: { id: string }[] }>( - await app.request("/api/artifacts/upload", { method: "POST", body: form }), + await app.request("/api/artifacts/upload", { + method: "POST", + body: form, + }), ); id = uploaded.artifacts[0]!.id; await app.request( `/api/artifacts/${id}/versions`, - postJson({ content: `data:image/png;base64,${Buffer.from(REVISED).toString("base64")}` }), + postJson({ + content: `data:image/png;base64,${Buffer.from(REVISED).toString("base64")}`, + }), ); }); test("?version=1 downloads the original bytes; omitted downloads the current version", async () => { - const original = await app.request(`/api/artifacts/${id}/download?version=1`); + const original = await app.request( + `/api/artifacts/${id}/download?version=1`, + ); expect(new Uint8Array(await original.arrayBuffer())).toEqual(ORIGINAL); const current = await app.request(`/api/artifacts/${id}/download`); @@ -203,8 +244,12 @@ describe("download an older version's content over HTTP (DataUrlContentStore)", }); test("an unknown ?version is 404 and a non-integer one is 400", async () => { - expect((await app.request(`/api/artifacts/${id}/download?version=99`)).status).toBe(404); - expect((await app.request(`/api/artifacts/${id}/download?version=abc`)).status).toBe(400); + expect( + (await app.request(`/api/artifacts/${id}/download?version=99`)).status, + ).toBe(404); + expect( + (await app.request(`/api/artifacts/${id}/download?version=abc`)).status, + ).toBe(400); }); }); @@ -217,14 +262,26 @@ describe("?version on a blob-backed upload (InlineContentStore) serves that vers beforeAll(async () => { app = host.buildApp(InlineContentStore); const form = new FormData(); - form.append("files", new File([ORIGINAL], "chart.png", { type: "image/png" })); + form.append( + "files", + new File([ORIGINAL], "chart.png", { type: "image/png" }), + ); const uploaded = await json<{ artifacts: { id: string }[] }>( - await app.request("/api/artifacts/upload", { method: "POST", body: form }), + await app.request("/api/artifacts/upload", { + method: "POST", + body: form, + }), ); id = uploaded.artifacts[0]!.id; const revise = new FormData(); - revise.append("file", new File([REVISED], "chart.png", { type: "image/png" })); - await app.request(`/api/artifacts/${id}/versions`, { method: "POST", body: revise }); + revise.append( + "file", + new File([REVISED], "chart.png", { type: "image/png" }), + ); + await app.request(`/api/artifacts/${id}/versions`, { + method: "POST", + body: revise, + }); }); test("?version=1 downloads the original bytes", async () => { @@ -247,8 +304,14 @@ describe("an unsupported upload is refused, leaving nothing behind", () => { await host.request("/api/artifacts?limit=100"), ); const form = new FormData(); - form.append("files", new File([""], "logo.svg", { type: "image/svg+xml" })); - const res = await host.request("/api/artifacts/upload", { method: "POST", body: form }); + form.append( + "files", + new File([""], "logo.svg", { type: "image/svg+xml" }), + ); + const res = await host.request("/api/artifacts/upload", { + method: "POST", + body: form, + }); const after = await json<{ artifacts: unknown[] }>( await host.request("/api/artifacts?limit=100"), ); @@ -260,9 +323,10 @@ describe("an unsupported upload is refused, leaving nothing behind", () => { describe("list: keyset paging and the archived toggle", () => { test("a keyset cursor is minted and the next page repeats nothing", async () => { - const page1 = await json<{ artifacts: { id: string }[]; nextCursor: string | null }>( - await host.request("/api/artifacts?limit=2"), - ); + const page1 = await json<{ + artifacts: { id: string }[]; + nextCursor: string | null; + }>(await host.request("/api/artifacts?limit=2")); expect(page1.artifacts.length).toBe(2); expect(page1.nextCursor).not.toBeNull(); @@ -281,7 +345,11 @@ describe("list: keyset paging and the archived toggle", () => { describe("archive is a soft-hide, not a revocation", () => { test("archiving hides the artifact from the default listing only", async () => { expect( - (await host.request(`/api/artifacts/${artifactId}/archive`, { method: "POST" })).status, + ( + await host.request(`/api/artifacts/${artifactId}/archive`, { + method: "POST", + }) + ).status, ).toBe(200); const listed = await json<{ artifacts: { id: string }[] }>( @@ -294,7 +362,9 @@ describe("archive is a soft-hide, not a revocation", () => { ); expect(archivedView.artifacts.some((a) => a.id === artifactId)).toBe(true); - expect((await host.request(`/api/artifacts/${artifactId}`)).status).toBe(200); + expect((await host.request(`/api/artifacts/${artifactId}`)).status).toBe( + 200, + ); }); test("revising an archived artifact is refused as not found", async () => { @@ -303,14 +373,18 @@ describe("archive is a soft-hide, not a revocation", () => { postJson({ content: "sneaky" }), ); expect(res.status).toBe(404); - await host.request(`/api/artifacts/${artifactId}/unarchive`, { method: "POST" }); + await host.request(`/api/artifacts/${artifactId}/unarchive`, { + method: "POST", + }); }); }); describe("host grant authorization", () => { test("a denied grant returns 403 and leaves archived_at null", async () => { const app = host.buildApp(InlineContentStore, () => false); - const res = await app.request(`/api/artifacts/${artifactId}/archive`, { method: "POST" }); + const res = await app.request(`/api/artifacts/${artifactId}/archive`, { + method: "POST", + }); expect(res.status).toBe(403); const [row] = await host.db.execute<{ archived_at: string | null }>( @@ -326,7 +400,9 @@ describe("host grant authorization", () => { return true; }); - const res = await app.request(`/api/artifacts/${artifactId}/archive`, { method: "POST" }); + const res = await app.request(`/api/artifacts/${artifactId}/archive`, { + method: "POST", + }); expect(res.status).toBe(200); expect(checks).toContainEqual({ resource: `artifact:${artifactId}`, @@ -334,7 +410,11 @@ describe("host grant authorization", () => { }); expect( - (await app.request(`/api/artifacts/${artifactId}/unarchive`, { method: "POST" })).status, + ( + await app.request(`/api/artifacts/${artifactId}/unarchive`, { + method: "POST", + }) + ).status, ).toBe(200); }); }); @@ -376,15 +456,21 @@ describe("ownership-derived grants: real provisioning, real refusal", () => { postJson({ content: "bob was here" }), ); expect(revise.status).toBe(403); - const archive = await host.request(`/api/artifacts/${artifactId}/archive`, { - method: "POST", - }); + const archive = await host.request( + `/api/artifacts/${artifactId}/archive`, + { + method: "POST", + }, + ); expect(archive.status).toBe(403); } finally { host.setSession({ userId: "user-alice" }); } - const [row] = await host.db.execute<{ content: string; archived_at: string | null }>( + const [row] = await host.db.execute<{ + content: string; + archived_at: string | null; + }>( sql`SELECT "content", "archived_at" FROM "artifacts"."artifact" WHERE "id" = ${artifactId}`, ); expect(row!.content).not.toBe("bob was here"); @@ -421,7 +507,9 @@ describe("no session", () => { }); test("a single-artifact detail read is refused 403", async () => { - expect((await host.request(`/api/artifacts/${artifactId}`)).status).toBe(403); + expect((await host.request(`/api/artifacts/${artifactId}`)).status).toBe( + 403, + ); }); }); @@ -444,10 +532,14 @@ describe("a cross-tenant request fails closed", () => { VALUES ('some-other-tenant', 'outsider', 'outsider', 'document', 'Other tenant secret', 'not yours', '{"origin":"manual"}'::jsonb, 1) `); - const res = await host.request("/api/artifacts?tenantId=some-other-tenant&limit=100"); + const res = await host.request( + "/api/artifacts?tenantId=some-other-tenant&limit=100", + ); expect(res.status).toBe(200); const body = await json<{ artifacts: { title: string }[] }>(res); - expect(body.artifacts.some((a) => a.title === "Other tenant secret")).toBe(false); + expect(body.artifacts.some((a) => a.title === "Other tenant secret")).toBe( + false, + ); }); // Filtering the LIST is only half of failing closed: the artifact still has @@ -521,8 +613,11 @@ describe("pdf parsing is the host's, and the module's contract with it holds", ( }; const count = async () => - (await json<{ artifacts: unknown[] }>(await host.request("/api/artifacts?limit=100"))) - .artifacts.length; + ( + await json<{ artifacts: unknown[] }>( + await host.request("/api/artifacts?limit=100"), + ) + ).artifacts.length; test("a host parse failure mints no artifact and stores no bytes", async () => { const before = await count(); @@ -530,7 +625,9 @@ describe("pdf parsing is the host's, and the module's contract with it holds", ( sql`SELECT count(*)::int AS n FROM "artifacts"."upload"`, ); - expect(() => parsePdf(new Uint8Array(Buffer.from("not a pdf at all")))).toThrow(); + expect(() => + parsePdf(new Uint8Array(Buffer.from("not a pdf at all"))), + ).toThrow(); expect(await count()).toBe(before); const afterUploads = await host.db.execute<{ n: number }>( @@ -560,7 +657,9 @@ describe("pdf parsing is the host's, and the module's contract with it holds", ( // The artifact and its version 1 exist, and the bytes serve back inline // for a PDF that asks — the half of "pdf parse+inline" this module owns. - const inline = await host.request(`/api/artifacts/${row.id}/download?inline=1`); + const inline = await host.request( + `/api/artifacts/${row.id}/download?inline=1`, + ); expect(inline.headers.get("content-disposition")).toStartWith("inline;"); expect(new Uint8Array(await inline.arrayBuffer())).toEqual(PDF); diff --git a/e2e/tools.test.ts b/e2e/tools.test.ts index bc9853f..c6cbf0d 100644 --- a/e2e/tools.test.ts +++ b/e2e/tools.test.ts @@ -19,7 +19,8 @@ import { testDb } from "./helpers.js"; async function read(content: string, offset?: number, limit?: number) { const db = await testDb(); const row = await seedArtifact(db, { content }); - if (offset === undefined) return await readArtifact(db, { scope: SCOPE, artifactId: row.id }); + if (offset === undefined) + return await readArtifact(db, { scope: SCOPE, artifactId: row.id }); return await readArtifactChunk(db, { scope: SCOPE, artifactId: row.id, @@ -82,7 +83,11 @@ describe("artifact_read", () => { test("reads the latest version by default", async () => { const db = await testDb(); const row = await seedArtifact(db, { title: "Doc", content: "v1" }); - await writeArtifactVersion(db, { scope: SCOPE, artifactId: row.id, content: "v2" }); + await writeArtifactVersion(db, { + scope: SCOPE, + artifactId: row.id, + content: "v2", + }); const result = await readArtifact(db, { scope: SCOPE, artifactId: row.id }); expect(result).toMatchObject({ version: 2, content: "v2" }); @@ -103,7 +108,11 @@ describe("artifact_read", () => { artifactId: row.id, version: 1, }); - expect(result).toMatchObject({ version: 1, content: "v1", title: "Old title" }); + expect(result).toMatchObject({ + version: 1, + content: "v1", + title: "Old title", + }); }); test("a missing version is an error naming the version", async () => { @@ -143,7 +152,11 @@ describe("artifact_read_chunk", () => { test("reads a pinned version's content in chunks", async () => { const db = await testDb(); const row = await seedArtifact(db, { content: "original" }); - await writeArtifactVersion(db, { scope: SCOPE, artifactId: row.id, content: "revised" }); + await writeArtifactVersion(db, { + scope: SCOPE, + artifactId: row.id, + content: "revised", + }); const result = await readArtifactChunk(db, { scope: SCOPE, @@ -161,14 +174,18 @@ describe("tool definitions", () => { expect(new Set(names).size).toBe(names.length); for (const definition of ARTIFACT_TOOL_DEFINITIONS) { for (const required of definition.inputSchema.required) { - expect(Object.keys(definition.inputSchema.properties)).toContain(required); + expect(Object.keys(definition.inputSchema.properties)).toContain( + required, + ); } } }); test("artifact_create and artifact_write both declare an optional metadata object", () => { for (const name of ["artifact_create", "artifact_write"]) { - const definition = ARTIFACT_TOOL_DEFINITIONS.find((d) => d.name === name)!; + const definition = ARTIFACT_TOOL_DEFINITIONS.find( + (d) => d.name === name, + )!; expect(definition.inputSchema.properties["metadata"]).toEqual({ type: "object", description: @@ -179,10 +196,14 @@ describe("tool definitions", () => { }); test("only the mutating tools declare a write side effect", () => { - const writes = ARTIFACT_TOOL_DEFINITIONS.filter((d) => d.sideEffect === "write").map( - (d) => d.name, - ); - expect(writes.sort()).toEqual(["artifact_create", "artifact_link_file", "artifact_write"]); + const writes = ARTIFACT_TOOL_DEFINITIONS.filter( + (d) => d.sideEffect === "write", + ).map((d) => d.name); + expect(writes.sort()).toEqual([ + "artifact_create", + "artifact_link_file", + "artifact_write", + ]); }); // A descriptor with no behavior behind it is worse than a missing tool: the @@ -222,12 +243,18 @@ describe("artifact_link_file", () => { test("mints the artifact and its version 1, recording the workspace path", async () => { const db = await testDb(); - const row = await linkFileArtifact(db, linkArgs({ preview: "Slide 1: revenue" })); + const row = await linkFileArtifact( + db, + linkArgs({ preview: "Slide 1: revenue" }), + ); expect(row.version).toBe(1); expect(row.kind).toBe("file"); expect(row.content).toBe("Slide 1: revenue"); - expect(row.source).toEqual({ origin: "agent", workspace: { path: "out/deck.pdf" } }); + expect(row.source).toEqual({ + origin: "agent", + workspace: { path: "out/deck.pdf" }, + }); const versions = await listArtifactVersions(db, row.id); expect(versions.versions.map((v) => v.version)).toEqual([1]); @@ -237,7 +264,9 @@ describe("artifact_link_file", () => { test("no bytes move: nothing is written to the blob side-table", async () => { const db = await testDb(); await linkFileArtifact(db, linkArgs()); - const uploads = await db.execute<{ n: string }>(sql`SELECT count(*)::text AS n FROM "artifacts"."upload"`); + const uploads = await db.execute<{ n: string }>( + sql`SELECT count(*)::text AS n FROM "artifacts"."upload"`, + ); expect(uploads[0]!.n).toBe("0"); }); @@ -249,18 +278,26 @@ describe("artifact_link_file", () => { test("a blank path is refused, and no artifact is left behind", async () => { const db = await testDb(); - await expect(linkFileArtifact(db, linkArgs({ path: " " }))).rejects.toThrow( - "requires a workspace path", + await expect( + linkFileArtifact(db, linkArgs({ path: " " })), + ).rejects.toThrow("requires a workspace path"); + const rows = await db.execute<{ n: string }>( + sql`SELECT count(*)::text AS n FROM "artifacts"."artifact"`, ); - const rows = await db.execute<{ n: string }>(sql`SELECT count(*)::text AS n FROM "artifacts"."artifact"`); expect(rows[0]!.n).toBe("0"); }); - test("a linked artifact is readable through artifact_read", async () => { const db = await testDb(); - const row = await linkFileArtifact(db, linkArgs({ preview: "Slide 1: revenue" })); + const row = await linkFileArtifact( + db, + linkArgs({ preview: "Slide 1: revenue" }), + ); const read = await readArtifact(db, { scope: SCOPE, artifactId: row.id }); - expect(read).toMatchObject({ title: "Quarterly deck", version: 1, content: "Slide 1: revenue" }); + expect(read).toMatchObject({ + title: "Quarterly deck", + version: 1, + content: "Slide 1: revenue", + }); }); }); diff --git a/e2e/upgrade-from-0.1.0.test.ts b/e2e/upgrade-from-0.1.0.test.ts index 88f0c98..0adeaa5 100644 --- a/e2e/upgrade-from-0.1.0.test.ts +++ b/e2e/upgrade-from-0.1.0.test.ts @@ -7,9 +7,16 @@ import { sql } from "drizzle-orm"; import * as v010 from "@corbits/artifacts-0.1.0"; import { runArtifactMigrations } from "../src/index.js"; import { grant, seedActor, type Actor } from "./fixtures.js"; -import { artifactApp, connectionString, createTestDb, type TestDb } from "./helpers.js"; +import { + artifactApp, + connectionString, + createTestDb, + type TestDb, +} from "./helpers.js"; -const PDF = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37, 0x00, 0xff]); +const PDF = new Uint8Array([ + 0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37, 0x00, 0xff, +]); let testDb: TestDb; let actor: Actor; @@ -44,14 +51,23 @@ beforeAll(async () => { }), ); fileId = file.id; - await v010.writeArtifactVersion(legacy.db, { scope, artifactId: file.id, title: "deck v2.pdf" }); + await v010.writeArtifactVersion(legacy.db, { + scope, + artifactId: file.id, + title: "deck v2.pdf", + }); await v010.saveMailAttachmentRefs(legacy.db, { scope, instanceId: "inst-1", body: { mailId: "mail-1", attachments: [ - { artifactId: file.id, name: "deck.pdf", type: "application/pdf", size: PDF.length }, + { + artifactId: file.id, + name: "deck.pdf", + type: "application/pdf", + size: PDF.length, + }, ], }, }); @@ -81,7 +97,11 @@ const createDoc = (as: Actor) => artifactApp(testDb.db, as).request("/api/artifacts", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ mode: "text", title: "After upgrade", content: "hi" }), + body: JSON.stringify({ + mode: "text", + title: "After upgrade", + content: "hi", + }), }); describe("upgrading a 0.1.0 database", () => { @@ -119,7 +139,9 @@ describe("upgrading a 0.1.0 database", () => { test("every version of a 0.1.0 upload downloads its bytes", async () => { const app = artifactApp(testDb.db, actor); for (const query of ["?version=1", "?version=2", ""]) { - const res = await app.request(`/api/artifacts/${fileId}/download${query}`); + const res = await app.request( + `/api/artifacts/${fileId}/download${query}`, + ); expect(res.status).toBe(200); expect(new Uint8Array(await res.arrayBuffer())).toEqual(PDF); } @@ -129,7 +151,10 @@ describe("upgrading a 0.1.0 database", () => { const app = artifactApp(testDb.db, actor); const revised = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x32]); const form = new FormData(); - form.append("file", new File([revised], "deck.pdf", { type: "application/pdf" })); + form.append( + "file", + new File([revised], "deck.pdf", { type: "application/pdf" }), + ); const res = await app.request(`/api/artifacts/${fileId}/versions`, { method: "POST", body: form, @@ -139,7 +164,9 @@ describe("upgrading a 0.1.0 database", () => { const bytesOf = async (query: string) => new Uint8Array( - await (await app.request(`/api/artifacts/${fileId}/download${query}`)).arrayBuffer(), + await ( + await app.request(`/api/artifacts/${fileId}/download${query}`) + ).arrayBuffer(), ); expect(await bytesOf("?version=1")).toEqual(PDF); expect(await bytesOf("?version=3")).toEqual(revised); diff --git a/e2e/upload-policy.test.ts b/e2e/upload-policy.test.ts index 9117a65..22c5de5 100644 --- a/e2e/upload-policy.test.ts +++ b/e2e/upload-policy.test.ts @@ -20,7 +20,10 @@ afterAll(async () => { async function upload(file: File): Promise { const form = new FormData(); form.append("files", file); - return await app.request("/api/artifacts/upload", { method: "POST", body: form }); + return await app.request("/api/artifacts/upload", { + method: "POST", + body: form, + }); } async function artifactCount(): Promise { @@ -38,7 +41,9 @@ describe("upload policy through the mounted app", () => { }); test("a disallowed MIME type is 415 and stores nothing", async () => { - const script = new File(["#!/bin/sh\n"], "run.sh", { type: "application/x-sh" }); + const script = new File(["#!/bin/sh\n"], "run.sh", { + type: "application/x-sh", + }); expect((await upload(script)).status).toBe(415); expect(await artifactCount()).toBe(0); }); diff --git a/e2e/uploads.test.ts b/e2e/uploads.test.ts index 3a7149f..a02c4c8 100644 --- a/e2e/uploads.test.ts +++ b/e2e/uploads.test.ts @@ -20,18 +20,25 @@ import { artifact, upload } from "../src/schema.js"; import { SCOPE } from "./fixtures.js"; import { testDb } from "./helpers.js"; -const XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; +const XLSX = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; describe("MIME gating", () => { test("trusts a declared type the policy accepts", () => { expect( - effectiveUploadMime({ name: "a.png", type: "image/png" }, ARTIFACT_UPLOAD_POLICY), + effectiveUploadMime( + { name: "a.png", type: "image/png" }, + ARTIFACT_UPLOAD_POLICY, + ), ).toBe("image/png"); }); test("falls back to the extension when the browser omits the type", () => { expect( - effectiveUploadMime({ name: "sheet.XLSX", type: "" }, ARTIFACT_UPLOAD_POLICY), + effectiveUploadMime( + { name: "sheet.XLSX", type: "" }, + ARTIFACT_UPLOAD_POLICY, + ), ).toBe(XLSX); }); @@ -46,7 +53,10 @@ describe("MIME gating", () => { test("rejects a file that resolves through neither type nor extension", () => { expect( - effectiveUploadMime({ name: "payload.bin", type: "" }, ARTIFACT_UPLOAD_POLICY), + effectiveUploadMime( + { name: "payload.bin", type: "" }, + ARTIFACT_UPLOAD_POLICY, + ), ).toBe(""); expect( effectiveUploadMime( @@ -62,16 +72,28 @@ describe("MIME gating", () => { expect(ARTIFACT_UPLOAD_POLICY.accepts("application/x-tar")).toBe(true); expect( - effectiveUploadMime({ name: "build.tar.gz", type: "" }, ARTIFACT_UPLOAD_POLICY), + effectiveUploadMime( + { name: "build.tar.gz", type: "" }, + ARTIFACT_UPLOAD_POLICY, + ), ).toBe("application/gzip"); expect( - effectiveUploadMime({ name: "build.tgz", type: "" }, ARTIFACT_UPLOAD_POLICY), + effectiveUploadMime( + { name: "build.tgz", type: "" }, + ARTIFACT_UPLOAD_POLICY, + ), ).toBe("application/gzip"); expect( - effectiveUploadMime({ name: "build.gz", type: "" }, ARTIFACT_UPLOAD_POLICY), + effectiveUploadMime( + { name: "build.gz", type: "" }, + ARTIFACT_UPLOAD_POLICY, + ), ).toBe("application/gzip"); expect( - effectiveUploadMime({ name: "build.tar", type: "" }, ARTIFACT_UPLOAD_POLICY), + effectiveUploadMime( + { name: "build.tar", type: "" }, + ARTIFACT_UPLOAD_POLICY, + ), ).toBe("application/x-tar"); }); @@ -80,7 +102,11 @@ describe("MIME gating", () => { expect(uploadArtifactKind("application/x-gzip")).toBe("file"); expect(uploadArtifactKind("application/x-tar")).toBe("file"); - for (const mime of ["application/gzip", "application/x-gzip", "application/x-tar"]) { + for (const mime of [ + "application/gzip", + "application/x-gzip", + "application/x-tar", + ]) { expect(disposition(mime, false)).toBe("attachment"); expect(disposition(mime, true)).toBe("attachment"); } @@ -107,15 +133,21 @@ describe("MIME gating", () => { expect(PARSED_DOCUMENT_POLICY.accepts("application/pdf")).toBe(true); expect(PARSED_DOCUMENT_POLICY.accepts(XLSX)).toBe(false); expect(PARSED_DOCUMENT_POLICY.accepts("image/svg+xml")).toBe(false); - expect(effectiveUploadMime({ name: "sheet.xlsx", type: "" }, PARSED_DOCUMENT_POLICY)).toBe( - "", - ); + expect( + effectiveUploadMime( + { name: "sheet.xlsx", type: "" }, + PARSED_DOCUMENT_POLICY, + ), + ).toBe(""); expect(ARTIFACT_UPLOAD_POLICY.accepts("image/png")).toBe(true); expect(ARTIFACT_UPLOAD_POLICY.accepts("application/pdf")).toBe(true); expect( - effectiveUploadMime({ name: "a.png", type: "image/png" }, SPREADSHEET_UPLOAD_POLICY), + effectiveUploadMime( + { name: "a.png", type: "image/png" }, + SPREADSHEET_UPLOAD_POLICY, + ), ).toBe(""); }); @@ -156,31 +188,37 @@ describe("every entry-point policy gates createFileArtifact", () => { }), ); - const SURFACES: [string, UploadPolicy, [string, string], [string, string]][] = [ + const SURFACES: [string, UploadPolicy, [string, string], [string, string]][] = [ - "ARTIFACT_UPLOAD_POLICY (gallery import — this package's own route)", - ARTIFACT_UPLOAD_POLICY, - ["chart.png", "image/png"], - // An SVG can carry inline