diff --git a/.agents/skills/swiftui-pro/LICENSE b/.agents/skills/swiftui-pro/LICENSE new file mode 100644 index 0000000..9f028c3 --- /dev/null +++ b/.agents/skills/swiftui-pro/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Paul Hudson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0584aa5..054a2ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,11 +17,12 @@ jobs: worker: name: Worker and tests runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 22 cache: npm @@ -35,15 +36,20 @@ jobs: run: npm run test:worker - name: Build Worker run: npm run build + - name: Check forward-compatible D1 migrations + run: npm run migrations:check + - name: Smoke-test local API and Auth stack + run: npm run dev:smoke ios: name: iOS Simulator build runs-on: macos-15 + timeout-minutes: 20 steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 22 cache: npm @@ -51,6 +57,8 @@ jobs: run: npm ci - name: Test iOS helpers run: npm run test:ios + - name: Run iOS unit tests + run: npm run ios:test:unit - name: Build unsigned iOS app run: >- xcodebuild diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d0510ab..4a85bf3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -20,10 +20,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 with: languages: javascript-typescript - name: Analyze - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 diff --git a/.github/workflows/dco.yml b/.github/workflows/dco.yml new file mode 100644 index 0000000..0634777 --- /dev/null +++ b/.github/workflows/dco.yml @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: Apache-2.0 +name: DCO + +on: + pull_request: + +permissions: + contents: read + +jobs: + signoff: + name: Developer Certificate of Origin + runs-on: ubuntu-latest + steps: + - name: Check out pull request history + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + - name: Check commit sign-offs + env: + DCO_BASE_SHA: ${{ github.event.pull_request.base.sha }} + DCO_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: node scripts/check-dco.mjs diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 8cf06ef..5b03704 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -33,9 +33,9 @@ jobs: timeout-minutes: 20 steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 22 cache: npm @@ -49,6 +49,8 @@ jobs: run: npm run test:worker - name: Build production Workers without uploading run: npm run build:production + - name: Check forward-compatible D1 migrations + run: npm run migrations:check deploy: name: Deploy Auth then API @@ -58,19 +60,20 @@ jobs: environment: production env: CI: "true" - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 22 cache: npm - name: Install dependencies run: npm ci - name: Require Cloudflare deployment credentials + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} shell: bash run: | set -euo pipefail @@ -78,6 +81,9 @@ jobs: test -n "${CLOUDFLARE_API_TOKEN:?}" - name: Capture rollback versions id: rollback_versions + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} shell: bash run: | set -euo pipefail @@ -89,18 +95,30 @@ jobs: echo "auth_version=$auth_version" >> "$GITHUB_OUTPUT" echo "api_version=$api_version" >> "$GITHUB_OUTPUT" - name: Apply Auth D1 migrations + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} run: npm run d1:migrate:auth:production - name: Deploy Auth Worker id: deploy_auth + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} run: >- npm run deploy:auth:production -- --message "GitHub ${GITHUB_SHA}: deploy Bellwire Auth" - name: Verify Auth production boundary run: npm run verify:auth:production - name: Apply business D1 migrations + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} run: npm run d1:migrate:api:production - name: Deploy API Worker id: deploy_api + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} run: >- npm run deploy:api:production -- --message "GitHub ${GITHUB_SHA}: deploy Bellwire API" @@ -118,8 +136,11 @@ jobs: echo "- API: https://api.bellwire.app/health" echo "- Order: Auth D1 → Auth Worker → Auth verification → Business D1 → API Worker → full verification" } >> "$GITHUB_STEP_SUMMARY" - - name: Roll back deployed Worker versions after failure + - name: Roll back Worker code after failure; D1 migrations stay forward-only if: ${{ failure() && (steps.deploy_auth.outcome == 'success' || steps.deploy_api.outcome == 'success') }} + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} shell: bash run: | set -euo pipefail diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d170686..d993f39 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,14 +15,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 22 cache: npm - name: Install dependencies run: npm ci + - name: Validate release metadata and license payloads + env: + RELEASE_TAG: ${{ github.ref_name }} + run: npm run release:check - name: Lint run: npm run lint - name: Typecheck @@ -37,9 +41,9 @@ jobs: runs-on: macos-15 steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 22 cache: npm @@ -47,6 +51,8 @@ jobs: run: npm ci - name: Test iOS helpers run: npm run test:ios + - name: Run iOS unit tests + run: npm run ios:test:unit - name: Build unsigned iOS app run: >- xcodebuild @@ -68,14 +74,28 @@ jobs: contents: write steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 22 + cache: npm + - name: Install dependencies + run: npm ci + - name: Validate release metadata and license payloads + env: + RELEASE_TAG: ${{ github.ref_name }} + run: npm run release:check - name: Package Agent Skill run: | cd skills zip -r -X "../bellwire-skill-${GITHUB_REF_NAME}.zip" bellwire cd .. unzip -t "bellwire-skill-${GITHUB_REF_NAME}.zip" + unzip -p "bellwire-skill-${GITHUB_REF_NAME}.zip" bellwire/LICENSE | grep -q "MIT No Attribution" sha256sum "bellwire-skill-${GITHUB_REF_NAME}.zip" > "bellwire-skill-${GITHUB_REF_NAME}.zip.sha256" + - name: Generate CycloneDX npm SBOM + run: npm sbom --sbom-format=cyclonedx > "bellwire-npm-${GITHUB_REF_NAME}.cdx.json" - name: Create release env: GH_TOKEN: ${{ github.token }} @@ -86,3 +106,5 @@ jobs: --generate-notes "bellwire-skill-${GITHUB_REF_NAME}.zip" "bellwire-skill-${GITHUB_REF_NAME}.zip.sha256" + "bellwire-npm-${GITHUB_REF_NAME}.cdx.json" + "THIRD_PARTY_NOTICES.md" diff --git a/.gitignore b/.gitignore index 7eb5f10..a2f32f7 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ wrangler.auth.self-host.toml wrangler.*.local.toml .wrangler/ ios/Bellwire/Configuration/Local.xcconfig +ios/Bellwire/Bellwire/Assets.xcassets/SelfHostedAppIcon.appiconset/ *.local *.p8 diff --git a/CHANGELOG.md b/CHANGELOG.md index c5008a0..1988693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ All notable changes to Bellwire are documented here. The project follows ## [Unreleased] +## [0.2.0] - 2026-08-08 + +### Added + +- Add an explicit hosted/self-hosted entitlement contract so self-hosted + deployments keep export and Live Activity features without App Store billing. +- Add required self-hosted app-name, app-icon, support, and legal configuration, + with bootstrap and doctor validation that keeps Bellwire brand assets out of + redistributed forks. +- Add a one-command local API/Auth development stack, real iOS entitlement unit + tests, machine-readable OpenAPI, telemetry, upgrade, and security operations + documentation. +- Add DCO enforcement, migration safety checks, release metadata validation, + third-party notices, and a CycloneDX npm SBOM release artifact. + +### Changed + +- Pin GitHub Actions to full commit SHAs and scope Cloudflare deployment + credentials to only the steps that require them. +- Make D1 rollback policy explicitly forward-only and keep Worker code rollback + separate from schema migration recovery. +- Move compact iOS text tokens to semantic Dynamic Type styles and make support, + legal, product, display-name, and keychain identifiers deployment-aware. +- Generate six-digit pairing codes with rejection sampling to avoid modulo bias. + ## [0.1.3] - 2026-08-08 ### Fixed @@ -85,7 +110,8 @@ All notable changes to Bellwire are documented here. The project follows - Bellwire name, app icon, and official service identifiers reserved under the trademark policy. -[Unreleased]: https://github.com/wegoft/bellwire/compare/v0.1.3...HEAD +[Unreleased]: https://github.com/wegoft/bellwire/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/wegoft/bellwire/compare/v0.1.3...v0.2.0 [0.1.3]: https://github.com/wegoft/bellwire/compare/v0.1.2...v0.1.3 [0.1.2]: https://github.com/wegoft/bellwire/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/wegoft/bellwire/compare/v0.1.0...v0.1.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bba9ee8..f162954 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,9 @@ npm ci npm run lint npm run typecheck npm test +npm run migrations:check npm run build +npm run ios:test:unit npm run ios:build ``` @@ -46,7 +48,8 @@ git commit -s The `Signed-off-by` line certifies the contribution under the [Developer Certificate of Origin](https://developercertificate.org/). Do not -sign off on code you do not have the right to contribute. +sign off on code you do not have the right to contribute. Pull requests run an +automated DCO check; bot-authored dependency updates are exempt. ## Contribution licensing diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..99114b1 --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,30 @@ + + +# Governance + +Bellwire uses maintainer-led governance while the project is in `0.x` releases. +The goal is predictable technical decisions with a clear path for community +participation. + +## Decisions + +- Routine fixes and documentation changes are accepted through review by a + maintainer. +- API, protocol, storage, authentication, licensing, and default-delivery + changes require an issue or architecture decision record before merge. +- Security fixes may be developed privately and disclosed after supported + deployments can be updated. +- The project lead has final responsibility for release, trademark, hosted + service, and incident decisions. Rationale should be recorded publicly when + disclosure is safe. + +## Contributions and releases + +All non-bot commits require a Developer Certificate of Origin sign-off. Pull +requests must pass CI, CodeQL, DCO, license-boundary, migration, and relevant iOS +checks. Releases use Semantic Versioning for the repository distribution; the +iOS marketing version, API version, and D1 migration identifiers are separate +compatibility axes described in [the upgrading guide](docs/upgrading.md). + +Maintainers are listed in [MAINTAINERS.md](MAINTAINERS.md). Project conduct is +governed by [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). diff --git a/LICENSE.md b/LICENSE.md index c1361c2..f3fd2c2 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -16,10 +16,14 @@ Copyright © 2026 Bellwire contributors. | Native iOS app | `ios/**` and `test/*.swift` | `MPL-2.0` | | Agent Skill, its CLI, and bundled protocol references/examples | `skills/**` | `MIT-0` | | Standalone integration examples and public documentation | `examples/**`, public `docs/**`, `.github/**`, and the root community/documentation files listed below | `Apache-2.0` | -| Bellwire app icon artwork and other Bellwire brand assets | `ios/Bellwire/Bellwire/Assets.xcassets/AppIcon.appiconset/BellwireIcon.png`, `ios/Bellwire/Design/AppIcon.svg`, and assets identified in `TRADEMARK.md` | No open-source trademark or artwork license | +| Bundled SwiftUI review skill | `.agents/skills/swiftui-pro/**` | `MIT` | +| Bellwire app icon artwork and other Bellwire brand assets | `ios/Bellwire/Bellwire/Assets.xcassets/AppIcon.appiconset/**`, `ios/Bellwire/Bellwire/Assets.xcassets/BellwireLogo.imageset/**`, `ios/Bellwire/Bellwire/Assets.xcassets/Mascot*.imageset/**`, `ios/Bellwire/Design/AppIcon.svg`, and `docs/brand/assets/**` | No open-source trademark or artwork license | The Apache-2.0 root files are `README.md`, `CHANGELOG.md`, `CONTRIBUTING.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, `TRADEMARK.md`, and this license map. +`GOVERNANCE.md`, `MAINTAINERS.md`, and `THIRD_PARTY_NOTICES.md` are also +licensed under Apache-2.0. + Files inside the ignored `docs/private/**` directory are not part of the public distribution and are not licensed by this repository. @@ -35,6 +39,7 @@ not covered by an explicit exception, the repository default is - [Mozilla Public License 2.0](LICENSES/MPL-2.0.txt) - [MIT No Attribution](LICENSES/MIT-0.txt) - [Apache License 2.0](LICENSES/Apache-2.0.txt) +- [MIT license for the bundled SwiftUI review skill](.agents/skills/swiftui-pro/LICENSE) ## Contributions @@ -51,4 +56,4 @@ files remain brand assets and are excluded from the open-source license grants. Third-party dependencies and bundled third-party material remain subject to their own license terms. No license in this repository grants rights that the -copyright holders do not have. +copyright holders do not have. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..e368e32 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,19 @@ + + +# Maintainers + +## Current maintainers + +| GitHub | Responsibility | +| --- | --- | +| [`@xwchris`](https://github.com/xwchris) | Project lead, releases, security coordination, hosted service operations | + +Maintainer status is based on sustained review and operational responsibility, +not only commit volume. New maintainers are nominated by an existing maintainer +and recorded through a pull request to this file. + +## Contact + +- Public bugs and proposals: [GitHub Issues](https://github.com/wegoft/bellwire/issues) +- Security reports: follow [SECURITY.md](SECURITY.md) +- Conduct reports: follow [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) diff --git a/README.md b/README.md index 291c2d5..fffa193 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,9 @@ out of this public repository. Start with the [Private-first quick start](docs/quickstart.md), browse the [integration examples](examples/README.md), or deploy the full stack with the -[self-hosting guide](docs/self-hosting.md). +[self-hosting guide](docs/self-hosting.md). The repository also publishes an +[OpenAPI 3.1 description](docs/openapi.yaml), [upgrade guide](docs/upgrading.md), +[telemetry contract](docs/telemetry.md), and [security model](docs/security-model.md). ## See Bellwire in action @@ -56,7 +58,7 @@ Start with the [Private-first quick start](docs/quickstart.md), browse the | API and Queue | Operated by Bellwire | Your Cloudflare account | | Auth and database | Better Auth + Cloudflare D1 | Your two Cloudflare D1 databases | | Push credentials | Bellwire App ID and APNs key | Your App ID and APNs key | -| Source code edits | None | None; use ignored local configuration | +| Source code edits | None | None for a private deployment; redistributed visual forks must replace reserved Bellwire artwork | | Private data path | Content-free wake; phone reads your service directly | Same protocol on your infrastructure | | Commercial limits | Free or Pro plan | None | | Operations | Managed service | You own upgrades, cost, security, and uptime | @@ -102,9 +104,9 @@ and [Private-first quick start](docs/quickstart.md) for the complete flow. project pause controls, and retry-aware delivery health. - Project-level Private/Hosted isolation, signed Direct v2 endpoints, encrypted one-time manifests, opaque wake references, and device readiness. -- Server-authoritative Free/Pro entitlements, atomic monthly Signal metering, - StoreKit 2 transaction verification, App Store Server Notifications V2, and - entitlement-based retention. +- Explicit deployment capabilities: hosted Free/Pro entitlements with atomic + monthly Signal metering and StoreKit 2 verification, while self-hosted builds + disable billing and commercial limits without losing export or Live Activities. - Cloudflare Queue dispatch and APNs HTTP/2 provider-token authentication. - Optional public HTTPS project logos in native project avatars and rich APNs notification attachments, with monogram fallback when an image is absent or fails. @@ -135,7 +137,8 @@ Private references are cleared after delivery settles and expire within 24 hours; content-free wake metadata expires after seven days. Architecture decisions are recorded in [`docs/architecture`](docs/architecture). -Release history is recorded in [`CHANGELOG.md`](CHANGELOG.md). +Release history is recorded in [`CHANGELOG.md`](CHANGELOG.md); contribution and +decision authority are documented in [`GOVERNANCE.md`](GOVERNANCE.md). ## Local development @@ -143,8 +146,7 @@ Requires Node.js 22 or newer. ```bash npm install -cp .env.example .dev.vars -npm run dev +npm run dev:stack ``` Run all local checks: @@ -157,9 +159,11 @@ npm run build npm run ios:build ``` -The API Worker uses in-memory storage only when `APP_ENV=development` and no -`DB` binding is present. Staging and production fail closed without D1. Run the -Auth Worker separately with `npm run dev:auth`. +The command applies both local D1 schemas, starts the API on port 8787 and Auth +on port 8788, then waits for both health checks. It uses dedicated development +configuration and never loads production routes or credentials. Use +`npm run dev` or `npm run dev:auth` when only one Worker is needed, and +`npm run dev:smoke` for a start-migrate-health-stop verification. ## Cloud configuration @@ -248,7 +252,7 @@ project-scoped Ingest token. | `POST` | `/v1/projects/:projectId/notification-surfaces` | Create a notification Surface | | `GET` | `/v1/surfaces` | List current live Surfaces across projects | | `GET` | `/v1/projects/:projectId/surfaces` | List current project Surfaces | -| `GET` | `/v1/projects/:projectId/export` | Export Hosted Event and delivery history (Pro) | +| `GET` | `/v1/projects/:projectId/export` | Export Hosted Event and delivery history (hosted Pro; enabled for self-hosted deployments) | | `PUT, DELETE` | `/v1/projects/:projectId/surfaces/:surfaceKey` | Update or end a stable live Surface | | `POST` | `/v1/projects/:projectId/ingest-tokens` | Issue an Ingest token | | `DELETE` | `/v1/projects/:projectId/ingest-tokens/:tokenId` | Revoke an Ingest token | @@ -291,7 +295,8 @@ with the original Event ID and `"deduplicated": true`. Private wake ingestion accepts only a 22–200 character random URL-safe `reference` and optional `priority`. It rejects unknown fields and never accepts title, body, Event data, project name, Logo URL, or service hostname. See the -[Direct v2 protocol](skills/bellwire/references/direct-connections.md). +[Direct v2 protocol](skills/bellwire/references/direct-connections.md). The full +machine-readable route inventory is in [`docs/openapi.yaml`](docs/openapi.yaml). ## Live smoke test diff --git a/SECURITY.md b/SECURITY.md index 3b97c71..80020b6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -16,10 +16,14 @@ disclosure after a fix is available. This is a target, not an SLA. ## Supported versions Until the first tagged stable release, security fixes are applied only to the -latest commit on the default branch and the current Bellwire Cloud deployment. +latest `0.x` release, the default branch, and the current Bellwire Cloud deployment. Forks and self-hosted deployments are responsible for applying updates and rotating their own Apple, Cloudflare, Better Auth, Agent, and Ingest credentials. +The [security model and operations guide](docs/security-model.md) documents +trust boundaries, backup/restore expectations, incident response, and the +forward-only D1 migration policy. + ## Secret handling - Never commit `.dev.vars`, `.p8` files, Auth/internal secrets, APNs private keys, diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..5bc3163 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,34 @@ + + +# Third-party notices + +Bellwire includes or depends on third-party software. Each component remains +subject to its own license; Bellwire's repository licenses do not replace those +terms. + +## Bundled material + +### SwiftUI-Agent-Skill + +- Project: `SwiftUI-Agent-Skill` +- Copyright: Copyright (c) 2026 Paul Hudson +- License: MIT +- Source revision used here: `be297e146e9080167780afabeee896873c6fc1c5` +- Bundled path: `.agents/skills/swiftui-pro/**` +- License text: [`.agents/skills/swiftui-pro/LICENSE`](.agents/skills/swiftui-pro/LICENSE) + +The bundled skill is development guidance and is not linked into the Bellwire +iOS application binary. + +## Package dependencies + +JavaScript production and development dependencies, resolved versions, +integrity hashes, and declared license metadata are recorded in +`package-lock.json`. Each GitHub release also includes a CycloneDX npm SBOM. +Run the following against a checkout to produce the same machine-readable +inventory: + +```bash +npm ci +npm sbom --sbom-format=cyclonedx +``` diff --git a/docs/lovable-ui-mapping.md b/docs/lovable-ui-mapping.md index 1d7a9df..6b831c5 100644 --- a/docs/lovable-ui-mapping.md +++ b/docs/lovable-ui-mapping.md @@ -1,12 +1,12 @@ -# Bellwire Lovable → Native iOS UI Mapping +# Bellwire Lovable → Native iOS UI Mapping (historical) > [!IMPORTANT] > 本文保留旧 Lovable 到原生 iOS 的功能映射和业务逻辑边界。新的视觉主题、字体、Surface、吉祥物使用和逐屏布局以 [Bellwire UI 重构总纲](./brand/ui-redesign-blueprint.md) 为准;旧暖纸色与衬线视觉方向不再作为实现依据。 ## Scope and source of truth -- Design reference (read-only): `/Users/xwchris/projects/agent-whisper-sync`, HEAD `7ac14e8f46b9df97f4c6159e2571e3b73d4076e0`. -- Native app (editable): `/Users/xwchris/projects/agentpush/ios/Bellwire`. +- Design reference (historical): the former `agent-whisper-sync` repository at commit `7ac14e8f46b9df97f4c6159e2571e3b73d4076e0`. +- Native app: [`../ios/Bellwire`](../ios/Bellwire). - Functional source of truth: the existing Swift models, `AppModel`, `APIClient`, Keychain session, APNs delegate, deep-link handling, and server responses. - Visual source of truth: Lovable `src/routes/index.tsx`, `src/styles.css`, and `src/routes/__root.tsx`. - The Lovable phone frame, fake status bar/notch, showcase captions, fixed browser dimensions, hover behavior, and web backdrop implementation are explicitly excluded. @@ -148,12 +148,12 @@ Dynamic metrics use monospaced digits. Text uses semantic styles or `@ScaledMetr ## 9. Risks and mitigations -1. **All iOS source is currently untracked.** There is no Git baseline for per-line restoration. Limit edits to known UI files and verify non-UI file hashes/status remain unchanged. +1. **This document is historical.** Treat the current tracked Swift sources and tests as authoritative; do not infer implementation status from this original migration checklist. 2. **Projects have no dedicated unread/latest-event fields.** Derive badges/latest event from the already loaded `events` collection; do not fabricate values. 3. **Running status is not on `ProjectSummary`.** Derive a running affordance only from matching progress/segmented/timer live surfaces; otherwise show the real project status. 4. **Event schema version is not returned in `EventDetail`.** Do not invent a version. Show the event type; omit version or label it only where a real schema record can be matched. 5. **Rotate endpoint is unsupported.** Keep endpoint copy; do not surface an enabled Rotate control. -6. **Privacy/Terms URLs are not present in current configuration.** Do not invent URLs. Keep legal copy non-interactive or mark the row unavailable until a real route is configured. +6. **Privacy/Terms URLs are deployment configuration.** Use `AppConfig` values generated through xcconfig; do not hard-code fork-specific legal endpoints in Swift. 7. **Offline is not separately modeled.** Existing connection errors are presented as actionable offline/error state while preserving loaded content when available. 8. **Notification denial after onboarding.** Settings must expose real permission state and open the native iOS Settings app. 9. **Small devices and Dynamic Type.** Avoid fixed screen heights, let cards wrap, keep controls at least 44pt, and use scrollable onboarding content for compact height. diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..87c2f02 --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,702 @@ +# SPDX-License-Identifier: Apache-2.0 +openapi: 3.1.0 +info: + title: Bellwire API + version: 0.2.0 + description: >- + Public Bellwire business API. User requests use an ES256 Bellwire Auth JWT; + Agent and runtime requests use a scoped bearer token. The separately + deployed Auth Worker owns native Apple sign-in and session lifecycle. + license: + name: AGPL-3.0-only + identifier: AGPL-3.0-only +servers: + - url: https://api.bellwire.app + description: Bellwire Cloud + - url: http://localhost:8787 + description: Local development +security: + - bearerAuth: [] +tags: + - name: System + - name: Account + - name: Connections + - name: Devices + - name: Projects + - name: Surfaces + - name: Events + - name: Delivery + - name: Billing +paths: + /health: + get: + tags: [System] + operationId: getHealth + security: [] + responses: + "200": + description: Worker health and compatibility metadata + content: + application/json: + schema: + $ref: "#/components/schemas/Health" + /v1/account/entitlement: + get: + tags: [Account] + operationId: getAccountEntitlement + responses: + "200": + description: Deployment mode, billing state, usage, limits, and capabilities + content: + application/json: + schema: + $ref: "#/components/schemas/AccountEntitlement" + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/account: + delete: + tags: [Account] + operationId: deleteAccount + responses: + "204": { description: Account deleted } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/analytics/events: + post: + tags: [Account] + operationId: captureProductEvent + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/AnalyticsEventInput" } + responses: + "204": { description: Event accepted or analytics disabled } + "400": { $ref: "#/components/responses/InvalidRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/device-bindings: + post: + tags: [Connections] + operationId: createDeviceBinding + responses: + "201": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/device-bindings/confirm: + post: + tags: [Connections] + operationId: confirmDeviceBinding + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code] + properties: + code: { type: string, pattern: "^[0-9]{6}$" } + responses: + "201": { $ref: "#/components/responses/JsonResource" } + "400": { $ref: "#/components/responses/InvalidRequest" } + /v1/agent-connections: + get: + tags: [Connections] + operationId: listAgentConnections + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/agent-connections/{connectionId}: + delete: + tags: [Connections] + operationId: revokeAgentConnection + parameters: + - $ref: "#/components/parameters/ConnectionId" + responses: + "204": { description: Connection revoked } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/direct-connections: + get: + tags: [Connections] + operationId: listDirectConnectionEnvelopes + parameters: + - in: query + name: deviceKeyId + required: true + schema: { type: string } + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + post: + tags: [Connections] + operationId: createDirectConnectionEnvelope + requestBody: { $ref: "#/components/requestBodies/JsonBody" } + responses: + "201": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/direct-connections/{envelopeId}/ack: + post: + tags: [Connections] + operationId: acknowledgeDirectConnectionEnvelope + parameters: + - $ref: "#/components/parameters/EnvelopeId" + requestBody: { $ref: "#/components/requestBodies/JsonBody" } + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/direct-connection-recoveries: + get: + tags: [Connections] + operationId: listDirectConnectionRecoveries + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/device-keys: + post: + tags: [Devices] + operationId: registerDeviceKey + requestBody: { $ref: "#/components/requestBodies/JsonBody" } + responses: + "201": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/devices: + get: + tags: [Devices] + operationId: listDevices + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + post: + tags: [Devices] + operationId: registerDevice + requestBody: { $ref: "#/components/requestBodies/JsonBody" } + responses: + "201": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/devices/{deviceId}: + delete: + tags: [Devices] + operationId: deleteDevice + parameters: + - $ref: "#/components/parameters/DeviceId" + responses: + "204": { description: Device deleted } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/devices/live-activity-capability: + put: + tags: [Devices] + operationId: updateLiveActivityCapability + requestBody: { $ref: "#/components/requestBodies/JsonBody" } + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/live-activities/{activityId}: + put: + tags: [Devices] + operationId: registerLiveActivity + parameters: + - $ref: "#/components/parameters/ActivityId" + requestBody: { $ref: "#/components/requestBodies/JsonBody" } + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + delete: + tags: [Devices] + operationId: deleteLiveActivity + parameters: + - $ref: "#/components/parameters/ActivityId" + responses: + "204": { description: Live Activity registration deleted } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/demo: + post: + tags: [Projects] + operationId: createDemoExperience + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "201": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/projects: + get: + tags: [Projects] + operationId: listProjects + responses: + "200": + description: Owned projects + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/Project" } + "401": { $ref: "#/components/responses/Unauthorized" } + post: + tags: [Projects] + operationId: createProject + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CreateProjectInput" } + responses: + "201": + description: Project created + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "400": { $ref: "#/components/responses/InvalidRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/projects/{projectId}: + get: + tags: [Projects] + operationId: getProject + parameters: + - $ref: "#/components/parameters/ProjectId" + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + tags: [Projects] + operationId: updateProject + parameters: + - $ref: "#/components/parameters/ProjectId" + requestBody: { $ref: "#/components/requestBodies/JsonBody" } + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + delete: + tags: [Projects] + operationId: deleteProject + parameters: + - $ref: "#/components/parameters/ProjectId" + responses: + "204": { description: Project and owned data deleted } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/projects/{projectId}/order: + patch: + tags: [Projects] + operationId: reorderProject + parameters: + - $ref: "#/components/parameters/ProjectId" + requestBody: { $ref: "#/components/requestBodies/JsonBody" } + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/projects/{projectId}/delivery-mode-requests: + post: + tags: [Projects] + operationId: requestDeliveryModeChange + parameters: + - $ref: "#/components/parameters/ProjectId" + requestBody: { $ref: "#/components/requestBodies/JsonBody" } + responses: + "201": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/delivery-mode-requests: + get: + tags: [Projects] + operationId: listDeliveryModeRequests + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/delivery-mode-requests/{requestId}/approve: + post: + tags: [Projects] + operationId: approveDeliveryModeRequest + parameters: + - $ref: "#/components/parameters/RequestId" + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/delivery-mode-requests/{requestId}/reject: + post: + tags: [Projects] + operationId: rejectDeliveryModeRequest + parameters: + - $ref: "#/components/parameters/RequestId" + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/projects/{projectId}/direct-connection-recovery: + post: + tags: [Connections] + operationId: requestDirectConnectionRecovery + parameters: + - $ref: "#/components/parameters/ProjectId" + requestBody: { $ref: "#/components/requestBodies/JsonBody" } + responses: + "202": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/projects/{projectId}/event-schemas: + post: + tags: [Events] + operationId: createEventSchema + parameters: + - $ref: "#/components/parameters/ProjectId" + requestBody: { $ref: "#/components/requestBodies/JsonBody" } + responses: + "201": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/projects/{projectId}/notification-surfaces: + post: + tags: [Surfaces] + operationId: createNotificationSurface + parameters: + - $ref: "#/components/parameters/ProjectId" + requestBody: { $ref: "#/components/requestBodies/JsonBody" } + responses: + "201": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/surfaces: + get: + tags: [Surfaces] + operationId: listSurfaces + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/projects/{projectId}/surfaces: + get: + tags: [Surfaces] + operationId: listProjectSurfaces + parameters: + - $ref: "#/components/parameters/ProjectId" + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/projects/{projectId}/surfaces/{surfaceKey}: + put: + tags: [Surfaces] + operationId: upsertSurface + parameters: + - $ref: "#/components/parameters/ProjectId" + - $ref: "#/components/parameters/SurfaceKey" + requestBody: { $ref: "#/components/requestBodies/JsonBody" } + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "201": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + delete: + tags: [Surfaces] + operationId: deleteSurface + parameters: + - $ref: "#/components/parameters/ProjectId" + - $ref: "#/components/parameters/SurfaceKey" + responses: + "204": { description: Surface ended and deleted } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/projects/{projectId}/surfaces/{surfaceKey}/order: + patch: + tags: [Surfaces] + operationId: reorderSurface + parameters: + - $ref: "#/components/parameters/ProjectId" + - $ref: "#/components/parameters/SurfaceKey" + requestBody: { $ref: "#/components/requestBodies/JsonBody" } + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/projects/{projectId}/ingest-tokens: + post: + tags: [Events] + operationId: createIngestToken + parameters: + - $ref: "#/components/parameters/ProjectId" + requestBody: { $ref: "#/components/requestBodies/JsonBody" } + responses: + "201": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/projects/{projectId}/ingest-tokens/{tokenId}: + delete: + tags: [Events] + operationId: revokeIngestToken + parameters: + - $ref: "#/components/parameters/ProjectId" + - $ref: "#/components/parameters/TokenId" + responses: + "204": { description: Token revoked } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/projects/{projectId}/wake-tokens: + post: + tags: [Events] + operationId: createWakeToken + parameters: + - $ref: "#/components/parameters/ProjectId" + requestBody: { $ref: "#/components/requestBodies/JsonBody" } + responses: + "201": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/projects/{projectId}/wake-tokens/{tokenId}: + delete: + tags: [Events] + operationId: revokeWakeToken + parameters: + - $ref: "#/components/parameters/ProjectId" + - $ref: "#/components/parameters/TokenId" + responses: + "204": { description: Token revoked } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/projects/{projectId}/private-wakes: + post: + tags: [Events] + operationId: ingestPrivateWake + parameters: + - $ref: "#/components/parameters/ProjectId" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PrivateWakeInput" } + responses: + "202": { $ref: "#/components/responses/JsonResource" } + "400": { $ref: "#/components/responses/InvalidRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/events/{id}: + post: + tags: [Events] + operationId: ingestHostedEvent + parameters: + - $ref: "#/components/parameters/EventRouteId" + - in: header + name: Idempotency-Key + required: true + schema: { type: string, minLength: 8, maxLength: 200 } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/IngestEventInput" } + responses: + "200": { description: Existing idempotent Event returned } + "201": { description: Event accepted } + "400": { $ref: "#/components/responses/InvalidRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + get: + tags: [Events] + operationId: getEvent + parameters: + - $ref: "#/components/parameters/EventRouteId" + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + /v1/projects/{projectId}/events/test: + post: + tags: [Events] + operationId: sendTestEvent + parameters: + - $ref: "#/components/parameters/ProjectId" + requestBody: { $ref: "#/components/requestBodies/JsonBody" } + responses: + "201": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/projects/{projectId}/events: + get: + tags: [Events] + operationId: listProjectEvents + parameters: + - $ref: "#/components/parameters/ProjectId" + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/inbox: + get: + tags: [Events] + operationId: listInbox + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/inbox/read-all: + post: + tags: [Events] + operationId: markInboxRead + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/events/{eventId}/read: + post: + tags: [Events] + operationId: markEventRead + parameters: + - $ref: "#/components/parameters/EventId" + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/events/{eventId}/deliveries: + get: + tags: [Delivery] + operationId: listEventDeliveries + parameters: + - $ref: "#/components/parameters/EventId" + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/projects/{projectId}/delivery-health: + get: + tags: [Delivery] + operationId: getDeliveryHealth + parameters: + - $ref: "#/components/parameters/ProjectId" + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/projects/{projectId}/export: + get: + tags: [Delivery] + operationId: exportProject + parameters: + - $ref: "#/components/parameters/ProjectId" + responses: + "200": + description: Hosted Event and delivery history export + content: + application/json: + schema: { type: object, additionalProperties: true } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { description: Capability unavailable for this deployment or entitlement } + /v1/billing/apple/transactions: + post: + tags: [Billing] + operationId: submitAppleTransaction + requestBody: { $ref: "#/components/requestBodies/JsonBody" } + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "401": { $ref: "#/components/responses/Unauthorized" } + /v1/billing/apple/notifications: + post: + tags: [Billing] + operationId: processAppleServerNotification + security: [] + requestBody: { $ref: "#/components/requestBodies/JsonBody" } + responses: + "200": { $ref: "#/components/responses/JsonResource" } + "400": { $ref: "#/components/responses/InvalidRequest" } +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: ES256 JWT, Agent token, Ingest token, or wake token + parameters: + ActivityId: { in: path, name: activityId, required: true, schema: { type: string } } + ConnectionId: { in: path, name: connectionId, required: true, schema: { type: string } } + DeviceId: { in: path, name: deviceId, required: true, schema: { type: string } } + EnvelopeId: { in: path, name: envelopeId, required: true, schema: { type: string } } + EventRouteId: + in: path + name: id + required: true + description: Project ID for POST; Event ID for GET. + schema: { type: string } + EventId: { in: path, name: eventId, required: true, schema: { type: string } } + ProjectId: { in: path, name: projectId, required: true, schema: { type: string } } + RequestId: { in: path, name: requestId, required: true, schema: { type: string } } + SurfaceKey: { in: path, name: surfaceKey, required: true, schema: { type: string } } + TokenId: { in: path, name: tokenId, required: true, schema: { type: string } } + requestBodies: + JsonBody: + required: true + content: + application/json: + schema: { type: object, additionalProperties: true } + responses: + InvalidRequest: + description: Invalid request + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + JsonResource: + description: JSON resource + content: + application/json: + schema: { type: object, additionalProperties: true } + NotFound: + description: Owned resource not found + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Unauthorized: + description: Missing, invalid, expired, or insufficiently scoped credentials + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + schemas: + Error: + type: object + required: [error] + properties: + error: { type: string } + Health: + type: object + required: [status, service, compatibility] + properties: + status: { const: ok } + service: { const: bellwire-api } + compatibility: + type: object + required: [appVersion, apiVersion, schemaMigration] + properties: + appVersion: { type: string } + apiVersion: { const: v1 } + schemaMigration: { type: string } + EntitlementCapabilities: + type: object + required: [billing, commercialLimitsEnforced, projectExport, liveActivities] + properties: + billing: { type: boolean } + commercialLimitsEnforced: { type: boolean } + projectExport: { type: boolean } + liveActivities: { type: boolean } + AccountEntitlement: + type: object + required: [deployment, plan, status, capabilities, limits, usage] + properties: + deployment: { type: string, enum: [hosted, self_hosted] } + plan: { type: string, enum: [free, pro] } + status: { type: string } + productId: { type: [string, "null"] } + capabilities: { $ref: "#/components/schemas/EntitlementCapabilities" } + limits: { type: object, additionalProperties: true } + usage: { type: object, additionalProperties: true } + Project: + type: object + required: [id, name, slug, status, deliveryMode, endpoint, createdAt, updatedAt] + properties: + id: { type: string } + name: { type: string } + slug: { type: string } + icon: { type: string } + logoUrl: { type: string, format: uri } + category: { type: string } + status: { type: string, enum: [active, paused] } + deliveryMode: { type: string, enum: [private, hosted] } + endpoint: { type: string } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + CreateProjectInput: + type: object + required: [name] + properties: + name: { type: string, minLength: 1 } + slug: { type: string } + icon: { type: string } + logoUrl: { type: string, format: uri } + category: { type: string } + deliveryMode: { type: string, enum: [private, hosted], default: private } + IngestEventInput: + type: object + required: [type, data] + properties: + type: { type: string } + data: { type: object, additionalProperties: true } + occurredAt: { type: string, format: date-time } + PrivateWakeInput: + type: object + required: [reference] + additionalProperties: false + properties: + reference: { type: string, minLength: 22, maxLength: 200, pattern: "^[A-Za-z0-9_-]+$" } + priority: { type: string, enum: [normal, high] } + AnalyticsEventInput: + type: object + required: [event] + additionalProperties: false + properties: + event: { type: string } + properties: { type: object, additionalProperties: true } diff --git a/docs/security-model.md b/docs/security-model.md new file mode 100644 index 0000000..bf068b7 --- /dev/null +++ b/docs/security-model.md @@ -0,0 +1,52 @@ + + +# Security model and operations + +## Trust boundaries + +- The public Auth Worker handles Apple identity exchange and issues short-lived + ES256 access tokens. Its Auth D1 and Apple token-encryption secret are isolated + from the business API. +- The API Worker authenticates user JWTs or scoped Agent tokens before owned + resource access. Hosted ingestion uses project-scoped Ingest tokens. +- Private delivery sends only an opaque wake through Bellwire infrastructure; + the device fetches content from the user's signed HTTPS service. +- APNs, Apple, Cloudflare, and an optional PostHog instance are external trust + boundaries. A healthy Worker does not by itself prove those providers are + available. +- Self-hosted operators own account security, secrets, domain routing, data + retention, backups, costs, and incident response. + +Primary threats include credential theft, cross-account authorization mistakes, +replayed Direct requests, malicious event payloads, notification data leakage, +dependency compromise, and destructive schema changes. Controls include scoped +tokens, ownership checks, signed Direct v2 requests, timestamp/replay checks, +bounded JSON bodies, sensitive-field restrictions, immutable CI actions, CodeQL, +Dependabot, migration review, and secret-free local configuration. + +## Backup and restore + +Back up both D1 databases before migrations and periodically according to the +deployment's recovery objectives. Store exports outside the repository with +restricted access. A backup is not considered valid until it has been restored +into separate D1 databases and its Auth and owned-resource queries have been +verified. Record the associated code version and migration set with each backup. + +Worker rollback and data restore are separate operations. D1 migrations are +forward-only in automation; see [the upgrading guide](upgrading.md). + +## Incident response + +1. Preserve relevant logs and identify the affected deployment, identities, + resources, credentials, versions, and time window. +2. Contain access by revoking or rotating the narrowest affected Cloudflare, + Apple, APNs, Auth, Agent, Ingest, or PostHog credential. Do not print secrets + while investigating. +3. Patch and validate in an isolated environment, including authorization and + provider-boundary checks. +4. Restore or forward-repair data from a tested backup when necessary; do not + assume Worker rollback reverses a D1 migration. +5. Notify affected operators or users and coordinate disclosure under + [SECURITY.md](../SECURITY.md). +6. Document root cause, timeline, corrective actions, and any new regression + test without publishing active credentials or user data. diff --git a/docs/self-hosting.md b/docs/self-hosting.md index c5761f9..5799dba 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -47,6 +47,12 @@ Use the D1 UUIDs printed by Wrangler: ```bash npm run self-host:bootstrap -- \ + --app-name SignalNest \ + --app-icon /secure/signalnest-app-icon-1024.png \ + --support-email support@example.com \ + --privacy-url https://example.com/privacy \ + --terms-url https://example.com/terms \ + --support-url https://example.com/support \ --team-id ABC123DEFG \ --bundle-id com.example.bellwire \ --url-scheme bellwire-self-host \ @@ -57,14 +63,25 @@ npm run self-host:bootstrap -- \ --auth-d1-id 22222222-2222-4222-8222-222222222222 ``` -The command creates three ignored files and refuses to overwrite any of them: +The command creates three ignored configuration files and one ignored app-icon +asset set, and refuses to overwrite any of them: - `ios/Bellwire/Configuration/Local.xcconfig` - `wrangler.self-host.toml` - `wrangler.auth.self-host.toml` - -The iOS file contains only public Worker origins and Apple identifiers. The two -Wrangler files contain resource bindings but no secrets. +- `ios/Bellwire/Bellwire/Assets.xcassets/SelfHostedAppIcon.appiconset` + +The icon must be a regular 1024×1024 PNG. The iOS file contains only public +Worker origins, Apple identifiers, custom support/legal endpoints, and disables +App Store billing. The two Wrangler files contain resource bindings but no +secrets. Self-hosted entitlement responses explicitly expose the deployment +mode and capabilities and do not enforce Bellwire Cloud commercial limits. + +The bootstrap replaces the app name and icon, but it does not generate new +mascot or logo illustrations. The official Bellwire mascot, logo, app icon, and +other brand artwork listed in `LICENSE.md` remain reserved. A redistributed +visual fork must replace or remove those assets as required by `TRADEMARK.md`; +private deployments may keep them only to the extent permitted by those terms. ## 4. Apply both D1 schemas diff --git a/docs/telemetry.md b/docs/telemetry.md new file mode 100644 index 0000000..0ceb22e --- /dev/null +++ b/docs/telemetry.md @@ -0,0 +1,41 @@ + + +# Telemetry + +Bellwire's Worker telemetry is optional, fail-open product analytics. It does +not control authentication, delivery, billing state, or API responses. + +## Enable or disable + +Telemetry is disabled when the API Worker has no `POSTHOG_PROJECT_KEY` secret. +Self-hosted configuration generated by `self-host:bootstrap` omits this secret, +so analytics is off by default. To enable it, set `POSTHOG_PROJECT_KEY` and, +optionally, the public `POSTHOG_HOST` Worker variable for your own PostHog +deployment. + +Removing the secret disables new captures: + +```bash +npx wrangler secret delete POSTHOG_PROJECT_KEY -c wrangler.self-host.toml +``` + +## Events and fields + +The allow-listed event names are: + +- `pricing_viewed`, `paywall_viewed`, `upgrade_clicked`, and + `subscription_managed` +- `trial_started`, `subscription_purchased`, `subscription_restored`, + `subscription_expired`, and `subscription_refunded` +- `quota_warning_80`, `quota_reached_100`, `quota_grace_used`, and + `quota_rejected` + +Only these property names are accepted: `plan`, `productId`, `deliveryMode`, +`usagePercent`, `projectCount`, `deviceCount`, `storefront`, `appVersion`, and +`source`. Values must be strings, numbers, or booleans. Event payloads, +notification content, project names, APNs tokens, email addresses, Apple tokens, +Agent tokens, and Ingest tokens are not accepted as analytics properties. + +The PostHog `distinct_id` is the internal Bellwire user ID. Operators enabling +telemetry are responsible for disclosure, retention, access controls, and +deletion obligations applicable to their deployment. diff --git a/docs/upgrading.md b/docs/upgrading.md new file mode 100644 index 0000000..6d34a4e --- /dev/null +++ b/docs/upgrading.md @@ -0,0 +1,53 @@ + + +# Upgrading Bellwire + +Bellwire has four independent version axes: + +- repository releases follow Semantic Versioning, currently `0.2.0`; +- the native App Store marketing version remains independently versioned; +- HTTP routes use the `/v1` compatibility namespace; +- D1 schemas advance through ordered migration files in `d1/auth` and + `d1/business`. + +A repository release does not imply an App Store submission, an API namespace +change, or a migration reset. + +## Before upgrading + +1. Read `CHANGELOG.md` and compare local configuration with the checked-in + `.example` and development TOML files. +2. Export or copy both Auth and business D1 databases using Cloudflare's current + backup/export facilities, and record the deployed Auth and API Worker version + IDs. +3. Run `npm ci`, `npm run migrations:check`, `npm test`, and `npm run build`. +4. For the iOS fork, run `npm run ios:test:unit` and `npm run ios:build`. +5. Run `npm run self-host:doctor` before any remote change. + +## Upgrade order + +1. Apply Auth D1 migrations. +2. Deploy the Auth Worker and verify `/health` plus the ES256 JWKS. +3. Apply business D1 migrations. +4. Deploy the API Worker and verify `/health` and the unauthenticated `401` + boundary. +5. Run `npm run self-host:doctor -- --online` and complete a device acceptance + check before distributing a new iOS build. + +Migrations are forward-only. The automated guard rejects destructive SQL unless +the migration contains `bellwire-migration: destructive-reviewed`, but that +annotation records review rather than making the operation reversible. Worker +code can roll back to a recorded version; migrated data cannot be rolled back by +the deployment workflow. + +## Compatibility and recovery + +Use expand/contract changes: add compatible columns or records first, deploy +readers and writers that tolerate both shapes, backfill, then remove obsolete +data only in a separately reviewed release. If Worker verification fails after +a migration, roll back Worker code only when it remains compatible with the new +schema. Otherwise deploy a forward fix. + +Test D1 restoration in a separate database before treating a backup as usable. +Rotate Worker, APNs, Apple, Agent, and Ingest credentials if an upgrade exposes +them. Never copy production secrets into local development configuration. diff --git a/ios/Bellwire/Bellwire.xcodeproj/project.pbxproj b/ios/Bellwire/Bellwire.xcodeproj/project.pbxproj index fec5f2b..c36c8a4 100644 --- a/ios/Bellwire/Bellwire.xcodeproj/project.pbxproj +++ b/ios/Bellwire/Bellwire.xcodeproj/project.pbxproj @@ -47,6 +47,8 @@ B00000000000000000000027 /* TrendSurfaceCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00000000000000000000032 /* TrendSurfaceCard.swift */; }; B10000000000000000000001 /* BellwireRefreshIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10000000000000000000001 /* BellwireRefreshIndicator.swift */; }; B10000000000000000000002 /* BellwireRefreshScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10000000000000000000002 /* BellwireRefreshScrollView.swift */; }; + BT0000000000000000000001 /* EntitlementCapabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FT0000000000000000000001 /* EntitlementCapabilityTests.swift */; }; + BT0000000000000000000002 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FT0000000000000000000003 /* XCTest.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -64,6 +66,13 @@ remoteGlobalIDString = A00000000000000000000003; remoteInfo = BellwireWidgets; }; + HT0000000000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A30000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A00000000000000000000001; + remoteInfo = Bellwire; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -132,6 +141,9 @@ F00000000000000000000032 /* TrendSurfaceCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendSurfaceCard.swift; sourceTree = ""; }; F10000000000000000000001 /* BellwireRefreshIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BellwireRefreshIndicator.swift; sourceTree = ""; }; F10000000000000000000002 /* BellwireRefreshScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BellwireRefreshScrollView.swift; sourceTree = ""; }; + FT0000000000000000000001 /* EntitlementCapabilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitlementCapabilityTests.swift; sourceTree = ""; }; + FT0000000000000000000002 /* BellwireTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BellwireTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + FT0000000000000000000003 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = System/Library/Frameworks/XCTest.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -153,6 +165,14 @@ files = (); runOnlyForDeploymentPostprocessing = 0; }; + DT0000000000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + BT0000000000000000000002 /* XCTest.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -164,6 +184,7 @@ E00000000000000000000006 /* BellwireShared */, E00000000000000000000004 /* BellwireNotificationService */, E00000000000000000000007 /* BellwireWidgets */, + ET0000000000000000000001 /* BellwireTests */, E00000000000000000000003 /* Products */, ); sourceTree = ""; @@ -217,6 +238,7 @@ F00000000000000000000011 /* Bellwire.app */, F00000000000000000000016 /* BellwireNotificationService.appex */, F00000000000000000000026 /* BellwireWidgets.appex */, + FT0000000000000000000002 /* BellwireTests.xctest */, ); name = Products; sourceTree = ""; @@ -258,6 +280,15 @@ path = BellwireWidgets; sourceTree = ""; }; + ET0000000000000000000001 /* BellwireTests */ = { + isa = PBXGroup; + children = ( + FT0000000000000000000001 /* EntitlementCapabilityTests.swift */, + FT0000000000000000000003 /* XCTest.framework */, + ); + path = BellwireTests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -310,6 +341,23 @@ productReference = F00000000000000000000026 /* BellwireWidgets.appex */; productType = "com.apple.product-type.app-extension"; }; + AT0000000000000000000001 /* BellwireTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = CT0000000000000000000001 /* Build configuration list for PBXNativeTarget "BellwireTests" */; + buildPhases = ( + AT0000000000000000000002 /* Sources */, + DT0000000000000000000001 /* Frameworks */, + AT0000000000000000000003 /* Resources */, + ); + buildRules = (); + dependencies = ( + GT0000000000000000000001 /* PBXTargetDependency */, + ); + name = BellwireTests; + productName = BellwireTests; + productReference = FT0000000000000000000002 /* BellwireTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -323,6 +371,7 @@ A00000000000000000000001 = { CreatedOnToolsVersion = 26.0; }; A00000000000000000000002 = { CreatedOnToolsVersion = 26.0; }; A00000000000000000000003 = { CreatedOnToolsVersion = 26.0; }; + AT0000000000000000000001 = { CreatedOnToolsVersion = 26.0; TestTargetID = A00000000000000000000001; }; }; }; buildConfigurationList = C00000000000000000000001 /* Build configuration list for PBXProject "Bellwire" */; @@ -338,6 +387,7 @@ A00000000000000000000001 /* Bellwire */, A00000000000000000000002 /* BellwireNotificationService */, A00000000000000000000003 /* BellwireWidgets */, + AT0000000000000000000001 /* BellwireTests */, ); }; /* End PBXProject section */ @@ -388,6 +438,12 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AT0000000000000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = (); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -447,6 +503,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AT0000000000000000000002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BT0000000000000000000001 /* EntitlementCapabilityTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -460,6 +524,11 @@ target = A00000000000000000000003 /* BellwireWidgets */; targetProxy = H00000000000000000000002 /* PBXContainerItemProxy */; }; + GT0000000000000000000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A00000000000000000000001 /* Bellwire */; + targetProxy = HT0000000000000000000001 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -495,7 +564,7 @@ VALIDATE_PRODUCT = YES; }; name = Release; }; C20000000000000000000001 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "$(BELLWIRE_APP_ICON_NAME)"; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Bellwire/Bellwire.entitlements; CODE_SIGN_STYLE = Automatic; @@ -516,7 +585,7 @@ TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; C20000000000000000000002 /* Release */ = {isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "$(BELLWIRE_APP_ICON_NAME)"; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Bellwire/Bellwire.entitlements; CODE_SIGN_STYLE = Automatic; @@ -612,6 +681,30 @@ SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; + CT0000000000000000000002 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = "$(BELLWIRE_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(BELLWIRE_APP_BUNDLE_ID).Tests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Bellwire.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Bellwire"; + }; name = Debug; }; + CT0000000000000000000003 /* Release */ = {isa = XCBuildConfiguration; buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = "$(BELLWIRE_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(BELLWIRE_APP_BUNDLE_ID).Tests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Bellwire.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Bellwire"; + }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -639,6 +732,12 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + CT0000000000000000000001 /* Build configuration list for PBXNativeTarget "BellwireTests" */ = { + isa = XCConfigurationList; + buildConfigurations = (CT0000000000000000000002 /* Debug */, CT0000000000000000000003 /* Release */); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = A30000000000000000000001 /* Project object */; diff --git a/ios/Bellwire/Bellwire.xcodeproj/xcshareddata/xcschemes/Bellwire.xcscheme b/ios/Bellwire/Bellwire.xcodeproj/xcshareddata/xcschemes/Bellwire.xcscheme index 9bd16b4..cab1a8a 100644 --- a/ios/Bellwire/Bellwire.xcodeproj/xcshareddata/xcschemes/Bellwire.xcscheme +++ b/ios/Bellwire/Bellwire.xcodeproj/xcshareddata/xcschemes/Bellwire.xcscheme @@ -5,9 +5,18 @@ + + + - + + + + + + + diff --git a/ios/Bellwire/Bellwire/AppConfig.swift b/ios/Bellwire/Bellwire/AppConfig.swift index 9b77a91..769fa01 100644 --- a/ios/Bellwire/Bellwire/AppConfig.swift +++ b/ios/Bellwire/Bellwire/AppConfig.swift @@ -2,12 +2,47 @@ import Foundation enum AppConfig { + enum BillingMode: String { + case appStore = "app_store" + case disabled + } + static let apiBaseURL = requiredURL(for: "BellwireAPIBaseURL") static let authBaseURL = requiredURL(for: "BellwireAuthBaseURL") + static let displayName = requiredValue(for: "CFBundleDisplayName") + static let supportEmail = requiredValue(for: "BellwireSupportEmail") + static let privacyURL = requiredURL(for: "BellwirePrivacyURL") + static let termsURL = requiredURL(for: "BellwireTermsURL") + static let supportURL = requiredURL(for: "BellwireSupportURL") static let keychainService = "\(Bundle.main.bundleIdentifier ?? "app.bellwire").session" - static let sharedDirectKeychainService = "app.bellwire.direct-shared" + static let sharedDirectKeychainService = "\(Bundle.main.bundleIdentifier ?? "app.bellwire").direct-shared" static let keychainAccessGroup = requiredValue(for: "BellwireKeychainAccessGroup") static let urlScheme = requiredValue(for: "BellwireURLScheme") + static let billingMode = requiredBillingMode() + static let monthlyProductID = requiredValue(for: "BellwireProMonthlyProductID") + static let yearlyProductID = requiredValue(for: "BellwireProYearlyProductID") + + static var billingEnabled: Bool { billingMode == .appStore } + static var hostedServiceDisplayName: String { + billingEnabled ? "Bellwire Cloud" : "\(displayName) server" + } + + static func branded( + _ value: String.LocalizationValue, + locale: Locale = .autoupdatingCurrent + ) -> String { + String(localized: value, locale: locale) + .replacingOccurrences(of: "Bellwire Cloud", with: hostedServiceDisplayName) + .replacingOccurrences(of: "Bellwire", with: displayName) + } + + private static func requiredBillingMode() -> BillingMode { + let value = requiredValue(for: "BellwireBillingMode") + guard let mode = BillingMode(rawValue: value) else { + preconditionFailure("Invalid BellwireBillingMode: \(value)") + } + return mode + } private static func requiredURL(for key: String) -> URL { let value = requiredValue(for: key) diff --git a/ios/Bellwire/Bellwire/AppModel.swift b/ios/Bellwire/Bellwire/AppModel.swift index 16f88c3..b0d906f 100644 --- a/ios/Bellwire/Bellwire/AppModel.swift +++ b/ios/Bellwire/Bellwire/AppModel.swift @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MPL-2.0 import AuthenticationServices import ActivityKit +import Combine import CryptoKit import Security import SwiftUI @@ -32,6 +33,15 @@ final class AppModel: ObservableObject { @Published private(set) var devices: [DeviceRecord] = [] @Published private(set) var agentConnections: [AgentConnectionRecord] = [] @Published private(set) var entitlement: AccountEntitlement? + + var canExportProjects: Bool { + entitlement?.canExportProjects ?? !AppConfig.billingEnabled + } + + var canUseLiveActivities: Bool { + entitlement?.canUseLiveActivities ?? !AppConfig.billingEnabled + } + @Published private(set) var pendingModeRequests: [DeliveryModeChangeRequest] = [] @Published private(set) var resolvingModeRequestID: String? @Published private(set) var modeRequestErrors: [String: String] = [:] @@ -485,7 +495,7 @@ final class AppModel: ObservableObject { } func exportProject(_ project: ProjectOverview) async throws -> URL { - guard entitlement?.hasPro == true else { + guard canExportProjects else { throw ProjectExportError.proRequired } let data: Data @@ -997,7 +1007,7 @@ final class AppModel: ObservableObject { } func startLiveActivity(for surface: LiveSurfaceRecord) async throws { - guard entitlement?.hasPro == true else { + guard canUseLiveActivities else { throw ProjectExportError.proRequired } try await NativeDisplayManager.shared.startLiveActivity(for: surface) @@ -1021,7 +1031,7 @@ final class AppModel: ObservableObject { await NativeDisplayManager.shared.synchronize( surfaces: liveSurfaces, projects: projects, - isPro: entitlement?.hasPro == true + isPro: canUseLiveActivities ) } @@ -1495,7 +1505,7 @@ private enum ProjectExportError: LocalizedError { case proRequired var errorDescription: String? { - String(localized: "Project export is included with Bellwire Pro.") + AppConfig.branded("Project export is included with Bellwire Pro.") } } diff --git a/ios/Bellwire/Bellwire/BellwireRefreshIndicator.swift b/ios/Bellwire/Bellwire/BellwireRefreshIndicator.swift index 96a5f07..6a4a84d 100644 --- a/ios/Bellwire/Bellwire/BellwireRefreshIndicator.swift +++ b/ios/Bellwire/Bellwire/BellwireRefreshIndicator.swift @@ -8,6 +8,7 @@ struct BellwireRefreshIndicator: View { let isCompleting: Bool @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.locale) private var locale var body: some View { MascotView( @@ -44,7 +45,7 @@ struct BellwireRefreshIndicator: View { if isCompleting { Text("Refresh complete") } else { - Text("Refreshing Bellwire") + Text(AppConfig.branded("Refreshing Bellwire", locale: locale)) } } diff --git a/ios/Bellwire/Bellwire/Components.swift b/ios/Bellwire/Bellwire/Components.swift index 91b318c..2c7a8d3 100644 --- a/ios/Bellwire/Bellwire/Components.swift +++ b/ios/Bellwire/Bellwire/Components.swift @@ -796,7 +796,8 @@ struct DeviceRowView: View { SettingsRowView( icon: BellwireIcons.device, title: device.name, - hint: device.appVersion.map { "Bellwire \($0)" } ?? "Bellwire" + hint: device.appVersion.map { "\(AppConfig.displayName) \($0)" } + ?? AppConfig.displayName ) { HStack(spacing: BellwireSpacing.compact) { StatusBadgeView( @@ -808,11 +809,13 @@ struct DeviceRowView: View { Menu { Button("Remove device", role: .destructive, action: onDelete) } label: { - Image(systemName: "ellipsis") + Label("Device options", systemImage: "ellipsis") + .labelStyle(.iconOnly) .font(.body.weight(.semibold)) .foregroundStyle(BellwireTheme.mutedInk) .frame(width: 36, height: 36) } + .accessibilityLabel("Device options for \(device.name)") } } } diff --git a/ios/Bellwire/Bellwire/DetailViews.swift b/ios/Bellwire/Bellwire/DetailViews.swift index cd981bd..c57bcee 100644 --- a/ios/Bellwire/Bellwire/DetailViews.swift +++ b/ios/Bellwire/Bellwire/DetailViews.swift @@ -432,7 +432,7 @@ struct ProjectDetailView: View { .foregroundStyle(BellwireTheme.danger) } } else { - Text("Bellwire Cloud stores Event, Inbox, Surface, and detailed notification content according to your plan retention.") + Text("\(AppConfig.hostedServiceDisplayName) stores Event, Inbox, Surface, and detailed notification content according to your plan retention.") .font(.caption) .foregroundStyle(BellwireTheme.secondaryInk) } @@ -450,7 +450,7 @@ struct ProjectDetailView: View { VStack(alignment: .leading, spacing: BellwireSpacing.small) { SectionHeaderView( title: "Plan & usage", - hint: entitlement.plan == "pro" ? "Pro" : "Free" + hint: entitlement.planDisplayName ) VStack(alignment: .leading, spacing: BellwireSpacing.standard) { HStack { @@ -776,7 +776,7 @@ struct ProjectDetailView: View { } private func export(_ project: ProjectOverview) { - guard model.entitlement?.hasPro == true else { + guard model.canExportProjects else { Task { await model.captureProductEvent("upgrade_clicked", source: "project_export") } showsPaywall = true return @@ -798,7 +798,7 @@ struct ProjectDetailView: View { } private func toggleLiveActivity(_ surface: LiveSurfaceRecord) { - guard model.entitlement?.hasPro == true else { + guard model.canUseLiveActivities else { Task { await model.captureProductEvent("upgrade_clicked", source: "live_activity") } showsPaywall = true return diff --git a/ios/Bellwire/Bellwire/InboxViews.swift b/ios/Bellwire/Bellwire/InboxViews.swift index ad7ece3..0f14856 100644 --- a/ios/Bellwire/Bellwire/InboxViews.swift +++ b/ios/Bellwire/Bellwire/InboxViews.swift @@ -407,6 +407,7 @@ enum EventFilter: String, CaseIterable, Identifiable { } private struct FirstSessionActivationView: View { + @Environment(\.locale) private var locale let isGeneratingBinding: Bool let isCreatingHostedDemo: Bool let connectAgent: () -> Void @@ -482,10 +483,16 @@ private struct FirstSessionActivationView: View { } .buttonStyle(PressableButtonStyle()) .disabled(isGeneratingBinding || isCreatingHostedDemo) - .accessibilityHint("Creates a Hosted sample in Bellwire Cloud") + .accessibilityHint(AppConfig.branded( + "Creates a Hosted sample in Bellwire Cloud", + locale: locale + )) Label { - Text("Sample content is stored in Bellwire Cloud. Delete the demo at any time.") + Text(AppConfig.branded( + "Sample content is stored in Bellwire Cloud. Delete the demo at any time.", + locale: locale + )) .fixedSize(horizontal: false, vertical: true) } icon: { Image(systemName: "cloud.fill") diff --git a/ios/Bellwire/Bellwire/Info.plist b/ios/Bellwire/Bellwire/Info.plist index 7412175..37abc65 100644 --- a/ios/Bellwire/Bellwire/Info.plist +++ b/ios/Bellwire/Bellwire/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Bellwire + $(BELLWIRE_APP_DISPLAY_NAME) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -22,6 +22,20 @@ $(BELLWIRE_API_BASE_URL) BellwireAuthBaseURL $(BELLWIRE_AUTH_BASE_URL) + BellwireBillingMode + $(BELLWIRE_BILLING_MODE) + BellwireProMonthlyProductID + $(BELLWIRE_PRO_MONTHLY_PRODUCT_ID) + BellwireProYearlyProductID + $(BELLWIRE_PRO_YEARLY_PRODUCT_ID) + BellwireSupportEmail + $(BELLWIRE_SUPPORT_EMAIL) + BellwirePrivacyURL + $(BELLWIRE_PRIVACY_URL) + BellwireTermsURL + $(BELLWIRE_TERMS_URL) + BellwireSupportURL + $(BELLWIRE_SUPPORT_URL) BellwireKeychainAccessGroup $(AppIdentifierPrefix)$(BELLWIRE_APP_BUNDLE_ID).shared BellwireAppGroup diff --git a/ios/Bellwire/Bellwire/Models.swift b/ios/Bellwire/Bellwire/Models.swift index 2078b57..4f3b9b2 100644 --- a/ios/Bellwire/Bellwire/Models.swift +++ b/ios/Bellwire/Bellwire/Models.swift @@ -579,6 +579,8 @@ struct PrivateEventPayload: Codable { struct AccountEntitlement: Codable { let plan: String let status: String + let deployment: String? + let capabilities: EntitlementCapabilities? let productId: String? let expiresAt: String? let downgradeDeadline: String? @@ -587,7 +589,23 @@ struct AccountEntitlement: Codable { let activeProjects: Int let activeDevices: Int - var hasPro: Bool { plan == "pro" && (status == "active" || status == "grace") } + var isSelfHosted: Bool { deployment == "self_hosted" } + + var hasPro: Bool { + isSelfHosted || (plan == "pro" && (status == "active" || status == "grace")) + } + + var canExportProjects: Bool { capabilities?.projectExport ?? hasPro } + var canUseLiveActivities: Bool { capabilities?.liveActivities ?? hasPro } + var billingEnabled: Bool { capabilities?.billing != "disabled" && !isSelfHosted } + var planDisplayName: String { isSelfHosted ? "Self-hosted" : (hasPro ? "Pro" : "Free") } +} + +struct EntitlementCapabilities: Codable { + let billing: String + let commercialLimitsEnforced: Bool + let projectExport: Bool + let liveActivities: Bool } struct PlanLimits: Codable { diff --git a/ios/Bellwire/Bellwire/NativeDisplayManager.swift b/ios/Bellwire/Bellwire/NativeDisplayManager.swift index 92f2683..de6a772 100644 --- a/ios/Bellwire/Bellwire/NativeDisplayManager.swift +++ b/ios/Bellwire/Bellwire/NativeDisplayManager.swift @@ -221,7 +221,7 @@ final class NativeDisplayManager { return BellwireNativeSurface( id: surface.id, projectID: surface.projectId, - projectName: surface.project?.name ?? "Bellwire", + projectName: surface.project?.name ?? AppConfig.displayName, projectIcon: surface.project?.icon ?? "rectangle.3.group", surfaceKey: surface.surfaceKey, type: surface.type, diff --git a/ios/Bellwire/Bellwire/OnboardingViews.swift b/ios/Bellwire/Bellwire/OnboardingViews.swift index 247445e..f033a8d 100644 --- a/ios/Bellwire/Bellwire/OnboardingViews.swift +++ b/ios/Bellwire/Bellwire/OnboardingViews.swift @@ -6,6 +6,7 @@ struct WelcomeView: View { @EnvironmentObject private var model: AppModel @Environment(\.colorScheme) private var colorScheme @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.locale) private var locale @State private var mascotState: MascotState = .idle var body: some View { @@ -17,7 +18,7 @@ struct WelcomeView: View { ScrollView { VStack(alignment: .leading, spacing: 0) { HStack(spacing: BellwireSpacing.compact) { - Text("Bellwire") + Text(AppConfig.displayName) .bellwireTechnicalLabel() Spacer() } @@ -30,7 +31,10 @@ struct WelcomeView: View { .fixedSize(horizontal: false, vertical: true) .accessibilityAddTraits(.isHeader) - Text("Bellwire is wired up by your AI Agent. Codex, Claude Code, and other agents connect project events to your phone — no notification code or webhook setup required.") + Text(AppConfig.branded( + "Bellwire is wired up by your AI Agent. Codex, Claude Code, and other agents connect project events to your phone — no notification code or webhook setup required.", + locale: locale + )) .font(.body) .foregroundStyle(BellwireTheme.secondaryInk) .lineSpacing(3) @@ -97,12 +101,22 @@ struct WelcomeView: View { .foregroundStyle(BellwireTheme.mutedInk) } - Text("By continuing, you agree to Bellwire’s [Terms of Service](https://bellwire.app/terms) and [Privacy Policy](https://bellwire.app/privacy). Sensitive fields stay redacted until you reveal them.") - .font(.footnote) - .foregroundStyle(BellwireTheme.mutedInk) - .multilineTextAlignment(.center) - .lineSpacing(2) - .fixedSize(horizontal: false, vertical: true) + VStack(spacing: BellwireSpacing.micro) { + Text(AppConfig.branded( + "By continuing, you agree to Bellwire’s policies.", + locale: locale + )) + HStack(spacing: BellwireSpacing.standard) { + Link("Terms of Service", destination: AppConfig.termsURL) + Link("Privacy Policy", destination: AppConfig.privacyURL) + } + Text("Sensitive fields stay redacted until you reveal them.") + } + .font(.footnote) + .foregroundStyle(BellwireTheme.mutedInk) + .multilineTextAlignment(.center) + .lineSpacing(2) + .fixedSize(horizontal: false, vertical: true) } .padding(.top, 34) .padding(.bottom, BellwireSpacing.roomy) @@ -162,6 +176,7 @@ private struct WelcomePreviewRow: View { struct NotificationOnboardingView: View { @EnvironmentObject private var model: AppModel + @Environment(\.locale) private var locale @Binding var isComplete: Bool @State private var isRequesting = false @@ -180,7 +195,7 @@ struct NotificationOnboardingView: View { ) .padding(.top, 28) - Text("Let Bellwire ring\nwhen it matters.") + Text(AppConfig.branded("Let Bellwire ring\nwhen it matters.", locale: locale)) .font(BellwireTypography.pageTitle) .tracking(-0.6) .foregroundStyle(BellwireTheme.ink) diff --git a/ios/Bellwire/Bellwire/PaywallView.swift b/ios/Bellwire/Bellwire/PaywallView.swift index 06a790c..19f46c6 100644 --- a/ios/Bellwire/Bellwire/PaywallView.swift +++ b/ios/Bellwire/Bellwire/PaywallView.swift @@ -73,7 +73,7 @@ struct PaywallView: View { private var topBar: some View { ZStack { - Text(localized("Bellwire Pro")) + Text("\(AppConfig.displayName) Pro") .font(.headline.weight(.semibold)) .foregroundStyle(BellwireTheme.ink) @@ -266,10 +266,10 @@ struct PaywallView: View { HStack(spacing: BellwireSpacing.roomy) { Button(localized("Terms")) { - openURL(URL(string: "https://bellwire.app/terms")!) + openURL(AppConfig.termsURL) } Button(localized("Privacy")) { - openURL(URL(string: "https://bellwire.app/privacy")!) + openURL(AppConfig.privacyURL) } } .font(.caption.weight(.medium)) @@ -345,7 +345,7 @@ private struct PaywallPlanRow: View { if plan == .yearly { Text(savingsLabel) - .font(.system(size: 9, weight: .bold, design: .monospaced)) + .font(BellwireTypography.technicalLabel.bold()) .tracking(0.6) .foregroundStyle(BellwireTheme.accentInk) .padding(.horizontal, 8) diff --git a/ios/Bellwire/Bellwire/PurchaseManager.swift b/ios/Bellwire/Bellwire/PurchaseManager.swift index 831090b..7eeb76b 100644 --- a/ios/Bellwire/Bellwire/PurchaseManager.swift +++ b/ios/Bellwire/Bellwire/PurchaseManager.swift @@ -1,4 +1,5 @@ // SPDX-License-Identifier: MPL-2.0 +import Combine import Foundation import OSLog import StoreKit @@ -11,8 +12,8 @@ enum BellwirePurchasePlan: String, CaseIterable, Identifiable { var productID: String { switch self { - case .yearly: return "app.bellwire.pro.yearly" - case .monthly: return "app.bellwire.pro.monthly" + case .yearly: AppConfig.yearlyProductID + case .monthly: AppConfig.monthlyProductID } } @@ -63,12 +64,17 @@ final class PurchaseManager: ObservableObject { var hasPro: Bool { serverEntitlement?.hasPro - ?? !purchasedProductIDs.isDisjoint(with: Self.productIDs) + ?? (!AppConfig.billingEnabled + || !purchasedProductIDs.isDisjoint(with: Self.productIDs)) } - static let productIDs = Set(BellwirePurchasePlan.allCases.map(\.productID)) + static var productIDs: Set { + guard AppConfig.billingEnabled else { return [] } + return Set(BellwirePurchasePlan.allCases.map(\.productID)) + } init() { + guard AppConfig.billingEnabled else { return } updatesTask = Task { [weak self] in for await update in Transaction.updates { guard let self else { return } @@ -90,6 +96,11 @@ final class PurchaseManager: ObservableObject { } func prepare() async { + guard AppConfig.billingEnabled else { + loadState = .loaded + await refreshServerEntitlement() + return + } async let products: Void = loadProducts() async let entitlements: Void = refreshEntitlements() _ = await (products, entitlements) @@ -105,6 +116,11 @@ final class PurchaseManager: ObservableObject { } func loadProducts() async { + guard AppConfig.billingEnabled else { + loadState = .loaded + products = [:] + return + } guard loadState != .loading else { return } loadState = .loading isUnavailableInCurrentStorefront = false @@ -119,7 +135,7 @@ final class PurchaseManager: ObservableObject { } else { loadState = .unavailable isUnavailableInCurrentStorefront = true - errorMessage = "Bellwire Pro is not available in your current App Store region." + errorMessage = "\(AppConfig.displayName) Pro is not available in your current App Store region." Self.logger.error( "StoreKit did not return configured products: \(missingProductIDs.sorted().joined(separator: ", "), privacy: .public)" ) @@ -138,7 +154,7 @@ final class PurchaseManager: ObservableObject { } catch { loadState = .unavailable isUnavailableInCurrentStorefront = false - errorMessage = "Bellwire Pro products are temporarily unavailable. Please try again." + errorMessage = "\(AppConfig.displayName) Pro products are temporarily unavailable. Please try again." Self.logger.error( "StoreKit product request failed: \(String(describing: error), privacy: .public)" ) @@ -183,6 +199,7 @@ final class PurchaseManager: ObservableObject { } func restorePurchases() async { + guard AppConfig.billingEnabled else { return } guard !isRestoring else { return } isRestoring = true errorMessage = nil @@ -195,7 +212,7 @@ final class PurchaseManager: ObservableObject { if hasPro { BellwireHaptics.success() } else { - errorMessage = "No previous Bellwire Pro purchase was found." + errorMessage = "No previous \(AppConfig.displayName) Pro purchase was found." } } catch { errorMessage = "Purchases could not be restored. Please try again." @@ -204,6 +221,10 @@ final class PurchaseManager: ObservableObject { } func refreshEntitlements(source: String = "sync") async { + guard AppConfig.billingEnabled else { + purchasedProductIDs = [] + return + } var activeProductIDs = Set() for await entitlement in Transaction.currentEntitlements { @@ -216,7 +237,9 @@ final class PurchaseManager: ObservableObject { do { try await upload(entitlement.jwsRepresentation, source: source) } catch { - errorMessage = "Your App Store purchase could not be synced with Bellwire." + errorMessage = AppConfig.branded( + "Your App Store purchase could not be synced with Bellwire." + ) } } @@ -229,7 +252,9 @@ final class PurchaseManager: ObservableObject { try await upload(result.jwsRepresentation, source: "sync") await transaction.finish() } catch { - errorMessage = "Your App Store purchase could not be synced with Bellwire." + errorMessage = AppConfig.branded( + "Your App Store purchase could not be synced with Bellwire." + ) return } await refreshEntitlements() @@ -242,7 +267,7 @@ final class PurchaseManager: ObservableObject { serverEntitlement = try await entitlementLoader() } catch { if serverEntitlement == nil { - errorMessage = "Bellwire could not refresh your plan status." + errorMessage = AppConfig.branded("Bellwire could not refresh your plan status.") } } } diff --git a/ios/Bellwire/Bellwire/RootView.swift b/ios/Bellwire/Bellwire/RootView.swift index 1f9a45b..0306f6b 100644 --- a/ios/Bellwire/Bellwire/RootView.swift +++ b/ios/Bellwire/Bellwire/RootView.swift @@ -122,6 +122,7 @@ struct MainTabView: View { } private struct InitialDashboardFailureView: View { + @Environment(\.locale) private var locale let message: String let isRetrying: Bool let retry: () -> Void @@ -139,7 +140,7 @@ private struct InitialDashboardFailureView: View { .accessibilityHidden(true) VStack(spacing: BellwireSpacing.compact) { - Text("Bellwire couldn’t load") + Text(AppConfig.branded("Bellwire couldn’t load", locale: locale)) .font(.title2) .bold() .foregroundStyle(BellwireTheme.ink) diff --git a/ios/Bellwire/Bellwire/SettingsView.swift b/ios/Bellwire/Bellwire/SettingsView.swift index 0126156..cb10b31 100644 --- a/ios/Bellwire/Bellwire/SettingsView.swift +++ b/ios/Bellwire/Bellwire/SettingsView.swift @@ -8,6 +8,7 @@ struct SettingsView: View { @EnvironmentObject private var purchaseManager: PurchaseManager @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.openURL) private var openURL + @Environment(\.locale) private var locale @State private var isGeneratingBinding = false @State private var isRequestingNotificationPermission = false @State private var showsAgentInstructions = false @@ -25,7 +26,7 @@ struct SettingsView: View { @AppStorage("agentLiveActivitiesEnabled") private var agentLiveActivitiesEnabled = false private var hasPro: Bool { - model.entitlement?.hasPro ?? purchaseManager.hasPro + model.entitlement?.hasPro ?? (!AppConfig.billingEnabled || purchaseManager.hasPro) } private func refresh() async { @@ -80,7 +81,7 @@ struct SettingsView: View { FeedbackMailView( isPresented: $showsFeedbackMail, recipient: feedbackEmail, - subject: "Bellwire Feedback", + subject: "\(AppConfig.displayName) Feedback", body: feedbackBody ) } @@ -115,7 +116,7 @@ struct SettingsView: View { Text("Agents can start a Live Activity only when a Surface explicitly requests one. You can turn this off at any time.") } .alert( - "Sign out of Bellwire?", + AppConfig.branded("Sign out of Bellwire?", locale: locale), isPresented: $showsSignOutConfirmation ) { Button("Sign out", role: .destructive) { model.signOut() } @@ -135,7 +136,10 @@ struct SettingsView: View { .alert(item: $pendingDeviceDeletion) { device in Alert( title: Text("Remove device?"), - message: Text("“\(device.name)” will stop receiving Bellwire notifications. You can register it again later."), + message: Text(AppConfig.branded( + "“\(device.name)” will stop receiving Bellwire notifications. You can register it again later.", + locale: locale + )), primaryButton: .destructive(Text("Remove")) { Task { await model.deleteDevice(id: device.id) } }, @@ -169,7 +173,7 @@ struct SettingsView: View { } Spacer() StatusBadgeView( - text: hasPro ? "Pro" : "Free", + text: model.entitlement?.planDisplayName ?? (hasPro ? "Pro" : "Free"), color: hasPro ? BellwireTheme.accent : BellwireTheme.mutedInk, showsDot: false ) @@ -178,32 +182,45 @@ struct SettingsView: View { Divider().overlay(BellwireTheme.separator) - Button { - if hasPro { - Task { await model.captureProductEvent("subscription_managed", source: "settings") } - openURL(URL(string: "https://apps.apple.com/account/subscriptions")!) - } else { - Task { await model.captureProductEvent("upgrade_clicked", source: "settings") } - showsPaywall = true + if model.entitlement?.isSelfHosted == true { + SettingsRowView( + icon: "server.rack", + title: "Self-hosted deployment", + hint: "Commercial feature limits and App Store billing are disabled" + ) { + Image(systemName: "checkmark.seal.fill") + .foregroundStyle(BellwireTheme.success) } - } label: { - if hasPro { - ProActiveSettingsRow() - } else { - SettingsRowView( - icon: "sparkles", - title: "Upgrade to Bellwire Pro", - hint: "More projects, events, devices, and history" - ) { - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(BellwireTheme.accent) + } else { + Button { + if hasPro { + Task { await model.captureProductEvent("subscription_managed", source: "settings") } + if let url = URL(string: "https://apps.apple.com/account/subscriptions") { + openURL(url) + } + } else { + Task { await model.captureProductEvent("upgrade_clicked", source: "settings") } + showsPaywall = true + } + } label: { + if hasPro { + ProActiveSettingsRow() + } else { + SettingsRowView( + icon: "sparkles", + title: "Upgrade to \(AppConfig.displayName) Pro", + hint: "More projects, events, devices, and history" + ) { + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(BellwireTheme.accent) + } } } + .buttonStyle(PressableButtonStyle()) } - .buttonStyle(PressableButtonStyle()) - if let entitlement = model.entitlement { + if let entitlement = model.entitlement, !entitlement.isSelfHosted { Divider().overlay(BellwireTheme.separator).padding(.leading, 44) VStack(alignment: .leading, spacing: BellwireSpacing.compact) { HStack { @@ -291,12 +308,12 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: BellwireSpacing.compact) { - Text("Bellwire Pro") + Text("\(AppConfig.displayName) Pro") .font(.headline.weight(.semibold)) .foregroundStyle(BellwireTheme.ink) if hasPro { Text("ACTIVE") - .font(.system(size: 9, weight: .bold, design: .monospaced)) + .font(BellwireTypography.technicalLabel.bold()) .tracking(0.6) .foregroundStyle(BellwireTheme.accentInk) .padding(.horizontal, 7) @@ -341,7 +358,7 @@ struct SettingsView: View { .accessibilityHint( hasPro ? "Opens App Store subscription management" - : "Opens Bellwire Pro purchase options" + : "Opens \(AppConfig.displayName) Pro purchase options" ) } @@ -352,7 +369,7 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: BellwireSpacing.standard) { HStack { VStack(alignment: .leading, spacing: 3) { - Text(entitlement.plan == "pro" ? "Pro" : "Free") + Text(entitlement.planDisplayName) .font(.headline.weight(.semibold)) .foregroundStyle(BellwireTheme.ink) Text("\(entitlement.usage.acceptedSignals.formatted()) of \(entitlement.limits.monthlySignals.formatted()) Signals") @@ -409,7 +426,7 @@ struct SettingsView: View { .font(.subheadline.weight(.semibold)) .foregroundStyle(BellwireTheme.warning) Text( - "Pause projects and remove devices until you are within the Free limits. Bellwire will never delete them just because Pro ended." + "Pause projects and remove devices until you are within the Free limits. \(AppConfig.displayName) will never delete them just because Pro ended." ) .font(.caption) .foregroundStyle(BellwireTheme.secondaryInk) @@ -480,8 +497,8 @@ struct SettingsView: View { } Text( request.toMode == .hosted - ? "Bellwire Cloud will receive and retain this project's Event, Inbox, Surface, and detailed notification content." - : "Bellwire will send content-free wakes. At least one iPhone must have a verified Direct connection." + ? "\(AppConfig.hostedServiceDisplayName) will receive and retain this project's Event, Inbox, Surface, and detailed notification content." + : "\(AppConfig.displayName) will send content-free wakes. At least one iPhone must have a verified Direct connection." ) .font(.caption) .foregroundStyle(BellwireTheme.secondaryInk) @@ -712,7 +729,7 @@ struct SettingsView: View { title: "Automatic Live Activities", hint: hasPro ? "Only for Surfaces that explicitly request one" - : "Included with Bellwire Pro" + : "Included with \(AppConfig.displayName) Pro" ) { Image(systemName: agentLiveActivitiesEnabled ? "checkmark.circle.fill" : "circle") .font(.body.weight(.semibold)) @@ -811,7 +828,7 @@ struct SettingsView: View { SettingsRowView( icon: "globe", title: "Language", - hint: "Choose the language used throughout Bellwire" + hint: "Choose the language used throughout \(AppConfig.displayName)" ) { settingSelectionLabel(selectedLanguage.title) } @@ -819,7 +836,7 @@ struct SettingsView: View { .buttonStyle(PressableButtonStyle()) .accessibilityLabel("Language") .accessibilityValue(Text(selectedLanguage.title)) - .accessibilityHint("Changes the language used throughout Bellwire") + .accessibilityHint("Changes the language used throughout \(AppConfig.displayName)") Divider().overlay(BellwireTheme.separator).padding(.leading, 44) @@ -846,7 +863,7 @@ struct SettingsView: View { SettingsRowView( icon: "circle.lefthalf.filled", title: "Appearance", - hint: "Choose the appearance used throughout Bellwire" + hint: "Choose the appearance used throughout \(AppConfig.displayName)" ) { settingSelectionLabel(selectedAppearance.title) } @@ -854,7 +871,7 @@ struct SettingsView: View { .buttonStyle(PressableButtonStyle()) .accessibilityLabel("Appearance") .accessibilityValue(Text(selectedAppearance.title)) - .accessibilityHint("Changes Bellwire between light and dark appearance") + .accessibilityHint("Changes \(AppConfig.displayName) between light and dark appearance") } .padding(.horizontal, BellwireSpacing.standard) .bellwireListGroup() @@ -877,13 +894,15 @@ struct SettingsView: View { } } .buttonStyle(PressableButtonStyle()) - .accessibilityHint("Opens Bellwire support in your browser") + .accessibilityHint("Opens \(AppConfig.displayName) support in your browser") Divider().overlay(BellwireTheme.separator).padding(.leading, 44) Button { openFeedback() } label: { SettingsRowView( icon: feedbackEmailCopied ? "checkmark" : "bubble.left.and.bubble.right", title: "Send feedback", - hint: feedbackEmailCopied ? "Feedback email copied" : "Help improve Bellwire" + hint: feedbackEmailCopied + ? "Feedback email copied" + : "Help improve \(AppConfig.displayName)" ) { Image(systemName: feedbackEmailCopied ? "checkmark" : "arrow.up.right") .font(.caption.weight(.semibold)) @@ -891,7 +910,7 @@ struct SettingsView: View { } } .buttonStyle(PressableButtonStyle()) - .accessibilityHint("Opens an email to Bellwire support") + .accessibilityHint("Opens an email to \(AppConfig.displayName) support") } .padding(.horizontal, BellwireSpacing.standard) .bellwireListGroup() @@ -906,7 +925,7 @@ struct SettingsView: View { SettingsRowView( icon: "hand.raised", title: "Privacy policy", - hint: "How Bellwire handles account, device, and project data" + hint: "How \(AppConfig.displayName) handles account, device, and project data" ) { Image(systemName: "arrow.up.right") .font(.caption.weight(.semibold)) @@ -914,13 +933,13 @@ struct SettingsView: View { } } .buttonStyle(PressableButtonStyle()) - .accessibilityHint("Opens the Bellwire privacy policy in your browser") + .accessibilityHint("Opens the \(AppConfig.displayName) privacy policy in your browser") Divider().overlay(BellwireTheme.separator).padding(.leading, 44) Button { openTermsOfService() } label: { SettingsRowView( icon: "doc.text", title: "Terms of service", - hint: "Rules for using Bellwire" + hint: "Rules for using \(AppConfig.displayName)" ) { Image(systemName: "arrow.up.right") .font(.caption.weight(.semibold)) @@ -928,7 +947,7 @@ struct SettingsView: View { } } .buttonStyle(PressableButtonStyle()) - .accessibilityHint("Opens the Bellwire terms of service in your browser") + .accessibilityHint("Opens the \(AppConfig.displayName) terms of service in your browser") Divider().overlay(BellwireTheme.separator).padding(.leading, 44) Button { showsSignOutConfirmation = true } label: { SettingsRowView( @@ -981,7 +1000,7 @@ struct SettingsView: View { .foregroundStyle(BellwireTheme.accent) .lineLimit(1) Image(systemName: "chevron.up.chevron.down") - .font(.system(size: 9, weight: .semibold)) + .font(.caption.weight(.semibold)) .foregroundStyle(BellwireTheme.mutedInk) } } @@ -1010,10 +1029,10 @@ struct SettingsView: View { private var appVersion: String { let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "—" let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "—" - return "Bellwire \(version) · build \(build)" + return "\(AppConfig.displayName) \(version) · build \(build)" } - private var feedbackEmail: String { "feedback@bellwire.app" } + private var feedbackEmail: String { AppConfig.supportEmail } private var feedbackBody: String { let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "—" @@ -1024,7 +1043,7 @@ struct SettingsView: View { --- - Bellwire \(version) (\(build)) + \(AppConfig.displayName) \(version) (\(build)) \(device.localizedModel) · \(device.systemName) \(device.systemVersion) """ } @@ -1060,18 +1079,15 @@ struct SettingsView: View { } private func openPrivacyPolicy() { - guard let url = URL(string: "https://bellwire.app/privacy") else { return } - openURL(url) + openURL(AppConfig.privacyURL) } private func openTermsOfService() { - guard let url = URL(string: "https://bellwire.app/terms") else { return } - openURL(url) + openURL(AppConfig.termsURL) } private func openSupport() { - guard let url = URL(string: "https://bellwire.app/support") else { return } - openURL(url) + openURL(AppConfig.supportURL) } } @@ -1147,7 +1163,7 @@ private struct ProActiveSettingsRow: View { .frame(width: 32, height: 32) VStack(alignment: .leading, spacing: 2) { - Text("Manage Bellwire Pro") + Text("Manage \(AppConfig.displayName) Pro") .font(.subheadline.weight(.medium)) .foregroundStyle(BellwireTheme.ink) Text("Your Pro access is active") @@ -1167,7 +1183,7 @@ private struct ProActiveSettingsRow: View { .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) .contentShape(Rectangle()) .accessibilityElement(children: .ignore) - .accessibilityLabel(Text("Manage Bellwire Pro")) + .accessibilityLabel(Text("Manage \(AppConfig.displayName) Pro")) .accessibilityValue(Text("Your Pro access is active")) } } @@ -1234,14 +1250,20 @@ private struct AgentConnectionRowView: View { .buttonStyle(PressableButtonStyle()) .disabled(isRevoking) .accessibilityLabel(Text("Disconnect \(connection.name)")) - .accessibilityHint("Revokes this Agent’s Bellwire access") + .accessibilityHint(AppConfig.branded( + "Revokes this Agent’s Bellwire access", + locale: locale + )) } .padding(.vertical, BellwireSpacing.compact) .alert("Disconnect Agent?", isPresented: $showsDisconnectConfirmation) { Button("Cancel", role: .cancel) {} Button("Disconnect", role: .destructive, action: disconnect) } message: { - Text("“\(connection.name)” will immediately lose access to Bellwire. Your projects and data will remain.") + Text(AppConfig.branded( + "“\(connection.name)” will immediately lose access to Bellwire. Your projects and data will remain.", + locale: locale + )) } } @@ -1258,6 +1280,7 @@ private struct AgentConnectionRowView: View { } private struct DeleteAccountView: View { + @Environment(\.locale) private var locale @EnvironmentObject private var model: AppModel @Environment(\.dismiss) private var dismiss @State private var showsFinalConfirmation = false @@ -1279,7 +1302,10 @@ private struct DeleteAccountView: View { .foregroundStyle(BellwireTheme.ink) .accessibilityAddTraits(.isHeader) - Text("Your Bellwire account and all connected data will be permanently deleted.") + Text(AppConfig.branded( + "Your Bellwire account and all connected data will be permanently deleted.", + locale: locale + )) .font(.body) .foregroundStyle(BellwireTheme.secondaryInk) .fixedSize(horizontal: false, vertical: true) @@ -1325,7 +1351,10 @@ private struct DeleteAccountView: View { HStack(alignment: .top, spacing: BellwireSpacing.small) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(BellwireTheme.danger) - Text("This action cannot be undone. You will need to create a new account to use Bellwire again.") + Text(AppConfig.branded( + "This action cannot be undone. You will need to create a new account to use Bellwire again.", + locale: locale + )) .font(.subheadline) .foregroundStyle(BellwireTheme.secondaryInk) .fixedSize(horizontal: false, vertical: true) @@ -1367,7 +1396,7 @@ private struct DeleteAccountView: View { .toolbar(.visible, for: .navigationBar) .interactiveDismissDisabled(isDeletingAccount) .alert( - "Permanently delete your Bellwire account?", + AppConfig.branded("Permanently delete your Bellwire account?", locale: locale), isPresented: $showsFinalConfirmation ) { Button("Delete account and data", role: .destructive) { @@ -1375,7 +1404,10 @@ private struct DeleteAccountView: View { } Button("Cancel", role: .cancel) {} } message: { - Text("This permanently deletes your account and all Bellwire data. This action cannot be undone.") + Text(AppConfig.branded( + "This permanently deletes your account and all Bellwire data. This action cannot be undone.", + locale: locale + )) } } @@ -1495,7 +1527,10 @@ struct BindingCodeSheet: View { .font(BellwireTypography.pageTitle) .foregroundStyle(BellwireTheme.ink) .accessibilityAddTraits(.isHeader) - Text("This code is single-use. Once your Agent connects, project events can start flowing into Bellwire.") + Text(AppConfig.branded( + "This code is single-use. Once your Agent connects, project events can start flowing into Bellwire.", + locale: locale + )) .font(.subheadline) .foregroundStyle(BellwireTheme.secondaryInk) .fixedSize(horizontal: false, vertical: true) @@ -1653,7 +1688,7 @@ struct BindingCodeSheet: View { .bellwireTechnicalLabel() Text("> ") .foregroundStyle(BellwireTheme.mutedInk) - + Text("Connect Bellwire with code ") + + Text("Connect \(AppConfig.displayName) with code ") .foregroundStyle(BellwireTheme.ink) + Text(binding.code) .foregroundStyle(BellwireTheme.accent) @@ -1668,7 +1703,7 @@ struct BindingCodeSheet: View { } private var instruction: String { - "Connect Bellwire with code \(binding.code)." + "Connect \(AppConfig.displayName) with code \(binding.code)." } private var expiryDate: Date? { @@ -1688,6 +1723,7 @@ private enum CopiedBindingAction { private struct AgentInstructionSheet: View { @Environment(\.dismiss) private var dismiss + @Environment(\.locale) private var locale var body: some View { VStack(alignment: .leading, spacing: BellwireSpacing.roomy) { @@ -1700,7 +1736,10 @@ private struct AgentInstructionSheet: View { .foregroundStyle(BellwireTheme.accent) .frame(minHeight: 44) } - Text("1. Generate a binding code.\n2. Open the project with your Agent.\n3. Say: “Connect Bellwire with code …”\n4. Let the Agent configure the supported event and live-surface calls.") + Text(AppConfig.branded( + "1. Generate a binding code.\n2. Open the project with your Agent.\n3. Say: “Connect Bellwire with code …”\n4. Let the Agent configure the supported event and live-surface calls.", + locale: locale + )) .font(.body) .foregroundStyle(BellwireTheme.secondaryInk) .lineSpacing(6) diff --git a/ios/Bellwire/Bellwire/SurfaceViews.swift b/ios/Bellwire/Bellwire/SurfaceViews.swift index 5fb4f31..4c85db4 100644 --- a/ios/Bellwire/Bellwire/SurfaceViews.swift +++ b/ios/Bellwire/Bellwire/SurfaceViews.swift @@ -299,7 +299,7 @@ struct SurfaceFooter: View { HStack(spacing: 4) { Text(action.title) Image(systemName: "arrow.up.right") - .font(.system(size: 9, weight: .bold)) + .font(.caption.bold()) } .font(BellwireTypography.metadata.weight(.semibold)) .foregroundStyle(BellwireTheme.accent) diff --git a/ios/Bellwire/Bellwire/Theme.swift b/ios/Bellwire/Bellwire/Theme.swift index b8351e3..a540d52 100644 --- a/ios/Bellwire/Bellwire/Theme.swift +++ b/ios/Bellwire/Bellwire/Theme.swift @@ -116,13 +116,13 @@ enum BellwireTypography { static let hero = Font.system(.largeTitle, design: .serif, weight: .regular) static let pageTitle = Font.system(.largeTitle, design: .serif, weight: .regular) static let sectionTitle = Font.system(.subheadline, design: .default, weight: .semibold) - static let technicalLabel = Font.system(size: 10, weight: .medium, design: .monospaced) - static let technical = Font.system(size: 11, weight: .regular, design: .monospaced) - static let technicalStrong = Font.system(size: 11, weight: .semibold, design: .monospaced) + static let technicalLabel = Font.system(.caption, design: .monospaced, weight: .medium) + static let technical = Font.system(.caption, design: .monospaced, weight: .regular) + static let technicalStrong = Font.system(.caption, design: .monospaced, weight: .semibold) static let metric = Font.system(.title, design: .serif, weight: .regular) - static let cardTitle = Font.system(size: 13, weight: .semibold, design: .default) - static let metadata = Font.system(size: 11, weight: .regular, design: .default) - static let microLabel = Font.system(size: 9, weight: .medium, design: .monospaced) + static let cardTitle = Font.system(.subheadline, design: .default, weight: .semibold) + static let metadata = Font.system(.caption, design: .default, weight: .regular) + static let microLabel = Font.system(.caption, design: .monospaced, weight: .medium) static let microMetric = Font.system(.title3, design: .serif, weight: .regular) } diff --git a/ios/Bellwire/Bellwire/en.lproj/Localizable.strings b/ios/Bellwire/Bellwire/en.lproj/Localizable.strings index d3ff57f..2c3c4a3 100644 --- a/ios/Bellwire/Bellwire/en.lproj/Localizable.strings +++ b/ios/Bellwire/Bellwire/en.lproj/Localizable.strings @@ -10,7 +10,7 @@ "Changes Bellwire between light and dark appearance" = "Changes Bellwire between light and dark appearance"; "Light" = "Light"; "Dark" = "Dark"; -"BELLWIRE_PRIVATE_NOTIFICATION_BODY" = "Open Bellwire to view the update."; +"BELLWIRE_PRIVATE_NOTIFICATION_BODY" = "Open %@ to view the update."; "Support" = "Support"; "Send feedback" = "Send feedback"; "Help improve Bellwire" = "Help improve Bellwire"; @@ -52,7 +52,7 @@ "Unable to delete account. Please try again." = "Unable to delete account. Please try again."; "This permanently deletes your projects, events, devices, Agent connections, and account. This action cannot be undone." = "This permanently deletes your projects, events, devices, Agent connections, and account. This action cannot be undone."; "Signals from every project,\non your iPhone." = "Signals from every project,\non your iPhone."; -"By continuing, you agree to Bellwire’s [Terms of Service](https://bellwire.app/terms) and [Privacy Policy](https://bellwire.app/privacy). Sensitive fields stay redacted until you reveal them." = "By continuing, you agree to Bellwire’s [Terms of Service](https://bellwire.app/terms) and [Privacy Policy](https://bellwire.app/privacy). Sensitive fields stay redacted until you reveal them."; +"By continuing, you agree to Bellwire’s policies." = "By continuing, you agree to Bellwire’s policies."; "Connect your first project." = "Connect your first project."; "Your Agent can wire real project Signals to this iPhone with a one-time binding code." = "Your Agent can wire real project Signals to this iPhone with a one-time binding code."; "Connect your Agent" = "Connect your Agent"; diff --git a/ios/Bellwire/Bellwire/zh-Hans.lproj/Localizable.strings b/ios/Bellwire/Bellwire/zh-Hans.lproj/Localizable.strings index 131493a..efa10f1 100644 --- a/ios/Bellwire/Bellwire/zh-Hans.lproj/Localizable.strings +++ b/ios/Bellwire/Bellwire/zh-Hans.lproj/Localizable.strings @@ -10,7 +10,7 @@ "Changes Bellwire between light and dark appearance" = "切换 Bellwire 的浅色或深色外观"; "Light" = "浅色"; "Dark" = "深色"; -"BELLWIRE_PRIVATE_NOTIFICATION_BODY" = "打开 Bellwire 查看更新。"; +"BELLWIRE_PRIVATE_NOTIFICATION_BODY" = "打开 %@ 查看更新。"; "Support" = "支持"; "Send feedback" = "发送反馈"; "Help improve Bellwire" = "告诉我们哪里可以做得更好"; @@ -68,7 +68,7 @@ "Just now" = "刚刚"; "Signing in…" = "正在登录…"; "Signs in using your Apple ID" = "使用你的 Apple ID 登录"; -"By continuing, you agree to Bellwire’s [Terms of Service](https://bellwire.app/terms) and [Privacy Policy](https://bellwire.app/privacy). Sensitive fields stay redacted until you reveal them." = "继续即表示你同意 Bellwire 的[服务条款](https://bellwire.app/terms)和[隐私政策](https://bellwire.app/privacy)。敏感字段在你主动查看前会保持隐藏。"; +"By continuing, you agree to Bellwire’s policies." = "继续即表示你同意 Bellwire 的相关政策。"; "Step 2 of 3" = "第 2 步,共 3 步"; "Let Bellwire ring\nwhen it matters." = "让 Bellwire 只在\n重要时刻提醒你。"; diff --git a/ios/Bellwire/BellwireNotificationService/Info.plist b/ios/Bellwire/BellwireNotificationService/Info.plist index b3eddd5..b7eca47 100644 --- a/ios/Bellwire/BellwireNotificationService/Info.plist +++ b/ios/Bellwire/BellwireNotificationService/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Bellwire Notification Service + $(BELLWIRE_APP_DISPLAY_NAME) Notifications CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier diff --git a/ios/Bellwire/BellwireTests/EntitlementCapabilityTests.swift b/ios/Bellwire/BellwireTests/EntitlementCapabilityTests.swift new file mode 100644 index 0000000..20117a9 --- /dev/null +++ b/ios/Bellwire/BellwireTests/EntitlementCapabilityTests.swift @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MPL-2.0 +import XCTest +@testable import Bellwire + +final class EntitlementCapabilityTests: XCTestCase { + func testSelfHostedCapabilitiesEnableFeaturesWithoutBilling() { + let entitlement = makeEntitlement( + plan: "pro", + deployment: "self_hosted", + capabilities: EntitlementCapabilities( + billing: "disabled", + commercialLimitsEnforced: false, + projectExport: true, + liveActivities: true + ) + ) + + XCTAssertTrue(entitlement.isSelfHosted) + XCTAssertTrue(entitlement.hasPro) + XCTAssertTrue(entitlement.canExportProjects) + XCTAssertTrue(entitlement.canUseLiveActivities) + XCTAssertFalse(entitlement.billingEnabled) + XCTAssertEqual(entitlement.planDisplayName, "Self-hosted") + } + + func testHostedFreeCapabilitiesRemainRestricted() { + let entitlement = makeEntitlement( + plan: "free", + deployment: "hosted", + capabilities: EntitlementCapabilities( + billing: "app_store", + commercialLimitsEnforced: true, + projectExport: false, + liveActivities: false + ) + ) + + XCTAssertFalse(entitlement.hasPro) + XCTAssertFalse(entitlement.canExportProjects) + XCTAssertFalse(entitlement.canUseLiveActivities) + XCTAssertTrue(entitlement.billingEnabled) + XCTAssertEqual(entitlement.planDisplayName, "Free") + } + + func testOlderProResponseUsesBackwardsCompatibleFeatureFallbacks() { + let entitlement = makeEntitlement(plan: "pro") + + XCTAssertTrue(entitlement.hasPro) + XCTAssertTrue(entitlement.canExportProjects) + XCTAssertTrue(entitlement.canUseLiveActivities) + XCTAssertTrue(entitlement.billingEnabled) + } + + private func makeEntitlement( + plan: String, + deployment: String? = nil, + capabilities: EntitlementCapabilities? = nil + ) -> AccountEntitlement { + AccountEntitlement( + plan: plan, + status: "active", + deployment: deployment, + capabilities: capabilities, + productId: nil, + expiresAt: nil, + downgradeDeadline: nil, + limits: PlanLimits( + activeProjects: 1, + activeDevices: 1, + monthlySignals: 100, + courtesySignals: 110, + ingestPerMinute: 60, + hostedRetentionDays: 7, + surfacesPerProject: 3 + ), + usage: SignalUsage( + periodStart: "2026-08-01T00:00:00.000Z", + periodEnd: "2026-09-01T00:00:00.000Z", + acceptedSignals: 0, + remainingSignals: 100, + courtesyRemainingSignals: 110 + ), + activeProjects: 0, + activeDevices: 0 + ) + } +} diff --git a/ios/Bellwire/BellwireWidgets/BellwireWidgets.swift b/ios/Bellwire/BellwireWidgets/BellwireWidgets.swift index 27b07df..3087f76 100644 --- a/ios/Bellwire/BellwireWidgets/BellwireWidgets.swift +++ b/ios/Bellwire/BellwireWidgets/BellwireWidgets.swift @@ -97,7 +97,7 @@ private struct BellwireSurfacesWidget: Widget { Color(red: 0.055, green: 0.052, blue: 0.046) } } - .configurationDisplayName("Bellwire Surfaces") + .configurationDisplayName("Project Surfaces") .description("Keep your most important project state on the Home Screen.") .supportedFamilies([.systemSmall, .systemMedium]) } @@ -110,7 +110,11 @@ private struct BellwireWidgetView: View { var body: some View { if !entry.snapshot.isPro { VStack(alignment: .leading, spacing: 8) { - Label("Bellwire Pro", systemImage: "bolt.fill") + Label { + Text("\(appDisplayName) Pro") + } icon: { + Image(systemName: "bolt.fill") + } .font(.headline) .foregroundStyle(accent) Text("Unlock live project Surfaces on your Home Screen.") @@ -121,7 +125,11 @@ private struct BellwireWidgetView: View { .widgetURL(widgetURL("home")) } else if entry.snapshot.surfaces.isEmpty { VStack(alignment: .leading, spacing: 8) { - Label("Bellwire", systemImage: "bell.fill") + Label { + Text(appDisplayName) + } icon: { + Image(systemName: "bell.fill") + } .font(.headline) .foregroundStyle(accent) Text("Publish a Surface to see live project state here.") @@ -147,6 +155,11 @@ private struct BellwireWidgetView: View { } } + private var appDisplayName: String { + Bundle.main.object(forInfoDictionaryKey: "BellwireAppDisplayName") as? String + ?? "App" + } + private func surfaceRow(_ surface: BellwireNativeSurface) -> some View { VStack(alignment: .leading, spacing: 5) { HStack(spacing: 7) { diff --git a/ios/Bellwire/BellwireWidgets/Info.plist b/ios/Bellwire/BellwireWidgets/Info.plist index 5e47ff2..6019105 100644 --- a/ios/Bellwire/BellwireWidgets/Info.plist +++ b/ios/Bellwire/BellwireWidgets/Info.plist @@ -6,8 +6,10 @@ $(BELLWIRE_APP_GROUP) BellwireURLScheme $(BELLWIRE_URL_SCHEME) + BellwireAppDisplayName + $(BELLWIRE_APP_DISPLAY_NAME) CFBundleDisplayName - Bellwire + $(BELLWIRE_APP_DISPLAY_NAME) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier diff --git a/ios/Bellwire/Configuration/Defaults.xcconfig b/ios/Bellwire/Configuration/Defaults.xcconfig index 034d2ba..2337147 100644 --- a/ios/Bellwire/Configuration/Defaults.xcconfig +++ b/ios/Bellwire/Configuration/Defaults.xcconfig @@ -8,9 +8,18 @@ BELLWIRE_EXTENSION_BUNDLE_ID = $(BELLWIRE_APP_BUNDLE_ID).NotificationService BELLWIRE_WIDGET_BUNDLE_ID = $(BELLWIRE_APP_BUNDLE_ID).Widgets BELLWIRE_APP_GROUP = group.$(BELLWIRE_APP_BUNDLE_ID).shared BELLWIRE_URL_SCHEME = bellwire +BELLWIRE_APP_DISPLAY_NAME = Bellwire +BELLWIRE_APP_ICON_NAME = AppIcon +BELLWIRE_BILLING_MODE = app_store +BELLWIRE_PRO_MONTHLY_PRODUCT_ID = app.bellwire.pro.monthly +BELLWIRE_PRO_YEARLY_PRODUCT_ID = app.bellwire.pro.yearly +BELLWIRE_SUPPORT_EMAIL = feedback@bellwire.app // The empty $() keeps // from being parsed as an xcconfig comment. BELLWIRE_API_BASE_URL = https:/$()/api.bellwire.app BELLWIRE_AUTH_BASE_URL = https:/$()/auth.bellwire.app +BELLWIRE_PRIVACY_URL = https:/$()/bellwire.app/privacy +BELLWIRE_TERMS_URL = https:/$()/bellwire.app/terms +BELLWIRE_SUPPORT_URL = https:/$()/bellwire.app/support #include? "Local.xcconfig" diff --git a/ios/Bellwire/Configuration/Local.xcconfig.example b/ios/Bellwire/Configuration/Local.xcconfig.example index 3d30713..6a0bd3b 100644 --- a/ios/Bellwire/Configuration/Local.xcconfig.example +++ b/ios/Bellwire/Configuration/Local.xcconfig.example @@ -7,7 +7,16 @@ BELLWIRE_EXTENSION_BUNDLE_ID = $(BELLWIRE_APP_BUNDLE_ID).NotificationService BELLWIRE_WIDGET_BUNDLE_ID = $(BELLWIRE_APP_BUNDLE_ID).Widgets BELLWIRE_APP_GROUP = group.$(BELLWIRE_APP_BUNDLE_ID).shared BELLWIRE_URL_SCHEME = bellwire-self-host +BELLWIRE_APP_DISPLAY_NAME = My Signal App +BELLWIRE_APP_ICON_NAME = SelfHostedAppIcon +BELLWIRE_BILLING_MODE = disabled +BELLWIRE_PRO_MONTHLY_PRODUCT_ID = self-hosted.disabled.monthly +BELLWIRE_PRO_YEARLY_PRODUCT_ID = self-hosted.disabled.yearly +BELLWIRE_SUPPORT_EMAIL = support@example.com // Keep the https:/$()/ form so xcconfig does not treat // as a comment. BELLWIRE_API_BASE_URL = https:/$()/YOUR_WORKER.workers.dev BELLWIRE_AUTH_BASE_URL = https:/$()/YOUR_AUTH_WORKER.workers.dev +BELLWIRE_PRIVACY_URL = https:/$()/example.com/privacy +BELLWIRE_TERMS_URL = https:/$()/example.com/terms +BELLWIRE_SUPPORT_URL = https:/$()/example.com/support diff --git a/package-lock.json b/package-lock.json index 13ae39d..b798bfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bellwire", - "version": "0.1.3", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bellwire", - "version": "0.1.3", + "version": "0.2.0", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@apple/app-store-server-library": "^3.1.0", @@ -328,7 +328,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -345,7 +344,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -362,7 +360,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -379,7 +376,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -396,7 +392,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -413,7 +408,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -430,7 +424,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -447,7 +440,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -464,7 +456,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -481,7 +472,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -498,7 +488,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -515,7 +504,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -532,7 +520,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -549,7 +536,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -566,7 +552,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -583,7 +568,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -600,7 +584,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -617,7 +600,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -634,7 +616,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -651,7 +632,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -668,7 +648,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -685,7 +664,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -702,7 +680,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -719,7 +696,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -736,7 +712,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -753,7 +728,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1640,7 +1614,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1654,7 +1627,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1668,7 +1640,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1682,7 +1653,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1696,7 +1666,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1710,7 +1679,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1724,7 +1692,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1738,7 +1705,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1752,7 +1718,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1766,7 +1731,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1780,7 +1744,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1794,7 +1757,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1808,7 +1770,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1822,7 +1783,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1836,7 +1796,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1850,7 +1809,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1864,7 +1822,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1878,7 +1835,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1892,7 +1848,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1906,7 +1861,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1920,7 +1874,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1934,7 +1887,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1948,7 +1900,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1962,7 +1913,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1976,7 +1926,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3352,7 +3301,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4490,9 +4438,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { @@ -4915,16 +4863,6 @@ "node": ">=22.0.0" } }, - "node_modules/wrangler/node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, "node_modules/wrangler/node_modules/workerd": { "version": "1.20260801.1", "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260801.1.tgz", diff --git a/package.json b/package.json index 924f9d4..fcc780f 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,14 @@ { "name": "bellwire", - "version": "0.1.3", + "version": "0.2.0", "private": true, "license": "SEE LICENSE IN LICENSE.md", "type": "module", "scripts": { - "dev": "wrangler dev", - "dev:auth": "wrangler dev -c wrangler.auth.toml", + "dev": "wrangler dev -c wrangler.dev.toml", + "dev:auth": "wrangler dev -c wrangler.auth.dev.toml", + "dev:stack": "node scripts/dev-stack.mjs", + "dev:smoke": "node scripts/dev-stack.mjs --smoke", "lint": "eslint .", "typecheck": "tsc --noEmit", "test": "vitest run", @@ -20,14 +22,18 @@ "ios:release": "node scripts/app-store-release.mjs", "build": "wrangler deploy --dry-run --outdir dist && wrangler deploy --dry-run --outdir dist-auth -c wrangler.auth.toml", "build:production": "wrangler deploy --dry-run --outdir dist -c wrangler.production.toml && wrangler deploy --dry-run --outdir dist-auth -c wrangler.auth.production.toml", - "d1:migrate:local": "wrangler d1 migrations apply DB --local && wrangler d1 migrations apply AUTH_DB --local -c wrangler.auth.toml", + "d1:migrate:local": "wrangler d1 migrations apply DB --local -c wrangler.dev.toml && wrangler d1 migrations apply AUTH_DB --local -c wrangler.auth.dev.toml", + "migrations:check": "node scripts/check-migrations.mjs", + "dco:check": "node scripts/check-dco.mjs", + "release:check": "node scripts/check-release.mjs", "d1:migrate:auth:production": "wrangler d1 migrations apply AUTH_DB --remote -c wrangler.auth.production.toml", "d1:migrate:api:production": "wrangler d1 migrations apply DB --remote -c wrangler.production.toml", "deploy:auth:production": "wrangler deploy --strict -c wrangler.auth.production.toml", "deploy:api:production": "wrangler deploy --strict -c wrangler.production.toml", "verify:auth:production": "node scripts/verify-cloudflare-production.mjs --auth-only", "verify:production": "node scripts/verify-cloudflare-production.mjs", - "ios:build": "xcodebuild -project ios/Bellwire/Bellwire.xcodeproj -scheme Bellwire -sdk iphonesimulator -configuration Debug CODE_SIGNING_ALLOWED=NO build" + "ios:build": "xcodebuild -quiet -project ios/Bellwire/Bellwire.xcodeproj -scheme Bellwire -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -configuration Debug CODE_SIGNING_ALLOWED=NO build", + "ios:test:unit": "node scripts/ios-test.mjs" }, "dependencies": { "@apple/app-store-server-library": "^3.1.0", @@ -47,6 +53,9 @@ "vitest": "^3.2.4", "wrangler": "^4.25.0" }, + "overrides": { + "undici": "^7.29.0" + }, "engines": { "node": ">=22" } diff --git a/scripts/check-dco.mjs b/scripts/check-dco.mjs new file mode 100644 index 0000000..2f64e81 --- /dev/null +++ b/scripts/check-dco.mjs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { execFileSync } from "node:child_process"; + +const options = parseArguments(process.argv.slice(2)); +if (options.help) { + process.stdout.write(`Usage: + DCO_BASE_SHA= DCO_HEAD_SHA= npm run dco:check + npm run dco:check -- --base --head + +Checks every non-bot commit in base..head for a Signed-off-by trailer. +`); + process.exit(0); +} + +const base = validateSha(options.base ?? process.env.DCO_BASE_SHA, "base SHA"); +const head = validateSha(options.head ?? process.env.DCO_HEAD_SHA, "head SHA"); +const commits = git(["rev-list", "--reverse", `${base}..${head}`]) + .trim() + .split("\n") + .filter(Boolean); + +const failures = []; +let botCount = 0; +for (const commit of commits) { + const [sha, authorName, authorEmail, message] = git([ + "show", + "-s", + "--format=%H%x00%an%x00%ae%x00%B", + commit, + ]).split("\0"); + if (isBot(authorName, authorEmail)) { + botCount += 1; + continue; + } + if (!/^Signed-off-by:\s+.+\s+<[^<>\s]+@[^<>\s]+>\s*$/imu.test(message)) { + failures.push(`${sha.slice(0, 12)} ${message.split("\n")[0] || "(no subject)"}`); + } +} + +if (failures.length > 0) { + process.stderr.write("The following commits are missing a valid Signed-off-by trailer:\n"); + for (const failure of failures) process.stderr.write(`- ${failure}\n`); + process.stderr.write("Amend each commit with `git commit --amend -s` and update the branch.\n"); + process.exit(1); +} + +process.stdout.write(`✓ DCO sign-off verified for ${commits.length - botCount} commit(s)`); +if (botCount > 0) process.stdout.write(`; skipped ${botCount} bot commit(s)`); +process.stdout.write("\n"); + +function git(arguments_) { + return execFileSync("git", arguments_, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }); +} + +function validateSha(value, label) { + if (typeof value !== "string" || !/^[0-9a-f]{7,40}$/iu.test(value)) { + throw new Error(`Missing or invalid ${label}`); + } + return value; +} + +function isBot(name, email) { + return name.endsWith("[bot]") || email.endsWith("[bot]@users.noreply.github.com"); +} + +function parseArguments(argv) { + const result = {}; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--help") { + result.help = true; + continue; + } + if (argument !== "--base" && argument !== "--head") { + throw new Error(`Unknown option: ${argument}`); + } + const value = argv[index + 1]; + if (!value) throw new Error(`Missing value for ${argument}`); + result[argument.slice(2)] = value; + index += 1; + } + return result; +} diff --git a/scripts/check-migrations.mjs b/scripts/check-migrations.mjs new file mode 100644 index 0000000..ea24f52 --- /dev/null +++ b/scripts/check-migrations.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: AGPL-3.0-only + +import { readFile, readdir } from "node:fs/promises"; +import path from "node:path"; + +const migrationRoots = ["d1/auth", "d1/business"]; +const destructivePatterns = [ + /\bDROP\s+(?:TABLE|INDEX|TRIGGER|COLUMN)\b/iu, + /\bALTER\s+TABLE\b[\s\S]*?\b(?:DROP|RENAME)\b/iu, + /\bTRUNCATE\b/iu, + /\bDELETE\s+FROM\b/iu, +]; +const override = "bellwire-migration: destructive-reviewed"; +const failures = []; + +for (const root of migrationRoots) { + const entries = await readdir(root, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".sql")) continue; + const file = path.join(root, entry.name); + const source = await readFile(file, "utf8"); + if (source.includes(override)) continue; + if (destructivePatterns.some((pattern) => pattern.test(source))) { + failures.push(file); + } + } +} + +if (failures.length > 0) { + process.stderr.write( + `Destructive D1 migration statements require an expand/contract review marker (${override}):\n`, + ); + for (const file of failures) process.stderr.write(`- ${file}\n`); + process.exit(1); +} + +process.stdout.write("D1 migrations satisfy the forward-compatible policy.\n"); diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs new file mode 100644 index 0000000..5e5e178 --- /dev/null +++ b/scripts/check-release.mjs @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { readFileSync } from "node:fs"; + +const tag = readTag(process.argv.slice(2), process.env.RELEASE_TAG ?? process.env.GITHUB_REF_NAME); +const packageMetadata = JSON.parse(readFileSync("package.json", "utf8")); +const expectedTag = `v${packageMetadata.version}`; + +if (tag !== expectedTag) { + throw new Error(`Release tag ${tag} does not match package version ${expectedTag}`); +} + +const changelog = readFileSync("CHANGELOG.md", "utf8"); +if (!changelog.includes(`## [${packageMetadata.version}] - `)) { + throw new Error(`CHANGELOG.md has no dated ${packageMetadata.version} release entry`); +} + +const requiredArtifacts = new Map([ + ["LICENSE", "GNU AFFERO GENERAL PUBLIC LICENSE"], + ["LICENSE.md", "Bellwire licensing"], + ["THIRD_PARTY_NOTICES.md", "SwiftUI-Agent-Skill"], + ["skills/bellwire/LICENSE", "MIT No Attribution"], + [".agents/skills/swiftui-pro/LICENSE", "MIT License"], + ["docs/openapi.yaml", "openapi: 3.1.0"], +]); +for (const [path, expectedText] of requiredArtifacts) { + const contents = readFileSync(path, "utf8"); + if (!contents.includes(expectedText)) { + throw new Error(`${path} is missing required release text: ${expectedText}`); + } +} + +process.stdout.write(`✓ Release metadata and license payloads match ${tag}\n`); + +function readTag(argv, environmentTag) { + if (argv.length === 0) { + if (!environmentTag) throw new Error("Pass --tag vX.Y.Z or set RELEASE_TAG"); + return environmentTag; + } + if (argv.length !== 2 || argv[0] !== "--tag" || !argv[1]) { + throw new Error("Usage: npm run release:check -- --tag vX.Y.Z"); + } + return argv[1]; +} diff --git a/scripts/dev-stack.mjs b/scripts/dev-stack.mjs new file mode 100644 index 0000000..06e7ec8 --- /dev/null +++ b/scripts/dev-stack.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: AGPL-3.0-only + +import { spawn } from "node:child_process"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const smoke = process.argv.slice(2).includes("--smoke"); +const children = []; +const wranglerBin = fileURLToPath( + new URL("../node_modules/wrangler/bin/wrangler.js", import.meta.url), +); + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.once(signal, async () => { + await stopChildren(); + process.exit(0); + }); +} + +try { + await runWrangler([ + "d1", "migrations", "apply", "DB", "--local", "-c", "wrangler.dev.toml", + ]); + await runWrangler([ + "d1", "migrations", "apply", "AUTH_DB", "--local", + "-c", "wrangler.auth.dev.toml", + ]); + + children.push(start("API", [ + "dev", "-c", "wrangler.dev.toml", + "--inspector-port", "9230", "--show-interactive-dev-session", "false", + ])); + children.push(start("Auth", [ + "dev", "-c", "wrangler.auth.dev.toml", + "--inspector-port", "9231", "--show-interactive-dev-session", "false", + ])); + + const [api, auth] = await Promise.all([ + waitForJSON("http://127.0.0.1:8787/health", (body) => + body?.status === "ok" && body?.service === "bellwire-api"), + waitForJSON("http://127.0.0.1:8788/health", (body) => + body?.ok === true && body?.service === "bellwire-auth"), + ]); + process.stdout.write(`Bellwire local stack ready: ${api.service}, ${auth.service}\n`); + + if (smoke) { + await stopChildren(); + } else { + process.stdout.write("API http://127.0.0.1:8787\nAuth http://127.0.0.1:8788\n"); + await Promise.race(children.map(({ process: child }) => new Promise((resolve) => { + child.once("exit", resolve); + }))); + throw new Error("A local Worker exited unexpectedly"); + } +} catch (error) { + await stopChildren(); + process.stderr.write(`Bellwire local stack: ${error instanceof Error ? error.message : "Unknown error"}\n`); + process.exitCode = 1; +} + +function run(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd: process.cwd(), stdio: "inherit" }); + child.once("error", reject); + child.once("exit", (code) => { + if (code === 0) resolve(); + else reject(new Error(`${command} ${args.join(" ")} exited with ${code}`)); + }); + }); +} + +function runWrangler(args) { + return run(process.execPath, [wranglerBin, ...args]); +} + +function start(label, args) { + const child = spawn(process.execPath, [wranglerBin, ...args], { + cwd: process.cwd(), + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout.on("data", (chunk) => process.stdout.write(`[${label}] ${chunk}`)); + child.stderr.on("data", (chunk) => process.stderr.write(`[${label}] ${chunk}`)); + return { label, process: child }; +} + +async function waitForJSON(url, validate) { + let lastError = "not reachable"; + for (let attempt = 0; attempt < 60; attempt += 1) { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(1_000) }); + const body = await response.json(); + if (response.ok && validate(body)) return body; + lastError = `HTTP ${response.status}`; + } catch (error) { + lastError = error instanceof Error ? error.message : "request failed"; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`${url} did not become healthy: ${lastError}`); +} + +async function stopChildren() { + const running = children.splice(0); + await Promise.all(running.map(({ process: child }) => new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + child.once("exit", resolve); + child.kill("SIGTERM"); + setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + }, 2_000).unref(); + }))); +} diff --git a/scripts/ios-test.mjs b/scripts/ios-test.mjs new file mode 100644 index 0000000..fc67efd --- /dev/null +++ b/scripts/ios-test.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: AGPL-3.0-only + +import { spawnSync } from "node:child_process"; +import process from "node:process"; + +const devices = spawnSync("xcrun", ["simctl", "list", "devices", "available", "--json"], { + encoding: "utf8", +}); +if (devices.status !== 0) { + process.stderr.write(devices.stderr || "Unable to list iOS simulators\n"); + process.exit(1); +} + +const runtimes = JSON.parse(devices.stdout).devices; +const iphone = Object.values(runtimes) + .flat() + .find((device) => device.isAvailable && device.name.startsWith("iPhone")); +if (!iphone) { + process.stderr.write("No available iPhone simulator was found.\n"); + process.exit(1); +} + +process.stdout.write(`Running BellwireTests on ${iphone.name} (${iphone.udid})\n`); +const result = spawnSync("xcodebuild", [ + "test", + "-quiet", + "-project", "ios/Bellwire/Bellwire.xcodeproj", + "-scheme", "Bellwire", + "-destination", `id=${iphone.udid}`, + "-configuration", "Debug", + "-enableCodeCoverage", "YES", + "CODE_SIGNING_ALLOWED=NO", +], { stdio: "inherit" }); +process.exit(result.status ?? 1); diff --git a/scripts/self-host-bootstrap.mjs b/scripts/self-host-bootstrap.mjs index 3d315a9..58df674 100644 --- a/scripts/self-host-bootstrap.mjs +++ b/scripts/self-host-bootstrap.mjs @@ -5,16 +5,19 @@ import path from "node:path"; import { LOCAL_XCCONFIG_PATH, + SELF_HOSTED_APP_ICON_PATH, WRANGLER_AUTH_SELF_HOST_PATH, WRANGLER_SELF_HOST_PATH, fileExists, parseArguments, + readSelfHostedAppIcon, renderAuthWranglerConfiguration, renderLocalXcconfig, renderWranglerConfiguration, resolveRoot, validateBootstrapOptions, writeNewFile, + writeSelfHostedAppIcon, } from "./self-host-config.mjs"; const usage = `Bellwire self-host bootstrap @@ -26,7 +29,13 @@ Usage: --api-url https://bellwire-self-host.example.workers.dev \\ --auth-url https://bellwire-self-host-auth.example.workers.dev \\ --business-d1-id 11111111-1111-4111-8111-111111111111 \\ - --auth-d1-id 22222222-2222-4222-8222-222222222222 + --auth-d1-id 22222222-2222-4222-8222-222222222222 \\ + --app-name "My Signal App" \\ + --app-icon /secure/MySignalApp-1024.png \\ + --support-email support@example.com \\ + --privacy-url https://example.com/privacy \\ + --terms-url https://example.com/terms \\ + --support-url https://example.com/support Optional: --extension-bundle-id Defaults to .NotificationService @@ -61,6 +70,12 @@ const allowedOptions = new Set([ "api-url", "auth-url", "apns-environment", + "app-name", + "app-icon", + "support-email", + "privacy-url", + "terms-url", + "support-url", "root", "json", "help", @@ -82,16 +97,26 @@ try { if (await fileExists(iosPath)) existing.push(LOCAL_XCCONFIG_PATH); if (await fileExists(workerPath)) existing.push(WRANGLER_SELF_HOST_PATH); if (await fileExists(authWorkerPath)) existing.push(WRANGLER_AUTH_SELF_HOST_PATH); + if (await fileExists(path.join(root, SELF_HOSTED_APP_ICON_PATH))) { + existing.push(SELF_HOSTED_APP_ICON_PATH); + } if (existing.length > 0) { throw new Error(`Refusing to overwrite existing configuration: ${existing.join(", ")}`); } + const appIcon = await readSelfHostedAppIcon(configuration.appIcon); await writeNewFile(iosPath, renderLocalXcconfig(configuration)); await writeNewFile(workerPath, renderWranglerConfiguration(configuration)); await writeNewFile(authWorkerPath, renderAuthWranglerConfiguration(configuration)); + await writeSelfHostedAppIcon(root, appIcon); const result = { - created: [LOCAL_XCCONFIG_PATH, WRANGLER_SELF_HOST_PATH, WRANGLER_AUTH_SELF_HOST_PATH], + created: [ + LOCAL_XCCONFIG_PATH, + WRANGLER_SELF_HOST_PATH, + WRANGLER_AUTH_SELF_HOST_PATH, + SELF_HOSTED_APP_ICON_PATH, + ], workerName: configuration.workerName, authWorkerName: configuration.authWorkerName, businessD1: configuration.businessD1Name, @@ -106,6 +131,7 @@ try { process.stdout.write(`Created ${LOCAL_XCCONFIG_PATH}\n`); process.stdout.write(`Created ${WRANGLER_SELF_HOST_PATH}\n`); process.stdout.write(`Created ${WRANGLER_AUTH_SELF_HOST_PATH}\n`); + process.stdout.write(`Created ${SELF_HOSTED_APP_ICON_PATH}\n`); process.stdout.write("No secrets were written. Add Worker secrets with wrangler, then run npm run self-host:doctor.\n"); } } catch (error) { diff --git a/scripts/self-host-config.mjs b/scripts/self-host-config.mjs index 33ef577..e821f0a 100644 --- a/scripts/self-host-config.mjs +++ b/scripts/self-host-config.mjs @@ -1,10 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-only -import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { access, lstat, mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; export const LOCAL_XCCONFIG_PATH = "ios/Bellwire/Configuration/Local.xcconfig"; export const WRANGLER_SELF_HOST_PATH = "wrangler.self-host.toml"; export const WRANGLER_AUTH_SELF_HOST_PATH = "wrangler.auth.self-host.toml"; +export const SELF_HOSTED_APP_ICON_PATH = + "ios/Bellwire/Bellwire/Assets.xcassets/SelfHostedAppIcon.appiconset"; export function parseArguments(argv, booleanOptions = new Set(), allowedOptions) { const options = {}; @@ -38,6 +40,18 @@ export function validateBootstrapOptions(options) { const apiURL = httpsOrigin(required(options, "api-url"), "--api-url"); const authURL = httpsOrigin(required(options, "auth-url"), "--auth-url"); + const appName = required(options, "app-name"); + if (appName.toLowerCase() === "bellwire" || !validXcconfigText(appName)) { + throw new Error("--app-name must be a safe custom name other than Bellwire"); + } + const appIcon = required(options, "app-icon"); + const supportEmail = required(options, "support-email").toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(supportEmail)) { + throw new Error("--support-email must be a valid email address"); + } + const privacyURL = httpsURL(required(options, "privacy-url"), "--privacy-url"); + const termsURL = httpsURL(required(options, "terms-url"), "--terms-url"); + const supportURL = httpsURL(required(options, "support-url"), "--support-url"); const workerName = options["worker-name"] ?? "bellwire-self-host"; const authWorkerName = options["auth-worker-name"] ?? `${workerName}-auth`; @@ -92,6 +106,12 @@ export function validateBootstrapOptions(options) { queuePrefix, urlScheme, apnsEnvironment, + appName, + appIcon, + supportEmail, + privacyURL, + termsURL, + supportURL, }; } @@ -104,13 +124,56 @@ BELLWIRE_EXTENSION_BUNDLE_ID = ${configuration.extensionBundleId} BELLWIRE_WIDGET_BUNDLE_ID = ${configuration.widgetBundleId} BELLWIRE_APP_GROUP = ${configuration.appGroup} BELLWIRE_URL_SCHEME = ${configuration.urlScheme} +BELLWIRE_APP_DISPLAY_NAME = ${configuration.appName} +BELLWIRE_APP_ICON_NAME = SelfHostedAppIcon +BELLWIRE_BILLING_MODE = disabled +BELLWIRE_PRO_MONTHLY_PRODUCT_ID = self-hosted.disabled.monthly +BELLWIRE_PRO_YEARLY_PRODUCT_ID = self-hosted.disabled.yearly +BELLWIRE_SUPPORT_EMAIL = ${configuration.supportEmail} // The empty $() keeps // from being parsed as an xcconfig comment. BELLWIRE_API_BASE_URL = ${urlForXcconfig(configuration.apiURL)} BELLWIRE_AUTH_BASE_URL = ${urlForXcconfig(configuration.authURL)} +BELLWIRE_PRIVACY_URL = ${urlForXcconfig(configuration.privacyURL)} +BELLWIRE_TERMS_URL = ${urlForXcconfig(configuration.termsURL)} +BELLWIRE_SUPPORT_URL = ${urlForXcconfig(configuration.supportURL)} `; } +export async function readSelfHostedAppIcon(sourcePath) { + const source = path.resolve(sourcePath); + const details = await lstat(source).catch(() => undefined); + if (!details?.isFile() || details.isSymbolicLink()) { + throw new Error("--app-icon must be a regular PNG file, not a symlink"); + } + const image = await readFile(source); + if ( + image.length < 24 + || image.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a" + ) { + throw new Error("--app-icon must be a valid PNG file"); + } + if (image.readUInt32BE(16) !== 1024 || image.readUInt32BE(20) !== 1024) { + throw new Error("--app-icon must be exactly 1024x1024 pixels"); + } + return image; +} + +export async function writeSelfHostedAppIcon(root, image) { + const directory = path.join(root, SELF_HOSTED_APP_ICON_PATH); + await mkdir(directory, { recursive: true }); + await writeFile(path.join(directory, "AppIcon.png"), image, { flag: "wx", mode: 0o600 }); + await writeFile(path.join(directory, "Contents.json"), `${JSON.stringify({ + images: [{ + filename: "AppIcon.png", + idiom: "universal", + platform: "ios", + size: "1024x1024", + }], + info: { author: "xcode", version: 1 }, + }, null, 2)}\n`, { flag: "wx", mode: 0o600 }); +} + export function renderWranglerConfiguration(configuration) { const deliveryQueue = `${configuration.queuePrefix}-deliveries`; return `name = ${JSON.stringify(configuration.workerName)} @@ -126,6 +189,7 @@ AUTH_ISSUER = ${JSON.stringify(configuration.authURL)} AUTH_AUDIENCE = "bellwire-api" APNS_BUNDLE_ID = ${JSON.stringify(configuration.bundleId)} APP_URL_SCHEME = ${JSON.stringify(configuration.urlScheme)} +APP_DISPLAY_NAME = ${JSON.stringify(configuration.appName)} APNS_ENVIRONMENT = ${JSON.stringify(configuration.apnsEnvironment)} ENTITLEMENT_ENFORCEMENT_MODE = "disabled" @@ -298,6 +362,23 @@ function httpsOrigin(value, label) { return url.origin; } +function httpsURL(value, label) { + let url; + try { + url = new URL(value); + } catch { + throw new Error(`${label} must be a valid HTTPS URL`); + } + if (url.protocol !== "https:" || url.username || url.password) { + throw new Error(`${label} must be an HTTPS URL without credentials`); + } + return url.href; +} + +function validXcconfigText(value) { + return value.length <= 40 && !/[\r\n=#$\\/]/u.test(value); +} + function validBundleId(value) { return value.includes(".") && !value.includes("..") diff --git a/scripts/self-host-doctor.mjs b/scripts/self-host-doctor.mjs index 62827b0..0805399 100644 --- a/scripts/self-host-doctor.mjs +++ b/scripts/self-host-doctor.mjs @@ -5,6 +5,7 @@ import path from "node:path"; import { LOCAL_XCCONFIG_PATH, + SELF_HOSTED_APP_ICON_PATH, WRANGLER_AUTH_SELF_HOST_PATH, WRANGLER_SELF_HOST_PATH, containsPlaceholder, @@ -12,6 +13,7 @@ import { parseArguments, parseWranglerConfiguration, parseXcconfig, + readSelfHostedAppIcon, readText, resolveRoot, validateBootstrapOptions, @@ -51,12 +53,28 @@ try { const iosPath = path.join(root, LOCAL_XCCONFIG_PATH); const workerPath = path.join(root, WRANGLER_SELF_HOST_PATH); const authWorkerPath = path.join(root, WRANGLER_AUTH_SELF_HOST_PATH); + const appIconPath = path.join(root, SELF_HOSTED_APP_ICON_PATH, "AppIcon.png"); + const appIconContentsPath = path.join(root, SELF_HOSTED_APP_ICON_PATH, "Contents.json"); const iosSource = await requiredFile(iosPath, LOCAL_XCCONFIG_PATH); const workerSource = await requiredFile(workerPath, WRANGLER_SELF_HOST_PATH); const authWorkerSource = await requiredFile(authWorkerPath, WRANGLER_AUTH_SELF_HOST_PATH); + const appIconContents = await requiredFile( + appIconContentsPath, + `${SELF_HOSTED_APP_ICON_PATH}/Contents.json`, + ); + try { + await readSelfHostedAppIcon(appIconPath); + checks.push("custom app icon is a regular 1024x1024 PNG"); + } catch (error) { + errors.push( + `${SELF_HOSTED_APP_ICON_PATH}/AppIcon.png is invalid: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + ); + } const gitignore = await optionalFile(path.join(root, ".gitignore")); - if (iosSource && workerSource && authWorkerSource) { + if (iosSource && workerSource && authWorkerSource && appIconContents) { rejectSecrets(LOCAL_XCCONFIG_PATH, iosSource); rejectSecrets(WRANGLER_SELF_HOST_PATH, workerSource); rejectSecrets(WRANGLER_AUTH_SELF_HOST_PATH, authWorkerSource); @@ -129,6 +147,15 @@ function validateRequiredValues(ios, worker, authWorker) { "BELLWIRE_URL_SCHEME", "BELLWIRE_API_BASE_URL", "BELLWIRE_AUTH_BASE_URL", + "BELLWIRE_APP_DISPLAY_NAME", + "BELLWIRE_APP_ICON_NAME", + "BELLWIRE_BILLING_MODE", + "BELLWIRE_PRO_MONTHLY_PRODUCT_ID", + "BELLWIRE_PRO_YEARLY_PRODUCT_ID", + "BELLWIRE_SUPPORT_EMAIL", + "BELLWIRE_PRIVACY_URL", + "BELLWIRE_TERMS_URL", + "BELLWIRE_SUPPORT_URL", ]; const workerKeys = [ "APP_ENV", @@ -136,6 +163,7 @@ function validateRequiredValues(ios, worker, authWorker) { "AUTH_AUDIENCE", "APNS_BUNDLE_ID", "APP_URL_SCHEME", + "APP_DISPLAY_NAME", "APNS_ENVIRONMENT", "ENTITLEMENT_ENFORCEMENT_MODE", ]; @@ -195,6 +223,12 @@ function validateFormats(ios, worker, authWorker) { "auth-d1-name": authWorker.d1.database_name, "auth-d1-id": authWorker.d1.database_id, "apns-environment": worker.vars.APNS_ENVIRONMENT, + "app-name": ios.BELLWIRE_APP_DISPLAY_NAME, + "app-icon": path.join(SELF_HOSTED_APP_ICON_PATH, "AppIcon.png"), + "support-email": ios.BELLWIRE_SUPPORT_EMAIL, + "privacy-url": ios.BELLWIRE_PRIVACY_URL, + "terms-url": ios.BELLWIRE_TERMS_URL, + "support-url": ios.BELLWIRE_SUPPORT_URL, }); checks.push("configuration values use valid formats"); } catch (error) { @@ -203,8 +237,18 @@ function validateFormats(ios, worker, authWorker) { } function validateConsistency(ios, worker, authWorker) { + if (ios.BELLWIRE_APP_DISPLAY_NAME.toLowerCase() === "bellwire") { + errors.push("self-hosted app name must not use the Bellwire trademark"); + } else checks.push("self-hosted app uses a custom display name"); + if (ios.BELLWIRE_APP_ICON_NAME !== "SelfHostedAppIcon") { + errors.push("self-hosted app must use the generated SelfHostedAppIcon asset"); + } else checks.push("self-hosted app uses a custom icon asset"); + if (ios.BELLWIRE_BILLING_MODE !== "disabled") { + errors.push("self-hosted App Store billing must be disabled"); + } else checks.push("self-hosted App Store billing is disabled"); compare("Bundle ID", ios.BELLWIRE_APP_BUNDLE_ID, worker.vars.APNS_BUNDLE_ID); compare("URL scheme", ios.BELLWIRE_URL_SCHEME, worker.vars.APP_URL_SCHEME); + compare("App display name", ios.BELLWIRE_APP_DISPLAY_NAME, worker.vars.APP_DISPLAY_NAME); compare("Auth URL", normalizeURL(ios.BELLWIRE_AUTH_BASE_URL), normalizeURL(worker.vars.AUTH_ISSUER)); compare("Auth Worker issuer", normalizeURL(ios.BELLWIRE_AUTH_BASE_URL), normalizeURL(authWorker.vars.AUTH_ISSUER)); compare("Auth audience", worker.vars.AUTH_AUDIENCE, authWorker.vars.AUTH_AUDIENCE); @@ -276,6 +320,7 @@ function validateGitignore(source) { LOCAL_XCCONFIG_PATH, WRANGLER_SELF_HOST_PATH, WRANGLER_AUTH_SELF_HOST_PATH, + `${SELF_HOSTED_APP_ICON_PATH}/`, ".dev.vars", ]) { if (ignored.has(expected)) checks.push(`${expected} is ignored by Git`); diff --git a/src/domain/models.ts b/src/domain/models.ts index 9742ea5..638be5c 100644 --- a/src/domain/models.ts +++ b/src/domain/models.ts @@ -399,9 +399,22 @@ export interface SignalUsage { courtesyRemainingSignals: number; } +export type DeploymentMode = "hosted" | "self_hosted"; + +export interface EntitlementCapabilities { + billing: "app_store" | "disabled"; + commercialLimitsEnforced: boolean; + projectExport: boolean; + liveActivities: boolean; +} + export interface AccountEntitlement { plan: PlanId; status: EntitlementStatus; + /** Added by the API boundary. Optional for stored records and older clients. */ + deployment?: DeploymentMode; + /** Server-authoritative feature gates. Optional for backwards compatibility. */ + capabilities?: EntitlementCapabilities; productId?: string; expiresAt?: string; downgradeDeadline?: string; diff --git a/src/index.ts b/src/index.ts index b63d76b..6a10de7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,6 +47,7 @@ export interface Env { APNS_TEAM_ID?: string; APNS_BUNDLE_ID?: string; APP_URL_SCHEME?: string; + APP_DISPLAY_NAME?: string; APNS_PRIVATE_KEY?: string; APNS_ENVIRONMENT?: "sandbox" | "production"; ENTITLEMENT_ENFORCEMENT_MODE?: "disabled" | "shadow" | "enforce"; @@ -113,6 +114,7 @@ export default { const apnsForEnvironment = (environment: "sandbox" | "production") => apnsClients.get({ bundleId: requiredEnv(env.APNS_BUNDLE_ID, "APNS_BUNDLE_ID"), urlScheme: env.APP_URL_SCHEME ?? "bellwire", + appName: env.APP_DISPLAY_NAME?.trim() || "Bellwire", environment, }, providerTokens); const processor = new DeliveryProcessor(repository, apnsForEnvironment); diff --git a/src/security/tokens.ts b/src/security/tokens.ts index 3168b63..81718a3 100644 --- a/src/security/tokens.ts +++ b/src/security/tokens.ts @@ -26,8 +26,13 @@ export async function hashSecret(secret: string): Promise { } export function createPairingCode(): string { - const bytes = crypto.getRandomValues(new Uint32Array(1)); - return String((bytes[0] ?? 0) % 1_000_000).padStart(6, "0"); + const modulus = 1_000_000; + const unbiasedUpperBound = Math.floor(2 ** 32 / modulus) * modulus; + const bytes = new Uint32Array(1); + do { + crypto.getRandomValues(bytes); + } while ((bytes[0] ?? 0) >= unbiasedUpperBound); + return String((bytes[0] ?? 0) % modulus).padStart(6, "0"); } export function readBearerToken(value: string | undefined): string | undefined { diff --git a/src/services/apns-client.ts b/src/services/apns-client.ts index d8b466a..7740cad 100644 --- a/src/services/apns-client.ts +++ b/src/services/apns-client.ts @@ -3,6 +3,7 @@ export interface ApnsConfiguration { bundleId: string; urlScheme: string; + appName: string; environment: "sandbox" | "production"; } @@ -89,18 +90,19 @@ export class ApnsClient { alert: notification.modeRequest ? { title: notification.title ?? "Approval needed", - body: notification.body ?? "Open Bellwire to review this request.", + body: notification.body ?? `Open ${this.config.appName} to review this request.`, ...(notification.subtitle ? { subtitle: notification.subtitle } : {}), } : notification.deliveryMode === "hosted" ? { - title: notification.title ?? "Bellwire", + title: notification.title ?? this.config.appName, body: notification.body ?? "", ...(notification.subtitle ? { subtitle: notification.subtitle } : {}), } : { - title: "Bellwire", + title: this.config.appName, "loc-key": "BELLWIRE_PRIVATE_NOTIFICATION_BODY", + "loc-args": [this.config.appName], }, sound: notification.sound ?? "default", "thread-id": notification.threadId, @@ -225,6 +227,7 @@ export class ApnsClientPool { function sameConfiguration(left: ApnsConfiguration, right: ApnsConfiguration): boolean { return left.bundleId === right.bundleId && left.urlScheme === right.urlScheme && + left.appName === right.appName && left.environment === right.environment; } diff --git a/src/services/bellwire-service.ts b/src/services/bellwire-service.ts index 0cc0a55..ba41f28 100644 --- a/src/services/bellwire-service.ts +++ b/src/services/bellwire-service.ts @@ -516,7 +516,36 @@ export class BellwireService { async getAccountEntitlement(principal: Principal): Promise { this.requireSignedInUser(principal); - return this.repository.getAccountEntitlement(principal.userId, new Date().toISOString()); + const entitlement = await this.repository.getAccountEntitlement( + principal.userId, + new Date().toISOString(), + ); + const activePro = entitlement.plan === "pro" + && (entitlement.status === "active" || entitlement.status === "grace"); + if (this.enforcementMode === "disabled") { + return { + ...entitlement, + plan: "pro", + status: "active", + deployment: "self_hosted", + capabilities: { + billing: "disabled", + commercialLimitsEnforced: false, + projectExport: true, + liveActivities: true, + }, + }; + } + return { + ...entitlement, + deployment: "hosted", + capabilities: { + billing: "app_store", + commercialLimitsEnforced: this.enforcementMode === "enforce", + projectExport: activePro, + liveActivities: activePro, + }, + }; } async getProjectOverview(principal: Principal, projectId: string) { @@ -1681,7 +1710,11 @@ export class BellwireService { principal.userId, new Date().toISOString(), ); - if (entitlement.plan !== "pro") { + if ( + this.enforcementMode === "enforce" + && (entitlement.plan !== "pro" + || (entitlement.status !== "active" && entitlement.status !== "grace")) + ) { throw planLimitReached("project export", entitlement); } diff --git a/test/apns-provider-token-authority.test.ts b/test/apns-provider-token-authority.test.ts index 978e1ff..d22f41b 100644 --- a/test/apns-provider-token-authority.test.ts +++ b/test/apns-provider-token-authority.test.ts @@ -331,6 +331,7 @@ function apnsConfig(environment: "sandbox" | "production") { return { bundleId: "app.bellwire", urlScheme: "bellwire", + appName: "Bellwire", environment, }; } diff --git a/test/app.test.ts b/test/app.test.ts index 37a1885..801e16a 100644 --- a/test/app.test.ts +++ b/test/app.test.ts @@ -161,6 +161,49 @@ describe("Bellwire MVP API", () => { }); }); + it("publishes explicit hosted and self-hosted feature capabilities", async () => { + const selfHosted = new BellwireService(repository, dispatcher, undefined, "disabled"); + await expect(selfHosted.getAccountEntitlement(userPrincipal)).resolves.toMatchObject({ + deployment: "self_hosted", + plan: "pro", + status: "active", + capabilities: { + billing: "disabled", + commercialLimitsEnforced: false, + projectExport: true, + liveActivities: true, + }, + }); + + const hosted = new BellwireService(repository, dispatcher, undefined, "enforce"); + await expect(hosted.getAccountEntitlement(userPrincipal)).resolves.toMatchObject({ + deployment: "hosted", + plan: "free", + capabilities: { + billing: "app_store", + commercialLimitsEnforced: true, + projectExport: false, + liveActivities: false, + }, + }); + }); + + it("allows self-hosted export without an App Store transaction", async () => { + const selfHosted = new BellwireService(repository, dispatcher, undefined, "disabled"); + const project = await selfHosted.createProject(userPrincipal, { name: "Self-hosted" }); + await repository.updateProject({ ...project, deliveryMode: "hosted" }); + await expect(selfHosted.exportHostedProject(userPrincipal, project.id)).resolves.toMatchObject({ + version: 1, + project: { id: project.id, deliveryMode: "hosted" }, + events: [], + }); + + const hosted = new BellwireService(repository, dispatcher, undefined, "enforce"); + await expect(hosted.exportHostedProject(userPrincipal, project.id)).rejects.toMatchObject({ + code: "PLAN_LIMIT_REACHED", + }); + }); + it("creates a project, schema, surface, and one-time ingest token while storing only its hash", async () => { const projectId = await createProject(); const token = await configureProject(projectId); @@ -2361,6 +2404,10 @@ describe("Bellwire MVP API", () => { }); it("exports Hosted Event and delivery records only for a server-authoritative Pro account", async () => { + const hostedApp = createApp({ + service: new BellwireService(repository, dispatcher, undefined, "enforce"), + authenticator: new StaticAuthenticator(userPrincipal), + }); const projectId = await createProject(); await makeHosted(projectId); const eventId = "55555555-5555-4555-8555-555555555555"; @@ -2388,7 +2435,7 @@ describe("Bellwire MVP API", () => { updatedAt: occurredAt, }); - expect((await app.request(`/v1/projects/${projectId}/export`, { + expect((await hostedApp.request(`/v1/projects/${projectId}/export`, { headers: { authorization: "Bearer test" }, })).status).toBe(409); @@ -2404,7 +2451,7 @@ describe("Bellwire MVP API", () => { signedDate: occurredAt, updatedAt: occurredAt, }); - const response = await app.request(`/v1/projects/${projectId}/export`, { + const response = await hostedApp.request(`/v1/projects/${projectId}/export`, { headers: { authorization: "Bearer test" }, }); expect(response.status).toBe(200); diff --git a/test/cloudflare-production-deploy.test.js b/test/cloudflare-production-deploy.test.js index 8a4fb47..e1e89d3 100644 --- a/test/cloudflare-production-deploy.test.js +++ b/test/cloudflare-production-deploy.test.js @@ -92,7 +92,10 @@ describe("Cloudflare production verification", () => { expect(positions.every((position) => position >= 0)).toBe(true); expect(positions).toEqual([...positions].sort((left, right) => left - right)); expect(workflow).toContain("environment: production"); - expect(workflow).toContain("Roll back deployed Worker versions after failure"); + expect(workflow).toContain("Roll back Worker code after failure; D1 migrations stay forward-only"); + expect(workflow.indexOf("CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}")) + .toBeGreaterThan(workflow.indexOf("Install dependencies")); + expect(workflow.match(/^\s+CLOUDFLARE_ACCOUNT_ID:/gmu)).toHaveLength(7); expect(`${authConfig}\n${apiConfig}`).not.toMatch(/SUPABASE|LEGACY_SUPABASE|REWRAP|CUTOVER/u); }); }); diff --git a/test/delivery.test.ts b/test/delivery.test.ts index 86a5ce1..5806a9b 100644 --- a/test/delivery.test.ts +++ b/test/delivery.test.ts @@ -560,6 +560,7 @@ describe("APNs client", () => { const baseConfig = { bundleId: "app.bellwire", urlScheme: "bellwire", + appName: "Bellwire", }; const clients = ["sandbox", "production"].flatMap((environment) => Array.from({ length: 8 }, () => pool.get({ @@ -602,6 +603,7 @@ describe("APNs client", () => { const client = new ApnsClient({ bundleId: "app.bellwire", urlScheme: "bellwire", + appName: "Bellwire", environment: "production", }, providerTokens, async () => new Response(JSON.stringify({ reason }), { status: 403, @@ -630,6 +632,7 @@ describe("APNs client", () => { const client = new ApnsClient({ bundleId: "app.bellwire", urlScheme: "bellwire-self-host", + appName: "Example Signals", environment: "sandbox", }, staticProviderTokenSource(), fetchImpl); @@ -669,6 +672,7 @@ describe("APNs client", () => { const client = new ApnsClient({ bundleId: "app.bellwire", urlScheme: "bellwire", + appName: "Bellwire", environment: "sandbox", }, staticProviderTokenSource(), async (input, init) => { captured = new Request(input, init); @@ -715,6 +719,7 @@ describe("APNs client", () => { const client = new ApnsClient({ bundleId: "app.bellwire", urlScheme: "bellwire", + appName: "Example Signals", environment: "production", }, staticProviderTokenSource(), fetchImpl); @@ -732,8 +737,9 @@ describe("APNs client", () => { expect(payload).toMatchObject({ aps: { alert: { - title: "Bellwire", + title: "Example Signals", "loc-key": "BELLWIRE_PRIVATE_NOTIFICATION_BODY", + "loc-args": ["Example Signals"], }, "mutable-content": 1, }, @@ -756,6 +762,7 @@ describe("APNs client", () => { const client = new ApnsClient({ bundleId: "app.bellwire", urlScheme: "bellwire", + appName: "Bellwire", environment: "sandbox", }, staticProviderTokenSource(), fetchImpl); diff --git a/test/governance.test.js b/test/governance.test.js new file mode 100644 index 0000000..af360ad --- /dev/null +++ b/test/governance.test.js @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const read = (path) => readFileSync(path, "utf8"); + +describe("open-source governance and release contracts", () => { + it("enforces DCO sign-offs with immutable Actions", () => { + const workflow = read(".github/workflows/dco.yml"); + expect(workflow).toContain("Developer Certificate of Origin"); + expect(workflow).toContain("fetch-depth: 0"); + expect(workflow).toContain("node scripts/check-dco.mjs"); + expect(workflow).toMatch(/actions\/checkout@[0-9a-f]{40}/u); + }); + + it("binds repository releases to versioned metadata, notices, and an SBOM", () => { + const packageMetadata = JSON.parse(read("package.json")); + const workflow = read(".github/workflows/release.yml"); + expect(packageMetadata.version).toBe("0.2.0"); + expect(read("CHANGELOG.md")).toContain(`## [${packageMetadata.version}] - `); + expect(workflow).toContain("npm run release:check"); + expect(workflow).toContain("npm sbom --sbom-format=cyclonedx"); + expect(workflow).toContain("MIT No Attribution"); + expect(workflow).toContain("THIRD_PARTY_NOTICES.md"); + }); + + it("publishes operational and API documentation without private paths", () => { + const openapi = read("docs/openapi.yaml"); + expect(openapi).toContain("openapi: 3.1.0"); + expect(read("docs/telemetry.md")).toContain("POSTHOG_PROJECT_KEY"); + expect(read("docs/upgrading.md")).toContain("forward-only"); + expect(read("docs/security-model.md")).toContain("Incident response"); + expect(read("docs/lovable-ui-mapping.md")).not.toContain("/Users/"); + }); + + it("keeps every public business API operation in OpenAPI", () => { + const sourceOperations = [...read("src/app.ts").matchAll( + /app\.(get|post|put|patch|delete)\("([^"]+)"/gu, + )].map((match) => normalizeOperation(match[1], match[2])); + const documentedOperations = []; + let currentPath; + for (const line of read("docs/openapi.yaml").split("\n")) { + const path = /^ {2}(\/[^:]+(?:\{[^}]+\}[^:]*)?):$/u.exec(line); + if (path) { + currentPath = path[1]; + continue; + } + const method = /^ {4}(get|post|put|patch|delete):$/u.exec(line); + if (method && currentPath) { + documentedOperations.push(normalizeOperation(method[1], currentPath)); + } + } + expect(new Set(documentedOperations)).toEqual(new Set(sourceOperations)); + }); +}); + +function normalizeOperation(method, path) { + return `${method} ${path.replace(/:[^/]+|\{[^}]+\}/gu, "{parameter}")}`; +} diff --git a/test/ios-preview.test.js b/test/ios-preview.test.js index a9ae02a..75e5f93 100644 --- a/test/ios-preview.test.js +++ b/test/ios-preview.test.js @@ -433,9 +433,10 @@ describe("iOS Inbox preview", () => { ); expect(accountOverview).toContain("if hasPro {"); - expect(accountOverview).toContain("ProActiveSettingsRow()\n } else {"); - expect(accountOverview).toContain("SettingsRowView(\n icon: \"sparkles\""); - expect(accountOverview).toContain('title: "Upgrade to Bellwire Pro"'); + expect(accountOverview).toContain("ProActiveSettingsRow()"); + expect(accountOverview).toContain("if model.entitlement?.isSelfHosted == true {"); + expect(accountOverview).toContain('icon: "sparkles"'); + expect(accountOverview).toContain('title: "Upgrade to \\(AppConfig.displayName) Pro"'); expect(accountOverview).not.toContain('icon: hasPro ? "checkmark.seal.fill" : "sparkles"'); expect(theme).not.toContain("static let proActiveSurface = adaptiveColor("); @@ -454,13 +455,15 @@ describe("iOS Inbox preview", () => { expect(proActiveRow).not.toContain(".overlay"); expect(proActiveRow).not.toContain(".stroke("); expect(proActiveRow).not.toContain(".frame(width: 40, height: 40)"); - expect(proActiveRow).toContain('Text("Manage Bellwire Pro")'); + expect(proActiveRow).toContain('Text("Manage \\(AppConfig.displayName) Pro")'); expect(proActiveRow).toContain('Text("Your Pro access is active")'); expect(proActiveRow).toContain('Image(systemName: "chevron.right")'); expect(proActiveRow).toContain(".foregroundStyle(BellwireTheme.mutedInk)"); expect(proActiveRow).toContain(".padding(.vertical, 13)"); expect(proActiveRow).toContain(".frame(maxWidth: .infinity, minHeight: 44"); - expect(proActiveRow).toContain('.accessibilityLabel(Text("Manage Bellwire Pro"))'); + expect(proActiveRow).toContain( + '.accessibilityLabel(Text("Manage \\(AppConfig.displayName) Pro"))', + ); expect(proActiveRow).toContain('.accessibilityValue(Text("Your Pro access is active"))'); expect(zhHans).not.toContain('"PRO ACTIVE" = "PRO 已生效";'); }); @@ -594,7 +597,7 @@ describe("iOS Inbox preview", () => { ); }); - it("localizes the Welcome hero and linked legal footer as complete phrases", () => { + it("localizes the Welcome hero while keeping deployment legal links configurable", () => { const onboarding = readFileSync("ios/Bellwire/Bellwire/OnboardingViews.swift", "utf8"); const english = readFileSync( "ios/Bellwire/Bellwire/en.lproj/Localizable.strings", @@ -608,11 +611,10 @@ describe("iOS Inbox preview", () => { onboarding.indexOf("struct WelcomeView"), onboarding.indexOf("private struct WelcomePreviewRow"), ); - const legal = - "By continuing, you agree to Bellwire’s [Terms of Service](https://bellwire.app/terms) and [Privacy Policy](https://bellwire.app/privacy). Sensitive fields stay redacted until you reveal them."; - expect(welcome).toContain('Text("Signals from every project,\\non your iPhone.")'); - expect(welcome).toContain(`Text("${legal}")`); + expect(welcome).toContain('"By continuing, you agree to Bellwire’s policies."'); + expect(welcome).toContain('Link("Terms of Service", destination: AppConfig.termsURL)'); + expect(welcome).toContain('Link("Privacy Policy", destination: AppConfig.privacyURL)'); expect(welcome).not.toContain('+ Text("every project,")'); expect(welcome).not.toContain(".foregroundColor("); expect(english).toContain( @@ -621,9 +623,12 @@ describe("iOS Inbox preview", () => { expect(chinese).toContain( '"Signals from every project,\\non your iPhone." = "每个项目的 Signal,\\n尽在你的 iPhone。";', ); - expect(english).toContain(`"${legal}" = "${legal}";`); - expect(chinese).toContain("[服务条款](https://bellwire.app/terms)"); - expect(chinese).toContain("[隐私政策](https://bellwire.app/privacy)"); + expect(english).toContain( + '"By continuing, you agree to Bellwire’s policies." = "By continuing, you agree to Bellwire’s policies.";', + ); + expect(chinese).toContain( + '"By continuing, you agree to Bellwire’s policies." = "继续即表示你同意 Bellwire 的相关政策。";', + ); }); it("makes Notification permission state-aware in Settings", () => { diff --git a/test/license-boundaries.test.js b/test/license-boundaries.test.js index 8d6cf4f..1671021 100644 --- a/test/license-boundaries.test.js +++ b/test/license-boundaries.test.js @@ -44,6 +44,8 @@ describe("multi-license boundaries", () => { expect(policy).toContain(identifier); } expect(policy).toContain("docs/private/**"); + expect(policy).toContain(".agents/skills/swiftui-pro/**"); + expect(policy).toContain("docs/brand/assets/**"); expect(policy).toMatch(/Bellwire app icon\s+files remain brand assets/); expect(read("CHANGELOG.md")).toContain( "SPDX-License-Identifier: Apache-2.0", @@ -53,6 +55,16 @@ describe("multi-license boundaries", () => { ); }); + it("ships the bundled SwiftUI skill's upstream MIT notice", () => { + const license = read(".agents/skills/swiftui-pro/LICENSE"); + const notices = read("THIRD_PARTY_NOTICES.md"); + expect(license).toContain("MIT License"); + expect(license).toContain("Copyright (c) 2026 Paul Hudson"); + expect(notices).toContain("SwiftUI-Agent-Skill"); + expect(notices).toContain("be297e146e9080167780afabeee896873c6fc1c5"); + expect(notices).toContain("CycloneDX npm SBOM"); + }); + it("marks Workers, D1, tooling, and JS/TS tests as AGPL", () => { const paths = [ ...filesBelow("src").filter((path) => extname(path) === ".ts"), diff --git a/test/self-hosting.test.js b/test/self-hosting.test.js index fb699a8..658ad50 100644 --- a/test/self-hosting.test.js +++ b/test/self-hosting.test.js @@ -40,7 +40,20 @@ describe("self-host bootstrap and doctor", () => { expect(ios).toContain("BELLWIRE_EXTENSION_BUNDLE_ID = com.example.bellwire.NotificationService"); expect(ios).toContain("BELLWIRE_WIDGET_BUNDLE_ID = com.example.bellwire.Widgets"); expect(ios).toContain("BELLWIRE_APP_GROUP = group.com.example.bellwire.shared"); + expect(ios).toContain("BELLWIRE_APP_DISPLAY_NAME = Example Signals"); + expect(ios).toContain("BELLWIRE_APP_ICON_NAME = SelfHostedAppIcon"); + expect(ios).toContain("BELLWIRE_BILLING_MODE = disabled"); + expect(ios).toContain("BELLWIRE_SUPPORT_EMAIL = support@example.com"); + expect(ios).toContain("BELLWIRE_PRIVACY_URL = https:/$()/example.com/privacy"); + expect(readFileSync( + join( + root, + "ios/Bellwire/Bellwire/Assets.xcassets/SelfHostedAppIcon.appiconset/Contents.json", + ), + "utf8", + )).toContain('"filename": "AppIcon.png"'); expect(worker).toContain('APP_URL_SCHEME = "bellwire-self-host"'); + expect(worker).toContain('APP_DISPLAY_NAME = "Example Signals"'); expect(worker).toContain('compatibility_flags = ["nodejs_compat"]'); expect(worker).toContain('ENTITLEMENT_ENFORCEMENT_MODE = "disabled"'); expect(worker).toContain('crons = ["17 * * * *"]'); @@ -97,6 +110,20 @@ describe("self-host bootstrap and doctor", () => { expect(result.stderr).toContain("must be a Cloudflare D1 database UUID"); }); + it("validates the custom app icon before writing any configuration", () => { + const root = temporaryRoot(); + const invalidIcon = join(root, "invalid.png"); + writeFileSync(invalidIcon, "not a png"); + const args = bootstrapArguments(root); + args[args.indexOf("--app-icon") + 1] = invalidIcon; + + const result = run(bootstrapScript, args); + expect(result.status).toBe(1); + expect(result.stderr).toContain("--app-icon must be a valid PNG file"); + expect(() => readFileSync(join(root, "wrangler.self-host.toml"))).toThrow(); + expect(() => readFileSync(join(root, "ios/Bellwire/Configuration/Local.xcconfig"))).toThrow(); + }); + it("rejects unknown options instead of silently ignoring a typo", () => { const result = run(bootstrapScript, ["--teamid", "ABC123DEFG"]); expect(result.status).toBe(1); @@ -137,7 +164,7 @@ function temporaryRoot() { function writeGitignore(root) { writeFileSync( join(root, ".gitignore"), - ".dev.vars\nwrangler.self-host.toml\nwrangler.auth.self-host.toml\nios/Bellwire/Configuration/Local.xcconfig\n", + ".dev.vars\nwrangler.self-host.toml\nwrangler.auth.self-host.toml\nios/Bellwire/Configuration/Local.xcconfig\nios/Bellwire/Bellwire/Assets.xcassets/SelfHostedAppIcon.appiconset/\n", ); } @@ -152,6 +179,15 @@ function bootstrapArguments(root) { "--auth-url", "https://bellwire-auth.example.workers.dev", "--business-d1-id", "11111111-1111-4111-8111-111111111111", "--auth-d1-id", "22222222-2222-4222-8222-222222222222", + "--app-name", "Example Signals", + "--app-icon", join( + repositoryRoot, + "ios/Bellwire/Bellwire/Assets.xcassets/AppIcon.appiconset/BellwireIcon.png", + ), + "--support-email", "support@example.com", + "--privacy-url", "https://example.com/privacy", + "--terms-url", "https://example.com/terms", + "--support-url", "https://example.com/support", ]; } diff --git a/wrangler.auth.dev.toml b/wrangler.auth.dev.toml new file mode 100644 index 0000000..cae34bb --- /dev/null +++ b/wrangler.auth.dev.toml @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: AGPL-3.0-only +name = "bellwire-auth-dev" +main = "src/auth/index.ts" +compatibility_date = "2026-08-05" +compatibility_flags = ["nodejs_compat"] +workers_dev = true +preview_urls = false + +[dev] +port = 8788 +local_protocol = "http" + +[vars] +AUTH_ENVIRONMENT = "development" +AUTH_ISSUER = "http://127.0.0.1:8788" +AUTH_AUDIENCE = "bellwire-api" +APPLE_SIGN_IN_CLIENT_ID = "app.bellwire.local" +APPLE_APP_BUNDLE_ID = "app.bellwire.local" + +[[d1_databases]] +binding = "AUTH_DB" +database_name = "bellwire-auth-local" +database_id = "00000000-0000-4000-8000-000000000002" +migrations_dir = "d1/auth" diff --git a/wrangler.dev.toml b/wrangler.dev.toml new file mode 100644 index 0000000..d1ad66f --- /dev/null +++ b/wrangler.dev.toml @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: AGPL-3.0-only +name = "bellwire-api-dev" +main = "src/index.ts" +compatibility_date = "2026-07-20" +compatibility_flags = ["nodejs_compat"] +workers_dev = true +preview_urls = false + +[dev] +port = 8787 +local_protocol = "http" + +[vars] +APP_ENV = "development" +APNS_BUNDLE_ID = "app.bellwire.local" +APP_URL_SCHEME = "bellwire-local" +APP_DISPLAY_NAME = "Bellwire" +APNS_ENVIRONMENT = "sandbox" +ENTITLEMENT_ENFORCEMENT_MODE = "disabled" +LIVE_ACTIVITY_AUTOMATION_ENABLED = "false" + +[[d1_databases]] +binding = "DB" +database_name = "bellwire-local" +database_id = "00000000-0000-4000-8000-000000000001" +migrations_dir = "d1/business" diff --git a/wrangler.production.toml b/wrangler.production.toml index 37a9cfd..38da794 100644 --- a/wrangler.production.toml +++ b/wrangler.production.toml @@ -12,6 +12,7 @@ AUTH_ISSUER = "https://auth.bellwire.app" AUTH_AUDIENCE = "bellwire-api" APNS_BUNDLE_ID = "app.bellwire" APP_URL_SCHEME = "bellwire" +APP_DISPLAY_NAME = "Bellwire" APNS_ENVIRONMENT = "production" ENTITLEMENT_ENFORCEMENT_MODE = "enforce" LIVE_ACTIVITY_AUTOMATION_ENABLED = "false" diff --git a/wrangler.toml b/wrangler.toml index a487191..e87f791 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -12,6 +12,7 @@ AUTH_ISSUER = "https://auth.bellwire.app" AUTH_AUDIENCE = "bellwire-api" APNS_BUNDLE_ID = "app.bellwire" APP_URL_SCHEME = "bellwire" +APP_DISPLAY_NAME = "Bellwire" APNS_ENVIRONMENT = "production" ENTITLEMENT_ENFORCEMENT_MODE = "enforce" LIVE_ACTIVITY_AUTOMATION_ENABLED = "false"