Cross-App Services

Apps can expose services that other apps consume. The router (compute_space) mediates all cross-app communication; apps never talk directly to each other.

A service is identified by a URL (typically a git URL pointing at a spec) plus a SemVer version. Multiple apps can implement the same service; the router resolves which provider to use per call.

Quick start

Declare what you want in your manifest:

[[services.v2.consumes]]
service = "github.com/imbue-openhost/openhost/services/secrets"
shortname = "secrets"
version = ">=0.1.0"
grants = [{key = "MY_API_KEY"}]

The owner approves those grants when they install your app. Then call the service through the router, which resolves the provider and forwards your grants with the request:

curl -X POST "$BOTTLE_ROUTER_URL/api/services/v2/call/secrets/get" \
  -H "Authorization: Bearer $BOTTLE_APP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"keys": ["MY_API_KEY"]}'

That is the whole consumer side. The rest of this page is the detail: how providers are selected, how permissions work, and how to write a provider.

Service identity + spec

Services are identified by URL, e.g. github.com/imbue-openhost/openhost/services/secrets. The URL is a git path (and optional subdirectory).

Currently the URL is only used comparatively, to match providers and consumers. But probably in the future we'll fetch the git URL to lookup details about the service, eg from a manifest file. You should put documentation on the service specification at the service URL - ideally a formal openAPI spec, but informal documentation is allowable also. This should document the API endpoints, and also the structure of the permission grants consumer apps must acquire to use the service (these are up to the service to define).

Versions follow SemVer. Providers declare a specific version; consumers declare a SemVer specifier (e.g. >=0.1.0). Major version indicate breaking changes; minor versions indicate backward-compatible changes. The git repo should have tags for each version (eg v1.1.1, or sub/dir:v1.1.1 if at a subdir).

The services that ship with Cloud in a Bottle are specified in the repo's services/ folder, each at its own service URL:

ServiceURLSpec
secretsgithub.com/imbue-openhost/openhost/services/secretsopenapi.yaml
oauthgithub.com/imbue-openhost/openhost/services/oauthREADME, openapi.yaml

Provider apps

Provider apps declare what services they offer in their manifest:

[[services.v2.provides]]
service = "github.com/imbue-openhost/openhost/services/secrets"
version = "0.1.0"
endpoint = "/api/"

Service requests land rooted at endpoint in the provider app, ie app-name.your-domain.com/<endpoint>/<whatever_api_route>.

Consumer apps

Consumer apps declare what they consume, with a shortname they'll use to call it and the permission grants they're requesting:

[[services.v2.consumes]]
service = "github.com/imbue-openhost/openhost/services/oauth"
shortname = "oauth"
version = ">=0.1.0"
grants = [
    {provider = "google", scopes = ["https://www.googleapis.com/auth/gmail.readonly"]},
    {provider = "github", scopes = ["repo"]},
]

Each entry in grants is either an opaque string (e.g. "read") or a TOML/JSON object (e.g. {key = "DB_URL"}). Strings work well for simple flag-style permissions; objects are for grants with structured fields. The shape is defined by the service, not the router: providers receive the raw grants verbatim and decide what they mean.

shortname must match ^[a-z][a-z0-9_-]{0,31}$ and be unique within the manifest.

Calling a service

GET|POST|WS|... [BOTTLE_ROUTER_URL]/api/services/v2/call/<shortname>/<rest>

along with Authorization: Bearer $BOTTLE_APP_TOKEN header for server-side requests. BOTTLE_ROUTER_URL is provided to apps as an env var.

This endpoint is app-specific - the router loads the consumer's manifest, finds the [[services.v2.consumes]] entry matching <shortname>, resolves the correct provider of the requested service, and proxies to provider_app.BOTTLE_ROUTER_URL/<provider_endpoint>/<rest>.

The router identifies and authenticates the calling app two ways:

  • Server-side calls: must include Authorization: Bearer $BOTTLE_APP_TOKEN. Each app gets a unique BOTTLE_APP_TOKEN injected as an env var at deploy time.
  • Browser calls: the request's Origin is matched against the app's subdomain, with the owner session cookie authenticating the user. No bearer token is needed for these; the browser provides the cookie automatically.

Service calls should be API-only - the user's browser should never be redirected to a service endpoint, with the exception of permission grant pages.

If permission is needed to access the service, a 403 is returned - see the Permissions section below.

Provider selection

Each service URL has one default provider, resolved in this order:

  1. the provider the owner picked in Cloud in a Bottle's settings;
  2. the router's builtin, if the router implements the service itself (see below);
  3. the app that has provided the service the longest.

Only the owner's choice is stored; steps 2 and 3 are derived on each call, so a service starts working the moment something provides it and keeps working when the app that was serving it is uninstalled. Installing a provider never makes it the default on its own: a second provider of a service holds its own data, so it sits alongside the incumbent (reachable via X-OpenHost-Provider) until the owner switches over.

