terp-cli 0.3.0__tar.gz → 0.5.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.
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: terp-cli
3
+ Version: 0.5.0
4
+ Summary: Terp command-line tool — inspect, scaffolding, migrations, checks, api-docs.
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: terp-arch==0.5.0
8
+ Requires-Dist: terp-core==0.5.0
9
+ Requires-Dist: terp-migrations==0.5.0
10
+ Provides-Extra: jobs
11
+ Requires-Dist: terp-cap-outbox==0.5.0; extra == 'jobs'
12
+ Requires-Dist: terp-cap-scheduler-apscheduler==0.5.0; extra == 'jobs'
13
+ Provides-Extra: scheduler
14
+ Requires-Dist: terp-cap-scheduler-apscheduler==0.5.0; extra == 'scheduler'
15
+ Provides-Extra: worker
16
+ Requires-Dist: terp-cap-outbox==0.5.0; extra == 'worker'
@@ -4,29 +4,29 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "terp-cli"
7
- version = "0.3.0"
7
+ version = "0.5.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.3.0",
12
+ "terp-core==0.5.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.3.0",
15
+ "terp-migrations==0.5.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.3.0",
18
+ "terp-arch==0.5.0",
19
19
  ]
20
20
 
21
21
  # Job-process adapters stay optional: the base CLI must not install the table-owning outbox
22
22
  # or a scheduler engine into apps that only use inspect/check/migrate. Deployments can select
23
23
  # one process role, while `jobs` is the convenient complete jobs-process bundle.
24
24
  [project.optional-dependencies]
25
- worker = ["terp-cap-outbox==0.3.0"]
26
- scheduler = ["terp-cap-scheduler-apscheduler==0.3.0"]
25
+ worker = ["terp-cap-outbox==0.5.0"]
26
+ scheduler = ["terp-cap-scheduler-apscheduler==0.5.0"]
27
27
  jobs = [
28
- "terp-cap-outbox==0.3.0",
29
- "terp-cap-scheduler-apscheduler==0.3.0",
28
+ "terp-cap-outbox==0.5.0",
29
+ "terp-cap-scheduler-apscheduler==0.5.0",
30
30
  ]
31
31
 
32
32
  [project.scripts]
@@ -18,6 +18,7 @@ from terp.cli.access import (
18
18
  render_access_graph,
19
19
  )
20
20
  from terp.cli.apidocs import api_docs
21
+ from terp.cli.capabilities import render_capabilities
21
22
  from terp.cli.dev import dev_plan, run_dev_command
22
23
  from terp.cli.docker import run_docker_dev_command
23
24
  from terp.cli.jobs import (
@@ -36,6 +37,12 @@ from terp.cli.schema import (
36
37
  scan_declared_table_models,
37
38
  )
38
39
  from terp.cli.seed import run_seed_command
40
+ from terp.cli.grants import (
41
+ grant_add_command,
42
+ grant_list_command,
43
+ grant_revoke_command,
44
+ )
45
+ from terp.cli.service_accounts import create_service_account_command
39
46
  from terp.cli.users import create_user_command
40
47
  from terp.cli.verify import profile_ids, run_verify_command, verify_manifest
41
48
 
@@ -74,6 +81,49 @@ Add a module (the "10-minute module")
74
81
  module = ModuleSpec(name="invoices", router=router, policy=Policy.default())
75
82
 
76
83
  Then run `terp check`. Policy.default() = authenticated; read VIEWER, write EDITOR.
84
+ """,
85
+ "dependencies": """\
86
+ One module needs another (declared edges)
87
+
88
+ Modules are independent by default: an import of a sibling is refused. When a real
89
+ dependency exists, DECLARE it — do not hand-roll dependency inversion.
90
+
91
+ 1) Name the sibling in the DEPENDING module's manifest:
92
+ # app/modules/catalogs/module.py
93
+ module = ModuleSpec(
94
+ name="catalogs",
95
+ router=router,
96
+ policy=Policy.default(),
97
+ requires=("connections",), # <- the edge, in the manifest a reader consults
98
+ )
99
+
100
+ 2) Now import its published surface:
101
+ from app.modules.connections.service import ConnectionProfileService
102
+
103
+ Three limits, each enforced:
104
+
105
+ - DECLARED. An undeclared sibling import fails `no_cross_module_imports`. The point
106
+ is not to make coupling hard, it is to make coupling VISIBLE.
107
+ - PUBLIC SURFACE ONLY. An edge grants models / schemas / service / events. Never the
108
+ dependency's router (that couples two modules through HTTP shapes and lets an
109
+ in-process call walk past the policy guarding those routes) and never an
110
+ underscore-prefixed internal. Import a named surface, not the bare package.
111
+ - ONE-WAY. The declared graph must be acyclic — `terp check` reports the cycle and
112
+ the app refuses to boot on one. A cycle means the two modules are really one.
113
+
114
+ When NOT to declare an edge:
115
+
116
+ - You only need to REACT to the other module -> subscribe to its event
117
+ (`terp guide events`). An event keeps the direction one-way and the coupling loose.
118
+ - BOTH directions want an edge -> the shared concept belongs in a third module that
119
+ both depend on. Inverting one direction by hand (a Protocol plus a global registry
120
+ plus a composition-root adapter) is the same coupling with more moving parts and
121
+ module-global mutable state; declare the edge or extract the concept.
122
+ - The dependency is a capability (audit, files, users) -> that is not a module edge.
123
+ `requires=("audit",)` still means "must be installed"; capabilities are importable
124
+ by every module already.
125
+
126
+ Check it: `terp check` | See the declared edges: `terp inspect control-plane`
77
127
  """,
78
128
  "service": """\
79
129
  Services (BaseService)
@@ -86,12 +136,21 @@ Services (BaseService)
86
136
  conditions and CANNOT drop soft-delete / tenant scope:
87
137
  def business_filters(self):
88
138
  return (Invoice.status == "open",)
89
- - A per-call filter goes in a custom list() built on base_query():
90
- def list(self, session, *, skip, limit, status=None):
91
- q = self.base_query()
92
- if status is not None:
93
- q = q.where(Invoice.status == status)
94
- return self._paginate(session, q, skip=skip, limit=limit)
139
+ - A per-call filter/sort (a query parameter) is DECLARED, not hand-written. Name the
140
+ allowed columns on the service and pass the values through — an undeclared field is
141
+ refused (400), and the caller sends values, never operators:
142
+ class InvoiceService(BaseService[Invoice, InvoiceCreate, InvoiceUpdate]):
143
+ model = Invoice
144
+ filterable = (FilterField("status", Invoice.status),
145
+ FilterField("q", Invoice.title, op="contains"))
146
+ sortable = (SortField("due_on", Invoice.due_on),)
147
+ default_sort = ("-due_on",)
148
+ # route: status: str | None = None -> typed, and it shows up in OpenAPI
149
+ rows, total = service.list(session, skip=..., limit=..., filters={"status": status},
150
+ sort=sort)
151
+ None values are dropped, so an absent optional parameter needs no branching. Filters
152
+ compose ON TOP of base_query(), so row scope and business_filters() still apply —
153
+ they cannot widen a read. Don't write a list() override to do this by hand.
95
154
  - On a large table, prefer keyset pagination: a route takes CursorPaginationDep and
96
155
  returns CursorPage[ReadDTO] from self.list_by_cursor(session, pagination=...) —
97
156
  no OFFSET scan, and the exact COUNT runs only when the caller asks
@@ -128,6 +187,56 @@ Authorization (Policy)
128
187
  min_role floor AND hold the grant.
129
188
  - Route-level extra check: dependencies=[Depends(require_permission("invoices.approve"))].
130
189
  - Authority is always a typed object (Role / Permission), never a bare string.
