Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c7b7771
Replace transition:generic with permission sets scopes
Ashex Jul 7, 2026
990ef2b
Drop hyperboards permission set from demo
Ashex Jul 7, 2026
c730fce
Update e2e tests to match permission sets usage
Ashex Jul 7, 2026
ce25beb
fix(auth-service): name email recipient log field consistently
aspiers Jul 14, 2026
aa2e1a4
fix(auth-service): log all email delivery paths
aspiers Jul 14, 2026
78d9769
Merge pull request #197 from hypercerts-org/fix/email-log-field
aspiers Jul 14, 2026
dbd5ead
feat(shared): persist Resend delivery events
aspiers Jul 15, 2026
1a9c922
feat(auth): receive signed Resend webhooks
aspiers Jul 15, 2026
2bf9af4
docs(auth): document Resend webhook setup
aspiers Jul 15, 2026
f54524a
fix(auth): keep Resend monitoring explicitly optional
aspiers Jul 15, 2026
8048eb0
fix(auth): log Resend events without persistence
aspiers Jul 15, 2026
c78b4e5
test(shared): surface database cleanup failures
aspiers Jul 15, 2026
def61ec
fix(auth): include subjects in Resend event logs
aspiers Jul 15, 2026
fb49dae
fix(auth): configure trusted proxies before middleware
aspiers Jul 15, 2026
c2055a8
docs(auth): clarify Resend is an optional provider
aspiers Jul 15, 2026
54f2d0b
style(auth): order Resend webhook imports
aspiers Jul 15, 2026
58a1e12
refactor(auth): normalize email delivery logs
aspiers Jul 15, 2026
fc369fe
fix(auth): filter account-wide Resend webhooks
aspiers Jul 16, 2026
6bfa3db
fix(auth): redact OTPs from delivery logs
aspiers Jul 20, 2026
fd50cf0
style(auth): simplify OTP redaction regex
aspiers Jul 20, 2026
fa9e740
chore(ci): document safe internal HTTP URLs
aspiers Jul 20, 2026
2193515
feat(auth-service): log OTP send duration, messageId and SMTP response
aspiers Jul 16, 2026
c0b1eb1
Merge pull request #203 from hypercerts-org/feat/otp-send-timing
aspiers Jul 20, 2026
d275b33
feat(auth): log Resend email open events
aspiers Jul 20, 2026
fd558aa
Merge pull request #198 from hypercerts-org/feat/resend-delivery-webh…
aspiers Jul 20, 2026
c060319
feat(auth): expand Resend email event handling
aspiers Jul 20, 2026
aec72ac
Merge pull request #206 from hypercerts-org/feat/resend-webhook-addit…
aspiers Jul 20, 2026
0d05513
chore: release
github-actions[bot] Jul 20, 2026
7dc00a7
Merge pull request #207 from hypercerts-org/changeset-release/main
aspiers Jul 20, 2026
28c3521
Merge pull request #188 from hypercerts-org/fix/oauth-scopes
aspiers Jul 20, 2026
904c871
Merge pull request #208 from hypercerts-org/main
aspiers Jul 20, 2026
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
21 changes: 19 additions & 2 deletions .agents/skills/epds-login/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ AT Protocol universe (a DID, a handle, a data repository) automatically provisio
From your app's perspective, ePDS uses standard AT Protocol OAuth (PAR + PKCE + DPoP).
The reference implementation is `packages/demo` in the [ePDS repository](https://github.com/hypercerts-org/ePDS).

For protocol-level detail beyond ePDS specifics — DPoP proof mechanics, granular
scope design (`repo:`/`rpc:`/`blob:`/`account:`), identity verification after token
exchange, and refresh-token race handling — see the `atproto-oauth` skill. This skill
covers only what is ePDS-specific.

## Two Flows

| Flow | App provides | How user starts | Implementation |
Expand Down Expand Up @@ -57,7 +62,7 @@ are mutually exclusive:
"client_id": "https://yourapp.example.com/client-metadata.json",
"client_name": "Your App",
"redirect_uris": ["https://yourapp.example.com/api/oauth/callback"],
"scope": "atproto transition:generic",
"scope": "atproto include:org.hypercerts.authWrite include:app.certified.authWrite",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "private_key_jwt",
Expand All @@ -67,6 +72,17 @@ are mutually exclusive:
}
```

> **On the `scope` value:** `atproto` is mandatory and must be listed first; the
> remaining entries request only the permissions your app needs. The examples here
> reference two hypercerts specific permission sets via the `include:` prefix —
> `include:org.hypercerts.authWrite` and `include:app.certified.authWrite`;
> substitute the permission sets your own app defines. A
> permission set that bundles `rpc:` service calls also needs an
> `?aud=<service-did>` parameter (with `#` percent-encoded as `%23`); these are
> write-only sets, so no `aud` is required. Avoid the legacy `transition:generic`
> catch-all. See the `atproto-oauth` skill's "Scopes and Permission Sets" for the
> grammar.

