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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ This repository is split deliberately, by audience:

Root-level documents:

- **[plan-remaining.md](plan-remaining.md)** — everything specified but not built yet: test-mode User expiry, the first-party web pages, Phase 3, dedicated tenancy infrastructure, scale-out triggers, and the rules every change follows.
- **[plan-remaining.md](plan-remaining.md)** — everything specified but not built yet: the first-party web pages, Phase 3, dedicated tenancy infrastructure, scale-out triggers, and the rules every change follows.
- **[`[bespoke]` issues](https://github.com/CompositeCode/substratalapps.com/issues?q=is%3Aissue+%22%5Bbespoke%5D%22+sort%3Acreated-asc)** — the one-time setup still to do by hand to go live, as numbered GitHub issues in the order to do them (#18–#28): the Lambda quota, redeploy, the superadmin password, alert emails, SES production access, Stripe, push credentials, registering Applications, the plan job, legal, and the optional domain transfer.
- **[infra/README.md](infra/README.md)** — the deployment architecture as built: AWS infrastructure (cost-capped, Aurora Serverless v2, Lambda, no VPC/NAT at Tier 0), the account setup, Stripe Billing, email, webhook and push delivery, audit archival, and local development.
- **[LICENSE](LICENSE)** — MIT.
Expand Down
2 changes: 1 addition & 1 deletion docs/api-reference/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ Endpoints that take a token (`mfa/verify`, `token/refresh`, `password/reset`, `e
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 /users`](../users/#post-users) 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"`).
- **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`, with the `expires_at` field's code `expires_at_out_of_range` in `details.fields`. Sending `expires_at` for a live User is `422 validation_failed` with the code `live_user`.
- **Changing it:** `users.manage` can move `expires_at` with [`PATCH /users/{id}`](../users/#patch-usersid), 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.
Expand Down
17 changes: 16 additions & 1 deletion docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@ What's changed in this specification over time. Every decision is written into t

---

## 2026-10-22 — Test-mode User expiry implemented

[Test-mode Users → Lifetime](../api-reference/auth/#lifetime) is built, as specified on 2026-10-20. `openapi.yaml` is now at `1.0.0-draft.6`:

- `User.expires_at`, and `expires_at` on `POST /auth/signup`, `POST /users`, and `PATCH /users/{id}`.
- `user.expired` in the webhook event and audit action enums, `test_user_expired` and `test_user_extended` in the access-event reasons, and `test_user_expired` in the device inactive reasons.
- `POST /auth/token/refresh` and `POST /auth/mfa/verify` now declare `403`, which they return for `test_user_expired` (and, for `mfa/verify`, `account_suspended`, which it already returned).

A few behaviors the spec left implicit are now stated:

- An out-of-range or live-User `expires_at` is reported like any other field error: `422 validation_failed` with the code in `details.fields`, not a separate `details.reason`.
- A Platform access token whose test-mode User has expired gets `401 session_revoked`, the same as a suspended User's. Sign-in, refresh, MFA verification, and app-token issuance return `403 test_user_expired`.
- Extending a User the expiry sweep hasn't processed yet fires no `access.granted`, since no `access.revoked` went out for them either.
- Migration `0009_test_user_expiry` sets every existing test-mode User's `expires_at` to `created_at` + 30 days, when the purge would have removed them anyway.

## 2026-10-22 — Version header declared in `openapi.yaml`; 405 for a wrong method

- **`openapi.yaml` declares `Substratal-Version`.** Every versioned operation now carries the shared `ApiVersion` header parameter (`Substratal-Version`, enum `1`) and documents its `400` (`api_version_required`, `unsupported_api_version`). Generated clients learn the header from the spec instead of needing it configured by hand. `GET /health` and `POST /auth/oauth/token` don't declare it, because they're exempt. Nothing changes on the wire: the API already required the header ([Conventions → Versioning](../api-reference/conventions/#versioning)).
Expand Down Expand Up @@ -59,7 +74,7 @@ Test mode already never counted toward billing (decision #36, confirmed on 2026-
- **At expiry** sign-in, refresh, and app-token issuance return the new `403 test_user_expired`, and every token already issued stops working. No token's `exp` is ever later than the User's `expires_at`. The 5-minute entitlement-expiry sweep then revokes sessions and devices (reason `test_user_expired`), fires `access.revoked` (reason `test_user_expired`) and the new `user.expired` webhook, and writes the `user.expired` Audit Event. `status`, Entitlements, Roles, and memberships are unchanged. An expired test User isn't a covered member of a test-mode subscription.
- Test-mode API Keys, Organizations, and Applications don't expire. The nightly 30-day purge is unchanged.

Not yet built: until it is, test-mode Users last until the 30-day purge, as before. `openapi.yaml` gains `expires_at` in the same change as the code. The build steps are in the repository's `plan-remaining.md` → Test-mode User expiry.
Built on 2026-10-21; see the entry above.

## 2026-10-18 — Push notifications implemented

Expand Down
14 changes: 12 additions & 2 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: Substratal Apps — Platform API
version: "1.0.0-draft.5"
version: "1.0.0-draft.6"
summary: >-
User accounts, auth, roles, per-app entitlements (the on/off access
switch), profiles, settings, organizations, tenancy, subscription billing,
Expand Down Expand Up @@ -105,6 +105,7 @@ paths:
display_name: { type: string, minLength: 1, maxLength: 100 }
application_id: { type: string, description: "Informational: records which app the signup came from. Grants nothing." }
test_mode: { type: boolean, default: false, description: "Create a test-mode User." }
expires_at: { type: string, format: date-time, description: "Test mode only: when the account stops working. Default 24 hours from now; at least 15 minutes away, at most 30 days. 422 expires_at_out_of_range, or live_user when test_mode is false." }
responses:
"400": { $ref: "#/components/responses/BadRequest" }
"201":
Expand Down Expand Up @@ -178,6 +179,7 @@ paths:
application/json:
schema: { $ref: "#/components/schemas/AuthSession" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"429": { $ref: "#/components/responses/TooManyRequests" }

/auth/token/refresh:
Expand Down Expand Up @@ -206,6 +208,7 @@ paths:
application/json:
schema: { $ref: "#/components/schemas/AuthSession" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }

/auth/logout:
post:
Expand Down Expand Up @@ -701,6 +704,7 @@ paths:
display_name: { type: string, maxLength: 100 }
status: { type: string, enum: [invited, active], default: invited }
send_invitation: { type: boolean, default: true }
expires_at: { type: string, format: date-time, description: "Test-mode keys only: when the User stops working. Default 24 hours from now; at least 15 minutes away, at most 30 days. 422 expires_at_out_of_range, or live_user with a live key." }
responses:
"400": { $ref: "#/components/responses/BadRequest" }
"201":
Expand Down Expand Up @@ -750,6 +754,7 @@ paths:
properties:
email: { type: string, format: email }
status: { type: string, enum: [active, suspended], description: "users.manage only. active on a deleted User restores it." }
expires_at: { type: string, format: date-time, description: "users.manage only, test-mode Users only. At least 15 minutes from now and at most 30 days after created_at. Moving it later on an expired User brings them back and fires access.granted (test_user_extended)." }
responses:
"400": { $ref: "#/components/responses/BadRequest" }
"200":
Expand Down Expand Up @@ -3229,6 +3234,7 @@ components:
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
last_login_at: { type: [string, "null"], format: date-time }
expires_at: { type: [string, "null"], format: date-time, description: "When a test-mode User stops working (Auth → Test-mode Users → Lifetime). Always null for live Users." }
plan: { $ref: "#/components/schemas/Plan" }
plan_sources: { type: array, items: { $ref: "#/components/schemas/PlanSource" }, description: "What gives this User Standard: their own subscription, an Organization's, or both. Empty on Free." }

Expand Down Expand Up @@ -3775,6 +3781,7 @@ components:
- user.suspended
- user.reactivated
- user.deleted
- user.expired
- user.erasure_requested
- user.erasure_cancelled
- user.erased
Expand Down Expand Up @@ -3889,6 +3896,7 @@ components:
- user.suspended
- user.reactivated
- user.deleted
- user.expired
- organization.member_added
- organization.member_removed
- application.review_status_changed
Expand All @@ -3915,6 +3923,8 @@ components:
- user_suspended
- user_reactivated
- user_deleted
- test_user_expired
- test_user_extended

WebhookEventEnvelope:
type: object
Expand Down Expand Up @@ -4035,7 +4045,7 @@ components:

DeviceInactiveReason:
type: string
enum: [unregistered, session_revoked, user_suspended, user_deleted, stale, limit, credential_removed]
enum: [unregistered, session_revoked, user_suspended, user_deleted, test_user_expired, stale, limit, credential_removed]

WebPushSubscription:
type: object
Expand Down
41 changes: 34 additions & 7 deletions internal/api/gen/gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading