Skip to content
Talk to our solutions team

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.

  1. Prerequisites
  2. Request Headers Reference
  3. Server Run Modes
  4. Sample Product Tree
  5. Testing with curl
  6. Testing with the OpenAPI Spec
  7. DevSecOps Checklist
Terminal window
# From the meta.svc source repo root
cd /path/to/meta-svc
go build -o meta .

Or run directly without building:

Terminal window
go run . serve acme:demo:./my-product
Terminal window
brew install jq # macOS
apt-get install jq # Debian/Ubuntu
Terminal window
./meta version
./meta --help
./meta serve --help

Every authenticated request needs these headers. The server injects missing values where it can, but supplying all of them explicitly avoids routing ambiguity.

HeaderRequiredDescription
X-Api-KeyWhen server started with --apikeyMatches the value passed to meta serve -k <key>. Can also be passed as ?apikey=<key> query param.
AuthorizationAlternative to X-Api-KeyBearer <jwt> — used in full-stack mode with IAM issuing the token.
X-CustomerYes for most readsCustomer / organisation name. Must match the first segment of the product registration (customer:product:path).
X-ProductRecommendedProduct name. Helps the server skip customer-level scoping.
X-EnvRecommendedEnvironment name (local, dev, staging, prod). Defaults to local.
X-TenantOptionalFull tenant key (customer/_/env). If absent, server synthesises it from X-Customer + X-Env.
If-None-MatchFor conditional requestsETag value from a previous v2 response. Returns 304 when content has not changed.
Content-TypeFor POST / PATCH / PUTapplication/json for JSON bodies.

Convenience shell aliases used throughout this guide:

Terminal window
# Adjust these to match your setup
BASE="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")

All examples use the binary at ./meta. Replace with go run . if not built.

When to use: Local development, quickest setup, no secrets.

Terminal window
mkdir -p my-product/services/iam
echo "name: iam" > my-product/services/iam/config.yaml
./meta serve acme:kisai-demo:./my-product
meta serve listening on :8080
auth: disabled
acme:kisai-demo [folder] -> /absolute/path/to/my-product
  • Port defaults to 8080.
  • X-Api-Key header is ignored — no auth check.
  • X-Customer header must still be acme for the repo to be found.

When to use: Shared dev/CI environments where you need a lightweight secret gate.

Terminal window
./meta serve acme:kisai-demo:./my-product -k kisai_A1B2C3D4
meta serve listening on :8080
auth: API key required
acme:kisai-demo [folder] -> /absolute/path/to/my-product
  • Any request missing X-Api-Key: kisai_A1B2C3D4 (or ?apikey=kisai_A1B2C3D4) returns 401 Unauthorized.
  • Use a randomly generated key in shared environments: openssl rand -hex 20.

Custom port:

Terminal window
./meta serve acme:kisai-demo:./my-product -k kisai_A1B2C3D4 -p 9090
BASE="http://localhost:9090"

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.

Terminal window
# Make sure ./my-product is a git repo
git -C my-product init
git -C my-product add .
git -C my-product commit -m "init"
./meta serve acme:kisai-demo:./my-product
meta serve listening on :8080
auth: disabled
acme:kisai-demo [git] -> /absolute/path/to/my-product

Branches and tags are accessible via the ?version=<name>&versionType=branch|tag query parameter:

Terminal window
curl "$BASE/product/kisai-demo/file/config.yaml?version=main&versionType=branch" \
"${CPET[@]}"
Terminal window
git -C my-product tag v1.0.0
./meta serve acme:kisai-demo:./my-product -k kisai_A1B2C3D4

Test tag-based access:

Terminal window
curl "$BASE/api/v2/product/kisai-demo/file/config.yaml?version=v1.0.0&versionType=tag" \
"${AUTH[@]}" "${CPET[@]}"

When to use: A customer has several independent products.

Terminal window
./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-product

Requests differentiate products via the URL path parameter and the X-Product header:

Terminal window
# Frontend product
curl "$BASE/product/frontend/files" \
"${AUTH[@]}" -H "X-Customer: acme" -H "X-Product: frontend"
# Backend product
curl "$BASE/product/backend/files" \
"${AUTH[@]}" -H "X-Customer: acme" -H "X-Product: backend"

When to use: Testing multi-tenant repo isolation.

Terminal window
./meta serve \
acme:app:./acme-product \
contoso:app:./contoso-product \
-k kisai_A1B2C3D4

Customer header routes the request to the correct repo:

Terminal window
# acme sees only their app
curl "$BASE/product/app/files" \
"${AUTH[@]}" -H "X-Customer: acme"
# contoso sees only their app
curl "$BASE/product/app/files" \
"${AUTH[@]}" -H "X-Customer: contoso"

Verify isolation — acme should not read contoso’s files:

Terminal window
# Should return their own repo, not the other customer's
curl -s "$BASE/product/app/file/config.yaml" \
"${AUTH[@]}" -H "X-Customer: acme" | jq .

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):

Terminal window
# Build a binary with an embedded product first:
./meta embed pack --product ./my-product --output embedded-meta
./embedded-meta serve

With a license key (production-encrypted embed):

Terminal window
./embedded-meta serve --license kisai_LICENSE_KEY_HERE

When no args and no embedded product:

Terminal window
./meta serve
# Error: no products specified and no embedded product found

Check what is embedded:

Terminal window
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"}

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):

# Port
port: "8080"
# Service mode: "meta" | "product" | "marketplace"
metamode: meta
# API key for inter-service calls
apikey: "kisai_INTERNAL_KEY"
# Config service URL (config.svc)
config_url: "http://config.svc:8090"
config_apikey: "kisai_CONFIG_KEY"
# Datacenter / cluster identity
dc: us-west
cluster: 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 marketplace
marketplace:
- 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: 30s

Start the full-stack server:

Terminal window
./meta --config meta-config.yaml

Or with individual flags:

Terminal window
./meta \
--config meta-config.yaml \
--apikey kisai_INTERNAL_KEY \
--port 8080 \
--metamode meta \
--dc us-west \
--cluster prod-01

JWT 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:

Terminal window
# 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 \
-d '{"email":"[email protected]","password":"secret"}' \
-H "Content-Type: application/json" | jq -r .access_token)
curl "$BASE/product/kisai-demo/files" \
-H "Authorization: Bearer $TOKEN" \
-H "X-Customer: acme"

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.html

Create it:

Terminal window
mkdir -p my-product/{services/iam/{datastore,rules,plugins/preauth},content/apps/dashboard/templates}
cat > my-product/config.yaml <<'EOF'
name: kisai-demo
version: "1.0.0"
EOF
cat > my-product/services/iam/config.yaml <<'EOF'
name: iam
version: "2.1.0"
defaultdatastore: main
EOF
cat > my-product/services/iam/datastore/main.yaml <<'EOF'
name: main
type: postgres
host: db.internal
port: 5432
database: iam_db
EOF
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: dashboard
type: content-app
EOF
cat > my-product/content/apps/dashboard/templates/welcome.html <<'EOF'
<h1>Welcome to {{tenant}}</h1>
EOF
# For git-mode tests
git -C my-product init
git -C my-product add .
git -C my-product commit -m "init product"
git -C my-product tag v1.0.0

Start the server once and leave it running for all curl tests below:

Terminal window
./meta serve acme:kisai-demo:./my-product -k kisai_A1B2C3D4

Variables used throughout:

Terminal window
BASE="http://localhost:8080"
APIKEY="kisai_A1B2C3D4"
CUSTOMER="acme"
PRODUCT="kisai-demo"
SERVICE="iam"
ENV="local"
# Shorthand
A=(-H "X-Api-Key: $APIKEY")
C=(-H "X-Customer: $CUSTOMER" -H "X-Product: $PRODUCT" -H "X-Env: $ENV")

Liveness and readiness (no auth required):

Terminal window
curl -s "$BASE/health" | jq .
# {"status":"healthy"}
curl -s "$BASE/ready" | jq .
# {"status":"ready"}

List all registered repositories (v1):

Terminal window
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):

Terminal window
curl -s "$BASE/api/v2/ops/repositories" \
"${A[@]}" -H "X-Customer: $CUSTOMER" | jq .

List tenant-scoped repositories (v1):

Terminal window
curl -s "$BASE/tenantrepositories/list" \
"${A[@]}" "${C[@]}" | jq .

Embedded product info (v2):

Terminal window
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}}

List files in the product root:

Terminal window
curl -s "$BASE/product/$PRODUCT/files" \
"${A[@]}" "${C[@]}" | jq .

With depth and type filter:

Terminal window
# depth=2, files only
curl -s "$BASE/product/$PRODUCT/files?path=/&depth=2&listtype=files" \
"${A[@]}" "${C[@]}" | jq .
# depth=1, folders only
curl -s "$BASE/product/$PRODUCT/files?path=/&depth=1&listtype=folders" \
"${A[@]}" "${C[@]}" | jq .

Get a single file by path:

Terminal window
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:

Terminal window
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:

Terminal window
curl -s "$BASE/product/$PRODUCT/type/datastore/file/main.yaml" \
"${A[@]}" "${C[@]}"

List files under a typed path:

Terminal window
curl -s "$BASE/product/$PRODUCT/type/datastore/files/services/iam" \
"${A[@]}" "${C[@]}" | jq .

With version (git mode only):

Terminal window
# By branch
curl -s "$BASE/product/$PRODUCT/file/config.yaml?version=main&versionType=branch" \
"${A[@]}" "${C[@]}"
# By tag
curl -s "$BASE/product/$PRODUCT/file/config.yaml?version=v1.0.0&versionType=tag" \
"${A[@]}" "${C[@]}"
# By commit SHA
curl -s "$BASE/product/$PRODUCT/file/config.yaml?version=abc1234&versionType=commit" \
"${A[@]}" "${C[@]}"

The v2 surface lives under /api/v2/ and adds ETag headers on every response.

List files:

Terminal window
curl -si "$BASE/api/v2/product/$PRODUCT/files?path=/&depth=1" \
"${A[@]}" "${C[@]}" | head -30

Get a file (note the ETag header):

Terminal window
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:

Terminal window
curl -s "$BASE/api/v2/product/$PRODUCT/type/datastore" \
"${A[@]}" "${C[@]}" | jq .

List files by type under a path:

Terminal window
curl -s "$BASE/api/v2/product/$PRODUCT/type/datastore/files/services/iam" \
"${A[@]}" "${C[@]}" | jq .

Get a typed file by name:

Terminal window
curl -s "$BASE/api/v2/product/$PRODUCT/type/datastore/file/main.yaml" \
"${A[@]}" "${C[@]}"

These require the product to be a git repository (mode 3.3 / 3.4).

List refs (branches and tags):

Terminal window
curl -s "$BASE/product/$PRODUCT/git/refs" \
"${A[@]}" "${C[@]}" | jq .
# {"branches":["main"],"tags":["v1.0.0"]}
# v2
curl -s "$BASE/api/v2/product/$PRODUCT/git/refs" \
"${A[@]}" "${C[@]}" | jq .

Get current version (HEAD commit info):

Terminal window
curl -s "$BASE/product/$PRODUCT/git/version" \
"${A[@]}" "${C[@]}" | jq .
# {"hash":"abc1234...","branch":"refs/heads/main","version":"main"}
# v2
curl -s "$BASE/api/v2/product/$PRODUCT/git/version" \
"${A[@]}" "${C[@]}" | jq .

Git status (shows modified / untracked files):

Terminal window
curl -s -X POST "$BASE/product/$PRODUCT/git/status" \
"${A[@]}" "${C[@]}" \
-H "Content-Type: application/json" -d '{}' | jq .

Git diff:

Terminal window
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):

Terminal window
curl -s -X POST "$BASE/product/$PRODUCT/git/fetch" \
"${A[@]}" "${C[@]}" \
-H "Content-Type: application/json" -d '{}' | jq .

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):

Terminal window
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):

Terminal window
curl -s "$BASE/product/$PRODUCT/service/$SERVICE/config?array=true" \
"${A[@]}" "${C[@]}" | jq .

With product and service injection into the response:

Terminal window
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:

Terminal window
curl -s "$BASE/api/v2/product/$PRODUCT/service/$SERVICE/config?array=true" \
"${A[@]}" "${C[@]}" | jq .

Service datastore — v1:

Terminal window
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:

Terminal window
curl -s "$BASE/api/v2/product/$PRODUCT/service/$SERVICE/datastore" \
"${A[@]}" "${C[@]}" | jq .

Service files by type — v1:

Terminal window
# All YAML files under services/iam/rules/
curl -s "$BASE/product/$PRODUCT/service/$SERVICE/type/rules" \
"${A[@]}" "${C[@]}" | jq .
# Single file of type
curl -s "$BASE/product/$PRODUCT/service/$SERVICE/type/rules/file" \
"${A[@]}" "${C[@]}" | jq .
# v2
curl -s "$BASE/api/v2/product/$PRODUCT/service/$SERVICE/type/rules" \
"${A[@]}" "${C[@]}" | jq .

Standalone service (not product-scoped) last-updated:

Terminal window
curl -s "$BASE/service/$SERVICE/lastupdated" \
"${A[@]}" -H "X-Customer: $CUSTOMER" | jq .
# {"lastupdated": 1716400000}

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:

Terminal window
# v1
curl -s "$BASE/product/$PRODUCT/fs/stat/config.yaml" \
"${A[@]}" "${C[@]}" | jq .
# {"name":"config.yaml","size":42,"is_dir":false,"mod_time":1716400000}
# v2
curl -s "$BASE/api/v2/product/$PRODUCT/fs/stat/config.yaml" \
"${A[@]}" "${C[@]}" | jq .

Read a file (raw bytes, sets ETag on v2):

Terminal window
# v1
curl -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 -10

Read a directory (list entries):

Terminal window
# v1
curl -s "$BASE/product/$PRODUCT/fs/readdir/" \
"${A[@]}" "${C[@]}" | jq .
# [{"name":"config.yaml","is_dir":false,"size":42},
# {"name":"services","is_dir":true,"size":0}, ...]
# v2
curl -s "$BASE/api/v2/product/$PRODUCT/fs/readdir/services/iam" \
"${A[@]}" "${C[@]}" | jq .

Check existence (never returns 404 — always 200 with exists bool):

Terminal window
# v1
curl -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}
# v2
curl -s "$BASE/api/v2/product/$PRODUCT/fs/exists/services/iam" \
"${A[@]}" "${C[@]}" | jq .
# {"exists":true,"is_dir":true}

These are v2-exclusive endpoints. They exist for efficiency — reducing round-trips when a client needs multiple files.

Batch fetch (multiple files, one request):

Terminal window
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:

Terminal window
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 -d

With version:

Terminal window
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):

Terminal window
# Root manifest
curl -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:

Terminal window
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.

Terminal window
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": []
}

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:

Terminal window
# Step 1: get the file and capture ETag
ETAG=$(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 body
HTTP_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 content
curl -si "$BASE/api/v2/product/$PRODUCT/file/config.yaml" \
"${A[@]}" "${C[@]}" \
-H 'If-None-Match: "definitely-wrong-etag"' \
| head -5

Manifest conditional request:

Terminal window
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"
# 304

ETag stability (same content → same ETag on repeated GETs):

Terminal window
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"

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:

Terminal window
MARKET="kisai-components"
curl -s "$BASE/market/$MARKET/file/README.md" \
"${A[@]}" -H "X-Customer: $CUSTOMER" | head -20
# v2
curl -s "$BASE/api/v2/market/$MARKET/file/README.md" \
"${A[@]}" -H "X-Customer: $CUSTOMER"

Get marketplace component tree:

Terminal window
curl -s "$BASE/market/$MARKET/components/" \
"${A[@]}" -H "X-Customer: $CUSTOMER" | jq .
# v2
curl -s "$BASE/api/v2/market/$MARKET/components/" \
"${A[@]}" -H "X-Customer: $CUSTOMER" | jq .

Multi-file fetch from marketplace (PATCH):

Terminal window
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 data

Write operations require the server to be started without --readonly (the default is readonly=true so you must explicitly pass --readonly=false):

Terminal window
./meta serve acme:kisai-demo:./my-product -k kisai_A1B2C3D4 --readonly=false

Create a directory:

Terminal window
curl -s -X PUT "$BASE/product/$PRODUCT/files" \
"${A[@]}" "${C[@]}" \
-H "Content-Type: application/json" \
-d '{"path": "services/newservice"}' | jq .

Upload a file (multipart):

Terminal window
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:

Terminal window
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):

Terminal window
# Stage all untracked changes
curl -s -X POST "$BASE/product/$PRODUCT/git/stage" \
"${A[@]}" "${C[@]}" -H "Content-Type: application/json" -d '{}' | jq .
# Commit
curl -s -X POST "$BASE/product/$PRODUCT/git/commit" \
"${A[@]}" "${C[@]}" \
-H "Content-Type: application/json" \
-d '{"message": "Add new service config"}' | jq .
# Push
curl -s -X POST "$BASE/product/$PRODUCT/git/push" \
"${A[@]}" "${C[@]}" -H "Content-Type: application/json" -d '{}' | jq .

Add and commit a single file:

Terminal window
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 .

Missing API key — expect 401:

Terminal window
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
"$BASE/repositories/list" -H "X-Customer: $CUSTOMER")
echo $STATUS # 401 when server started with --apikey

Wrong API key — expect 401:

Terminal window
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
"$BASE/repositories/list" \
-H "X-Api-Key: wrong_key" \
-H "X-Customer: $CUSTOMER")
echo $STATUS # 401

Unknown product — expect 4xx (not 500):

Terminal window
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
"$BASE/product/nonexistent-product/files" \
"${A[@]}" -H "X-Customer: $CUSTOMER")
echo $STATUS # 400 or 404

Unknown customer — expect 4xx:

Terminal window
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
"$BASE/product/$PRODUCT/files" \
"${A[@]}" -H "X-Customer: unknown-customer")
echo $STATUS # 400

Server error check (internal errors must never be 5xx on valid paths):

Terminal window
# Valid path, valid auth — must be 200
curl -s -o /dev/null -w "%{http_code}" \
"$BASE/product/$PRODUCT/file/config.yaml" \
"${A[@]}" "${C[@]}"
# 200
# Missing file — must be 404, never 500
curl -s -o /dev/null -w "%{http_code}" \
"$BASE/api/v2/product/$PRODUCT/file/this-does-not-exist.yaml" \
"${A[@]}" "${C[@]}"
# 404 (not 500)

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.

Bruno (OSS, no cloud account required) natively imports OpenAPI specs.

# macOS
brew install --cask bruno
# or download from https://www.usebruno.com
  1. Open Bruno → Import Collection
  2. Choose OpenAPI v3 → select docs/openapi.yaml
  3. Bruno creates one request per operation, grouped by tag (liveness, discovery, product-files, etc.)
  4. Set up environment variables:
VariableExample valueDescription
baseUrlhttp://localhost:8080Server base URL
apikeykisai_ABC123Value of X-Api-Key header
customeracmeX-Customer header
productdemoX-Product + URL {name} segment
envdevX-Env header
tenantacmeX-Tenant header
testFileconfig/app.yamlFile path used in file-read requests
serviceiamService name for service-config ops
tagVersionv1.0.0Git tag for version-pinned reads
  1. Select the environment, then run requests individually or use Run Collection.

Tip: Bruno can also run collections headlessly via the @usebruno/cli npm package:

Terminal window
npm install -g @usebruno/cli
bru run --env local-apikey
  1. Open Postman → Import → drag in docs/openapi.yaml
  2. Postman creates a collection with folders per tag
  3. Create an Environment with the same variables listed in §6.1
  4. Run the collection via Collection Runner or Newman:
Terminal window
npm install -g newman
newman 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=iam

Newman can also import an OpenAPI spec directly without converting to a Postman collection first.

Insomnia:

  1. File → Import → choose docs/openapi.yaml → Import as Request Collection
  2. Set base environment variables (same set as §6.1)
  3. Run via the Insomnia UI or inso run collection

Hoppscotch (browser-based, no install):

  1. Go to hoppscotch.ioImport/ExportImport from OpenAPI
  2. Upload docs/openapi.yaml
  3. Configure environment variables in the Environments panel

httpYac can drive an OpenAPI spec directly from the terminal or VS Code.

Terminal window
npm install -g httpyac
# List all operations
httpyac send docs/openapi.yaml --list
# Run a specific operation
httpyac 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=acme

The smoke tag covers /health, /ready, and the basic file-read operations — a quick sanity-check you can run in CI after deploying:

Terminal window
# 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 \
--bail

Run through this before every production deployment.

Terminal window
# 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"
Terminal window
# 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)"
Terminal window
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"
done
Terminal window
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"
Terminal window
# 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)"
Terminal window
# Verify TLS is enforced and cert is valid
curl -sv https://meta.staging.kis.ai/health 2>&1 | grep -E "SSL|TLS|certificate|issuer"
# Expired or self-signed cert should fail
curl -s --fail https://meta.staging.kis.ai/health \
&& echo "PASS: TLS valid" || echo "FAIL: TLS error"
# Paste into your Deployment spec
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 3
periodSeconds: 5
failureThreshold: 2

Verify both return 200:

Terminal window
curl -sf $BASE/health && echo "healthy" || echo "unhealthy"
curl -sf $BASE/ready && echo "ready" || echo "not ready"