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.
- {terp_cli-0.36.0 → terp_cli-0.37.0}/PKG-INFO +9 -9
- {terp_cli-0.36.0 → terp_cli-0.37.0}/pyproject.toml +9 -9
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/__init__.py +153 -17
- terp_cli-0.37.0/src/terp/cli/api_contract.py +385 -0
- terp_cli-0.37.0/src/terp/cli/api_contract_bounds.py +70 -0
- terp_cli-0.37.0/src/terp/cli/api_contract_check.py +155 -0
- terp_cli-0.37.0/src/terp/cli/api_contract_schemas.py +458 -0
- terp_cli-0.37.0/src/terp/cli/api_contract_shapes.py +294 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/openapi.py +58 -8
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/_common.py +9 -4
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/cli.py +7 -2
- terp_cli-0.37.0/src/terp/cli/release/gate.py +293 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/manifest.py +20 -2
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/sign.py +12 -8
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/reseal.py +132 -37
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/service_accounts.py +5 -5
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/verify.py +116 -22
- terp_cli-0.36.0/src/terp/cli/release/gate.py +0 -146
- {terp_cli-0.36.0 → terp_cli-0.37.0}/.gitignore +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/signer/Dockerfile +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/signer/terp-arch-releases.json +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/_appref.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/_engine.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/_output.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/_subjects.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/access.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/apidocs.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/authz_surface.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/capabilities.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/deploy_safety.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/deployment_adopt.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/deployment_app.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/deployment_check.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/deployment_declare.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/deployment_render.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/dev.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/docker.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/docker_masks.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/envfile.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/envrequired.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/envschema.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/envseams.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/fmt.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/grants.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/heartbeat.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/jobs.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/leases.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/locks.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/module_roles.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/outbox.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/ports.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/profiles.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/py.typed +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/__init__.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/_compare.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/_dockerfile.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/_git.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/_image.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/_locks.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/_plan.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/_registry.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/_render.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/_sandbox.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/build.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/dsse.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/release/promote.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/routes.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/scaffold.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/schema.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/seed.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/smoke.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/users.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/version.py +0 -0
- {terp_cli-0.36.0 → terp_cli-0.37.0}/src/terp/cli/workbench.py +0 -0
- {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.
|
|
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.
|
|
11
|
-
Requires-Dist: terp-core==0.
|
|
12
|
-
Requires-Dist: terp-migrations==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.
|
|
15
|
-
Requires-Dist: terp-cap-scheduler-apscheduler==0.
|
|
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.
|
|
19
|
+
Requires-Dist: terp-cap-egress==0.37.0; extra == 'release'
|
|
20
20
|
Provides-Extra: scheduler
|
|
21
|
-
Requires-Dist: terp-cap-scheduler-apscheduler==0.
|
|
21
|
+
Requires-Dist: terp-cap-scheduler-apscheduler==0.37.0; extra == 'scheduler'
|
|
22
22
|
Provides-Extra: worker
|
|
23
|
-
Requires-Dist: terp-cap-outbox==0.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
33
|
-
scheduler = ["terp-cap-scheduler-apscheduler==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
|
-
"terp-cap-scheduler-apscheduler==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.
|
|
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
|
|
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;
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
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;
|
|
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
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
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,
|
|
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(
|
|
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(
|