Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,10 @@ per consuming project: its **bucket key**, minted by the catalog method and
written to `secret/data/<project>/<process>/s3`, and the buckets the key may
use, each `<project>-<name>` for a name an edge's `buckets` gives. An edge
naming buckets hands its consumer `endpoint`, `accessKeyId` and
`secretAccessKey` ([chapter 16](spec/v1/16-dependencies.md#the-bucket-catalog)).
`secretAccessKey` ([chapter 16](spec/v1/16-dependencies.md#the-bucket-catalog)). A bucket the
consumer's pages call from a browser names the exposures whose origins it
admits, and the catalog method sets its one CORS rule
([chapter 16](spec/v1/16-dependencies.md#a-bucket-a-browser-calls)).

**Migration policy**: the Platform document's runner image alias, deadline and
resources, which every managed migration builds on
Expand Down
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ and linted by the same script with `emf` as its root.
| [0149](model/0149-grafanas-instance-and-data-sources-derive-from-the-monitoring-stack.md) | Grafana's instance, its admin credential and its data sources derive from the monitoring stack | open |
| [0150](model/0150-k3s-is-scraped-where-the-bootstrap-set-records-its-metrics.md) | k3s is scraped where the bootstrap set records its metrics, as the metrics stack's own ServiceAccount | open |
| [0119](model/0119-an-edge-names-its-buckets-and-the-stores-method-mints-each-projects-key.md) | An edge into an object store names its buckets, and the store's catalog method mints each project's key | open |
| [0153](model/0153-a-bucket-a-browser-calls-names-the-exposures-it-admits.md) | A bucket a browser calls names the exposures it admits, and the store's catalog method sets its one CORS rule | open |
| [0144](model/0144-the-device-plugin-names-each-card-by-its-index.md) | The device plugin names each card by its index, as the hosts' CDI specification does | open |
| [0147](model/0147-the-speakers-find-each-other-by-memberlist-keyed-by-a-generated-key.md) | The speakers find each other by memberlist, keyed by a key the `node` Project's own Vault policy job generates | open |

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
---
tier: decision
status: accepted
claim: open
owner: joris
date: 2026-11-02
normative: spec/v1/16-dependencies.md#a-bucket-a-browser-calls
rests-on: ["0004", "0005"]
---

# A bucket a browser calls names the exposures it admits, and the store's catalog method sets its one CORS rule

A bucket entry on an edge into an object store may name the exposures of the
consuming Application whose pages call it from a browser. Each derives one
origin, the catalog carries those origins per bucket, and the store's catalog
method sets one fixed CORS rule on each such bucket and removes any rule from
every other bucket of the catalog. The rules are in
[A bucket a browser calls](../../../spec/v1/16-dependencies.md#a-bucket-a-browser-calls).

## Rests on

A bucket's configuration is the object store's, which every consumer of it
contends for, so the platform applies it
([0004](0004-contention-decides-authority.md)), and an origin is a host an
exposure already declares, so it is derived and never written again
([0005](0005-derivation-is-total.md)).

**False if:** a consumer's pages need a bucket to answer an origin that is no
exposure of its own Application, or need a method beyond the fixed four.
**Settled by:** outline's pages posting an upload to its bucket and reading it
back, with the bucket admitting outline's own origin and no other.

## Why

**Most consumers never need it, so it is opt-in per bucket.** Karakeep and
Overleaf reach their buckets from their own Processes alone. A rule derived
from every consumer's exposures would open each of their buckets to their
pages for nothing. The one consumer whose pages call its bucket, outline,
says so on that bucket and no other.

**The origin is named by exposure, never written as a URL.** The host is
already authored once, on the exposure. A URL in the edge would be a second
copy that drifts the day the host changes, and could name a host the
consuming Application does not serve.

**The methods are fixed.** A browser posts or puts an upload and reads it
back, so `GET`, `HEAD`, `POST` and `PUT` cover every case the estate has. An
authored method list is a field nobody would set to anything else. Leaving
`DELETE` out keeps a page from deleting an object without its application.

**The catalog states every bucket's rule, so a removed `cors` leaves nothing
behind.** The method removes a rule from a bucket that admits no origin. A
method that only ever added rules would leave an origin admitted after the
edge stopped naming it, which nobody reading the document could see.

**The admin API sets the rule.** The store's admin API sets a bucket's CORS
rule with the token the method already signs in with, so no key with
ownership of the bucket is minted to do it.

## Alternatives

| option | cost if taken | why rejected |
|---|---|---|
| Derive the rule from every exposure of every consumer | no field | opens every consumer's buckets to its pages, Karakeep's and Overleaf's among them, for no request that needs it |
| An authored origin URL on the bucket | one string | a second copy of a host the exposure already holds, free to name a host the Application does not serve |
| An authored method and header list | full S3 CORS vocabulary | provider vocabulary in Project Intent, for a choice with one answer |
| The method only adds rules | simpler method | a `cors` taken off an edge leaves its origin admitted, invisibly |
| Set the rule from a bootstrap job with an owner key | no model change | a step outside the render, and an owner key on the bucket while it runs |

## Reversibility

Undo cost today: one bucket form, one catalog field and one method step: an
hour. Becomes costly once: consumers' pages rely on posting straight to their
buckets, and taking the rule away means routing every upload through the
application.

## Consequences

- A bucket entry is a name, or `{name, cors}`.
- A key in the bucket catalog carries `cors` where any of its buckets admits
an origin.
- The catalog method sets and removes bucket CORS rules over the admin API,
and reads each back.
- `E_UNKNOWN_CORS_EXPOSURE` refuses a `cors` entry that names no exposure of
the consuming Application.
- Reaching the bucket from a browser also needs a route to the store's `s3`
surface on a host of its own, which is an ordinary exposure of the store,
restricted by path to the bucket.
1 change: 1 addition & 0 deletions docs/requirements/volumes-and-engines.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ A behaviour of state: Volumes and their Durability Classes, backups and off-clus
| REQ-077 | A Process whose `engine` is `mongo` is backed up by the method the Platform document names for `mongo`, which dumps it over the surface that entry names, and its volume's class decides the schedule, the copies kept and the off-cluster copy as for any engine; a `mongo` owns no databases, so an edge into it derives no catalog, no credential and no migration question, `credentials` on that edge is `E_CREDENTIALS_WITHOUT_DATABASE`, and a `mongo` over a volume that derives no backup is `E_ENGINE_WITHOUT_DURABILITY` | [test/model/resolution-derivations.test.ts](../../test/model/resolution-derivations.test.ts) |
| REQ-104 | An `irreplaceable` volume's off-cluster copy is encrypted to the age public key its class's policy names: a policy with `offCluster` and no `recipient`, or one that is no age public key, is refused with the Platform document; the volume's backup plan carries the destination, the recipient and `<project>/<process>/<claim>`, and its backup pod is handed them as `BACKUP_OFF_CLUSTER`, `BACKUP_OFF_CLUSTER_RECIPIENT` and `BACKUP_OFF_CLUSTER_PATH` | [test/model/resolution-derivations.test.ts](../../test/model/resolution-derivations.test.ts) |
| REQ-109 | An edge into an `s3` Process that names buckets derives the store's bucket catalog: each project's key at `secret/data/<project>/<process>/s3`, scoped to `<project>-<name>` for every name its edges give, applied by the engine's catalog method over its `catalogSurface` with the derived admin token; each Process naming buckets holds `read` on its project's key and reads it, and the store's endpoint, through `${dependency:<application>.accessKeyId}`, `.secretAccessKey` and `.endpoint`; `buckets` on an edge that reaches no `s3` Process is `E_BUCKETS_WITHOUT_OBJECT_STORE`, and a `catalogSurface` the Process does not provide is `E_CATALOG_SURFACE_NOT_PROVIDED` | [test/model/render.test.ts](../../test/model/render.test.ts) |
| REQ-171 | A bucket entry `{name, cors}` admits the origin of each exposure of the consuming Application that `cors` names, its tier's scheme and its host; the bucket catalog lists, per key, each bucket that admits an origin with every origin it admits, in bucket and origin order, and lists no `cors` for a key none of whose buckets admits one; a `cors` entry that names no exposure of the consuming Application, or of any Application beneath an edge declared above it, is `E_UNKNOWN_CORS_EXPOSURE` | [test/model/render.test.ts](../../test/model/render.test.ts) |
| REQ-124 | A Process that seeds an Asset (`seedAt`) is resolved with the Platform document's `seeder`, its image pinned by digest with the lock's version and its `memory` and `cpu`, which a node must fit beside the Process; the Asset carries its committed text, a `${...}` naming no placeholder source left as written; a seeded Asset the platform names no seeder for is `E_NO_SEEDER`, a seeder the lock does not hold `E_UNLOCKED_IMAGE`, and a `seedAt` that is not a file below one of the Process's volumes or writable paths, whichever level declares it, or that reaches one through `.` or `..`, is `E_SEED_OUTSIDE_VOLUME`, each at the Asset where it is written | [test/model/resolution-derivations.test.ts](../../test/model/resolution-derivations.test.ts) |
| REQ-125 | The kubernetes adapter renders a seeded Asset as the first init container `seed`, before the VPN, on the resolved seeder: `/bin/sh -c` a script that makes the directory, keeps the file it replaces as `<seedAt>.superseded`, and writes the Asset at `0600` through a staged copy it moves into place, every path single-quoted; under the posture, with the seeder's resources, mounting the Asset's ConfigMap and only the volumes and writable paths the Assets land below; the Process's own container never mounts a seeded Asset | [test/model/render.test.ts](../../test/model/render.test.ts) |
| REQ-158 | A Process whose engine is `mongo` resolves its own Service's name, `<process>.<namespace>.svc.cluster.local`, to itself: it records the name as `selfNames`, and its pod renders a `hostAliases` entry giving that name `127.0.0.1`, so a replica set member configured by the name its consumers reach it by finds itself whatever the Service's type; a Process of any other engine, or of none, records and renders neither | [test/model/resolution-derivations.test.ts](../../test/model/resolution-derivations.test.ts) |
52 changes: 50 additions & 2 deletions spec/v1/16-dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ dependsOn:
| `application` | yes | An Application Id, the only referencable identity ([0010](../../docs/adr/model/0010-flat-application-identity.md)). It must resolve in the composed union: `E_UNRESOLVED_APPLICATION`. |
| `surface` | yes | One surface declared by one of that Application's Processes. The port is written once, by the provider, and never restated by a consumer: `E_UNKNOWN_SURFACE` where the name matches nothing. |
| `required` | no | Defaults to `true`. |
| `buckets` | no | The buckets the consumer uses in an object store, each a name of up to 32 lower-case letters, digits and hyphens that begins and ends with a letter or a digit ([The bucket catalog](#the-bucket-catalog)). On an edge whose surface no `s3` Process provides it derives nothing, and is `E_BUCKETS_WITHOUT_OBJECT_STORE`. |
| `buckets` | no | The buckets the consumer uses in an object store, each a name of up to 32 lower-case letters, digits and hyphens that begins and ends with a letter or a digit ([The bucket catalog](#the-bucket-catalog)), or `{name, cors}` for one the consumer's pages call from a browser ([A bucket a browser calls](#a-bucket-a-browser-calls)). On an edge whose surface no `s3` Process provides it derives nothing, and is `E_BUCKETS_WITHOUT_OBJECT_STORE`. |

**`provides` moved to the Process; the edge did not.** A port is a property of
a process, so surfaces are declared by the Process that listens
Expand Down Expand Up @@ -289,6 +289,51 @@ bucket, all three are `E_UNRESOLVED_PLACEHOLDER`.
A bucket the platform owns rather than a consumer, such as one whose name is a public website's hostname, is not
a catalog entry: no edge names it.

### A bucket a browser calls

Most consumers reach their buckets from their own Processes alone. One whose
pages upload to a bucket, or read from it, straight from the browser needs the
store to answer those pages cross-origin, and says so on the bucket itself
([0153](../../docs/adr/model/0153-a-bucket-a-browser-calls-names-the-exposures-it-admits.md)):

```yaml
dependsOn:
- application: platform-garage
surface: s3
buckets:
- {name: uploads, cors: [public]}
```

`cors` names one or more exposures of the consuming Application, by `name`.
Each admits one origin: the exposure's tier's scheme and its host, as
`${exposure:<application>.<name>#url}` reads it. A bucket written by its name
alone admits none. A bucket two edges of one project name admits every origin
either gives.

**The rule is fixed, and only the origins are authored.** Every bucket that
admits an origin carries one CORS rule: those origins, the methods `GET`,
`HEAD`, `POST` and `PUT`, any request header, `ETag` exposed, and a preflight
cached for 3600 seconds. The methods are what a browser needs to post or put an
upload and to read it back. `DELETE` is never among them: a page deletes
through its own application, never straight from the browser.

**CORS grants no access.** The bucket stays private, and every request still
needs a signature the store checks. The rule only lets the named origin's pages
read the answers the store already gives a signed request.

**The catalog states each bucket's rule exactly.** A key's buckets that admit
an origin are listed in its `cors`, each with every origin it admits, in bucket
and origin order. A bucket of the key absent from that list admits none, and
the method removes any rule the store holds on it, so a `cors` taken off an
edge leaves no origin behind.

| condition | error |
|---|---|
| a `cors` entry names no exposure of the consuming Application; on an edge declared above the Application, of any Application beneath it | `E_UNKNOWN_CORS_EXPOSURE` |

`E_UNKNOWN_CORS_EXPOSURE` is scoped to a single document and refused as soon
as the fragment is read: an Application's exposures are its own.

### Applying the catalog

The catalog is applied in the cluster by its engine's method
Expand Down Expand Up @@ -318,7 +363,7 @@ applying it does not bring up:

| object | holds |
|---|---|
| the catalog | one JSON document: the datastore's `host` and `port`, the Secret Store's database `connection` its roles issue through, named after the datastore's Process, so two datastores of one engine never share one, the `admin` login's `path`, `user` and `password` keys, and the `databases`, each a `name`, an `owner` and a `data` role, in name order; or, for an object store, its `host` and `port` on the surface the catalog is applied over, the `admin` login's `path` and `token` key, and the `keys`, each a `name`, the `path` the key is written to and its `buckets`, in name order |
| the catalog | one JSON document: the datastore's `host` and `port`, the Secret Store's database `connection` its roles issue through, named after the datastore's Process, so two datastores of one engine never share one, the `admin` login's `path`, `user` and `password` keys, and the `databases`, each a `name`, an `owner` and a `data` role, in name order; or, for an object store, its `host` and `port` on the surface the catalog is applied over, the `admin` login's `path` and `token` key, and the `keys`, each a `name`, the `path` the key is written to, its `buckets` and, where any admits an origin, its `cors`, in name order |
| the identity's Vault policy and auth role | `read` on the admin login's document and its metadata, and `create`, `read` and `update` on `database/roles/<role>` for each role the catalog names, or on each key's `path`, written by the Vault policy job like every identity's |
| a `ConfigMap`, a `Job` and an hourly `CronJob` | the method image, as the user the lock records, handed the catalog; the policy job's requests, deadline and eligible nodes, and its retries, spelled as its Job and CronJob are ([chapter 30](30-deliverables.md#vault-configuration-is-rendered-not-applied)); named `<process>-catalog-<12 hex>`, the digest of the catalog with what the pod runs with, so a changed catalog or method is a new Job, and marked for the applier to recreate it where only the render changed its template. Its pods carry the [consumer label](#the-consumer-label) of the datastore's surface, which the datastore's policy admits |

Expand Down Expand Up @@ -352,6 +397,9 @@ catalog names:
key named after the project and writes its id and secret there.
2. It creates each of the key's buckets where it is absent.
3. It grants the key read and write on each of its buckets.
4. It sets each of the key's buckets' CORS rule to the one its `cors` derives,
or removes the rule from a bucket that admits no origin, where the store
holds anything else, and reads the rule back.

It never deletes a bucket, a key or a grant, and a run applied twice changes
nothing the second time.
Expand Down
7 changes: 6 additions & 1 deletion spec/v1/20-resolved-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -1342,7 +1342,7 @@ own Vault role:
| `catalog.connection` | derived | a database catalog's: the engine's name, the Secret Store's database connection |
| `catalog.admin` | platform-assigned | `secret/data/<project>/<process>/catalog`, keys `user` and `password`, or `token` for an object store |
| `catalog.databases` | derived | a database catalog's: for each project an edge of which reaches the datastore, in name order: `<project>_db`, `<project>-owner`, `<project>-data` |
| `catalog.keys` | derived | a bucket catalog's: for each project an edge of which names buckets in the store, in name order: its `name`, the project; its `path`, `secret/data/<project>/<process>/s3`; and its `buckets`, `<project>-<name>` for each name its edges give, in name order ([chapter 16](16-dependencies.md#the-bucket-catalog)) |
| `catalog.keys` | derived | a bucket catalog's: for each project an edge of which names buckets in the store, in name order: its `name`, the project; its `path`, `secret/data/<project>/<process>/s3`; its `buckets`, `<project>-<name>` for each name its edges give, in name order; and, where any of them admits an origin, its `cors`: each such `bucket` with its `origins`, in bucket and origin order ([chapter 16](16-dependencies.md#a-bucket-a-browser-calls)) |
| `consumes` | derived | the surface the catalog is applied over, as the [consumer label](16-dependencies.md#the-consumer-label) the method's pods carry names it, which the datastore's policy admits |
| `egress` | derived | the datastore on its surface, the Secret Store on its `http` surface, and the cluster's DNS |

Expand Down Expand Up @@ -1837,6 +1837,10 @@ classDiagram
+string path
+string[] buckets
}
class CatalogBucketCors {
+string bucket
+string[] origins
}
class ResolvedEvents {
+Namespace namespace
+string address
Expand Down Expand Up @@ -2235,6 +2239,7 @@ classDiagram
ResolvedCatalogJob "1" *-- "1" ResolvedBucketCatalog : catalog
ResolvedBucketCatalog "1" *-- "1" CatalogAdminToken : admin
ResolvedBucketCatalog "1" *-- "1..*" CatalogKey : keys
CatalogKey "1" *-- "0..*" CatalogBucketCors : cors
ResolvedDeployment "1" *-- "0..1" ResolvedEvents : events
ResolvedDeployment "1" *-- "1..*" ResolvedApplication : applications
Provenance "1" *-- "1..*" InputDigest : inputDigests
Expand Down
2 changes: 1 addition & 1 deletion spec/v1/examples/expected/descriptor.json

Large diffs are not rendered by default.

Loading
Loading