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)
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/).
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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-----
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 506 KiB |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "cloud-3.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 343 KiB |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "cloud-4.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 242 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "aol.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "fastmail.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "gmx.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "godaddy.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "hostinger.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "icloud.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "namecheap.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "purelymail.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "rackspace.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "yahoo.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "yandex.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 833 B |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "zoho.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
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"
|
||||
}
|
||||
],
|
||||
|
After Width: | Height: | Size: 302 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "cheapinboxes.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "inboxkit.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "infraforge.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "maildoso.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "mailforge.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "scaledmail.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 6.7 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "zapmail.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||