Alternatively, replace `jwks_uri` with an inline `jwks` object containing
the public key directly — see
[client-metadata.md](references/client-metadata.md) for both forms, the
Expand All @@ -85,7 +101,8 @@ const client = new NodeOAuthClient({
client_id: 'https://yourapp.example.com/client-metadata.json',
client_name: 'Your App',
redirect_uris: ['https://yourapp.example.com/api/oauth/callback'],
scope: 'atproto transition:generic',
scope:
'atproto include:org.hypercerts.authWrite include:app.certified.authWrite',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'private_key_jwt',
Expand Down
8 changes: 4 additions & 4 deletions .agents/skills/epds-login/references/client-metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ mutually exclusive — the PDS rejects metadata that has both.
"client_id": "https://yourapp.example.com/client-metadata.json",
"client_name": "Your App Name",
"redirect_uris": ["https://yourapp.example.com/api/oauth/callback"],
"scope": "atproto transition:generic",
"scope": "atproto include:org.hypercerts.authWrite include:app.certified.authWrite",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "private_key_jwt",
Expand All @@ -70,7 +70,7 @@ mutually exclusive — the PDS rejects metadata that has both.
"client_id": "https://yourapp.example.com/client-metadata.json",
"client_name": "Your App Name",
"redirect_uris": ["https://yourapp.example.com/api/oauth/callback"],
"scope": "atproto transition:generic",
"scope": "atproto include:org.hypercerts.authWrite include:app.certified.authWrite",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "private_key_jwt",
Expand Down Expand Up @@ -103,7 +103,7 @@ key generation and serving details.
| `client_id` | Yes | Must match the URL where this file is hosted |
| `client_name` | Yes | Shown on the login page and in OTP emails |
| `redirect_uris` | Yes | Array of allowed callback URLs after login |
| `scope` | Yes | Always `"atproto transition:generic"` |
| `scope` | Yes | Space-separated list. `atproto` is required and must come first; add the scopes/permission sets your app needs (the examples use `include:org.hypercerts.authWrite include:app.certified.authWrite`). Avoid the legacy `transition:generic` catch-all — see the `atproto-oauth` skill. |
| `grant_types` | Yes | Always `["authorization_code", "refresh_token"]` |
| `response_types` | Yes | Always `["code"]` |
| `token_endpoint_auth_method` | Yes | `"private_key_jwt"` (recommended) or `"none"` — see above |
Expand Down Expand Up @@ -266,7 +266,7 @@ above for the trade-offs.
"client_id": "https://yourapp.example.com/client-metadata.json",
"client_name": "Your App Name",
"redirect_uris": ["https://yourapp.example.com/api/oauth/callback"],
"scope": "atproto transition:generic",
"scope": "atproto include:org.hypercerts.authWrite include:app.certified.authWrite",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
Expand Down
3 changes: 2 additions & 1 deletion .agents/skills/epds-login/references/flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,8 @@ export async function handleLogin(email: string) {
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'atproto transition:generic',
scope:
'atproto include:org.hypercerts.authWrite include:app.certified.authWrite',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
Expand Down
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,12 @@ SMTP_PASS=
SMTP_FROM=noreply@pds.example
SMTP_FROM_NAME=ePDS

# Resend users only (optional): email webhook signing secret. ePDS supports
# any SMTP provider and does not require Resend. To opt into Resend email event
# logs, configure https://auth.pds.example/webhooks/resend and set:
# RESEND_WEBHOOK_SECRET=whsec_...
# Account-wide Resend events are filtered to the exact SMTP_FROM address.

DB_LOCATION=/data/epds.sqlite

# ============================================================================
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
# ePDS

## 0.7.0

### Who should read this release

- **Operators:**
- [Email delivery logs use a consistent recipient field.](#v0.7.0-email-delivery-logs-use-a-consistent-recipient-field)
- [OTP send logs now record how long the email handoff took, the provider's message ID, and the SMTP server response.](#v0.7.0-otp-send-logs-now-record-how-long-the-email-handoff-took)
- [Optional email-event logging for operators who choose the third-party Resend service to send email; ePDS continues to support other email providers.](#v0.7.0-optional-email-event-logging-for-operators-who-choose-the)

### Minor Changes

- <a id="v0.7.0-email-delivery-logs-use-a-consistent-recipient-field"></a> [#197](https://github.com/hypercerts-org/ePDS/pull/197) [`ce25beb`](https://github.com/hypercerts-org/ePDS/commit/ce25beba5e231e8300a875f1fc1d2ad37ae75527) Thanks [@aspiers](https://github.com/aspiers)! - Email delivery logs use a consistent recipient field.

**Affects:** Operators

**Operators:** All four email delivery paths now log the recipient as `email`: client-branded OTP, standard sign-in OTP, welcome OTP, and backup-email verification. Update structured-log queries for `Sent client-branded OTP email` to read `email` instead of `to`.

- <a id="v0.7.0-otp-send-logs-now-record-how-long-the-email-handoff-took"></a> [#203](https://github.com/hypercerts-org/ePDS/pull/203) [`2193515`](https://github.com/hypercerts-org/ePDS/commit/2193515970c56035fd026c3df2b3f44e403eb801) Thanks [@aspiers](https://github.com/aspiers)! - OTP send logs now record how long the email handoff took, the provider's message ID, and the SMTP server response.

**Affects:** Operators

**Operators:** every OTP email completion line (all four paths — client-branded, sign-in, welcome, backup-email verification) now carries `elapsedMs`, `messageId`, and `smtpResponse` alongside `email`. `messageId` lets these logs be joined to Resend delivery events; `elapsedMs` isolates a slow SMTP handoff from other causes of late-arriving codes. Failed sends log `elapsedMs` too, on the existing `better-auth: failed to send OTP email` error line.

- <a id="v0.7.0-optional-email-event-logging-for-operators-who-choose-the"></a> [#198](https://github.com/hypercerts-org/ePDS/pull/198) [`2bf9af4`](https://github.com/hypercerts-org/ePDS/commit/2bf9af420281c769ec88f19c9badd89f00acc03c) Thanks [@aspiers](https://github.com/aspiers)! - Optional email-event logging for operators who choose the third-party Resend service to send email; ePDS continues to support other email providers.

**Affects:** Operators

**Operators:** ePDS remains compatible with any SMTP provider and does not require Resend. Operators who already use Resend can opt in by registering `https://<AUTH_HOSTNAME>/webhooks/resend` for the documented email events and setting `RESEND_WEBHOOK_SECRET`; logs include delivery, open, complaint, suppression, and scheduling events. The route verifies each webhook, logs only events whose sender exactly matches `SMTP_FROM`, acknowledges unsupported click and inbound events without logging their payloads, and does not persist webhook data.

## 0.6.5

### Who should read this release
Expand Down
56 changes: 56 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,62 @@ buttons appear on the login page.
| `POSTMARK_SERVER_TOKEN` | Postmark server token |
| `EMAIL_TEMPLATE_ALLOWED_DOMAINS` | Optional comma-separated list of HTTPS hostnames from which `email_template_uri` can be fetched. If unset, any HTTPS URL is allowed. If set, templates hosted on unlisted domains are logged as a warning and ignored. |

#### Optional Resend email event logs

ePDS does **not** require Resend: operators can use any SMTP provider. This
provider-specific integration is only for operators who already send through
Resend and choose to log its email webhooks. Without
`RESEND_WEBHOOK_SECRET`, the webhook route is not mounted.

| Variable | Description |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `RESEND_WEBHOOK_SECRET` | Resend users only. Optional webhook signing secret (`whsec_...`) that enables `POST /webhooks/resend` and structured Resend event logs when provided. |

To opt into email-event logging for email sent through Resend:

1. In the Resend dashboard, register `https://<AUTH_HOSTNAME>/webhooks/resend`.
2. Subscribe it to `email.sent`, `email.delivered`, `email.delivery_delayed`,
`email.opened`, `email.bounced`, `email.failed`, `email.complained`,
`email.suppressed`, and `email.scheduled`.
3. To receive `email.opened`, enable Resend open tracking for the sending domain
and configure its tracking CNAME. Resend inserts the tracking pixel; ePDS
does not alter email HTML. Without tracking, the other events still work.
4. Set `RESEND_WEBHOOK_SECRET` to that endpoint's signing secret and redeploy
the auth service.
5. Send a test sign-in code through the production SMTP configuration and
confirm that `email.sent` and `email.delivered` reach the receiver. If open
tracking is enabled, open the message and confirm `email.opened` also arrives.
Resend's documentation does not explicitly guarantee that SMTP-submitted
email emits webhooks, so verify this before relying on the logs.

Resend webhooks cover the whole Resend account rather than one sending domain.
The receiver parses each event's `data.from` address and logs it only when it
exactly matches this ePDS instance's `SMTP_FROM`; events for other senders are
acknowledged without logging their payload. ePDS instances sharing a Resend
account must therefore use distinct `SMTP_FROM` addresses. Events cannot be
separated when multiple instances use the same sender address.

The receiver verifies the Svix signature against the raw request body and emits
a provider-neutral log schema: `provider`, `eventId`, `eventType`, `occurredAt`,
`messageId`, `email`, and `subject`. Any sign-in code in `subject` is replaced
with `[REDACTED]` to prevent it from reaching logs while preserving the rest of
the subject. Resend event types are normalized to `sent`, `delivered`, `delayed`,
`opened`, `bounced`, `failed`, `complained`, `suppressed`, or `scheduled`.
`Delayed`, `complained`, and `suppressed` events use `warn`; the rest use `info`.
An `opened` event means the tracking pixel was fetched, which can be caused or
suppressed by mail-client privacy features and is not proof that the recipient
read the message. Signed `email.clicked` and inbound `email.received` events are
acknowledged without logging their payloads, preventing retries if the Resend
endpoint is accidentally subscribed to them. No webhook data is persisted by
ePDS. The
provider-specific rate limit permits 300 webhook requests per minute per source
IP.

Resend retries carry the same source event ID, logged as `eventId`, and events
may arrive out of order. Log analysis should deduplicate by `eventId`, order
each `messageId` by `occurredAt`, and subtract `sent` from `delivered` to
calculate delivery latency.

### Database

| Variable | Description |
Expand Down
Loading
Loading