feat: redesign the iOS app (inbox with inbox agent drafted replies edited in the full composer, Remie, home, analytics, CRM, contacts, settings hubs, mailbox connect flow covering every dashboard source) and replace Turnstile in the app with Apple App Attest (attested keys in app_attest_keys, challenge and key endpoints, verifyHuman gate on login, registration and password reset, auth config flag, docs and OpenAPI)

This commit is contained in:
Matthew Meszaros
2026-10-11 19:00:50 +02:00
parent 5dba6483d0
commit ec46f57d22
194 changed files with 21928 additions and 8341 deletions
+2 -1
View File
@@ -212,6 +212,7 @@ Every instance that has not updated yet runs the code an attacker can read here.
- passwords are hashed with **Argon2id** and nothing else. No change may introduce a second scheme, weaken the parameters, or store a password in any reversible form
- `crypt.CheckPassword` (`internal/pkg/crypt/validation.go`) is the only gate on a new or changed password, and it refuses anything on the embedded NCSC breached list (`internal/pkg/crypt/passwords/breached.txt`). Every path that accepts a password must call it: registration, reset, change, invitation acceptance, and any future one
- every auth-sensitive entry point is behind CAPTCHA (`internal/pkg/captcha/turnstile.go`): login, registration, password reset. The login and registration code-confirmation steps take no second captcha: their signed, single-use session from the start step already proves it passed
- the native app proves itself with App Attest instead (`internal/app/appattest`, verifier in `internal/pkg/appattest`): a key attested against Apple's root once per install, then an assertion over a single-use challenge bound to the action on each of those calls, with an advancing counter and a per-key budget. `verifyHuman` in `internal/app/auth/service.go` is the one gate for both; a new captcha-gated path calls it, never `captcha.Verify` alone. The app never renders Turnstile
- TOTP verification records the step it consumed (`user_totp_settings.last_used_step`) and refuses a replay of it. Any new second factor needs equivalent single-use enforcement
- **admin routes require a session that verified a second factor.** `middleware.AdminMiddleware` refuses `!session.MFAVerified` with `admin_mfa_required`, and `RequireAdminPermission` then checks the admin bit. Every admin route sits behind both; never add one outside them. The instance-wide Warmbly Cloud link is gated the same way, because it belongs to the instance, not to a workspace
- an operation that changes who can get in, or moves money or ownership, requires a fresh authentication (`middleware.RequireFreshAuth`, `POST /v1/auth/reauth`). API-key and OAuth callers pass through, because they present a credential on every call and have no session to refresh
@@ -754,7 +755,7 @@ Instead, the codebase uses layered abuse controls and trust signals across auth,
### Auth and signup protection
Authentication flows use Cloudflare Turnstile:
Authentication flows use Cloudflare Turnstile from a browser and App Attest from the native app:
- login
- registration
+9
View File
@@ -26,6 +26,7 @@ import (
"github.com/warmbly/warmbly/internal/app/admin"
"github.com/warmbly/warmbly/internal/app/adminoutreach"
"github.com/warmbly/warmbly/internal/app/advanced"
"github.com/warmbly/warmbly/internal/app/appattest"
"github.com/warmbly/warmbly/internal/app/unsublink"
"github.com/warmbly/warmbly/internal/observability/errs"
@@ -212,6 +213,7 @@ func main() {
var uniboxService unibox.UniboxService
var cipherService cipher.CipherService
var passkeyService passkey.Service
var appAttestService *appattest.Service
var encryptedKeys encryptedkeys.Store
var storageBackendRepo repository.StorageBackendRepository
var fleetNodeRepo repository.FleetNodeRepository
@@ -954,6 +956,12 @@ func main() {
// gate can issue a pending challenge.
twofaService = twofa.NewService(repository.NewTOTPRepository(primaryDB.Pool), userRepostory, tokenService, cache, twofa.DeriveKey(authCfg.TwoFASecret))
authService.WireTwoFA(twofaService)
// The native app proves itself with App Attest where a browser solves Turnstile.
appAttestService = appattest.New(repository.NewAppAttestRepository(primaryDB.Pool), cache, appattest.Config{
AppIDs: authCfg.AppAttestAppIDs,
AllowDevelopment: authCfg.AppAttestAllowDevelopment,
})
authService.WireAppAttest(appAttestService)
// Deployment-level auth policy: whether an emailed code gates a login,
// whether signups are open, whether verification is required. Resolved
@@ -2338,6 +2346,7 @@ func main() {
TokenService: tokenService,
PasskeyService: passkeyService,
AppAttestService: appAttestService,
UserService: userService,
EmailService: emailService,
MailboxImportService: mailboxImportService,
+3
View File
@@ -516,6 +516,7 @@ With [multiple dashboards sharing a backend](/development/configuration/#google-
- `POST /auth/login`, `/auth/login/confirm`, `/auth/register`, `/auth/register/confirm`, `/auth/refresh`, `/auth/reset-password`, `/auth/reset-password/confirm`
- `POST /auth/reauth` (re-prove the account holder behind a live session, for the changes that require a recent confirmation)
- `GET /auth/config` (public deployment capabilities: which sign-in methods this backend has enabled, whether a login code step follows, whether signups are open, whether the instance still needs claiming)
- `POST /auth/app-attest/challenge`, `/auth/app-attest/keys` (the native app's [App Attest](/api/reference/account-org/#app-attest-native-apps) challenge and one-time key registration, on their own per-IP budget `APP_ATTEST_IP_RATE_LIMIT`)
- `GET /auth/instance` (JWT only: the running Warmbly version of a self-hosted instance and whether a newer release exists, for the dashboard's version pill; a hosted deployment answers `self_hosted: false` and nothing else)
`POST /auth/register` accepts an optional `invite` field carrying an invitation token. On a deployment running `DISABLE_REGISTRATION=invite_only` it is what permits the signup, and the account is created inside the inviting organization rather than in a new one. The token must resolve to a live invitation whose email equals the submitted address, otherwise the request is refused with `invitation_invalid`. Omitting it on a closed deployment returns `registration_invite_only` or `registration_closed`. See [error codes](/api/error-codes/#registration-and-invitation-refusals).
@@ -557,6 +558,8 @@ Alongside them, `api_url` is this API's own public base (for a copyable example
`GET /auth/config` also carries `gmail_oauth_connect` (boolean): whether a new Gmail mailbox may be connected with Google sign-in. Both `BOX_GOOGLE_CLIENT_ID` and `BOX_GOOGLE_CLIENT_SECRET` are required. With them set, the capability defaults to `true`; explicit `BOX_GOOGLE_OAUTH_CONNECT=false` or an invalid value disables it. When `false`, offer the app-password route (`POST /emails/onboarding/smtp-imap` against Gmail's servers) rather than start an OAuth round trip that returns `mailbox_gmail_oauth_disabled`. The dashboard additionally requires its deployment-local [frontend opt-in](/development/configuration/#google-sign-in-per-dashboard-deployment) to show Google sign-in. Existing Google sign-in mailboxes are unaffected either way.
`GET /auth/config` also carries `app_attest` (boolean): whether this server accepts an [App Attest proof](/api/reference/account-org/#app-attest-native-apps) from the native app in place of a Turnstile token on `POST /auth/login`, `/auth/register`, `/auth/reset-password` and `/auth/reset-password/confirm`. Read it with `captcha`: no captcha means nothing to send, a captcha with `app_attest` means the app sends a proof, and a captcha without it means email and password sign-in is unavailable in the app.
`GET /auth/config` also carries `billing_enabled` (boolean). It is `false` when the deployment runs with `BILLING_PROVIDER=none`, which is the self-host default: every feature is unlocked server-side, so the dashboard shows the workspace as self-hosted instead of on a free trial and hides the billing and referral pages. `self_hosted` alone does not imply this, because a self-hosted install may still run Stripe.
- `POST /auth/setup` (first-run claim: exchanges the one-time token printed at boot for the owner account. Refused once any account exists)
- `GET /auth/providers`, `POST /auth/apple`, `POST /auth/google` (native-app social sign-in)
+22
View File
@@ -153,6 +153,27 @@ A password must be 8 to 128 characters. There is no composition rule, but it is
}
```
#### App Attest refusals
The native app proves itself with [App Attest](/api/reference/account-org/#app-attest-native-apps) in place of the captcha on sign-in, registration and password reset.
| `code` | Status | Meaning |
|--------|--------|---------|
| `app_attest_failed` | 400 | The proof did not verify: the challenge expired or was already used, the proof came from another app or from a development build the server does not accept, the assertion's counter did not advance, the key id was already registered, or the object was malformed. Fetch a fresh challenge and make a new proof; if it keeps failing, update the app |
| `app_attest_key_unknown` | 400 | The `key_id` was never registered on this server. Register the key again with `POST /auth/app-attest/keys`, then retry the call |
| `app_attest_unavailable` | 404 | `POST /auth/app-attest/challenge` or `/keys` on an instance with App Attest off. When `GET /auth/config` reports a captcha without `app_attest`, email and password sign-in is unavailable in the app; Sign in with Apple, Google and passkeys still work |
Each registered key may make 30 protected calls per hour. Past that the call answers `400` with code `bad_request` and the message `Too many attempts, please try again later.`, the same answer as the other sign-in attempt budgets.
```json
{
"error": "Bad Request",
"message": "We couldn’t verify this copy of the app. Update it from the App Store and try again.",
"code": "app_attest_failed",
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
}
```
#### Cloud connection refusals
| `code` | Status | Meaning |
@@ -618,6 +639,7 @@ Returned when the requested resource doesn't exist.
| `code` | Meaning |
|--------|---------|
| `unknown_view` | `/me/views/:view` was given a view name other than `contacts`, `campaign_leads` or `unibox_rail` |
| `app_attest_unavailable` | `POST /auth/app-attest/challenge` or `/keys` on an instance with App Attest off. See [App Attest refusals](#app-attest-refusals) |
| `lead_cc_contact_not_found` | [Set a lead's CC](/api/reference/campaigns/#set-a-leads-cc) named a contact that is not in the workspace |
| `slack_not_connected` | A [Slack](/guides/slack/) route was called for a workspace that has not connected Slack, or whose connection was removed. Connect it under **Integrations > Slack** |
| `slack_link_invalid` | The Slack link code is unknown, expired or already used. Mention or message the bot in Slack for a new link button |
@@ -11,7 +11,7 @@ All error responses use the shared envelope (`error`, `message`, `code`, `reques
## Authentication
These endpoints are public (no session required). Login and registration are two-step: the first call sends a one-time code by email and returns a short-lived signed `session` token, the second call confirms the code and mints the real token pair. Captcha (Cloudflare Turnstile) is required on the start calls.
These endpoints are public (no session required). Login and registration are two-step: the first call sends a one-time code by email and returns a short-lived signed `session` token, the second call confirms the code and mints the real token pair. When the deployment runs a captcha, the start calls and both password reset calls require one: a Cloudflare Turnstile token from a browser, or an [App Attest proof](#app-attest-native-apps) from the native app.
### Start login
@@ -27,7 +27,8 @@ Auth: public (not available to API keys).
|-------|------|----------|-------------|
| `email` | string | yes | Account email. |
| `password` | string | yes | Account password. |
| `turnstile` | string | yes | Cloudflare Turnstile token. |
| `turnstile` | string | yes, unless `app_attest` is sent | Cloudflare Turnstile token. |
| `app_attest` | object | no | The native app's [App Attest proof](#app-attest-native-apps), sent instead of `turnstile`. |
```json
{
@@ -101,7 +102,7 @@ Auth: public (not available to API keys).
#### Request body
Same shape as `/auth/login`: `email`, `password`, `turnstile`.
Same shape as `/auth/login`: `email`, `password`, and `turnstile` or `app_attest`.
```json
{
@@ -183,7 +184,8 @@ Auth: public (not available to API keys).
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `email` | string | yes | Account email. |
| `turnstile` | string | yes | Cloudflare Turnstile token. |
| `turnstile` | string | yes, unless `app_attest` is sent | Cloudflare Turnstile token. |
| `app_attest` | object | no | The native app's [App Attest proof](#app-attest-native-apps), sent instead of `turnstile`. |
#### Response
@@ -203,7 +205,8 @@ Auth: public (not available to API keys).
|-------|------|----------|-------------|
| `session` | string | yes | The reset session token tied to the email. |
| `password` | string | yes | The new password. |
| `turnstile` | string | yes | Cloudflare Turnstile token. |
| `turnstile` | string | yes, unless `app_attest` is sent | Cloudflare Turnstile token. |
| `app_attest` | object | no | The native app's [App Attest proof](#app-attest-native-apps), sent instead of `turnstile`. |
#### Response
@@ -278,6 +281,51 @@ All three are public and unavailable to API keys. A provider-verified identity c
Apple and Google both return a token pair (same shape as the login confirm success response), or a two-factor challenge when the account has TOTP enrolled.
### App Attest (native apps)
The iOS app proves it is a genuine copy of the app with Apple App Attest, and that proof stands in for the captcha on the captcha-gated calls. Browsers keep sending a Turnstile token. Both endpoints below are public, unavailable to API keys, and share their own per-IP budget (`APP_ATTEST_IP_RATE_LIMIT`, 300 per 15 minutes by default) separate from the one on the rest of `/auth`. When the instance has App Attest off they answer `404` with code [`app_attest_unavailable`](/api/error-codes/#app-attest-refusals). `GET /auth/config` reports whether it is on in `app_attest`.
| Endpoint | Body | Purpose |
|----------|------|---------|
| `POST /auth/app-attest/challenge` | none | Returns `{"challenge": "..."}`, a base64url string that is single-use and expires after five minutes. Every attestation and every assertion is made over a fresh one. |
| `POST /auth/app-attest/keys` | `key_id`, `attestation`, `challenge` (all required) | Registers the install's App Attest key, once per install. `key_id` is the base64 of the 32-byte key identifier and `attestation` the base64 object from `DCAppAttestService.attestKey`, made over a client data hash of SHA-256 of the UTF-8 bytes of `challenge`. Answers `204`. |
The server verifies the attestation's certificate chain against Apple's App Attest root, the nonce, the app id, a counter of 0, the environment (a development-signed build is refused unless the instance allows them) and the key id, then stores the key. A key id is registered once; a second registration of the same id is refused with `app_attest_failed`.
A captcha-gated call (`POST /auth/login`, `/auth/register`, `/auth/reset-password`, `/auth/reset-password/confirm`) then carries an `app_attest` object in place of `turnstile`:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `key_id` | string | yes | The registered key identifier, base64. |
| `assertion` | string | yes | The base64 assertion from `DCAppAttestService.generateAssertion`. |
| `challenge` | string | yes | A fresh challenge from `POST /auth/app-attest/challenge`. |
```json
{
"email": "alex@acme.com",
"password": "correct horse battery staple",
"app_attest": {
"key_id": "q1Zf3...=",
"assertion": "omlzaWduYXR1cmVYRzBF...",
"challenge": "b3Vy1Wq0..."
}
}
```
The assertion is made over a client data hash of SHA-256 of this UTF-8 string, with a newline between the parts:
```text
warmbly-app-attest/v1
<action>
<challenge>
```
`<action>` names the call: `login`, `register`, `reset_password` or `reset_password_confirm`. A proof made for one call does not verify on another. Each challenge is spent by the first call that presents it, and the assertion's counter must be higher than the last one the key presented. Each registered key also has its own budget of 30 protected calls per hour; past it the call answers `400` with the message `Too many attempts, please try again later.`, the same answer as the other sign-in attempt budgets.
A proof that does not verify answers [`app_attest_failed`](/api/error-codes/#app-attest-refusals). A key id this server has never registered answers `app_attest_key_unknown`: register the key again with a fresh challenge, then retry the call. When the deployment runs no captcha, neither `turnstile` nor `app_attest` is checked.
`GET /auth/config` carries `captcha` and `app_attest` together, and the app reads them as one decision: no captcha means nothing to send, a captcha with `app_attest` means the app sends a proof, and a captcha without `app_attest` means email and password sign-in is unavailable in the app. Sign in with Apple, Google and passkeys never need a captcha. The iOS simulator cannot use App Attest, so local development keeps using `TURNSTILE_BYPASS_TOKEN`, which is honoured only when `APP_ENV=dev`.
### Browser sign-in
A browser cannot authenticate with the provider itself, so it is sent there and comes back with an authorization code. Sign in with Google, Sign in with Apple and generic OpenID Connect are the same three steps against different providers, and which ones a deployment offers is the `providers` array in `GET /auth/config`.
@@ -82,6 +82,16 @@ Returns a `data` array of contacts plus a `pagination` envelope. Paginate by pas
"is_catch_all": false,
"mail_host": "gmail",
"esp_provider": "gmail",
"outreach": {
"sent": 3,
"opened": 2,
"clicked": 0,
"replied": 1,
"bounced": 0,
"last_sent_at": "2026-06-08T09:12:00Z",
"last_opened_at": "2026-06-08T10:40:00Z",
"last_replied_at": "2026-06-09T07:05:00Z"
},
"updated_at": "2026-06-10T12:00:00Z",
"created_at": "2026-05-01T09:30:00Z"
}
@@ -140,6 +150,10 @@ On the first page (no `cursor`), the response includes a `counts` object with or
}
```
### Outreach
Every contact carries `outreach`, its engagement across every campaign: how many emails reached it (`sent`), and how many of those it `opened`, `clicked`, `replied` to or `bounced`, with the time each last happened (`last_sent_at`, `last_opened_at`, `last_clicked_at`, `last_replied_at`, `last_bounced_at`, absent until the first one). These are the same counts the [contact 360](#get-a-contact) reports under `engagement`. Opens count a person only; a mail client fetching the message automatically is not counted.
## Create contacts
`POST /contacts`
@@ -148,6 +148,7 @@ TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12
| `AUTH_IP_RATE_LIMIT` | Unauthenticated auth requests allowed per source IP per 15 minutes | `60` | yes |
| `CLI_AUTH_IP_RATE_LIMIT` | CLI sign-in handshake requests allowed per source IP per 15 minutes. Its own budget, because one `warmbly auth login` polls around 200 times and must not exhaust the allowance above | `500` | yes |
| `PASSKEY_IP_RATE_LIMIT` | Passkey login challenges allowed per source IP per 15 minutes. Its own budget, because the sign-in page asks for a challenge on load rather than when someone acts, so opening that page must not spend the allowance above. Finishing the ceremony is an authentication attempt and counts against `AUTH_IP_RATE_LIMIT` | `300` | yes |
| `APP_ATTEST_IP_RATE_LIMIT` | Native app App Attest challenges and key registrations allowed per source IP per 15 minutes. Its own budget, because the app asks for a challenge before every protected call; the call the challenge is spent on counts against `AUTH_IP_RATE_LIMIT` | `300` | yes |
| `PUBLIC_IP_RATE_LIMIT` | Requests allowed per source IP per 15 minutes on the remaining public routes: unsubscribe, invitation preview, fleet enrolment and the analytics proxy. Each carries its authorization in a high-entropy token, so this bounds unmetered writes and relayed bandwidth rather than guessing. Counts reads as well as writes, unlike the auth limiter | `600` | yes |
| `WARMBLY_BOOTSTRAP_EMAIL` | First owner's address, read only while the users table is empty | unset | yes |
| `WARMBLY_BOOTSTRAP_PASSWORD_HASH` | Argon2id PHC string for that owner, as `warmblyctl hash-password` prints it. Preferred over the plaintext form. A malformed hash, or one whose cost parameters are below `m=65536,t=3,p=2` with a 16-byte salt and 32-byte key, is refused and no owner is created | unset | yes |
@@ -202,12 +203,16 @@ The proxy forwards only what PostHog needs to read the request. Cookies and `Aut
| `WARMBLY_CONFIG_OUT` | Where the dashboard and admin panel write their runtime `config.js`. Defaults to the path nginx serves; a static host sets it to the build output so the same script renders the file at build time instead of container start | the nginx path | yes (build or container start) |
| `WARMBLY_CSP_CONNECT_ORIGINS` | Additional dashboard CSP connection origins, separated by spaces. Realtime is discovered automatically from the backend; use this as an offline fallback or for other connection destinations. Use exact HTTP(S) or WS(S) URLs, not wildcard hosts or CSP directives. `VITE_CSP_CONNECT_ORIGINS` supplies the build-time/development fallback | unset | yes (build or container start) |
| `TURNSTILE_BYPASS_TOKEN` | A token that skips verification. Only honoured when `APP_ENV=dev` | unset | yes |
| `APP_ATTEST_APP_IDS` | Comma-separated `<team id>.<bundle id>` App IDs whose App Attest proofs the backend accepts in place of a captcha. Unset with no `APPLE_TEAM_ID`, App Attest is off | `APPLE_TEAM_ID` + `.` + `APPLE_IOS_BUNDLE_ID` when `APPLE_TEAM_ID` is set | yes |
| `APP_ATTEST_ALLOW_DEVELOPMENT` | Accept App Attest proofs from development-signed builds (an Xcode install on a device). Leave it off in production | `false` | yes |
| `TURNSTILE_SITE_KEY` | The public Turnstile widget key. The backend hands it to the forms service, which renders it on hosted form pages that enable spam protection. The frontends carry their own copy in `WARMBLY_TURNSTILE_KEY` | unset | no (read per request) |
`CAPTCHA_PROVIDER` has no constant default. Unset, it resolves to `turnstile` when `TURNSTILE_SECRET` holds a value, whether from the environment or from AWS Secrets Manager, and to `none` when it does not, so configuring the secret is what turns captcha on and clearing it is what turns captcha off. The panel reports the resolved value with source `derived`.
Setting `CAPTCHA_PROVIDER=turnstile` explicitly while `TURNSTILE_SECRET` is empty is the one combination that breaks: every verification fails, which means nobody can sign in. Set the secret or set the provider back to `none`.
The native iOS app never renders Turnstile. It proves it is a genuine copy of the app with Apple [App Attest](/api/reference/account-org/#app-attest-native-apps), which stands in for the captcha on sign-in, registration and password reset. With `CAPTCHA_PROVIDER=none` the app sends nothing. With Turnstile on, the app offers email and password sign-in only when `APP_ATTEST_APP_IDS` names its App ID; otherwise it offers Sign in with Apple, Google and passkeys, which never need a captcha. A self-hosted instance that runs Turnstile and wants the iOS app to sign in with a password sets `APP_ATTEST_APP_IDS` to the iOS app's App ID. Verification needs no Apple account and makes no outbound call: it checks against Apple's App Attest root certificate, which ships in the binary. Each install's key is stored in the `app_attest_keys` table, which belongs to the instance and is not part of a [workspace export](/guides/workspace-export-import/). The iOS simulator cannot use App Attest, so local development keeps using `TURNSTILE_BYPASS_TOKEN`.
## Platform mail
Platform mail is the product's own outbound: registration codes, password resets, team invitations, notification digests and login codes where those are enabled. It is separate from campaign mail, which leaves through the mailboxes you connect.
@@ -126,6 +126,10 @@ Mail whose warmup verification is temporarily unavailable waits in the `unibox_p
A [tracking comparison](/guides/placement-tests/#tracking) renders each seed's copy once and sends it from both halves, so while it runs it keeps that rendered copy, with merge fields and AI blocks filled in for the chosen contact, in the `placement_renders` Postgres table, sealed with the workspace's data key. The placement poller deletes it once neither half of the comparison is running. It is included in instance backups and excluded from workspace exports.
### Native app keys
The iOS app proves it is a genuine copy with [App Attest](/api/reference/account-org/#app-attest-native-apps), and each install registers one key in the `app_attest_keys` Postgres table: the key id, its public key, the app ID and environment it was attested under, its counter, Apple's receipt, and when it was created and last used. A row names no user and no workspace. It has no retention window, is included in instance backups, and does not travel in workspace exports, because it belongs to the instance the app verified against.
### Warmup reconnect recovery
`warmup_recovery_identifiers` holds SHA-256 fingerprints of a mailbox address and exact warmup message IDs or verification tokens, scoped to a workspace. They survive removing a mailbox so adding the same address again can recognize recent warmup mail without retaining its content or credentials. Each expires 90 days after its source record, expired identifiers are swept every minute in bounded batches, and deleting the workspace removes them immediately. They travel in the Warmup group of a [workspace export](/guides/workspace-export-import/).
+163 -4
View File
@@ -775,6 +775,133 @@
}
}
},
"/auth/app-attest/challenge": {
"post": {
"operationId": "auth_app_attest_challenge",
"summary": "Mint an App Attest challenge",
"description": "Returns a single-use challenge, valid for five minutes, for the native iOS app to make an App Attest attestation or assertion over. Has its own per-IP budget (APP_ATTEST_IP_RATE_LIMIT).",
"tags": [
"auth"
],
"security": [],
"responses": {
"200": {
"description": "A fresh challenge.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"challenge"
],
"properties": {
"challenge": {
"type": "string",
"description": "Base64url challenge. Single-use; expires after five minutes."
}
}
}
}
}
},
"404": {
"description": "App Attest is off on this instance (app_attest_unavailable).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"429": {
"description": "Rate limited.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/auth/app-attest/keys": {
"post": {
"operationId": "auth_app_attest_register_key",
"summary": "Register an App Attest key",
"description": "Registers the native iOS app install's App Attest key, once per install. The attestation must be made over a client data hash of SHA-256 of the UTF-8 bytes of the challenge. The server verifies the certificate chain against Apple's App Attest root, the nonce, the app id, a counter of 0, the environment and the key id. A key id can be registered once.",
"tags": [
"auth"
],
"security": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"key_id",
"attestation",
"challenge"
],
"properties": {
"key_id": {
"type": "string",
"description": "Base64 of the 32-byte App Attest key identifier."
},
"attestation": {
"type": "string",
"description": "Base64 attestation object from DCAppAttestService.attestKey."
},
"challenge": {
"type": "string",
"description": "A challenge from /auth/app-attest/challenge."
}
}
}
}
}
},
"responses": {
"204": {
"description": "Key registered."
},
"400": {
"description": "The attestation did not verify, or the key id is already registered (app_attest_failed).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "App Attest is off on this instance (app_attest_unavailable).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"429": {
"description": "Rate limited.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/auth/logout": {
"post": {
"operationId": "auth_logout",
@@ -27847,9 +27974,32 @@
}
}
},
"AppAttestProof": {
"type": "object",
"description": "The native iOS app's Apple App Attest proof, sent instead of a Turnstile token on the captcha-gated auth calls. The assertion is made over a client data hash of SHA-256 of the UTF-8 string \"warmbly-app-attest/v1\\n\" + action + \"\\n\" + challenge, where action is login, register, reset_password or reset_password_confirm.",
"required": [
"key_id",
"assertion",
"challenge"
],
"properties": {
"key_id": {
"type": "string",
"description": "The registered App Attest key identifier, base64."
},
"assertion": {
"type": "string",
"description": "The base64 assertion from DCAppAttestService.generateAssertion."
},
"challenge": {
"type": "string",
"description": "A fresh, single-use challenge from /auth/app-attest/challenge."
}
}
},
"AuthCredentials": {
"type": "object",
"description": "Email/password credentials with a Cloudflare Turnstile token. Used for login start and registration start.",
"description": "Email/password credentials with a Cloudflare Turnstile token or an App Attest proof. Used for login start and registration start.",
"required": [
"email",
"password"
@@ -27867,7 +28017,10 @@
},
"turnstile": {
"type": "string",
"description": "Cloudflare Turnstile challenge token."
"description": "Cloudflare Turnstile challenge token. Required when the deployment runs a captcha, unless app_attest is sent."
},
"app_attest": {
"$ref": "#/components/schemas/AppAttestProof"
}
}
},
@@ -28014,7 +28167,10 @@
},
"turnstile": {
"type": "string",
"description": "Cloudflare Turnstile challenge token."
"description": "Cloudflare Turnstile challenge token. Required when the deployment runs a captcha, unless app_attest is sent."
},
"app_attest": {
"$ref": "#/components/schemas/AppAttestProof"
}
}
},
@@ -28037,7 +28193,10 @@
},
"turnstile": {
"type": "string",
"description": "Cloudflare Turnstile challenge token."
"description": "Cloudflare Turnstile challenge token. Required when the deployment runs a captcha, unless app_attest is sent."
},
"app_attest": {
"$ref": "#/components/schemas/AppAttestProof"
}
}
},
+1 -1
View File
@@ -24,6 +24,7 @@ require (
github.com/emersion/go-message v0.18.1
github.com/emersion/go-msgauth v0.7.0
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/fxamacker/cbor/v2 v2.9.2
github.com/getsentry/sentry-go v0.36.2
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.10.1
@@ -97,7 +98,6 @@ require (
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
+49
View File
@@ -0,0 +1,49 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/warmbly/warmbly/internal/errx"
)
var errAppAttestOff = errx.NewWithIdentifier(errx.NotFound, "app_attest_unavailable", "This server does not accept app verification.")
// AppAttestChallenge mints the single-use challenge an attestation or assertion is made over.
func (h *Handler) AppAttestChallenge(c *gin.Context) {
if !h.AppAttestService.Enabled() {
errx.Handle(c, errAppAttestOff)
return
}
challenge, xerr := h.AppAttestService.Challenge(c.Request.Context())
if xerr != nil {
errx.Handle(c, xerr)
return
}
c.JSON(http.StatusOK, gin.H{"challenge": challenge})
}
type appAttestKeyRequest struct {
KeyID string `json:"key_id"`
Attestation string `json:"attestation"`
Challenge string `json:"challenge"`
}
// AppAttestRegisterKey stores an install's key once Apple's attestation of it verifies.
func (h *Handler) AppAttestRegisterKey(c *gin.Context) {
if !h.AppAttestService.Enabled() {
errx.Handle(c, errAppAttestOff)
return
}
var req appAttestKeyRequest
if err := c.ShouldBindJSON(&req); err != nil {
errx.Handle(c, errx.InvalidBody(err))
return
}
if xerr := h.AppAttestService.RegisterKey(c.Request.Context(), req.KeyID, req.Attestation, req.Challenge); xerr != nil {
errx.Handle(c, xerr)
return
}
c.Status(http.StatusNoContent)
}
+5
View File
@@ -19,6 +19,10 @@ type DeploymentAuthConfig struct {
// install cannot reach challenges.cloudflare.com.
Captcha bool `json:"captcha"`
// AppAttest reports whether the native app may prove itself with App Attest
// instead of the captcha. The app never renders Turnstile.
AppAttest bool `json:"app_attest"`
// PasswordLogin is false when the deployment authenticates only through
// OIDC or passkeys.
PasswordLogin bool `json:"password_login"`
@@ -132,6 +136,7 @@ func (h *Handler) AuthConfig(c *gin.Context) {
c.JSON(http.StatusOK, DeploymentAuthConfig{
Captcha: config.CaptchaProvider() != "none",
AppAttest: h.AppAttestService.Enabled(),
PasswordLogin: !policy.DisablePasswordLogin,
LoginCode: policy.LoginCode,
Registration: registration,
+3
View File
@@ -10,6 +10,7 @@ import (
"github.com/warmbly/warmbly/internal/app/aitools"
"github.com/warmbly/warmbly/internal/app/analytics"
"github.com/warmbly/warmbly/internal/app/apikey"
"github.com/warmbly/warmbly/internal/app/appattest"
"github.com/warmbly/warmbly/internal/app/appdirectory"
"github.com/warmbly/warmbly/internal/app/audit"
"github.com/warmbly/warmbly/internal/app/auth"
@@ -102,6 +103,8 @@ type Handler struct {
AuthService auth.AuthService
TokenService token.TokenService
PasskeyService passkey.Service
// AppAttestService registers native app keys. Nil when no app id is configured.
AppAttestService *appattest.Service
// Native-app social sign-in discovery (GET /auth/providers).
ExternalAuthProviders models.ExternalAuthProviders
+13
View File
@@ -42,6 +42,12 @@ const (
passkeyIPWindow = 15 * time.Minute
passkeyIPDefaultLimit = 300
// The native app's challenge and key registration are page furniture in the
// same way, so they get a budget of their own; the request a challenge is
// spent on stays on /auth.
appAttestIPWindow = 15 * time.Minute
appAttestIPDefaultLimit = 300
// The remaining public routes (unsubscribe, invitation preview, fleet
// enrolment, the analytics proxy) carry their authorization in a
// high-entropy token rather than a session, so this budget is not what
@@ -80,6 +86,13 @@ func (h *Handler) PasskeyChallengeIPRateLimitMiddleware() gin.HandlerFunc {
"Too many passkey sign-in requests from this address. Try again later.", false)
}
// AppAttestIPRateLimitMiddleware throttles the native app's challenge and key
// registration per source IP, on a key of its own.
func (h *Handler) AppAttestIPRateLimitMiddleware() gin.HandlerFunc {
return h.ipRateLimiter("app_attest_ip:", appAttestIPDefaultLimit, "APP_ATTEST_IP_RATE_LIMIT", appAttestIPWindow,
"Too many app verification requests from this address. Try again later.", false)
}
// AuthIPRateLimitMiddleware throttles the public /auth group per source IP.
//
// This is the only limiter those routes have: RateLimitMiddleware keys on the
+9
View File
@@ -361,6 +361,15 @@ func Run(
// has typed. It gets its own per-IP budget so opening that page repeatedly
// cannot spend the allowance password sign-in, registration and reset all
// draw on. Finishing the ceremony is an attempt and stays on /auth below.
// The native app's App Attest handshake: a single-use challenge, and the
// once-per-install key registration that Apple's attestation authorises.
appAttestPublic := v1.Group("/auth/app-attest")
appAttestPublic.Use(m.AppAttestIPRateLimitMiddleware())
{
appAttestPublic.POST("/challenge", h.AppAttestChallenge)
appAttestPublic.POST("/keys", h.AppAttestRegisterKey)
}
passkeyPublic := v1.Group("/auth/passkey")
passkeyPublic.Use(m.PasskeyChallengeIPRateLimitMiddleware())
{
+204
View File
@@ -0,0 +1,204 @@
// Package appattest is the native app's anti-automation check: a key attested
// by Apple once per install, then an assertion over a single-use challenge on
// every unauthenticated auth write, in place of the browser's Turnstile.
package appattest
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"slices"
"time"
"github.com/rs/zerolog/log"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/infrastructure/cache"
"github.com/warmbly/warmbly/internal/observability/errs"
attest "github.com/warmbly/warmbly/internal/pkg/appattest"
"github.com/warmbly/warmbly/internal/repository"
)
const (
challengeTTL = 5 * time.Minute
challengePrefix = "app_attest:challenge:"
// keyBudget bounds what one install can trigger, emails included.
keyBudget = 30
keyBudgetWindow = time.Hour
keyBudgetPrefix = "app_attest:key:"
clientDataTag = "warmbly-app-attest/v1"
)
// Actions name the request an assertion was made for, so one cannot be spent on another.
const (
ActionLogin = "login"
ActionRegister = "register"
ActionResetPassword = "reset_password"
ActionResetPasswordConfirm = "reset_password_confirm"
)
// Proof is what the app sends with a protected request.
type Proof struct {
KeyID string `json:"key_id"`
Assertion string `json:"assertion"`
Challenge string `json:"challenge"`
}
// Config is what this instance accepts.
type Config struct {
AppIDs []string
AllowDevelopment bool
}
type Service struct {
repo repository.AppAttestRepository
cache *cache.Cache
policy attest.Policy
now func() time.Time
}
// New returns nil when no app id is configured, which turns the check off.
func New(repo repository.AppAttestRepository, c *cache.Cache, cfg Config) *Service {
if repo == nil || c == nil || len(cfg.AppIDs) == 0 {
return nil
}
return &Service{
repo: repo,
cache: c,
policy: attest.Policy{AppIDs: cfg.AppIDs, AllowDevelopment: cfg.AllowDevelopment},
now: time.Now,
}
}
// Enabled reports whether native app proofs are accepted here.
func (s *Service) Enabled() bool { return s != nil }
// ClientData is the exact byte string the app asserts over.
func ClientData(action, challenge string) []byte {
return []byte(clientDataTag + "\n" + action + "\n" + challenge)
}
// Challenge mints a single-use challenge.
func (s *Service) Challenge(ctx context.Context) (string, *errx.Error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
errs.CaptureException(err)
return "", errx.InternalError()
}
challenge := base64.RawURLEncoding.EncodeToString(buf)
if err := s.cache.SetEx(ctx, challengePrefix+challenge, 1, challengeTTL).Err(); err != nil {
errs.CaptureException(err)
return "", errx.InternalError()
}
return challenge, nil
}
// consume deletes the challenge and reports whether this call was the one that did.
func (s *Service) consume(ctx context.Context, challenge string) (bool, *errx.Error) {
if challenge == "" || len(challenge) > 128 {
return false, nil
}
n, err := s.cache.Del(ctx, challengePrefix+challenge).Result()
if err != nil {
errs.CaptureException(err)
return false, errx.InternalError()
}
return n == 1, nil
}
// RegisterKey verifies a key attestation made over SHA-256 of the challenge and stores the key.
func (s *Service) RegisterKey(ctx context.Context, keyID, attestation, challenge string) *errx.Error {
ok, xerr := s.consume(ctx, challenge)
if xerr != nil {
return xerr
}
if !ok {
return errx.ErrAppAttest
}
rawKeyID, err := base64.StdEncoding.DecodeString(keyID)
if err != nil || len(rawKeyID) != sha256.Size {
return errx.ErrAppAttest
}
object, err := base64.StdEncoding.DecodeString(attestation)
if err != nil {
return errx.ErrAppAttest
}
clientDataHash := sha256.Sum256([]byte(challenge))
key, err := attest.VerifyAttestation(object, rawKeyID, clientDataHash[:], s.policy, s.now())
if err != nil {
log.Info().Err(err).Msg("app attest: attestation refused")
return errx.ErrAppAttest
}
inserted, err := s.repo.Insert(ctx, repository.AppAttestKey{
KeyID: base64.StdEncoding.EncodeToString(key.ID),
PublicKey: key.PublicKey,
AppID: key.AppID,
Environment: string(key.Environment),
Receipt: key.Receipt,
})
if err != nil {
errs.CaptureException(err)
return errx.InternalError()
}
// A key is attested once; a second attestation of the same id is a replay.
if !inserted {
return errx.ErrAppAttest
}
return nil
}
// Verify checks an assertion made for action over a fresh challenge.
func (s *Service) Verify(ctx context.Context, action string, proof *Proof) *errx.Error {
if proof == nil || proof.KeyID == "" || proof.Assertion == "" {
return errx.ErrAppAttest
}
ok, xerr := s.consume(ctx, proof.Challenge)
if xerr != nil {
return xerr
}
if !ok {
return errx.ErrAppAttest
}
key, err := s.repo.Get(ctx, proof.KeyID)
if err != nil {
errs.CaptureException(err)
return errx.InternalError()
}
if key == nil {
return errx.ErrAppAttestUnknown
}
// Fails open on a cache error, like every other auth budget.
if within, err := s.cache.ReserveAttempt(ctx, keyBudgetPrefix+key.KeyID, keyBudget, keyBudgetWindow); err != nil {
errs.CaptureException(err)
} else if !within {
return errx.ErrAuthLimit
}
object, err := base64.StdEncoding.DecodeString(proof.Assertion)
if err != nil {
return errx.ErrAppAttest
}
// A key outlives the policy it was attested under; it answers to the current one.
if (key.Environment == string(attest.Development) && !s.policy.AllowDevelopment) || !slices.Contains(s.policy.AppIDs, key.AppID) {
return errx.ErrAppAttest
}
counter, err := attest.VerifyAssertion(object, ClientData(action, proof.Challenge), key.PublicKey, key.AppID, key.Counter, s.policy.AllowDevelopment)
if err != nil {
if errors.Is(err, attest.ErrInvalid) {
log.Info().Err(err).Msg("app attest: assertion refused")
return errx.ErrAppAttest
}
errs.CaptureException(err)
return errx.InternalError()
}
advanced, err := s.repo.AdvanceCounter(ctx, key.KeyID, counter)
if err != nil {
errs.CaptureException(err)
return errx.InternalError()
}
if !advanced {
return errx.ErrAppAttest
}
return nil
}
+22
View File
@@ -0,0 +1,22 @@
package appattest
import (
"testing"
)
// The iOS app builds the same bytes; changing them breaks every shipped install.
func TestClientDataIsTheWireContract(t *testing.T) {
if got := string(ClientData(ActionLogin, "abc")); got != "warmbly-app-attest/v1\nlogin\nabc" {
t.Fatalf("client data = %q", got)
}
}
func TestOffWithoutAppIDs(t *testing.T) {
s := New(nil, nil, Config{})
if s.Enabled() {
t.Fatal("enabled with nothing configured")
}
if New(nil, nil, Config{AppIDs: []string{"TEAM.com.example"}}).Enabled() {
t.Fatal("enabled without storage")
}
}
+2 -1
View File
@@ -3,6 +3,7 @@ package auth
import (
"context"
"errors"
"github.com/warmbly/warmbly/internal/app/appattest"
"github.com/warmbly/warmbly/internal/app/authrisk"
"time"
@@ -24,7 +25,7 @@ func (s *authService) LoginStart(ctx context.Context, data *AuthData, ipaddr, us
// A failed challenge is the caller's 400, not an incident: reporting it
// filed an issue for every bot and every reloaded sign-in page.
if xerr := s.captcha.Verify(ctx, data.Turnstile, ipaddr); xerr != nil {
if xerr := s.verifyHuman(ctx, appattest.ActionLogin, data.Turnstile, data.AppAttest, ipaddr); xerr != nil {
return nil, xerr
}
+13 -6
View File
@@ -1,11 +1,16 @@
package auth
import "github.com/warmbly/warmbly/internal/models"
import (
"github.com/warmbly/warmbly/internal/app/appattest"
"github.com/warmbly/warmbly/internal/models"
)
type AuthData struct {
Email string `json:"email"`
Password string `json:"password"`
Turnstile string `json:"turnstile"`
// AppAttest is the native app's proof, sent instead of Turnstile.
AppAttest *appattest.Proof `json:"app_attest,omitempty"`
// ReferralCode is the optional ?ref= code the signup arrived with. Carried
// to RegistrationConfirm so the new org can be attributed to its referrer.
ReferralCode string `json:"referral_code"`
@@ -31,14 +36,16 @@ type ConfirmData struct {
}
type ResetPasswordStart struct {
Email string `json:"email"`
Turnstile string `json:"turnstile"`
Email string `json:"email"`
Turnstile string `json:"turnstile"`
AppAttest *appattest.Proof `json:"app_attest,omitempty"`
}
type ResetPasswordConfirm struct {
Session string `json:"session"`
Password string `json:"password"`
Turnstile string `json:"turnstile"`
Session string `json:"session"`
Password string `json:"password"`
Turnstile string `json:"turnstile"`
AppAttest *appattest.Proof `json:"app_attest,omitempty"`
}
type ChangePassword struct {
+2 -1
View File
@@ -5,6 +5,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/app/appattest"
"github.com/warmbly/warmbly/internal/app/token"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
@@ -29,7 +30,7 @@ func (s *authService) RegistrationStart(ctx context.Context, data *AuthData, ori
}
// The caller's 400, not an incident. See LoginStart.
if xerr := s.captcha.Verify(ctx, data.Turnstile, ipaddr); xerr != nil {
if xerr := s.verifyHuman(ctx, appattest.ActionRegister, data.Turnstile, data.AppAttest, ipaddr); xerr != nil {
return nil, xerr
}
+3 -2
View File
@@ -8,6 +8,7 @@ import (
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"github.com/warmbly/warmbly/internal/app/appattest"
tokenpkg "github.com/warmbly/warmbly/internal/app/token"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/errx"
@@ -20,7 +21,7 @@ import (
func (s *authService) ResetPasswordStart(ctx context.Context, data *ResetPasswordStart, ipaddr string) *errx.Error {
// The caller's 400, not an incident. See LoginStart.
if err := s.captcha.Verify(ctx, data.Turnstile, ipaddr); err != nil {
if err := s.verifyHuman(ctx, appattest.ActionResetPassword, data.Turnstile, data.AppAttest, ipaddr); err != nil {
return err
}
@@ -138,7 +139,7 @@ func (s *authService) sendResetEmailWithRetry(ctx context.Context, to, subject,
func (s *authService) ResetPasswordConfirm(ctx context.Context, data *ResetPasswordConfirm, session, ipaddr string) *errx.Error {
// The caller's 400, not an incident. See LoginStart.
if err := s.captcha.Verify(ctx, data.Turnstile, ipaddr); err != nil {
if err := s.verifyHuman(ctx, appattest.ActionResetPasswordConfirm, data.Turnstile, data.AppAttest, ipaddr); err != nil {
return err
}
+20 -3
View File
@@ -7,6 +7,7 @@ import (
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/app/appattest"
"github.com/warmbly/warmbly/internal/app/organization"
"github.com/warmbly/warmbly/internal/app/token"
"github.com/warmbly/warmbly/internal/app/trial"
@@ -78,6 +79,8 @@ type AuthService interface {
LoginConfirm(ctx context.Context, data *ConfirmData, session, ipaddr, userAgent string) (*models.LoginResult, *errx.Error)
// WireTwoFA attaches the 2FA challenger (post-construction; nil = 2FA off).
WireTwoFA(t TwoFAChallenger)
// WireAppAttest accepts native app proofs on the captcha-gated paths (nil = off).
WireAppAttest(a *appattest.Service)
RegistrationStart(ctx context.Context, data *AuthData, origin SignupOrigin) (*models.AuthSession, *errx.Error)
RegistrationConfirm(ctx context.Context, data *ConfirmData, session string, origin SignupOrigin) (*models.AuthSession, *errx.Error)
@@ -173,9 +176,11 @@ type authService struct {
organizationService organization.OrganizationService
emailNotificationService notify.EmailNotificationService
// opsNotify raises instance-wide operator alerts. Nil is the default.
opsNotify OperatorNotifier
cache *cache.Cache
captcha *captcha.Turnstile
opsNotify OperatorNotifier
cache *cache.Cache
captcha *captcha.Turnstile
// appAttest takes the native app's proof in place of Turnstile. Nil turns it off.
appAttest *appattest.Service
appleIDTokens IDTokenVerifier
googleIDTokens IDTokenVerifier
twofa TwoFAChallenger
@@ -207,6 +212,18 @@ type authService struct {
func (s *authService) WireTwoFA(t TwoFAChallenger) { s.twofa = t }
// WireAppAttest accepts native app proofs on the captcha-gated paths.
func (s *authService) WireAppAttest(a *appattest.Service) { s.appAttest = a }
// verifyHuman is the anti-automation gate on every unauthenticated auth write:
// Turnstile for a browser, an App Attest assertion for the native app.
func (s *authService) verifyHuman(ctx context.Context, action, turnstile string, proof *appattest.Proof, ipaddr string) *errx.Error {
if proof != nil && s.captcha.Required() && s.appAttest.Enabled() {
return s.appAttest.Verify(ctx, action, proof)
}
return s.captcha.Verify(ctx, turnstile, ipaddr)
}
func (s *authService) WireDeployment(policy *config.AuthPolicy, mailDelivers bool) {
if policy != nil {
s.policy = policy
+25
View File
@@ -0,0 +1,25 @@
package auth
import (
"context"
"testing"
"github.com/warmbly/warmbly/internal/app/appattest"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/pkg/captcha"
)
func TestVerifyHumanRouting(t *testing.T) {
proof := &appattest.Proof{KeyID: "k", Assertion: "a", Challenge: "c"}
off := &authService{captcha: captcha.NewTurnstileFromConfig(captcha.TurnstileConfig{Disabled: true})}
if err := off.verifyHuman(context.Background(), appattest.ActionLogin, "", proof, "203.0.113.1"); err != nil {
t.Fatalf("no captcha on the deployment, proof sent: %v", err)
}
// With App Attest off a proof is no substitute: the Turnstile check still runs.
on := &authService{captcha: captcha.NewTurnstileFromConfig(captcha.TurnstileConfig{Secret: "s"})}
if err := on.verifyHuman(context.Background(), appattest.ActionLogin, "", proof, "203.0.113.1"); err != errx.ErrCaptcha {
t.Fatalf("err = %v, want the captcha refusal", err)
}
}
+17 -1
View File
@@ -35,6 +35,12 @@ type AuthConfig struct {
TurnstileSecret string
TurnstileBypass string
// AppAttestAppIDs are the "<team id>.<bundle id>" values whose App Attest
// proofs stand in for the captcha. Empty turns native app proofs off.
AppAttestAppIDs []string
// AppAttestAllowDevelopment accepts proofs from development-signed builds.
AppAttestAllowDevelopment bool
// TwoFASecret is the server-wide key the TOTP secret is sealed with (AES-GCM).
// Must be set when 2FA is in use; falls back to AuthSecret when unset so a
// deployment without the env var still functions (sealed under the auth key).
@@ -99,6 +105,13 @@ func (c *Config) LoadAuthConfig(ctx context.Context) (*AuthConfig, error) {
SetTurnstileSecretResolved(turnstileSecret != "")
turnstileBypass := c.GetSecretOptional(ctx, "TURNSTILE_BYPASS_TOKEN", "turnstile/bypass_token", "")
// Defaults to the iOS app this instance's Apple team ships, when it names one.
appAttestAppIDs := splitCSV(c.GetStringOptional(ctx, "APP_ATTEST_APP_IDS", "app-attest/app_ids", ""))
if len(appAttestAppIDs) == 0 && appleTeamID != "" && appleIOSBundleID != "" {
appAttestAppIDs = []string{appleTeamID + "." + appleIOSBundleID}
}
appAttestAllowDev := c.GetBoolOptional(ctx, "APP_ATTEST_ALLOW_DEVELOPMENT", "app-attest/allow_development", false)
// 2FA sealing key. Optional: falls back to AUTH_SECRET so existing
// deployments keep working; rotating it invalidates enrolled TOTP secrets.
twoFASecret := c.GetSecretOptional(ctx, "TWOFA_SECRET", "twofa_secret", authSecret)
@@ -125,7 +138,10 @@ func (c *Config) LoadAuthConfig(ctx context.Context) (*AuthConfig, error) {
AuthSecret: authSecret,
TurnstileSecret: turnstileSecret,
TurnstileBypass: turnstileBypass,
TwoFASecret: twoFASecret,
AppAttestAppIDs: appAttestAppIDs,
AppAttestAllowDevelopment: appAttestAllowDev,
TwoFASecret: twoFASecret,
WebAuthnRPID: rpID,
WebAuthnRPDisplayName: rpDisplayName,
+3
View File
@@ -114,6 +114,9 @@ var (
// Captch
ErrCaptcha = New(BadRequest, "We couldn’t verify you’re human. Please try the security check again or reload the page.")
// The native app's proof in place of the captcha.
ErrAppAttest = NewWithIdentifier(BadRequest, "app_attest_failed", "We couldn’t verify this copy of the app. Update it from the App Store and try again.")
ErrAppAttestUnknown = NewWithIdentifier(BadRequest, "app_attest_key_unknown", "This device needs to verify the app again.")
// Group
ErrGroupTitle = New(BadRequest, "The title length must be between 1 and 50 characters.")
@@ -0,0 +1 @@
DROP TABLE IF EXISTS app_attest_keys;
@@ -0,0 +1,13 @@
-- App Attest keys the native app registered: one per install, verified against
-- Apple's root, then used to check the assertion on each unauthenticated auth
-- write. Not tied to a user or a workspace.
CREATE TABLE app_attest_keys (
key_id TEXT PRIMARY KEY,
public_key BYTEA NOT NULL,
app_id TEXT NOT NULL,
environment TEXT NOT NULL CHECK (environment IN ('production', 'development')),
counter BIGINT NOT NULL DEFAULT 0 CHECK (counter >= 0),
receipt BYTEA,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_used_at TIMESTAMPTZ
);
+21
View File
@@ -68,6 +68,10 @@ type Contact struct {
// leads are queued, in progress, replied, bounced, or unsubscribed.
CampaignLead *ContactCampaignProgress `json:"campaign_lead,omitempty"`
// Outreach is the contact's engagement across every campaign. Populated by
// Search on every row; nil elsewhere.
Outreach *ContactOutreach `json:"outreach,omitempty"`
// IsNew is set by the upsert write when this call inserted the row rather
// than matching an existing contact. Server-side only: it decides whether
// a contact.created event fires.
@@ -407,6 +411,23 @@ type ContactCategoryCount struct {
// ContactEngagement summarises every email touchpoint we have for a
// single contact. It's denormalised on read so the contact 360 view
// can render counts and "last X" timestamps in a single round-trip.
// ContactOutreach is where a contact stands with your outreach: the emails
// that reached them, what they did with them, and when each last happened.
// Opens count people only; a mail client's automatic fetch is not engagement.
type ContactOutreach struct {
Sent int `json:"sent"`
Opened int `json:"opened"`
Clicked int `json:"clicked"`
Replied int `json:"replied"`
Bounced int `json:"bounced"`
LastSentAt *time.Time `json:"last_sent_at,omitempty"`
LastOpenedAt *time.Time `json:"last_opened_at,omitempty"`
LastClickedAt *time.Time `json:"last_clicked_at,omitempty"`
LastRepliedAt *time.Time `json:"last_replied_at,omitempty"`
LastBouncedAt *time.Time `json:"last_bounced_at,omitempty"`
}
type ContactEngagement struct {
TotalSent int `json:"total_sent"`
TotalOpened int `json:"total_opened"`
+330
View File
@@ -0,0 +1,330 @@
// Package appattest verifies Apple App Attest attestations and assertions:
// proof that a request comes from a genuine, unmodified build of the native app
// on a real Apple device. It holds no state; callers store keys and counters.
package appattest
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/sha256"
"crypto/subtle"
"crypto/x509"
_ "embed"
"encoding/asn1"
"encoding/binary"
"errors"
"fmt"
"time"
"github.com/fxamacker/cbor/v2"
)
//go:embed apple_app_attestation_root_ca.pem
var appleRootPEM []byte
// Environment is the App Attest environment a key was created in.
type Environment string
const (
Production Environment = "production"
Development Environment = "development"
)
// ErrInvalid wraps every verification failure; the wrapped text is for logs only.
var ErrInvalid = errors.New("appattest: invalid")
var (
aaguidProduction = append([]byte("appattest"), make([]byte, 7)...)
aaguidDevelopment = []byte("appattestdevelop")
nonceExtensionOID = asn1.ObjectIdentifier{1, 2, 840, 113635, 100, 8, 2}
)
const (
flagAttestedData = 0x40
flagExtensions = 0x80
extValidationCategory = "apple_validation_category_01"
categoryTestFlight = 2
categoryDevelopment = 3
categoryAppStore = 4
)
// Policy is what a server accepts.
type Policy struct {
// AppIDs are the accepted "<team id>.<bundle id>" values.
AppIDs []string
// AllowDevelopment accepts keys from development-signed builds.
AllowDevelopment bool
// Roots overrides Apple's root; nil means Apple's. Tests only.
Roots *x509.CertPool
}
// Key is an attested key, ready to store.
type Key struct {
ID []byte
PublicKey []byte // uncompressed P-256 point
AppID string
Environment Environment
Receipt []byte
}
func invalid(format string, args ...any) error {
return fmt.Errorf("%w: %s", ErrInvalid, fmt.Sprintf(format, args...))
}
func appleRoots() (*x509.CertPool, error) {
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(appleRootPEM) {
return nil, errors.New("appattest: embedded root does not parse")
}
return pool, nil
}
type attestationObject struct {
Fmt string `cbor:"fmt"`
AttStmt struct {
X5C [][]byte `cbor:"x5c"`
Receipt []byte `cbor:"receipt"`
} `cbor:"attStmt"`
AuthData []byte `cbor:"authData"`
}
type authenticatorData struct {
rpIDHash []byte
flags byte
counter uint32
aaguid []byte
credentialID []byte
extensions map[string]any
}
// parseAuthenticatorData reads the fields; assertions set the attested-data
// flag without carrying the data, so only attestations parse it.
func parseAuthenticatorData(raw []byte, attested bool) (*authenticatorData, error) {
if len(raw) < 37 {
return nil, invalid("authenticator data is %d bytes", len(raw))
}
ad := &authenticatorData{rpIDHash: raw[:32], flags: raw[32], counter: binary.BigEndian.Uint32(raw[33:37])}
rest := raw[37:]
if attested {
if ad.flags&flagAttestedData == 0 {
return nil, invalid("attested credential data is missing")
}
if len(rest) < 18 {
return nil, invalid("attested credential data is truncated")
}
ad.aaguid = rest[:16]
n := int(binary.BigEndian.Uint16(rest[16:18]))
rest = rest[18:]
if len(rest) < n {
return nil, invalid("credential id is truncated")
}
ad.credentialID = rest[:n]
var coseKey cbor.RawMessage
var err error
if rest, err = cbor.UnmarshalFirst(rest[n:], &coseKey); err != nil {
return nil, invalid("credential public key: %v", err)
}
}
if ad.flags&flagExtensions != 0 {
var err error
if rest, err = cbor.UnmarshalFirst(rest, &ad.extensions); err != nil {
return nil, invalid("extensions: %v", err)
}
}
if len(rest) != 0 {
return nil, invalid("%d trailing bytes in authenticator data", len(rest))
}
return ad, nil
}
// checkCategory refuses a launch category the policy does not cover. Devices
// that report none (older systems) are judged on the rest of the checks.
func (ad *authenticatorData) checkCategory(allowDevelopment bool) error {
raw, ok := ad.extensions[extValidationCategory]
if !ok {
return nil
}
var category uint64
switch v := raw.(type) {
case uint64:
category = v
case int64:
if v < 0 {
return invalid("validation category %d", v)
}
category = uint64(v)
default:
return invalid("validation category has type %T", raw)
}
switch {
case category == categoryAppStore, category == categoryTestFlight:
return nil
case category == categoryDevelopment && allowDevelopment:
return nil
}
return invalid("validation category %d is not accepted", category)
}
func matchAppID(rpIDHash []byte, appIDs []string) (string, bool) {
for _, id := range appIDs {
sum := sha256.Sum256([]byte(id))
if subtle.ConstantTimeCompare(sum[:], rpIDHash) == 1 {
return id, true
}
}
return "", false
}
// VerifyAttestation checks a key attestation made over clientDataHash and
// returns the key to store. keyID is the raw 32-byte key identifier.
func VerifyAttestation(object, keyID, clientDataHash []byte, policy Policy, now time.Time) (*Key, error) {
var att attestationObject
if err := cbor.Unmarshal(object, &att); err != nil {
return nil, invalid("attestation object: %v", err)
}
if att.Fmt != "apple-appattest" {
return nil, invalid("format %q", att.Fmt)
}
if len(att.AttStmt.X5C) < 2 {
return nil, invalid("certificate chain has %d entries", len(att.AttStmt.X5C))
}
certs := make([]*x509.Certificate, 0, len(att.AttStmt.X5C))
for _, der := range att.AttStmt.X5C {
cert, err := x509.ParseCertificate(der)
if err != nil {
return nil, invalid("certificate: %v", err)
}
certs = append(certs, cert)
}
roots := policy.Roots
if roots == nil {
var err error
if roots, err = appleRoots(); err != nil {
return nil, err
}
}
intermediates := x509.NewCertPool()
for _, cert := range certs[1:] {
intermediates.AddCert(cert)
}
credCert := certs[0]
if _, err := credCert.Verify(x509.VerifyOptions{
Roots: roots,
Intermediates: intermediates,
CurrentTime: now,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
}); err != nil {
return nil, invalid("certificate chain: %v", err)
}
nonce := sha256.Sum256(append(append([]byte{}, att.AuthData...), clientDataHash...))
var certNonce []byte
for _, ext := range credCert.Extensions {
if !ext.Id.Equal(nonceExtensionOID) {
continue
}
var wrapped struct {
Nonce []byte `asn1:"tag:1,explicit"`
}
if _, err := asn1.Unmarshal(ext.Value, &wrapped); err != nil {
return nil, invalid("nonce extension: %v", err)
}
certNonce = wrapped.Nonce
}
if subtle.ConstantTimeCompare(certNonce, nonce[:]) != 1 {
return nil, invalid("nonce does not match the challenge")
}
pub, ok := credCert.PublicKey.(*ecdsa.PublicKey)
if !ok || pub.Curve != elliptic.P256() {
return nil, invalid("credential key is not P-256")
}
ecdhKey, err := pub.ECDH()
if err != nil {
return nil, invalid("credential key: %v", err)
}
point := ecdhKey.Bytes()
pointHash := sha256.Sum256(point)
if subtle.ConstantTimeCompare(pointHash[:], keyID) != 1 {
return nil, invalid("key id does not match the credential key")
}
ad, err := parseAuthenticatorData(att.AuthData, true)
if err != nil {
return nil, err
}
appID, ok := matchAppID(ad.rpIDHash, policy.AppIDs)
if !ok {
return nil, invalid("app id is not accepted")
}
if ad.counter != 0 {
return nil, invalid("counter is %d", ad.counter)
}
var env Environment
switch {
case bytes.Equal(ad.aaguid, aaguidProduction):
env = Production
case bytes.Equal(ad.aaguid, aaguidDevelopment) && policy.AllowDevelopment:
env = Development
default:
return nil, invalid("environment is not accepted")
}
if !bytes.Equal(ad.credentialID, keyID) {
return nil, invalid("credential id does not match the key id")
}
if err := ad.checkCategory(policy.AllowDevelopment); err != nil {
return nil, err
}
return &Key{
ID: append([]byte{}, keyID...),
PublicKey: point,
AppID: appID,
Environment: env,
Receipt: att.AttStmt.Receipt,
}, nil
}
type assertionObject struct {
Signature []byte `cbor:"signature"`
AuthenticatorData []byte `cbor:"authenticatorData"`
}
// VerifyAssertion checks an assertion over clientData made with a stored key
// and returns its counter, which must be stored for the next one.
func VerifyAssertion(object, clientData, publicKey []byte, appID string, prevCounter uint32, allowDevelopment bool) (uint32, error) {
var as assertionObject
if err := cbor.Unmarshal(object, &as); err != nil {
return 0, invalid("assertion object: %v", err)
}
pub, err := ecdsa.ParseUncompressedPublicKey(elliptic.P256(), publicKey)
if err != nil {
return 0, invalid("stored key: %v", err)
}
clientDataHash := sha256.Sum256(clientData)
nonce := sha256.Sum256(append(append([]byte{}, as.AuthenticatorData...), clientDataHash[:]...))
// The device signs nonce as the message, so ES256 hashes it once more.
digest := sha256.Sum256(nonce[:])
if !ecdsa.VerifyASN1(pub, digest[:], as.Signature) {
return 0, invalid("signature does not verify")
}
ad, err := parseAuthenticatorData(as.AuthenticatorData, false)
if err != nil {
return 0, err
}
if _, ok := matchAppID(ad.rpIDHash, []string{appID}); !ok {
return 0, invalid("app id does not match the key")
}
if ad.counter <= prevCounter {
return 0, invalid("counter %d did not advance past %d", ad.counter, prevCounter)
}
if err := ad.checkCategory(allowDevelopment); err != nil {
return 0, err
}
return ad.counter, nil
}
+147
View File
@@ -0,0 +1,147 @@
package appattest
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"os"
"testing"
"time"
)
// Vectors produced by a real device, from github.com/bas-d/appattest
// (BSD-3-Clause, Copyright (c) 2019, Duo Labs).
type deviceVectors struct {
AppID string `json:"app_id"`
KeyID string `json:"key_id"`
Attestation string `json:"attestation"`
AttestationClientData string `json:"attestation_client_data"`
AttestedAt string `json:"attested_at"`
PublicKey string `json:"public_key"`
Assertion string `json:"assertion"`
AssertionClientData string `json:"assertion_client_data"`
}
func loadVectors(t *testing.T) (deviceVectors, []byte, []byte, []byte, []byte, time.Time) {
t.Helper()
raw, err := os.ReadFile("testdata/device_vectors.json")
if err != nil {
t.Fatal(err)
}
var v deviceVectors
if err := json.Unmarshal(raw, &v); err != nil {
t.Fatal(err)
}
keyID, err := base64.StdEncoding.DecodeString(v.KeyID)
if err != nil {
t.Fatal(err)
}
att, err := base64.RawURLEncoding.DecodeString(v.Attestation)
if err != nil {
t.Fatal(err)
}
pub, err := hex.DecodeString(v.PublicKey)
if err != nil {
t.Fatal(err)
}
as, err := base64.RawURLEncoding.DecodeString(v.Assertion)
if err != nil {
t.Fatal(err)
}
at, err := time.Parse(time.RFC3339, v.AttestedAt)
if err != nil {
t.Fatal(err)
}
return v, keyID, att, pub, as, at
}
func TestVerifyAttestation(t *testing.T) {
v, keyID, att, pub, _, at := loadVectors(t)
cdh := sha256.Sum256([]byte(v.AttestationClientData))
dev := Policy{AppIDs: []string{"OTHERTEAM.com.example", v.AppID}, AllowDevelopment: true}
key, err := VerifyAttestation(att, keyID, cdh[:], dev, at)
if err != nil {
t.Fatalf("device attestation refused: %v", err)
}
if hex.EncodeToString(key.PublicKey) != hex.EncodeToString(pub) {
t.Fatalf("public key = %x", key.PublicKey)
}
if key.AppID != v.AppID || key.Environment != Development {
t.Fatalf("app id %q, environment %q", key.AppID, key.Environment)
}
otherHash := sha256.Sum256([]byte("another-challenge"))
otherKey := append([]byte{}, keyID...)
otherKey[0] ^= 1
cases := map[string]struct {
keyID, cdh []byte
policy Policy
now time.Time
}{
"development refused": {keyID, cdh[:], Policy{AppIDs: []string{v.AppID}}, at},
"another app": {keyID, cdh[:], Policy{AppIDs: []string{"OTHERTEAM.com.example"}, AllowDevelopment: true}, at},
"another challenge": {keyID, otherHash[:], dev, at},
"another key id": {otherKey, cdh[:], dev, at},
"expired credential": {keyID, cdh[:], dev, at.AddDate(0, 1, 0)},
"no accepted app ids": {keyID, cdh[:], Policy{AllowDevelopment: true}, at},
"truncated attestation": {keyID, cdh[:], dev, at},
}
for name, tc := range cases {
object := att
if name == "truncated attestation" {
object = att[:len(att)/2]
}
if _, err := VerifyAttestation(object, tc.keyID, tc.cdh, tc.policy, tc.now); !errors.Is(err, ErrInvalid) {
t.Errorf("%s: err = %v, want ErrInvalid", name, err)
}
}
}
func TestVerifyAssertion(t *testing.T) {
v, _, _, pub, as, _ := loadVectors(t)
data := []byte(v.AssertionClientData)
counter, err := VerifyAssertion(as, data, pub, v.AppID, 0, true)
if err != nil {
t.Fatalf("device assertion refused: %v", err)
}
if counter == 0 {
t.Fatal("counter did not advance")
}
if _, err := VerifyAssertion(as, data, pub, v.AppID, counter, true); !errors.Is(err, ErrInvalid) {
t.Errorf("replayed counter: err = %v", err)
}
if _, err := VerifyAssertion(as, []byte(`{"challenge":"other"}`), pub, v.AppID, 0, true); !errors.Is(err, ErrInvalid) {
t.Errorf("other client data: err = %v", err)
}
if _, err := VerifyAssertion(as, data, pub, "OTHERTEAM.com.example", 0, true); !errors.Is(err, ErrInvalid) {
t.Errorf("other app id: err = %v", err)
}
tampered := append([]byte{}, as...)
tampered[len(tampered)-1] ^= 1
if _, err := VerifyAssertion(tampered, data, pub, v.AppID, 0, true); !errors.Is(err, ErrInvalid) {
t.Errorf("tampered assertion: err = %v", err)
}
}
func TestValidationCategory(t *testing.T) {
ad := &authenticatorData{extensions: map[string]any{}}
for category, want := range map[uint64]bool{2: true, 3: false, 4: true, 5: false, 10: false, 0: false} {
ad.extensions[extValidationCategory] = category
if got := ad.checkCategory(false) == nil; got != want {
t.Errorf("category %d accepted = %v", category, got)
}
}
ad.extensions[extValidationCategory] = uint64(3)
if err := ad.checkCategory(true); err != nil {
t.Errorf("development build refused with development allowed: %v", err)
}
delete(ad.extensions, extValidationCategory)
if err := ad.checkCategory(false); err != nil {
t.Errorf("no category reported: %v", err)
}
}
@@ -0,0 +1,14 @@
-----BEGIN CERTIFICATE-----
MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw
JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK
QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa
Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv
biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y
bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh
NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au
Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/
MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw
CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn
53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV
oyFraWVIyd/dganmrduC1bmTBGwD
-----END CERTIFICATE-----
File diff suppressed because one or more lines are too long
+3
View File
@@ -60,6 +60,9 @@ func NewTurnstileFromConfig(cfg TurnstileConfig) *Turnstile {
return &Turnstile{cfg: cfg}
}
// Required reports whether this deployment checks for a human at all.
func (t *Turnstile) Required() bool { return t != nil && !t.cfg.Disabled }
func (t *Turnstile) Verify(ctx context.Context, token, remoteIP string) *errx.Error {
if t.cfg.Disabled {
return nil
+83
View File
@@ -0,0 +1,83 @@
package repository
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// AppAttestKey is a native-app key that passed Apple's attestation.
type AppAttestKey struct {
KeyID string
PublicKey []byte
AppID string
Environment string
Counter uint32
Receipt []byte
CreatedAt time.Time
LastUsedAt *time.Time
}
// AppAttestRepository stores attested App Attest keys and their counters.
type AppAttestRepository interface {
// Insert stores a newly attested key and reports false when the id is taken.
Insert(ctx context.Context, key AppAttestKey) (bool, error)
// Get returns the key, or nil when it was never registered.
Get(ctx context.Context, keyID string) (*AppAttestKey, error)
// AdvanceCounter stores counter only when it is above the stored one, so two
// requests carrying the same assertion cannot both pass.
AdvanceCounter(ctx context.Context, keyID string, counter uint32) (bool, error)
}
type appAttestRepository struct {
db *pgxpool.Pool
}
func NewAppAttestRepository(db *pgxpool.Pool) AppAttestRepository {
return &appAttestRepository{db: db}
}
func (r *appAttestRepository) Insert(ctx context.Context, key AppAttestKey) (bool, error) {
tag, err := r.db.Exec(ctx, `
INSERT INTO app_attest_keys (key_id, public_key, app_id, environment, counter, receipt)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (key_id) DO NOTHING`,
key.KeyID, key.PublicKey, key.AppID, key.Environment, int64(key.Counter), key.Receipt)
if err != nil {
return false, err
}
return tag.RowsAffected() == 1, nil
}
func (r *appAttestRepository) Get(ctx context.Context, keyID string) (*AppAttestKey, error) {
var key AppAttestKey
var counter int64
err := r.db.QueryRow(ctx, `
SELECT key_id, public_key, app_id, environment, counter, receipt, created_at, last_used_at
FROM app_attest_keys
WHERE key_id = $1`, keyID).
Scan(&key.KeyID, &key.PublicKey, &key.AppID, &key.Environment, &counter, &key.Receipt, &key.CreatedAt, &key.LastUsedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
key.Counter = uint32(counter)
return &key, nil
}
func (r *appAttestRepository) AdvanceCounter(ctx context.Context, keyID string, counter uint32) (bool, error) {
tag, err := r.db.Exec(ctx, `
UPDATE app_attest_keys
SET counter = $2, last_used_at = now()
WHERE key_id = $1 AND counter < $2`,
keyID, int64(counter))
if err != nil {
return false, err
}
return tag.RowsAffected() == 1, nil
}
+31 -1
View File
@@ -1522,6 +1522,26 @@ type leadRowJSON struct {
Hold *models.LeadHold `json:"hold"`
}
// contactOutreachSelect is a search row's engagement across every campaign,
// the same counts and times the contact 360 reports. One indexed aggregate per
// returned row, so it costs a page, not the organization.
var contactOutreachSelect = `(
SELECT json_build_object(
'sent', COUNT(*) FILTER (WHERE a.sent_at IS NOT NULL AND ` + progressIsEmailStep("a") + `),
'opened', COUNT(*) FILTER (WHERE a.opened_at IS NOT NULL AND NOT a.opened_machine),
'clicked', COUNT(*) FILTER (WHERE a.clicked_at IS NOT NULL),
'replied', COUNT(*) FILTER (WHERE a.replied_at IS NOT NULL),
'bounced', COUNT(*) FILTER (WHERE a.bounced_at IS NOT NULL),
'last_sent_at', MAX(a.sent_at) FILTER (WHERE ` + progressIsEmailStep("a") + `),
'last_opened_at', MAX(a.opened_at) FILTER (WHERE NOT a.opened_machine),
'last_clicked_at', MAX(a.clicked_at),
'last_replied_at', MAX(a.replied_at),
'last_bounced_at', MAX(a.bounced_at)
)
FROM campaign_contact_progress a
WHERE a.contact_id = c.id
)`
func (r *contactRepository) Search(
ctx context.Context,
orgID string,
@@ -1748,6 +1768,7 @@ func (r *contactRepository) Search(
), '[]'::json
) AS categories,
%s AS lead_progress,
`+contactOutreachSelect+` AS outreach,
%s AS sort_value
FROM contacts c
%s
@@ -1806,6 +1827,7 @@ func (r *contactRepository) Search(
var campaignsJSON []byte
var categoriesJSON []byte
var leadProgressJSON []byte
var outreachJSON []byte
var sortValue *string
if err := rows.Scan(
@@ -1815,7 +1837,7 @@ func (r *contactRepository) Search(
&c.VerificationStatus, &c.VerificationReason, &c.IsCatchAll, &c.VerificationCheckedAt,
&c.VerificationSource, &c.VerificationProvider, &c.VerificationSubStatus, &c.VerificationConfidence,
&c.VerificationRequestedAt, &c.MailHost, &c.ESPProvider,
&campaignsJSON, &categoriesJSON, &leadProgressJSON,
&campaignsJSON, &categoriesJSON, &leadProgressJSON, &outreachJSON,
&sortValue,
); err != nil {
db.CaptureError(err, "", nil, "scan")
@@ -1907,6 +1929,14 @@ func (r *contactRepository) Search(
}
}
c.Outreach = &models.ContactOutreach{}
if len(outreachJSON) > 0 {
if err := json.Unmarshal(outreachJSON, c.Outreach); err != nil {
errs.CaptureException(err)
return nil, errx.InternalError()
}
}
if len(campaignsJSON) > 0 {
var campaigns []struct {
ID string `json:"id"`
+39 -11
View File
@@ -34,14 +34,17 @@ Local service map:
2. Pick the **Warmbly** scheme and an iOS 26 simulator.
3. Run (Cmd-R).
On a simulator the defaults already point at `http://localhost:8080`, and `Info.plist` allows local (non-HTTPS) networking via `NSAllowsLocalNetworking`, so no ATS changes are needed.
Every build starts on Warmbly Cloud, so open the gear on the sign-in screen once and pick **Local development**; the choice persists. `Info.plist` allows local (non-HTTPS) networking via `NSAllowsLocalNetworking`, so no ATS changes are needed.
## First run
The app opens on an animated welcome screen; **Get started** begins account creation, **I already have an account** goes to sign-in. The gear button (top-right on the welcome screen, and on the email step's card) opens **Connection settings**:
The app opens on an animated welcome screen; **Get started** begins account creation, **I already have an account** goes to sign-in. The gear button (top-right on sign-in) opens the **Server** sheet, which offers three choices:
- **Server origin** — bare origin, no path. Default `http://localhost:8080`. The app appends `/v1` itself.
- **Turnstile token** — pre-filled with the dev bypass token `warmbly-local-turnstile-bypass`. The dev backend accepts it so you don't need a real captcha widget. (Production is captcha-gated and needs a real token.)
- **Warmbly Cloud**: the default, `https://api.warmbly.com`.
- **Self-hosted**: any origin you type, bare with no path. The app appends `/v1` itself and adds `https://` when the scheme is missing (`http://` for localhost and LAN addresses).
- **Local development** (debug builds): `http://localhost:8080`, with the dev bypass token `warmbly-local-turnstile-bypass`, because the simulator can't use App Attest.
Under the choices, the sheet reads the server's public `GET /v1/auth/config` and reports whether it answers. A self-hosted server that asks for a captcha but takes no App Attest proof (`app_attest` false) is marked "Email sign-in off": the app never renders Turnstile, so email and password sign-in there needs `APP_ATTEST_APP_IDS` on the server, or a dev backend's bypass token under **Advanced**. Apple, Google and passkey sign-in work regardless.
Then sign in with a seeded account:
@@ -70,11 +73,10 @@ First-time social sign-ins auto-provision the user, workspace, and trial server-
## Pointing at another host (staging, self-hosted)
The server is an in-app runtime setting, not a build-time constant, so one binary serves everyone: the hosted SaaS, a self-hosted instance, or a local stack. Users switch hosts from the login screen's Connection settings; nobody rebuilds the app to reach their own infrastructure.
The server is an in-app runtime setting, not a build-time constant, so one binary serves everyone: the hosted SaaS, a self-hosted instance, or a local stack. Users switch hosts from the Server sheet on the sign-in screen; nobody rebuilds the app to reach their own infrastructure.
- Shipped (Release) builds default the origin to `https://api.warmbly.com`, so ordinary customers just log in and never open the setting.
- Debug builds default to `http://localhost:8080` for the native dev stack.
- Enter any other origin (a staging box, a self-hosted instance) in Connection settings; it persists in `UserDefaults`. "Reset to default" returns to the build's default.
- Every build defaults the origin to Warmbly Cloud (`https://api.warmbly.com`), so ordinary customers just log in and never open the setting.
- Pick **Local development** for the native dev stack, or **Self-hosted** for any other origin (a staging box, a self-hosted instance); the choice persists in `UserDefaults`. Picking Warmbly Cloud again clears the bypass token and realtime override.
- App Transport Security: a remote/custom host must be `https://`. Plain `http://` is allowed only to localhost and LAN/reserved addresses (dev and on-device testing). See the [README caveat](README.md).
## Running on a physical device
@@ -99,12 +101,37 @@ Because there is no iOS SDK on CI-less machines, the fast local signal for Swift
cd ios && find Warmbly -name '*.swift' -print0 | xargs -0 swiftc -parse
```
A real typecheck happens when you build in Xcode. Conventions to keep the code consistent:
A real typecheck happens when you build in Xcode.
### Scripted simulator runs
Debug builds read launch environment variables so a script can open the app signed in on a given tab without tapping through sign-in (both are compiled out of Release builds):
- `WARMBLY_DEBUG_TOKEN`: the JSON body returned by `POST /v1/auth/login/confirm`. It is written to the Keychain on launch, and the push permission prompt is skipped.
- `WARMBLY_DEBUG_TAB`: `home`, `inbox`, `campaigns`, `contacts` or `more`.
- `WARMBLY_DEBUG_HOME`: the Home hero style to render, `immersive` (the default), `card` or `fade`.
- `WARMBLY_DEBUG_SCROLL`: opens Home, the settings hub, a settings page or Analytics scrolled to the `center` or `bottom`, for screenshots below the hero.
- `WARMBLY_DEBUG_THREAD`: with `WARMBLY_DEBUG_TAB=inbox`, `1` opens the newest conversation once the list loads `end` also scrolls it to the bottom, where an inbox agent's drafted reply sits, and `edit` opens that draft in the reply composer.
- `WARMBLY_DEBUG_DRAWER`: `1` opens the drawer on launch (Inbox, Campaigns or Contacts, whichever tab is open, or Deals, Tasks and Meetings when one is opened from settings).
- `WARMBLY_DEBUG_CAMPAIGN`: with `WARMBLY_DEBUG_TAB=campaigns`, opens the campaign whose name contains the value (the first campaign when nothing matches).
- `WARMBLY_DEBUG_CONTACT`: with `WARMBLY_DEBUG_TAB=contacts`, opens the contact whose name or address contains the value. `WARMBLY_DEBUG_SORT` sets the contacts sort by its API name (`first_name`, `created_at`, ...).
- `WARMBLY_DEBUG_SETTINGS`: with `WARMBLY_DEBUG_TAB=more`, opens a settings page (`profile`, `notifications`, `security`, `billing`, `team`, `templates` or `activity`) or a full-screen browser (`analytics`, `mailboxes`, `deals`, `tasks` or `meetings`). `WARMBLY_DEBUG_NOTIFICATIONS=preferences` opens the notifications page on its preferences. `WARMBLY_DEBUG_ANALYTICS` picks the analytics page (`overview`, `deliverability`, `warmup` or `accounts`), and `WARMBLY_DEBUG_ANALYTICS_ACCOUNT=1` then opens a mailbox's health. `WARMBLY_DEBUG_TEMPLATE=1` opens the first template and `WARMBLY_DEBUG_AUDIT_ENTRY=1` the newest activity entry. With `mailboxes`, `WARMBLY_DEBUG_CONNECT` opens the add-a-mailbox flow on a step (`provider`, `google`, `twostep`, `apppassword`, `appconnect`, `account`, `imap`, `smtp`, `grants`, `users`, `vendors`, `vendorkey`, `vendormailboxes`, `file`, `filereview` or `running`); every step past `provider` uses sample data, including an admin grant, a vendor catalog and a file preview, and `home` opens the first step with that data.
- `WARMBLY_DEBUG_PHASE`: opens a screen from before the app, leaving the stored session alone: `launching` holds the splash, `signedout` shows sign-in, `onboarding` the first-run questions and `workspace` the create-a-workspace step. With `signedout`, `WARMBLY_DEBUG_AUTH` opens a later sign-in step (`signup`, `password`, `verify`, `twofa` or `reset`), or `server` to open the Server sheet; with `onboarding`, `WARMBLY_DEBUG_ONBOARDING` opens `referral`, `role` or `team`.
- `WARMBLY_DEBUG_ASSISTANT`: opens Remie on launch. `1` opens it empty, any other text is sent as the first message, and `demo` or `demo-approval` plays a scripted run (tool steps, a streamed answer and, for the second, an approval card) without calling the AI provider, which the local stack does not configure.
`simctl` forwards variables prefixed with `SIMCTL_CHILD_`:
```
SIMCTL_CHILD_WARMBLY_DEBUG_TOKEN="$(cat token.json)" SIMCTL_CHILD_WARMBLY_DEBUG_TAB=inbox \
xcrun simctl launch booted com.warmbly.app
xcrun simctl io booted screenshot inbox.png
``` Conventions to keep the code consistent:
- New Codable models declare explicit `CodingKeys` (the decoder does not convert snake_case) and copy the Go json tags exactly. Prefer optional fields so a missing key never fails a whole screen.
- Reach the app graph with `@Environment(AppEnvironment.self) private var env` → `env.api` (the `/v1` client), `env.session`, `env.realtime`, `env.badges`.
- Every list: load with `.task`, reload on the matching `env.realtime.pulse(for:)`, and support `.refreshable`. Gate mutating actions with `env.session.can(.somePermission)`.
- Copy is sentence case, terse, and avoids em dashes.
- Style with the `WTheme` tokens and `DesignSystem/` primitives, never raw `.white`/`.black` or system grouped backgrounds: `WTheme.canvas`/`raised`/`fill` surfaces, `hairline`/`hairlineStrong` borders, `ink`/`inkSecondary`/`inkTertiary` text, `InkButtonStyle` for the primary action, `WFont.meta` for meta (`WFont.code` only for real code), sentence case everywhere (no uppercased labels). List tabs use `GlassHeaderBar` and drawers `DrawerWorkspaceHeader`. Settings pages sit on `WTheme.groupedPage` with `groupedCard` cards (`SettingsPage`, `SettingsSection`, and `coverCard` from `SettingsCoverKit.swift`); a list that needs swipe actions is an `.insetGrouped` `List` with `CoverSectionHeader` headers instead. Sky (`WTheme.accent`) is for data and live state, not chrome.
## Troubleshooting
@@ -116,8 +143,9 @@ A real typecheck happens when you build in Xcode. Conventions to keep the code c
| Stuck on the workspace picker | Your account has 0 or several orgs. Create one or pick one; a fresh login always starts with no org selected and the app switches you in. |
| Lists never update live | Realtime not connected. See [Verifying realtime works](#verifying-realtime-works). |
| A screen is empty for a teammate account | Permission-gated. Some data (e.g. campaign analytics) is owner-only server-side; the app surfaces this rather than erroring. |
| Captcha error on login | The bypass token was cleared. Reset it to `warmbly-local-turnstile-bypass` in Connection settings. |
| Captcha error on login | The server verifies a captcha and got neither an App Attest proof nor a bypass token. In the simulator against a dev backend, set its `TURNSTILE_BYPASS_TOKEN` under Advanced; on a device, the server needs `APP_ATTEST_APP_IDS` naming the app (and `APP_ATTEST_ALLOW_DEVELOPMENT=true` for an Xcode build); a self-host can also run with `CAPTCHA_PROVIDER=none`. |
| "Couldn't verify this copy of the app" | The server refused the App Attest proof (`app_attest_failed`). A development-signed build needs `APP_ATTEST_ALLOW_DEVELOPMENT=true`, and `APP_ATTEST_APP_IDS` must name `<team id>.com.warmbly.app` for the team that signed it. |
## What stays on the web
By design, a few flows link out instead of being reimplemented on mobile: mailbox OAuth connect (Gmail/Outlook), campaign sequence editing, and plan changes/billing management. SMTP/IMAP mailbox connect does work natively. See the [README](README.md#known-limits) for the full list.
By design, a few flows link out instead of being reimplemented on mobile: setting up a new admin grant, campaign sequence editing, and plan changes/billing management. Mailbox connect works natively with app passwords, existing admin grants (connecting a grant's mailboxes asks for a recent sign-in through `POST /v1/auth/reauth`), inbox vendor keys, file and pasted imports (`POST /emails/imports/preview`, then `POST /emails/imports`) and Warmbly Cloud workspace mailboxes; per-mailbox Google or Microsoft sign-in is not offered. See the [README](README.md#known-limits) for the full list.
+5 -8
View File
@@ -1,6 +1,6 @@
# Warmbly iOS
Native SwiftUI companion app for the Warmbly dashboard. Covers the mobile-relevant dashboard surface: unified inbox (read, reply, snooze), mailbox accounts with warmup monitoring and controls, campaign monitoring with start/stop, contacts with the 360 view, analytics, deliverability, CRM (deals, tasks, meetings), templates, team management, notifications, and profile/billing views. Realtime by default: the app joins the Phoenix websocket and refreshes lists live off the same `AUDIT_CREATED` spine the web uses, including org presence (who is viewing/editing which record).
Native SwiftUI companion app for the Warmbly dashboard. Covers the mobile-relevant dashboard surface: unified inbox (read, reply, snooze, review the inbox agent's drafted replies and send them as written or refine them in the full composer with its AI tools, draft a reply from the thread with AI or ask Remie to summarize it, and the web's sidebar: Mail folders, Views, Mailboxes, Labels and Tags as sections that fold and move, with Favorites, hidden and reordered rows synced to the account through `unibox_rail`), mailbox accounts with warmup monitoring and controls (every mailbox shows its profile photo and the service that runs it, from Google Workspace and Microsoft 365 to Zoho or the vendor it was imported from), campaign monitoring with start/stop (each row shows how far through its leads it is, the campaign page splits its leads by outcome and charts sends, opens and replies over 7 to 90 days in the same scrubbable chart as Home, and the drawer filters by status, manages folders and sorts), contacts (each row shows the last thing that happened with them, rings the avatar on a fresh reply or click, groups under letters or dates by the sort, and swipes to label, add to a campaign, unsubscribe or delete) with the 360 view (an outreach journey from sent to replied and one-tap email, campaign and note actions), analytics (a scrubbable trend, rates against the previous period, a reply funnel, deliverability rates against their safe ranges, warmup against its ramp and where it landed, and every mailbox's health), CRM (a pipeline card with the win rate and each stage's value, deals you swipe to win; tasks with a ring for today and a check that ticks itself off, celebrating when the day is clear; meetings with a live countdown to the next call and a join button, grouped by day), templates (with their merge fields and an email-style preview), a live activity timeline of who changed what, and settings: a hub showing your workspace at a glance (mailbox health, the week's sends, open deals, tasks and meetings, members online) beside profile, notifications, security, billing and appearance, each its own page (a name card that saves in place, a password change with a strength meter, every signed-in device, sign-in protection, the plan with its price, renewal and what it includes, usage against the limits the server enforces, AI credit spend by day and by feature, the other plans side by side and a referral code to copy or share, the roster with who is online and pending invites, and an inbox of alerts grouped by day with its delivery switches). Realtime by default: the app joins the Phoenix websocket and refreshes lists live off the same `AUDIT_CREATED` spine the web uses, including org presence (who is viewing/editing which record).
## Requirements
@@ -11,9 +11,7 @@ Native SwiftUI companion app for the Warmbly dashboard. Covers the mobile-releva
1. Open `ios/Warmbly.xcodeproj` in Xcode.
2. Select the Warmbly scheme and an iOS 26 simulator, then run.
3. On the login screen, tap the gear to check the connection settings:
- Server origin: `http://localhost:8080` by default (the app calls `<origin>/v1`). On a physical device, use your Mac's LAN address.
- Captcha: local dev backends accept the pre-filled Turnstile bypass token. Against production you must supply a real Turnstile token; password login is captcha-gated.
3. On the login screen, tap the gear and pick **Local development** (every build starts on Warmbly Cloud). That points the app at `http://localhost:8080` (it calls `<origin>/v1`) and fills in the captcha bypass token local backends accept, since the simulator can't use App Attest. On a physical device, choose **Self-hosted** and enter your Mac's LAN address instead.
4. Sign in with a seeded user (`make seed` creates `dev@warmbly.com` / `password123`) or register from the app. Password login is two-step: credentials, then an emailed 6-digit code (visible in Mailpit at `http://localhost:18025` during local dev).
For a full local development walkthrough (running the stack, the first-run flow, physical devices, and troubleshooting), see [DEVELOPMENT.md](DEVELOPMENT.md).
@@ -23,13 +21,12 @@ For a full local development walkthrough (running the stack, the first-run flow,
- `Warmbly/Core/Networking/APIClient.swift`: async client for the `/v1` API. Bearer auth, single-flight token refresh (refresh tokens are single-use server-side), one retry on 401, `Idempotency-Key` on mutating calls that need it.
- `Warmbly/Core/Session/SessionStore.swift`: login (password + OTP + optional TOTP), registration, org selection. The active org is server-side session state, so the store re-POSTs `/organization/switch/:id` once per launch, mirroring the web's OrgGate.
- `Warmbly/Core/Realtime/`: a Phoenix Channels client speaking the V1 object-frame serializer (`vsn=1.0.0`) over `URLSessionWebSocketTask`, with 25s heartbeats, an 8s reply watchdog, resume via `last_seq`, and jittered reconnect backoff. `RealtimeService` maps events onto invalidation "pulses" that views observe, and tracks org presence.
- `Warmbly/DesignSystem/`: the brand translated to native iOS. Slate neutrals with sky as the single accent, status tones matching the web (emerald running, amber paused, rose issues), eyebrow labels, thin tabular stat numbers, presence avatars with amber/emerald rings.
- `Warmbly/DesignSystem/`: the marketing site's design language translated to native iOS. A clean canvas with hairline borders instead of shadows, near-black pill actions (`InkButtonStyle`, `OutlineButtonStyle`), medium-weight display type with tight tracking and the site's two-tone headings (`TwoToneTitle`), system-font meta text with tabular digits (`WFont.meta`; `WFont.code` only for real code), sentence-case section labels (`EyebrowLabel`), one Liquid Glass header for every list tab (`GlassHeaderBar`), one workspace header for every drawer (`DrawerWorkspaceHeader`), one scrubbable sends, opens and replies chart (`TrendChart`, with `CountUpText` and the staggered `reveal` entrance), sky kept for data and live state, status tones matching the web (emerald running, amber paused, rose issues), and the site's photo frames (`PhotoFrame`). Sign-in and onboarding sit on the brand backdrop (`SkyBackdrop`): the site's sheer sky photo in light mode, a dark sky in dark mode, with `WTheme.onSky` for what is drawn on it. The launch screen and splash follow the theme too. Every color is a light/dark pair in `WTheme`.
- `Warmbly/Features/`: one directory per feature; each screen loads with `.task`, reloads on realtime pulses, and supports pull-to-refresh.
The Xcode project uses `PBXFileSystemSynchronizedRootGroup`, so new Swift files under `Warmbly/` are picked up automatically; the pbxproj never needs editing for added files.
## Known limits
- Mailbox connect (OAuth/SMTP) and campaign sequence editing stay on the web; the app links out and says so.
- Sign in with Apple/Google is not offered because the backend exposes no social login endpoints.
- Light appearance first, matching the production web dashboard; colors are defined as dynamic pairs so a dark palette can land later.
- Adding a mailbox takes an app password (Gmail walks through creating one; Zoho, Yahoo, iCloud, Fastmail and other common hosts get their servers filled in from the address), an admin grant over a whole Workspace domain or Microsoft 365 organization, an inbox vendor's API key (InboxKit, Zapmail, Mailforge and the rest of the dashboard's catalog), a CSV, spreadsheet or pasted list, or a mailbox already on a linked Warmbly Cloud workspace. Bulk sources let you pick or preview the mailboxes first, then follow the import live. There is no per-mailbox Google or Microsoft sign-in, and setting up a new admin grant happens on the dashboard, since it needs the provider's admin console. Campaign sequence editing also stays on the web.
- The app follows the system appearance; dark mode uses zinc neutrals on true black.
@@ -1,38 +1,38 @@
{
"colors" : [
"colors": [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xC7",
"green" : "0x84",
"red" : "0x02"
"color": {
"color-space": "srgb",
"components": {
"alpha": "1.000",
"blue": "0x2A",
"green": "0x17",
"red": "0x0F"
}
},
"idiom" : "universal"
"idiom": "universal"
},
{
"appearances" : [
"appearances": [
{
"appearance" : "luminosity",
"value" : "dark"
"appearance": "luminosity",
"value": "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xF8",
"green" : "0xBD",
"red" : "0x38"
"color": {
"color-space": "srgb",
"components": {
"alpha": "1.000",
"blue": "0xFA",
"green": "0xFA",
"red": "0xFA"
}
},
"idiom" : "universal"
"idiom": "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
"info": {
"author": "xcode",
"version": 1
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 KiB

@@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "cloud-3.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

@@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "cloud-4.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "aol.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "fastmail.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "gmx.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "godaddy.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "hostinger.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "icloud.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1,15 @@
{
"images": [
{
"filename": "ionos.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true
}
}
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="favicon" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><g id="_32"><rect width="32" height="32" style="fill:#0b2a63; stroke-width:0px;"/><path d="M21.737,27.777c-.996.487-2.199.003-2.737-1.101L9.429,7.051l-.015-.04c-.508-1.073-.138-2.306.85-2.79.812-.395,1.78-.155,2.411.601.026.027.061.071.097.122l.053.079c.067.099.126.199.175.3,0,0,9.458,19.388,9.563,19.613.54,1.105.176,2.352-.827,2.842" style="fill:#fff; stroke-width:0px;"/></g></svg>

After

Width:  |  Height:  |  Size: 519 B

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "migadu.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "namecheap.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,15 @@
{
"images": [
{
"filename": "ovh.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true
}
}
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<style>
.cls-1 {
fill: #000e9c;
stroke-width: 0px;
}
</style>
</defs>
<path class="cls-1" d="M12.61,25.66H3.66C-.58,20.47-1.19,13.23,2.11,7.4l5.8,10.08,6.4-11.14h9.42l-11.12,19.31h0ZM29.94,7.45c3.23,5.82,2.63,13.01-1.53,18.21h-8.78l2.7-4.77h-3.57l4.21-7.43h3.6l3.37-6.01Z"/>
</svg>

After

Width:  |  Height:  |  Size: 463 B

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "proton.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "purelymail.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "rackspace.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "yahoo.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "yandex.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 833 B

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "zoho.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -5,9 +5,27 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xF8",
"green" : "0xBD",
"red" : "0x38"
"blue" : "0xFF",
"green" : "0xFF",
"red" : "0xFF"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x0B",
"green" : "0x09",
"red" : "0x09"
}
},
"idiom" : "universal"
@@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "cloud-1.png",
"filename" : "sheer-sky.jpg",
"idiom" : "universal"
}
],
Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "cheapinboxes.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "inboxkit.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "infraforge.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "maildoso.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "mailforge.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "scaledmail.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "zapmail.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

+22 -10
View File
@@ -2,18 +2,18 @@ import Foundation
/// Server configuration, persisted in UserDefaults (thread-safe) so it can be
/// read from any isolation domain without shared mutable state. Values are
/// editable from the login screen's connection sheet.
/// editable from the sign-in screen's Server sheet.
enum AppConfig {
/// Shipped builds default to the hosted SaaS API; debug builds default to
/// the local dev backend. Self-hosters override the origin in-app, so one
/// binary serves the SaaS, a self-hosted instance, or a local stack.
#if DEBUG
static let defaultOrigin = "http://localhost:8080"
static let defaultTurnstileToken = "warmbly-local-turnstile-bypass"
#else
static let defaultOrigin = "https://api.warmbly.com"
static let cloudOrigin = "https://api.warmbly.com"
static let localOrigin = "http://localhost:8080"
/// The captcha value local dev backends accept in place of a widget token.
static let localTurnstileToken = "warmbly-local-turnstile-bypass"
/// Every build signs in to Warmbly Cloud until someone picks another
/// server, so one binary serves the SaaS, a self-hosted instance or a
/// local stack.
static let defaultOrigin = cloudOrigin
static let defaultTurnstileToken = ""
#endif
/// Bare origin, no path. The `/v1` prefix is applied in APIClient,
/// mirroring the web's `API_BASE_URL = ${API_URL}/v1`.
@@ -43,6 +43,18 @@ enum AppConfig {
set { UserDefaults.standard.set(newValue, forKey: "last_org_id") }
}
/// The web dashboard for a path: the hosted app beside the API, else the
/// same origin a self-hosted instance serves it from.
static func dashboardURL(_ path: String) -> URL? {
var origin = serverOrigin.trimmingCharacters(in: .whitespaces)
if origin.contains("://api.") {
origin = origin.replacingOccurrences(of: "://api.", with: "://app.")
} else if origin.hasSuffix("localhost:8080") {
origin = origin.replacingOccurrences(of: "localhost:8080", with: "localhost:5173")
}
return URL(string: origin + path)
}
static var apiBaseURL: URL? {
URL(string: serverOrigin.trimmingCharacters(in: .whitespaces))?.appending(path: "v1")
}
+2
View File
@@ -344,8 +344,10 @@ struct OrgCounts: Codable, Sendable {
var totalContacts: Int?
var totalMembers: Int?
var emailAccounts: Int?
var emailsSentToday: Int?
enum CodingKeys: String, CodingKey {
case emailsSentToday = "emails_sent_today"
case totalCampaigns = "total_campaigns"
case activeCampaigns = "active_campaigns"
case totalContacts = "total_contacts"
@@ -22,6 +22,12 @@ actor APIClient {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
session = URLSession(configuration: config)
#if DEBUG
// Lets scripted simulator runs start signed in (SIMCTL_CHILD_WARMBLY_DEBUG_TOKEN).
if let raw = ProcessInfo.processInfo.environment["WARMBLY_DEBUG_TOKEN"], !raw.isEmpty {
KeychainStore.set(raw, for: Self.tokenKey)
}
#endif
if let raw = KeychainStore.get(Self.tokenKey),
let stored = try? Self.decoder.decode(AuthToken.self, from: Data(raw.utf8)) {
token = stored
@@ -286,6 +292,41 @@ actor APIClient {
try await request(.delete, path, bodyData: try Self.encoder.encode(body))
}
/// A file part of a multipart upload.
struct UploadFile {
var field: String
var filename: String
var mimeType: String
var data: Data
}
/// POSTs `multipart/form-data`: text fields plus an optional file.
func postMultipart<T: Decodable>(_ path: String, fields: [String: String], file: UploadFile? = nil, timeout: TimeInterval = 60) async throws -> T {
let boundary = "warmbly-\(UUID().uuidString)"
var body = Data()
func append(_ string: String) { body.append(Data(string.utf8)) }
for (name, value) in fields.sorted(by: { $0.key < $1.key }) {
append("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\"\r\n\r\n\(value)\r\n")
}
if let file {
append("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(file.field)\"; filename=\"\(file.filename)\"\r\nContent-Type: \(file.mimeType)\r\n\r\n")
body.append(file.data)
append("\r\n")
}
append("--\(boundary)--\r\n")
var request = try buildRequest(.post, path, query: [:], body: body, idempotencyKey: nil)
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = timeout
let (data, http) = try await execute(request, authorized: true)
try validate(data, http)
do {
return try Self.decoder.decode(T.self, from: data)
} catch {
throw APIError.decoding(error)
}
}
// MARK: - Server-sent events
/// POSTs and streams the response as SSE, yielding each `data:` payload.
+4
View File
@@ -51,6 +51,10 @@ final class PushManager: NSObject {
}
private func requestAndRegister() async {
#if DEBUG
// Scripted simulator runs have no one to answer the permission prompt.
if ProcessInfo.processInfo.environment["WARMBLY_DEBUG_TOKEN"] != nil { return }
#endif
let center = UNUserNotificationCenter.current()
var status = await center.notificationSettings().authorizationStatus
if status == .notDetermined {
@@ -361,6 +361,7 @@ final class RealtimeService {
}
if name == "EMAIL_UPDATED" || name == "EMAIL_DELETED" { return [.unibox, .analytics] }
if name == "EMAIL_REPLIED" { return [.campaigns, .analytics, .unibox] }
if name == "AI_DRAFT_READY" { return [.unibox] }
if name == "EMAIL_SENT" || name == "EMAIL_OPENED" || name == "EMAIL_CLICKED"
|| name == "EMAIL_FAILED" || name == "EMAIL_BOUNCED" || name.contains("TASK_PROGRESS") {
return [.campaigns, .analytics]
@@ -0,0 +1,109 @@
import CryptoKit
import DeviceCheck
import Foundation
/// Proves an email sign-in comes from this app on a real device (App Attest),
/// which the server accepts in place of a captcha. One key per server.
actor AppAttestClient {
/// The `app_attest` object sent with a protected auth call.
struct Proof: Encodable, Sendable {
let keyID: String
let assertion: String
let challenge: String
enum CodingKeys: String, CodingKey {
case keyID = "key_id"
case assertion, challenge
}
}
/// The request a proof is made for; the server checks it matches.
enum Action: String, Sendable {
case login
case register
case resetPassword = "reset_password"
}
enum Failure: LocalizedError {
case unsupported
var errorDescription: String? {
"This device can't verify the app. Sign in with Apple or Google instead."
}
}
private struct Challenge: Decodable { let challenge: String }
private struct KeyBody: Encodable {
let keyID: String
let attestation: String
let challenge: String
enum CodingKeys: String, CodingKey {
case keyID = "key_id"
case attestation, challenge
}
}
private let api: APIClient
private var registering: Task<String, Error>?
init(api: APIClient) {
self.api = api
}
static var isSupported: Bool { DCAppAttestService.shared.isSupported }
private static var storageKey: String { "app_attest_key." + AppConfig.serverOrigin }
/// Signs a fresh challenge for action with this install's key, registering it first if needed.
func proof(for action: Action) async throws -> Proof {
guard Self.isSupported else { throw Failure.unsupported }
let keyID = try await registeredKey()
let challenge = try await fetchChallenge()
let clientData = Data("warmbly-app-attest/v1\n\(action.rawValue)\n\(challenge)".utf8)
do {
let assertion = try await DCAppAttestService.shared.generateAssertion(keyID, clientDataHash: Self.hash(clientData))
return Proof(keyID: keyID, assertion: assertion.base64EncodedString(), challenge: challenge)
} catch let error as DCError where error.code == .invalidKey {
// The system dropped the key (a restore onto another device); the next proof makes a new one.
forgetKey()
throw error
}
}
/// Drops the stored key, so the next proof registers a new one.
func forgetKey() {
UserDefaults.standard.removeObject(forKey: Self.storageKey)
}
private func registeredKey() async throws -> String {
if let keyID = UserDefaults.standard.string(forKey: Self.storageKey) { return keyID }
if let registering { return try await registering.value }
let task = Task { try await self.register() }
registering = task
defer { registering = nil }
let keyID = try await task.value
UserDefaults.standard.set(keyID, forKey: Self.storageKey)
return keyID
}
private func register() async throws -> String {
let service = DCAppAttestService.shared
let keyID = try await service.generateKey()
let challenge = try await fetchChallenge()
let attestation = try await service.attestKey(keyID, clientDataHash: Self.hash(Data(challenge.utf8)))
let body = KeyBody(keyID: keyID, attestation: attestation.base64EncodedString(), challenge: challenge)
let _: EmptyBody = try await api.post("auth/app-attest/keys", body: body, authorized: false)
return keyID
}
private func fetchChallenge() async throws -> String {
let response: Challenge = try await api.post("auth/app-attest/challenge", authorized: false)
return response.challenge
}
private static func hash(_ data: Data) -> Data {
Data(SHA256.hash(data: data))
}
}
+110 -24
View File
@@ -29,6 +29,7 @@ enum LoginOutcome {
@Observable
final class SessionStore {
let api: APIClient
private let appAttest: AppAttestClient
private(set) var phase: SessionPhase = .launching
/// True when the last sign-out was forced by an expired/revoked session
@@ -46,6 +47,7 @@ final class SessionStore {
init(api: APIClient) {
self.api = api
self.appAttest = AppAttestClient(api: api)
// The outer task is one-shot registration (strong self is fine); the
// stored handler captures weakly to avoid an APIClient<->store cycle.
Task { [api, self] in
@@ -68,6 +70,27 @@ final class SessionStore {
// MARK: - Launch
func bootstrap() async {
#if DEBUG
// Scripted runs can open a signed-out or first-run screen; the stored session is left alone.
switch ProcessInfo.processInfo.environment["WARMBLY_DEBUG_PHASE"] {
case "launching":
return
case "signedout":
phase = .loggedOut
return
case "onboarding":
try? await loadIdentity()
phase = .onboarding
return
case "workspace":
try? await loadIdentity()
memberships = []
phase = .selectOrg
return
default:
break
}
#endif
guard await api.hasToken else {
phase = .loggedOut
return
@@ -178,15 +201,88 @@ final class SessionStore {
// MARK: - Login flow
func startLogin(email: String, password: String) async throws -> LoginOutcome {
let body = [
"email": email,
"password": password,
"turnstile": AppConfig.turnstileToken,
]
let result: AuthSession = try await api.post("auth/login", body: body, authorized: false)
let result: AuthSession = try await verified(.login) { check in
try await self.api.post("auth/login", body: PasswordAuthBody(email: email, password: password, check: check), authorized: false)
}
return try await resolve(result)
}
// MARK: - Human check
/// What stands in for a captcha on the email paths: nothing, the dev stack's bypass token, or an App Attest proof.
struct HumanCheck: Sendable {
var turnstile: String?
var appAttest: AppAttestClient.Proof?
}
enum HumanCheckError: LocalizedError {
case emailSignInOff
var errorDescription: String? {
"This server only allows email sign-in on the web. Sign in with Apple, Google or a passkey instead."
}
}
private struct DeploymentChecks: Decodable {
var captcha: Bool?
var appAttest: Bool?
enum CodingKeys: String, CodingKey {
case captcha
case appAttest = "app_attest"
}
}
private struct PasswordAuthBody: Encodable {
var email: String
var password: String
var turnstile: String?
var appAttest: AppAttestClient.Proof?
init(email: String, password: String, check: HumanCheck) {
self.email = email
self.password = password
turnstile = check.turnstile
appAttest = check.appAttest
}
enum CodingKeys: String, CodingKey {
case email, password, turnstile
case appAttest = "app_attest"
}
}
private struct ResetBody: Encodable {
var email: String
var turnstile: String?
var appAttest: AppAttestClient.Proof?
enum CodingKeys: String, CodingKey {
case email, turnstile
case appAttest = "app_attest"
}
}
private func humanCheck(_ action: AppAttestClient.Action) async throws -> HumanCheck {
let bypass = AppConfig.turnstileToken.trimmingCharacters(in: .whitespaces)
if !bypass.isEmpty { return HumanCheck(turnstile: bypass) }
let checks: DeploymentChecks? = try? await api.get("auth/config", authorized: false)
guard let checks, checks.captcha == true else { return HumanCheck() }
guard checks.appAttest == true else { throw HumanCheckError.emailSignInOff }
return HumanCheck(appAttest: try await appAttest.proof(for: action))
}
/// Runs a captcha-gated call, registering this install's key again once if the server lost it.
private func verified<T>(_ action: AppAttestClient.Action, _ send: (HumanCheck) async throws -> T) async throws -> T {
let check = try await humanCheck(action)
do {
return try await send(check)
} catch let error as APIError where error.code == "app_attest_key_unknown" && check.appAttest != nil {
await appAttest.forgetKey()
return try await send(try await humanCheck(action))
}
}
/// Maps a start-of-login response onto an outcome, completing the sign-in
/// when the backend already resolved it.
private func resolve(_ result: AuthSession) async throws -> LoginOutcome {
@@ -207,11 +303,7 @@ final class SessionStore {
}
func confirmLogin(session: String, code: String) async throws -> LoginOutcome {
let body = [
"session": session,
"code": code,
"turnstile": AppConfig.turnstileToken,
]
let body = ["session": session, "code": code]
let result: LoginResult = try await api.post("auth/login/confirm", body: body, authorized: false)
if result.twoFARequired == true, let pending = result.pendingToken {
return .needsTwoFA(pendingToken: pending)
@@ -276,28 +368,22 @@ final class SessionStore {
/// Returns the session to confirm, or nil when the deployment has email
/// verification off and the account already exists.
func startRegister(email: String, password: String) async throws -> String? {
let body = [
"email": email,
"password": password,
"turnstile": AppConfig.turnstileToken,
]
let result: AuthSession = try await api.post("auth/register", body: body, authorized: false)
let result: AuthSession = try await verified(.register) { check in
try await self.api.post("auth/register", body: PasswordAuthBody(email: email, password: password, check: check), authorized: false)
}
return result.needsCode ? result.session : nil
}
/// Registration confirm returns 204 (no tokens); chain into the login flow after.
func confirmRegister(session: String, code: String) async throws {
let body = [
"session": session,
"code": code,
"turnstile": AppConfig.turnstileToken,
]
let body = ["session": session, "code": code]
let _: EmptyBody = try await api.post("auth/register/confirm", body: body, authorized: false)
}
func requestPasswordReset(email: String) async throws {
let body = ["email": email, "turnstile": AppConfig.turnstileToken]
let _: EmptyBody = try await api.post("auth/reset-password", body: body, authorized: false)
let _: EmptyBody = try await verified(.resetPassword) { check in
try await self.api.post("auth/reset-password", body: ResetBody(email: email, turnstile: check.turnstile, appAttest: check.appAttest), authorized: false)
}
}
// MARK: - Logout
+229 -100
View File
@@ -17,18 +17,14 @@ extension EnvironmentValues {
// MARK: - Cards
/// The in-app surface: a soft rounded card on the grouped background.
/// The in-app surface: a soft filled panel, no outline and no shadow.
struct AirCardModifier: ViewModifier {
var padding: CGFloat = 16
func body(content: Content) -> some View {
content
.padding(padding)
.background(
Color(.secondarySystemGroupedBackground),
in: RoundedRectangle(cornerRadius: 22, style: .continuous)
)
.shadow(color: .black.opacity(0.045), radius: 14, y: 5)
.background(WTheme.surface, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
}
}
@@ -47,22 +43,23 @@ struct AirSectionHeader: View {
var body: some View {
HStack(alignment: .firstTextBaseline) {
Text(title)
.font(.title3.weight(.bold))
.displayFont(20)
.foregroundStyle(WTheme.ink)
Spacer()
if let actionTitle, let action {
Button(action: action) {
HStack(spacing: 3) {
HStack(spacing: 4) {
Text(actionTitle)
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .semibold))
Image(systemName: "arrow.right")
.font(.system(size: 11, weight: .medium))
}
.font(.subheadline.weight(.medium))
.foregroundStyle(WTheme.accent)
.font(.system(size: 13.5, weight: .medium))
.foregroundStyle(WTheme.inkSecondary)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 4)
.padding(.horizontal, 2)
}
}
@@ -76,10 +73,10 @@ struct IconTile: View {
var body: some View {
Image(systemName: symbol)
.font(.system(size: size * 0.44, weight: .semibold))
.font(.system(size: size * 0.42, weight: .medium))
.foregroundStyle(tone.color)
.frame(width: size, height: size)
.background(tone.background, in: RoundedRectangle(cornerRadius: size * 0.32, style: .continuous))
.background(tone.background, in: RoundedRectangle(cornerRadius: size * 0.3, style: .continuous))
}
}
@@ -126,47 +123,42 @@ struct HealthRing: View {
}
}
// MARK: - Hero stat chip (white-on-sky)
// MARK: - Hero stat
/// Glass stat chip used on sky hero headers, matching the auth showcase glass.
/// An open stat for screen headers: a tinted glyph and label over a big
/// number, no tile around it.
struct AirStatChip: View {
let value: String
let label: String
var symbol: String?
var tint: Color = WTheme.accent
var body: some View {
VStack(alignment: .leading, spacing: 3) {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 5) {
if let symbol {
Image(systemName: symbol)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.white.opacity(0.85))
.font(.system(size: 10.5, weight: .semibold))
.foregroundStyle(tint)
} else {
Circle().fill(tint).frame(width: 6, height: 6)
}
Text(value)
.font(.system(size: 21, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.white)
.contentTransition(.numericText())
Text(label)
.font(.system(size: 12.5, weight: .medium))
.foregroundStyle(WTheme.inkSecondary)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
Text(label)
.font(.system(size: 11.5, weight: .medium))
.foregroundStyle(.white.opacity(0.72))
Text(value)
.font(.system(size: 26, weight: .semibold, design: .rounded))
.tracking(-0.6)
.monospacedDigit()
.foregroundStyle(WTheme.ink)
.contentTransition(.numericText())
.lineLimit(1)
.minimumScaleFactor(0.7)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.vertical, 11)
.background(.white.opacity(0.14), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(
LinearGradient(
colors: [.white.opacity(0.38), .white.opacity(0.06)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 1
)
)
}
}
@@ -194,7 +186,7 @@ struct AirUnderlineTabBar: View {
}
}
.padding(.horizontal, 8)
.overlay(alignment: .bottom) { Divider() }
.overlay(alignment: .bottom) { Rectangle().fill(WTheme.hairline).frame(height: 1) }
.sensoryFeedback(.selection, trigger: selection)
}
@@ -208,11 +200,11 @@ struct AirUnderlineTabBar: View {
VStack(spacing: 0) {
HStack(spacing: 5) {
Image(systemName: tab.icon)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(selected ? WTheme.accent : Color.secondary)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(selected ? WTheme.ink : WTheme.inkTertiary)
Text(tab.title)
.font(.system(size: 13.5, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? Color.primary : .secondary)
.font(.system(size: 13.5, weight: selected ? .medium : .regular))
.foregroundStyle(selected ? WTheme.ink : WTheme.inkSecondary)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
@@ -221,11 +213,11 @@ struct AirUnderlineTabBar: View {
ZStack {
if selected {
Capsule()
.fill(WTheme.accent)
.frame(height: 3)
.fill(WTheme.ink)
.frame(height: 2)
.matchedGeometryEffect(id: "air-underline", in: underlineNS)
} else {
Color.clear.frame(height: 3)
Color.clear.frame(height: 2)
}
}
.padding(.horizontal, 10)
@@ -236,74 +228,210 @@ struct AirUnderlineTabBar: View {
}
}
/// Detail-screen scaffold: sky hero fixed at the top (behind a transparent
/// nav bar), a rounded white sheet whose underline tab bar stays pinned, and
/// the tab content swiping horizontally underneath (Gmail-profile style). Tab
/// contents keep their own Lists, scrolling, and swipe actions. The sheet is
/// clipped so scrolling content never bleeds over its rounded corners.
/// Detail-screen scaffold: a clean header on the canvas, then a pinned
/// underline tab bar with the tab content swiping underneath. Tab contents
/// keep their own Lists, scrolling, and swipe actions.
struct AirDetailScaffold<Hero: View, Content: View>: View {
let tabs: [AirTabItem]
@Binding var selection: String
@ViewBuilder var hero: Hero
@ViewBuilder var content: Content
private var sheetShape: UnevenRoundedRectangle {
UnevenRoundedRectangle(topLeadingRadius: 26, topTrailingRadius: 26, style: .continuous)
}
var body: some View {
VStack(spacing: 0) {
// The hero keeps its full height; the paged content takes the rest.
hero
.frame(maxWidth: .infinity, alignment: .leading)
VStack(spacing: 0) {
AirUnderlineTabBar(tabs: tabs, selection: $selection)
content
}
.clipShape(sheetShape)
.background(
sheetShape
.fill(Color(.systemBackground))
.ignoresSafeArea(edges: .bottom)
.shadow(color: .black.opacity(0.12), radius: 18, y: -4)
)
.fixedSize(horizontal: false, vertical: true)
AirUnderlineTabBar(tabs: tabs, selection: $selection)
content
}
.background(alignment: .top) {
AirSkyWash().ignoresSafeArea(edges: .top)
}
.toolbarBackground(.hidden, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
.background(WTheme.canvas)
.toolbarBackground(WTheme.canvas, for: .navigationBar)
}
}
/// Lightweight sky slice for detail heros: the brand radial with a soft bloom,
/// cheaper than the full animated SkyBackdrop.
/// Header backdrop for heros and drawers: the canvas with a faint sky haze
/// and dot grid along the top edge, so a header reads as a place without
/// a slab of colour behind it.
struct AirSkyWash: View {
var body: some View {
ZStack {
ZStack(alignment: .top) {
WTheme.canvas
DotGrid(spacing: 18, opacity: 0.05)
.mask(LinearGradient(colors: [.black, .clear], startPoint: .top, endPoint: .bottom))
RadialGradient(
stops: [
.init(color: Color(hex: 0x7DD3FC), location: 0),
.init(color: Color(hex: 0x38BDF8), location: 0.34),
.init(color: Color(hex: 0x0EA5E9), location: 0.66),
.init(color: Color(hex: 0x0284C7), location: 1),
],
center: UnitPoint(x: 0.55, y: 0.05),
colors: [WTheme.accent.opacity(0.09), .clear],
center: UnitPoint(x: 0.85, y: 0),
startRadius: 0,
endRadius: 560
)
RadialGradient(
stops: [
.init(color: Color(hex: 0xBAE6FD).opacity(0.55), location: 0),
.init(color: .clear, location: 0.6),
],
center: UnitPoint(x: 0.6, y: 0.1),
startRadius: 0,
endRadius: 420
endRadius: 320
)
}
}
}
// MARK: - Site primitives
/// The site's dot-grid texture (`bg-dotgrid`), in the current ink.
struct DotGrid: View {
var spacing: CGFloat = 22
var opacity: Double = 0.08
var color: Color = WTheme.ink
var body: some View {
Canvas { ctx, size in
var path = Path()
var y: CGFloat = spacing / 2
while y < size.height {
var x: CGFloat = spacing / 2
while x < size.width {
path.addEllipse(in: CGRect(x: x - 0.75, y: y - 0.75, width: 1.5, height: 1.5))
x += spacing
}
y += spacing
}
ctx.fill(path, with: .color(color.opacity(opacity)))
}
.allowsHitTesting(false)
}
}
/// A full-width hairline rule.
struct Hairline: View {
var body: some View {
Rectangle().fill(WTheme.hairline).frame(height: 1)
}
}
/// The site's two-tone heading: a grey lead, then the point in ink.
struct TwoToneTitle: View {
var lead: String?
let title: String
var size: CGFloat = 30
var leadColor: Color = WTheme.inkTertiary
var titleColor: Color = WTheme.ink
var body: some View {
Group {
if let lead {
Text("\(Text(lead).foregroundStyle(leadColor)) \(Text(title).foregroundStyle(titleColor))")
} else {
Text(title).foregroundStyle(titleColor)
}
}
.displayFont(size)
.fixedSize(horizontal: false, vertical: true)
}
}
/// A live/state dot with the web's ping halo.
struct LiveDot: View {
var color: Color = WTheme.positive
var size: CGFloat = 7
var pulsing = true
var body: some View {
Circle()
.fill(color)
.frame(width: size, height: size)
.modifier(PingEffect(active: pulsing, color: color))
}
}
/// One of the site's photo frames: a soft texture with UI floating on it.
struct PhotoFrame<Content: View>: View {
var photo = "SheerSky"
var cornerRadius: CGFloat = 22
@ViewBuilder var content: Content
@Environment(\.colorScheme) private var colorScheme
var body: some View {
content
.background {
Image(photo)
.resizable()
.scaledToFill()
.overlay(WTheme.night.opacity(colorScheme == .dark ? 0.45 : 0))
.allowsHitTesting(false)
}
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.strokeBorder(WTheme.ink.opacity(0.06), lineWidth: 1)
)
}
}
// MARK: - Buttons
enum ButtonSize {
case small, regular, large
var height: CGFloat {
switch self {
case .small: 34
case .regular: 44
case .large: 54
}
}
var font: Font {
switch self {
case .small: .system(size: 13.5, weight: .medium)
case .regular: .system(size: 15, weight: .medium)
case .large: .system(size: 16.5, weight: .medium)
}
}
var padding: CGFloat {
switch self {
case .small: 14
case .regular: 18
case .large: 22
}
}
}
/// The site's primary action: a near-black pill (white in dark mode).
struct InkButtonStyle: ButtonStyle {
var size: ButtonSize = .regular
var fullWidth = false
@Environment(\.isEnabled) private var isEnabled
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(size.font)
.foregroundStyle(WTheme.inkInverse)
.padding(.horizontal, size.padding)
.frame(maxWidth: fullWidth ? .infinity : nil)
.frame(height: size.height)
.background(WTheme.ink.opacity(isEnabled ? 1 : 0.22), in: Capsule())
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.opacity(configuration.isPressed ? 0.86 : 1)
.animation(.spring(response: 0.28, dampingFraction: 0.75), value: configuration.isPressed)
.animation(.easeOut(duration: 0.18), value: isEnabled)
}
}
/// The site's secondary action: a hairline pill on the canvas.
struct OutlineButtonStyle: ButtonStyle {
var size: ButtonSize = .regular
var fullWidth = false
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(size.font)
.foregroundStyle(WTheme.ink)
.padding(.horizontal, size.padding)
.frame(maxWidth: fullWidth ? .infinity : nil)
.frame(height: size.height)
.background(configuration.isPressed ? WTheme.fill : WTheme.raised, in: Capsule())
.overlay(Capsule().strokeBorder(WTheme.hairlineStrong, lineWidth: 1))
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.animation(.spring(response: 0.28, dampingFraction: 0.75), value: configuration.isPressed)
}
}
// MARK: - Web handoff
/// Points at the web dashboard for work that needs a big screen (sequence
@@ -315,10 +443,10 @@ struct WebHandoffBanner: View {
var body: some View {
HStack(alignment: .top, spacing: 11) {
Image(systemName: "macbook")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(WTheme.accent)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(WTheme.ink)
.frame(width: 30, height: 30)
.background(Tone.sky.background, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
.background(WTheme.fill, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
VStack(alignment: .leading, spacing: 2) {
Text("Best on a big screen")
.font(.footnote.weight(.semibold))
@@ -330,10 +458,11 @@ struct WebHandoffBanner: View {
Spacer(minLength: 0)
}
.padding(12)
.background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.background(WTheme.raised, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(Tone.sky.color.opacity(0.18), lineWidth: 1)
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [3, 3]))
.foregroundStyle(WTheme.hairlineStrong)
)
}
}

Some files were not shown because too many files have changed in this diff Show More