Skip to content

@kindgi/api

View as Markdown

npm install @kindgi/api · source

REST + SSE HTTP surface for the Kindgi platform. Exposes the runtime (kernel, agents, HITL, supervisor — reached through the bindings passed to createApp) behind two wire faces:

  • /v1/* — the Kindgi REST API. JSON in, JSON out. Bearer token auth. Documented via OpenAPI 3.1 (snapshot shipped as @kindgi/api/openapi.json). Conventions locked in docs/API-ROUTE-CONVENTIONS.md.
  • /s3/* — AWS S3 API v4 wire surface. XML in, XML out. SigV4 auth. Documented in this file.

Both surfaces route through the same runtime bindings — an artifact uploaded via one is readable via the other. Deployments enable either or both via createApp(...) bindings.

Assemble the Hono app instance. Every runtime dependency is caller- plugged; the API package does not own persistence. See src/app.ts for the full CreateAppInput shape.

Minimal wiring for BOTH surfaces:

import { createApp } from '@kindgi/api';
const app = createApp({
// Required runtime bindings — supplied by the Kindgi runtime in a
// deployment; `createStubAppBindings()` from @kindgi/testing in tests.
...runtimeBindings,
resolveToken: async (token) => /* ... */,
runHandler: /* ... */,
blobStorage, // any BlobStorageBinding (filesystem, S3, GCS, …)
s3Credentials: {
async resolve(accessKeyId) {
// Deployment-owned lookup — return { secretKey, tenantId, bucket }
// or null for unknown / revoked.
return await lookupAccessKey(accessKeyId);
},
},
});
Excerpt

Omit s3Credentials → the /s3/* mount is skipped (Hono 404). Omit blobStorage → both /v1/artifacts AND /s3 are skipped.

Set openapi.docs on createApp(...) to mount a Scalar-rendered interactive API reference alongside the JSON spec. The docs page reads the (already-public) /v1/openapi.json in the browser, so it sits on the public side of the auth chain — no bearer required to browse.

const app = createApp({
...runtimeBindings,
resolveToken: /* ... */,
runHandler: /* ... */,
openapi: { docs: true }, // → mounts /docs (default path)
});
// Or customize:
openapi: { docs: { path: '/reference', title: 'Acme Legal API' } }
Excerpt

docs is opt-in — omit it and the docs UI is unmounted (Hono 404). The JSON spec at /v1/openapi.json is always mounted independent of this flag.

Any S3-aware client (aws s3 cp, rclone, mc, aws-sdk-js, boto3) hits /s3/* with zero code changes. The server verifies AWS Signature Version 4 itself (node:crypto; see src/middleware/sigv4-verify.ts).

Every /s3/* request carries SigV4 credentials, either as:

  • Header auth — Authorization: AWS4-HMAC-SHA256 Credential=<accessKeyId>/<date>/<region>/s3/aws4_request, SignedHeaders=..., Signature=<hex>
  • Presigned URL — query params X-Amz-Algorithm=AWS4-HMAC-SHA256, X-Amz-Credential=..., X-Amz-Date=..., X-Amz-Expires=..., X-Amz-SignedHeaders=..., X-Amz-Signature=....

The middleware:

  1. Parses the auth params (header OR presigned).
  2. Checks the signed timestamp and credential scope: within ±15 minutes of the server clock for header auth (RequestTimeTooSkewed otherwise); presigned URLs until X-Amz-Expires.
  3. Calls S3CredentialBinding.resolve(accessKeyId) → resolves to { secretKey, tenantId, bucket, expiresAt? } or null.
  4. Re-derives the signature under secretKey from exactly what the client signed (its timestamp and its SignedHeaders, which for header auth include x-amz-content-sha256) and compares it in constant time.
  5. Compares the URL-declared bucket to the credential's bucket field. Mismatch → 403 AccessDenied (each credential is scoped to one bucket).
  6. Reads the body (not for GET, HEAD or DELETE), at most artifactMaxBytes (default 100 MiB): more is 400 EntityTooLarge. For header auth, verifies x-amz-content-sha256 matches it, an empty body too, unless it's UNSIGNED-PAYLOAD: a mismatch is 400 XAmzContentSHA256Mismatch.
  7. Sets c.set('tenantId', ...) + c.set('bucket', ...) for the downstream S3 route handlers, which read the body step 6 read.

All auth failures return S3-style XML errors (<Error><Code>...</Code>...</Error>) with Content-Type: application/xml.

Object routes (bucket + key):

Verb Path Op
PUT /s3/:bucket/* PutObject
GET /s3/:bucket/* GetObject
HEAD /s3/:bucket/* HeadObject
DELETE /s3/:bucket/* DeleteObject

Bucket routes:

Verb Path Op
HEAD /s3/:bucket HeadBucket
GET /s3/:bucket ListObjectsV2 (requires ?list-type=2)

Multipart upload (S3-standard md5(concat(md5(part_i)))-N ETag):

Verb Path Op
POST /s3/:bucket/*?uploads InitiateMultipartUpload
PUT /s3/:bucket/*?partNumber=&uploadId= UploadPart
POST /s3/:bucket/*?uploadId= CompleteMultipartUpload
GET /s3/:bucket/*?uploadId= ListParts
DELETE /s3/:bucket/*?uploadId= AbortMultipartUpload

Semantic notes:

  • Size: a request's body is at most artifactMaxBytes (default 100 MiB), the limit POST /v1/artifacts has; more is 400 EntityTooLarge. A larger object goes up as a multipart upload, each part under the limit.
  • Content-MD5 on PUT (regular OR part upload) is verified; mismatch → 400 BadDigest.
  • Range requests on GET support single-range only (bytes=N-M, bytes=N-, bytes=-M) — response is 206 Partial Content with Content-Range: bytes N-M/total. Multi-range (comma-separated) requests are rejected with 416 InvalidRange.
  • DELETE always returns 204 — whether the key existed or not (S3 semantics).
  • ListObjectsV2 requires ?list-type=2; v1 listing is not implemented (returns 501 NotImplemented). ?prefix=, ?max-keys=, ?continuation-token= are honored.
  • x-amz-tagging header on PUT is form-encoded (key1=val1&key2=val2) and stored as BlobMeta.tags. The dedicated ?tagging subresource verbs are not implemented.

Every persisted blob carries BOTH a blobId (UUID) AND a (bucket, key) pair. Bespoke uploads via POST /v1/artifacts land under bucket: 'artifacts', key: <blobId> by default — S3 credentials granted the artifacts bucket see them via s3://artifacts/<blobId>.

Conversely, S3 uploads via PUT /s3/:bucket/:key are visible in GET /v1/artifacts listings under the same blobId and are downloadable via GET /v1/artifacts/:blobId.

A listing's entries and an upload's answer carry the pair as bucket and key when the storage gives the blob one, so a client can match an S3 object to its blobId.

HTTP S3 Code When
400 InvalidRequest Malformed request
400 MalformedXML CompleteMultipartUpload body invalid
400 InvalidArgument Bad query param (e.g. partNumber OOB)
416 InvalidRange Bad Range header
400 BadDigest Content-MD5 mismatch
400 XAmzContentSHA256Mismatch Payload hash header mismatch
400 EntityTooLarge Body over artifactMaxBytes (default 100 MiB)
400 InvalidPart Multipart part ETag mismatch
403 AccessDenied Credential expired / wrong bucket
403 InvalidAccessKeyId Unknown access key
403 SignatureDoesNotMatch SigV4 verification failed
403 RequestTimeTooSkewed Signed timestamp outside the ±15 min window
403 InvalidRequest Malformed SigV4 authorization
400 AuthorizationQueryParametersError Malformed presigned-URL parameters
404 NoSuchKey Unknown key
404 NoSuchUpload Unknown or aborted uploadId
501 NotImplemented Unsupported operation
500 InternalError Backing store failure
Terminal window
pnpm --filter @kindgi/api build # compile TS + emit dist
pnpm --filter @kindgi/api typecheck # no-emit typecheck
pnpm --filter @kindgi/api test # vitest run
pnpm --filter @kindgi/api gen:openapi # regenerate packages/api/openapi.json

gen:openapi documents the /v1/* surface only. The /s3/* surface follows the S3 wire (XML, AWS-native SDKs) and is intentionally NOT in the OpenAPI document — consumers use their own S3 SDKs pointed at the mount.

createApp(...) bindings are caller-plugged so this package can stay free of persistence choices. The Kindgi runtime ships production implementations (e.g. a Postgres-backed idempotencyStore whose retries survive restarts and multi-instance deployments, filesystem and object-store blobStorage). This package includes createInMemoryIdempotencyStore(), which is correct for dev + tests.