End-to-End Testing
Audience: customer developers integrating against meta.svc, and kis.ai DevSecOps running the service in staging / production. Covers every server run mode, authenticated and unauthenticated testing with curl, and the OpenAPI 3.1 spec (docs/openapi.yaml) importable into Bruno, Postman, Insomnia, or any OpenAPI-compatible client.
Table of Contents
Section titled “Table of Contents”- Prerequisites
- Request Headers Reference
- Server Run Modes
- Sample Product Tree
- Testing with curl
- 5.1 Health and Discovery
- 5.2 Product File Reads — v1 surface
- 5.3 Product File Reads — v2 surface
- 5.4 Git Operations
- 5.5 Service Config, Datastore, Type Reads
- 5.6 Remote Filesystem Endpoints
- 5.7 Batch, Manifest, Resolve — v2 only
- 5.8 Conditional Requests (ETag / 304)
- 5.9 Marketplace Endpoints
- 5.10 Write Operations
- 5.11 Negative / Auth Tests
- Testing with the OpenAPI Spec
- DevSecOps Checklist
1. Prerequisites
Section titled “1. Prerequisites”Build the binary
Section titled “Build the binary”# From the meta.svc source repo rootcd /path/to/meta-svcgo build -o meta .Or run directly without building:
go run . serve acme:demo:./my-productjq (for readable curl output)
Section titled “jq (for readable curl output)”brew install jq # macOSapt-get install jq # Debian/UbuntuConfirm the binary works
Section titled “Confirm the binary works”./meta version./meta --help./meta serve --help2. Request Headers Reference
Section titled “2. Request Headers Reference”Every authenticated request needs these headers. The server injects missing values where it can, but supplying all of them explicitly avoids routing ambiguity.
| Header | Required | Description |
|---|---|---|
X-Api-Key | When server started with --apikey | Matches the value passed to meta serve -k <key>. Can also be passed as ?apikey=<key> query param. |
Authorization | Alternative to X-Api-Key | Bearer <jwt> — used in full-stack mode with IAM issuing the token. |
X-Customer | Yes for most reads | Customer / organisation name. Must match the first segment of the product registration (customer:product:path). |
X-Product | Recommended | Product name. Helps the server skip customer-level scoping. |
X-Env | Recommended | Environment name (local, dev, staging, prod). Defaults to local. |
X-Tenant | Optional | Full tenant key (customer/_/env). If absent, server synthesises it from X-Customer + X-Env. |
If-None-Match | For conditional requests | ETag value from a previous v2 response. Returns 304 when content has not changed. |
Content-Type | For POST / PATCH / PUT | application/json for JSON bodies. |
Convenience shell aliases used throughout this guide:
# Adjust these to match your setupBASE="http://localhost:8080"CUSTOMER="acme"PRODUCT="kisai-demo"SERVICE="iam"ENV="local"APIKEY="kisai_A1B2C3D4"
AUTH=(-H "X-Api-Key: $APIKEY")CPET=(-H "X-Customer: $CUSTOMER" -H "X-Product: $PRODUCT" -H "X-Env: $ENV")3. Server Run Modes
Section titled “3. Server Run Modes”All examples use the binary at ./meta. Replace with go run . if not built.
3.1 Plain Folder — No Auth
Section titled “3.1 Plain Folder — No Auth”When to use: Local development, quickest setup, no secrets.
mkdir -p my-product/services/iamecho "name: iam" > my-product/services/iam/config.yaml
./meta serve acme:kisai-demo:./my-productmeta serve listening on :8080auth: disabled acme:kisai-demo [folder] -> /absolute/path/to/my-product- Port defaults to
8080. X-Api-Keyheader is ignored — no auth check.X-Customerheader must still beacmefor the repo to be found.
3.2 Plain Folder — API Key Auth
Section titled “3.2 Plain Folder — API Key Auth”When to use: Shared dev/CI environments where you need a lightweight secret gate.
./meta serve acme:kisai-demo:./my-product -k kisai_A1B2C3D4meta serve listening on :8080auth: API key required acme:kisai-demo [folder] -> /absolute/path/to/my-product- Any request missing
X-Api-Key: kisai_A1B2C3D4(or?apikey=kisai_A1B2C3D4) returns401 Unauthorized. - Use a randomly generated key in shared environments:
openssl rand -hex 20.
Custom port:
./meta serve acme:kisai-demo:./my-product -k kisai_A1B2C3D4 -p 9090BASE="http://localhost:9090"3.3 Local Git Repo — No Auth
Section titled “3.3 Local Git Repo — No Auth”When to use: Testing branch / tag-based version access without a remote.
The server detects git repos automatically (git.PlainOpen succeeds). No extra flag needed.
# Make sure ./my-product is a git repogit -C my-product initgit -C my-product add .git -C my-product commit -m "init"
./meta serve acme:kisai-demo:./my-productmeta serve listening on :8080auth: disabled acme:kisai-demo [git] -> /absolute/path/to/my-productBranches and tags are accessible via the ?version=<name>&versionType=branch|tag query parameter:
curl "$BASE/product/kisai-demo/file/config.yaml?version=main&versionType=branch" \ "${CPET[@]}"3.4 Local Git Repo — API Key Auth
Section titled “3.4 Local Git Repo — API Key Auth”git -C my-product tag v1.0.0
./meta serve acme:kisai-demo:./my-product -k kisai_A1B2C3D4Test tag-based access:
curl "$BASE/api/v2/product/kisai-demo/file/config.yaml?version=v1.0.0&versionType=tag" \ "${AUTH[@]}" "${CPET[@]}"3.5 Multiple Products, One Customer
Section titled “3.5 Multiple Products, One Customer”When to use: A customer has several independent products.
./meta serve \ acme:frontend:./frontend-product \ acme:backend:./backend-product \ -k kisai_A1B2C3D4 \ -p 8080 acme:frontend [git] -> /path/to/frontend-product acme:backend [folder] -> /path/to/backend-productRequests differentiate products via the URL path parameter and the X-Product header:
# Frontend productcurl "$BASE/product/frontend/files" \ "${AUTH[@]}" -H "X-Customer: acme" -H "X-Product: frontend"
# Backend productcurl "$BASE/product/backend/files" \ "${AUTH[@]}" -H "X-Customer: acme" -H "X-Product: backend"3.6 Multiple Products, Multiple Customers
Section titled “3.6 Multiple Products, Multiple Customers”When to use: Testing multi-tenant repo isolation.
./meta serve \ acme:app:./acme-product \ contoso:app:./contoso-product \ -k kisai_A1B2C3D4Customer header routes the request to the correct repo:
# acme sees only their appcurl "$BASE/product/app/files" \ "${AUTH[@]}" -H "X-Customer: acme"
# contoso sees only their appcurl "$BASE/product/app/files" \ "${AUTH[@]}" -H "X-Customer: contoso"Verify isolation — acme should not read contoso’s files:
# Should return their own repo, not the other customer'scurl -s "$BASE/product/app/file/config.yaml" \ "${AUTH[@]}" -H "X-Customer: acme" | jq .3.7 Embedded Product
Section titled “3.7 Embedded Product”When to use: Shipping a binary that carries its product definition as an embedded payload. Used by customer developers distributing a self-contained meta.svc server.
Without a license key (testing unencrypted embedded product):
# Build a binary with an embedded product first:./meta embed pack --product ./my-product --output embedded-meta./embedded-meta serveWith a license key (production-encrypted embed):
./embedded-meta serve --license kisai_LICENSE_KEY_HEREWhen no args and no embedded product:
./meta serve# Error: no products specified and no embedded product foundCheck what is embedded:
curl "$BASE/api/v2/ops/embedded-info" \ "${AUTH[@]}"# {"embedded":true,"manifest":{"name":"kisai-demo","files":42,"size":128000}}# or# {"embedded":false,"message":"no product embedded"}3.8 Full-Stack Mode (Config File)
Section titled “3.8 Full-Stack Mode (Config File)”When to use: Running meta.svc as a proper BaaS service — connected to config.svc, with remote git repos, multi-tenant repo loading, and JWT auth via IAM.
This mode uses the default command (not serve).
Minimal config file (meta-config.yaml):
# Portport: "8080"
# Service mode: "meta" | "product" | "marketplace"metamode: meta
# API key for inter-service callsapikey: "kisai_INTERNAL_KEY"
# Config service URL (config.svc)config_url: "http://config.svc:8090"config_apikey: "kisai_CONFIG_KEY"
# Datacenter / cluster identitydc: us-westcluster: prod-01
# Product git repositories# These are loaded at startup and watched for changes.products: acme: - name: kisai-demo url: "https://git.internal/acme/kisai-demo.git" type: remote ref: main readonly: true
# Service definition repositories (auth rules, datastores, etc.)services: - name: iam url: "https://git.internal/platform/iam-service.git" type: remote ref: main readonly: true - name: seshat url: "https://git.internal/platform/seshat-service.git" type: remote ref: main readonly: true
# Optional marketplacemarketplace: - name: kisai-components url: "https://git.internal/platform/marketplace.git" type: remote ref: main readonly: true
# Watch interval for remote repos (default 15s for products, 1m for services)repowatchduration: 30sStart the full-stack server:
./meta --config meta-config.yamlOr with individual flags:
./meta \ --config meta-config.yaml \ --apikey kisai_INTERNAL_KEY \ --port 8080 \ --metamode meta \ --dc us-west \ --cluster prod-01JWT auth in full-stack mode:
In full-stack mode the server validates JWT tokens issued by IAM. API key is still accepted for internal service-to-service calls. Use whichever is appropriate:
# API key (internal)curl "$BASE/product/kisai-demo/files" \ -H "X-Api-Key: kisai_INTERNAL_KEY" \ -H "X-Customer: acme"
# JWT (user-facing)TOKEN=$(curl -s http://janus:8091/account/auth/login/password \ -H "Content-Type: application/json" | jq -r .access_token)
curl "$BASE/product/kisai-demo/files" \ -H "Authorization: Bearer $TOKEN" \ -H "X-Customer: acme"4. Sample Product Tree
Section titled “4. Sample Product Tree”Most curl examples below assume this product layout at ./my-product:
my-product/├── config.yaml # product-level config├── services/│ └── iam/│ ├── config.yaml # service config (served by /service/.../config)│ ├── datastore/│ │ └── main.yaml # datastore definition│ ├── rules/│ │ └── authorization.yaml # IAM rules│ └── plugins/│ └── preauth/│ └── plugin.js # plugin implementation└── content/ └── apps/ └── dashboard/ ├── config.yaml └── templates/ └── welcome.htmlCreate it:
mkdir -p my-product/{services/iam/{datastore,rules,plugins/preauth},content/apps/dashboard/templates}
cat > my-product/config.yaml <<'EOF'name: kisai-demoversion: "1.0.0"EOF
cat > my-product/services/iam/config.yaml <<'EOF'name: iamversion: "2.1.0"defaultdatastore: mainEOF
cat > my-product/services/iam/datastore/main.yaml <<'EOF'name: maintype: postgreshost: db.internalport: 5432database: iam_dbEOF
cat > my-product/services/iam/rules/authorization.yaml <<'EOF'rules: - resource: /api/admin roles: [admin] - resource: /api/user roles: [user, admin]EOF
cat > my-product/services/iam/plugins/preauth/plugin.js <<'EOF'function preauth(ctx) { return { allow: true };}EOF
cat > my-product/content/apps/dashboard/config.yaml <<'EOF'name: dashboardtype: content-appEOF
cat > my-product/content/apps/dashboard/templates/welcome.html <<'EOF'<h1>Welcome to {{tenant}}</h1>EOF
# For git-mode testsgit -C my-product initgit -C my-product add .git -C my-product commit -m "init product"git -C my-product tag v1.0.0Start the server once and leave it running for all curl tests below:
./meta serve acme:kisai-demo:./my-product -k kisai_A1B2C3D45. Testing with curl
Section titled “5. Testing with curl”Variables used throughout:
BASE="http://localhost:8080"APIKEY="kisai_A1B2C3D4"CUSTOMER="acme"PRODUCT="kisai-demo"SERVICE="iam"ENV="local"
# ShorthandA=(-H "X-Api-Key: $APIKEY")C=(-H "X-Customer: $CUSTOMER" -H "X-Product: $PRODUCT" -H "X-Env: $ENV")5.1 Health and Discovery
Section titled “5.1 Health and Discovery”Liveness and readiness (no auth required):
curl -s "$BASE/health" | jq .# {"status":"healthy"}
curl -s "$BASE/ready" | jq .# {"status":"ready"}List all registered repositories (v1):
curl -s "$BASE/repositories/list" \ "${A[@]}" -H "X-Customer: $CUSTOMER" | jq .Expected response:
{ "repositories": { "products": { "acme": ["kisai-demo"] }, "services": [], "marketplace": [] }}List all registered repositories (v2):
curl -s "$BASE/api/v2/ops/repositories" \ "${A[@]}" -H "X-Customer: $CUSTOMER" | jq .List tenant-scoped repositories (v1):
curl -s "$BASE/tenantrepositories/list" \ "${A[@]}" "${C[@]}" | jq .Embedded product info (v2):
curl -s "$BASE/api/v2/ops/embedded-info" \ "${A[@]}" | jq .# {"embedded":false,"message":"no product embedded"}# or# {"embedded":true,"manifest":{"name":"...","files":42,"size":12800}}5.2 Product File Reads — v1 surface
Section titled “5.2 Product File Reads — v1 surface”List files in the product root:
curl -s "$BASE/product/$PRODUCT/files" \ "${A[@]}" "${C[@]}" | jq .With depth and type filter:
# depth=2, files onlycurl -s "$BASE/product/$PRODUCT/files?path=/&depth=2&listtype=files" \ "${A[@]}" "${C[@]}" | jq .
# depth=1, folders onlycurl -s "$BASE/product/$PRODUCT/files?path=/&depth=1&listtype=folders" \ "${A[@]}" "${C[@]}" | jq .Get a single file by path:
curl -s "$BASE/product/$PRODUCT/file/config.yaml" \ "${A[@]}" "${C[@]}"# Returns raw file bytes (YAML)
curl -s "$BASE/product/$PRODUCT/file/services/iam/config.yaml" \ "${A[@]}" "${C[@]}"Get all files of a type:
curl -s "$BASE/product/$PRODUCT/type/kisai" \ "${A[@]}" "${C[@]}" | jq .
curl -s "$BASE/product/$PRODUCT/type/datastore" \ "${A[@]}" "${C[@]}" | jq .Get a specific typed file by name:
curl -s "$BASE/product/$PRODUCT/type/datastore/file/main.yaml" \ "${A[@]}" "${C[@]}"List files under a typed path:
curl -s "$BASE/product/$PRODUCT/type/datastore/files/services/iam" \ "${A[@]}" "${C[@]}" | jq .With version (git mode only):
# By branchcurl -s "$BASE/product/$PRODUCT/file/config.yaml?version=main&versionType=branch" \ "${A[@]}" "${C[@]}"
# By tagcurl -s "$BASE/product/$PRODUCT/file/config.yaml?version=v1.0.0&versionType=tag" \ "${A[@]}" "${C[@]}"
# By commit SHAcurl -s "$BASE/product/$PRODUCT/file/config.yaml?version=abc1234&versionType=commit" \ "${A[@]}" "${C[@]}"5.3 Product File Reads — v2 surface
Section titled “5.3 Product File Reads — v2 surface”The v2 surface lives under /api/v2/ and adds ETag headers on every response.
List files:
curl -si "$BASE/api/v2/product/$PRODUCT/files?path=/&depth=1" \ "${A[@]}" "${C[@]}" | head -30Get a file (note the ETag header):
curl -si "$BASE/api/v2/product/$PRODUCT/file/config.yaml" \ "${A[@]}" "${C[@]}"# HTTP/1.1 200 OK# ETag: "a3f1...c9d4"# Content-Type: application/octet-stream# ...# name: kisai-demo# version: "1.0.0"Get files by type:
curl -s "$BASE/api/v2/product/$PRODUCT/type/datastore" \ "${A[@]}" "${C[@]}" | jq .List files by type under a path:
curl -s "$BASE/api/v2/product/$PRODUCT/type/datastore/files/services/iam" \ "${A[@]}" "${C[@]}" | jq .Get a typed file by name:
curl -s "$BASE/api/v2/product/$PRODUCT/type/datastore/file/main.yaml" \ "${A[@]}" "${C[@]}"5.4 Git Operations
Section titled “5.4 Git Operations”These require the product to be a git repository (mode 3.3 / 3.4).
List refs (branches and tags):
curl -s "$BASE/product/$PRODUCT/git/refs" \ "${A[@]}" "${C[@]}" | jq .# {"branches":["main"],"tags":["v1.0.0"]}
# v2curl -s "$BASE/api/v2/product/$PRODUCT/git/refs" \ "${A[@]}" "${C[@]}" | jq .Get current version (HEAD commit info):
curl -s "$BASE/product/$PRODUCT/git/version" \ "${A[@]}" "${C[@]}" | jq .# {"hash":"abc1234...","branch":"refs/heads/main","version":"main"}
# v2curl -s "$BASE/api/v2/product/$PRODUCT/git/version" \ "${A[@]}" "${C[@]}" | jq .Git status (shows modified / untracked files):
curl -s -X POST "$BASE/product/$PRODUCT/git/status" \ "${A[@]}" "${C[@]}" \ -H "Content-Type: application/json" -d '{}' | jq .Git diff:
curl -s -X POST "$BASE/product/$PRODUCT/git/diff" \ "${A[@]}" "${C[@]}" \ -H "Content-Type: application/json" -d '{}' | jq .Fetch from remote (requires non-readonly repo):
curl -s -X POST "$BASE/product/$PRODUCT/git/fetch" \ "${A[@]}" "${C[@]}" \ -H "Content-Type: application/json" -d '{}' | jq .5.5 Service Config, Datastore, Type Reads
Section titled “5.5 Service Config, Datastore, Type Reads”These are the endpoints chassis services use to pull their configuration. They’re the most important to verify in CI.
Service config — v1 (the primary chassis use-case):
curl -s "$BASE/product/$PRODUCT/service/$SERVICE/config" \ "${A[@]}" "${C[@]}" | jq .With array=true (returns data as a list of file bodies, not a merged map):
curl -s "$BASE/product/$PRODUCT/service/$SERVICE/config?array=true" \ "${A[@]}" "${C[@]}" | jq .With product and service injection into the response:
curl -s "$BASE/product/$PRODUCT/service/$SERVICE/config?array=true&injectproduct=true&injectservices=true" \ "${A[@]}" "${C[@]}" | jq .Expected response shape (non-array mode):
{ "data": { "name": "iam", "version": "2.1.0", "defaultdatastore": "main" }, "service": { "name": "iam", "version": "refs/heads/main", "hash": "abc1234..." }}Service config — v2:
curl -s "$BASE/api/v2/product/$PRODUCT/service/$SERVICE/config?array=true" \ "${A[@]}" "${C[@]}" | jq .Service datastore — v1:
curl -s "$BASE/product/$PRODUCT/service/$SERVICE/datastore" \ "${A[@]}" "${C[@]}" | jq .Expected response:
{ "data": { "datastore": { "main": { "files": { "data": ["name: main\ntype: postgres\n..."] } } }, "defaultdatastore": "main" }, "service": { "name": "iam", "version": "...", "hash": "..." }}Service datastore — v2:
curl -s "$BASE/api/v2/product/$PRODUCT/service/$SERVICE/datastore" \ "${A[@]}" "${C[@]}" | jq .Service files by type — v1:
# All YAML files under services/iam/rules/curl -s "$BASE/product/$PRODUCT/service/$SERVICE/type/rules" \ "${A[@]}" "${C[@]}" | jq .
# Single file of typecurl -s "$BASE/product/$PRODUCT/service/$SERVICE/type/rules/file" \ "${A[@]}" "${C[@]}" | jq .
# v2curl -s "$BASE/api/v2/product/$PRODUCT/service/$SERVICE/type/rules" \ "${A[@]}" "${C[@]}" | jq .Standalone service (not product-scoped) last-updated:
curl -s "$BASE/service/$SERVICE/lastupdated" \ "${A[@]}" -H "X-Customer: $CUSTOMER" | jq .# {"lastupdated": 1716400000}5.6 Remote Filesystem Endpoints
Section titled “5.6 Remote Filesystem Endpoints”The fs/ endpoints expose a direct filesystem abstraction over the product repo.
Used by lib-chassis/meta for local-mirror mode.
Stat a file or directory:
# v1curl -s "$BASE/product/$PRODUCT/fs/stat/config.yaml" \ "${A[@]}" "${C[@]}" | jq .# {"name":"config.yaml","size":42,"is_dir":false,"mod_time":1716400000}
# v2curl -s "$BASE/api/v2/product/$PRODUCT/fs/stat/config.yaml" \ "${A[@]}" "${C[@]}" | jq .Read a file (raw bytes, sets ETag on v2):
# v1curl -s "$BASE/product/$PRODUCT/fs/readfile/config.yaml" \ "${A[@]}" "${C[@]}"
# v2 (note ETag header in response)curl -si "$BASE/api/v2/product/$PRODUCT/fs/readfile/config.yaml" \ "${A[@]}" "${C[@]}" | head -10Read a directory (list entries):
# v1curl -s "$BASE/product/$PRODUCT/fs/readdir/" \ "${A[@]}" "${C[@]}" | jq .# [{"name":"config.yaml","is_dir":false,"size":42},# {"name":"services","is_dir":true,"size":0}, ...]
# v2curl -s "$BASE/api/v2/product/$PRODUCT/fs/readdir/services/iam" \ "${A[@]}" "${C[@]}" | jq .Check existence (never returns 404 — always 200 with exists bool):
# v1curl -s "$BASE/product/$PRODUCT/fs/exists/config.yaml" \ "${A[@]}" "${C[@]}" | jq .# {"exists":true,"is_dir":false}
curl -s "$BASE/product/$PRODUCT/fs/exists/does-not-exist.yaml" \ "${A[@]}" "${C[@]}" | jq .# {"exists":false,"is_dir":false}
# v2curl -s "$BASE/api/v2/product/$PRODUCT/fs/exists/services/iam" \ "${A[@]}" "${C[@]}" | jq .# {"exists":true,"is_dir":true}5.7 Batch, Manifest, Resolve — v2 only
Section titled “5.7 Batch, Manifest, Resolve — v2 only”These are v2-exclusive endpoints. They exist for efficiency — reducing round-trips when a client needs multiple files.
Batch fetch (multiple files, one request):
curl -s -X POST "$BASE/api/v2/product/$PRODUCT/batch" \ "${A[@]}" "${C[@]}" \ -H "Content-Type: application/json" \ -d '{ "paths": [ "config.yaml", "services/iam/config.yaml", "services/iam/datastore/main.yaml", "this-file-does-not-exist.yaml" ] }' | jq .Expected response (missing files carry error, not a 4xx):
{ "files": [ { "path": "config.yaml", "content": "bmFtZToga2lzYWktZGVtbwo=", "hash": "a3f1...c9d4", "size": 28, "error": "" }, { "path": "services/iam/config.yaml", "content": "...", "hash": "...", "size": 45, "error": "" }, { "path": "services/iam/datastore/main.yaml", "content": "...", "hash": "...", "size": 82, "error": "" }, { "path": "this-file-does-not-exist.yaml", "content": "", "hash": "", "size": 0, "error": "file not found" } ]}Content is base64-encoded. Decode with:
curl -s -X POST "$BASE/api/v2/product/$PRODUCT/batch" \ "${A[@]}" "${C[@]}" \ -H "Content-Type: application/json" \ -d '{"paths":["config.yaml"]}' \ | jq -r '.files[0].content' | base64 -dWith version:
curl -s -X POST "$BASE/api/v2/product/$PRODUCT/batch" \ "${A[@]}" "${C[@]}" \ -H "Content-Type: application/json" \ -d '{"paths":["config.yaml"],"version":"v1.0.0","versionType":"tag"}' \ | jq .Manifest (Merkle hash tree):
# Root manifestcurl -si "$BASE/api/v2/product/$PRODUCT/manifest" \ "${A[@]}" "${C[@]}" | jq .Expected response (plus ETag: "<root_hash>" header):
{ "root_hash": "sha256:4a3b...", "entries": [ { "path": "config.yaml", "hash": "sha256:a3f1...", "size": 28, "mod_time": 1716400000, "is_dir": false }, { "path": "services", "hash": "sha256:...", "size": 0, "mod_time": 1716400000, "is_dir": true }, { "path": "services/iam", "hash": "sha256:...", "size": 0, "mod_time": 1716400000, "is_dir": true }, { "path": "services/iam/config.yaml", "hash": "sha256:...", "size": 45, "mod_time": 1716400000, "is_dir": false } ]}Scoped to a sub-path:
curl -s "$BASE/api/v2/product/$PRODUCT/manifest?path=services/iam" \ "${A[@]}" "${C[@]}" | jq .Resolve (import graph resolution with deduplication):
The have map tells the server which file hashes the client already possesses so they
can be skipped in the response. Use the hashes from a previous manifest call.
curl -s -X POST "$BASE/api/v2/product/$PRODUCT/resolve" \ "${A[@]}" "${C[@]}" \ -H "Content-Type: application/json" \ -d '{ "path": "services/iam/config.yaml", "have": { "sha256:a3f1c9d4...": "config.yaml" }, "depth": 3 }' | jq .Expected response:
{ "root": "services/iam/config.yaml", "files": { "services/iam/config.yaml": { "path": "services/iam/config.yaml", "content": "...", "hash": "sha256:...", "size": 45 } }, "skipped": { "sha256:a3f1c9d4...": "config.yaml" }, "unresolved": []}5.8 Conditional Requests (ETag / 304)
Section titled “5.8 Conditional Requests (ETag / 304)”v2 endpoints set an ETag header on every file and manifest response.
Clients can cache aggressively and revalidate with If-None-Match.
Full round-trip:
# Step 1: get the file and capture ETagETAG=$(curl -si "$BASE/api/v2/product/$PRODUCT/file/config.yaml" \ "${A[@]}" "${C[@]}" \ | grep -i '^etag:' | tr -d '\r' | awk '{print $2}')
echo "ETag: $ETAG"
# Step 2: conditional request — expect 304 with empty bodyHTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ "$BASE/api/v2/product/$PRODUCT/file/config.yaml" \ "${A[@]}" "${C[@]}" \ -H "If-None-Match: $ETAG")
echo "Status: $HTTP_STATUS" # should print 304
# Step 3: stale ETag — expect 200 with fresh contentcurl -si "$BASE/api/v2/product/$PRODUCT/file/config.yaml" \ "${A[@]}" "${C[@]}" \ -H 'If-None-Match: "definitely-wrong-etag"' \ | head -5Manifest conditional request:
MANIFEST_ETAG=$(curl -si "$BASE/api/v2/product/$PRODUCT/manifest" \ "${A[@]}" "${C[@]}" \ | grep -i '^etag:' | tr -d '\r' | awk '{print $2}')
curl -s -o /dev/null -w "%{http_code}" \ "$BASE/api/v2/product/$PRODUCT/manifest" \ "${A[@]}" "${C[@]}" \ -H "If-None-Match: $MANIFEST_ETAG"# 304ETag stability (same content → same ETag on repeated GETs):
ETAG1=$(curl -si "$BASE/api/v2/product/$PRODUCT/file/config.yaml" \ "${A[@]}" "${C[@]}" | grep -i '^etag:' | tr -d '\r' | awk '{print $2}')
ETAG2=$(curl -si "$BASE/api/v2/product/$PRODUCT/file/config.yaml" \ "${A[@]}" "${C[@]}" | grep -i '^etag:' | tr -d '\r' | awk '{print $2}')
[ "$ETAG1" = "$ETAG2" ] && echo "PASS: ETags are stable" || echo "FAIL: ETags differ"5.9 Marketplace Endpoints
Section titled “5.9 Marketplace Endpoints”Marketplace endpoints require a marketplace repo to be registered. Start the server with
a marketplace argument (there is no --marketplace flag — use full-stack mode or set up
AvailableMarketplaceRepos via config). In the simple serve mode, marketplace endpoints
return 404 when no marketplace is configured.
Get a marketplace file:
MARKET="kisai-components"
curl -s "$BASE/market/$MARKET/file/README.md" \ "${A[@]}" -H "X-Customer: $CUSTOMER" | head -20
# v2curl -s "$BASE/api/v2/market/$MARKET/file/README.md" \ "${A[@]}" -H "X-Customer: $CUSTOMER"Get marketplace component tree:
curl -s "$BASE/market/$MARKET/components/" \ "${A[@]}" -H "X-Customer: $CUSTOMER" | jq .
# v2curl -s "$BASE/api/v2/market/$MARKET/components/" \ "${A[@]}" -H "X-Customer: $CUSTOMER" | jq .Multi-file fetch from marketplace (PATCH):
curl -s -X PATCH "$BASE/market/$MARKET/paths" \ "${A[@]}" -H "X-Customer: $CUSTOMER" \ -H "Content-Type: application/json" \ -d '{ "paths": ["README.md", "components/button/config.yaml"], "destination": "/tmp/market-download" }' -o market-files.zip
file market-files.zip# market-files.zip: Zip archive data5.10 Write Operations
Section titled “5.10 Write Operations”Write operations require the server to be started without --readonly (the default is
readonly=true so you must explicitly pass --readonly=false):
./meta serve acme:kisai-demo:./my-product -k kisai_A1B2C3D4 --readonly=falseCreate a directory:
curl -s -X PUT "$BASE/product/$PRODUCT/files" \ "${A[@]}" "${C[@]}" \ -H "Content-Type: application/json" \ -d '{"path": "services/newservice"}' | jq .Upload a file (multipart):
curl -s -X POST "$BASE/product/$PRODUCT/file/services/newservice/config.yaml" \ "${A[@]}" "${C[@]}" \ -F "file=@./newservice-config.yaml" | jq .Save a file with format:
curl -s -X PUT "$BASE/product/$PRODUCT/type/datastore/file/services/newservice/db.yaml" \ "${A[@]}" "${C[@]}" \ -H "Content-Type: application/json" \ -d '{"content": "name: newdb\ntype: postgres\n"}' | jq .Stage, commit, push (git mode only, requires remote):
# Stage all untracked changescurl -s -X POST "$BASE/product/$PRODUCT/git/stage" \ "${A[@]}" "${C[@]}" -H "Content-Type: application/json" -d '{}' | jq .
# Commitcurl -s -X POST "$BASE/product/$PRODUCT/git/commit" \ "${A[@]}" "${C[@]}" \ -H "Content-Type: application/json" \ -d '{"message": "Add new service config"}' | jq .
# Pushcurl -s -X POST "$BASE/product/$PRODUCT/git/push" \ "${A[@]}" "${C[@]}" -H "Content-Type: application/json" -d '{}' | jq .Add and commit a single file:
curl -s -X PATCH "$BASE/product/$PRODUCT/git/commitfile" \ "${A[@]}" "${C[@]}" \ -H "Content-Type: application/json" \ -d '{ "path": "services/iam/notes.txt", "content": "reviewed 2026-05-22", "message": "Add review notes" }' | jq .5.11 Negative / Auth Tests
Section titled “5.11 Negative / Auth Tests”Missing API key — expect 401:
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ "$BASE/repositories/list" -H "X-Customer: $CUSTOMER")echo $STATUS # 401 when server started with --apikeyWrong API key — expect 401:
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ "$BASE/repositories/list" \ -H "X-Api-Key: wrong_key" \ -H "X-Customer: $CUSTOMER")echo $STATUS # 401Unknown product — expect 4xx (not 500):
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ "$BASE/product/nonexistent-product/files" \ "${A[@]}" -H "X-Customer: $CUSTOMER")echo $STATUS # 400 or 404Unknown customer — expect 4xx:
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ "$BASE/product/$PRODUCT/files" \ "${A[@]}" -H "X-Customer: unknown-customer")echo $STATUS # 400Server error check (internal errors must never be 5xx on valid paths):
# Valid path, valid auth — must be 200curl -s -o /dev/null -w "%{http_code}" \ "$BASE/product/$PRODUCT/file/config.yaml" \ "${A[@]}" "${C[@]}"# 200
# Missing file — must be 404, never 500curl -s -o /dev/null -w "%{http_code}" \ "$BASE/api/v2/product/$PRODUCT/file/this-does-not-exist.yaml" \ "${A[@]}" "${C[@]}"# 404 (not 500)6. Testing with the OpenAPI Spec
Section titled “6. Testing with the OpenAPI Spec”The single importable specification lives at docs/openapi.yaml (OpenAPI 3.1.0).
It covers both the v1 surface (legacy root paths) and the v2 surface (/api/v2/), with all
parameters, request bodies, response schemas, and inline examples.
Import it into any OpenAPI-compatible client to get a fully pre-populated collection — no manual request creation needed.
6.1 Import into Bruno
Section titled “6.1 Import into Bruno”Bruno (OSS, no cloud account required) natively imports OpenAPI specs.
# macOSbrew install --cask bruno# or download from https://www.usebruno.com- Open Bruno → Import Collection
- Choose OpenAPI v3 → select
docs/openapi.yaml - Bruno creates one request per operation, grouped by tag (liveness, discovery, product-files, etc.)
- Set up environment variables:
| Variable | Example value | Description |
|---|---|---|
baseUrl | http://localhost:8080 | Server base URL |
apikey | kisai_ABC123 | Value of X-Api-Key header |
customer | acme | X-Customer header |
product | demo | X-Product + URL {name} segment |
env | dev | X-Env header |
tenant | acme | X-Tenant header |
testFile | config/app.yaml | File path used in file-read requests |
service | iam | Service name for service-config ops |
tagVersion | v1.0.0 | Git tag for version-pinned reads |
- Select the environment, then run requests individually or use Run Collection.
Tip: Bruno can also run collections headlessly via the
@usebruno/clinpm package:Terminal window npm install -g @usebruno/clibru run --env local-apikey
6.2 Import into Postman
Section titled “6.2 Import into Postman”- Open Postman → Import → drag in
docs/openapi.yaml - Postman creates a collection with folders per tag
- Create an Environment with the same variables listed in §6.1
- Run the collection via Collection Runner or Newman:
npm install -g newmannewman run docs/openapi.yaml \ --env-var baseUrl=http://localhost:8080 \ --env-var customer=acme \ --env-var product=demo \ --env-var env=dev \ --env-var tenant=acme \ --env-var apikey=kisai_ABC123 \ --env-var testFile=config/app.yaml \ --env-var service=iamNewman can also import an OpenAPI spec directly without converting to a Postman collection first.
6.3 Import into Insomnia / Hoppscotch
Section titled “6.3 Import into Insomnia / Hoppscotch”Insomnia:
- File → Import → choose
docs/openapi.yaml→ Import as Request Collection - Set base environment variables (same set as §6.1)
- Run via the Insomnia UI or
inso run collection
Hoppscotch (browser-based, no install):
- Go to hoppscotch.io → Import/Export → Import from OpenAPI
- Upload
docs/openapi.yaml - Configure environment variables in the Environments panel
6.4 Using httpYac (VS Code / CLI)
Section titled “6.4 Using httpYac (VS Code / CLI)”httpYac can drive an OpenAPI spec directly from the terminal or VS Code.
npm install -g httpyac
# List all operationshttpyac send docs/openapi.yaml --list
# Run a specific operationhttpyac send docs/openapi.yaml --name getFileV1 \ -e baseUrl=http://localhost:8080 \ -e customer=acme \ -e product=demo \ -e env=dev \ -e tenant=acme \ -e testFile=config/app.yaml
# Run all operations tagged 'smoke' (health + file reads)httpyac send docs/openapi.yaml --tag smoke \ -e baseUrl=http://localhost:8080 \ -e customer=acme \ -e product=demo \ -e env=dev \ -e tenant=acmeThe smoke tag covers /health, /ready, and the basic file-read operations — a quick
sanity-check you can run in CI after deploying:
# CI one-liner (exits non-zero on any failure)httpyac send docs/openapi.yaml --tag smoke \ -e baseUrl=$META_URL \ -e customer=$CUSTOMER \ -e product=$PRODUCT \ -e env=$ENV \ -e tenant=$TENANT \ --bail7. DevSecOps Checklist
Section titled “7. DevSecOps Checklist”Run through this before every production deployment.
Authentication
Section titled “Authentication”# 1. Unauthenticated request is rejected[ $(curl -s -o /dev/null -w "%{http_code}" $BASE/repositories/list) -eq 401 ] \ && echo "PASS" || echo "FAIL: auth not enforced"
# 2. Wrong key is rejected[ $(curl -s -o /dev/null -w "%{http_code}" $BASE/repositories/list \ -H "X-Api-Key: wrong") -eq 401 ] \ && echo "PASS" || echo "FAIL: wrong key accepted"
# 3. Correct key is accepted[ $(curl -s -o /dev/null -w "%{http_code}" $BASE/repositories/list \ "${A[@]}" -H "X-Customer: $CUSTOMER") -eq 200 ] \ && echo "PASS" || echo "FAIL: correct key rejected"Customer Isolation
Section titled “Customer Isolation”# Start with two customers./meta serve \ acme:app:./acme-product \ contoso:app:./contoso-product \ -k kisai_A1B2C3D4
# acme can read their product[ $(curl -s -o /dev/null -w "%{http_code}" $BASE/product/app/files \ "${A[@]}" -H "X-Customer: acme") -eq 200 ] \ && echo "PASS" || echo "FAIL"
# contoso should not be able to access acme's files via wrong customer header# (both repos are named "app" — customer header is the distinguisher)ACME_FILE=$(curl -s $BASE/product/app/file/config.yaml \ "${A[@]}" -H "X-Customer: acme")CONTOSO_FILE=$(curl -s $BASE/product/app/file/config.yaml \ "${A[@]}" -H "X-Customer: contoso")[ "$ACME_FILE" != "$CONTOSO_FILE" ] \ && echo "PASS: customer isolation working" || echo "WARN: files identical (may be intentional)"No 5xx on Valid Paths
Section titled “No 5xx on Valid Paths”for ENDPOINT in \ "/health" \ "/ready" \ "/repositories/list" \ "/api/v2/ops/repositories" \ "/api/v2/ops/embedded-info" \ "/product/$PRODUCT/files" \ "/api/v2/product/$PRODUCT/files" \ "/api/v2/product/$PRODUCT/manifest"do STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ "$BASE$ENDPOINT" "${A[@]}" "${C[@]}") [ "$STATUS" -lt 500 ] \ && echo "PASS [$STATUS]: $ENDPOINT" \ || echo "FAIL [$STATUS]: $ENDPOINT"doneETag Caching Works
Section titled “ETag Caching Works”ETAG=$(curl -si "$BASE/api/v2/product/$PRODUCT/file/config.yaml" \ "${A[@]}" "${C[@]}" | grep -i '^etag:' | tr -d '\r' | awk '{print $2}')
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ "$BASE/api/v2/product/$PRODUCT/file/config.yaml" \ "${A[@]}" "${C[@]}" -H "If-None-Match: $ETAG")
[ "$STATUS" -eq 304 ] && echo "PASS: ETag caching works" || echo "FAIL: got $STATUS, expected 304"Readonly Enforcement
Section titled “Readonly Enforcement”# Start with readonly=true (default)./meta serve acme:$PRODUCT:./my-product -k kisai_A1B2C3D4
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ -X PUT "$BASE/product/$PRODUCT/files" \ "${A[@]}" "${C[@]}" \ -H "Content-Type: application/json" \ -d '{"path":"test-dir"}')
[ "$STATUS" -eq 403 ] || [ "$STATUS" -eq 405 ] \ && echo "PASS: writes blocked in readonly mode" \ || echo "WARN: got $STATUS (check if writes should be blocked)"TLS (staging / production only)
Section titled “TLS (staging / production only)”# Verify TLS is enforced and cert is validcurl -sv https://meta.staging.kis.ai/health 2>&1 | grep -E "SSL|TLS|certificate|issuer"
# Expired or self-signed cert should failcurl -s --fail https://meta.staging.kis.ai/health \ && echo "PASS: TLS valid" || echo "FAIL: TLS error"Liveness / Readiness Probes (Kubernetes)
Section titled “Liveness / Readiness Probes (Kubernetes)”# Paste into your Deployment speclivenessProbe: httpGet: path: /health port: 8080 initialDelaySeconds: 5 periodSeconds: 10 failureThreshold: 3
readinessProbe: httpGet: path: /ready port: 8080 initialDelaySeconds: 3 periodSeconds: 5 failureThreshold: 2Verify both return 200:
curl -sf $BASE/health && echo "healthy" || echo "unhealthy"curl -sf $BASE/ready && echo "ready" || echo "not ready"