plans-3a-creation-caps
Squashed from plans-3a-creation-caps into main.
25 files changed,
+1277
−1
docs/superpowers/plans/2026-10-22-plans-3a-creation-caps.md+95 −0docs/superpowers/specs/CODEX-FINDINGS-PLANS-3A-CODE.md+22 −0docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-3A-CODE.md+33 −0internal/console/accountplans.go+26 −0internal/console/apicli.go+14 −0internal/console/apidomains.go+6 −0internal/console/apiinstances.go+12 −0internal/console/apisidecars.go+11 −0internal/console/apitemporary.go+11 −0internal/console/authcli.go+11 −0internal/console/claim_restore.go+7 −0internal/console/domainattach.go+13 −0internal/console/domaincatchall.go+4 −0internal/console/fleets.go+10 −0internal/console/ownership.go+4 −0internal/console/plancaps.go+290 −0internal/console/plancaps_copy.go+18 −0internal/console/plancaps_doors_test.go+374 −0internal/console/plancaps_test.go+225 −0internal/console/restore.go+7 −0internal/console/server.go+25 −0internal/provision/plan14_root_reads_test.go+13 −0internal/provision/plan9_b8_census_test.go+1 −1internal/provision/plans3a_census_test.go+36 −0internal/provision/testdata/plan14-root-reads-plans-3a-creation-caps.tsv+9 −0
CI passed — run details
CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (1 s)
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
=== vet (19 s)
=== gofmt (1 s)
gofmt -l .: clean
=== rest (172 s)
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.589s
ok github.com/carlosframework/platform/cmd/moveprobe 11.836s
ok github.com/carlosframework/platform/cmd/zddprobe 0.775s
ok github.com/carlosframework/platform/internal/activator 60.315s
ok github.com/carlosframework/platform/internal/agentsock 0.093s
ok github.com/carlosframework/platform/internal/alerting 0.017s
ok github.com/carlosframework/platform/internal/analytics 0.041s
ok github.com/carlosframework/platform/internal/appconfig 2.906s
ok github.com/carlosframework/platform/internal/apperr 0.230s
ok github.com/carlosframework/platform/internal/applog 0.270s
ok github.com/carlosframework/platform/internal/awsinv 0.010s
ok github.com/carlosframework/platform/internal/boxchoice 0.016s
ok github.com/carlosframework/platform/internal/boxid 0.652s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.019s
ok github.com/carlosframework/platform/internal/caps 0.022s
ok github.com/carlosframework/platform/internal/certwarm 0.010s
ok github.com/carlosframework/platform/internal/cgroup 0.750s
ok github.com/carlosframework/platform/internal/changelog 0.576s
ok github.com/carlosframework/platform/internal/claimactivation 1.234s
ok github.com/carlosframework/platform/internal/clamd 0.069s
? github.com/carlosframework/platform/internal/cliflag [no test files]
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 8.495s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.022s
ok github.com/carlosframework/platform/internal/dbquery 0.014s
ok github.com/carlosframework/platform/internal/dbreads 2.223s
? github.com/carlosframework/platform/internal/dbreads/dbreadstest [no test files]
ok github.com/carlosframework/platform/internal/dbverify 0.016s
ok github.com/carlosframework/platform/internal/dnsrec 0.014s
ok github.com/carlosframework/platform/internal/docsite 0.031s
ok github.com/carlosframework/platform/internal/domains 2.847s
ok github.com/carlosframework/platform/internal/doorkey 0.014s
ok github.com/carlosframework/platform/internal/economics 0.013s
ok github.com/carlosframework/platform/internal/edge 45.555s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.135s
ok github.com/carlosframework/platform/internal/gate 1.659s
ok github.com/carlosframework/platform/internal/genselect 0.015s
ok github.com/carlosframework/platform/internal/geo 0.015s
ok github.com/carlosframework/platform/internal/geoip 0.033s
ok github.com/carlosframework/platform/internal/historyreadback 0.158s
ok github.com/carlosframework/platform/internal/host 141.494s
ok github.com/carlosframework/platform/internal/hostactors 5.796s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.017s
ok github.com/carlosframework/platform/internal/hostbox 3.021s
ok github.com/carlosframework/platform/internal/hostclaim 0.825s
ok github.com/carlosframework/platform/internal/hostenv 0.017s
ok github.com/carlosframework/platform/internal/hostevidence 0.017s
ok github.com/carlosframework/platform/internal/hostexec 5.390s
ok github.com/carlosframework/platform/internal/hostfence 0.047s
ok github.com/carlosframework/platform/internal/hostfiles 10.335s
ok github.com/carlosframework/platform/internal/hostfloor 0.020s
ok github.com/carlosframework/platform/internal/hostguard 1.040s
ok github.com/carlosframework/platform/internal/hostops 1.963s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.022s
ok github.com/carlosframework/platform/internal/hostquarantine 0.015s
ok github.com/carlosframework/platform/internal/hostrelocate 0.726s
ok github.com/carlosframework/platform/internal/hostresolve 0.198s
ok github.com/carlosframework/platform/internal/hostwrite 0.015s
ok github.com/carlosframework/platform/internal/infraview 0.017s
ok github.com/carlosframework/platform/internal/instancectl 5.259s
ok github.com/carlosframework/platform/internal/instanceexec 2.835s
ok github.com/carlosframework/platform/internal/instancefs 0.020s
ok github.com/carlosframework/platform/internal/instancekey 0.020s
ok github.com/carlosframework/platform/internal/instanceupdates 0.026s
ok github.com/carlosframework/platform/internal/layout 1.705s
ok github.com/carlosframework/platform/internal/lbhealth 0.019s
ok github.com/carlosframework/platform/internal/lease 10.173s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.543s
ok github.com/carlosframework/platform/internal/ledger 0.047s
ok github.com/carlosframework/platform/internal/mail 0.204s
ok github.com/carlosframework/platform/internal/maintenance 0.106s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.560s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.114s
ok github.com/carlosframework/platform/internal/metrics 0.008s
ok github.com/carlosframework/platform/internal/moves 22.175s
ok github.com/carlosframework/platform/internal/moves/archive 0.973s
ok github.com/carlosframework/platform/internal/objstore 0.303s
ok github.com/carlosframework/platform/internal/ops 0.232s
ok github.com/carlosframework/platform/internal/park 0.284s
ok github.com/carlosframework/platform/internal/plans 0.010s
ok github.com/carlosframework/platform/internal/privatefile 0.010s
ok github.com/carlosframework/platform/internal/provision 22.866s
ok github.com/carlosframework/platform/internal/publicrelease 0.012s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.546s
ok github.com/carlosframework/platform/internal/relay 4.237s
ok github.com/carlosframework/platform/internal/release 25.671s
ok github.com/carlosframework/platform/internal/rollout 3.846s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.009s
ok github.com/carlosframework/platform/internal/rootparticipation 0.058s
ok github.com/carlosframework/platform/internal/rootstate 0.027s
ok github.com/carlosframework/platform/internal/runs 0.556s
ok github.com/carlosframework/platform/internal/s3compat 0.006s
ok github.com/carlosframework/platform/internal/safefile 0.007s
ok github.com/carlosframework/platform/internal/schedule 2.673s
ok github.com/carlosframework/platform/internal/seeds 0.023s
ok github.com/carlosframework/platform/internal/sim/engines 2.743s
ok github.com/carlosframework/platform/internal/sim/hostlife 10.214s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.016s
ok github.com/carlosframework/platform/internal/status 0.073s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.324s
ok github.com/carlosframework/platform/internal/svcrole 0.006s
ok github.com/carlosframework/platform/internal/telemetry 0.005s
ok github.com/carlosframework/platform/internal/termfence 0.005s
ok github.com/carlosframework/platform/internal/tunnelclient 0.005s
ok github.com/carlosframework/platform/internal/unixdial 0.005s
ok github.com/carlosframework/platform/internal/updatewords 0.004s
ok github.com/carlosframework/platform/internal/usage 0.005s
ok github.com/carlosframework/platform/internal/version 0.001s
ok github.com/carlosframework/platform/internal/vet 1.656s
=== internal_console@0 (81 s)
ok github.com/carlosframework/platform/internal/console 79.633s
=== internal_console@1 (41 s)
ok github.com/carlosframework/platform/internal/console 40.169s
=== internal_console@2 (44 s)
ok github.com/carlosframework/platform/internal/console 42.710s
=== internal_console@3 (42 s)
ok github.com/carlosframework/platform/internal/console 41.116s
=== internal_console@4 (46 s)
ok github.com/carlosframework/platform/internal/console 44.292s
=== internal_console@5 (42 s)
ok github.com/carlosframework/platform/internal/console 40.283s
=== internal_console@6 (40 s)
ok github.com/carlosframework/platform/internal/console 38.214s
=== internal_console@7 (36 s)
ok github.com/carlosframework/platform/internal/console 34.228s
=== root@0 (110 s)
ok github.com/carlosframework/platform 108.900s
=== root@1 (89 s)
ok github.com/carlosframework/platform 87.429s
=== root@2 (81 s)
ok github.com/carlosframework/platform 79.351s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 50462882 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.023s
=== RUN TestT4StorageProbeMinio
2026/10/22 23:14:49 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E0FD0E61978632, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.00s)
PASS
ok github.com/carlosframework/platform/internal/host 0.022s
=== RUN TestS3StoreMinioContract
=== RUN TestS3StoreMinioContract/conditional_get_and_etag_listing
=== RUN TestS3StoreMinioContract/bump_through_every_mutation_path
--- PASS: TestS3StoreMinioContract (0.02s)
--- PASS: TestS3StoreMinioContract/conditional_get_and_etag_listing (0.01s)
--- PASS: TestS3StoreMinioContract/bump_through_every_mutation_path (0.01s)
PASS
ok github.com/carlosframework/platform/internal/release 0.029s
Bucket created successfully `local/dbf-deploy`.
Bucket created successfully `local/dbf-data-ie`.
Bucket created successfully `local/dbf-data-uk`.
local/dbf-deploy versioning is enabled
Created policy `dbf-console` successfully.
Created policy `dbf-ukbox` successfully.
Added user `dbfconsole` successfully.
Added user `dbfukbox` successfully.
Attached Policies: [dbf-console]
To User: dbfconsole
Attached Policies: [dbf-ukbox]
To User: dbfukbox
=== RUN TestDBForwardMinio
2026/10/22 23:14:52 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/22 23:14:52 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/22 23:14:52 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/22 23:14:52 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/22 23:14:52 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/22 23:14:52 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/22 23:14:52 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000003
2026/10/22 23:14:52 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000003 maxTXID=0000000000000003 size=454
2026/10/22 23:14:52 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000003 txid.db=0000000000000003
2026/10/22 23:14:52 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/22 23:14:52 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/22 23:14:52 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/22 23:14:52 console: CARLOS_SECRETS_PUBKEY is unset, so no region outside the home can be chosen automatically
2026/10/22 23:14:52 console: could not notify member@example.com of a CLI token issue: keymail: lookup member@example.com: keymail: GET /api/federation/lookup?addr=member%40example.com&from=console%40127.0.0.1%3A43977: could not determine target mailbox
agent.go:123: dbreads: dbr_ru7vyfkqhqydhjyusreexqvd7y: planned 3 files (1544 bytes) and restored to 3 in 9ms
agent.go:123: dbreads: dbr_ru7vyfkqhqydhjyusreexqvd7y: output 5 bytes in 1 confirmed chunks in 1ms
2026/10/22 23:14:53 console: db read dbr_ru7vyfkqhqydhjyusreexqvd7y: relayed 1 chunks (102 bytes) in 1.004s, ended done
agent.go:123: dbreads: dbr_df3r2bsa4be454vrkxtrecioyu: planned 1 files (720 bytes) and restored to 1 in 8ms
agent.go:123: dbreads: dbr_df3r2bsa4be454vrkxtrecioyu: output 5 bytes in 1 confirmed chunks in 1ms
2026/10/22 23:14:54 console: db read dbr_df3r2bsa4be454vrkxtrecioyu: relayed 1 chunks (102 bytes) in 1.004s, ended done
=== RUN TestDBForwardMinio/by_disconnect
=== NAME TestDBForwardMinio
agent.go:123: dbreads: dbr_r5zb77dvs3b6sxakrik5vwaaa4: planned 3 files (1544 bytes) and restored to 3 in 9ms
2026/10/22 23:14:54 console: db read dbr_r5zb77dvs3b6sxakrik5vwaaa4: relayed 0 chunks (0 bytes) in 18ms, ended client gone
agent.go:123: dbreads: dbr_r5zb77dvs3b6sxakrik5vwaaa4: output 5 bytes in 1 confirmed chunks in 9ms
=== RUN TestDBForwardMinio/by_timeout
=== NAME TestDBForwardMinio
agent.go:123: dbreads: dbr_h522yovgxhh44b4uqufirgapqq: planned 3 files (1544 bytes) and restored to 3 in 10ms
2026/10/22 23:15:14 console: db read dbr_h522yovgxhh44b4uqufirgapqq: relayed 0 chunks (0 bytes) in 19.628s, ended no-result
agent.go:123: dbreads: dbr_h522yovgxhh44b4uqufirgapqq: output 5 bytes in 1 confirmed chunks in 19.616s
--- PASS: TestDBForwardMinio (21.95s)
--- PASS: TestDBForwardMinio/by_disconnect (0.13s)
--- PASS: TestDBForwardMinio/by_timeout (19.74s)
PASS
ok github.com/carlosframework/platform/internal/console 21.972s
minio contract: PASS
diff --git a/docs/superpowers/plans/2026-10-22-plans-3a-creation-caps.md b/docs/superpowers/plans/2026-10-22-plans-3a-creation-caps.md| new file mode 100644 |
| index 000000000..b10cae7d6 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-10-22-plans-3a-creation-caps.md |
| @@ -0,0 +1,95 @@ |
| +# Plans 3a: Creation Caps Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** When plans are on, the console refuses to create what an account's plan does not allow: more running things (apps and instances) than the cap, more workers per app than the cap, a second free account for the same person, fleets and custom domains on plans without them. |
| + |
| +**Architecture:** One file, `internal/console/plancaps.go`, holds the checks. Each takes the account's effective limits (Plan 1's `EffectiveLimits`; nil means plans are off and every check passes) and returns a `*planCapError` whose message is approved copy. Each capped door takes the account lock (`lockAccount`) around its count and its write so two concurrent creates cannot both pass. JSON doors answer 409 with `"code":"cap"`; browser doors flash the message, as they already do for their other refusals. |
| + |
| +**Tech Stack:** Go, `internal/console`, `internal/provision` (record listing). |
| + |
| +**Spec:** `docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md` §5.1 (running things, sidecars, creation has no grace), §8.1 (one free account per person, creation only), §8.4 (paid-only custom domains), table 4.2 (fleets). Builds on Plans 1 and 2 (merged f550afa, 0235046). |
| + |
| +## Global Constraints |
| + |
| +- **No catalog, no change.** Every check returns nil when `EffectiveLimits` is nil. Each task pins this with a test. |
| +- Creation refusals have no grace (spec §5.1): refuse at the cap, touch nothing that already runs. |
| +- **Running things** = live apps (including temporary apps and apps being restored: `deleted_at = 0 OR restoring = 1`) plus customer instance records of those live apps. Trashed apps and their instances do not count. |
| +- Lock order is account before app (`applocks.go:50`). A door takes `lockAccount` first, before any app lock. |
| +- Direct-mode creations never reach the console (`apps.go:546`) and are out of scope; adopting such an app through `ownerClaim` is capped like any create. |
| +- All refusal text is approved copy (copy review before the strings go into files). Browser copy is sentence case; CLI and JSON copy is lower case. No em dashes. |
| +- Tests: console tests run serially in practice; touched packages only, with `-run`; CI runs the full suite. New routes: none. Claim fixtures: none expected (no new doors or workers). The #280 root-read overlay is re-pinned in the last task. |
| + |
| +## Review Focus |
| + |
| +1. **Plans off.** With no catalog, every door behaves exactly as today, including under concurrency. Pinned per task (`...WithoutACatalog` tests). |
| +2. **Concurrent creates at the cap.** Two creates racing for the last slot: exactly one succeeds. Pinned in Task 2 (`TestRunningThingsCapIsExactUnderConcurrentCreates`). |
| +3. **Restore over the cap.** Restoring a trashed app when the account is at its cap is refused, with nothing changed. Pinned in Task 2 (`TestRestoreRefusedAtTheRunningThingsCap`). |
| +4. **A sponsored account above the catalog ceiling.** Its recorded limit, not the plan's, is the cap. Pinned in Task 1 (`TestRunningThingsCapUsesEffectiveLimits`). |
| +5. **Platform hostnames.** Attaching a platform hostname stays allowed on free plans; only custom domains are paid-only. Pinned in Task 5 (`TestPlatformHostnamesStayFreeOnFreePlans`). |
| + |
| +--- |
| + |
| +### Task 1: The checks |
| + |
| +**Files:** Create `internal/console/plancaps.go`, `internal/console/plancaps_test.go`, `internal/console/plancaps_copy.go` (generated from approved copy); Modify `internal/console/accountplans.go` (one store query). |
| + |
| +**Interfaces — produces:** |
| +```go |
| +type planCapError struct{ msg string } |
| +func (e *planCapError) Error() string |
| +func (s *Store) LiveAppCount(accountID int64) (int, error) // deleted_at = 0 OR restoring = 1 |
| +func (s *Server) runningThings(ctx context.Context, acct *Account) (int, error) // live apps + their instance records (key-only bucket list of apps/<sqid>/) |
| +func (s *Server) checkRunningThings(ctx context.Context, acct *Account, adding int) error |
| +func (s *Server) checkSidecars(ctx context.Context, acct *Account, app string) error |
| +func (s *Server) checkNewFreeAccount(addr string) error |
| +func (s *Server) checkFleets(acct *Account) error |
| +func (s *Server) checkCustomDomains(acct *Account) error |
| +``` |
| +Each returns nil when plans are off, `*planCapError` at the cap, or an ordinary error when counting failed (doors answer that as they answer other internal errors). |
| + |
| +- [ ] **Step 1: Failing tests** in `plancaps_test.go`: |
| + `TestPlanChecksPassWithoutACatalog` (every check nil with no catalog, even at absurd counts); `TestLiveAppCountCountsRestoringNotTrashed`; `TestRunningThingsCountsAppsAndInstances` (two apps, three instance records, one trashed app with an instance: 5); `TestRunningThingsCapAtAndBelow` (free cap 3: at 2 → nil, at 3 → planCapError); `TestRunningThingsCapUsesEffectiveLimits` (sponsored running_things 300 above the 200 ceiling: 250 → nil); `TestSidecarCap` (free: second sidecar refused; personal: sixth refused); `TestNewFreeAccountCap` (owner of one free account: refused; owner of one paid account: allowed; member, not owner, of a free account: allowed); `TestFleetsAndCustomDomainsFollowThePlan` (free refused, personal allowed). |
| +- [ ] **Step 2:** Run `go test ./internal/console/ -run 'PlanChecks|LiveAppCount|RunningThings|SidecarCap|NewFreeAccountCap|FleetsAndCustomDomains'` → FAIL. |
| +- [ ] **Step 3:** Implement. `runningThings` lists keys under `apps/<sqid>/` once and counts `provision.ParseRecordKey` hits whose app is in the live set from `AppsIn`. `checkNewFreeAccount` uses `AccountsFor(addr)`, owner role only, each account's plan from `AccountPlan` (nil → `DefaultPlan`), counting accounts whose plan is not `Paid`, refusing at `FreeAccountsPerPerson` (0 means no cap). |
| +- [ ] **Step 4:** Run → PASS. **Step 5:** Commit `console: plan creation checks`. |
| + |
| +### Task 2: App creation and restore |
| + |
| +**Files:** Modify `server.go` (`handleNewApp`), `apicli.go` (`handleAPICreateApp`, `handleAPIRestoreApp`), `apitemporary.go`, `restore.go`, `claim_restore.go`, `ownership.go` (`handleRestoreApp`); tests beside each (`apicli_test.go`, `restore_test.go`, `apitemporary_test.go`). |
| + |
| +- [ ] **Step 1: Failing tests:** `TestCreateAppRefusedAtTheRunningThingsCap` (CLI: 409 `"code":"cap"`, no row; browser: flash with the approved message, no row); `TestTemporaryAppCountsTowardRunningThings`; `TestRestoreRefusedAtTheRunningThingsCap` (both restore doors, both claim modes; nothing changed); `TestRunningThingsCapIsExactUnderConcurrentCreates` (cap 3, two live, 8 concurrent CLI creates: exactly one 201); `TestCreateAppWithoutACatalogUnchanged`. |
| +- [ ] **Step 2:** Run → FAIL. |
| +- [ ] **Step 3:** In `handleNewApp` and `handleAPICreateApp`, take `defer s.lockAccount(acct.Sqid)()` before `claimApp` and call `checkRunningThings(ctx, acct, 1)`; refuse with the door's existing shape. In `/new-temporary`, call it after `temporaryCapCheck` (the lock is already held). In `restoreApp` and `restoreClaimApp`, call it for every row, beside the temporary check, returning the `*planCapError`; both restore doors map it with `errors.As` (CLI 409 `"code":"cap"`; browser `fail`). |
| +- [ ] **Step 4:** Run → PASS. **Step 5:** Commit `console: the running-things cap on app creation and restore`. |
| + |
| +### Task 3: Instances, first-route attach and sidecars |
| + |
| +**Files:** Modify `apiinstances.go` (`handleAPIInstanceCreate`, before the seed branch at ~1580), `domainattach.go` (first-route record write ~237 and `attachClaimFirstRoute`), `apisidecars.go` (`handleAPISidecarCreate`); tests in `apiinstancescanary_test.go`, `apiinstanceseed_test.go`, `apidomains_firstroute_test.go`, `apisidecars_test.go`. |
| + |
| +- [ ] **Step 1: Failing tests:** `TestInstanceCreateRefusedAtTheRunningThingsCap` (plain, seeded and imported: 409 `"code":"cap"`, no record); `TestFirstRouteAttachRefusedAtTheRunningThingsCap`; `TestSidecarCreateRefusedAtTheSidecarCap`; `TestInstanceAndSidecarCreateWithoutACatalogUnchanged`. |
| +- [ ] **Step 2:** Run → FAIL. |
| +- [ ] **Step 3:** Each door takes `lockAccount` before its checks and calls the matching check; the instance door does so once, before the seed branch, so all three variants are covered. |
| +- [ ] **Step 4:** Run → PASS. **Step 5:** Commit `console: caps on instances, first routes and sidecars`. |
| + |
| +### Task 4: One free account per person |
| + |
| +**Files:** Modify `server.go` (`handleNewAccount`), `authcli.go` (`handleCLINewAccount`); tests in `server_account_test.go`, `authcli_test.go`. |
| + |
| +- [ ] **Step 1: Failing tests:** `TestNewAccountRefusedForASecondFreeAccount` (browser flash; CLI 409 `"code":"cap"`; no account minted, no bucket manifest); `TestFirstAccountIsNeverRefused` (auto-mint on first sign-in with a catalog configured); `TestNewAccountAllowedWhenTheOtherIsPaid`; `TestNewAccountWithoutACatalogUnchanged`. |
| +- [ ] **Step 2:** Run → FAIL. **Step 3:** Call `checkNewFreeAccount(addr)` in both doors before `mintAccount`. `currentAccount`'s auto-mint is untouched (spec §1: landing never refuses). **Step 4:** Run → PASS. **Step 5:** Commit `console: one free account per person`. |
| + |
| +### Task 5: Fleets and custom domains |
| + |
| +**Files:** Modify `fleets.go` (`handleAPIFleetNew`), `domainattach.go` (`attachDomainAs`, after the platform-hostname check and above `heldElsewhere`, per the file's ordering rule), `apidomains.go` (alias branch above `heldElsewhere` ~525; map `*planCapError` to 409 `"code":"cap"`), `appdomains.go` (`attachInstanceCatchall` and the refusal render); tests in `fleets_test.go`, `apidomains_test.go`, `appdomains_test.go`. |
| + |
| +- [ ] **Step 1: Failing tests:** `TestFleetCreateRefusedOnAPlanWithoutFleets`; `TestCustomDomainRefusedOnAFreePlan` (CLI static, CLI alias, browser alias, browser catch-all: refused, nothing claimed); `TestPlatformHostnamesStayFreeOnFreePlans`; `TestFleetsAndDomainsWithoutACatalogUnchanged`. |
| +- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement the gates. **Step 4:** Run → PASS. **Step 5:** Commit `console: fleets and custom domains follow the plan`. |
| + |
| +### Task 6: Census and gates |
| + |
| +- [ ] Re-pin the #280 root-read overlay for the changed console units (generator recipe from Plans 1–2), loaded last, `rev3=PROPOSED-plans-3a-creation-caps`; check the record-write census; run the full claim invariant once; merge main; push; CI green; ask for the ruling on #280. |
| + |
| +## Done when |
| + |
| +Every task's tests pass, Review Focus 1–5 are pinned, CI is green after merging current main, and Astra's whole-branch review has no open Critical or Important finding. |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-PLANS-3A-CODE.md b/docs/superpowers/specs/CODEX-FINDINGS-PLANS-3A-CODE.md| new file mode 100644 |
| index 000000000..19590b5e4 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-FINDINGS-PLANS-3A-CODE.md |
| @@ -0,0 +1,22 @@ |
| +1. **Important — Restore counts one slot but revives the app’s instances too.** [plancaps.go:100](internal/console/plancaps.go:100) |
| + With two running things and a limit of three, restoring an app with two retained instance records passes, then raises the count to five. `runningThings` correctly excludes those records while the app is trashed, but `checkRestore` adds only one. Both restore modes use this check. **Fix:** count the full restored footprint before changing anything; test this scenario in both claim modes. |
| + |
| +2. **Important — Concurrent requests can mint multiple free accounts.** [authcli.go:1778](internal/console/authcli.go:1778) |
| + Someone who owns only paid accounts can send two creation requests. Both can observe zero free accounts before either reaches `mintAccount`, so both succeed. The browser door has the same gap; the account allocator prevents duplicate IDs, not duplicate free allowances. **Fix:** serialize the check and mint by person across both doors. Coordinate auto-mint through the same mechanism while preserving its never-refuse behavior. |
| + |
| +3. **Important — Adopting an existing app undercounts its instances.** [apicli.go:455](internal/console/apicli.go:455) |
| + An owner may adopt an app that already has bucket objects but no console row. Its instance records are excluded from `runningThings` because the app is outside `liveSet`. The new check reserves only one slot; inserting the app row makes all its instances count immediately. The browser door behaves identically. **Fix:** include existing instance records in the prospective count when adopting an app, under the account lock. |
| + |
| +4. **Important — Pending claim creates do not retain cap reservations.** [plancaps.go:73](internal/console/plancaps.go:73) |
| + Counting includes only materialized instance keys. A claims-mode create can return `202` after an uncertain record write, releasing `planLock` while its claim remains pending. Another create can consume the same final slot before the original write becomes visible. `checkSidecars` has the equivalent omission. **Fix:** count durable pending reservations alongside records, deduplicating by identity, and release reservations only after definitive cancellation. Test a delayed record write followed by another create. |
| + |
| +5. **Minor — The sponsored-limit test does not exercise the catalog ceiling.** [plancaps_test.go:117](internal/console/plancaps_test.go:117) |
| + The test sets a sponsored limit of 300 but creates only four apps. It would still pass if enforcement incorrectly clamped that limit to 200. **Fix:** use the planned count of 250 and assert both admission below 300 and refusal beyond it. |
| + |
| +**Declined to judge** |
| + |
| +- Refusal wording and settled policy decisions, as instructed. |
| +- Direct-to-bucket enforcement: the implementation plan excludes it, although spec §5.1 requires coverage. This review does not certify that path as capped. |
| +- Runtime test results and production-scale listing cost. Targeted tests could not start because the read-only sandbox prevented Go from creating its build directory. Census coverage and hashes matched on static inspection. |
| + |
| +**Verdict:** Request changes. The creation caps have Important bypasses. HEAD remains `4681e4c3660cccd6fc0c3958a9691bc45dc82754`; the working tree is unchanged. |
| \ No newline at end of file |
diff --git a/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-3A-CODE.md b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-3A-CODE.md| new file mode 100644 |
| index 000000000..a748e5f70 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-3A-CODE.md |
| @@ -0,0 +1,33 @@ |
| +# Whole-branch code review brief: Plans 3a (creation caps) |
| + |
| +You are a senior Go reviewer on the carlos platform. Review branch |
| +`plans-3a-creation-caps`, range `13e2f75f5c931b6f9ab2b8dad8192ec1553a9461..HEAD` (`git diff 13e2f75f5c931b6f9ab2b8dad8192ec1553a9461..HEAD`; docs |
| +under docs/superpowers/ are requirements). Read-only: do not modify the |
| +working tree, index, HEAD or branches. You may run `go test` with |
| +GOFLAGS=-buildvcs=false. |
| + |
| +Requirements, in order: the spec docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md |
| +(§5.1, §8.1, §8.4, table 4.2; section 3 decisions are not to be argued with); |
| +the plan docs/superpowers/plans/2026-10-22-plans-3a-creation-caps.md; the |
| +executor's `Ruling:` lines in .superpowers/sdd/2026-10-22-plans-3a-creation-caps/progress.md. |
| + |
| +Grade by effect on a reasonable operator, self-hoster or customer. Check |
| +deliberately (the plan's Review Focus): plans off changes nothing (including |
| +concurrency); concurrent creates at the cap (exactly one wins); restore over |
| +the cap; a sponsored account above the catalog ceiling; platform hostnames |
| +stay free on free plans. |
| + |
| +Also look hard at: every path that creates a running thing (apps, temporary |
| +apps, restore in both claim modes, plain/seeded/imported instances, first-route |
| +domain attach) and whether any bypasses the cap; lock order (account before |
| +app) and deadlock risk where planLock is taken, including long-running seed |
| +creates; how runningThings counts (trashed apps, restoring apps, claims-mode |
| +pending creates, bucket list cost); the custom-domain gate on every attach |
| +path (static, alias, catch-all, browser and CLI) and what counts as a custom |
| +domain; one free account per person (auto-mint never refused; paid accounts |
| +not counted); fleets; error handling when counting fails; census rows. |
| + |
| +Do not review the wording of the refusals (approved copy in plancaps_copy.go). |
| + |
| +Output: findings most severe first (Critical / Important / Minor, file:line, |
| +failure scenario, fix); then "Declined to judge"; then a verdict. |
diff --git a/internal/console/accountplans.go b/internal/console/accountplans.go| index 2a2cac308..4917eb1b7 100644 |
| --- a/internal/console/accountplans.go |
| +++ b/internal/console/accountplans.go |
| @@ -156,3 +156,29 @@ func (s *Store) MarkPlanPublished(accountID, revision int64, catalogID string) e |
| WHERE account_id = ? AND revision = ?`, revision, catalogID, accountID, revision) |
| return err |
| } |
| + |
| +// LiveAppCount counts the account's running apps for the creation caps: |
| +// live rows and rows being restored (temporary apps included). |
| +func (s *Store) LiveAppCount(accountID int64) (int, error) { |
| + var n int |
| + err := s.db.QueryRow(`SELECT COUNT(*) FROM apps WHERE account_id = ? AND (deleted_at = 0 OR restoring = 1)`, accountID).Scan(&n) |
| + return n, err |
| +} |
| + |
| +// RestoringAppsIn names the account's apps that are mid-restore. |
| +func (s *Store) RestoringAppsIn(accountID int64) ([]string, error) { |
| + rows, err := s.db.Query(`SELECT app FROM apps WHERE account_id = ? AND deleted_at != 0 AND restoring = 1 ORDER BY app`, accountID) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []string |
| + for rows.Next() { |
| + var a string |
| + if err := rows.Scan(&a); err != nil { |
| + return nil, err |
| + } |
| + out = append(out, a) |
| + } |
| + return out, rows.Err() |
| +} |
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index b5c75ea34..69e5f77f4 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -451,6 +451,16 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) { |
| // account role, and handing a CI credential the power to adopt release |
| // history is exactly the widening svcrole exists to prevent. |
| ownerClaim := p.Kind == PrincipalUser && acct.Role == RoleOwner |
| + defer s.planLock(acct)() |
| + if err := s.checkCreateApp(r.Context(), acct, name); err != nil { |
| + if isPlanCap(err) { |
| + writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) |
| + return |
| + } |
| + log.Printf("console: api create %s/%s: plan cap: %v", acct.Sqid, name, err) |
| + jsonError(w, http.StatusInternalServerError, "could not create the app — try again") |
| + return |
| + } |
| outcome, placed, held := s.claimApp(r.Context(), acct, name, addr, place, ownerClaim, rq) |
| switch outcome { |
| case claimInvalid: |
| @@ -1584,6 +1594,7 @@ func (s *Server) handleAPIRestoreApp(w http.ResponseWriter, r *http.Request) { |
| // that comes back and then vanishes again on the next pass. |
| // restoreApp (restore.go) is the one restore every door runs: it locks, |
| // settles, checks the temporary cap and orders the row and marker writes. |
| + var planCap *planCapError |
| var capErr *temporaryCapError |
| var rf *restoreFailure |
| switch err := s.restoreApp(ctx, acct, app, time.Now()); { |
| @@ -1591,6 +1602,9 @@ func (s *Server) handleAPIRestoreApp(w http.ResponseWriter, r *http.Request) { |
| case errors.Is(err, errNotInTrash): |
| http.NotFound(w, r) |
| return |
| + case errors.As(err, &planCap): |
| + writeJSON(w, http.StatusConflict, map[string]any{"error": planCap.Error(), "code": "cap"}) |
| + return |
| case errors.As(err, &capErr): |
| writeJSON(w, http.StatusConflict, map[string]any{"error": capErr.Error(), "code": "cap"}) |
| return |
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| index c97618b7d..a44711821 100644 |
| --- a/internal/console/apidomains.go |
| +++ b/internal/console/apidomains.go |
| @@ -522,6 +522,12 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| // can refuse first, the two 409s are indistinguishable in status, body |
| // AND reachability. Anything added to this branch later belongs above |
| // this line, not below it. |
| + // Plans 3a: custom domains need a plan that includes them (above |
| + // heldElsewhere, per the rule). |
| + if ref := s.customDomainRefusal(acct, host); ref != nil { |
| + writeJSON(w, ref.Status, capOrError(ref)) |
| + return |
| + } |
| if ref := s.heldElsewhere(ctx, host, acct.Sqid, app); ref != nil { |
| jsonError(w, ref.Status, ref.Msg) |
| return |
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go| index 1aed8a1d5..110867b64 100644 |
| --- a/internal/console/apiinstances.go |
| +++ b/internal/console/apiinstances.go |
| @@ -1576,6 +1576,18 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request) |
| jsonError(w, ref.Status, ref.Msg) |
| return |
| } |
| + // Plans 3a: an instance is a running thing. Checked once here, so plain, |
| + // seeded and imported creates all meet the cap, under the account lock. |
| + defer s.planLock(acct)() |
| + if err := s.checkRunningThings(r.Context(), acct, 1); err != nil { |
| + if isPlanCap(err) { |
| + writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) |
| + return |
| + } |
| + log.Printf("console: api instances create %s/%s: plan cap: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not create the instance — try again") |
| + return |
| + } |
| // Every ordinary check has passed: a seed door takes over from here, and |
| // writes its own record (apiinstanceseed.go). |
| if req.From != nil { |
diff --git a/internal/console/apisidecars.go b/internal/console/apisidecars.go| index b16346bc7..698b7daa4 100644 |
| --- a/internal/console/apisidecars.go |
| +++ b/internal/console/apisidecars.go |
| @@ -311,6 +311,17 @@ func (s *Server) handleAPISidecarCreate(w http.ResponseWriter, r *http.Request) |
| jsonError(w, http.StatusBadRequest, err.Error()) |
| return |
| } |
| + // Plans 3a: the plan caps workers per app, under the account lock. |
| + defer s.planLock(acct)() |
| + if err := s.checkSidecars(r.Context(), acct, app); err != nil { |
| + if isPlanCap(err) { |
| + writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) |
| + return |
| + } |
| + log.Printf("console: api sidecars create %s/%s: plan cap: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not create the sidecar — try again") |
| + return |
| + } |
| // The leftover-replica rail (#199, createleftovers.go): a sidecar's host |
| // is derived from its name, so a name reused after a delete lands on the |
| // deleted sidecar's replica. Refused, after every validation above. |
diff --git a/internal/console/apitemporary.go b/internal/console/apitemporary.go| index dcfd00993..23166278d 100644 |
| --- a/internal/console/apitemporary.go |
| +++ b/internal/console/apitemporary.go |
| @@ -171,6 +171,17 @@ func (s *Server) handleAPICreateTemporaryApp(w http.ResponseWriter, r *http.Requ |
| jsonError(w, http.StatusInternalServerError, "could not count this account's temporary apps. Nothing was created. Try again") |
| return |
| } |
| + // A temporary app is a running thing too (plans 3a); the account lock is |
| + // already held. |
| + if err := s.checkRunningThings(r.Context(), acct, 1); err != nil { |
| + if isPlanCap(err) { |
| + writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) |
| + return |
| + } |
| + log.Printf("console: temporary create %s: plan cap: %v", acct.Sqid, err) |
| + jsonError(w, http.StatusInternalServerError, "could not count this account's temporary apps. Nothing was created. Try again") |
| + return |
| + } |
| switch wired, err := s.accountAppHasRoutes(acct.Sqid, name); { |
| case err != nil: |
| log.Printf("console: temporary create %s/%s: routes: %v", acct.Sqid, name, err) |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index c55633abb..954e47d57 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -1777,6 +1777,17 @@ func (s *Server) handleCLINewAccount(w http.ResponseWriter, r *http.Request) { |
| } |
| ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) |
| defer cancel() |
| + // Plans 3a: one free account per person (spec §8.1). |
| + defer s.planPersonLock(addr)() |
| + if err := s.checkNewFreeAccount(addr); err != nil { |
| + if isPlanCap(err) { |
| + writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) |
| + return |
| + } |
| + log.Printf("console: cli mint account for %s: plan cap: %v", addr, err) |
| + jsonError(w, http.StatusBadGateway, "could not create the account — try again") |
| + return |
| + } |
| a, err := s.mintAccount(ctx, name, addr) |
| if err != nil { |
| // Logged, not returned: every other failure in this console answers a |
diff --git a/internal/console/claim_restore.go b/internal/console/claim_restore.go| index 43a960fe5..cde0291bc 100644 |
| --- a/internal/console/claim_restore.go |
| +++ b/internal/console/claim_restore.go |
| @@ -116,6 +116,13 @@ func (s *Server) restoreClaimApp(ctx context.Context, acct *Account, app string, |
| if life == nil || !life.Trashed() { |
| return errNotInTrash |
| } |
| + // Plans 3a: restoring brings a running thing back, so it meets the cap. |
| + if err := s.checkRestore(ctx, acct, app); err != nil { |
| + if isPlanCap(err) { |
| + return err |
| + } |
| + return &restoreFailure{Marker: true, Err: err} |
| + } |
| if life.Temporary() { |
| if err := s.temporaryCapCheck(acct.ID); err != nil { |
| var capErr *temporaryCapError |
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go| index 36769f333..34da425d3 100644 |
| --- a/internal/console/domainattach.go |
| +++ b/internal/console/domainattach.go |
| @@ -137,6 +137,10 @@ func (s *Server) attachDomainAs(ctx context.Context, acct *Account, app, host, k |
| return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity, |
| "that hostname is platform-managed"} |
| } |
| + // Plans 3a: custom domains need a plan that includes them. |
| + if ref := s.customDomainRefusal(acct, host); ref != nil { |
| + return attachResult{}, ref |
| + } |
| // The reserved sidecar suffix, refused at the same policy stage and with |
| // the same status as the two checks above: this door must never mint a |
| // claim (let alone a route record) under a name provision.SidecarSuffix |
| @@ -205,6 +209,15 @@ func (s *Server) attachDomainAs(ctx context.Context, acct *Account, app, host, k |
| if ref != nil { |
| return attachResult{}, ref |
| } |
| + // Plans 3a: a first route starts a running thing, so it meets the cap, |
| + // above heldElsewhere per the ordering rule. |
| + defer s.planLock(acct)() |
| + if err := s.checkRunningThings(ctx, acct, 1); err != nil { |
| + if isPlanCap(err) { |
| + return attachResult{}, &doorRefusal{http.StatusConflict, err.Error()} |
| + } |
| + return attachResult{}, &doorRefusal{http.StatusBadGateway, "could not create the instance — try again"} |
| + } |
| if ref := s.heldElsewhere(ctx, host, acct.Sqid, app); ref != nil { |
| return attachResult{}, ref |
| } |
diff --git a/internal/console/domaincatchall.go b/internal/console/domaincatchall.go| index 578a96ced..e6a97a7c9 100644 |
| --- a/internal/console/domaincatchall.go |
| +++ b/internal/console/domaincatchall.go |
| @@ -92,6 +92,10 @@ func (s *Server) attachInstanceCatchall(ctx context.Context, acct *Account, app, |
| if s.catchallParentRefused(parent, acct.Sqid) { |
| return false, &doorRefusal{http.StatusBadRequest, "that hostname is platform-managed"} |
| } |
| + // Plans 3a: a catch-all is a custom domain too. |
| + if ref := s.customDomainRefusal(acct, parent); ref != nil { |
| + return false, ref |
| + } |
| created, err := domains.AttachInstanceCatchall(ctx, s.cfg.Releases, parent, acct.Sqid, app, |
| time.Now().UTC().Format(time.RFC3339), principal) |
| switch { |
diff --git a/internal/console/fleets.go b/internal/console/fleets.go| index dd445ddbc..39310ba62 100644 |
| --- a/internal/console/fleets.go |
| +++ b/internal/console/fleets.go |
| @@ -172,6 +172,16 @@ func (s *Server) handleAPIFleetNew(w http.ResponseWriter, r *http.Request) { |
| "fleet creation limit reached for today — try again tomorrow") |
| return |
| } |
| + // Plans 3a: fleets need a plan that includes them. |
| + if err := s.checkFleets(acct); err != nil { |
| + if isPlanCap(err) { |
| + writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) |
| + return |
| + } |
| + log.Printf("console: fleet create %s: plan: %v", acct.Sqid, err) |
| + jsonError(w, http.StatusBadGateway, "could not check this account's plan. Try again") |
| + return |
| + } |
| var req struct { |
| Name string `json:"name"` |
| Bucket string `json:"bucket"` |
diff --git a/internal/console/ownership.go b/internal/console/ownership.go| index 72cc1eb9f..4868954cf 100644 |
| --- a/internal/console/ownership.go |
| +++ b/internal/console/ownership.go |
| @@ -252,6 +252,7 @@ func (s *Server) handleRestoreApp(w http.ResponseWriter, r *http.Request) { |
| } |
| // restoreApp (restore.go) is the one restore every door runs: it locks, |
| // settles, checks the temporary cap and orders the row and marker writes. |
| + var planCap *planCapError |
| var capErr *temporaryCapError |
| var rf *restoreFailure |
| switch err := s.restoreApp(r.Context(), acct, app, time.Now()); { |
| @@ -259,6 +260,9 @@ func (s *Server) handleRestoreApp(w http.ResponseWriter, r *http.Request) { |
| case errors.Is(err, errNotInTrash): |
| fail("There is nothing called “" + app + "” in this account's trash.") |
| return |
| + case errors.As(err, &planCap): |
| + fail(planCap.Browser()) |
| + return |
| case errors.As(err, &capErr): |
| fail(capErr.Error()) |
| return |
diff --git a/internal/console/plancaps.go b/internal/console/plancaps.go| new file mode 100644 |
| index 000000000..f775fcf54 |
| --- /dev/null |
| +++ b/internal/console/plancaps.go |
| @@ -0,0 +1,290 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "net/http" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// Creation caps (plans-caps-packs spec §5.1, §8.1, §8.4; plans 3a). Every |
| +// check passes when plans are off (EffectiveLimits nil), so a deployment |
| +// without a catalog sees no change. A refusal is a *planCapError carrying |
| +// approved copy; JSON doors answer it 409 {"code":"cap"} and browser doors |
| +// flash it. Creation refusals have no grace: the cap refuses what is new and |
| +// never touches what already runs. |
| + |
| +// planCapError carries the CLI wording (Error) and, where a browser door can |
| +// meet it, the browser wording (Browser). |
| +type planCapError struct{ msg, browser string } |
| + |
| +func (e *planCapError) Error() string { return e.msg } |
| + |
| +// Browser is the sentence-case wording for a browser door. |
| +func (e *planCapError) Browser() string { |
| + if e.browser != "" { |
| + return e.browser |
| + } |
| + return e.msg |
| +} |
| + |
| +// capMessage formats approved copy by its review id (plancaps_copy.go). |
| +func capMessage(id string, args ...any) string { |
| + format, ok := capCopy[id] |
| + if !ok { |
| + format = id |
| + } |
| + return fmt.Sprintf(format, args...) |
| +} |
| + |
| +// runningThings is the account's live apps (temporary and restoring |
| +// included) plus the instance records of those apps. Trashed apps and their |
| +// instances do not count. One key-only list of the account's prefix. |
| +func (s *Server) runningThings(ctx context.Context, acct *Account) (int, error) { |
| + apps, err := s.store.LiveAppCount(acct.ID) |
| + if err != nil { |
| + return 0, err |
| + } |
| + live, err := s.store.AppsIn(acct.ID) |
| + if err != nil { |
| + return 0, err |
| + } |
| + restoring, err := s.store.RestoringAppsIn(acct.ID) |
| + if err != nil { |
| + return 0, err |
| + } |
| + liveSet := map[string]bool{} |
| + for _, a := range append(live, restoring...) { |
| + liveSet[a] = true |
| + } |
| + if s.cfg.Releases == nil { |
| + return apps, nil |
| + } |
| + keys, err := s.cfg.Releases.List(ctx, "apps/"+acct.Sqid+"/") |
| + if err != nil { |
| + return 0, err |
| + } |
| + known := map[string]bool{acct.Sqid: true} |
| + instances := 0 |
| + for _, k := range keys { |
| + if _, app, _, ok := provision.ParseRecordKey(k, known); ok && liveSet[app] { |
| + instances++ |
| + } |
| + } |
| + return apps + instances, nil |
| +} |
| + |
| +// checkRunningThings refuses when adding more running things would pass |
| +// the account's cap. |
| +func (s *Server) checkRunningThings(ctx context.Context, acct *Account, adding int) error { |
| + l, err := s.EffectiveLimits(acct) |
| + if err != nil || l == nil { |
| + return err |
| + } |
| + n, err := s.runningThings(ctx, acct) |
| + if err != nil { |
| + return err |
| + } |
| + if n+adding > l.RunningThings { |
| + return &planCapError{capMessage("cli.cap.running_things", l.RunningThings), capMessage("browser.cap.running_things", l.RunningThings)} |
| + } |
| + return nil |
| +} |
| + |
| +// checkRestore is checkRunningThings for restoring app, with the restore |
| +// wording. |
| +func (s *Server) checkRestore(ctx context.Context, acct *Account, app string) error { |
| + // Restoring brings back the app and its retained instance records |
| + // (Astra plans-3a finding 1). |
| + n, err := s.appInstanceCount(ctx, acct, app) |
| + if err != nil { |
| + return err |
| + } |
| + err = s.checkRunningThings(ctx, acct, 1+n) |
| + if !isPlanCap(err) { |
| + return err |
| + } |
| + l, _ := s.EffectiveLimits(acct) |
| + return &planCapError{capMessage("cli.cap.restore", app, l.RunningThings), capMessage("browser.cap.restore", app, l.RunningThings)} |
| +} |
| + |
| +// checkSidecars refuses a new worker when app already has as many as the |
| +// plan allows. |
| +func (s *Server) checkSidecars(ctx context.Context, acct *Account, app string) error { |
| + l, err := s.EffectiveLimits(acct) |
| + if err != nil || l == nil { |
| + return err |
| + } |
| + if s.cfg.Releases == nil { |
| + return nil |
| + } |
| + keys, err := s.cfg.Releases.List(ctx, provision.SidecarsPrefix(acct.Sqid, app)) |
| + if err != nil { |
| + return err |
| + } |
| + n := 0 |
| + for _, k := range keys { |
| + if strings.HasSuffix(k, ".json") { |
| + n++ |
| + } |
| + } |
| + if n >= l.SidecarsPerApp { |
| + return &planCapError{msg: capMessage("cli.cap.sidecars", app, l.SidecarsPerApp)} |
| + } |
| + return nil |
| +} |
| + |
| +// checkNewFreeAccount refuses a person a further account on a free plan once |
| +// they own the catalog's allowance of them (spec §8.1; creation only). |
| +func (s *Server) checkNewFreeAccount(addr string) error { |
| + cat := s.cfg.PlanCatalog |
| + if cat == nil || cat.FreeAccountsPerPerson <= 0 { |
| + return nil |
| + } |
| + if p, ok := cat.Plans[cat.DefaultPlan]; ok && p.Paid { |
| + return nil // new accounts start on a paid plan here: nothing to cap |
| + } |
| + accts, err := s.store.AccountsFor(addr) |
| + if err != nil { |
| + return err |
| + } |
| + free := 0 |
| + for _, a := range accts { |
| + if a.Role != RoleOwner { |
| + continue |
| + } |
| + plan := cat.DefaultPlan |
| + row, err := s.store.AccountPlan(a.ID) |
| + if err != nil { |
| + return err |
| + } |
| + if row != nil { |
| + plan = row.Plan |
| + } |
| + if p, ok := cat.Plans[plan]; !ok || !p.Paid { |
| + free++ |
| + } |
| + } |
| + if free >= cat.FreeAccountsPerPerson { |
| + return &planCapError{capMessage("cli.cap.free_account"), capMessage("browser.cap.free_account")} |
| + } |
| + return nil |
| +} |
| + |
| +// checkFleets refuses a fleet on a plan without fleets. |
| +func (s *Server) checkFleets(acct *Account) error { |
| + l, err := s.EffectiveLimits(acct) |
| + if err != nil || l == nil || l.Fleets { |
| + return err |
| + } |
| + return &planCapError{msg: capMessage("cli.cap.fleets")} |
| +} |
| + |
| +// checkCustomDomains refuses a custom domain on a plan without them. |
| +// Platform hostnames are not custom domains; callers check those first. |
| +func (s *Server) checkCustomDomains(acct *Account) error { |
| + l, err := s.EffectiveLimits(acct) |
| + if err != nil || l == nil || l.CustomDomains { |
| + return err |
| + } |
| + return &planCapError{capMessage("cli.cap.custom_domain"), capMessage("browser.cap.custom_domain")} |
| +} |
| + |
| +func isPlanCap(err error) bool { |
| + var c *planCapError |
| + return errors.As(err, &c) |
| +} |
| + |
| +// planLock takes the account lock around a capped door's count and write, |
| +// so two concurrent creates cannot both pass. With plans off it takes |
| +// nothing, leaving those deployments exactly as they were. Never call it |
| +// from a door that already holds lockAccount (/new-temporary, restore). |
| +func (s *Server) planLock(acct *Account) func() { |
| + if s.cfg.PlanCatalog == nil { |
| + return func() {} |
| + } |
| + return s.lockAccount(acct.Sqid) |
| +} |
| + |
| +// customDomain reports whether host is a customer's own domain, the kind a |
| +// free plan may not attach: an open host outside the deployment's apps |
| +// domain. The account's own <sqid>.<apps-domain> addresses and the |
| +// operator's platform zones are not custom domains. |
| +func (s *Server) customDomain(host, account string) bool { |
| + if s.domainHostPolicy(host, account) != hostOpen { |
| + return false |
| + } |
| + if domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)); domain != "" && strings.HasSuffix(host, "."+domain) { |
| + return false |
| + } |
| + return true |
| +} |
| + |
| +// customDomainRefusal is checkCustomDomains as a door refusal, for the |
| +// shared attach paths; nil when the attach may go ahead. |
| +func (s *Server) customDomainRefusal(acct *Account, host string) *doorRefusal { |
| + if !s.customDomain(host, acct.Sqid) { |
| + return nil |
| + } |
| + if err := s.checkCustomDomains(acct); err != nil { |
| + if isPlanCap(err) { |
| + return &doorRefusal{http.StatusConflict, err.Error()} |
| + } |
| + return &doorRefusal{http.StatusBadGateway, "could not check this account's plan. Try again"} |
| + } |
| + return nil |
| +} |
| + |
| +// capOrError is a door refusal as a JSON body, with "code":"cap" when it is |
| +// a plan cap (the only 409 customDomainRefusal returns). |
| +func capOrError(ref *doorRefusal) map[string]any { |
| + body := map[string]any{"error": ref.Msg} |
| + if ref.Status == http.StatusConflict { |
| + body["code"] = "cap" |
| + } |
| + return body |
| +} |
| + |
| +// checkCreateApp is the running-things check for creating (or adopting) app. |
| +func (s *Server) checkCreateApp(ctx context.Context, acct *Account, app string) error { |
| + // Adopting an app whose objects already exist brings its instance records |
| + // with it (Astra plans-3a finding 3); a brand-new name has none. |
| + n, err := s.appInstanceCount(ctx, acct, app) |
| + if err != nil { |
| + return err |
| + } |
| + return s.checkRunningThings(ctx, acct, 1+n) |
| +} |
| + |
| +// appInstanceCount counts app's instance records in the bucket, whether or |
| +// not the app has a live console row. |
| +func (s *Server) appInstanceCount(ctx context.Context, acct *Account, app string) (int, error) { |
| + if s.cfg.Releases == nil { |
| + return 0, nil |
| + } |
| + keys, err := s.cfg.Releases.List(ctx, provision.RecordsPrefix(acct.Sqid, app)) |
| + if err != nil { |
| + return 0, err |
| + } |
| + known := map[string]bool{acct.Sqid: true} |
| + n := 0 |
| + for _, k := range keys { |
| + if _, a, _, ok := provision.ParseRecordKey(k, known); ok && a == app { |
| + n++ |
| + } |
| + } |
| + return n, nil |
| +} |
| + |
| +// planPersonLock serialises a person's account creates when plans are on, |
| +// so concurrent requests cannot each see room for one more free account |
| +// (Astra plans-3a finding 2). The key cannot collide with an account sqid. |
| +func (s *Server) planPersonLock(addr string) func() { |
| + if s.cfg.PlanCatalog == nil { |
| + return func() {} |
| + } |
| + return s.acctLocks.lock("person:" + addr) |
| +} |
diff --git a/internal/console/plancaps_copy.go b/internal/console/plancaps_copy.go| new file mode 100644 |
| index 000000000..6c43a14ef |
| --- /dev/null |
| +++ b/internal/console/plancaps_copy.go |
| @@ -0,0 +1,18 @@ |
| +// Code generated from the approved copy review of 2026-10-22 (plans 3a). |
| +// Apply later edits through copy review, not by hand. |
| + |
| +package console |
| + |
| +// capCopy holds the creation-cap refusals by copy-review id. |
| +var capCopy = map[string]string{ |
| + "browser.cap.running_things": "This account is running %d apps and instances, the most its plan allows. Delete one to make room, or move to a plan that allows more.", |
| + "browser.cap.restore": "Restoring %s would take this account past the %d apps and instances its plan allows. Delete one first, or move to a plan that allows more.", |
| + "browser.cap.free_account": "You already own a free account, and each person can own one. Use that account, or ask an operator for another.", |
| + "browser.cap.custom_domain": "Custom domains need a paid plan. On this plan the app keeps its platform address.", |
| + "cli.cap.running_things": "this account is running %d apps and instances, the most its plan allows. delete one to make room, or move to a plan that allows more", |
| + "cli.cap.restore": "restoring %s would take this account past the %d apps and instances its plan allows. delete one first, or move to a plan that allows more", |
| + "cli.cap.sidecars": "%s already has %d workers, the most its plan allows. remove one first, or move to a plan that allows more", |
| + "cli.cap.free_account": "you already own a free account, and each person can own one. use that account, or ask an operator for another", |
| + "cli.cap.fleets": "this account's plan does not include its own boxes (fleets). move to a plan that does to add one", |
| + "cli.cap.custom_domain": "custom domains need a paid plan. on this plan the app keeps its platform address", |
| +} |
diff --git a/internal/console/plancaps_doors_test.go b/internal/console/plancaps_doors_test.go| new file mode 100644 |
| index 000000000..78345a212 |
| --- /dev/null |
| +++ b/internal/console/plancaps_doors_test.go |
| @@ -0,0 +1,374 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "sync" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// atRunningCap turns plans on and leaves the fixture's ops account with |
| +// exactly capN running things against a cap of capN (capN >= the free |
| +// plan's 3). |
| +func atRunningCap(t *testing.T, fx *consoleFixture, capN int) *Account { |
| + t.Helper() |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct, err := fx.st.AccountBySqid(fx.sqid) |
| + if err != nil || acct == nil { |
| + t.Fatalf("ops account: %v", err) |
| + } |
| + for i := 0; ; i++ { |
| + n, err := fx.srv.runningThings(context.Background(), acct) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if n > capN { |
| + capN = n |
| + } |
| + if n == capN { |
| + break |
| + } |
| + capsApp(t, fx, acct, fmt.Sprintf("filler%02d", i)) |
| + } |
| + if _, err := fx.st.SetAccountPlan(acct.ID, "free", nil, PlanSourceSponsored, &plans.Limits{RunningThings: capN}, "test", "ops@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + return acct |
| +} |
| + |
| +func raiseRunningCap(t *testing.T, fx *consoleFixture, acct *Account, by int) { |
| + t.Helper() |
| + n, err := fx.srv.runningThings(context.Background(), acct) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.st.SetAccountPlan(acct.ID, "free", nil, PlanSourceSponsored, &plans.Limits{RunningThings: n + by}, "test", "ops@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestCreateAppRefusedAtTheRunningThingsCap(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + acct := atRunningCap(t, fx, 3) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-cap-create") |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": "onemore"}, token) |
| + if status != http.StatusConflict || out["code"] != "cap" || out["error"] == nil { |
| + t.Fatalf("CLI create at the cap = %d %v, want 409 cap", status, out) |
| + } |
| + c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-cap-create-web") |
| + if st, _, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"webmore"}}); st != http.StatusSeeOther { |
| + t.Fatalf("browser create at the cap = %d, want the flash redirect", st) |
| + } |
| + for _, app := range []string{"onemore", "webmore"} { |
| + if has, _ := fx.st.HasAppIn(acct.ID, app); has { |
| + t.Fatalf("%s was created past the cap", app) |
| + } |
| + } |
| +} |
| + |
| +func TestCreateAppBelowTheCapStillWorks(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + acct := atRunningCap(t, fx, 3) |
| + raiseRunningCap(t, fx, acct, 1) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-cap-below") |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": "fits"}, token); status != http.StatusOK { |
| + t.Fatalf("create below the cap = %d %v", status, out) |
| + } |
| +} |
| + |
| +func TestTemporaryAppCountsTowardRunningThings(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.TemporaryApps = true |
| + atRunningCap(t, fx, 3) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-cap-temp") |
| + status, out := createTemporary(t, fx, token, "tmp-capcheck", 3600) |
| + if status != http.StatusConflict || out["code"] != "cap" { |
| + t.Fatalf("temporary create at the running cap = %d %v, want 409 cap", status, out) |
| + } |
| +} |
| + |
| +func TestRestoreRefusedAtTheRunningThingsCap(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-cap-restore") |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/delete", map[string]string{}, token); status != http.StatusOK { |
| + t.Fatalf("delete kass = %d %v", status, out) |
| + } |
| + atRunningCap(t, fx, 3) |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/restore", map[string]string{}, token) |
| + if status != http.StatusConflict || out["code"] != "cap" { |
| + t.Fatalf("CLI restore at the cap = %d %v, want 409 cap", status, out) |
| + } |
| + c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-cap-restore-web") |
| + if st, _, _ := postForm(t, c, fx.u("/trash/restore"), url.Values{"csrf": {csrf}, "app": {"kass"}}); st != http.StatusSeeOther { |
| + t.Fatalf("browser restore at the cap = %d, want the flash redirect", st) |
| + } |
| + if markerOf(t, fx, "kass") == nil { |
| + t.Fatal("a refused restore removed the trash marker") |
| + } |
| +} |
| + |
| +func TestRunningThingsCapIsExactUnderConcurrentCreates(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + acct := atRunningCap(t, fx, 3) |
| + raiseRunningCap(t, fx, acct, 1) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-cap-race") |
| + var mu sync.Mutex |
| + created := 0 |
| + var wg sync.WaitGroup |
| + for i := range 8 { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + status, _ := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": fmt.Sprintf("race%d", i)}, token) |
| + if status == http.StatusOK { |
| + mu.Lock() |
| + created++ |
| + mu.Unlock() |
| + } |
| + }() |
| + } |
| + wg.Wait() |
| + if created != 1 { |
| + t.Fatalf("%d creates succeeded for one free slot, want exactly 1", created) |
| + } |
| +} |
| + |
| +func TestCreateAppWithoutACatalogUnchanged(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + acct, _ := fx.st.AccountBySqid(fx.sqid) |
| + for i := range 5 { |
| + capsApp(t, fx, acct, fmt.Sprintf("many%d", i)) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-cap-nocat") |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": "stillfine"}, token); status != http.StatusOK { |
| + t.Fatalf("create with no catalog = %d %v", status, out) |
| + } |
| +} |
| + |
| +// sponsorCustomDomains keeps the cap as atRunningCap left it and adds custom |
| +// domains, so a first-route attach meets the running-things cap rather than |
| +// the custom-domain gate. |
| +func sponsorCustomDomains(t *testing.T, fx *consoleFixture, acct *Account) { |
| + t.Helper() |
| + row, err := fx.st.AccountPlan(acct.ID) |
| + if err != nil || row == nil || row.Sponsored == nil { |
| + t.Fatalf("plan row: %+v, %v", row, err) |
| + } |
| + sp := *row.Sponsored |
| + sp.CustomDomains = true |
| + if _, err := fx.st.SetAccountPlan(acct.ID, "free", nil, PlanSourceSponsored, &sp, "test", "ops@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestInstanceCreateRefusedAtTheRunningThingsCap(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-cap-instance") |
| + enableInstances(t, fx, "titogo", titoManifest()) |
| + atRunningCap(t, fx, 3) |
| + status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token, `{"host":"acme.go.tito.io","region":"ie"}`) |
| + if status != http.StatusConflict || !strings.Contains(out, `"code":"cap"`) { |
| + t.Fatalf("instance create at the cap = %d %s, want 409 cap", status, out) |
| + } |
| + if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io"); err == nil { |
| + t.Fatal("an instance record was written past the cap") |
| + } |
| +} |
| + |
| +func TestFirstRouteAttachRefusedAtTheRunningThingsCap(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-cap-firstroute") |
| + shipVersion(t, fx, "titogo", "v1") |
| + seedChannel(t, fx, "titogo", "canary/live", "v1") |
| + acct := atRunningCap(t, fx, 3) |
| + sponsorCustomDomains(t, fx, acct) |
| + status, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), token, `{"host":"shop.example.com","kind":"static"}`) |
| + if status != http.StatusConflict { |
| + t.Fatalf("first-route attach at the cap = %d %s, want 409", status, out) |
| + } |
| + if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "shop.example.com"); err == nil { |
| + t.Fatal("a first-route record was written past the cap") |
| + } |
| +} |
| + |
| +func TestSidecarCreateRefusedAtTheSidecarCap(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-cap-sidecar") |
| + enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct, _ := fx.st.AccountBySqid("bab") |
| + capsSidecar(t, fx, acct, "titogo", "first") |
| + status, _, out := rawReq(t, http.MethodPost, sidecarsURL(fx, "bab", "titogo"), token, `{"name":"bot","region":"ie"}`) |
| + if status != http.StatusConflict || !strings.Contains(out, `"code":"cap"`) { |
| + t.Fatalf("second sidecar on free = %d %s, want 409 cap", status, out) |
| + } |
| + if _, _, err := fx.rel.Get(context.Background(), provision.SidecarRecordKey("bab", "titogo", "bot")); err == nil { |
| + t.Fatal("a sidecar record was written past the cap") |
| + } |
| +} |
| + |
| +func TestInstanceAndSidecarCreateWithoutACatalogUnchanged(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-cap-inst-nocat") |
| + enableInstances(t, fx, "titogo", titoManifest()) |
| + if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token, `{"host":"acme.go.tito.io","region":"ie"}`); status != http.StatusCreated { |
| + t.Fatalf("instance create with no catalog = %d %s", status, out) |
| + } |
| + enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| + acct, _ := fx.st.AccountBySqid("bab") |
| + capsSidecar(t, fx, acct, "titogo", "first") |
| + if status, _, out := rawReq(t, http.MethodPost, sidecarsURL(fx, "bab", "titogo"), token, `{"name":"bot","region":"ie"}`); status != http.StatusCreated { |
| + t.Fatalf("sidecar create with no catalog = %d %s", status, out) |
| + } |
| +} |
| + |
| +func accountCount(t *testing.T, fx *consoleFixture) int { |
| + t.Helper() |
| + all, err := fx.st.Accounts() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return len(all) |
| +} |
| + |
| +func TestNewAccountRefusedForASecondFreeAccount(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + before := accountCount(t, fx) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-cap-acct") |
| + status, out := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": "second"}, token) |
| + if status != http.StatusConflict || out["code"] != "cap" { |
| + t.Fatalf("CLI second free account = %d %v, want 409 cap", status, out) |
| + } |
| + c := signInPaul(t, fx) |
| + st, loc, _ := postForm(t, c, fx.ts.URL+"/accounts/new", url.Values{"csrf": {sessionToken(t, fx, c)}, "name": {"thirdtry"}}) |
| + if st != http.StatusSeeOther || loc != "/accounts/new" { |
| + t.Fatalf("browser second free account = %d -> %q, want the flash redirect back to /accounts/new", st, loc) |
| + } |
| + if after := accountCount(t, fx); after != before { |
| + t.Fatalf("accounts %d -> %d: one was minted past the cap", before, after) |
| + } |
| +} |
| + |
| +func TestFirstAccountIsNeverRefused(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + signInAs(t, fx, "newcomer@keymail.dev", "code-cap-first") |
| + accts, err := fx.st.AccountsFor("newcomer@keymail.dev") |
| + if err != nil || len(accts) != 1 { |
| + t.Fatalf("first sign-in with plans on: %d accounts, %v; want the auto-minted one", len(accts), err) |
| + } |
| +} |
| + |
| +func TestNewAccountAllowedWhenTheOtherIsPaid(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + ops, _ := fx.st.AccountBySqid(fx.sqid) |
| + if _, err := fx.st.SetAccountPlan(ops.ID, "personal", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-cap-acct-paid") |
| + if status, out := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": "second"}, token); status != http.StatusOK { |
| + t.Fatalf("a first free account beside a paid one = %d %v", status, out) |
| + } |
| +} |
| + |
| +func TestNewAccountWithoutACatalogUnchanged(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-cap-acct-nocat") |
| + for _, name := range []string{"one", "two"} { |
| + if status, out := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": name}, token); status != http.StatusOK { |
| + t.Fatalf("account %s with no catalog = %d %v", name, status, out) |
| + } |
| + } |
| +} |
| + |
| +func TestFleetCreateRefusedOnAPlanWithoutFleets(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-cap-fleet") |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + status, out := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", map[string]string{"name": "acme", "bucket": "bkt-1"}, token) |
| + if status != http.StatusConflict || out["code"] != "cap" { |
| + t.Fatalf("fleet create on free = %d %v, want 409 cap", status, out) |
| + } |
| +} |
| + |
| +func TestCustomDomainRefusedOnAFreePlan(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-cap-domain") |
| + shipVersion(t, fx, "titogo", "v1") |
| + seedChannel(t, fx, "titogo", "canary/live", "v1") |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + for name, body := range map[string]string{ |
| + "alias": `{"host":"tickets.example.com","route":"go.tito.io"}`, |
| + "first route": `{"host":"fresh.example.com","kind":"static"}`, |
| + } { |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, body) |
| + if code != http.StatusConflict { |
| + t.Errorf("%s on free = %d %s, want 409", name, code, out) |
| + } |
| + } |
| + claimAbsent(t, fx, "tickets.example.com") |
| + claimAbsent(t, fx, "fresh.example.com") |
| +} |
| + |
| +func TestFleetsAndDomainsWithoutACatalogUnchanged(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-cap-domain-nocat") |
| + if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, `{"host":"tickets.example.com","route":"go.tito.io"}`); code != http.StatusOK { |
| + t.Fatalf("alias attach with no catalog = %d %s", code, out) |
| + } |
| + fx2, token := fleetFixture(t, "code-cap-fleet-nocat") |
| + if status, out := postJSON(t, fleetsURL(fx2, fx2.sqid)+"/new", map[string]string{"name": "acme", "bucket": "bkt-1"}, token); status != http.StatusOK { |
| + t.Fatalf("fleet create with no catalog = %d %v", status, out) |
| + } |
| +} |
| + |
| +// Review Focus 5: an address under the deployment's apps domain is the |
| +// account's own platform address, not a custom domain, so free plans keep it. |
| +func TestPlatformHostnamesStayFreeOnFreePlans(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-cap-platform") |
| + fx.srv.cfg.AppsDomain = "apps.example.com" |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct, _ := fx.st.AccountBySqid("bab") |
| + if fx.srv.customDomain("shop.bab.apps.example.com", acct.Sqid) { |
| + t.Fatal("an address under the apps domain was treated as a custom domain") |
| + } |
| + if !fx.srv.customDomain("shop.example.com", acct.Sqid) { |
| + t.Fatal("a customer domain was not treated as a custom domain") |
| + } |
| + if ref := fx.srv.customDomainRefusal(acct, "shop.bab.apps.example.com"); ref != nil { |
| + t.Fatalf("free plan refused its own platform address: %+v", ref) |
| + } |
| + _ = member |
| +} |
| + |
| +// Astra plans-3a finding 2: concurrent creates by one person who owns only |
| +// paid accounts must not mint more free accounts than the allowance. |
| +func TestFreeAccountCapIsExactUnderConcurrentCreates(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + ops, _ := fx.st.AccountBySqid(fx.sqid) |
| + if _, err := fx.st.SetAccountPlan(ops.ID, "personal", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-cap-acct-race") |
| + var mu sync.Mutex |
| + created := 0 |
| + var wg sync.WaitGroup |
| + for i := range 6 { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + status, _ := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": fmt.Sprintf("race%d", i)}, token) |
| + if status == http.StatusOK { |
| + mu.Lock() |
| + created++ |
| + mu.Unlock() |
| + } |
| + }() |
| + } |
| + wg.Wait() |
| + if created != 1 { |
| + t.Fatalf("%d free accounts minted concurrently, want exactly 1", created) |
| + } |
| +} |
diff --git a/internal/console/plancaps_test.go b/internal/console/plancaps_test.go| new file mode 100644 |
| index 000000000..57f123012 |
| --- /dev/null |
| +++ b/internal/console/plancaps_test.go |
| @@ -0,0 +1,225 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +func capsApp(t *testing.T, fx *consoleFixture, acct *Account, app string) { |
| + t.Helper() |
| + if _, err := fx.st.CreateAppIn(acct.ID, app, "owner@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func capsInstance(t *testing.T, fx *consoleFixture, acct *Account, app, host string) { |
| + t.Helper() |
| + if _, err := fx.rel.Put(context.Background(), provision.RecordKey(acct.Sqid, app, host), []byte(`{}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func capsSidecar(t *testing.T, fx *consoleFixture, acct *Account, app, name string) { |
| + t.Helper() |
| + if _, err := fx.rel.Put(context.Background(), provision.SidecarRecordKey(acct.Sqid, app, name), []byte(`{}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func isCap(err error) bool { |
| + var c *planCapError |
| + return errors.As(err, &c) |
| +} |
| + |
| +func TestPlanChecksPassWithoutACatalog(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + acct := planAccount(t, fx, 501) |
| + for i := range 5 { |
| + capsApp(t, fx, acct, "app"+string(rune('a'+i))) |
| + } |
| + capsSidecar(t, fx, acct, "appa", "w1") |
| + capsSidecar(t, fx, acct, "appa", "w2") |
| + ctx := context.Background() |
| + for name, err := range map[string]error{ |
| + "running things": fx.srv.checkRunningThings(ctx, acct, 1), |
| + "sidecars": fx.srv.checkSidecars(ctx, acct, "appa"), |
| + "free account": fx.srv.checkNewFreeAccount("owner" + acct.Sqid + "@example.com"), |
| + "fleets": fx.srv.checkFleets(acct), |
| + "custom domains": fx.srv.checkCustomDomains(acct), |
| + } { |
| + if err != nil { |
| + t.Errorf("%s with no catalog: %v", name, err) |
| + } |
| + } |
| +} |
| + |
| +func TestLiveAppCountCountsRestoringNotTrashed(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + acct := planAccount(t, fx, 501) |
| + for _, app := range []string{"live", "gone", "coming-back"} { |
| + capsApp(t, fx, acct, app) |
| + } |
| + if _, err := fx.st.db.Exec(`UPDATE apps SET deleted_at = ? WHERE account_id = ? AND app IN ('gone', 'coming-back')`, planNow.Unix(), acct.ID); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.st.db.Exec(`UPDATE apps SET restoring = 1 WHERE account_id = ? AND app = 'coming-back'`, acct.ID); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n, err := fx.st.LiveAppCount(acct.ID); err != nil || n != 2 { |
| + t.Fatalf("LiveAppCount = %d, %v; want 2 (live + restoring)", n, err) |
| + } |
| +} |
| + |
| +func TestRunningThingsCountsAppsAndInstances(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + acct := planAccount(t, fx, 501) |
| + capsApp(t, fx, acct, "shop") |
| + capsApp(t, fx, acct, "blog") |
| + capsApp(t, fx, acct, "old") |
| + capsInstance(t, fx, acct, "shop", "a.shop.example.com") |
| + capsInstance(t, fx, acct, "shop", "b.shop.example.com") |
| + capsInstance(t, fx, acct, "blog", "c.blog.example.com") |
| + capsInstance(t, fx, acct, "old", "d.old.example.com") |
| + if err := fx.st.TrashAppRow(acct.ID, "old", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n, err := fx.srv.runningThings(context.Background(), acct); err != nil || n != 5 { |
| + t.Fatalf("runningThings = %d, %v; want 5 (2 apps + 3 instances; the trashed app and its instance do not count)", n, err) |
| + } |
| +} |
| + |
| +func TestRunningThingsCapAtAndBelow(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + capsApp(t, fx, acct, "one") |
| + capsApp(t, fx, acct, "two") |
| + if err := fx.srv.checkRunningThings(context.Background(), acct, 1); err != nil { |
| + t.Fatalf("at 2 of 3: %v", err) |
| + } |
| + capsApp(t, fx, acct, "three") |
| + if err := fx.srv.checkRunningThings(context.Background(), acct, 1); !isCap(err) { |
| + t.Fatalf("at 3 of 3: %v, want a plan cap", err) |
| + } |
| +} |
| + |
| +func TestRunningThingsCapUsesEffectiveLimits(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + if _, err := fx.st.SetAccountPlan(acct.ID, "free", nil, PlanSourceSponsored, &plans.Limits{RunningThings: 300}, "existing account", "ops@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + for i := range 4 { |
| + capsApp(t, fx, acct, "app"+string(rune('a'+i))) |
| + } |
| + if err := fx.srv.checkRunningThings(context.Background(), acct, 1); err != nil { |
| + t.Fatalf("sponsored 300, running 4: %v", err) |
| + } |
| +} |
| + |
| +func TestSidecarCap(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + capsApp(t, fx, acct, "shop") |
| + if err := fx.srv.checkSidecars(context.Background(), acct, "shop"); err != nil { |
| + t.Fatalf("free, no sidecars: %v", err) |
| + } |
| + capsSidecar(t, fx, acct, "shop", "w1") |
| + if err := fx.srv.checkSidecars(context.Background(), acct, "shop"); !isCap(err) { |
| + t.Fatalf("free, one sidecar: %v, want a plan cap", err) |
| + } |
| + if _, err := fx.st.SetAccountPlan(acct.ID, "personal", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, n := range []string{"w2", "w3", "w4"} { |
| + capsSidecar(t, fx, acct, "shop", n) |
| + } |
| + if err := fx.srv.checkSidecars(context.Background(), acct, "shop"); err != nil { |
| + t.Fatalf("personal, four sidecars: %v", err) |
| + } |
| + capsSidecar(t, fx, acct, "shop", "w5") |
| + if err := fx.srv.checkSidecars(context.Background(), acct, "shop"); !isCap(err) { |
| + t.Fatalf("personal, five sidecars: %v, want a plan cap", err) |
| + } |
| +} |
| + |
| +func TestNewFreeAccountCap(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + owner := "owner" + acct.Sqid + "@example.com" |
| + if err := fx.srv.checkNewFreeAccount(owner); !isCap(err) { |
| + t.Fatalf("owner of one free account: %v, want a plan cap", err) |
| + } |
| + if _, err := fx.st.SetAccountPlan(acct.ID, "personal", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.srv.checkNewFreeAccount(owner); err != nil { |
| + t.Fatalf("owner of one paid account: %v", err) |
| + } |
| + member := "member@example.com" |
| + other := planAccount(t, fx, 502) |
| + if err := fx.st.AddMember(other.ID, member, RoleMember, planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.srv.checkNewFreeAccount(member); err != nil { |
| + t.Fatalf("member, not owner, of a free account: %v", err) |
| + } |
| +} |
| + |
| +func TestFleetsAndCustomDomainsFollowThePlan(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + if !isCap(fx.srv.checkFleets(acct)) || !isCap(fx.srv.checkCustomDomains(acct)) { |
| + t.Fatal("free plan allowed fleets or custom domains") |
| + } |
| + if _, err := fx.st.SetAccountPlan(acct.ID, "personal", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + if fx.srv.checkFleets(acct) != nil || fx.srv.checkCustomDomains(acct) != nil { |
| + t.Fatal("personal plan refused fleets or custom domains") |
| + } |
| +} |
| + |
| +// Astra plans-3a finding 1: restoring an app brings back its retained |
| +// instance records too, so the check must count the whole footprint. |
| +func TestRestoreCountsTheAppsInstances(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + capsApp(t, fx, acct, "live") |
| + capsApp(t, fx, acct, "old") |
| + capsInstance(t, fx, acct, "old", "a.old.example.com") |
| + capsInstance(t, fx, acct, "old", "b.old.example.com") |
| + if err := fx.st.TrashAppRow(acct.ID, "old", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + // One running thing of three: restoring "old" would make four. |
| + if err := fx.srv.checkRestore(context.Background(), acct, "old"); !isCap(err) { |
| + t.Fatalf("restore bringing back an app and two instances at 1 of 3: %v, want a plan cap", err) |
| + } |
| +} |
| + |
| +// Astra plans-3a finding 3: adopting an app whose objects already exist in |
| +// the bucket brings its instance records with it. |
| +func TestCreateCountsAnAdoptedAppsInstances(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + capsApp(t, fx, acct, "live") |
| + capsInstance(t, fx, acct, "legacy", "a.legacy.example.com") |
| + capsInstance(t, fx, acct, "legacy", "b.legacy.example.com") |
| + if err := fx.srv.checkCreateApp(context.Background(), acct, "legacy"); !isCap(err) { |
| + t.Fatalf("adopting an app with two instances at 1 of 3: %v, want a plan cap", err) |
| + } |
| + if err := fx.srv.checkCreateApp(context.Background(), acct, "fresh"); err != nil { |
| + t.Fatalf("a new app at 1 of 3: %v", err) |
| + } |
| +} |
diff --git a/internal/console/restore.go b/internal/console/restore.go| index 5887d32d5..325e225ed 100644 |
| --- a/internal/console/restore.go |
| +++ b/internal/console/restore.go |
| @@ -83,6 +83,13 @@ func (s *Server) restoreApp(ctx context.Context, acct *Account, app string, now |
| if life == nil || !life.Trashed() { |
| return errNotInTrash |
| } |
| + // Plans 3a: restoring brings a running thing back, so it meets the cap. |
| + if err := s.checkRestore(ctx, acct, app); err != nil { |
| + if isPlanCap(err) { |
| + return err |
| + } |
| + return &restoreFailure{Marker: true, Err: err} |
| + } |
| if life.Temporary() { |
| if err := s.temporaryCapCheck(acct.ID); err != nil { |
| var capErr *temporaryCapError |
diff --git a/internal/console/server.go b/internal/console/server.go| index 49f65a74b..e7c940087 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -2523,6 +2523,20 @@ func (s *Server) handleNewAccount(w http.ResponseWriter, r *http.Request) { |
| http.Redirect(w, r, "/accounts/new", http.StatusSeeOther) |
| return |
| } |
| + // Plans 3a: one free account per person, checked at creation only (spec |
| + // §8.1). A person's first account, minted on landing, is never refused. |
| + defer s.planPersonLock(addr)() |
| + if err := s.checkNewFreeAccount(addr); err != nil { |
| + var planCap *planCapError |
| + if errors.As(err, &planCap) { |
| + s.setFlash(w, "err", planCap.Browser()) |
| + http.Redirect(w, r, "/accounts/new", http.StatusSeeOther) |
| + return |
| + } |
| + log.Printf("console: create account for %s: plan cap: %v", addr, err) |
| + s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not create the account. Please try again.") |
| + return |
| + } |
| a, err := s.mintAccount(r.Context(), name, addr) |
| if err != nil { |
| log.Printf("console: create account for %s: %v", addr, err) |
| @@ -4402,6 +4416,17 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) { |
| // The browser door never places at create time, so the second return |
| // (what actually stuck) is always "" here and there is nothing to echo |
| // it to — this form has no placement concept at all. |
| + defer s.planLock(acct)() |
| + if err := s.checkCreateApp(r.Context(), acct, name); err != nil { |
| + var planCap *planCapError |
| + if errors.As(err, &planCap) { |
| + fail(planCap.Browser()) |
| + return |
| + } |
| + log.Printf("console: create %s/%s: plan cap: %v", acct.Sqid, name, err) |
| + fail("Could not create the app. Please try again.") |
| + return |
| + } |
| outcome, _, held := s.claimApp(r.Context(), acct, name, addr, "", role == RoleOwner, rq) |
| switch outcome { |
| case claimInvalid: |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index c826ea1eb..ba87f3e80 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -1382,6 +1382,19 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| t.Fatal(err) |
| } |
| seal = "bd4cc1909225cd574c2c609f0cd02177076ddb5c1476d9cf5e178be05acccae1" |
| + // Census overlay plans-3a-creation-caps (the console refuses creates past |
| + // an account's plan limits), loaded last, after db-region-forward-2. |
| + // PROPOSED-plans-3a-creation-caps, asked of the census owner on #280. Two |
| + // new console store counts, handleAPICreateApp re-frozen, the console |
| + // envelope re-pinned. Frozen declarations are added, so the seal moves; |
| + // db-region-forward-2's is checked first. |
| + if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 { |
| + t.Fatal(errs) |
| + } |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-plans-3a-creation-caps.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + seal = "dc7dbb4f562fe31556e7ed1e09fcfb380f551b45a444e94e72662f0b202c605b" |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index b714ac32f..389ad2e5c 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -366,7 +366,7 @@ func TestPlan9B8RecordWriteCensus(t *testing.T) { |
| if len(got) < 100 { |
| t.Fatalf("census found only %d units under %s; the walk is broken", len(got), root) |
| } |
| - inventory := dbRegionForward2WriteCensusInventory() |
| + inventory := plans3aWriteCensusInventory() |
| problems := append(censusDiff(inventory, got), censusPolicy(inventory)...) |
| if len(problems) > 0 { |
| t.Fatalf("record-write census rejected %d site(s); update the explicit inventory only after reviewing the source:\n %s", len(problems), strings.Join(problems, "\n ")) |
diff --git a/internal/provision/plans3a_census_test.go b/internal/provision/plans3a_census_test.go| new file mode 100644 |
| index 000000000..ba8736a84 |
| --- /dev/null |
| +++ b/internal/provision/plans3a_census_test.go |
| @@ -0,0 +1,36 @@ |
| +package provision |
| + |
| +// Record-write census entries for plans-3a-creation-caps, layered on |
| +// dbRegionForward2WriteCensusInventory. Reviewed by the branch author; the census |
| +// owner rules on #280 (PROPOSED-plans-3a-creation-caps). |
| +// - The instance, sidecar and both domain-attach doors gain a plan-cap |
| +// check under the account lock before their existing writes; their |
| +// write sites and classes are unchanged. |
| +// - Server.runningThings, Server.checkSidecars and Server.appInstanceCount |
| +// only list and parse record keys to count an account's running things |
| +// and an app's workers; they write nothing: key-site. |
| +func plans3aWriteCensusInventory() []censusEntry { |
| + entries := append([]censusEntry(nil), dbRegionForward2WriteCensusInventory()...) |
| + replacements := []censusEntry{ |
| + {"internal/console/apidomains.go", "Server.handleAPIDomainAttach", "key-site", "lit:instances", "27aa0452556355854cf2a211bd21c33092f241f450d8263d3058cd6c424bcfd9"}, |
| + {"internal/console/apiinstances.go", "Server.handleAPIInstanceCreate", "record-writer", "key:CreateRecord", "e3ecf19f43f8ebf7cd117c6f78393e35bf8fe35571df50e3df8b7d120d393928"}, |
| + {"internal/console/apisidecars.go", "Server.handleAPISidecarCreate", "record-writer", "key:CreateSidecarRecord", "bbcb85365658ed1ba2bb090a1a3d4f02123ecd8d7d6d04a6bbbf433e94f965ed"}, |
| + {"internal/console/domainattach.go", "Server.attachDomainAs", "record-writer", "key:CreateRecord", "dcb7c4fc09a181d27f6a549965273b27b24ac8180aa38abc1b250923fc3ccb3b"}, |
| + {"internal/console/plancaps.go", "Server.checkSidecars", "key-site", "key:SidecarsPrefix", "01c9ce701fd8da1d2d41a42eb048c0f2dcfe6654ee245a0a43d8944294d46a62"}, |
| + {"internal/console/plancaps.go", "Server.runningThings", "key-site", "key:ParseRecordKey", "6668bb47e5364f4064abd0c4e92f06901fb5803e13e5b3ccb71e169d4afde1f6"}, |
| + {"internal/console/plancaps.go", "Server.appInstanceCount", "key-site", "key:ParseRecordKey,key:RecordsPrefix", "e49e0b3ca9ee49240fd88840997fb93ea39aaa7c8e51d6ae634099f5e47595e7"}, |
| + } |
| + for _, replacement := range replacements { |
| + found := false |
| + for i, entry := range entries { |
| + if entry.File == replacement.File && entry.Unit == replacement.Unit { |
| + entries[i], found = replacement, true |
| + break |
| + } |
| + } |
| + if !found { |
| + entries = append(entries, replacement) |
| + } |
| + } |
| + return entries |
| +} |
diff --git a/internal/provision/testdata/plan14-root-reads-plans-3a-creation-caps.tsv b/internal/provision/testdata/plan14-root-reads-plans-3a-creation-caps.tsv| new file mode 100644 |
| index 000000000..1f7114528 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-plans-3a-creation-caps.tsv |
| @@ -0,0 +1,9 @@ |
| +# Census overlay plans-3a-creation-caps (the console refuses creates past an account's plan limits), loaded LAST, after db-region-forward-2. PROPOSED-plans-3a-creation-caps, asked of the census owner on #280. |
| +# Every unit runs in the console. New: Store.LiveAppCount and Store.RestoringAppsIn count the account's own apps rows (accepted legacy at their store siblings' J4 root-input disposition). Changed: Server.handleAPICreateApp gains the plan-cap check under the account lock and is re-frozen at its main disposition. The internal/console envelope is re-pinned. Frozen declarations are added, so the seal moves. |
| +@legacy 287d2aad49adf3fe22a00bb5f7ed077ee36c027a9fabe5f3dfdfb4130b56aa53 internal/console/apicli.go Server.handleAPICreateApp legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:Account@3000,whole:Fleet@2988 7f12ab30b790991160ba2127958e4a45eac4fd14f93830680002291f47976c95 |
| +@legacy 2cfd2d66db5f0e7b8f96b45de4d8645016c84d1c93c98e825ae6363a450937dc internal/console/accountplans.go Store.LiveAppCount legacy accepted-legacy accepted-legacy;rev3=PROPOSED-plans-3a-creation-caps;basis=root-input; console SQL counting the account's own apps rows for the creation caps, as its store siblings (J4). file:QueryRow@80 4358defda4e98b29886cb8218d135abd30c4dbeb15b0b33ee183afeb7c08af05 |
| +@legacy 933adb95b942d26c8b17fd2c218fa307674e4ba0c84bdbc7e281ae8c48c37638 internal/console/accountplans.go Store.RestoringAppsIn legacy accepted-legacy accepted-legacy;rev3=PROPOSED-plans-3a-creation-caps;basis=root-input; console SQL counting the account's own apps rows for the creation caps, as its store siblings (J4). file:Query@83 ed8bb63453ac4e36d685659f77c87c6fa8c880ae113f37f1378b84acddec5af0 |
| +internal/console/accountplans.go Store.LiveAppCount legacy accepted-legacy accepted-legacy;rev3=PROPOSED-plans-3a-creation-caps;basis=root-input; console SQL counting the account's own apps rows for the creation caps, as its store siblings (J4). file:QueryRow@80 4358defda4e98b29886cb8218d135abd30c4dbeb15b0b33ee183afeb7c08af05 |
| +internal/console/accountplans.go Store.RestoringAppsIn legacy accepted-legacy accepted-legacy;rev3=PROPOSED-plans-3a-creation-caps;basis=root-input; console SQL counting the account's own apps rows for the creation caps, as its store siblings (J4). file:Query@83 ed8bb63453ac4e36d685659f77c87c6fa8c880ae113f37f1378b84acddec5af0 |
| +internal/console/apicli.go Server.handleAPICreateApp legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:Account@3000,whole:Fleet@2988 7f12ab30b790991160ba2127958e4a45eac4fd14f93830680002291f47976c95 |
| +@coverage internal/console 393989f1998a6d9ed7699ad1fb04280c22016c8a02a802d9582e04960806fa24 |