190
+ - Choosing between a role and a permission, and the grant lifecycle: `terp guide permissions`.
191
+ """,
192
+ "permissions": """\
193
+ Permissions — when to add one, and how it reaches a subject
194
+
195
+ WHEN A PERMISSION IS THE RIGHT ANSWER
196
+ - The role ladder answers "how much of this app may you touch?" - VIEWER < EDITOR
197
+ < ADMIN, coarse and comparable. A permission answers "may you do this ONE thing?"
198
+ and is not comparable to anything.
199
+ - Add a permission when the authority is NARROWER than a role and does not belong
200
+ on the ladder: an integration that may export invoices but must not edit them; a
201
+ finance user who may approve but is not an app admin.
202
+ - Do NOT add a permission for something every EDITOR should be able to do - that is
203
+ the role, and a permission held by everyone is a checkbox nobody reads.
204
+ - Do NOT model a role as three permissions. The ladder exists so authority stays
205
+ comparable; a bag of permissions cannot answer "is this account privileged?".
206
+
207
+ DECLARE IT
208
+ from terp.core import EDITOR, Permission
209
+ APPROVE = Permission("invoices.approve", min_role=EDITOR)
210
+ min_role is a FLOOR, not a shortcut: the caller must clear it AND hold the grant.
211
+ Both checks are real - the floor stops a grant from smuggling authority to a
212
+ subject the app never intended to trust that far.
213
+ - Enforce it on the module (Policy(write=APPROVE)) or on one route
214
+ (dependencies=[Depends(require_permission("invoices.approve"))]).
215
+ - Wire the enforcer once, or the app refuses to boot (fail closed - a Permission
216
+ requirement with nothing to enforce it would silently pass):
217
+ create_app(..., permission_enforcer=terp.capabilities.access.enforce_permission)
218
+
219
+ GRANT IT
220
+ terp grant add ops@acme.test invoices.approve
221
+ terp grant add nightly-sync invoices.export # a service account, by name
222
+ terp grant list ops@acme.test # "why can it do that?"
223
+ terp grant revoke ops@acme.test invoices.approve
224
+ A subject is named the way you name it - a user email, a service-account name, or
225
+ a subject UUID. An unknown permission is refused WITH the app's catalog printed,
226
+ because a grant of a string the app never checks is a silent no-op, not a lenient
227
+ grant. Grants are stored per subject id with no foreign key, which is what lets a
228
+ user, a service account and a group all be granted the same way; a group grant
229
+ reaches every member (`terp guide access`).
230
+ - Mind the floor: granting a permission to a subject BELOW its min_role stores a row
231
+ that the rank check will shadow. `terp grant add` warns when it sees this.
232
+ - See the whole declared catalog and every module's policy:
233
+ terp inspect control-plane --app app.main:build
234
+
235
+ THE FAILURE MODE THIS EXISTS TO PREVENT
236
+ An integration needs one narrow capability, granting looks like work, so someone
237
+ makes it an admin "for now". Least privilege loses to a ten-second workaround. If
238
+ you catch yourself widening a role to unblock one call, that call wants a
239
+ permission - and the grant is one command (ADR 0089).
131
240
  """,
132
241
  "access": """\
133
242
  The access model (three layers) — profiles + the access graph
@@ -160,6 +269,8 @@ The access model (three layers) — profiles + the access graph
160
269
  write authority, and warnings (e.g. OwnedMixin gates writes only). `--format json`
161
270
  is the stable Studio contract; declare services=(InvoiceService,) on the ModuleSpec
162
271
  so the data layer is visualizable — an undeclared data layer is a warning.
272
+ - Narrowing authority below a role, and getting a permission to a subject:
273
+ `terp guide permissions` (declare it, enforce it, `terp grant add`).
163
274
  """,
164
275
  "ownership": """\
165
276
  Object-level (per-row) authorization (OwnedMixin)
@@ -279,6 +390,103 @@ Background jobs (terp.core.enqueue + JobCatalog)
279
390
  against the JobCatalog. Run schedules in-process with `terp jobs scheduler` (APScheduler;
280
391
  needs terp-cap-scheduler-apscheduler) or via Celery beat — each cron tick enqueues through
281
392
  the same typed seam, so a scheduled job stays audited + system-actor stamped.
393
+ """,
394
+ "outbox": """\
395
+ Durable post-commit delivery (outbox capability)
396
+
397
+ - The problem it solves: an in-process dispatcher runs the side effect AFTER the commit,
398
+ outside the transaction - so a crash between the two loses it, and a rollback after it
399
+ already fired published a lie. The outbox writes the INTENT to a table in the SAME
400
+ transaction as the row, and a separate worker delivers it afterwards.
401
+ - terp-cap-outbox is a LIBRARY capability: it ships no router and nothing auto-mounts.
402
+ You swap the two seams at the composition root:
403
+ create_app(specs, ..., job_queue=OutboxJobQueue(),
404
+ event_dispatcher=outbox_event_dispatcher)
405
+ It ships its own migrations, so add the dependency and run `terp migrate` before
406
+ switching the seams over.
407
+ - Nothing in a module changes. You still enqueue(session, job=..., payload=...) and
408
+ still emit events through the catalog; the seam is swapped once, centrally, so a
409
+ module never knows whether delivery is in-process or durable.
410
+ - A custom side effect joins the same atomic unit through the service's _after_write
411
+ hook (which runs inside the write transaction, before the commit):
412
+ def _after_write(self, session, entity, *, action):
413
+ enqueue(session, job=RUN_START, payload=RunStartPayload(run_id=entity.id))
414
+ - Run the relay as its own process (not inside the API container):
415
+ terp jobs worker
416
+ It leases rows (claim_id + locked_until, SKIP LOCKED), delivers AT LEAST ONCE, retries
417
+ with exponential backoff, and dead-letters once max_attempts is exhausted. Every
418
+ handler must therefore be safe to run twice - see `terp guide idempotency`.
419
+ - In production, refuse to boot on the in-process default:
420
+ create_app(..., job_queue=OutboxJobQueue(),
421
+ require_durable_jobs=get_settings().is_production)
422
+ """,
423
+ "idempotency": """\
424
+ Idempotency (the Idempotency-Key header, terp.core.idempotency)
425
+
426
+ - Already wired: create_app installs the idempotency middleware for every unsafe method
427
+ (POST/PUT/PATCH/DELETE). There is nothing to decorate and no per-route opt-in - the
428
+ CLIENT opts in per request by sending a header:
429
+ Idempotency-Key: <client-generated unique key>
430
+ A request without the header behaves exactly as it did before.
431
+ - The semantics, all typed envelopes:
432
+ first call -> executes; the response is stored under the key
433
+ retry, same body -> the stored response is replayed
434
+ (response header idempotency-replayed: true)
435
+ same key, different body -> 422 idempotency_key_mismatch
436
+ still executing -> 409 idempotency_in_flight (Retry-After: 1)
437
+ malformed key -> 400 invalid_idempotency_key
438
+ store unavailable -> 503 idempotency_unavailable (fail closed - never a
439
+ second write)
440
+ - Use it wherever a retry must not create a second row: starting a run, submitting a
441
+ payment, POSTing a snapshot from a worker that retries on a network error. Document
442
+ the header on those routes so the caller actually sends one.
443
+ - The default InMemoryIdempotencyStore is PER PROCESS - correct for dev and a single
444
+ replica, useless across replicas. A multi-replica deployment needs a shared store, and
445
+ should refuse to boot without one:
446
+ create_app(..., idempotency_store=RedisIdempotencyStore(...),
447
+ require_shared_idempotency_store=get_settings().is_production)
448
+ Only a store that marks itself shared (mark_shared_idempotency_store) satisfies the
449
+ guard, so a load-balanced deployment cannot silently lose the guarantee.
450
+ - Background work carries its own key: enqueue(..., idempotency_key="customers-2026-06-29").
451
+ Job delivery is at-least-once, so a handler must ALSO be safe to run twice on its own -
452
+ a natural unique constraint in the database is the strongest form of that.
453
+ """,
454
+ "realtime": """\
455
+ Realtime push (realtime capability)
456
+
457
+ - Install terp-cap-realtime; it is a routed capability, so discovery mounts it:
458
+ create_app(specs, discover_capabilities=True,
459
+ capability_names=(..., "realtime"))
460
+ - Declare every channel ONCE at import time, with a typed outbound model and the
461
+ authorization it demands - never a bare topic string:
462
+ class RunProgress(BaseModel):
463
+ run_id: uuid.UUID
464
+ state: str = Field(max_length=32)
465
+ done: int = Field(ge=0)
466
+ RUN_PROGRESS = register_channel(
467
+ RealtimeChannel("runs.progress", RunProgress, requirement=Roles.VIEWER))
468
+ RealtimeChannel(name, outbound_model, mode="sse"|"websocket", requirement=...,
469
+ inbound_requirement=..., inbound_model=..., on_message=...,
470
+ audience=principal_audience | global_audience). The default audience is
471
+ the principal, so a message reaches only the user you address it to.
472
+ - Publish from a service, an _after_write hook, or a job handler; the payload is
473
+ validated against the channel's outbound_model:
474
+ await publish(RUN_PROGRESS, RunProgress(...), audience=str(principal.id))
475
+ - Wire the runtime seams once at the composition root:
476
+ configure_realtime(permission_enforcer=..., principal_validator=...,
477
+ message_session_provider=...)
478
+ configure_broker / configure_ticket_store replace the in-memory defaults when you run
479
+ more than one replica (the in-memory ones are per process).
480
+ - Transport is TICKET-based, never a token in a URL: the client POSTs
481
+ /api/v1/realtime/tickets, receives a one-use short-lived ticket, then connects to
482
+ /api/v1/realtime/sse/<channel>?ticket=... (or /ws/<channel> in websocket mode). The
483
+ channel's requirement is enforced when the ticket is minted.
484
+ - Frontend: useRealtimeChannel({ channel: "runs.progress", validate }) from
485
+ @terpjs/react-core performs the whole dance. Never hand-roll EventSource or WebSocket -
486
+ the boundary lint refuses both.
487
+ - Inbound (websocket) messages are size-capped, validated against inbound_model, gated by
488
+ inbound_requirement, and handled by on_message with a real session - so a client message
489
+ goes through the same audited service path as an HTTP write.
282
490
  """,
283
491
  "files": """\
