# Storefront resource access

**Status:** draft · **Serves:** `GET /docs/resource-access` · **Verified:** 2026-09-12

Two things live here. **Site publication** — closing the whole shop behind a
short numeric PIN while it is being built — is finished and usable today.
The per-resource matrix below it (a password on one product, category or page)
is staged: keep `companies.resource_access_enabled` disabled until the complete
public-surface matrix and cache-isolation tests pass.

## Site publication

A shop's site is either **published** or **not published**. Published is the
default — for new shops and for every shop that already exists, with no
migration needed to say so. The state is not a stored flag: "not published" is
the presence of an **active system access policy** carrying the PIN on the
`shop` resource, and "published" is that policy being absent or switched off.
One source of truth, so the gate and the setting cannot disagree.

While a site is not published:

* every visitor sees a PIN prompt instead of the site and gets in by typing the
  PIN (`POST /storefront/access/unlock`, see below);
* search engines are shut out — `robots.txt` answers `Disallow: /` for the whole
  host, `sitemap.xml` answers 404, and storefront responses carry
  `X-Robots-Tag: noindex, nofollow`. The same applies on a custom domain;
* **three wrong PINs from one address lock that address out for 10 minutes.**
  The correct PIN does not open the door during the lockout either — otherwise
  the lockout would be a delay, not a lock. This lockout, not the length of the
  PIN, is what actually protects a closed site.

### `GET /site-publication`

```json
{ "published": false, "pin": "1234" }
```

**The PIN comes back in plain text, and that is deliberate.** It is a temporary
guard for a site under construction, not an account secret: the owner dictates
it to a client and has to be able to read it back. Only the shop's owner/admin
or a key with `access:read` ever sees it — the public storefront resolve carries
`site_published` and nothing else. `pin` is empty when the site has never been
closed; it survives publishing the site again, so re-closing does not force a
new one.

### `PUT /site-publication`

```bash
# close the site with the PIN 1234
curl -X PUT https://api.vizen.shop/site-publication \
  -H 'Authorization: Bearer vz_pat_…' -H 'Content-Type: application/json' \
  -d '{"published": false, "pin": "1234"}'

# publish it again (no PIN needed)
curl -X PUT https://api.vizen.shop/site-publication \
  -H 'Authorization: Bearer vz_pat_…' -H 'Content-Type: application/json' \
  -d '{"published": true}'
```

`published: false` **requires** a PIN of digits only, 3 to 6 of them (3–4 is
what the cabinet suggests); anything else is refused with
`SITE_PASSWORD_FORMAT`. Every close sets the PIN anew, so closing the site also
revokes every pass handed out under the previous one.

Rights: org role `owner` or `admin`, token scopes `access:read` for the GET and
`access:write` for the PUT. Both methods are **prod-sensitive**: a dev-contour
key may not even read them. A cache purge of the shop's host runs on every
successful `PUT` — the whole host changes at once.

The system policy behind the state is not a user policy: it is hidden from
`GET /access-policies`, its id answers `ACCESS_POLICY_NOT_FOUND` on edit or
delete, its code is refused on create (`ACCESS_POLICY_CODE_RESERVED`), and
`PUT /resource-access-bindings` leaves its binding alone. Everything about it
moves through the two methods above.

Read the state of a live storefront without a token from
`GET /v1/storefronts/resolve`: `storefront.site_published` says whether the site
is closed, `site_access_granted` says whether **this** caller (by the
`vz_store_access` cookie) is already past the prompt. Neither carries the PIN.

## Password challenge

`POST /storefront/access/unlock`

```json
{
  "company_id": 42,
  "resource_type": "product",
  "resource_id": 1001,
  "password": "customer supplied password"
}
```

For the site prompt this is `resource_type: "shop"` with `resource_id` equal to
`company_id`. Submitted passwords are accepted from 3 characters — the site PIN
is short by design, and refusing it while parsing the body would read to the
visitor as "the correct PIN does not work". The site PIN is compared in constant
time against the stored plain value; per-resource policy passwords are still
verified against their Argon2id hash.

On success the server returns `200` and sets the host-only HttpOnly cookie
`vz_store_access`. The cookie is `Secure`, `SameSite=Lax`, contains 256 bits of
random data and has no `Domain` attribute. Only its SHA-256 digest is stored.

Unknown resources, an absent password policy and a wrong password all return the
same `401 {"error":"ACCESS_DENIED"}`. The response never contains policy IDs,
audience/role names or password hints. Cross-site requests are rejected and
repeated attempts receive `429` with `Retry-After`: `60` from the per-minute
rate limit, or the remaining seconds of the 10-minute lockout after three
failures on the same address and resource.

`DELETE /storefront/access/session` revokes the current opaque session and clears
the cookie. Both routes return `Cache-Control: private, no-store`.

## Runtime contract

Roles are not embedded into the cookie. For every protected request the backend
loads the target shop, current customer status/roles and current password grant
versions. A role removal, customer block, password replacement or policy disable
therefore takes effect without waiting for cookie expiry.

Multiple effective policies are combined with AND. A password submission grants
only policies whose Argon2id hash matches; policies with different passwords can
be unlocked in successive requests.

Direct product/category/article reads are connected. Product catalog pages,
marketplace, wishlist, AddToCart/UpdateCartItem, GetCart/QuoteCart and checkout
use the same access predicate. Checkout revalidates the live session and grant
inside the order transaction.

Category/article lists and dependent redirect/sitemap/related/group/combo/gift,
HTML/form/private-file surfaces are still being implemented, so this document is
not an activation approval for the per-resource feature flag. Site publication
does **not** depend on that flag: it gates the site at the page level, in front
of everything, and is live today.
