App Contract¶
company-app.yaml is the operational contract for a deployable internal app. ihc-app provision (gitops-flow.md) reads it on every push to main and refuses to deploy when it breaks a rule below; templates/app starts every new app with a valid one. Schema: schemas/company-app.schema.json.
Minimal Deployable App¶
schema_version: 1
app:
slug: example-app # = repository name = <slug>.<BASE_DOMAIN>
name: Example App
owner_email: owner@example.com # the person who answers for the app
description: Example internal app.
runtime:
type: docker-compose
compose_file: docker-compose.yml
service: app # the service the proxy routes to
port: 8080 # the port it listens on inside its container
healthcheck:
type: http
path: /health # stays public; everything else is behind the SSO
expected_status: 200
deploy:
enabled: true
domain: example-app.cloud.localhost # optional; always <slug>.<BASE_DOMAIN>
port: auto # no host port: the proxy reaches the app on the ihc network
env_example: .env.example
auth:
required: true # forward auth through Authentik; false makes it public
provider: forward-auth
database:
required: true # own database and role on the shared Postgres
engine: postgres
dependencies:
redis:
required: true # ACL user limited to the <slug>: key prefix
object_storage:
required: false # bucket and key on the shared Garage (storage profile)
mcp: # optional: tools for AI agents through the MCP gateway
audience: example-app.mcp
tools_path: /mcp/tools
execute_path: /mcp/tools/{tool}/execute
scopes:
example-app.read: # scope -> the groups granted it
- ihc-users
checks:
build_command: docker compose build
test_command: npm test
What the app receives¶
ihc-app provision writes <install-dir>/apps/<slug>/.env (mode 0600, never in Git) and the deploy hands it to every service of the app:
| Variable | When | Value |
|---|---|---|
PORT |
always | runtime.port |
APP_SLUG, APP_BASE_URL |
always | the slug, <scheme>://<slug>.<BASE_DOMAIN> |
DATA_DIR |
always | /data: the host directory <install-dir>/app-data/<slug>, mounted in the main service, backed up with the install directory and visible to admins in the ops file browser. Written as the container's root user (rootless Docker: the host user); an image that runs as another user must make /data writable for it |
DATABASE_URL, PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD |
database.required |
database and role app_<slug> on postgres:5432 |
REDIS_URL, REDIS_PREFIX |
dependencies.redis.required |
ACL user app-<slug> on redis:6379, prefix <slug>: (every key and channel must start with it) |
S3_ENDPOINT, S3_PUBLIC_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGION, S3_FORCE_PATH_STYLE |
object_storage.required (the storage profile must be on, or the deploy stops before changing anything) |
bucket app-<slug> and access key app-<slug> on Garage: S3_ENDPOINT is http://garage:3900 (inside the ihc network), S3_PUBLIC_ENDPOINT is <scheme>://s3.<BASE_DOMAIN> (for presigned URLs a browser opens), S3_REGION is garage, S3_FORCE_PATH_STYLE is true (path-style requests: set forcePathStyle in the AWS SDK). The key reads and writes this bucket only: it cannot list, create or delete buckets, and gets 403 on another app's |
IHC_MCP_ISSUER, IHC_MCP_JWKS_URL, IHC_MCP_AUDIENCE |
always | the MCP gateway's public URL (the actor JWT's iss), http://mcp:8090/.well-known/jwks.json, and mcp.audience (default <slug>.mcp): what the app checks an actor JWT against (MCP) |
keys of .env.example |
when absent | the example's value; LOCAL_* keys are for a laptop and never reach the box |
Passwords and keys are created once and kept: redeploys never change them. To set a value the example cannot hold (an API key of a third party), edit the env file on the box; provision keeps it.
Behind the SSO the signed-in person arrives in the request headers X-authentik-username, X-authentik-email, X-authentik-name, X-authentik-groups and X-authentik-uid. Access is the users group (AUTHENTIK_APP_GROUP, ihc-users by default); narrower access is a policy binding in Authentik, by hand for now.
Migrations: the app runs its own at start (database.migrations_command is documentation for now; the executor does not run it).
MCP¶
Layer 1 of the cloud's agent interface: an app that declares mcp in company-app.yaml gets its tools into the cloud's MCP gateway (mcp-gateway.md), where a person signed in with the company account uses them from Claude Code or claude.ai. ihc-app new starts every app with the section and a working ping tool (templates/app/server.js). Delete the section and the app stays out of the gateway; nothing else changes.
| Field | Default | Rule |
|---|---|---|
mcp.audience |
<slug>.mcp |
starts with <slug>.: the actor JWT's aud for this app |
mcp.tools_path |
/mcp/tools |
a path on the app's container |
mcp.execute_path |
/mcp/tools/{tool}/execute |
a path on the app's container, with {tool} |
mcp.scopes |
(required) | a map scope: [groups]; every scope starts with <slug>.; at least one, each granted to at least one group (Authentik groups: ihc-users, ihc-admins, or any group you create) |
mcp.enabled |
true |
false keeps the section and leaves the app out |
Write the lists one item per line (- ihc-users), not [ihc-users]: on a box without PyYAML the executor's own reader takes only block lists.
What the gateway does. It reaches the app on the cloud network, http://<slug>:<runtime.port>, never through the proxy. For each person it computes the scopes of mcp.scopes granted to that person's groups; with none, the app is never called for them and its tools do not show. Otherwise:
GET <tools_path>→ the app answers{"tools": [{"name": "ping", "description": "…", "input_schema": {"type": "object", …}, "access": "read"}]}. Names: letters, digits,_and-, up to 64; the gateway shows them as<slug>__<name>. The app may list only the tools the person's scopes allow, or answer403when there is none.POST <execute_path>with{"arguments": {…}}→{"result": <any JSON>}. Errors:403(no access),404(no such tool),400(bad arguments), each with{"error": {"code": "…", "message": "…"}}; the message reaches the person. A5xxor a timeout (10 s to list, 30 s to call) counts as the app being down.
Every request carries Authorization: Bearer <actor JWT> and X-Request-ID.
What the app must check, on every request to those two paths, before anything else (the template does all of it with node:crypto, no library):
- A bearer token is present and is a JWT with
algRS256and akid. - Its signature verifies with the key of that
kidin the gateway's JWKS,IHC_MCP_JWKS_URL. Cache the JWKS (5 minutes); fetch it again when a newkidarrives. issisIHC_MCP_ISSUER,audis (or contains)IHC_MCP_AUDIENCE,expis in the future (30 s of leeway).scope(space separated) holds the scope the tool needs; otherwise403.- The person is
email(also insub), withgroups: apply the app's own rules (what this person may see) to the data, as the app does for a browser user withX-authentik-email.
Answer 401 when any of 1 to 3 fails. Keep the tools read only in this layer.
Example, a tool that lists the person's own records, next to ping in TOOLS of server.js:
list_my_expenses: {
scope: `${slug}.read`,
description: "Lists the caller's expenses of the last 30 days.",
input_schema: { type: "object", properties: { category: { type: "string" } }, additionalProperties: false },
run: async (actor, args) => ({
expenses: (await db.query(
"SELECT day, description, amount FROM expenses WHERE owner = $1 AND ($2::text IS NULL OR category = $2) ORDER BY day DESC LIMIT 100",
[actor.email, args.category ?? null])).rows,
}),
},
A second scope for a narrower group, granted in the manifest:
mcp:
scopes:
expenses.read:
- ihc-users
expenses.report:
- finance
and scope: "expenses.report" on the tool that only finance may call.
Required Rules¶
Checked by ihc-app provision, each failure with the fix in the message:
schema_versionis1.app.slughas 3 to 32 lowercase letters, digits and hyphens, is not a name the platform uses, and equals the repository name.app.nameandapp.owner_emailare present.runtime.typeisdocker-compose,deploy.enabledistrue, the compose file exists and hasruntime.service.runtime.healthcheckishttpwith a path; the app answers it withexpected_statuswithin four minutes of starting.deploy.portisauto. The app publishes no host port; the overlay drops any it declares.deploy.domain, when present, is<slug>.<BASE_DOMAIN>.mcp, when present, follows MCP: audience and scopes start with the slug, the paths are paths with{tool}inexecute_path, every scope is granted to a group.- No local infrastructure: no compose service runs Postgres, Redis or an S3 server (by image or by name), and the manifest declares no local one. Use
database,dependencies.redisandobject_storageinstead. - No host networking, no privileged mode, no mounted Docker socket.
By convention, not checked: .env.example exists and holds no real secret.
Not enforced by the executor yet: infrastructure.exception (refused: an app that needs its own database is deployed by hand until the portal reviews exceptions), auth.allowed_domains, checks.*.
Non-Deployable Repository¶
Documentation or contract-only repositories can declare:
runtime:
type: documentation
deploy:
enabled: false
reason: No deployable app.
and carry no .gitea/workflows/deploy.yml.