284
492
  File objects (files capability, ADR 0056/0057)
@@ -325,6 +533,12 @@ Using capabilities
325
533
 
326
534
  - Capabilities are opt-in packages (terp-cap-*); the base profile is auth + access +
327
535
  identity + users (+ projects). Install the ones you need.
536
+ - SEE WHAT EXISTS BEFORE YOU BUILD IT:
537
+ terp inspect capabilities
538
+ lists every maintained capability, whether this app already has it, the exact
539
+ `uv add` line and the composition-root wiring it expects. Durable delivery, realtime
540
+ push, tenancy, files, webhooks, scheduling and shared multi-replica state are all
541
+ already solved — hand-rolling one of them is a defect, not a shortcut.
328
542
  - A routed capability self-registers: create_app(specs, discover_capabilities=True)
329
543
  mounts it at /api/v1/<name> via its entry point — no composition-root edit.
330
544
  - A library capability (tenancy, eventbus) ships no router; you import and wire it
@@ -372,6 +586,29 @@ Database migrations (terp migrate)
372
586
  - Destructive DDL (drop table/column or alter-column type changes) is refused by
373
587
  `terp check` unless the operation carries `# arch-allow-no-destructive-migrations:
374
588
  <reason>` on (or immediately above) its line, budgeted by the escape-hatch ratchet.