If nothing provides the service at all, or the resolved default's version doesn't satisfy the consumer's version specifier, the router returns 503 service_not_available.

Builtin providers

The router can provide a service itself instead of proxying to an app. A builtin looks like any other provider to a consumer (same call path, same headers, same permission model), and appears in the provider listings under the app ID _openhost_router. It holds a service until the owner points that service at an app, and takes it back over when the owner clears that choice.

Calling a specific provider

To call a non-default provider, include the X-OpenHost-Provider header with the target app's app_id:

GET [BOTTLE_ROUTER_URL]/api/services/v2/call/<shortname>/<rest>
Authorization: Bearer $BOTTLE_APP_TOKEN
X-OpenHost-Provider: <provider_app_id>

If the specified provider doesn't exist for that service, or its version doesn't match the consumer's version specifier, the router returns 503 service_not_available. Omitting the header uses the default provider as before.

Discovering providers

Apps can list all providers for a service using the discovery endpoint (see Management API below). This is useful when aggregating data across multiple providers of the same service.

Permissions

Permissions are opaque grant payloads (strings or JSON objects), scoped per (consumer_app, service_url). The router stores grants and forwards the granted set (those that apply to the calling app and service URL) to the provider on every call, but the provider is what enforces access, not the router. This lets services define whatever permission shape they need.

Grant scope is one of:

  • global: applies to all providers of the given service. This is the scope for manifest-declared permission grants.
  • app: applies only to a specific provider app. These are often data-dependent permissions, eg "access email for me@example.com", where that data only lives in a specific provider app, so a global scoped permission wouldn't make sense.

On every proxied call, the router injects:

  • X-OpenHost-Consumer-Name: <consumer_app_name>
  • X-OpenHost-Consumer-Id: <consumer_app_id>
  • X-OpenHost-Permissions: <json array of granted payloads>

Each entry in the permissions array is {"grant": <payload>, "scope": "global"|"app"}. The router pre-filters the array: every scope: "app" entry the provider sees is one the router resolved as addressed to this provider. The internal provider_app_id field is stripped before forwarding, since the provider is the addressee by construction. Strict providers may still want to reject scope: "global" entries entirely if every legitimate grant for them flows through their own consent UI (the oauth service does this).

Global-scoped grants

When a consumer calls without sufficient grants, the provider returns:

HTTP 403
{
  "error": "permission_required",
  "required_grant": {
    "grant": { ... },
    "scope": "global"
  }
}

For scope: "global", the router rewrites the response to add a grant_url pointing at the owner-facing approval page. For scope: "app", the provider must include its own grant_url (see below).

The consumer redirects the owner to grant_url; after approval, the call can be retried.

Granting at deploy time. Global-scoped permissions specified in the consumer app manifest (grants) can be granted as part of the app install, either in the Cloud in a Bottle web UI or via the compute_space CLI's --grant-permissions-v2 flag.

Provider-app-scoped permissions

Because app-scoped grants are often data-dependent ("this consumer may access photos in this folder", "this email inbox", "this set of files"), the provider is responsible for the whole approval UX. The router only stores the resulting grant. This also allows a provider to ensure that its data can't be accessed by a permission granted to a different provider of this service, if that's desired. It gives this provider full control over access to its own data.

There's currently no way to grant these at install time of a consumer app (since consumers can potentially interact with multiple providers).

When a consumer calls without sufficient grants, the provider returns:

HTTP 403
{
  "error": "permission_required",
  "required_grant": {
    "grant": { ... },
    "scope": "app",
    "grant_url": GRANT_URL
  }
}

Note for scope: "app", the provider must include its own grant_url.

  1. Consumer hands the user off. The consumer redirects the user's browser to the grant_url returned in the 403. The consumer should arrange for a return_to URL on its own subdomain to be propagated to that page (typical convention: include return_to=https://<consumer>.<zone>/... on the request to grant_url)

  2. Provider renders a consent page. This is a normal page in the provider app: the user is on <provider_app>.<zone> with the owner cookie. The page should:

    • State plainly which consumer app is asking and exactly what data it's asking for. The narrower and more concrete, the better ("Grant photos-app read access to the folder Vacation/Italy?" beats "Grant photos-app read access?").
    • Let the user shape the grant where it makes sense (pick which folder, which inbox, which subset of items, etc.).
    • Run any side flows the grant needs, e.g. an OAuth dance with a third party, picking a row from the provider's own DB, prompting for a passphrase.
  3. Provider creates the grant. Once the user confirms (and any side flow has completed), the provider's backend calls:

    POST [BOTTLE_ROUTER_URL]/api/permissions/v2/grant_app_scoped
    Authorization: Bearer $BOTTLE_APP_TOKEN
    Content-Type: application/json
    
    {"consumer_app_name": "<consumer_app_name>", "service_url": "<url>", "grant": <grant>}
    

    Identify the consumer by name: the value from the X-OpenHost-Consumer-Name header, which is also what your consent page should have shown the user. Keying the grant to the name is what makes that page trustworthy: the field the user read is the field the access is granted to, so a page that names the wrong app grants to the wrong app instead of to itself. An unknown name is a 404.

    The router takes the provider's own app ID from the bearer token, so the provider can only grant permissions for itself; it can't create grants attributed to another provider. The grant body is the same shape the provider will later see in X-OpenHost-Permissions. str or json can be used.

  4. Provider sends the user back. After the grant call succeeds, the provider should redirect the user's browser to the consumer-supplied return_to. The consumer can then retry the original service call, which will now succeed. If the user declines, the provider should also redirect to return_to (without creating a grant) so the consumer can show its own "permission denied" UI rather than leaving the user stranded on the provider's page.

