Meta 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 spec (docs/openapi.yaml) importable into Bruno, Postman, Insomnia, or any OpenAPI-compatible client.
Meta serves a product’s files by path, as they are. Nothing below depends on what a file means: the paths are examples, and any file of any folder reads the same way. What a folder means is the business of the block that reads it.
Table of Contents
Section titled “Table of Contents”- Prerequisites
- Request Headers Reference
- Server Run Modes
- Sample Product Tree
- Testing with curl
- Testing with the OpenAPI Spec
- DevSecOps Checklist
1. Prerequisites
Section titled “1. Prerequisites”Install the binary
Section titled “Install the binary”kvm install baas/meta.svcThen serve a product directory:
meta.svc 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.svc versionmeta.svc serve --help2. Request Headers Reference
Section titled “2. Request Headers Reference”| Header | Required | Description |
|---|---|---|
X-Api-Key | When the server has an API key (meta serve -k <key>, or api.key in the config) | The key travels in this header only, never in the query string (a query string ends up in access logs and proxies). |
Authorization | Alternative to X-Api-Key | Bearer <token>: a tenant token issued by IAM. It carries the customer. |
X-Customer | Under an API key | The customer whose products resolve. meta serve defaults it when it serves a single customer. |
If-None-Match | For conditional requests | An ETag from a previous response: 304 when the content has not changed. |
Content-Type | For JSON bodies | application/json. |
The product is always in the path (/product/{id}/…).
Shorthand used throughout this guide:
BASE="http://localhost:8080"CUSTOMER="acme"PRODUCT="kisai-demo"APIKEY="kisai_A1B2C3D4"
A=(-H "X-Api-Key: $APIKEY")C=(-H "X-Customer: $CUSTOMER")3. Server Run Modes
Section titled “3. Server Run Modes”3.1 Plain Folder: No Auth
Section titled “3.1 Plain Folder: No Auth”When to use: Local development, quickest setup, no secrets.
meta.svc serve acme:kisai-demo:./my-product- Port defaults to
8080. - No
X-Api-Keyis checked. - A single customer is served, so
X-Customerdefaults toacme. - The server looks for edits on disk every
--watch(default2s) and sends a change event when the product changed.
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.svc serve acme:kisai-demo:./my-product -k kisai_A1B2C3D4- A request without
X-Api-Key: kisai_A1B2C3D4gets401./healthand/readystay open: a probe carries no key. - Generate a real key for shared environments:
openssl rand -hex 20.
Custom port:
meta.svc serve acme:kisai-demo:./my-product -k kisai_A1B2C3D4 -p 9090BASE="http://localhost:9090"3.3 Local Git Repo
Section titled “3.3 Local Git Repo”When to use: Testing branch / tag access and the git operations.
A git repository at the path is detected automatically:
git -C my-product initgit -C my-product add .git -C my-product commit -m "init"git -C my-product tag v1.0.0
meta.svc serve acme:kisai-demo:./my-product -k kisai_A1B2C3D4Without ?version, reads serve the working tree as it is. A branch or tag is read out of its
commit; nothing is checked out:
curl "$BASE/product/$PRODUCT/file/config.yaml?version=v1.0.0&versionType=tag" "${A[@]}"3.4 Multiple Products, One Customer
Section titled “3.4 Multiple Products, One Customer”meta.svc serve \ acme:frontend:./frontend-product \ acme:backend:./backend-product \ -k kisai_A1B2C3D4The path names the product:
curl "$BASE/product/frontend/files?path=/" "${A[@]}"curl "$BASE/product/backend/files?path=/" "${A[@]}"3.5 Multiple Customers
Section titled “3.5 Multiple Customers”When to use: Testing customer isolation.
meta.svc serve \ acme:app:./acme-product \ contoso:app:./contoso-product \ -k kisai_A1B2C3D4With two customers there is no default: X-Customer selects whose app resolves.
curl "$BASE/product/app/file/config.yaml" "${A[@]}" -H "X-Customer: acme"curl "$BASE/product/app/file/config.yaml" "${A[@]}" -H "X-Customer: contoso"3.6 Embedded Products
Section titled “3.6 Embedded Products”When to use: Shipping a binary that carries its products.
Without a key (an unencrypted payload):
# Embed one or more product folders (folder[:entitlement]) into a copy of the binary.# A folder's .git is never embedded.meta.svc embed ./my-product ./docs-site:em1 --output embedded-meta./embedded-meta serveWith a key (an encrypted payload):
meta.svc embed ./my-product --key kisai_LICENSE_KEY_HERE --output embedded-meta./embedded-meta serve --license kisai_LICENSE_KEY_HEREWith no arguments and no embedded product:
meta.svc serve# Error: no products specified and no embedded product foundCheck what is embedded:
./embedded-meta embed-info # every product's manifest./embedded-meta embed-info --license kisai_LICENSE_KEY_HERE # an encrypted payload's, once decrypted
curl "$BASE/ops/embedded-info" "${A[@]}"# {"embedded":true,"manifest":{"name":"kisai-demo","files":42,"size":128000}}# or, for several products# {"embedded":true,"count":2,"manifests":[…]}# or# {"embedded":false,"message":"no product embedded"}3.7 Config File (the service)
Section titled “3.7 Config File (the service)”When to use: Running meta.svc as the platform service: customers’ products from git, a tenant token or an API key, the config service.
port: "8080"
api: # optional: requests then need X-Api-Key key: ${env:META_API_KEY}
repowatchduration: 1m # how often each repo is fetchedcachetype: fs # memory | fs (mirrors on disk: survive restarts)cachedir: /var/cache/meta
products: # customer → the products it has acme: - name: kisai-demo url: https://x-access-token:${env:GIT_TOKEN}@git.example.com/acme/kisai-demo.git type: remote ref: main readonly: truemeta.svc -f .meta.yaml # default modemeta.svc -m product -f .meta.yaml # product mode: forge-authored productsmeta.svc -m marketplace -f .meta.yaml # marketplace modeA remote repository is mirrored and fetched, and served from its commits. Its credential is taken out of the URL: it authenticates every fetch and is never written to disk.
With a tenant token instead of an API key:
curl "$BASE/product/kisai-demo/file/config.yaml" -H "Authorization: Bearer $TOKEN"4. Sample Product Tree
Section titled “4. Sample Product Tree”The examples below assume this tree at ./my-product:
my-product/├── config.yaml├── data/│ ├── customer.yaml│ └── sub/│ └── deep.yaml└── content/ └── logo.svgmkdir -p my-product/data/sub my-product/contentecho 'name: kisai-demo' > my-product/config.yamlecho 'entity: customer' > my-product/data/customer.yamlecho 'deep: 1' > my-product/data/sub/deep.yamlecho '<svg/>' > my-product/content/logo.svg
meta.svc serve acme:kisai-demo:./my-product -k kisai_A1B2C3D4 --readonly=false5. Testing with curl
Section titled “5. Testing with curl”5.1 Health and Discovery
Section titled “5.1 Health and Discovery”curl -s "$BASE/health"curl -s "$BASE/ready" # 200 once the repositories have loaded
curl -s "$BASE/ops/repositories" "${A[@]}" "${C[@]}" | jq .# {"repositories":{"products":{"acme":[{"name":"kisai-demo","type":"fs"}]},"marketplace":[]}}An operator sees every customer’s products; anyone else sees their own.
5.2 Reads
Section titled “5.2 Reads”One file, as it is:
curl -s "$BASE/product/$PRODUCT/file/config.yaml" "${A[@]}" "${C[@]}"# name: kisai-demoA listing (path is required; depth bounds it; listtype=files|folders narrows it):
curl -s "$BASE/product/$PRODUCT/files?path=/&depth=2" "${A[@]}" "${C[@]}" | jq .# {"files":[…],"folders":[…]}At a version (git repositories):
curl -s "$BASE/product/$PRODUCT/file/config.yaml?version=main&versionType=branch" "${A[@]}" "${C[@]}"curl -s "$BASE/product/$PRODUCT/file/config.yaml?version=v1.0.0&versionType=tag" "${A[@]}" "${C[@]}"5.3 Batch and Manifest
Section titled “5.3 Batch and Manifest”Several files in one request. A missing file is an entry with error, not a failed batch:
curl -s -X POST "$BASE/product/$PRODUCT/batch" "${A[@]}" "${C[@]}" \ -H "Content-Type: application/json" \ -d '{"paths":["config.yaml","data/customer.yaml","nope.yaml"]}' | jq .# {"files":[{"path":"config.yaml","content":"<base64>","hash":"…","size":17},# …,# {"path":"nope.yaml","content":"","hash":"","size":0,"error":"not found"}]}An entry’s error says what failed (not found, invalid path, unreadable), never where on
the server. content is base64:
curl -s -X POST "$BASE/product/$PRODUCT/batch" "${A[@]}" "${C[@]}" \ -H "Content-Type: application/json" -d '{"paths":["config.yaml"]}' \ | jq -r '.files[0].content' | base64 -dA manifest: every file and folder under a path, hashed. Compare root_hash to tell whether
anything under the path changed:
curl -s "$BASE/product/$PRODUCT/manifest?path=data" "${A[@]}" "${C[@]}" | jq .# {"root_hash":"…","entries":[{"path":"data/customer.yaml","hash":"…","size":17,"mod_time":…,"is_dir":false},…]}A folder the product does not have is an empty manifest, not an error.
5.4 Stat and Directory Entries
Section titled “5.4 Stat and Directory Entries”fs/stat and fs/readdir describe the product’s files and folders (the meta client’s Stat and
List). A file is read with file/{path…}; whether a path exists is fs/stat’s 200 or 404.
curl -s "$BASE/product/$PRODUCT/fs/stat/config.yaml" "${A[@]}" "${C[@]}" | jq .# {"name":"config.yaml","size":17,"mode":420,"mod_time":1716400000000,"is_dir":false}
curl -s -o /dev/null -w '%{http_code}\n' "$BASE/product/$PRODUCT/fs/stat/does-not-exist.yaml" "${A[@]}" "${C[@]}"# 404
curl -s "$BASE/product/$PRODUCT/fs/readdir/data" "${A[@]}" "${C[@]}" | jq .# [{"name":"customer.yaml","is_dir":false,…},{"name":"sub","is_dir":true,…}]mod_time is Unix milliseconds.
5.5 Conditional Requests (ETag / 304)
Section titled “5.5 Conditional Requests (ETag / 304)”Every file read and manifest carries an ETag:
ETAG=$(curl -si "$BASE/product/$PRODUCT/file/config.yaml" "${A[@]}" "${C[@]}" \ | grep -i '^etag:' | tr -d '\r' | awk '{print $2}')
curl -s -o /dev/null -w "%{http_code}\n" "$BASE/product/$PRODUCT/file/config.yaml" \ "${A[@]}" "${C[@]}" -H "If-None-Match: $ETAG"# 304
curl -s -o /dev/null -w "%{http_code}\n" "$BASE/product/$PRODUCT/file/config.yaml" \ "${A[@]}" "${C[@]}" -H 'If-None-Match: "stale"'# 2005.6 Change Stream
Section titled “5.6 Change Stream”Server-sent events when asked for (Accept: text/event-stream; a browser EventSource asks),
one JSON object per line (application/x-ndjson) otherwise:
curl -sN "$BASE/product/stream?id=$PRODUCT" "${A[@]}" "${C[@]}" -H "Accept: text/event-stream"# event: snapshot# data: {"hash":"…","ref":"","repo":"kisai-demo","ts":"…"}
curl -sN "$BASE/product/stream?id=$PRODUCT" "${A[@]}" "${C[@]}"# {"data":{"hash":"…","ref":"","repo":"kisai-demo","ts":"…"},"event":"snapshot"}In another terminal, edit a file: within --watch a repo-updated event arrives with the new
hash and the previous one as prevHash. A write through the API publishes one at once. A ping
arrives every 30 s. ref is empty for a folder, and the branch or tag a git repository serves
otherwise.
5.7 Writes
Section titled “5.7 Writes”Writes need a local checkout or folder that is not read-only (--readonly=false). A read-only
repository answers 403, and a write that names a version 400: a write goes to the working
tree.
# Create and remove a foldercurl -s -X PUT "$BASE/product/$PRODUCT/files?path=drafts" "${A[@]}" "${C[@]}" # 409 if it existscurl -s -X DELETE "$BASE/product/$PRODUCT/files?path=drafts" "${A[@]}" "${C[@]}" # 404 if absent
# Upload: the path is the folder; each file lands at <folder>/<its filename>curl -s -X POST "$BASE/product/$PRODUCT/file/data" "${A[@]}" "${C[@]}" \ -F "files=@./orders.yaml"# again: 409 (it exists); add ?overwrite=true to replace it
# Upload a zip, expanded in placecurl -s -X POST "$BASE/product/$PRODUCT/zip/imports" "${A[@]}" "${C[@]}" -F "files=@./bundle.zip"The product root cannot be removed (400), and nothing under .git can be written or removed.
5.8 Git Operations
Section titled “5.8 Git Operations”A local git checkout (§3.3), not read-only:
curl -s "$BASE/product/$PRODUCT/git/refs" "${A[@]}" "${C[@]}" | jq . # {"branches":[…],"tags":[…]}curl -s "$BASE/product/$PRODUCT/git/version" "${A[@]}" "${C[@]}" | jq . # the commit served
J=(-H "Content-Type: application/json")curl -s -X POST "$BASE/product/$PRODUCT/git/status" "${A[@]}" "${C[@]}" "${J[@]}" -d '{}'curl -s -X POST "$BASE/product/$PRODUCT/git/diff" "${A[@]}" "${C[@]}" "${J[@]}" -d '{}'curl -s -X POST "$BASE/product/$PRODUCT/git/stage" "${A[@]}" "${C[@]}" "${J[@]}" -d '{"paths":["."]}'curl -s -X POST "$BASE/product/$PRODUCT/git/commit" "${A[@]}" "${C[@]}" "${J[@]}" \
# Write, stage and commit one filecurl -s -X PATCH "$BASE/product/$PRODUCT/git/commitfile" "${A[@]}" "${C[@]}" "${J[@]}" \ -d '{"path":"data/notes.yaml","file":"reviewed: true\n","message":"notes"}'
# Against the checkout's remotecurl -s -X POST "$BASE/product/$PRODUCT/git/fetch" "${A[@]}" "${C[@]}" "${J[@]}" -d '{}'curl -s -X POST "$BASE/product/$PRODUCT/git/pull" "${A[@]}" "${C[@]}" "${J[@]}" -d '{}'curl -s -X POST "$BASE/product/$PRODUCT/git/push" "${A[@]}" "${C[@]}" "${J[@]}" -d '{}'A git operation that needs a working tree (everything but git/refs and git/version) against a
folder or a remote repository is 400. git/version of a folder is 400 (it has no commit), and
git/refs of a folder is empty.
5.9 Marketplace Endpoints
Section titled “5.9 Marketplace Endpoints”A marketplace repository (marketplace mode, §3.7):
MARKET="kisai-components"curl -s "$BASE/market/$MARKET/file/README.md" "${A[@]}" "${C[@]}"curl -s "$BASE/market/$MARKET/git/zip" "${A[@]}" "${C[@]}" -o market.zipcurl -s "$BASE/market/$MARKET/fileorfolderzip/components/button" "${A[@]}" "${C[@]}" -o button.zipcurl -s -X PATCH "$BASE/market/$MARKET/paths" "${A[@]}" "${C[@]}" \ -H "Content-Type: application/json" -d '{"paths":["README.md","components/button/config.yaml"]}' -o some.zip5.10 Negative Tests
Section titled “5.10 Negative Tests”Every caller’s mistake answers its own 4xx; a 5xx is the server’s own failure.
code() { curl -s -o /dev/null -w "%{http_code}" "$@"; echo; }
code "$BASE/product/$PRODUCT/files?path=/" "${C[@]}" # 401: no keycode "$BASE/product/$PRODUCT/files?path=/" -H "X-Api-Key: wrong" "${C[@]}" # 401: wrong keycode "$BASE/product/nope/file/config.yaml" "${A[@]}" "${C[@]}" # 404: no such productcode "$BASE/product/$PRODUCT/file/nope.yaml" "${A[@]}" "${C[@]}" # 404: no such filecode "$BASE/product/$PRODUCT/file/.git/config" "${A[@]}" "${C[@]}" # 404: .git is never servedcode "$BASE/product/$PRODUCT/file/..%2F..%2Fetc%2Fpasswd" "${A[@]}" "${C[@]}" # 400: never outside the productcode -X PUT "$BASE/product/$PRODUCT/files?path=x&version=main" "${A[@]}" "${C[@]}" # 400: a write names a version6. Testing with the OpenAPI Spec
Section titled “6. Testing with the OpenAPI Spec”The specification lives at docs/openapi.yaml (OpenAPI 3.0) in the meta service. It describes
every route above: parameters, bodies and statuses. Import it into any OpenAPI-compatible
client for a pre-populated collection:
- Bruno: Import Collection → OpenAPI v3 → select the file.
- Postman: Import → drag in the file; run with the Collection Runner or
newman. - Insomnia: File → Import → Request Collection. Hoppscotch: Import/Export → Import from OpenAPI.
Set these environment variables:
| Variable | Example | Used as |
|---|---|---|
baseUrl | http://localhost:8080 | server base URL |
apikey | kisai_A1B2C3D4 | X-Api-Key |
customer | acme | X-Customer |
id | kisai-demo | the {id} path segment (the product) |
path | config.yaml | the {path} path segment |
7. DevSecOps Checklist
Section titled “7. DevSecOps Checklist”Run through this before every production deployment.
Authentication
Section titled “Authentication”[ "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/ops/repositories")" = 401 ] \ && echo "PASS" || echo "FAIL: auth not enforced"[ "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/ops/repositories" -H 'X-Api-Key: wrong')" = 401 ] \ && echo "PASS" || echo "FAIL: a wrong key is accepted"[ "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/ops/repositories?apikey=$APIKEY")" = 401 ] \ && echo "PASS" || echo "FAIL: a key in the query string is accepted"[ "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/ops/repositories" "${A[@]}" "${C[@]}")" = 200 ] \ && echo "PASS" || echo "FAIL: the right key is refused"Customer Isolation
Section titled “Customer Isolation”# Serve two customers (§3.5), each with a product named "app" whose config.yaml differsACME=$(curl -s "$BASE/product/app/file/config.yaml" "${A[@]}" -H "X-Customer: acme")CONTOSO=$(curl -s "$BASE/product/app/file/config.yaml" "${A[@]}" -H "X-Customer: contoso")[ "$ACME" != "$CONTOSO" ] && echo "PASS: each customer reads its own" || echo "FAIL: customers share a product".git Is Never Served
Section titled “.git Is Never Served”for P in ".git/config" ".GIT/config" "data/.git/config"; do S=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/product/$PRODUCT/file/$P" "${A[@]}" "${C[@]}") [ "$S" = 404 ] && echo "PASS: $P" || echo "FAIL [$S]: $P"donecurl -s "$BASE/product/$PRODUCT/fs/readdir/" "${A[@]}" "${C[@]}" | grep -qi '"\.git"' \ && echo "FAIL: .git listed" || echo "PASS: .git not listed"No 5xx on Valid Paths
Section titled “No 5xx on Valid Paths”for ENDPOINT in \ "/health" "/ready" "/ops/repositories" "/ops/embedded-info" \ "/product/$PRODUCT/files?path=/" "/product/$PRODUCT/manifest" \ "/product/$PRODUCT/file/config.yaml" "/product/$PRODUCT/file/does-not-exist.yaml"do S=$(curl -s -o /dev/null -w "%{http_code}" "$BASE$ENDPOINT" "${A[@]}" "${C[@]}") [ "$S" -lt 500 ] && echo "PASS [$S]: $ENDPOINT" || echo "FAIL [$S]: $ENDPOINT"doneETag Caching Works
Section titled “ETag Caching Works”ETAG=$(curl -si "$BASE/product/$PRODUCT/file/config.yaml" "${A[@]}" "${C[@]}" \ | grep -i '^etag:' | tr -d '\r' | awk '{print $2}')S=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/product/$PRODUCT/file/config.yaml" \ "${A[@]}" "${C[@]}" -H "If-None-Match: $ETAG")[ "$S" = 304 ] && echo "PASS: ETag caching works" || echo "FAIL: got $S, expected 304"Readonly Enforcement
Section titled “Readonly Enforcement”# Served with the default --readonly=trueS=$(curl -s -o /dev/null -w "%{http_code}" -X PUT "$BASE/product/$PRODUCT/files?path=test-dir" "${A[@]}" "${C[@]}")[ "$S" = 403 ] && echo "PASS: writes refused" || echo "FAIL: got $S, expected 403"TLS (staging / production only)
Section titled “TLS (staging / production only)”curl -sv https://meta.staging.kis.ai/health 2>&1 | grep -E "SSL|TLS|certificate|issuer"curl -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)”livenessProbe: httpGet: path: /health port: 8080 initialDelaySeconds: 5 periodSeconds: 10 failureThreshold: 3
readinessProbe: httpGet: path: /ready port: 8080 initialDelaySeconds: 3 periodSeconds: 5 failureThreshold: 2Liveness never fails for repository problems: a restart cannot fix an expired git token. Readiness fails only when every product repository failed to load.