589
+ - MOVING A MODEL TO ANOTHER MODULE is the one edit that looks free and is not. Just
590
+ moving the class emits NO ddl at all - the losing package stops owning the table so
591
+ its scoped autogenerate cannot propose a drop, and the gaining package diffs against
592
+ a database where the table already exists so it proposes no create. Your database
593
+ keeps upgrading; CI and staging stay green. Then the NEXT ordinary change to that
594
+ model (an added column) is authored into the gaining package's INDEPENDENT history,
595
+ which - with no foreign key between the two packages - a fresh install may run
596
+ BEFORE the history that creates the table: "no such table", months later, in a new
597
+ environment, blamed on an innocent add_column. Terp refuses the split at `terp
598
+ migrate make` and in the build-time guard. Move a table properly, expand/contract:
599
+ 1. Give the new module its own model with its own __tablename__;
600
+ `terp migrate make <new>` creates it.
601
+ 2. Copy the rows in that same release (a data migration, or a backfill job),
602
+ and write to both tables while the old one still has readers.
603
+ 3. In a LATER release, once nothing reads the old table, drop it - with
604
+ `# arch-allow-no-destructive-migrations: <reason>` and a human reviewer.
605
+ Step 3 stays human-reviewed on purpose: dropping a populated table is a genuine,
606
+ irreversible risk, and the one place where a second pair of eyes is worth the
607
+ friction. If the model is simply in the wrong module and holds no data you care
608
+ about, the cheap fix is to move the class BACK and rename the module instead.
609
+ Already shipped the move and planning the expand/contract for a later release? The
610
+ owning package's models.py may carry `# arch-allow-table-ownership-is-not-split:
611
+ <reason>`, counted by the escape-hatch budget like any other opt-out.
375
612
  - Adopt Terp on an EXISTING database (built by create_all or by hand) without dropping
376
613
  data - baseline each history at head, then only genuinely new migrations apply:
377
614
  terp migrate stamp # records head, runs no DDL
@@ -594,6 +831,7 @@ Golden rules (the gate enforces these — follow them and it stays green):
594
831
  More: terp guide <topic> (topics: {_TOPIC_NAMES})
595
832
  terp guide <rule> (the exact rule's remediation and related pattern)
596
833
  terp guide rules (every architecture rule the gate enforces, generated)
834
+ terp inspect capabilities (what the platform offers: installed vs adoptable)
597
835
  terp inspect control-plane (your roles / permissions / module authority map)
598
836
  terp inspect access (the full access graph: modules, endpoints, data traits)
599
837
  terp check (run the full architecture gate locally)
@@ -683,6 +921,25 @@ def guide(topic: str | None = None) -> str:
683
921
  return _render_rule_guide(topic)
684
922
 
685
923
 
924
+ def gate_root(root: str | pathlib.Path = ".", *, package: str = "app") -> pathlib.Path:
925
+ """Resolve the directory the architecture gate scans, from a CLI ``--root``.
926
+
927
+ The gate's unit of scope is the **app package** — the test harness calls
928
+ ``assert_app_clean("app")``, so ``app/`` is what the rules walk. A CLI ``--root``,
929
+ though, is naturally the *project* root (that is where every other ``terp``
930
+ command runs, and the default is ``.``), which also contains code the gate does
931
+ not govern: a standalone ``engine/``, ``conformance/``, tooling scripts. Scanning
932
+ that made the two entry points to the same gate disagree about scope.
933
+
934
+ So ``--root`` accepts either: if it contains the app package, the package dir is
935
+ the scan root; otherwise *root* is already the package dir and is used as-is.
936
+ Both spellings then hold the app to exactly the scope ``uv run pytest`` does.
937
+ """
938
+ path = pathlib.Path(root)
939
+ candidate = path / package
940
+ return candidate if candidate.is_dir() else path
941
+
942
+
686
943
  def check_report(
687
944
  root: str = ".", *, package: str = "app", budget_path: str | None = None
688
945
  ) -> dict[str, object]:
@@ -706,9 +963,10 @@ def check_report(
706
963
  from terp.arch import check_app, guide_topic_for, ungoverned_marker_violations
707
964
  from terp.arch.rules import GUIDE_TOPIC_BY_RULE
708
965
 
709
- violations = list(check_app(root, package=package, budget_path=budget_path))
966
+ scan_root = gate_root(root, package=package)
967
+ violations = list(check_app(scan_root, package=package, budget_path=budget_path))
710
968
  if budget_path is None:
711
- violations.extend(ungoverned_marker_violations(root, package=package))
969
+ violations.extend(ungoverned_marker_violations(scan_root, package=package))
712
970
  violations.sort(key=lambda violation: (violation.path, violation.line, violation.rule))
713
971
  rules = set(GUIDE_TOPIC_BY_RULE)
714
972
  if budget_path is None:
@@ -976,6 +1234,7 @@ def _module_json(spec: ModuleSpec) -> dict[str, object]:
976
1234
  return {
977
1235
  "name": spec.name,
978
1236
  "policy": policy,
1237
+ "requires": list(spec.requires),
979
1238
  "emits": [event.name for event in spec.emits],
980
1239
  "subscribes": [event.name for event in spec.subscribes],
981
1240
  "jobs": [job.name for job in spec.jobs],
@@ -998,6 +1257,8 @@ def _render_text(plane: ControlPlane, specs: Sequence[ModuleSpec]) -> str:
998
1257
  lines.append(" <none provided>")
999
1258
  for spec in sorted(specs, key=lambda item: item.name):
1000
1259
  lines.append(f" {spec.name} {_policy_label(spec)}")
1260
+ if spec.requires:
1261
+ lines.append(f" requires: {', '.join(sorted(spec.requires))}")
1001
1262
  lines.append("")
1002
1263
  lines.append("Audit")
1003
1264
  if plane.audit.enabled:
@@ -1166,6 +1427,17 @@ def _build_parser() -> argparse.ArgumentParser:
1166
1427
  default="text",
1167
1428
  help="Output format: text (human) or json (structured, for Studio; default: text)",
1168
1429
  )
1430
+ capabilities_parser = inspect_subcommands.add_parser(
1431
+ "capabilities",
1432
+ help="Every maintained terp-cap-* package: installed here vs available to "
1433
+ "adopt, with the `uv add` line and its composition-root wiring",
1434
+ )
1435
+ capabilities_parser.add_argument(
1436
+ "--format",
1437
+ choices=("text", "json"),
1438
+ default="text",
1439
+ help="Output format: text (human) or json (structured, for Studio; default: text)",
1440
+ )
1169
1441
  schema_parser = inspect_subcommands.add_parser(
1170
1442
  "schema",
1171
1443
  help="The schema graph: every table with ownership, traits, and fail-visible "
@@ -1349,7 +1621,12 @@ def _build_parser() -> argparse.ArgumentParser:
1349
1621
  )
1350
1622
 
1351
1623
  check_parser = subcommands.add_parser("check", help="Run the architecture gate locally")
1352
- check_parser.add_argument("--root", default=".", help="App root (default: .)")
1624
+ check_parser.add_argument(
1625
+ "--root",
1626
+ default=".",
1627
+ help="Project root, or the app package itself; the gate scans the app package "
1628
+ "(default: .)",
1629
+ )
1353
1630
  check_parser.add_argument("--package", default="app", help="App package (default: app)")
1354
1631
  check_parser.add_argument(
1355
1632
  "--budget", default=None, help="Escape-hatch budget JSON (governs # arch-allow markers)"
@@ -1423,6 +1700,78 @@ def _build_parser() -> argparse.ArgumentParser:
1423
1700
  help="Env var holding the new password; prompts if unset (default: TERP_USER_PASSWORD)",
1424
1701
  )
1425
1702
 
1703
+ sa_parser = subcommands.add_parser(
1704
+ "service-account",
1705
+ help="Manage machine credentials (so an integration never needs a person's login)",
1706
+ )
1707
+ sa_subcommands = sa_parser.add_subparsers(dest="service_account_command", required=True)
1708
+ sa_create_parser = sa_subcommands.add_parser(
1709
+ "create", help="Provision a service account and print its credentials once"
1710
+ )
1711
+ sa_create_parser.add_argument("name", help="Human-readable name of the integration")
1712
+ sa_create_parser.add_argument(
1713
+ "--role",
1714
+ required=True,
1715
+ help="viewer / editor / admin or an integer rank (required: an integration's "
1716
+ "authority is chosen, never inherited)",
1717
+ )
1718
+ sa_create_parser.add_argument(
1719
+ "--description", default=None, help="What this credential is for, and who owns it"
1720
+ )
1721
+ sa_create_parser.add_argument(
1722
+ "--expires-in-days",
1723
+ type=int,
1724
+ default=365,
1725
+ help="Days until the credential lapses; 0 means never (default: 365)",
1726
+ )
1727
+ sa_create_parser.add_argument(
1728
+ "--app",
1729
+ default="app.main:app",
1730
+ help="Dotted module:attribute of the FastAPI app (default: app.main:app)",
1731
+ )
1732
+ sa_create_parser.add_argument(
1733
+ "--app-root", default=".", help="App root placed first on sys.path (default: .)"
1734
+ )
1735
+
1736
+ grant_parser = subcommands.add_parser(
1737
+ "grant",
1738
+ help="Assign, list and revoke a subject's permission grants (least privilege "
1739
+ "without hand-writing an admin API call)",
1740
+ )
1741
+ grant_subcommands = grant_parser.add_subparsers(dest="grant_command", required=True)
1742
+
1743
+ _SUBJECT_HELP = (
1744
+ "A user email, a service-account name, or a subject UUID"
1745
+ )
1746
+ for _name, _help in (
1747
+ ("add", "Grant a permission to a subject (idempotent, audited)"),
1748
+ ("revoke", "Revoke a permission from a subject"),
1749
+ ):
1750
+ _p = grant_subcommands.add_parser(_name, help=_help)
1751
+ _p.add_argument("subject", help=_SUBJECT_HELP)
1752
+ _p.add_argument("permission", help="The permission name the app declares")
1753
+ _p.add_argument(
1754
+ "--app",
1755
+ default="app.main:app",
1756
+ help="Dotted module:attribute of the FastAPI app (default: app.main:app)",
1757
+ )
1758
+ _p.add_argument(
1759
+ "--app-root", default=".", help="App root placed first on sys.path (default: .)"
1760
+ )
1761
+
1762
+ grant_list_parser = grant_subcommands.add_parser(
1763
+ "list", help="List every permission a subject holds, including via groups"
1764
+ )
1765
+ grant_list_parser.add_argument("subject", help=_SUBJECT_HELP)
1766
+ grant_list_parser.add_argument(
1767
+ "--app",
1768
+ default="app.main:app",
1769
+ help="Dotted module:attribute of the FastAPI app (default: app.main:app)",
1770
+ )
1771
+ grant_list_parser.add_argument(
1772
+ "--app-root", default=".", help="App root placed first on sys.path (default: .)"
1773
+ )
1774
+
1426
1775
  seed_parser = subcommands.add_parser(
1427
1776
  "seed", help="Run the app's seed routine (idempotent demo / bootstrap data; dev only)"
1428
1777
  )
@@ -1485,6 +1834,9 @@ def main(argv: Sequence[str] | None = None) -> None:
1485
1834
  if args.command == "inspect" and args.inspect_command == "schema":
1486
1835
  print(inspect_schema(app_root=args.app_root, package=args.package, fmt=args.format))
1487
1836
  return
1837
+ if args.command == "inspect" and args.inspect_command == "capabilities":
1838
+ print(render_capabilities(fmt=args.format))
1839
+ return
1488
1840
  if args.command == "guide":
1489
1841
  if args.topic is not None and args.topic not in guide_choices():
1490
1842
  raise SystemExit(
@@ -1569,7 +1921,11 @@ def main(argv: Sequence[str] | None = None) -> None:
1569
1921
  return
1570
1922
  from terp.arch import assert_app_clean
1571
1923
 
1572
- assert_app_clean(args.root, package=args.package, budget_path=args.budget)
1924
+ assert_app_clean(
1925
+ gate_root(args.root, package=args.package),
1926
+ package=args.package,
1927
+ budget_path=args.budget,
1928
+ )
1573
1929
  print("terp.arch: app is clean")
1574
1930
  return
1575
1931
  if args.command == "verify":
@@ -1593,6 +1949,39 @@ def main(argv: Sequence[str] | None = None) -> None:
1593
1949
  )
1594
1950
  )
1595
1951
  return
1952
+ if args.command == "service-account" and args.service_account_command == "create":
1953
+ print(
1954
+ create_service_account_command(
1955
+ args.name,
1956
+ role=args.role,
1957
+ description=args.description,
1958
+ expires_in_days=args.expires_in_days or None,
1959
+ app_ref=args.app,
1960
+ app_root=args.app_root,
1961
+ )
1962
+ )
1963
+ return
1964
+ if args.command == "grant":
1965
+ _commands = {
1966
+ "add": grant_add_command,
1967
+ "revoke": grant_revoke_command,
1968
+ }
1969
+ if args.grant_command == "list":
1970
+ print(
1971
+ grant_list_command(
1972
+ args.subject, app_ref=args.app, app_root=args.app_root
1973
+ )
1974
+ )
1975
+ return
1976
+ print(
1977
+ _commands[args.grant_command](
1978
+ args.subject,
1979
+ args.permission,
1980
+ app_ref=args.app,
1981
+ app_root=args.app_root,
1982
+ )
1983
+ )
1984
+ return
1596
1985
  if args.command == "seed":
1597
1986
  print(run_seed_command(app_ref=args.app, app_root=args.app_root, seed_ref=args.seed))
1598
1987
  return
@@ -1610,9 +1999,14 @@ __all__ = [
1610
1999
  "api_docs",
1611
2000
  "check_report",
1612
2001
  "check_report_envelope",
2002
+ "create_service_account_command",
1613
2003
  "create_user_command",
2004
+ "grant_add_command",
2005
+ "grant_list_command",
2006
+ "grant_revoke_command",
1614
2007
  "dev_plan",
1615
2008
  "export_openapi",
2009
+ "gate_root",
1616
2010
  "guide",
1617
2011
  "guide_topics",
1618
2012
  "guide_choices",
@@ -0,0 +1,254 @@
1
+ """The adoptable-capability registry behind ``terp inspect capabilities``.
2
+
3
+ The gate tells an author what they may **not** do; nothing told them what the platform
4
+ already **offers**. An app that needs run progress hand-rolls polling, an app that needs
5
+ durable delivery fires a post-commit callback and hopes — not because the author was
6
+ careless, but because ``terp-cap-realtime`` and ``terp-cap-outbox`` were invisible from
7
+ inside the project. Discoverability is a platform responsibility, so it ships as a
8
+ command: one screen listing every capability, whether this app has it, the exact
9
+ ``uv add`` line, and the composition-root shape it expects.
10
+
11
+ The registry is static (it must describe capabilities that are *not* installed), so it
12
+ is pinned against the real packages by ``tests/architecture/test_cli_capabilities.py``:
13
+ every capability directory appears here exactly once, and ``kind`` agrees with whether
14
+ the package declares a ``terp.capabilities`` router entry point.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from dataclasses import dataclass
21
+ from importlib import metadata
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Capability:
26
+ """One adoptable ``terp-cap-*`` package, as an author needs to see it."""
27
+
28
+ name: str
29
+ """Package suffix, e.g. ``realtime`` (dist ``terp-cap-realtime``)."""
30
+
31
+ summary: str
32
+ """One line: what it gives you, in problem terms."""
33
+
34
+ kind: str
35
+ """``routed`` (discovery mounts a router) or ``library`` (you wire it yourself)."""
36
+
37
+ wiring: str
38
+ """The composition-root shape, copy-pasteable."""
39
+
40
+ guide: str | None = None
41
+ """``terp guide`` topic covering it, when one exists."""
42
+
43
+ @property
44
+ def distribution(self) -> str:
45
+ """The PyPI distribution name."""
46
+ return "terp-cap-" + self.name.replace("_", "-")
47
+
48
+ @property
49
+ def module(self) -> str:
50
+ """The import path."""
51
+ return "terp.capabilities." + self.name
52
+
53
+
54
+ CAPABILITIES: tuple[Capability, ...] = (
55
+ Capability(
56
+ name="access",
57
+ summary="RBAC permission grants + a fail-closed require_permission dependency.",
58
+ kind="routed",
59
+ wiring="create_app(..., discover_capabilities=True)",
60
+ guide="access",
61
+ ),
62
+ Capability(
63
+ name="audit",
64
+ summary="Durable append-only sink for the core audit seam (who changed what).",
65
+ kind="routed",
66
+ wiring="create_app(..., audit_sink=persist_audit)",
67
+ ),
68
+ Capability(
69
+ name="auth",
70
+ summary="Argon2 password hashing, JWT access tokens, the get_principal seam.",
71
+ kind="library",
72
+ wiring="mount the login router in app/auth.py",
73
+ guide="passwords",
74
+ ),
75
+ Capability(
76
+ name="eventbus",
77
+ summary="In-process dispatcher for the core event seam — react without coupling.",
78
+ kind="library",
79
+ wiring="create_app(..., event_dispatcher=dispatch_in_process)",
80
+ guide="events",
81
+ ),
82
+ Capability(
83
+ name="files",
84
+ summary="Owner-scoped file objects: metadata in the DB, bytes behind a storage port.",
85
+ kind="routed",
86
+ wiring="create_app(..., discover_capabilities=True)",
87
+ guide="files",
88
+ ),
89
+ Capability(
90
+ name="groups",
91
+ summary="Admin-managed user groups that bundle access-grant permissions.",
92
+ kind="routed",
93
+ wiring="create_app(..., discover_capabilities=True)",
94
+ guide="access",
95
+ ),
96
+ Capability(
97
+ name="identity",
98
+ summary="Persisted user store backing authentication.",
99
+ kind="library",
100
+ wiring="create_app(..., discover_capabilities=True)",
101
+ ),
102
+ Capability(
103
+ name="jobs_celery",
104
+ summary="Run catalog jobs on a Celery broker, with zero domain change.",
105
+ kind="library",
106
+ wiring="create_app(..., job_queue=CeleryJobQueue(...))",
107
+ guide="jobs",
108
+ ),
109
+ Capability(
110
+ name="oidc",
111
+ summary="Single sign-on via the OpenID Connect code flow with PKCE.",
112
+ kind="library",
113
+ wiring="build_oidc_module(...) in app/auth.py, mounted when OIDC_* is set",
114
+ ),
115
+ Capability(
116
+ name="outbox",
117
+ summary="Transactional, leased, retrying post-commit delivery for jobs and events.",
118
+ kind="library",
119
+ wiring=(
120
+ "create_app(..., job_queue=OutboxJobQueue(), "
121
+ "event_dispatcher=outbox_event_dispatcher)"
122
+ ),
123
+ guide="outbox",
124
+ ),
125
+ Capability(
126
+ name="realtime",
127
+ summary="Typed, policy-gated SSE/WebSocket channels with one-use connection tickets.",
128
+ kind="routed",
129
+ wiring="create_app(..., discover_capabilities=True) + configure_realtime(...)",
130
+ guide="realtime",
131
+ ),
132
+ Capability(
133
+ name="redis",
134
+ summary="Shared idempotency / throttling / cache state for multi-replica deploys.",
135
+ kind="library",
136
+ wiring="create_app(..., idempotency_store=RedisIdempotencyStore(...))",
137
+ guide="idempotency",
138
+ ),
139
+ Capability(
140
+ name="scheduler_apscheduler",
141
+ summary="Fire catalog schedules on their cron in-process, through the jobs seam.",
142
+ kind="library",
143
+ wiring="terp jobs scheduler",
144
+ guide="jobs",
145
+ ),
146
+ Capability(
147
+ name="scheduler_celery_beat",
148
+ summary="Drive catalog schedules from Celery beat, through the jobs seam.",
149
+ kind="library",
150
+ wiring="celery beat against the generated schedule",
151
+ guide="jobs",
152
+ ),
153
+ Capability(
154
+ name="sync",
155
+ summary="Reconcile a local entity against an external system on the jobs seam.",
156
+ kind="library",
157
+ wiring="declare a SyncSpec; run it through the jobs/scheduler seam",
158
+ guide="jobs",
159
+ ),
160
+ Capability(
161
+ name="tenancy",
162
+ summary="Tenant isolation by construction (session-level filter + insert stamp).",
163
+ kind="library",
164
+ wiring="install_tenancy(...) at the composition root",
165
+ guide="tenancy",
166
+ ),
167
+ Capability(
168
+ name="users",
169
+ summary="Admin user management over the identity store.",
170
+ kind="routed",
171
+ wiring="create_app(..., discover_capabilities=True)",
172
+ ),
173
+ Capability(
174
+ name="webhooks",
175
+ summary="Reliable, signed, SSRF-guarded outbound webhooks on the jobs/outbox seam.",
176
+ kind="routed",
177
+ wiring="create_app(..., discover_capabilities=True)",
178
+ ),
179
+ )
180
+
181
+
182
+ def _is_installed(capability: Capability) -> bool:
183
+ """Whether *capability*'s distribution is resolvable in this environment."""
184
+ try:
185
+ metadata.distribution(capability.distribution)
186
+ except metadata.PackageNotFoundError:
187
+ return False
188
+ return True
189
+
190
+
191
+ def render_capabilities(*, fmt: str = "text") -> str:
192
+ """Render every adoptable capability, marking the ones this app already has.
193
+
194
+ Answers the question the gate never could: *what else is on the shelf?* Installed
195
+ capabilities are listed first so the reader sees the current profile, then the
196
+ adoptable ones with the exact ``uv add`` line and wiring shape — no package index
197
+ search, no guessing at a name.
198
+ """
199
+ rows = [(cap, _is_installed(cap)) for cap in CAPABILITIES]
200
+ if fmt == "json":
201
+ return json.dumps(
202
+ {
203
+ "capabilities": [
204
+ {
205
+ "name": cap.name,
206
+ "distribution": cap.distribution,
207
+ "module": cap.module,
208
+ "summary": cap.summary,
209
+ "kind": cap.kind,
210
+ "wiring": cap.wiring,
211
+ "guide": cap.guide,
212
+ "installed": installed,
213
+ }
214
+ for cap, installed in rows
215
+ ]
216
+ },
217
+ indent=2,
218
+ )
219
+
220
+ installed = [cap for cap, present in rows if present]
221
+ available = [cap for cap, present in rows if not present]
222
+ lines = [
223
+ "Capabilities",
224
+ "",
225
+ "Opt-in packages the platform maintains. A `routed` capability mounts its own",
226
+ "router when discovery is on; a `library` capability is imported and wired at",
227
+ "the composition root. Adopting one is always: add the dependency, wire it, run",
228
+ "`uv run terp migrate` if it ships tables.",
229
+ "",
230
+ f"Installed in this app ({len(installed)})",
231
+ "",
232
+ ]
233
+ for cap in installed:
234
+ lines.append(f" {cap.distribution:<32} {cap.kind}")
235
+ lines.append(f" {cap.summary}")
236
+ if not installed:
237
+ lines.append(" (none)")
238
+ lines += ["", f"Available to adopt ({len(available)})", ""]
239
+ for cap in available:
240
+ lines.append(f" {cap.distribution:<32} {cap.kind}")
241
+ lines.append(f" {cap.summary}")
242
+ lines.append(f" uv add {cap.distribution}")
243
+ lines.append(f" {cap.wiring}")
244
+ if cap.guide is not None:
245
+ lines.append(f" uv run terp guide {cap.guide}")
246
+ if not available:
247
+ lines.append(" (none — every maintained capability is installed)")
248
+ lines += [
249
+ "",
250
+ "Do not hand-roll what a capability owns. If you need something none of these",
251
+ "covers, stop and report the missing capability rather than building a local",
252
+ "substitute (`uv run terp guide capability`).",
253
+ ]
254
+ return "\n".join(lines)
@@ -0,0 +1,233 @@
1
+ """``terp grant`` — assign, list and revoke a subject's permission grants.
2
+
3
+ Terp has had per-subject permission grants since ADR 0013, and almost nobody uses
4
+ them. The reason is not that operators prefer broad roles; it is that granting was
5
+ the expensive option:
6
+
7
+ - it needed an **admin token**, so you had to already be the thing you were trying
8
+ to avoid needing;
9
+ - it needed the subject's **UUID**, which nobody has — people have an email address
10
+ or the name of an integration;
11
+ - it needed the **exact permission string**, and nothing anywhere would tell you
12
+ which strings the app actually checks.
13
+
14
+ Faced with that, the rational move is to bump the account to a role that already
15
+ clears the check and move on. Least privilege loses to a ten-second workaround every
16
+ time. So this command removes all three costs: it resolves a subject from an email or
17
+ a service-account name, it validates the permission against the app's own catalog and
18
+ *prints the catalog* when you get it wrong, and it writes through the audited
19
+ ``AccessService`` chokepoint without an HTTP round trip.
20
+
21
+ It deliberately does **not** grow a ``--force`` for an unknown permission. A grant of
22
+ a string the app never checks is not a lenient grant, it is a silent no-op — and the
23
+ person who wrote it will believe the integration is authorized until it is not.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import contextlib
29
+ import pathlib
30
+ import uuid
31
+
32
+ from fastapi import FastAPI
33
+ from sqlmodel import Session
34
+
35
+ from terp.cli._appref import load_app, push_app_root
36
+ from terp.core.db import get_session
37
+
38
+
39
+ def _catalog(app: FastAPI) -> dict[str, str]:
40
+ """The app's declared permissions, name -> minimum role name.
41
+
42
+ Read off the composed app's control plane rather than a static list, so the
43
+ command can only ever offer permissions this app really enforces.
44
+ """
45
+ plane = getattr(getattr(app, "state", None), "terp_control_plane", None)
46
+ if plane is None:
47
+ raise SystemExit(
48
+ "the app exposes no control plane (create_app records it on app.state) — "
49
+ "pass a terp app factory, e.g. --app app.main:build"
50
+ )
51
+ return {
52
+ permission.name: permission.min_role.name
53
+ for permission in plane.permissions.permissions
54
+ }
55
+
56
+
57
+ def _resolve_permission(app: FastAPI, permission: str) -> str:
58
+ """Check *permission* against the app's catalog, or fail listing the choices.
59
+
60
+ The error is the feature: "unknown permission" alone sends the reader back to
61
+ grepping the source, which is the moment they give up and widen the role instead.
62
+ """
63
+ catalog = _catalog(app)
64
+ if permission in catalog:
65
+ return permission
66
+ known = "\n".join(
67
+ f" {name} (needs role {min_role} or higher)"
68
+ for name, min_role in sorted(catalog.items())
69
+ )
70
+ raise SystemExit(
71
+ f"unknown permission {permission!r}. This app declares:\n{known}\n\n"
72
+ "A grant of a permission the app never checks is a silent no-op, so it is "
73
+ "refused rather than stored."
74
+ )
75
+
76
+
77
+ def _resolve_subject(session: Session, subject: str) -> tuple[uuid.UUID, str]:
78
+ """Resolve *subject* — a UUID, a user email, or a service-account name.
79
+
80
+ Grants are keyed by a bare subject id with no foreign key (ADR 0013), which is
81
+ what lets a user, a service account and a group all be granted the same way. The
82
+ cost is that the id means nothing to a human, so this command accepts what people
83
+ actually have and does the lookup itself.
84
+ """
85
+ with contextlib.suppress(ValueError):
86
+ return uuid.UUID(subject), f"subject {subject}"
87
+
88
+ if "@" in subject:
89
+ from terp.capabilities.users import UsersService
90
+
91
+ user = UsersService().get_by_email(session, subject)
92
+ if user is None:
93
+ raise SystemExit(f"no user with email {subject!r}")
94
+ return user.id, f"user {subject!r}"
95
+
96
+ from terp.capabilities.identity import ServiceAccountService
97
+
98
+ account = ServiceAccountService().get_by_name(session, subject)
99
+ if account is None:
100
+ raise SystemExit(
101
+ f"no service account named {subject!r} (pass an email for a user, a "
102
+ "service-account name for a machine, or a subject UUID for anything else)"
103
+ )
104
+ return account.id, f"service account {subject!r}"
105
+
106
+
107
+ def _load(app_ref: str, app_root: str | pathlib.Path) -> FastAPI:
108
+ push_app_root(app_root)
109
+ return load_app(app_ref)
110
+
111
+
112
+ def _rank_shortfall(
113
+ app: FastAPI, session: Session, subject: str, permission: str
114
+ ) -> str | None:
115
+ """Describe the subject's role if it sits below *permission*'s minimum, else None.
116
+
117
+ Only answerable for subjects that carry a rank of their own — a bare UUID or a
118
+ group has none, and for those the question belongs to whatever the id resolves to.
119
+ """
120
+ plane = app.state.terp_control_plane
121
+ # The caller resolved *permission* against this same catalog, so the lookup cannot
122
+ # miss — an undeclared permission never reaches this point.
123
+ declared = next(p for p in plane.permissions.permissions if p.name == permission)
124
+
125
+ rank: int | None = None
126
+ if "@" in subject:
127
+ from terp.capabilities.users import UsersService
128
+
129
+ user = UsersService().get_by_email(session, subject)
130
+ rank = None if user is None else user.role
131
+ else:
132
+ from terp.capabilities.identity import ServiceAccountService
133
+
134
+ account = ServiceAccountService().get_by_name(session, subject)
135
+ rank = None if account is None else account.role
136
+
137
+ if rank is None or rank >= declared.min_role.rank:
138
+ return None
139
+ return f"{plane.permissions.role_for_rank(rank).name} (rank {rank})"
140
+
141
+
142
+ def grant_add_command(
143
+ subject: str,
144
+ permission: str,
145
+ *,
146
+ app_ref: str = "app.main:app",
147
+ app_root: str | pathlib.Path = ".",
148
+ ) -> str:
149
+ """Grant *permission* to *subject*, audited and idempotent."""
150
+ app = _load(app_ref, app_root)
151
+ name = _resolve_permission(app, permission)
152
+ min_role = _catalog(app)[name]
153
+
154
+ from terp.capabilities.access import AccessService
155
+ from terp.core import AppError
156
+
157
+ with contextlib.closing(get_session()) as gen:
158
+ session = next(gen)
159
+ subject_id, label = _resolve_subject(session, subject)
160
+ try:
161
+ AccessService().grant(session, subject_id, name)
162
+ except AppError as exc:
163
+ raise SystemExit(f"could not grant {name!r} to {label}: {exc}") from exc
164
+ shortfall = _rank_shortfall(app, session, subject, name)
165
+ granted = f"granted {name!r} to {label} ({subject_id})"
166
+ if shortfall is None:
167
+ return granted
168
+ # The guard checks rank before it checks grants, so a grant to a subject below the
169
+ # permission's minimum role is stored but can never fire. Saying so here is the
170
+ # whole point of the command: a grant that silently does nothing is worse than no
171
+ # grant, because the operator walks away believing the integration is authorized.
172
+ return (
173
+ f"{granted}\n"
174
+ f" WARNING: {name!r} requires role {min_role} or higher and this subject is "
175
+ f"{shortfall}. The grant is stored but will not take effect until the "
176
+ "subject's role rank reaches that minimum."
177
+ )
178
+
179
+
180
+ def grant_revoke_command(
181
+ subject: str,
182
+ permission: str,
183
+ *,
184
+ app_ref: str = "app.main:app",
185
+ app_root: str | pathlib.Path = ".",
186
+ ) -> str:
187
+ """Revoke *permission* from *subject*.
188
+
189
+ The permission is **not** validated against the catalog here: a permission the app
190
+ has since stopped declaring is exactly the stale grant you most want to be able to
191
+ clean up.
192
+ """
193
+ _load(app_ref, app_root)
194
+
195
+ from terp.capabilities.access import AccessService
196
+
197
+ with contextlib.closing(get_session()) as gen:
198
+ session = next(gen)
199
+ subject_id, label = _resolve_subject(session, subject)
200
+ removed = AccessService().revoke(session, subject_id, permission)
201
+ if not removed:
202
+ return f"{label} did not hold {permission!r}; nothing to revoke"
203
+ return f"revoked {permission!r} from {label} ({subject_id})"
204
+
205
+
206
+ def grant_list_command(
207
+ subject: str,
208
+ *,
209
+ app_ref: str = "app.main:app",
210
+ app_root: str | pathlib.Path = ".",
211
+ ) -> str:
212
+ """List everything *subject* holds — the answer to "why can it do that?".
213
+
214
+ Grants inherited through group membership are included, because the question being
215
+ asked is what this subject can do, not which rows happen to name it.
216
+ """
217
+ app = _load(app_ref, app_root)
218
+ catalog = _catalog(app)
219
+
220
+ from terp.capabilities.access import AccessService
221
+
222
+ with contextlib.closing(get_session()) as gen:
223
+ session = next(gen)
224
+ subject_id, label = _resolve_subject(session, subject)
225
+ held = sorted(AccessService().permissions_for(session, subject_id))
226
+ if not held:
227
+ return f"{label} ({subject_id}) holds no permission grants"
228
+ lines = "\n".join(
229
+ f" {name}"
230
+ + ("" if name in catalog else " [stale: this app no longer declares it]")
231
+ for name in held
232
+ )
233
+ return f"{label} ({subject_id}) holds:\n{lines}"
@@ -0,0 +1,79 @@
1
+ """``terp service-account create`` — provision a machine credential against the store.
2
+
3
+ The bootstrap problem is the same one ``terp user create`` solves, one step further
4
+ along: an integration needs a credential before it can call anything, and the only
5
+ alternative to a command like this is for somebody to hand it a person's password.
6
+ Given how that ends — a shared admin login nobody can revoke without breaking a
7
+ production job — the machine path has to be at least as easy as the wrong one.
8
+
9
+ The generated secret is printed **once**, to stdout, and never stored in the clear.
10
+ There is no command to read it back; a lost secret is re-provisioned. The account's
11
+ role rank is required, not defaulted, because the whole point is that an integration's
12
+ authority is chosen rather than inherited.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import contextlib
18
+ import datetime
19
+ import pathlib
20
+
21
+ from terp.cli._appref import load_app, push_app_root
22
+ from terp.cli.users import resolve_role
23
+ from terp.core.db import get_session
24
+
25
+
26
+ def create_service_account_command(
27
+ name: str,
28
+ *,
29
+ role: str,
30
+ app_ref: str = "app.main:app",
31
+ app_root: str | pathlib.Path = ".",
32
+ description: str | None = None,
33
+ expires_in_days: int | None = 365,
34
+ ) -> str:
35
+ """Build *app_ref*, then provision service account *name* at *role*, audited.
36
+
37
+ *expires_in_days* defaults to a year rather than to "never": a machine credential
38
+ outlives the ticket that justified it and the person who created it, so an end date
39
+ it has to be renewed past is the only thing that reliably forces a second look.
40
+ Pass ``None`` for a non-expiring credential — deliberately, and in writing.
41
+ """
42
+ push_app_root(app_root)
43
+ load_app(app_ref)
44
+
45
+ from terp.capabilities.identity import ServiceAccountService
46
+ from terp.capabilities.identity.schemas import ServiceAccountCreate
47
+ from terp.core import AppError
48
+
49
+ rank = resolve_role(role)
50
+ expires_at = (
51
+ datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=expires_in_days)
52
+ if expires_in_days is not None
53
+ else None
54
+ )
55
+ service = ServiceAccountService()
56
+ with contextlib.closing(get_session()) as gen:
57
+ session = next(gen)
58
+ try:
59
+ account, secret = service.provision(
60
+ session,
61
+ ServiceAccountCreate(
62
+ name=name,
63
+ role_rank=rank,
64
+ description=description,
65
+ expires_at=expires_at,
66
+ ),
67
+ )
68
+ except AppError as exc:
69
+ raise SystemExit(f"could not create service account {name!r}: {exc}") from exc
70
+
71
+ expiry = account.expires_at.date().isoformat() if account.expires_at else "never"
72
+ return (
73
+ f"created service account {name!r} (id {account.id}, role rank {rank}, "
74
+ f"expires {expiry})\n"
75
+ f" client_id: {account.client_id}\n"
76
+ f" client_secret: {secret}\n"
77
+ "\nThe secret is shown once and is not recoverable. Store it in the "
78
+ "integration's secret store now; if it is lost, provision a new account."
79
+ )
@@ -255,9 +255,86 @@ def _reports_in(stdout: str) -> list[dict]:
255
255
  ]
256
256
 
257
257
 
258
+ def _node_platform() -> tuple[str, str]:
259
+ """This machine as npm names it — ``process.platform`` / ``process.arch``."""
260
+ import platform as _platform
261
+
262
+ system = {"win32": "win32", "darwin": "darwin"}.get(sys.platform, "linux")
263
+ machine = _platform.machine().lower()
264
+ arch = {
265
+ "amd64": "x64",
266
+ "x86_64": "x64",
267
+ "aarch64": "arm64",
268
+ "arm64": "arm64",
269
+ }.get(machine, machine)
270
+ return system, arch
271
+
272
+
273
+ def _node_modules_problem(root: pathlib.Path) -> str | None:
274
+ """Explain an unusable ``frontend/node_modules``, or None if it looks fine.
275
+
276
+ An npm install is platform-specific: the native binaries a bundler needs are
277
+ optional dependencies gated on ``os``/``cpu``, so a tree installed on the
278
+ Windows host has no Linux binary, and running the gate in the container dies
279
+ with a raw Node stack (``Cannot find module '@rolldown/binding-linux-x64-gnu'``)
280
+ that names neither the cause nor the fix. Every Terp failure states the fix, so
281
+ this is detected up front rather than left to the reader to decode.
282
+
283
+ The lockfile already records which optional packages belong on which platform,
284
+ so the check is exact and needs no list of native package names to maintain.
285
+ """
286
+ frontend = root / "frontend"
287
+ if not (frontend / "package.json").is_file():
288
+ return None
289
+ modules = frontend / "node_modules"
290
+ if not modules.is_dir():
291
+ return (
292
+ "frontend/node_modules is missing — the frontend checks cannot run.\n"
293
+ " Fix: npm --prefix frontend ci"
294
+ )
295
+
296
+ lockfile = frontend / "package-lock.json"
297
+ if not lockfile.is_file():
298
+ return None
299
+ try:
300
+ packages = json.loads(lockfile.read_text(encoding="utf-8")).get("packages", {})
301
+ except (OSError, ValueError):
302
+ return None
303
+
304
+ system, arch = _node_platform()
305
+ missing = [
306
+ name
307
+ for name, entry in packages.items()
308
+ if name.startswith("node_modules/")
309
+ and isinstance(entry, dict)
310
+ # Only packages this platform is *supposed* to have: an entry with no os/cpu
311
+ # constraint is platform-neutral, and one constrained elsewhere is absent by
312
+ # design rather than by a bad install.
313
+ and system in (entry.get("os") or [system])
314
+ and arch in (entry.get("cpu") or [arch])
315
+ and (entry.get("os") or entry.get("cpu"))
316
+ and not (frontend / name).exists()
317
+ ]
318
+ if not missing:
319
+ return None
320
+ return (
321
+ f"frontend/node_modules was installed for a different platform: "
322
+ f"{len(missing)} package(s) this machine ({system}/{arch}) needs are absent, "
323
+ f"e.g. {missing[0].removeprefix('node_modules/')}.\n"
324
+ " This happens when the tree is installed on the host and the gate runs in "
325
+ "a container (or vice versa); native binaries are per-platform optional "
326
+ "dependencies and do not travel.\n"
327
+ " Fix: npm --prefix frontend ci (run it where the gate runs)"
328
+ )
329
+
330
+
258
331
  def _run_subprocess(check: VerifyCheck, root: pathlib.Path) -> tuple[int, str]:
259
332
  """Run one manifest command (shell-less; ``&&`` composites never land here)."""
260
333
  argv = shlex.split(check.command)
334
+ if argv and argv[0] == "npm":
335
+ problem = _node_modules_problem(root)
336
+ if problem is not None:
337
+ return 1, problem
261
338
  executable = shutil.which(argv[0]) or argv[0]
262
339
  try:
263
340
  completed = subprocess.run( # noqa: S603 - fixed manifest argv, shell=False
terp_cli-0.3.0/PKG-INFO DELETED
@@ -1,16 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: terp-cli
3
- Version: 0.3.0
4
- Summary: Terp command-line tool — inspect, scaffolding, migrations, checks, api-docs.
5
- License-Expression: Apache-2.0
6
- Requires-Python: >=3.13
7
- Requires-Dist: terp-arch==0.3.0
8
- Requires-Dist: terp-core==0.3.0
9
- Requires-Dist: terp-migrations==0.3.0
10
- Provides-Extra: jobs
11
- Requires-Dist: terp-cap-outbox==0.3.0; extra == 'jobs'
12
- Requires-Dist: terp-cap-scheduler-apscheduler==0.3.0; extra == 'jobs'
13
- Provides-Extra: scheduler
14
- Requires-Dist: terp-cap-scheduler-apscheduler==0.3.0; extra == 'scheduler'
15
- Provides-Extra: worker
16
- Requires-Dist: terp-cap-outbox==0.3.0; extra == 'worker'
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes