feat: mount module HTTP routes
Phase 5B, pass 2: production API mounting, authorization enforcement, and module-runtime lifecycle integration. apps/api now discovers the installed module inventory exactly once (prepareStartupModuleInventory), composes the platform permission registry from it, starts the module runtime from that same discovered list (startDiscoveredModuleRuntime, never rediscovering), and mounts StartedModuleRuntime's published routes under /api/v1/modules/<module-id> via a new apps/api-owned Hono adapter (module-http-routes.ts). Every module route is protected by the existing requirePermission middleware against the composed registry, state-changing methods additionally require a trusted Origin, OPTIONS remains entirely platform-owned, and a handler that throws, rejects, or returns a non-Response becomes a fixed, sanitized 500 MODULE_HANDLER_FAILED. Module-supplied CORS headers are stripped and every module response is forced to Cache-Control: no-store with Vary: Origin, Cookie. RunningApi shutdown now stops the module runtime between draining the HTTP server and closing the shared pool, and every startup failure path stops the runtime and/or closes the pool before propagating, without masking the original failure. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e296e083ec
commit
dfaf324a65
+87
-21
@@ -57,6 +57,7 @@ No real values are shown here — configure per environment and never commit the
|
||||
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Better Auth's own request/response shapes pass through unmodified — see [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for the exact endpoints (`/sign-up/email`, `/sign-in/email`, `/sign-out`, `/get-session`, ...). |
|
||||
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
|
||||
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. See [Authorization](authorization.md#explicit-grant-http-api). |
|
||||
| `/api/v1/modules/<module-id>/*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). |
|
||||
|
||||
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
|
||||
deterministic `404 {"code":"NOT_FOUND"}`.
|
||||
@@ -206,17 +207,74 @@ trusted-proxy IP policy is configured, Better Auth may fall back to a single sha
|
||||
rather than a genuine per-client bucket. This is not yet the final, distributed, production-grade
|
||||
abuse-control design.
|
||||
|
||||
## Module permission discovery at startup
|
||||
## Module discovery, permission composition, and module runtime startup
|
||||
|
||||
Before any database pool, Better Auth instance, or HTTP server exists, `startApi()` discovers
|
||||
every installed module's manifest and composes the one immutable platform permission registry
|
||||
(Core's own declarations plus every module's) that the rest of startup — and every subsequent
|
||||
request — enforces against. A discovery or composition failure (an invalid manifest, a module
|
||||
claiming the reserved `core.*` permission namespace, a duplicate permission declaration, ...) fails
|
||||
startup outright, before there is anything to close; startup never falls back to a Core-only
|
||||
registry. See [Authorization](authorization.md#api-startup-discovery-composition-and-enforcement)
|
||||
for the full flow, and [Module development](module-development/README.md) for the manifest shape
|
||||
that produces these declarations.
|
||||
`startApi()` composes production in one fixed order:
|
||||
|
||||
1. Discover every installed module's manifest exactly once and compose the one immutable platform
|
||||
permission registry (Core's own declarations plus every module's) — `prepareStartupModuleInventory`,
|
||||
`apps/api/src/platform-permissions.ts`.
|
||||
2. Construct the one shared PostgreSQL pool.
|
||||
3. Construct the Core identity runtime, authorization store, and evaluator.
|
||||
4. Start the module runtime from that exact same discovered inventory
|
||||
(`startDiscoveredModuleRuntime`) — modules are never rediscovered for this step.
|
||||
5. Create the Hono application, mounting the started module runtime's published routes.
|
||||
6. Bind the HTTP server.
|
||||
|
||||
Discovery and permission composition happen before any pool, Better Auth instance, module
|
||||
entrypoint import, or HTTP server exists, so a discovery or composition failure (an invalid
|
||||
manifest, a module claiming the reserved `core.*` permission namespace, a duplicate permission
|
||||
declaration, ...) fails startup outright, before there is anything to close; startup never falls
|
||||
back to a Core-only registry. Core infrastructure exists before any executable module lifecycle
|
||||
code runs, and every module route is published only after the *entire* module runtime has
|
||||
registered and started successfully, so no request can ever reach a module before every module has
|
||||
started. See [Authorization](authorization.md#api-startup-discovery-composition-and-enforcement)
|
||||
for the permission-composition detail, and [Module development](module-development/README.md) for
|
||||
the manifest shape that produces these declarations.
|
||||
|
||||
Unlike discovery/composition/Doctor/`modules:validate` (which only ever read manifests), a
|
||||
successful production startup does import each module's compiled server entrypoint and run its
|
||||
`register`/`start` hooks — this is the one place in the platform that deliberately executes module
|
||||
code, and only after every other startup step already listed has succeeded.
|
||||
|
||||
## Module HTTP routes
|
||||
|
||||
An installed module's routes (declared via `http.route(...)` in its `register` hook — see
|
||||
[Module development](module-development/README.md#http-routes) for the module-authoring contract)
|
||||
are mounted under `/api/v1/modules/<module-id>`, in the module runtime's published order, by
|
||||
`apps/api/src/module-http-routes.ts`. Mounting never invokes a handler.
|
||||
|
||||
- **Enforcement.** Every route runs through the identical `requirePermission` middleware Core's own
|
||||
routes use, with the route's own declared permission id as the only permission ever consulted —
|
||||
its trusted baseline always comes from the composed platform registry, never from the route or
|
||||
the request. A `public`, `authenticated`, and `restricted` module permission are enforced through
|
||||
the exact same path; a public-baseline route still resolves and receives an authorized principal.
|
||||
- **Trusted origin.** `POST`, `PUT`, `PATCH`, and `DELETE` module routes additionally require an
|
||||
exact trusted `Origin` (see [Origin, CORS, and cookies](#origin-cors-and-cookies)) before
|
||||
authorization ever runs, exactly like the existing grants-mutation routes. `GET` does not require
|
||||
one. `OPTIONS` remains entirely platform-owned: a module cannot register it, and a CORS preflight
|
||||
never reaches authorization or a handler. `GET` inherits Hono's own implicit `HEAD` handling
|
||||
(same permission check, response body stripped); a module cannot separately author `HEAD`.
|
||||
- **Handler input.** A handler receives the raw Fetch `Request` (so `Request.url`, headers, and
|
||||
body all behave normally, and the full query string remains available) plus a frozen context of
|
||||
exactly `moduleId`, the authorized `EkklesiaPrincipal`, and frozen, Hono-decoded path parameters
|
||||
(an empty frozen object for a route with none) — never a Hono `Context`, a database connection,
|
||||
the authorization store, storage, jobs, configuration, or secrets.
|
||||
- **Handler failure.** A handler that throws, rejects, or returns anything other than a `Response`
|
||||
becomes a fixed `500 {"code":"MODULE_HANDLER_FAILED"}` with `Cache-Control: no-store` — never the
|
||||
caught value, a stack, the route's path or permission id, or the module id. A `Response` a
|
||||
handler deliberately returns — including a 4xx/5xx one — passes through unchanged.
|
||||
- **CORS and caching.** The platform remains the sole owner of CORS for module routes exactly like
|
||||
every other `/api/v1/*` route (see [Origin, CORS, and cookies](#origin-cors-and-cookies), now
|
||||
additionally advertising `PUT`/`PATCH`/`DELETE`); any CORS-control header a handler sets on its
|
||||
own response is stripped before the response leaves the process. Every module response is forced
|
||||
to `Cache-Control: no-store` and a `Vary` that always includes `Origin` and `Cookie` (a handler's
|
||||
own legitimate `Vary` values are preserved alongside those) — deliberately conservative until a
|
||||
dedicated cache-policy contract exists, since even a public-baseline handler receives an
|
||||
authorized principal and may personalize its response.
|
||||
- **Static snapshot.** Routes are part of the same static startup snapshot as the permission
|
||||
registry: installing, removing, or editing a module's routes takes effect only on the next API
|
||||
restart. Removing a module makes its former routes return the ordinary `404 {"code":"NOT_FOUND"}`.
|
||||
|
||||
## Database connection limits
|
||||
|
||||
@@ -239,17 +297,25 @@ not two.
|
||||
`startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the
|
||||
executable entry point and tests) returns `{app, server, shutdown}`. `shutdown()` memoizes one
|
||||
shared promise the first time it is called: every caller, concurrent or sequential, awaits the
|
||||
exact same listener-drain-then-pool-close operation, and that operation runs exactly once no matter
|
||||
how many times `shutdown()` is called. It stops the HTTP listener from accepting new connections and
|
||||
lets in-flight requests finish, then closes the one shared PostgreSQL pool (identity and
|
||||
authorization both read from it — see
|
||||
[Authorization](authorization.md#shared-database-composition)). The executable entry point's
|
||||
`SIGINT`/`SIGTERM` wiring is extracted into `installShutdownSignalHandlers(running, exit)` — the
|
||||
same function production composition uses, but independently testable with a fake `running`/`exit`
|
||||
so a test can trigger "a signal arrived" without sending a real process signal or invoking the real
|
||||
`process.exit`. A startup failure after the database pool was already created (identity
|
||||
construction failure, or the server failing to bind its listen address) always closes that pool
|
||||
before the failure propagates.
|
||||
exact same operation, and that operation runs exactly once no matter how many times `shutdown()`
|
||||
is called. In order: it stops the HTTP listener from accepting new connections and lets in-flight
|
||||
requests finish; then stops the module runtime (so no module resource stops while a route handler
|
||||
might still be in flight); then closes the one shared PostgreSQL pool (identity and authorization
|
||||
both read from it — see [Authorization](authorization.md#shared-database-composition)). Unlike a
|
||||
startup-failure cleanup path (below), a module-stop failure here is not swallowed — it is a
|
||||
genuine result a caller should observe — but pool cleanup always still runs first, so a stop
|
||||
failure never leaves the pool open. The executable entry point's `SIGINT`/`SIGTERM` wiring is
|
||||
extracted into `installShutdownSignalHandlers(running, exit)` — the same function production
|
||||
composition uses, but independently testable with a fake `running`/`exit` so a test can trigger "a
|
||||
signal arrived" without sending a real process signal or invoking the real `process.exit`.
|
||||
|
||||
A startup failure after the database pool was already created always closes that pool before the
|
||||
failure propagates: identity/authorization construction failing (no module runtime was started
|
||||
yet); the server failing to bind its listen address; or an app-construction failure, both of which
|
||||
also stop the module runtime first, since it may have already started successfully. A module
|
||||
load/register/start failure uses Core's own existing rollback (already-started modules stop, in
|
||||
reverse order, before the failure propagates) and then closes the pool — there is no separate
|
||||
runtime handle to stop in that case, since `startDiscoveredModuleRuntime` never returned one.
|
||||
|
||||
## A real signup → sign-in → principal → sign-out transcript
|
||||
|
||||
|
||||
@@ -16,14 +16,15 @@ for the durable architectural decisions this page describes in more implementati
|
||||
|
||||
An operator can bootstrap exactly one platform owner for a deployment; a platform owner (or anyone
|
||||
they delegate to) can create, list, and revoke *explicit* authorization grants over HTTP for any
|
||||
registered permission, Core-owned or module-owned; and every enforced route fails closed on
|
||||
infrastructure trouble instead of silently allowing or denying. It is **not** possible to manage
|
||||
roles, groups, teams, or tenants; there is no owner-management HTTP endpoint (revoking or
|
||||
re-granting platform-owner status is not exposed at all); there are no service accounts or API
|
||||
tokens, so only cookie-authenticated browser callers can reach the mutation routes; a module cannot
|
||||
yet mount its own HTTP routes; and relationship-derived grants (for example, automatic access from
|
||||
a roster assignment) cannot yet be created by anything in this repository — the generic grants API
|
||||
structurally cannot create or revoke them either.
|
||||
registered permission, Core-owned or module-owned; a module can register its own HTTP routes and
|
||||
have them mounted, enforced, and reachable in production (see
|
||||
[API](api.md#module-http-routes)); and every enforced route fails closed on infrastructure trouble
|
||||
instead of silently allowing or denying. It is **not** possible to manage roles, groups, teams, or
|
||||
tenants; there is no owner-management HTTP endpoint (revoking or re-granting platform-owner status
|
||||
is not exposed at all); there are no service accounts or API tokens, so only cookie-authenticated
|
||||
browser callers can reach mutation routes, Core-owned or module-owned; and relationship-derived
|
||||
grants (for example, automatic access from a roster assignment) cannot yet be created by anything
|
||||
in this repository — the generic grants API structurally cannot create or revoke them either.
|
||||
|
||||
## Core authorization schema and store
|
||||
|
||||
@@ -105,27 +106,37 @@ integration layer depends on authorization).
|
||||
|
||||
## API startup: discovery, composition, and enforcement
|
||||
|
||||
`apps/api` builds its production permission registry from the complete installed-module inventory
|
||||
every time it starts, in a fixed order, before anything else:
|
||||
`apps/api` builds its production permission registry, and starts the module runtime, from the
|
||||
exact same installed-module inventory every time it starts, in a fixed order, before anything
|
||||
else:
|
||||
|
||||
1. Resolve module-discovery configuration (the repository's `modules/` directory, and the
|
||||
platform/Module SDK versions from their own `package.json` files).
|
||||
2. Discover installed modules (`discoverModules`).
|
||||
3. Compose the platform permission registry (`composePlatformPermissionRegistry`).
|
||||
2. Discover installed modules exactly once (`discoverModules`).
|
||||
3. Compose the platform permission registry from that exact discovered list
|
||||
(`composePlatformPermissionRegistry`).
|
||||
4. Construct the shared PostgreSQL pool.
|
||||
5. Construct Identity and Authorization.
|
||||
6. Create the Hono application, injecting the composed registry.
|
||||
7. Bind the HTTP server.
|
||||
5. Construct Identity, the authorization store, and the evaluator.
|
||||
6. Start the module runtime from that same discovered list (`startDiscoveredModuleRuntime`) —
|
||||
modules are never rediscovered for this step.
|
||||
7. Create the Hono application, injecting the composed registry and mounting the started module
|
||||
runtime's published routes (see [API](api.md#module-http-routes)).
|
||||
8. Bind the HTTP server.
|
||||
|
||||
`apps/api/src/platform-permissions.ts`'s `composeStartupPermissionRegistry` performs steps 1–3.
|
||||
Discovery and composition happen strictly before any pool, Better Auth instance, or HTTP server
|
||||
exists, so a failure there — an invalid manifest, a broken dependency graph, a module claiming the
|
||||
reserved `core.*` namespace, or a duplicate permission declaration — leaves nothing to close: it
|
||||
propagates directly out of `startApi()`, and startup never falls back to `CORE_PERMISSION_REGISTRY`
|
||||
or any Core-only registry. The repository root is resolved from this compiled/source file's own
|
||||
location (two directories up from `apps/api/dist/*.js` or `apps/api/src/*.ts`, which sit at
|
||||
identical depth), never `process.cwd()` — a package-scoped test run has `apps/api` as its working
|
||||
directory, which would otherwise point discovery at the wrong `modules/` directory.
|
||||
`apps/api/src/platform-permissions.ts`'s `prepareStartupModuleInventory` performs steps 1–3,
|
||||
returning both the discovered module list and the composed registry so step 6 can start the module
|
||||
runtime from that identical list rather than rediscovering modules a second time.
|
||||
(`composeStartupPermissionRegistry` remains available as a registry-only convenience wrapper
|
||||
around it, for callers — Doctor, `modules:validate`, existing tests — that only need the
|
||||
registry.) Discovery and composition happen strictly before any pool, Better Auth instance, module
|
||||
entrypoint import, or HTTP server exists, so a failure there — an invalid manifest, a broken
|
||||
dependency graph, a module claiming the reserved `core.*` namespace, or a duplicate permission
|
||||
declaration — leaves nothing to close: it propagates directly out of `startApi()`, and startup
|
||||
never falls back to `CORE_PERMISSION_REGISTRY` or any Core-only registry. The repository root is
|
||||
resolved from this compiled/source file's own location (two directories up from
|
||||
`apps/api/dist/*.js` or `apps/api/src/*.ts`, which sit at identical depth), never `process.cwd()`
|
||||
— a package-scoped test run has `apps/api` as its working directory, which would otherwise point
|
||||
discovery at the wrong `modules/` directory.
|
||||
|
||||
The resulting registry is a **static, immutable snapshot** taken once at startup. There is no hot
|
||||
installation, removal, or dynamic mutation of installed modules or their permissions while the
|
||||
@@ -148,10 +159,11 @@ consults the registry for a grant that already exists — revocation is keyed to
|
||||
and source kind, not to whether its permission is currently registered — so a manager can still
|
||||
revoke a historical grant for a now-unregistered permission.
|
||||
|
||||
Module HTTP route registration (a module owning its own mounted endpoints) and relationship-derived
|
||||
grant creation both remain deferred, unrelated slices — this work only makes a module's *declared*
|
||||
permission usable through Core's existing, generic explicit-grant HTTP API and enforcement
|
||||
middleware.
|
||||
A module's own mounted HTTP routes (`/api/v1/modules/<module-id>/*`) are enforced through this
|
||||
identical registry and `requirePermission` middleware — see [API](api.md#module-http-routes) for
|
||||
the route-mounting contract. Relationship-derived grant creation remains a deferred, unrelated
|
||||
slice — this work only makes a module's *declared* permission usable through Core's existing,
|
||||
generic explicit-grant HTTP API, enforcement middleware, and now its own mounted routes.
|
||||
|
||||
## Doctor and modules:validate
|
||||
|
||||
@@ -446,12 +458,34 @@ there are or whether the failure happened during startup.
|
||||
`403` and `store.createGrant` never called, exact server-derived-argument assertions,
|
||||
sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and
|
||||
store-error mapping.
|
||||
- `apps/api/tests/module-http-routes.test.ts` — the production route-mounting contract: exact
|
||||
prefix/path joining (including the non-trailing-slash root case), every supported method,
|
||||
independent same-path routes across two modules, deterministic static-versus-parameterized
|
||||
resolution by published order, request/handler adaptation (raw `Request`, frozen context, decoded
|
||||
and frozen path parameters, empty frozen params for static/root routes), the registry's exact
|
||||
baseline for `public`/`authenticated`/`restricted` module permissions, denial/infrastructure-
|
||||
failure paths never invoking the handler, `ModuleRouteConfigurationError` for an inconsistent
|
||||
route/registry snapshot, trusted-origin enforcement for state-changing methods, CORS (advertised
|
||||
methods, no reflection for an untrusted origin, no authorization or handler invocation on
|
||||
preflight), fixed sanitized `500` for a throwing/rejecting/non-`Response`-returning handler with
|
||||
no raw leakage, a valid module-owned 4xx/5xx response passing through unchanged, forced
|
||||
`Cache-Control`/`Vary` with a handler's own `Vary` preserved, stripped module-supplied CORS
|
||||
headers, `HEAD` traversing the same permission middleware as `GET` with no body, and unmatched
|
||||
paths/zero-route/no-handler-during-construction behavior.
|
||||
- `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above,
|
||||
plus module-permission composition at startup: discovery/composition run before any pool,
|
||||
Better Auth instance, or HTTP server is constructed; a discovery failure, a reserved-namespace
|
||||
failure, and a generic duplicate-declaration failure each create zero pools/Better Auth
|
||||
instances/servers and remain distinguishable from one another; zero installed modules still
|
||||
succeeds; and import safety for `platform-permissions.ts`.
|
||||
succeeds; and import safety for `platform-permissions.ts`. A dedicated block covers module
|
||||
*runtime* startup and shutdown against real, working (non-throwing) fixture modules: permission
|
||||
composition and module runtime startup share the exact same discovered inventory, route mounting
|
||||
order matches discovery order, a load/register/start failure each close the pool exactly once and
|
||||
bind no server (preserving Core's own rollback for a start failure), an app-construction failure
|
||||
and a bind failure both stop the module runtime before closing the pool, normal shutdown stops the
|
||||
server/module-runtime/pool in that exact order, a module stop failure still runs pool cleanup
|
||||
before the shutdown rejection surfaces, and a direct `discoveredModulesOverride` drives both
|
||||
composition and runtime startup identically.
|
||||
- `core/tests/authorization-evaluator.test.ts` — includes a malformed permission id denying under
|
||||
every baseline (`public`, `authenticated`, `restricted`), never just `restricted`, and never
|
||||
consulting the grant or platform-owner ports for `public`/`authenticated`.
|
||||
@@ -466,16 +500,23 @@ there are or whether the failure happened during startup.
|
||||
invisible to LIST and unreachable by REVOKE, and an infrastructure-failure scenario (database
|
||||
dropped out from under a live session) asserting a `503` rather than a false `403` or `200`.
|
||||
- `apps/api/tests/api-http.integration.ts`, "installed module permission end-to-end" (live
|
||||
PostgreSQL) — a real temporary module fixture (structurally valid manifest, a server entrypoint
|
||||
that throws if ever imported) proves the complete chain against a real API process and real
|
||||
Doctor run: Doctor recognizes the installed module and passes permission composition without
|
||||
executing its entrypoint; API startup discovers the fixture and makes its declared permission
|
||||
enforceable; a bootstrapped platform owner creates a real explicit grant for the module's
|
||||
permission with an atomic audit row recorded under its exact declared id; a well-formed
|
||||
permission absent from the composed registry is rejected with no grant or audit write; and,
|
||||
after a clean shutdown and restart with the module absent, the same permission can no longer be
|
||||
newly granted while the earlier, still-unrevoked grant remains listed — with ordinary identity
|
||||
HTTP behavior (sign-up, sign-in, principal resolution) unaffected throughout.
|
||||
PostgreSQL) — a real temporary module fixture (structurally valid manifest; a real, working
|
||||
server entrypoint recording register/start/stop markers instead of throwing, and declaring a
|
||||
restricted `GET /events/:eventId` route plus a restricted, state-changing `POST /events` route
|
||||
against its own manifest permission) proves the complete chain against a real API process and
|
||||
real Doctor run: Doctor recognizes the installed module and passes permission composition
|
||||
without executing its entrypoint (an empty marker set); a successful `startApi()` discovers the
|
||||
same fixture, imports/registers/starts its runtime exactly once, and makes its declared
|
||||
permission enforceable; a bootstrapped platform owner creates a real explicit grant for the
|
||||
module's permission with an atomic audit row recorded under its exact declared id; the module's
|
||||
own mounted route denies an anonymous caller and then serves the newly granted owner with the
|
||||
decoded path parameter and authenticated principal; the module's state-changing route requires a
|
||||
trusted `Origin` exactly like the platform's own mutation routes; a well-formed permission absent
|
||||
from the composed registry is rejected with no grant or audit write; and, after a clean shutdown
|
||||
(module stop invoked exactly once) and restart with the module absent, its former route returns
|
||||
the ordinary `404`, the same permission can no longer be newly granted, and the earlier,
|
||||
still-unrevoked grant remains listed — with ordinary identity HTTP behavior (sign-up, sign-in,
|
||||
principal resolution) unaffected throughout.
|
||||
|
||||
Run the focused suites:
|
||||
|
||||
@@ -487,10 +528,11 @@ pnpm test:migrations:integration # requires Docker
|
||||
|
||||
## Next phase
|
||||
|
||||
Deliberately deferred, unrelated slices — see
|
||||
Module HTTP route registration and production mounting (see
|
||||
[ADR 0014](../architecture/decisions/0014-module-permission-declarations-and-platform-registry.md#deferred)
|
||||
for the complete list: module HTTP route-registration contracts (a module mounting its own
|
||||
endpoints), a module runtime-context authorization service, relationship-derived grant creation
|
||||
for the deferral this closes) is now implemented — see [API](api.md#module-http-routes) and
|
||||
[Module development](module-development/README.md#http-routes). Deliberately deferred, unrelated
|
||||
slices remain: a module runtime-context authorization service, relationship-derived grant creation
|
||||
itself (a roster or similar module operation, not the generic grants API), reinstall/reactivation
|
||||
policy for a permission whose owning module was previously removed, roles/groups/teams/tenants, an
|
||||
owner-management HTTP endpoint, and service accounts/API tokens for non-browser automation.
|
||||
|
||||
Reference in New Issue
Block a user