Management API

These endpoints back the owner-facing UI and are authenticated by the owner login cookie unless otherwise noted. Bodies and responses are JSON.

Permissions

  • GET /api/permissions/v2[?app_id=<consumer_app_id>]: list grants, optionally filtered to one consumer app. Returns an array of {consumer_app_id, service_url, grant, scope, provider_app_id}.
  • POST /api/permissions/v2/grant_global_scoped: grant a global-scoped permission. Body: {app_id, service_url, grant}.
  • POST /api/permissions/v2/grant_app_scoped: grant an app-scoped permission. Authenticated with the calling provider's app token (not the owner cookie); the provider's app ID is taken from the token. Body: {consumer_app_name, service_url, grant} (404 if no app has that name). Used by provider apps after running their own user-facing approval flow (e.g. an OAuth dance).
  • POST /api/permissions/v2/revoke: revoke a permission. Body: {app_id, service_url, grant, scope?, provider_app_id?}. scope defaults to "global". 404 if no matching row.

The grant field on these endpoints is whatever shape the service defines, and is passed through the router verbatim.

Default provider

Each service URL has at most one default provider. Calls without an explicit provider use it; see Provider selection above for how it's resolved when the owner hasn't picked one.

Both listing endpoints return an array of {service_url, app_id, app_name, service_version, endpoint, status, is_default}, with the router's builtins included as providers (app_id: "_openhost_router", always running).

  • GET /api/services/v2: list every provider of every service.
  • GET /api/services/v2/providers?service=<url>: the same, narrowed to one service. Accepts both owner auth and app bearer tokens, so consumer apps can discover providers at runtime.
  • POST /api/services/v2/defaults: set the default. Body: {service_url, app_id}. Pass _openhost_router to hand the service to the router's builtin. 404 if that provider doesn't actually provide the service.
  • DELETE /api/services/v2/defaults: clear the owner's choice. Body: {service_url}. Selection then falls back to the builtin, or to the longest-serving provider app if there isn't one; calls return 503 if nothing is left to serve them.

Retrofitting existing apps

Many existing apps provide or consume APIs in a way that is not native to Cloud in a Bottle, and can be adapted readily to consume or expose these through the service interface.

Consumer apps

Consumer apps need to include the Authorization header on server-side requests. This can be added by a little reverse proxy running in in the application container, like mitmproxy.

# <shortname> matches the [[services.v2.consumes]] entry in your manifest.
mitmdump -p 9000 \
  --mode reverse:$BOTTLE_ROUTER_URL/api/services/v2/call/<shortname> \
  --set "modify_headers=/~q/Authorization/Bearer $BOTTLE_APP_TOKEN"

Then point the app at http://localhost:9000 and a request to http://localhost:9000/target_api_endpoint will reach $BOTTLE_ROUTER_URL/api/services/v2/call/<shortname>/target_api_endpoint with the bearer token attached.

Provider apps

Provider apps should verify permissions attached to inbound requests. A simple permission structure might just requires a string grant FULL_ACCESS, and can be implemented with a Caddyfile rule that verifies that the request header (something like X-OpenHost-Permissions=[{"grant": "FULL_ACCESS", "scope": "global", ...}]) contains this permission before passing on to the app's existing API:

:8080 {
  @denied not header_regexp X-OpenHost-Permissions "\"grant\"\\s*:\\s*\"FULL_ACCESS\""
  handle @denied {
    header Content-Type application/json
    respond `{"error":"permission_required","required_grant":{"grant":"FULL_ACCESS","scope":"global"}}` 403
  }
  reverse_proxy localhost:3000
}```


This page lives in the Cloud in a Bottle repo. Suggest an edit. Prefer the raw markdown? Add .md to the URL, or grab the whole manual at all.md.