Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
75 commits
Select commit Hold shift + click to select a range
d7c7b69
docs(specs): add competitor-gap specs from Maintenant analysis
fclairamb Jun 20, 2026
3453911
docs(specs): validate & revise StatusWarning spec against code
fclairamb Jun 20, 2026
baf9821
docs(specs): apply status decisions — two-status Warning + Degraded m…
fclairamb Jun 20, 2026
76d1319
docs(specs): apply recommended choices & cross-spec consistency
fclairamb Jun 20, 2026
1c8366b
chore: start implementing check-detail header actions own row
fclairamb Jun 20, 2026
d63c920
feat: stack check-detail header action toolbar onto its own row
fclairamb Jun 20, 2026
4792a83
feat: assert check-detail toolbar stacks below title in E2E
fclairamb Jun 20, 2026
1d3629f
chore: all checks passing for check-detail header actions own row
fclairamb Jun 20, 2026
75179ec
docs: archive completed spec check-detail header actions own row
fclairamb Jun 20, 2026
9ec2fba
Merge feat/check-detail-header-actions-own-row into batch/2026-06-20
fclairamb Jun 20, 2026
39dc396
chore: start implementing destructive button tinted shadow hover
fclairamb Jun 20, 2026
32869c4
feat: give destructive button tinted red elevation and hover lift
fclairamb Jun 20, 2026
63550e2
feat: assert destructive button shadow changes on hover in E2E
fclairamb Jun 20, 2026
30ce343
chore: all checks passing for destructive button tinted shadow hover
fclairamb Jun 20, 2026
2630368
docs: archive completed spec destructive button tinted shadow hover
fclairamb Jun 20, 2026
8b092ba
Merge feat/destructive-button-tinted-shadow-hover into batch/2026-06-20
fclairamb Jun 20, 2026
581ec63
chore: start implementing regenerate favicon pngs from new brand mark
fclairamb Jun 20, 2026
287a08e
chore: regenerate favicon PNG set from new brand mark
fclairamb Jun 20, 2026
6ba5124
chore: all checks passing for regenerate favicon pngs from new brand …
fclairamb Jun 20, 2026
1670f57
docs: archive completed spec regenerate favicon pngs from new brand mark
fclairamb Jun 20, 2026
cc5c7cf
Merge feat/regenerate-favicon-pngs-from-new-brand-mark into batch/202…
fclairamb Jun 20, 2026
95209a0
chore: start implementing status warning degraded
fclairamb Jun 20, 2026
01068f4
docs: add implementation plan for status warning degraded
fclairamb Jun 20, 2026
55a2fcf
feat: add Warning(8) and Degraded(7) status enums + Severity() ranking
fclairamb Jun 20, 2026
dcb9eae
feat: teach status->string mappers and status filter the warning/degr…
fclairamb Jun 20, 2026
3642ef5
feat: widen results.status CHECK to allow 6(error)/7(degraded)/8(warn…
fclairamb Jun 20, 2026
ccd6515
feat: count Warning as up and promote non-failing windows with warnin…
fclairamb Jun 20, 2026
427dfe4
feat: add incident-neutral Warning branch (visible status, up<->warni…
fclairamb Jun 20, 2026
3871276
feat: add shared statusStyle() util and route dash0+status0 surfaces …
fclairamb Jun 20, 2026
b324876
feat: add tests for warning/degraded enums, aggregation promotion, se…
fclairamb Jun 20, 2026
3070cc3
fix: resolve lint findings (exhaustive String case, misspellings, tes…
fclairamb Jun 20, 2026
0b348af
chore: all checks passing for status warning degraded
fclairamb Jun 20, 2026
ed2fcc9
docs: archive completed spec status warning degraded
fclairamb Jun 20, 2026
cb32653
Merge feat/status-warning-degraded into batch/2026-06-20
fclairamb Jun 20, 2026
f6daf9e
chore: start implementing ssl graduated expiry and chain reporting
fclairamb Jun 20, 2026
9583d2e
docs: add implementation plan for ssl graduated expiry and chain repo…
fclairamb Jun 20, 2026
2deaf00
feat: add shared gradedExpiryStatus helper and severity constants in …
fclairamb Jun 20, 2026
2b743a0
feat: two-tier ssl expiry config with legacy alias and whole-chain mi…
fclairamb Jun 20, 2026
034c393
feat: add table-driven chain-expiry tier tests with crafted cert chains
fclairamb Jun 20, 2026
56fb390
feat: ssl two-tier expiry form fields and chain-table check-detail card
fclairamb Jun 20, 2026
0313da7
docs: document ssl graduated expiry tiers and whole-chain output shape
fclairamb Jun 20, 2026
fc4b6a4
chore: all checks passing for ssl graduated expiry and chain reporting
fclairamb Jun 20, 2026
6b2da65
docs: archive completed spec ssl graduated expiry and chain reporting
fclairamb Jun 20, 2026
30000ab
Merge feat/ssl-graduated-expiry-and-chain-reporting into batch/2026-0…
fclairamb Jun 20, 2026
282ee09
chore: start implementing docker restart loop detection
fclairamb Jun 20, 2026
2c70412
docs: add implementation plan for docker restart loop detection
fclairamb Jun 20, 2026
1b864c8
feat: add restart-loop detection config fields to docker checker
fclairamb Jun 20, 2026
096ea58
feat: detect docker restart loop in buildResult and return StatusWarning
fclairamb Jun 20, 2026
2dd97ae
feat: add table-driven tests for docker restart loop detection
fclairamb Jun 20, 2026
d8b8c0f
feat: docker restart-loop form fields and check-detail restart-activi…
fclairamb Jun 20, 2026
c71b1b0
docs: document docker restart-loop config fields and output shape
fclairamb Jun 20, 2026
d63c13d
chore: all checks passing for docker restart loop detection
fclairamb Jun 20, 2026
ed23f8e
docs: archive completed spec docker restart loop detection
fclairamb Jun 20, 2026
ccadfdb
Merge feat/docker-restart-loop-detection into batch/2026-06-20
fclairamb Jun 20, 2026
1858b30
chore: start implementing mcp oauth2 authorization
fclairamb Jun 20, 2026
151fae3
docs: add implementation plan for mcp oauth2 authorization
fclairamb Jun 20, 2026
8ef5bb2
feat: mcp oauth2 authorization server core (phases 1-3 backend)
fclairamb Jun 20, 2026
85fe2e0
feat: mcp oauth2 consent screen + deny handling
fclairamb Jun 20, 2026
19a8798
docs: document mcp oauth2 discovery and flow endpoints
fclairamb Jun 20, 2026
a9fbaf7
test: mcp oauth2 flow, discovery, pkce, audience, repos (sqlite+postg…
fclairamb Jun 20, 2026
4a93aa9
chore: all checks passing for mcp oauth2 authorization
fclairamb Jun 20, 2026
25c4e6e
docs: archive completed spec mcp oauth2 authorization
fclairamb Jun 20, 2026
e1c4d56
Merge feat/mcp-oauth2-authorization into batch/2026-06-20
fclairamb Jun 20, 2026
0ce389f
docs(ideas): ground config-as-code in existing export/import + CLI
fclairamb Jun 20, 2026
e7d8d5a
docs: promote config-as-code from ideas to todos
fclairamb Jun 20, 2026
6a61f92
chore: start implementing config-as-code
fclairamb Jun 20, 2026
fc340f8
docs: add implementation plan for config-as-code
fclairamb Jun 20, 2026
e9d2257
feat(checks): config-as-code apply engine, endpoint, secret refs, adm…
fclairamb Jun 20, 2026
2a82028
feat(cli): sp apply + checks export/import commands
fclairamb Jun 20, 2026
7731a95
docs: config-as-code apply endpoint, manifest format, CLI, admin-gati…
fclairamb Jun 20, 2026
af88b91
test(checks): config-as-code apply tests + lint cleanup
fclairamb Jun 20, 2026
1d26fa5
chore: all checks passing for config-as-code
fclairamb Jun 20, 2026
93f79f2
docs: archive completed spec config-as-code
fclairamb Jun 20, 2026
7bb40dc
Merge feat/config-as-code into batch/2026-06-20
fclairamb Jun 20, 2026
c3ceab5
fix(e2e): scope check-detail header overflow assertion to the header
fclairamb Jun 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ the relevant code.
- [features/email-inbox-checks.md](features/email-inbox-checks.md) — Passive checks that succeed when an email arrives. JMAP supervisor, per-check token, status resolution priority, mailbox retention, distinction from email-as-channel.
- [features/mcp.md](features/mcp.md) — Model Context Protocol surface: endpoint, scopes (`mcp` / `mcp:read`), tool inventory, prompts, sessions, protocol version negotiation, how to add a new tool.
- [features/browser-monitoring.md](features/browser-monitoring.md) — Headless-Chrome (chromedp) checks: when to pick browser over http, execution model, capabilities & limits, worker requirements, security model.
- [features/config-as-code.md](features/config-as-code.md) — Declarative checks: export → edit → `sp apply` loop, the `solidping.io/managed` scope, reconcile plan (create/update/delete/unmanaged/rename), `${env:}`/`${param:}` secret references, prune + deletion cap, admin gating.

## Conventions

Expand Down
136 changes: 133 additions & 3 deletions docs/api-specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,15 +307,20 @@ Returns `{"valid": true}` or `{"valid": false, "fields": [...]}` with one
field-level entry per failing validator.

### GET /api/v1/orgs/:org/checks/export
Export all checks as JSON. Auth: required
Export all checks as JSON. Auth: **admin** (org admin role required)

Each `ExportCheck` carries an optional `dependsOn` array of
`{parentSlug, kind, description?}` entries, sorted by `parentSlug` for
deterministic diffs. The field is `omitempty` — exports for orgs with no
dep edges stay byte-identical to the pre-dependsOn shape.

> **Back-compat note (2026-06-20):** export/import were previously gated by
> authentication only (any org member). They are now **admin-only**, alongside
> the new apply endpoint, because they read/mutate the whole check set. Scripts
> that called these as a non-admin user must switch to an admin token.

### POST /api/v1/orgs/:org/checks/import
Import checks from JSON. Auth: required
Import checks from JSON. Auth: **admin** (org admin role required)

Two-pass when any entry carries `dependsOn`: pass 1 upserts every check
unchanged, pass 2 resolves `parentSlug` → check UID against the now-current
Expand All @@ -326,6 +331,65 @@ in the existing `errors` array. Pass 2 is skipped silently for any check
whose pass-1 upsert failed, with an explicit
`skipped dependsOn: pass-1 upsert failed for this check` error.

### POST /api/v1/orgs/:org/checks/apply
Reconcile checks against a declarative manifest (config-as-code). Auth:
**admin** (org admin role required). This is the *reconcile sibling* of
`/import` — idempotent upsert-by-slug plus delete-by-absence within a bounded,
opted-in managed scope.

**Request body.** The existing export document shape (`{version, organization,
checks[]}`), accepted as **JSON or YAML** (sniffed from `Content-Type` and the
first non-space byte). YAML is the hand-authoring surface; JSON is what export
emits — both parse to the same plan.

**Managed scope.** Apply stamps every check it owns with a reserved label
`solidping.io/managed=<manifest-name>`, where the manifest name is the document's
`organization` field (falling back to the org slug). The reconcile scope is
exactly the checks carrying that label. Hand-created checks (no managed label)
are reported as `unmanaged` and are **never** adopted, modified destructively,
or deleted.

**Plan / reconcile semantics.** Matching is on `slug` within the managed scope:
- `create` — slug in the manifest, absent from the org.
- `update` — managed slug present in both.
- `unmanaged` — slug exists **without** the managed label (reported only).
- `delete` — managed check absent from the manifest (delete-by-absence).
- `rename` — a manifest check with `previousSlug` (or `uid`) referencing an
existing managed check reconciles the rename in place instead of delete+create.

**Secret references.** Config string values may contain `${env:NAME}` and
`${param:KEY}` references, resolved **server-side at apply time** (env vars; the
`parameters` table — org-scoped first, then system-wide) into the existing
encrypted `config_private` envelope. The committed manifest stays secret-free.
A missing/unresolvable reference is a hard apply error. When
`SP_ENCRYPTION_MASTER_KEY` is unset (plaintext fallback), resolving a secret ref
emits a `warnings[]` entry rather than refusing — the resolved value lands in
plaintext config.

**Deletion safety (belt-and-suspenders).** Delete-by-absence happens **only**
when all of: (a) `?prune=true` is set, (b) the check carries the managed label,
and (c) the delete count is within the deletion cap (default 10). Beyond the
cap, apply refuses with `409 CONFLICT` unless `?force=true`.

Query parameters:
- `dryRun=true` — compute and return the plan only; mutate nothing.
- `prune=true` — enable delete-by-absence for managed, absent checks.
- `force=true` — lift the deletion cap for this apply.
- `deletionCap=<n>` — override the default cap (0 ⇒ default 10).

**Response** (extended import result):
```json
{
"manifest": "default",
"dryRun": false,
"pruned": true,
"created": 1, "updated": 2, "deleted": 1, "unmanaged": 0,
"plan": [{"slug": "api", "action": "update"}, {"slug": "old", "action": "delete"}],
"warnings": [],
"errors": []
}
```

### GET /api/v1/orgs/:org/checks/:checkUid
Get a single check by UID or slug. Auth: required

Expand Down Expand Up @@ -811,7 +875,73 @@ Slack interactive component handler. Auth: Slack signature verification
## MCP (Model Context Protocol)

### POST /api/v1/mcp
MCP endpoint for AI tool integrations. Auth: required (PAT token, org derived from token)
MCP endpoint for AI tool integrations. Auth: required — either a `mcp`/`mcp:read`-scoped
PAT pasted as a bearer token (back-compat) or an OAuth-issued access token (see OAuth below).
Org is derived from the token. On missing/invalid auth this endpoint returns **401** with a
`WWW-Authenticate: Bearer resource_metadata="<issuer>/.well-known/oauth-protected-resource"`
header so standard MCP clients can discover the authorization server and start the OAuth flow.
OAuth-issued tokens are audience-bound (RFC 8707) to the MCP resource and rejected here if their
`aud` does not include `<issuer>/api/v1/mcp`.

---

## OAuth 2.1 (MCP authorization server)

SolidPing is an embedded OAuth 2.1 authorization server for the MCP resource (spec
2026-06-20-03). Standard MCP clients (Claude Desktop, claude.ai remote connector, `mcp-remote`)
self-onboard via discovery → register → authorize+consent → token, with no hand-pasted token.
Issuer = the SolidPing base URL; the resource is `<issuer>/api/v1/mcp`. Access tokens reuse the
existing HS256 JWT format; refresh tokens rotate. PKCE (`S256`) is mandatory.

### GET /.well-known/oauth-protected-resource
RFC 9728 protected-resource metadata. Public, no auth. Advertises `resource` (the MCP URL),
`authorization_servers` (the issuer), `scopes_supported` (`mcp`, `mcp:read`), and
`bearer_methods_supported`.

### GET /.well-known/oauth-authorization-server
RFC 8414 authorization-server metadata. Public, no auth. Advertises `authorization_endpoint`,
`token_endpoint`, `registration_endpoint`, `jwks_uri`,
`code_challenge_methods_supported=["S256"]`, `grant_types_supported=["authorization_code",
"refresh_token"]`, `response_types_supported=["code"]`, and `scopes_supported=["mcp","mcp:read"]`.

### GET /.well-known/openid-configuration
Alias of the authorization-server metadata (many clients probe this path). Public, no auth.

### GET /.well-known/jwks.json
JWKS endpoint (`jwks_uri`). Public, no auth. The v1 signing key is symmetric (HS256), so the
secret is never published — this serves a well-formed but empty key set. The MCP resource server
validates tokens itself; clients do not verify locally. Asymmetric keys are a documented follow-on.

### POST /api/v1/oauth/register
RFC 7591 dynamic client registration. Public. Accepts `redirect_uris` (required), `client_name`,
`grant_types`, `response_types`, `scope`, `token_endpoint_auth_method`. Native clients are public
(PKCE + loopback redirects `http://127.0.0.1:*` / `http://localhost:*` / `http://[::1]:*`) and get
no secret; a client requesting a secret-based auth method is confidential and gets a `client_secret`
returned once. Redirect URIs must be https or http-loopback. Returns `client_id` (+ `client_secret`
for confidential clients).

### GET /api/v1/oauth/authorize
Authorization endpoint. Requires a logged-in dashboard session (the `access_token` cookie); if
absent, redirects to `/dash0/login?returnTo=…` and back. Validates `client_id`, `redirect_uri`
(exact match against the registered set, loopback ignores the port), `response_type=code`, PKCE
`code_challenge` + `code_challenge_method=S256` (both required; `plain` and missing are rejected),
`scope ⊆ {mcp, mcp:read}`, and `resource` (must equal the MCP resource). On success redirects to the
dashboard consent screen (`/dash0/orgs/:org/oauth/consent`).

### POST /api/v1/oauth/authorize
Consent decision. Re-validates the request, requires a session, and reads `decision` (`approve` /
`deny`). On approve, mints a single-use, short-TTL authorization code bound to
client→redirect→PKCE-challenge→resource→scope→user/org and redirects to the client's `redirect_uri`
with `code` + `state`. On deny, redirects with `error=access_denied`.

### POST /api/v1/oauth/token
Token endpoint (form-encoded). `grant_type=authorization_code` exchanges `code` + `code_verifier`
(+ matching `client_id`, `redirect_uri`) for an access token and a refresh token; the PKCE verifier
is checked against the stored S256 challenge and the code is consumed single-use.
`grant_type=refresh_token` rotates the refresh token (the presented one is revoked atomically and a
new pair issued). Access tokens are JWTs with `aud` = the MCP resource and the consented scopes,
short-lived; refresh tokens are revoked on logout and PAT revoke. Errors use the RFC 6749 §5.2 JSON
shape (`{ "error": "...", "error_description": "..." }`).

---

Expand Down
56 changes: 55 additions & 1 deletion docs/conventions/checker-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,45 @@ Either `url` or `host` is required. Default period: 5 minutes.
|-------|------|----------|---------|-------------|
| `host` | string | R | | Target hostname |
| `port` | int | O | 443 | Target port. Validation: 1-65535 |
| `threshold_days` | int | O | 30 | Days before expiration to mark as down. Must be >= 0 |
| `criticalDays` | int | O | 30 | Days before expiration to mark **Down** (paging). Must be `0..3650` |
| `warningDays` | int | O | 30 | Days before expiration to mark **Warning** (amber, non-paging). Must be `>= criticalDays`, `0..3650` |
| `thresholdDays` | int | O | 30 | **Legacy alias** for `criticalDays`. Read for back-compat; `criticalDays` wins when both are set |
| `timeout` | duration | O | 10s | Connection + handshake timeout (max: 60s) |
| `server_name` | string | O | same as host | Override SNI server name |

Min period: 1 hour. Default period: 6 hours.

**Graduated expiry & whole-chain reporting.** Expiry is evaluated against the
**minimum days-remaining across the whole presented certificate chain** (leaf +
intermediates), not just the leaf — an intermediate that expires first now drives
the status. Two tiers:

- `min <= criticalDays` → **Down** (`severity: "critical"`), pages, as before.
- `criticalDays < min <= warningDays` → **Warning** (`severity: "warning"`,
`certExpiryWarning: true`) — amber, counts as up, opens no incident; rollups
show **Degraded**.
- else → **Up**.

Defaults keep `warningDays == criticalDays == 30`, so behaviour is **unchanged**
until you widen `warningDays`. A handshake failure / actual expiry is **Down** as
before (trust is enforced at handshake). Recommended: lower `criticalDays` (e.g.
to 7) and widen `warningDays` (e.g. 30) so a valid-but-soon-expiring cert is a
warning, not a month-long fake outage.

Output (in `result.output`):

| Key | Description |
|-----|-------------|
| `chain` | Array of `{subject, issuer, notAfter, daysRemaining}`, one per presented cert |
| `soonestExpiring` | `{subject, daysRemaining}` for the cert driving the status |
| `chainLength` | Number of presented certificates |
| `chainVerified` | `true` when the handshake produced a verified chain |
| `certExpiryWarning` | `true` only in the warning tier |
| `severity` | `"critical"`, `"warning"`, or `""` |
| `subject`, `issuer`, `not_after`, `days_remaining`, `serial_number`, `dns_names` | Leaf-certificate fields (kept for back-compat) |

`metrics.days_remaining` is the **chain minimum** (the meaningful number to graph).

---

### `domain` -- Domain name expiration
Expand Down Expand Up @@ -425,9 +458,30 @@ Min period: 6 hours. Default period: 24 hours.
| `containerName` | string | O* | | Container name. *At least one of `containerName`/`containerId` required |
| `containerId` | string | O* | | Container ID. *At least one of `containerName`/`containerId` required |
| `timeout` | duration | O | 10s | Check timeout (max: 60s) |
| `restartLoopMinRestarts` | int | O | `0` (disabled) | Opt-in restart-loop detection. When `> 0`, a *running* container whose lifetime restart count is at least this many **and** that (re)started within `restartLoopWindow` is flagged as a suspected crash-loop |
| `restartLoopWindow` | duration | O | 120s (when enabled) | Recency window for restart-loop detection. Only applied when `restartLoopMinRestarts > 0`. Max: 24h |

Requires Docker socket access.

**Restart-loop detection (opt-in heuristic).** A container that is *running* at
inspect time but crash-looping (restarting every few seconds) would otherwise
report `up`. When `restartLoopMinRestarts > 0`, the checker flags it: an actively
looping container always shows a freshly-recent `startedAt`, so the recency
window (`restartLoopWindow`) carries the signal while the count floor
(`restartLoopMinRestarts`) keeps a single deploy restart from tripping it. A
detected loop returns the **`warning`** status (counts as up, rolls up to
`degraded`, does not page); a genuinely not-running/unhealthy container still
returns `down` and pages. Not-running and unhealthy short-circuits are evaluated
*before* the loop branch. Additional output keys when detection is enabled:

| Output key | Type | Meaning |
|------------|------|---------|
| `secondsSinceStart` | number | Seconds since the current run started (always emitted when detection is enabled, even below threshold) |
| `restartLoop` | bool | `true` only when a loop is suspected (status `warning`) |

`metrics.restartCount` is always emitted (lifetime restart count), independent of
detection.

---

## Specialized
Expand Down
93 changes: 93 additions & 0 deletions docs/features/config-as-code.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Config-as-code (declarative checks)

Manage an org's checks declaratively from a file in git: export the current
state, edit it, and `apply` it back. Apply is the **reconcile sibling** of
import — idempotent upsert-by-slug plus delete-by-absence within a bounded,
opted-in managed scope. The manifest is the existing export document shape, so
`export → edit → apply` round-trips with no separate schema.

This page covers the workflow and the CLI; the HTTP surface (request/response,
query flags, managed scope, secret references, deletion safety) is documented at
[`api-specification.md`](../api-specification.md) under
`POST /api/v1/orgs/:org/checks/apply`.

## The managed scope

Apply stamps every check it owns with a reserved label
`solidping.io/managed=<manifest-name>` (the manifest name is the document's
`organization` field, falling back to the org slug). The reconcile scope is
exactly the checks carrying that label. **Hand-created checks are never adopted,
modified destructively, or deleted** — they surface in the plan as `unmanaged`.

Plan actions (matched on `slug` within the managed scope):

| Action | Meaning |
|---|---|
| `create` | In the manifest, absent from the org. |
| `update` | Managed slug present in both. |
| `unmanaged` | Slug exists without the managed label — reported only. |
| `delete` | Managed check absent from the manifest (delete-by-absence). |
| `rename` | Manifest check with `previousSlug`/`uid` → rename in place. |

## Secret references

Never inline secrets. Config string values may reference `${env:NAME}` and
`${param:KEY}`, resolved **server-side at apply time** (env vars; the
`parameters` table — org-scoped first, then system-wide) into the existing
encrypted `config_private` envelope. The committed file stays secret-free. A
missing reference is a hard apply error. With `SP_ENCRYPTION_MASTER_KEY` unset,
a resolved reference emits a warning (the value lands in plaintext config).

```yaml
version: 1
organization: default
checks:
- slug: api
name: API
type: http
enabled: true
config:
url: https://api.example.com/health
header_authorization: "Bearer ${param:api_token}"
```

## Deletion safety

Delete-by-absence happens **only** when all of: `--prune` is set, the check
carries the managed label, and the delete count is within the deletion cap
(default 10). Beyond the cap, apply refuses unless `--force`.

## Authorization

Apply, export, and import are all **admin-only**. (Export/import were
authentication-only before 2026-06-20 — see the back-compat note in the API
spec.)

## CLI

```bash
# Bootstrap a manifest from the current org state
sp checks export --file checks.json
# …edit checks.json (or convert to YAML)…

# Preview the reconcile plan (mutates nothing)
sp apply -f checks.yaml --dry-run

# Apply (prints the plan, then prompts before mutating)
sp apply -f checks.yaml

# Apply non-interactively, allowing deletes of absent managed checks
sp apply -f checks.yaml --prune --yes

# Lift the deletion cap for a large prune
sp apply -f checks.yaml --prune --force --yes

# Plain idempotent import (no deletes, no managed scope)
sp checks import checks.json --dry-run
sp checks import checks.json
```

`sp apply` always computes a dry-run plan first and prints it; without `--yes`
it prompts for confirmation before applying. The file extension selects the body
format (`.json` → JSON, otherwise YAML). All three commands require an admin
session.
Binary file modified res/favicons/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified res/favicons/favicon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified res/favicons/favicon-32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified res/favicons/favicon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading