diff --git a/docs/repos/cryptify.md b/docs/repos/cryptify.md
index 2f6d0a9..2a0173d 100644
--- a/docs/repos/cryptify.md
+++ b/docs/repos/cryptify.md
@@ -33,10 +33,11 @@ Configuration parameters:
| `staging_mode` | When `true`, `send_email` skips SMTP entirely and logs the intended email metadata at info level. The upload finalize still returns `Ok`. Defaults to `false`. Intended for staging deploys where real email delivery is undesirable | `false` |
| `usage_db` | Path to the SQLite database used for upload usage accounting | `/app/data/usage.db` |
| `metrics_scan_interval_secs` | Interval in seconds for the background task that samples `data_dir` size and file count for the storage gauges exposed at `GET /metrics`. Defaults to `60`. | `60` |
+| `email_attribute` | Attribute type carrying the sender's email in the signing identity. Finalize requires this attribute to be present on the uploader's identity. Production keeps the default. Test environments override it with a test-scheme type. Must match the PKG's `PKG_EMAIL_ATTRIBUTE` and the SDK's `emailAttributes`. Defaults to `pbdf.sidn-pbdf.email.email`. | `irma-demo.sidn-pbdf.email.email` |
The `chunk_size` setting caps the size of each `PUT /fileupload/{uuid}` body. Clients (such as `@e4a/pg-js` and the PostGuard website) use the same value for their upload chunks, so increasing it server-side without updating the client default will not produce larger chunks on its own.
-[Source: src/config.rs#L3-L38](https://github.com/encryption4all/cryptify/blob/2af3ba0736ba138343295669411334af6f6de37a/src/config.rs#L3-L38)
+[Source: src/config.rs#L3-L22](https://github.com/encryption4all/cryptify/blob/e4a618249dee43d7fe9086844dc122186b3ea07f/src/config.rs#L3-L22)
### Staging mode
@@ -80,7 +81,7 @@ When a request would push the sender over the per-upload or the rolling-window l
`limit` is either `"per_upload"` or `"rolling_window"`. `resets_at` is an RFC 3339 timestamp for when the oldest counted upload expires from the rolling window. It is `null` for `per_upload` rejections, since the per-upload limit does not reset.
-`GET /usage` returns the current state for the authenticated sender, including `used_bytes`, `limit_bytes`, `per_upload_limit_bytes`, `window_days`, and `resets_at`. When the request includes a validated `Authorization: Bearer PG-…`, the response describes the per-tenant bucket (`api-key:`); otherwise it describes the per-email bucket.
+`GET /usage` returns the current state for the caller, including `used_bytes`, `limit_bytes`, `per_upload_limit_bytes`, `window_days`, and `resets_at`. It requires a validated `Authorization: Bearer PG-…` API key and describes that key's per-tenant bucket (`api-key:`). A missing or invalid key returns `401`; if the key cannot be confirmed because PKG is unreachable, the endpoint returns `503`. The `email` query parameter is optional and only echoed back in the response, it no longer selects which bucket is reported.
[Source: src/store.rs#L11-L15](https://github.com/encryption4all/cryptify/blob/58883a86b369af08d92db93aa1025f9eba3c73eb/src/store.rs#L11-L15)
@@ -114,8 +115,24 @@ Cryptify exposes a file upload/download API. An OpenAPI 3.0 specification is ava
- `POST /fileupload/finalize/{uuid}`: Finalize the upload (sends the recipient notification email if `notifyRecipients` was `true` on init).
- `GET /fileupload/{uuid}/status`: Read rolling-token state to resume an in-flight upload across a page refresh or tab crash. Authenticated via `X-Recovery-Token`.
- `GET /filedownload/{uuid}`: Download a file. Supports resumable downloads via the HTTP `Range` header (see [Range support on `/filedownload`](#range-support-on-filedownload) below).
+- `GET /email-template`: Return the email template pg-pkg has linked to the caller's API key. Authenticated with the same `Authorization: Bearer PG-…` key as the upload endpoints (see [Email template retrieval](#email-template-retrieval) below).
- `GET /metrics`: Prometheus text-format metrics for monitoring (see [Metrics](#metrics) below). Unauthenticated; intended for scraping over a restricted network only.
+### Email template retrieval
+
+`GET /email-template` returns the email template that pg-pkg has linked to the caller's API key. The key is validated through the same `Authorization: Bearer PG-…` flow the upload endpoints use, so no separate auth path is involved.
+
+| Case | Status | Body |
+|---|---|---|
+| Valid key with a template configured | `200` | `{ tenant_id, email_template }` |
+| Missing or invalid key | `401` | error body |
+| Valid key, no template configured | `404` | error body |
+| PKG unreachable during validation | `503` | error body |
+
+The template is resolved on the PKG side and returned as-is, keyed on the validated tenant.
+
+[Source: api-description.yaml#L434-L465](https://github.com/encryption4all/cryptify/blob/0946c066a805f1dad73c3966fb70b7aba90fea35/api-description.yaml#L434-L465)
+
### `POST /fileupload/init` request body
| Field | Type | Required | Description |
diff --git a/docs/repos/postguard.md b/docs/repos/postguard.md
index e5fd906..f04f82b 100644
--- a/docs/repos/postguard.md
+++ b/docs/repos/postguard.md
@@ -143,7 +143,14 @@ cargo run --release --bin pg-pkg server \
|---|---|---|
| `IRMA_SERVER` | Yivi/IRMA server URL | `https://is.yivi.app` |
| `DATABASE_URL` | PostgreSQL connection string | none |
-| `PKG_ALLOWED_ORIGINS` | Comma-separated CORS allowlist (e.g. `https://postguard.eu,https://postguard.nl`). `*` keeps the legacy any-origin behaviour and logs a warning at startup. Also available as `--allowed-origins`. | `*` |
+| `PKG_ALLOWED_ORIGINS` | Comma-separated CORS allowlist (e.g. `https://postguard.eu,https://postguard.nl`). Required: the server refuses to start without it. Pass `*` to deliberately allow any origin (this logs a warning at startup). Also available as `--allowed-origins`. | required |
+| `PKG_RATELIMIT_PER_SECOND` | Sustained request rate per client IP for the whole `/v2` scope. Also available as `--ratelimit-per-second`. | `10` |
+| `PKG_RATELIMIT_BURST` | Burst allowance for the general `/v2` limit. Also available as `--ratelimit-burst`. | `50` |
+| `PKG_RATELIMIT_SENSITIVE_PER_SECOND` | Sustained request rate per client IP for the key-issuing endpoints (`POST /start`, `GET /key`, `GET /key/{timestamp}`, `POST /sign/key`, `GET /api-key/validate`). Also available as `--ratelimit-sensitive-per-second`. | `2` |
+| `PKG_RATELIMIT_SENSITIVE_BURST` | Burst allowance for the sensitive-endpoint limit. Also available as `--ratelimit-sensitive-burst`. | `10` |
+| `PKG_RATELIMIT_DISABLED` | Set to `true` to build the limiter in permissive mode (every request passes). Use behind a trusted proxy that does its own rate limiting. Also available as `--ratelimit-disabled`. | `false` |
+| `PKG_RATELIMIT_TRUST_FORWARDED_FOR` | Set to `true` to key the limiter on the rightmost `X-Forwarded-For` entry instead of the TCP peer address, so per-client limiting works behind a trusted reverse proxy. Off by default so a directly exposed PKG never trusts client-supplied headers. Also available as `--ratelimit-trust-forwarded-for`. | `false` |
+| `PKG_EMAIL_ATTRIBUTE` | Attribute type carrying the email in API-key signing identities. Production keeps the default. Test environments set a test-scheme type (e.g. `irma-demo.sidn-pbdf.email.email`), since `pbdf.*` credentials cannot be issued outside production. Must match cryptify's `email_attribute` and the SDK's `emailAttributes`. Also available as `--email-attribute`. | `pbdf.sidn-pbdf.email.email` |
| `RUST_LOG` | Log level (`debug`, `info`, `warn`, `error`) | none |
### Running the PKG Server
@@ -255,6 +262,12 @@ Request body for signing keys:
| `GET` | `/health` | Health check. |
| `GET` | `/metrics` | Prometheus metrics. |
+### Rate limiting
+
+The `/v2` scope is rate limited per client IP in two tiers. The general tier covers the whole scope; the sensitive tier covers the key-issuing endpoints (`POST /start`, `GET /key`, `GET /key/{timestamp}`, `POST /sign/key`, `GET /api-key/validate`) with a tighter limit, applied before authentication runs. Requests over the limit get `429 Too Many Requests` with a `Retry-After` header. The limits are global across worker processes and tunable through the `PKG_RATELIMIT_*` variables listed under [Environment Variables](#environment-variables).
+
+The limiter keys on the TCP peer address by default. When the PKG runs behind a trusted reverse proxy, set `PKG_RATELIMIT_TRUST_FORWARDED_FOR=true` so it keys on the rightmost `X-Forwarded-For` entry (the hop the trusted proxy appends) instead of the proxy's own address. Only the rightmost entry is trusted; anything left of it is client-supplied and spoofable.
+
### Authentication
The PKG supports two authentication methods:
diff --git a/docs/sdk/js-encryption.md b/docs/sdk/js-encryption.md
index 5d88435..8ca772d 100644
--- a/docs/sdk/js-encryption.md
+++ b/docs/sdk/js-encryption.md
@@ -126,6 +126,25 @@ What gets retried: 5xx responses, fetch-level network errors (`TypeError` from `
The same `retry` config governs downloads. See [Decryption — Retries and resumable downloads](/sdk/js-decryption#retries-and-resumable-downloads).
+### Email attribute types
+
+The SDK uses the production `pbdf.sidn-pbdf.email` attribute types for recipient builders, key requests, and signing. Test setups that run against a different scheme can override these with `emailAttributes`:
+
+```ts
+const pg = new PostGuard({
+ pkgUrl: 'https://pkg.staging.postguard.eu',
+ cryptifyUrl: 'https://storage.staging.postguard.eu',
+ emailAttributes: {
+ email: 'irma-demo.sidn-pbdf.email.email',
+ domain: 'irma-demo.sidn-pbdf.email.domain',
+ },
+});
+```
+
+[Source: types.ts#L7-L27](https://github.com/encryption4all/postguard-js/blob/854d5c06ea7c852e8ebb8517687d71f0349bfd51/src/types.ts#L7-L27)
+
+Leave `emailAttributes` unset for production. An override must match the PKG's `PKG_EMAIL_ATTRIBUTE` and cryptify's `email_attribute`, or finalize and key issuance reject the identity.
+
## Resume an interrupted upload
A long-running upload can be interrupted by a page refresh, tab crash, navigation away, or process restart. The SDK exposes two primitives for rehydrating an in-flight session from Cryptify rather than starting over: the `FileState` type and the `resumeUpload` function.
@@ -249,6 +268,25 @@ const envelope = await pg.email.createEnvelope({
Call `.toBytes()` to get the encrypted data, or pass the `Sealed` object directly to `pg.email.createEnvelope()` for email integration.
+## Prepare a signing session ahead of time
+
+With Yivi signing, `encrypt()` starts the disclosure session after the user acts. On iOS that is a problem: a Yivi Universal Link only opens the app when the navigation happens inside a genuine user gesture, and at tap time the app deep-link does not exist yet, so the tap falls back to Safari.
+
+`pg.prepareSign()` starts the Yivi session early. It returns `mobileUrl` (the app deep-link, resolved once Yivi shows its mobile button — put it on an `` so one tap opens the app), `keys` (resolves on disclosure), and `cancel()`. Pass the resolved keys to `encrypt()` via `signingKeys`; `encrypt()` then uses them directly and never starts a second session.
+
+```ts
+// when the compose form becomes valid:
+const prep = pg.prepareSign({ element: '#hidden-yivi', attributes: SIGN_ATTRS, includeSender: true });
+const href = await prep.mobileUrl; // put on the "Send"
+// user taps the anchor -> Yivi app opens (one gesture)
+const signingKeys = await prep.keys; // resolves after disclosure
+await pg.encrypt({ files, recipients, sign, signingKeys }).upload({ notify });
+```
+
+[Source: types.ts#L116-L145](https://github.com/encryption4all/postguard-js/blob/f8bd16b154b79b92bb086c2c088f10e79babb7db/src/types.ts#L116-L145)
+
+The disclosure is identity-bound (sender email plus optional attributes) and independent of the files and recipients, so the resolved keys are valid for whatever is ultimately encrypted. `sign` is still required alongside `signingKeys`: it supplies envelope metadata and the friendly-sender line when `includeSender` is set. `mobileUrl` only settles on mobile, where Yivi shows the app button; on desktop it stays pending, so race it with a timeout.
+
## Error handling
All encryption methods can throw: