Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,14 @@ SMTP_ADDR=localhost:51025
EMAIL_FROM=Substratal <no-reply@mail.substratalapps.com>

# Webhooks: allow http://localhost receivers locally (never in production).
# Also lets live push delivery reach a Web Push fake on http://localhost.
WEBHOOKS_ALLOW_PRIVATE=true

# Push notifications: "log" records every push in the devserver's log and
# sends nothing; "live" sends to APNs, FCM, and Web Push with the
# credentials each Application configures (production's default).
PUSH_BACKEND=log

# Stripe test mode (optional; billing endpoints return 503 without it).
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ dist/
infra/terraform/**/.terraform/
infra/terraform/**/*.tfstate*
infra/terraform/**/.terraform.lock.hcl.bak
.claude/settings.local.json
213 changes: 0 additions & 213 deletions ORIGINAL-SPEC-DRAFT.md

This file was deleted.

106 changes: 0 additions & 106 deletions PLAN.md

This file was deleted.

27 changes: 15 additions & 12 deletions README.md

Large diffs are not rendered by default.

22 changes: 18 additions & 4 deletions cmd/devserver/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import (
"net/http"
"os"
"os/signal"
"sync/atomic"
"syscall"
"time"

"github.com/CompositeCode/substratalapps.com/internal/billing"
"github.com/CompositeCode/substratalapps.com/internal/core"
"github.com/CompositeCode/substratalapps.com/internal/jobs"
"github.com/CompositeCode/substratalapps.com/internal/mcp"
"github.com/CompositeCode/substratalapps.com/internal/platform/wire"
Expand All @@ -28,9 +30,21 @@ func main() {
if err != nil {
log.Fatal(err)
}
notifier := webhooks.NewLocal(ctx, base.Deliverer(), 2*time.Second)
api := base.Server(notifier)
apiHandler := api.Handler()
// One in-process worker for push and webhook delivery; pushes first,
// since their outcomes can emit webhook events.
var api atomic.Pointer[core.Server]
deliverer := base.Deliverer()
notifier := webhooks.NewLocalRunner(ctx, base.Log, 2*time.Second, func(ctx context.Context) error {
if s := api.Load(); s != nil {
if err := s.RunPush(ctx); err != nil {
return err
}
}
return deliverer.Run(ctx)
})
server := base.Server(notifier)
api.Store(server)
apiHandler := server.Handler()

mux := http.NewServeMux()
mux.Handle("/", apiHandler)
Expand All @@ -39,7 +53,7 @@ func main() {
if base.Config.StripeSecretKey != "" && base.Config.StripeWebhookSecret != "" {
stripe := billing.New(base.Config.StripeSecretKey, base.Config.StripePrices, base.Log)
mux.Handle("/internal/stripe/webhook", &billing.Webhook{Secret: base.Config.StripeWebhookSecret,
Stripe: stripe, Core: api, Log: base.Log})
Stripe: stripe, Core: server, Log: base.Log})
}

// The 5-minute entitlement sweep, in-process.
Expand Down
2 changes: 1 addition & 1 deletion cmd/mcp/main.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Command mcp is the MCP server Lambda at /mcp. It holds no data-plane
// permissions at all: every tool call is an HTTPS call to the API's own
// /v1 routes on the same API Gateway, with the caller's credential
// forwarded unchanged (DEPLOYMENT.md β†’ MCP server).
// forwarded unchanged (infra/README.md β†’ MCP server).
package main

import (
Expand Down
2 changes: 1 addition & 1 deletion cmd/stripe-webhook/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Command stripe-webhook is the Lambda at /internal/stripe/webhook: the one
// route whose caller isn't a Bearer holder at all, verified instead by
// Stripe's own signature (DEPLOYMENT.md β†’ Stripe Billing).
// Stripe's own signature (infra/README.md β†’ Stripe Billing).
package main

import (
Expand Down
10 changes: 8 additions & 2 deletions cmd/webhook-worker/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Command webhook-worker is the SQS-triggered Lambda that delivers
// outbound webhooks. A message only nudges it; it always drains the
// outbox and every due retry, so a lost or duplicate message is harmless.
// outbound webhooks and push notifications. A message only nudges it; it
// always drains both outboxes and every due retry, so a lost or duplicate
// message is harmless. Pushes go first, because their outcomes can emit
// webhook events (device.deactivated, notification.push_failed).
package main

import (
Expand All @@ -20,7 +22,11 @@ func main() {
log.Fatal(err)
}
d := base.Deliverer()
api := base.Server(nil)
lambda.Start(func(ctx context.Context, ev events.SQSEvent) error {
if err := api.RunPush(ctx); err != nil {
return err
}
return d.Run(ctx)
})
}
4 changes: 2 additions & 2 deletions docs/api-reference/api-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ The result: an agent integration is safer by default without being less capable

## Test vs. live

Uniqueness constraints include `test_mode` (e.g. `unique (email, test_mode) where deleted_at is null`), so a test signup can't collide with a live one. A test-mode credential sees only test-mode rows and a live credential only live rows, enforced by the same Row-Level Security mechanism as `tenant_id`. Test-mode rows older than 30 days are purged nightly.
Uniqueness constraints include `test_mode` (e.g. `unique (email, test_mode) where deleted_at is null`), so a test signup can't collide with a live one. A test-mode credential sees only test-mode rows and a live credential only live rows, enforced by the same Row-Level Security mechanism as `tenant_id`. Test-mode rows older than 30 days are purged nightly. Test-mode **Users** are also time-limited: each stops working at its `expires_at`, 24 hours after creation unless the caller chooses otherwise (up to 30 days). See [Auth β†’ Test-mode Users β†’ Lifetime](../auth/#lifetime).

`mode: "test"` produces a `satk_test_…` secret instead of `satk_live_…` β€” same permissions and scope, but every resource it creates (Users, Entitlements, anything) is tagged `test_mode: true`:

Expand All @@ -161,7 +161,7 @@ This is how an Application's developer integration-tests against the real API wi
### Walkthrough: a test run end to end

1. **Create a test key** (as the Application's owner): `POST /v1/api-keys` with `"scope": "app_timetrack"`, `"mode": "test"`, `"permissions": ["entitlements.manage", "users.list"]`. The response's `secret` starts `satk_test_`. Applications are shared catalog rows, visible to test and live credentials alike (an Application's own `test_mode` flag only marks one created for testing, which is what allows `localhost` redirect URIs). Everything the test key *writes* is tagged `test_mode: true`.
2. **Create a test User** with that key: `POST /v1/users` `{"email": "qa+1@example.com", "status": "active", "send_invitation": false}`. The response has `"test_mode": true`. A live User with the same email can exist alongside it without conflict.
2. **Create a test User** with that key: `POST /v1/users` `{"email": "qa+1@example.com", "status": "active", "send_invitation": false}`. The response has `"test_mode": true` and an `expires_at` 24 hours away; add `"expires_at"` to the request for a shorter or longer run. A live User with the same email can exist alongside it without conflict.
3. **Grant access**: `POST /v1/users/{id}/entitlements` with an `Idempotency-Key` and `{"application_id": "app_timetrack", "source": "trial", "ends_at": "…"}`. The Entitlement is `test_mode: true`, and the `entitlement.granted`/`access.granted` webhooks go only to subscriptions created with a test key.
4. **Sign in as that User**: set a password through `POST /v1/auth/password/forgot` `{"email": "qa+1@example.com", "test_mode": true}`, then `POST /v1/auth/login` with `"test_mode": true` (see [Auth β†’ Test-mode Users](../auth/#test-mode-users)). The access token and every app token carry `"test_mode": true`.
5. **Check isolation**: `GET /v1/users?email=qa+1@example.com` with a *live* key returns an empty list. The test User doesn't exist as far as live credentials are concerned.
Expand Down
17 changes: 16 additions & 1 deletion docs/api-reference/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ The platform access token's claims:
- Sends a verification email (token TTL 24 hours). An unverified User **can** log in; `email_verified` is carried in every token so an Application can decide for itself whether to gate anything on it.
- `application_id` is optional and **grants nothing** β€” it records which Application the signup came from (`signup_application_id` on the User, used to brand the verification email with that app's `email_from_name`) and is otherwise informational. Access to an Application still requires an [Entitlement](../entitlements/), which the developer's own backend grants (see [Workflows β†’ Purchase β†’ access](../../workflows/#purchase--access)).
- If the email belongs to an `invited` User, signup is **rejected** with `409 email_taken` (`details.reason: "invitation_pending"`), and a fresh invitation email is sent to that address (this counts against the same 3-per-email-per-hour limit as `email/verify/resend`). An invitation can only be completed with its `inv_` token, through [`invitations/accept`](#post-v1authinvitationsaccept), because whatever was already attached to the invited account (Entitlements, Roles, Organization memberships) must only go to someone who controls the mailbox. Completing it by signup would hand all of that to anyone who knew the address.
- `test_mode` (optional, default `false`): `true` creates a test-mode User instead. See [Test-mode Users](#test-mode-users).
- `test_mode` (optional, default `false`): `true` creates a test-mode User instead. A test-mode signup may also send `expires_at` to choose how long the account lasts (default 24 hours). See [Test-mode Users](#test-mode-users).
- **Password policy:** 12–128 characters; rejected if it appears in the bundled breached-password list (top 100,000) or equals the email address. No composition rules. Stored as Argon2id (`m=19456 KiB, t=2, p=1`).

| Error | Status | When |
Expand Down Expand Up @@ -172,6 +172,7 @@ Clients must branch on `mfa_required` before reading `access_token`. Add `"test_
|---|---|---|
| `invalid_credentials` | 401 | Unknown email, wrong password, an `invited` User with no password yet, or an SSO-only User (no `password` identity). Deliberately one code for all four β€” no account enumeration. |
| `account_suspended` | 403 | Correct credentials, but `status: suspended`. |
| `test_user_expired` | 403 | Correct credentials, but the test-mode User's `expires_at` has passed. See [Test-mode Users β†’ Lifetime](#lifetime). |
| `too_many_attempts` | 429 | 5 failed attempts against this account's `password` identity in 15 minutes locks that identity for 15 minutes; `Retry-After` is set. Successful login resets the counter. (The counter lives on the [UserIdentity](../../domain-model/users-and-organizations/#useridentity), so a lockout never blocks the same User's SSO login.) |

Updates `last_login_at` on the User and `last_used_at` on the matching UserIdentity, when the session is actually issued (for an MFA login, at `mfa/verify`).
Expand All @@ -188,6 +189,18 @@ A User created by a test-mode API Key (or by signup with `"test_mode": true`) is

Endpoints that take a token (`mfa/verify`, `token/refresh`, `password/reset`, `email/verify`, `invitations/accept`, `oauth/token`) don't need it, because the token already belongs to exactly one account. Every token issued to a test-mode User carries `"test_mode": true`, and the API confines that token to test-mode rows exactly as it does a `satk_test_` key. Applications themselves are shared catalog rows, visible in both modes, so a test-mode User gets app tokens for the same Applications a live one does, as long as they hold a (test-mode) Entitlement to it.

### Lifetime

Every test-mode User is **time-limited**. Its `expires_at` is set when it's created, and at that moment the account stops working. Live Users never expire (`expires_at` is always `null`).

- **Default: 24 hours** after creation. A caller creating a test-mode User, through [`POST /v1/users`](../users/#post-v1users) with a `satk_test_` key or through signup with `"test_mode": true`, may send `expires_at` to choose a different lifetime, to suit what's being tested: a short-lived account for one CI run, or one that lasts a week for a manual QA pass.
- **Range:** at least 15 minutes from now, and no later than **30 days after the User's `created_at`**, which is when its test data is purged. Anything outside that range is `422 validation_failed` (`details.reason: "expires_at_out_of_range"`). Sending `expires_at` for a live User is `422 validation_failed` (`details.reason: "live_user"`).
- **Changing it:** `users.manage` can move `expires_at` with [`PATCH /v1/users/{id}`](../users/#patch-v1usersid), within the same range, to extend a run, end it early, or bring back an expired User that hasn't been purged yet.
- **At expiry:** login returns `403 test_user_expired`, and every token issued to the User stops working, whatever its own `exp`. That covers access, refresh, and app tokens. No token is issued with an `exp` later than the User's `expires_at`, so an Application that verifies app tokens locally against JWKS stops accepting them at the same moment. Within 5 minutes, the expiry sweep revokes the User's sessions (`revoked_reason: test_user_expired`), deactivates their push devices, fires `access.revoked` (reason `test_user_expired`) for every Application they had active access to, and writes the `user.expired` Audit Event. Like suspension, expiry leaves `status` and the User's Entitlements, Roles, and memberships unchanged, so an extension restores exactly what was there and fires `access.granted` again.
- **Purge:** the nightly purge of test-mode rows older than 30 days is unchanged. The 30-day ceiling on `expires_at` means a test User has always expired before its data is purged.

Test-mode **API Keys**, Organizations, and Applications don't expire. Only the accounts people or test suites sign in as do.

## `POST /v1/auth/mfa/verify`

```json
Expand Down Expand Up @@ -256,6 +269,7 @@ Both paths produce the identical app token. Either way, issuance **fails** if th
| `application_not_available` | 409 | The Application's `review_status` is `suspended`/`rejected`/`pending_review` β€” see [Applications β†’ review lifecycle](../../domain-model/applications/#the-review-lifecycle). A state conflict, not a permission failure, so it's the same `409` wherever this code appears. |
| `tenant_suspended` | 403 | The Application's Tenant is `suspended` by the platform. See [Tenancy](../../domain-model/tenancy/#fields). |
| `account_suspended` | 403 | The User is suspended. |
| `test_user_expired` | 403 | The test-mode User's `expires_at` has passed. See [Test-mode Users β†’ Lifetime](#lifetime). |

### The app token

Expand Down Expand Up @@ -574,6 +588,7 @@ GET https://api.substratalapps.com/.well-known/jwks.json
|---|---|---|
| `invalid_credentials` | 401 | Login or password change with a wrong password/unknown email. |
| `account_suspended` | 403 | The User is suspended. |
| `test_user_expired` | 403 | A test-mode User whose `expires_at` has passed tried to sign in, refresh, or get an app token. |
| `too_many_attempts` | 429 | Account lockout after repeated failures. |
| `email_taken` | 409 | Signup with an email already in use. |
| `weak_password` | 422 | Password fails the policy. |
Expand Down
2 changes: 1 addition & 1 deletion docs/api-reference/billing.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ The Free/Standard subscription: a User subscribing to Standard for themselves, o
- **Access follows the subscription.** While a subscription covers someone, the platform keeps a `source: subscription` Entitlement for them to each standard Application. When it stops covering them, those Entitlements end. Applications hear about every change through the usual [`access.granted` / `access.revoked`](../webhooks/#event-types) webhooks.
- **Business and Enterprise** plans aren't offered yet. Checkout for either returns `422 plan_requires_sales`.

Stripe tells this API about every change through its webhook. This API never takes card data, and a subscription's `status` only changes in response to Stripe events. The handler is documented in [root `DEPLOYMENT.md` β†’ Stripe Billing](https://github.com/CompositeCode/substratalapps.com/blob/main/DEPLOYMENT.md#stripe-billing).
Stripe tells this API about every change through its webhook. This API never takes card data, and a subscription's `status` only changes in response to Stripe events. The handler is documented in [`infra/README.md` β†’ Stripe Billing](https://github.com/CompositeCode/substratalapps.com/blob/main/infra/README.md#stripe-billing).

## Endpoints

Expand Down
2 changes: 1 addition & 1 deletion docs/api-reference/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Authorization: Bearer <token>

User-facing requests carry a user access token (issued at login, see [Auth](../auth/)). Service-to-service requests (billing, an app's backend) carry a scoped [API Key](../api-keys/). Both go in the same header β€” the token type is distinguishable server-side by prefix, not by a different header name.

**Test vs. live:** every API Key is created with `satk_test_…` or `satk_live_…` (see [API Keys](../api-keys/)) β€” there is no separate sandbox deployment to point at. A `test` key operates against the same database, but every record it creates is tagged `test_mode: true`. Test and live rows are invisible to each other: a test credential reads and writes only test rows, a live credential only live rows, and no query parameter crosses that line. Test-mode events only reach test-mode webhook subscriptions, and test traffic is excluded from analytics counters. A User created in test mode signs in with `"test_mode": true` on the auth endpoints (see [Auth β†’ Test-mode Users](../auth/#test-mode-users)), and every token issued to them carries `test_mode: true`, which confines it to test rows the same way. This is cheaper to build and run than a parallel environment, and it's the right call at the current scale (see [Deployment Architecture](https://github.com/CompositeCode/substratalapps.com/blob/main/DEPLOYMENT.md)) β€” a true isolated sandbox is a Scale-out-trigger-shaped decision, not a day-one one.
**Test vs. live:** every API Key is created with `satk_test_…` or `satk_live_…` (see [API Keys](../api-keys/)) β€” there is no separate sandbox deployment to point at. A `test` key operates against the same database, but every record it creates is tagged `test_mode: true`. Test and live rows are invisible to each other: a test credential reads and writes only test rows, a live credential only live rows, and no query parameter crosses that line. Test-mode events only reach test-mode webhook subscriptions, and test traffic is excluded from analytics counters. A User created in test mode signs in with `"test_mode": true` on the auth endpoints (see [Auth β†’ Test-mode Users](../auth/#test-mode-users)), and every token issued to them carries `test_mode: true`, which confines it to test rows the same way. This is cheaper to build and run than a parallel environment, and it's the right call at the current scale (see [Deployment Architecture](https://github.com/CompositeCode/substratalapps.com/blob/main/infra/README.md)) β€” a true isolated sandbox is a Scale-out-trigger-shaped decision, not a day-one one.

## IDs

Expand Down
Loading
Loading