terp-cli 0.36.0__tar.gz → 0.37.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. {terp_cli-0.36.0 → terp_cli-0.37.0}/PKG-INFO +9 -9
  2. {terp_cli-0.36.0 → terp_cli-0.37.0}/pyproject.toml +9 -9
  3. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/__init__.py +153 -17
  4. terp_cli-0.37.0/src/terp/cli/api_contract.py +385 -0
  5. terp_cli-0.37.0/src/terp/cli/api_contract_bounds.py +70 -0
  6. terp_cli-0.37.0/src/terp/cli/api_contract_check.py +155 -0
  7. terp_cli-0.37.0/src/terp/cli/api_contract_schemas.py +458 -0
  8. terp_cli-0.37.0/src/terp/cli/api_contract_shapes.py +294 -0
  9. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/openapi.py +58 -8
  10. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/_common.py +9 -4
  11. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/cli.py +7 -2
  12. terp_cli-0.37.0/src/terp/cli/release/gate.py +293 -0
  13. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/manifest.py +20 -2
  14. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/sign.py +12 -8
  15. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/reseal.py +132 -37
  16. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/service_accounts.py +5 -5
  17. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/verify.py +116 -22
  18. terp_cli-0.36.0/src/terp/cli/release/gate.py +0 -146
  19. {terp_cli-0.36.0 → terp_cli-0.37.0}/.gitignore +0 -0
  20. {terp_cli-0.36.0 → terp_cli-0.37.0}/signer/Dockerfile +0 -0
  21. {terp_cli-0.36.0 → terp_cli-0.37.0}/signer/terp-arch-releases.json +0 -0
  22. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/_appref.py +0 -0
  23. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/_engine.py +0 -0
  24. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/_output.py +0 -0
  25. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/_subjects.py +0 -0
  26. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/access.py +0 -0
  27. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/apidocs.py +0 -0
  28. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/authz_surface.py +0 -0
  29. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/capabilities.py +0 -0
  30. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/deploy_safety.py +0 -0
  31. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/deployment_adopt.py +0 -0
  32. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/deployment_app.py +0 -0
  33. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/deployment_check.py +0 -0
  34. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/deployment_declare.py +0 -0
  35. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/deployment_render.py +0 -0
  36. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/dev.py +0 -0
  37. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/docker.py +0 -0
  38. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/docker_masks.py +0 -0
  39. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/envfile.py +0 -0
  40. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/envrequired.py +0 -0
  41. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/envschema.py +0 -0
  42. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/envseams.py +0 -0
  43. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/fmt.py +0 -0
  44. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/grants.py +0 -0
  45. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/heartbeat.py +0 -0
  46. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/jobs.py +0 -0
  47. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/leases.py +0 -0
  48. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/locks.py +0 -0
  49. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/module_roles.py +0 -0
  50. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/outbox.py +0 -0
  51. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/ports.py +0 -0
  52. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/profiles.py +0 -0
  53. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/py.typed +0 -0
  54. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/__init__.py +0 -0
  55. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/_compare.py +0 -0
  56. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/_dockerfile.py +0 -0
  57. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/_git.py +0 -0
  58. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/_image.py +0 -0
  59. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/_locks.py +0 -0
  60. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/_plan.py +0 -0
  61. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/_registry.py +0 -0
  62. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/_render.py +0 -0
  63. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/_sandbox.py +0 -0
  64. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/build.py +0 -0
  65. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/dsse.py +0 -0
  66. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/promote.py +0 -0
  67. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/routes.py +0 -0
  68. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/scaffold.py +0 -0
  69. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/schema.py +0 -0
  70. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/seed.py +0 -0
  71. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/smoke.py +0 -0
  72. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/users.py +0 -0
  73. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/version.py +0 -0
  74. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/workbench.py +0 -0
  75. {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/workbench_adopt.py +0 -0
@@ -1,23 +1,23 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: terp-cli
3
- Version: 0.36.0
3
+ Version: 0.37.0
4
4
  Summary: Terp command-line tool — inspect, scaffolding, migrations, checks, api-docs.
5
5
  Project-URL: Repository, https://github.com/AITT-NL/terp-framework
6
6
  Project-URL: Changelog, https://github.com/AITT-NL/terp-framework/blob/main/CHANGELOG.md
7
7
  License-Expression: Apache-2.0
8
8
  Requires-Python: >=3.13
9
9
  Requires-Dist: pyyaml>=6.0
10
- Requires-Dist: terp-arch==0.36.0
11
- Requires-Dist: terp-core==0.36.0
12
- Requires-Dist: terp-migrations==0.36.0
10
+ Requires-Dist: terp-arch==0.37.0
11
+ Requires-Dist: terp-core==0.37.0
12
+ Requires-Dist: terp-migrations==0.37.0
13
13
  Provides-Extra: jobs
14
- Requires-Dist: terp-cap-outbox==0.36.0; extra == 'jobs'
15
- Requires-Dist: terp-cap-scheduler-apscheduler==0.36.0; extra == 'jobs'
14
+ Requires-Dist: terp-cap-outbox==0.37.0; extra == 'jobs'
15
+ Requires-Dist: terp-cap-scheduler-apscheduler==0.37.0; extra == 'jobs'
16
16
  Provides-Extra: release
17
17
  Requires-Dist: cryptography>=46; extra == 'release'
18
18
  Requires-Dist: packaging>=24; extra == 'release'
19
- Requires-Dist: terp-cap-egress==0.36.0; extra == 'release'
19
+ Requires-Dist: terp-cap-egress==0.37.0; extra == 'release'
20
20
  Provides-Extra: scheduler
21
- Requires-Dist: terp-cap-scheduler-apscheduler==0.36.0; extra == 'scheduler'
21
+ Requires-Dist: terp-cap-scheduler-apscheduler==0.37.0; extra == 'scheduler'
22
22
  Provides-Extra: worker
23
- Requires-Dist: terp-cap-outbox==0.36.0; extra == 'worker'
23
+ Requires-Dist: terp-cap-outbox==0.37.0; extra == 'worker'
@@ -4,18 +4,18 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "terp-cli"
7
- version = "0.36.0"
7
+ version = "0.37.0"
8
8
  description = "Terp command-line tool — inspect, scaffolding, migrations, checks, api-docs."
9
9
  requires-python = ">=3.13"
10
10
  license = "Apache-2.0"
11
11
  dependencies = [
12
- "terp-core==0.36.0",
12
+ "terp-core==0.37.0",
13
13
  # `terp migrate` delegates to terp-migrations (lazily imported), keeping Alembic
14
14
  # off the path for `terp inspect` / `terp guide` while making migrations work.
15
- "terp-migrations==0.36.0",
15
+ "terp-migrations==0.37.0",
16
16
  # `terp guide rules` projects the live rule registry; terp-arch is imported lazily
17
17
  # (only when that topic is rendered), so it stays off the common `terp guide` path.
18
- "terp-arch==0.36.0",
18
+ "terp-arch==0.37.0",
19
19
  # `terp verify --only env-seams` and `terp smoke` read the compose profiles as data
20
20
  # (anchors and `<<:` merge keys resolved), so both answer without a Docker daemon or
21
21
  # even the `docker` binary. Declared explicitly rather than leant on: every app
@@ -29,11 +29,11 @@ dependencies = [
29
29
  # or a scheduler engine into apps that only use inspect/check/migrate. Deployments can select
30
30
  # one process role, while `jobs` is the convenient complete jobs-process bundle.
31
31
  [project.optional-dependencies]
32
- worker = ["terp-cap-outbox==0.36.0"]
33
- scheduler = ["terp-cap-scheduler-apscheduler==0.36.0"]
32
+ worker = ["terp-cap-outbox==0.37.0"]
33
+ scheduler = ["terp-cap-scheduler-apscheduler==0.37.0"]
34
34
  jobs = [
35
- "terp-cap-outbox==0.36.0",
36
- "terp-cap-scheduler-apscheduler==0.36.0",
35
+ "terp-cap-outbox==0.37.0",
36
+ "terp-cap-scheduler-apscheduler==0.37.0",
37
37
  ]
38
38
  # `terp release` and `terp promote` (ADR 0187): the DSSE signature (cryptography), the lock's
39
39
  # markers (packaging), and the one declared way to a registry, an index and the vulnerability
@@ -42,7 +42,7 @@ jobs = [
42
42
  release = [
43
43
  "cryptography>=46",
44
44
  "packaging>=24",
45
- "terp-cap-egress==0.36.0",
45
+ "terp-cap-egress==0.37.0",
46
46
  ]
47
47
 
48
48
  [project.scripts]
@@ -41,7 +41,7 @@ from terp.cli.jobs import (
41
41
  run_scheduler_command,
42
42
  run_worker_command,
43
43
  )
44
- from terp.cli.openapi import _load_app, export_openapi
44
+ from terp.cli.openapi import _load_app, export_openapi, run_openapi_command
45
45
  from terp.cli.routes import run_routes_command
46
46
  from terp.cli.profiles import DEFAULT_PROFILE, profile_names
47
47
  from terp.cli.scaffold import new_module, new_module_message
@@ -82,21 +82,24 @@ _GUIDE_TOPICS: dict[str, str] = {
82
82
  Add a module (the "10-minute module")
83
83
 
84
84
  1) models.py table model (inherit BaseTable; never redeclare id/created_at/version)
85
- from terp.core import BaseTable
85
+ from datetime import datetime
86
+ from terp.core import BaseTable, UtcDateTime
86
87
  from sqlmodel import Field
87
88
  class Invoice(BaseTable, table=True):
88
89
  number: str = Field(max_length=50, index=True, unique=True)
89
90
  amount_cents: int
91
+ issued_at: datetime = Field(sa_type=UtcDateTime()) # an instant, stored as UTC
90
92
 
91
93
  2) schemas.py DTOs (cap every input string; the Read DTO is what the API returns)
92
94
  from terp.core import BaseSchema, BaseUpdateSchema
93
95
  class InvoiceCreate(BaseSchema):
94
96
  number: str = Field(max_length=50)
95
97
  amount_cents: int
98
+ issued_at: datetime
96
99
  class InvoiceUpdate(BaseUpdateSchema): # `version` is required (optimistic concurrency)
97
100
  amount_cents: int | None = None
98
101
  class InvoiceRead(BaseSchema): # NOT the table model
99
- id: uuid.UUID; number: str; amount_cents: int; version: int
102
+ id: uuid.UUID; number: str; amount_cents: int; issued_at: datetime; version: int
100
103
 
101
104
  3) service.py business logic (CRUD is inherited and audited)
102
105
  from terp.core import BaseService
@@ -133,6 +136,9 @@ Then the codegen chain, in this order — each step reads what the one before it
133
136
 
134
137
  `terp verify` runs the drift halves of that chain and names the command to re-run when
135
138
  something is stale, so it is the one to reach for if you are unsure what is out of date.
139
+ An API-only app (rendered with the template's `frontend: false`, ADR 0202) has no
140
+ frontend: its chain is `terp migrate make`, `terp openapi` and `terp check`, and
141
+ `terp new module` writes no frontend slot where there is none.
136
142
  Policy.default() = authenticated; read VIEWER, write EDITOR.
137
143
 
138
144
  FORMATTING: `terp fmt`, NOT `ruff format .`
@@ -339,11 +345,15 @@ Both are comma-separated. The procedure, in this order:
339
345
  1. Put the old key in BOTH lists and the new one in SECRET_KEY. Deploy.
340
346
  2. Run `terp secrets reseal` where the app runs (its own container, its own
341
347
  environment). It rewrites every value sealed under a fallback under SECRET_KEY,
348
+ one nested in a JSON column included (a queued mail in the outbox, ADR 0199),
342
349
  one transaction per table, refuses while a migration is pending, is safe to run
343
350
  twice, and exits 0 only when nothing is left. `--check` counts and writes nothing.
344
351
  3. Drop SECRET_KEY_DECRYPT_FALLBACKS once reseal reports nothing left. Keep the old
345
352
  key itself while a backup sealed under it may still be restored: restoring one
346
- means listing it again and running reseal.
353
+ means listing it again and running reseal. A self-service link already mailed is
354
+ sealed under the key it was sent under, where reseal cannot reach it, so dropping
355
+ the fallback sooner than its lifetime (a day for a sign-up link by default) only
356
+ makes such a link stop working, and its owner asks for a new one.
347
357
  4. Drop SECRET_KEY_FALLBACKS once the longest signature lifetime has passed since
348
358
  step 1: REFRESH_TOKEN_TTL_SECONDS if the app issues refresh sessions, 15 minutes
349
359
  if not. Dropping it sooner loses nothing; it signs out sessions still on the old key.
@@ -991,6 +1001,58 @@ Object-level (per-row) authorization (OwnedMixin)
991
1001
  apply_object_authz does not restrict a row with no ownership trait, so the job body
992
1002
  itself would run fine. The clause is a judgement about the DESIGN, enforced before the
993
1003
  app serves anything -- not a description of what the write boundary would do.
1004
+ """,
1005
+ "aggregates": """\
1006
+ Aggregates: counts and sums declared on the service, like filters (ADR 0204)
1007
+
1008
+ - The problem: a statistics screen counts and sums the rows its lists read. A hand-built
1009
+ select(func.count(...)).group_by(...) over a soft-delete or tenant model is what
1010
+ reads_use_base_query refuses, and the workaround (an aggregate over a subquery of
1011
+ base_query()) makes every module solve calendar days, bounds and grouping on its own.
1012
+ - Declare what may be counted and summed, and by what, beside filterable and sortable:
1013
+ from terp.core import Dimension, FilterField, Measure, TimeBucket
1014
+ class InvoiceService(BaseService[Invoice, InvoiceCreate, InvoiceUpdate]):
1015
+ model = Invoice
1016
+ filterable = (FilterField("customer_id", Invoice.customer_id),)
1017
+ measures = (Measure("invoices"), Measure("revenue", Invoice.amount, op="sum"))
1018
+ dimensions = (Dimension("status", Invoice.status),)
1019
+ time_buckets = (TimeBucket("issued", Invoice.issued_at, steps=("day", "month")),)
1020
+ op is one of count, count_distinct, sum, avg, min, max. A dimension groups by a
1021
+ column's own values; an expression is a column the model declares, or a database view.
1022
+ - The route takes the capped query and returns a page typed by the declarations:
1023
+ from terp.core import Page, SessionDep, StatisticsQueryDep
1024
+ @router.get("/statistics", response_model=Page[InvoiceService.statistics_model()])
1025
+ def invoice_statistics(query: StatisticsQueryDep, session: SessionDep, customer_id: uuid.UUID | None = None):
1026
+ return InvoiceService().aggregate_page(session, query, filters={"customer_id": customer_id})
1027
+ The caller asks ?measures=invoices&measures=revenue&by=status&sort=-revenue, and for a
1028
+ series &bucket=issued&start=2026-01-01&end=2026-12-31&zone=Europe/Amsterdam&step=month.
1029
+ - Each group carries its dimensions, its measures and, with a step, its series: one point
1030
+ per calendar step of the zone, empty steps counting zero, each with its local date (label
1031
+ a step by date, not by re-zoning start). total counts the groups, so "the top eight" is
1032
+ limit=8 and the rest are still counted. A Numeric sum arrives as a decimal string.
1033
+ - A day is the zone's own, so a day that daylight saving shortens keeps its real length. A
1034
+ week starts on Monday; a series may start or end part way through a week or month.
1035
+ - Everything undeclared is refused with a 400 that lists what is declared: a measure, a
1036
+ dimension, a time bucket, a step, a sort key, a filter. A page holds at most max_groups
1037
+ (100); a series at most the bucket's max_buckets (366); a page at most max_points (10,000)
1038
+ groups times steps. Each cap refuses, never clamps, and names what would fit.
1039
+ - Personal data stays personal. A Dimension or a TimeBucket over a field declared
1040
+ Personal(...), and a sum, avg, min or max over one, is refused when the class is defined:
1041
+ over a group of one each is that person's value. count and count_distinct may run over
1042
+ personal data. A statistic is never narrowed by a filter over a personal field (counts
1043
+ under a contains filter would spell out an address); the list keeps its filter.
1044
+ - A read that is not a service's (a capability's own table) uses the same seam directly:
1045
+ from terp.core import Statistics, aggregate, group_model
1046
+ STATS = Statistics(measures=(Measure("events"),), dimensions=(...,), time_buckets=(...,))
1047
+ groups, total = aggregate(session, select(AuditEvent), STATS, measures=["events"], ...)
1048
+ - The runtime half: the request session scopes every ORM read, aggregates included, and
1049
+ refuses with UnscopedReadError (a 500) a SELECT that reads a row-scoped table through Core
1050
+ - Model.__table__.c.x, a Core join or subquery over it, a Core alias, table("name"),
1051
+ literal SQL in a FROM - because there is no mapped entity in that SELECT to attach the
1052
+ scope to. A many-to-many whose link table is row-scoped is refused the same way: map the
1053
+ link as an association object.
1054
+ - On SQLite a timestamp is stored as its wall time without its offset: write instants in UTC
1055
+ (every timestamp the framework stamps already is) and the steps are exact on both dialects.
994
1056
  """,
995
1057
  "leases": """\
996
1058
  Leases: expiring, fenced custody of work (leases capability)
@@ -1100,9 +1162,27 @@ The security declaration (SecurityConfig, refused at production boot)
1100
1162
  /openapi.json are hidden there because a production API's full schema is not public
1101
1163
  information. Development always serves them, and `terp openapi` exports the document
1102
1164
  either way - so turning this on is a choice to publish, not a way to get the file.
1165
+ Commit that file instead: `openapi.json` is the app's API contract, the one a mobile
1166
+ app or a site in another repository pins, and `terp verify --only api-contract` holds
1167
+ it to the API the app serves, naming every change to its shape, the ones that break a
1168
+ client first (ADR 0194). Who may call a route is not in it: authz-surface pins that.
1103
1169
  - headers ships a safe SecurityHeaders set; max_request_bytes (1 MiB) is the body cap and
1104
1170
  is per-mount overridable (ADR 0067); request_id_header names the correlation header
1105
1171
  ("X-Request-ID") echoed on every response.
1172
+ - A NATIVE MOBILE CLIENT stays signed in through its own routes, opt-in with a reason
1173
+ (ADR 0195): build_login_module(..., native_clients=NativeClients(reason="...")) mounts
1174
+ POST /api/v1/auth/native/login, /native/refresh and /native/logout. The first two answer
1175
+ the refresh token in the JSON body and run the same rotation, reuse detection and
1176
+ revocation as the cookie, its rotation grace window included; /native/logout takes the
1177
+ token and revokes its whole family without minting a successor. The client keeps that
1178
+ token in the OS keychain or keystore and nowhere else, and treats a 401 from
1179
+ /native/refresh as signed out. All three refuse any request with browser provenance
1180
+ (Origin, any Sec-Fetch-* header, the refresh cookie) with 403 native_client_only before
1181
+ the body is validated, so a browser stays on /login, /refresh and /logout. A HYBRID app
1182
+ (a page inside a native shell) calls them from its native layer and hands its page only
1183
+ access tokens: a page that holds the refresh token has given up what the cookie
1184
+ protects. No route that mints a credential (/login, /token, /refresh, /native/login,
1185
+ /native/refresh) accepts an Idempotency-Key; retry without one.
1106
1186
  - No terp.arch check applies - there is no module code shape to police. Enforcement is
1107
1187
  the create_app production fail-fast plus the production-readiness verify lane.
1108
1188
  """,
@@ -1213,6 +1293,21 @@ Password strength (PasswordPolicy, Tier-B)
1213
1293
  The reason is the opt-out: it is stored as `relaxed_reason` and named back to you in the
1214
1294
  BootError, so "why is this off" always has an answer that travels with the declaration.
1215
1295
  PasswordPolicy.relaxed(reason="legacy bulk import")
1296
+ - SELF-SERVICE accounts (ADR 0196) hold sign-up and reset to the same policy. They are
1297
+ opt-in and never discovered: after configure_mail(...), build the module and mount it
1298
+ build_accounts_module(SelfService(reason="members open their own accounts",
1299
+ users=users_service, throttle_store=shared_store,
1300
+ link_base="https://app.example.com"))
1301
+ for POST /api/v1/accounts/sign-up, /verify, /password-reset, /password-reset/confirm
1302
+ and /me/close. Sign-up takes only the address: the account opens when the mailed token
1303
+ is redeemed at /verify with the password, so whoever asked cannot choose it. Every
1304
+ request about an address answers 202 the same way and sends that address exactly one
1305
+ message, whether or not it has an account, and addresses compare case-insensitively.
1306
+ Tokens are sealed, expire, work once, and ride the URL fragment, so your account pages
1307
+ read them from location.hash and post them. Closing needs the password again and ends
1308
+ every session (AccountDeletion.SCRUB by default, or REMOVE: building the module refuses
1309
+ it while a reference to the user table would refuse the delete, so declare CASCADE or
1310
+ SET NULL on that reference; one the build cannot see is scrubbed instead, with a warning).
1216
1311
  - No terp.arch check applies (no module code shape to police) — enforcement is the
1217
1312
  service chokepoint plus the create_app production fail-fast.
1218
1313
  """,
@@ -1716,11 +1811,35 @@ File objects (files capability, ADR 0056/0057)
1716
1811
  objects) in a NAMED-PROFILE registry;
1717
1812
  metadata (name, type, size, sha256, storage_key, storage_profile) lives in the platform
1718
1813
  DB. storage_key/storage_profile never leave the boundary (FileRead omits both).
1719
- - Any provider is an adapter subclass (local ships; S3/Azure/NAS are each a
1720
- StorageBackend). Register each store once at the composition root:
1721
- register_storage_backend("azure-invoices", AzureBlobStorage(container="invoices"))
1722
- register_storage_backend("azure-hr", AzureBlobStorage(container="hr"))
1723
- Keep credentials in settings / sealed config — never in module code.
1814
+ - Any provider is an adapter subclass (local ships; a NAS mount is the local adapter on
1815
+ another root). Two object-store adapters ship behind extras (ADR 0201): S3Storage
1816
+ (terp-cap-files[s3]) takes the SDK's own client and a bucket; AzureBlobStorage
1817
+ (terp-cap-files[azure]) takes the SDK's ContainerClient and, for user-delegation SAS
1818
+ links, a BlobServiceClient under an Entra credential. Register each store once at the
1819
+ composition root:
1820
+ register_storage_backend("s3-media", S3Storage(boto3.client("s3"), bucket="app-media"))
1821
+ register_storage_backend("azure-invoices", AzureBlobStorage(invoices_container))
1822
+ register_storage_backend("azure-hr", AzureBlobStorage(hr_container, delegation=service))
1823
+ Keep credentials in settings / sealed config — never in module code. Building the Azure
1824
+ adapter without its extra fails at boot with the install line (StorageSdkMissingError);
1825
+ the S3 adapter takes the SDK's client, so a missing extra fails where that is built.
1826
+ - A client that cannot send a bearer (an <audio>/<video> element, a PDF viewer, a native
1827
+ client's media stack) gets a short-lived LINK to one file's bytes instead (ADR 0201):
1828
+ GET /api/v1/files/{id}/link?ttl_seconds=600&inline=true -> {url, expires_at}
1829
+ The link is minted where a download is authorized (row scope, then quarantine), by the
1830
+ store the ROW names: an S3 presigned GET or an Azure SAS where the store can sign, else
1831
+ an app-signed token the capability serves itself at GET /api/v1/files/link/{token} —
1832
+ its one public route, rate-limited, answering 404 to an expired, altered or foreign
1833
+ token. The default lifetime is an hour, the longest 24 hours; a link is not single-use
1834
+ (a player makes many range requests), its expiry is the control. inline is honoured only
1835
+ for a type a browser renders without running it (image, audio, video, PDF — not an XML
1836
+ image such as SVG, which is a document with script in it). From a module, mint through
1837
+ your own authorized row and a declared FileRef:
1838
+ link = FileService().link_for(session, invoice, "attachment_file_id", ttl=timedelta(minutes=10))
1839
+ Both content routes honour one bytes= Range (206 / 416); the local adapter seeks, an
1840
+ object store's stream is read forward. A link from a store that signs never streams
1841
+ through the API; the authenticated content route still serves any store's bytes to a
1842
+ caller who asks it to.
1724
1843
  - Pick the store per module (subclass default) or per call — never from a client:
1725
1844
  class InvoiceFileService(FileService):
1726
1845
  storage_profile = "azure-invoices"
@@ -1924,6 +2043,11 @@ Sending e-mail (terp-cap-mail)
1924
2043
  dead-letters` lists it. With the
1925
2044
  in-process default queue (no worker) the mail is sent inline, and a failing relay
1926
2045
  fails the request that asked for it.
2046
+ - The queued message is SEALED at rest (ADR 0199): the outbox row's payload holds the
2047
+ Message-ID and one value sealed under SECRET_KEY, never an address or the text. After a
2048
+ key rotation keep SECRET_KEY_DECRYPT_FALLBACKS until `terp secrets reseal` reports
2049
+ nothing left: a queued mail no configured key opens is never sent, and fails with
2050
+ MailSealError, which names that remedy, until it dead-letters.
1927
2051
  - Test without a relay:
1928
2052
  from terp.capabilities.mail import (
1929
2053
  CapturingMailTransport, MailSettings, configure_mail, reset_mail)
@@ -2757,7 +2881,12 @@ FOUR STATES, EXACTLY ONE AT A TIME
2757
2881
  "build": "generated" the framework renders Dockerfile.prod,
2758
2882
  frontend/Dockerfile.prod and docker-compose.prod.yml
2759
2883
  from this file (`terp deployment render`), and a
2760
- committed copy that is not the render is refused
2884
+ committed copy that is not the render is refused.
2885
+ frontend/Dockerfile.prod only beside a web unit: an api
2886
+ unit with no web unit in front of it is the deployment's
2887
+ only way in and publishes ${API_PORT:-23200}:8000 (ADR
2888
+ 0202) -- the shape an app rendered API-only (the
2889
+ template's `frontend: false`) has from the start
2761
2890
  {"unmanaged": true, "reason"} no tool can drive this deploy; the checks are off and
2762
2891
  a tool shows the reason. Nothing may stand beside it.
2763
2892
 
@@ -2854,7 +2983,8 @@ or an override; externalDatabase beside a database unit; a database or store wit
2854
2983
  or two of a kind; a one-shot that declares `terp migrate upgrade` (drop the command: the
2855
2984
  one-shot without one is the migration step) or a second step; a role with no command of
2856
2985
  its own and none declared; a web unit with a command, with no single api to proxy to, or
2857
- without frontend/package-lock.json; an api without both health paths; a start-order cycle
2986
+ without frontend/package-lock.json; two or more api units with no web unit in front of
2987
+ them (which one callers reach would be a guess); an api without both health paths; a start-order cycle
2858
2988
  or a unit it names that is not declared; a volume without its mount, mounted anywhere but
2859
2989
  var/files, on a unit not on the backend image, or on a unit that already mounts another
2860
2990
  volume there (Compose would mount only the last of them, without a word); any system
@@ -3000,14 +3130,20 @@ build container holds a credential or writes into what is built. Each takes the
3000
3130
  <registry>/<image>:<tag> with the credential your docker configuration holds for this
3001
3131
  release. It writes terp-release-build.json: BuildKit's own digests.
3002
3132
  uv run terp release gate --sha <sha> --build terp-release-build.json
3003
- runs in a clean checkout of the commit: `terp verify --profile release --format
3004
- assurance` with npm's scripts off, then the schedule catalog, read by running the pushed
3005
- image sealed (no network, read-only, its own non-root user). It holds no push credential
3006
- and no key. It writes terp-release-gate.json.
3133
+ runs in a clean checkout of the commit: `terp verify --profile release` once, with npm's
3134
+ scripts off, recording every check's verdict and the release-assurance claim those same
3135
+ results compose (no check's output), then the schedule catalog, read by running the
3136
+ pushed image sealed (no network, read-only, its own non-root user). It holds no push
3137
+ credential and no key. It writes terp-release-gate.json, and exits 1 unless every check
3138
+ passed and the claim holds, naming each red check. Run it from the commit's own
3139
+ environment: sign reads the document only in the version the commit's pinned terp-cli
3140
+ writes (ADR 0200).
3007
3141
  terp release sign --sha <sha> --tag <tag> --registry ... --build ... --gate ... \\
3008
3142
  --hosts separated --signer '["your-kms-signer", "--key", "release"]' --public-key k.pub
3009
3143
  runs from the signer image, never the commit's own toolchain, and runs nothing of the
3010
- app: it repeats the refusals, renders a generated app's files with the terp-arch the
3144
+ app: it repeats the refusals, refuses a gate with a red check or a claim that does not
3145
+ hold (ADR 0200; a commit pinning terp-cli below 0.36.1 has a gate that records the claim
3146
+ alone, and is held to it), renders a generated app's files with the terp-arch the
3011
3147
  commit pins (fetched by hash, run in a process of its own), reads the backend image from
3012
3148
  the registry as data and holds it to the tree and the lock, scans for known
3013
3149
  vulnerabilities and for secrets in the history, and signs the manifest as a DSSE envelope
@@ -5182,7 +5318,7 @@ def main(argv: Sequence[str] | None = None) -> None:
5182
5318
  emit(f"wrote {path}")
5183
5319
  return
5184
5320
  if args.command == "openapi":
5185
- emit(f"wrote {export_openapi(args.app, out=args.out, app_root=args.app_root)}")
5321
+ emit(run_openapi_command(args.app, out=args.out, app_root=args.app_root))
5186
5322
  return
5187
5323
  if args.command == "routes":
5188
5324
  emit(