mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 22:10:25 +00:00
feat(apps): admin Apps toggle, paused webhook status, app-name attribution, developer docs (TASK-3980) (#2258)
* feat(apps): admin Apps toggle, paused webhook status, app-name attribution, developer docs (TASK-3980)
Polish from the Connected Apps walkthrough, items 1-6:
1. Console > Admin > Settings gains an Apps section on the existing
GET/PUT /api/v1/admin/apps (draft-then-Save, read-only on Cloud); the
Apps tab's off-state copy now names it.
2. The install preview drops the stale "will activate when this server
supports them" notice: events and item actions are live.
3. An item an app's bot created reads "Created ... by <app name>" instead
of "by agent"; a person acting through an app keeps "by <person> via
<app>". created_by storage is unchanged.
4. An install's webhook status is derived as "paused" while the install
is not active, matching the delivery gate, instead of reading "active".
5. The context-code redeem route is /api/app/v1/context/redeem in both
code comments that named it under /workspaces/{ws}.
6. docs/apps.md: a developer guide to the manifest, install and redeem,
tokens, the app API, webhooks, item actions and lifecycle.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U
* docs(apps): r1 FIX-OR-CITE on the four untraced areas (TASK-3980)
Every claim in webhook delivery, install refusals, manifest limits and
comment author_kind is now backed by a file:line or corrected:
- The text limits (title, publisher, description, name, label, path)
are bytes, not characters.
- id and slug say their start and end rules.
- Reserved slugs refuse with 422 invalid_manifest.
- Unknown keys are refused in the manifest and collection schemas, but
not inside the free-form config_schema.
- Confirm documents 409 install_review_mismatch.
- Redeem documents its limits: 10 a minute per address, 5 per install.
- author_kind says when each value occurs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U
* docs(apps): r2 Lows and FIX-OR-CITE on the OAuth paragraphs (TASK-3980)
r2 Lows:
- Apps off: the apps list answers 200 available:false, and every other
app route answers 404.
- Preview failures add invalid_base_url 400, artifact_fetch_failed 502
and artifact_destination_missing 422.
- paused takes precedence over the held notice. Enable restores the
hook's earlier status. Only rotate takes the secret away.
FIX-OR-CITE on delegated consent and OAuth (24 claims):
- Do not discover the token endpoint from the well-known document. It is
gated on MCP OAuth and describes MCP clients.
- Only reads are re-validated before send; writes and downloads re-check
at their own points.
- Name the three OAuth doors that stay open with MCP off.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+446
@@ -0,0 +1,446 @@
|
||||
# Building a Pad Connected App
|
||||
|
||||
This guide is for a developer who wants to build an app that installs into a Pad workspace. It covers the manifest an app publishes, how a workspace owner installs it, how the app authenticates, the app API it calls, the webhooks Pad sends it, and what the owner can do to it afterwards.
|
||||
|
||||
It describes what the server does today. Where a behaviour is a limit rather than a feature, it is called out under [Limitations](#limitations).
|
||||
|
||||
## Overview
|
||||
|
||||
A Connected App is a web service you run at an `https` origin. It is not a plugin and no code of yours runs inside Pad. You publish a JSON manifest at a well-known URL; a workspace owner points Pad at your origin, reviews what the manifest asks for, and confirms. Pad then provisions:
|
||||
|
||||
- **A bot member.** The app gets its own workspace member, named after the manifest `title`. It holds the role `editor` when the manifest's service access is `write`, `viewer` when it is `read`. It is never an owner.
|
||||
- **Companion collections.** The collections declared in `companion_pack` are created in the workspace (or adopted from an earlier install of the same origin). These are the only collections the app can write to.
|
||||
- **A read ceiling.** The app can read its own companion collections plus the workspace's system collections (Conventions and Playbooks). It cannot read anything else.
|
||||
- **An OAuth client.** A confidential client (`client_secret_basic`) used to obtain tokens. The client secret is handed to the app once, through an install code.
|
||||
- **Optionally a webhook and item actions**, if the manifest declares `events` and `item_actions`.
|
||||
|
||||
The app then talks to Pad over `/api/app/v1` with one of two kinds of token:
|
||||
|
||||
| Token | Acts as | Obtained with |
|
||||
|---|---|---|
|
||||
| Service token | The app's bot | `client_credentials` |
|
||||
| Delegated token | A workspace member who signed in through the app | `authorization_code` + PKCE (and `refresh_token`) |
|
||||
|
||||
Neither token works on the normal `/api/v1` API, and no session or personal access token works on `/api/app/v1`.
|
||||
|
||||
## Prerequisites on the Pad server
|
||||
|
||||
- **An admin must enable Apps.** On a self-hosted server it is off by default. An instance admin turns it on in **Console → Admin → Settings → Apps** (or `PUT /api/v1/admin/apps {"enabled": true}`). Until then the app API and the workspace's app routes answer `404`, except the workspace's apps list (`GET /api/v1/workspaces/{ws}/apps`), which answers `200` with `available: false`.
|
||||
- **The server needs an `https` public origin and an OAuth server.** Apps are available on a self-hosted server only when Apps is enabled and the server's OAuth issuer is `https`, because every app credential is an OAuth token. `GET /api/v1/admin/apps` reports `available` and `https_issuer`.
|
||||
- **Apps do not depend on the MCP setting.** `/oauth/authorize`, its consent step and `/oauth/token` are served for install clients even when MCP is off. Other OAuth endpoints (dynamic registration, revocation, introspection, the discovery document) still follow the MCP setting.
|
||||
- **Pad Cloud is operator-managed.** Apps are always available on Pad Cloud, and the admin settings cannot be changed there (`403 managed_by_operator`).
|
||||
- **Private destinations.** Pad fetches your manifest and posts your webhooks from the server, and refuses private addresses. A self-hosted admin can allow specific private origins with `private_origins` (each entry has an `origin`, an `allowed` list of addresses or CIDRs, and `fetch` / `webhook` flags). On Pad Cloud no private destination is reachable. This matters mainly for local development.
|
||||
|
||||
## The manifest
|
||||
|
||||
Publish the manifest at:
|
||||
|
||||
```
|
||||
{base_url}/.well-known/pad-app.json
|
||||
```
|
||||
|
||||
`base_url` must be a bare `https` origin: no path, query, fragment or credentials, and the default port is dropped. The normalized origin is the app's identity. The manifest you serve must name the same origin it was fetched from.
|
||||
|
||||
Validation is strict. An unknown key in the manifest, or inside a collection schema, is refused rather than ignored. (`config_schema` is free-form JSON and is not checked this way.) Pad reports the first failure with the JSON path that caused it (`422 invalid_manifest`, `details.path`).
|
||||
|
||||
### Field reference
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `id` | yes | `publisher/name`. Each part is 1 to 64 characters of `a-z`, `0-9`, `.`, `_` and `-`, starting with a letter or digit. |
|
||||
| `version` | yes | A semantic version, e.g. `1.0.0`. |
|
||||
| `min_contract` | yes | `{"apps": 1, "events": 1}`. Each value must be between 1 and the contract this server speaks, which is 1. |
|
||||
| `title` | yes | The app's name. Up to 2000 bytes. Also the bot's display name. |
|
||||
| `publisher` | yes | Who publishes the app. Up to 2000 bytes. |
|
||||
| `description` | no | Up to 2000 bytes. |
|
||||
| `homepage` | no | An `https` URL on `base_url`'s origin. |
|
||||
| `base_url` | yes | The app's `https` origin. |
|
||||
| `redirect_uris` | yes | 1 to 16 `https` URLs on `base_url`'s origin. Used for the delegated sign-in. |
|
||||
| `scopes` | yes | `{"service": {"access": ...}, "delegated": {"access": ...}}`. Each `access` is `"read"` or `"write"`. |
|
||||
| `events` | no | Up to 64 webhook subscriptions. See below. |
|
||||
| `webhook_url` | if `events` is set | An `https` URL on `base_url`'s origin. |
|
||||
| `companion_pack` | yes | `{"collections": [...], "artifacts": [...]}`. |
|
||||
| `item_actions` | no | Up to 32 actions. See [Item actions](#item-actions-and-context-codes). |
|
||||
| `config_schema` | no | A JSON Schema object, up to 64 KiB. Pad checks only that it is an object and shows it to the owner at install; it does not evaluate it. Values are never secrets. |
|
||||
| `docs` | no | An `https` URL on `base_url`'s origin. |
|
||||
|
||||
Every URL field (`homepage`, `redirect_uris`, `webhook_url`, `docs`, artifact `url`) must be on exactly the manifest's origin. The whole manifest is capped at 256 KiB.
|
||||
|
||||
**`companion_pack.collections`** (1 to 32 required). Each entry:
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `key` | A lower-case key (`^[a-z][a-z0-9_-]{0,63}$`). `events` and `item_actions` refer to a collection by this key. |
|
||||
| `slug` | The collection's slug in the workspace: 2 to 64 characters of `a-z`, `0-9` and `-`, not starting or ending with `-`. Unique within the pack. Some slugs are reserved by Pad and refused (`422 invalid_manifest`). |
|
||||
| `name` | Display name, up to 200 bytes. |
|
||||
| `schema` | A Pad collection schema, `{"fields": [...]}`. Required. |
|
||||
|
||||
If the workspace already has a collection with that slug that the app did not create, the install is refused with `409 collection_conflict`. If it was created by an earlier install of the same origin that has since been uninstalled, it is adopted. If that install is still installed or disabled, the install is refused (`409 app_already_installed`).
|
||||
|
||||
**`companion_pack.artifacts`** (optional, up to 32). Starter content Pad imports as items when the app is installed (for example a convention or playbook). Each entry has a `key`, an `https` `url` on your origin and the `sha256` (64 lower-case hex characters) of the file at that URL. Pad fetches each URL once, verifies the hash, and runs the file through the same normalization as a Pad import. The owner reviews the normalized result before confirming. Artifact content may not contain `pad-attachment:` references or workspace-qualified links (`[[ws::REF]]`).
|
||||
|
||||
**`events`**. Each entry is `{"name": ..., "collections": [...]}`. `collections` lists companion collection keys (at least one). Subscribable names:
|
||||
|
||||
```
|
||||
item.created item.updated item.status_changed item.deleted item.restored
|
||||
comment.created comment.updated comment.deleted
|
||||
```
|
||||
|
||||
**`item_actions`**. Each entry is `{"key", "label", "collections", "path"}`. `label` is required and at most 80 bytes. `collections` lists companion collection keys. `path` is an absolute path on your origin (starts with a single `/`, no scheme or host, no backslash or control character, up to 1024 bytes).
|
||||
|
||||
### Minimal manifest
|
||||
|
||||
This is the shape the end-to-end install test uses. It declares one companion collection, a service token that can write, delegated sign-in that is read-only, and a webhook.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "acme/support-portal",
|
||||
"version": "1.0.0",
|
||||
"min_contract": { "apps": 1, "events": 1 },
|
||||
"title": "Support Portal",
|
||||
"publisher": "Acme",
|
||||
"base_url": "https://portal.example.com",
|
||||
"redirect_uris": ["https://portal.example.com/oauth/callback"],
|
||||
"scopes": {
|
||||
"service": { "access": "write" },
|
||||
"delegated": { "access": "read" }
|
||||
},
|
||||
"events": [
|
||||
{ "name": "item.created", "collections": ["tickets"] },
|
||||
{ "name": "comment.created", "collections": ["tickets"] }
|
||||
],
|
||||
"webhook_url": "https://portal.example.com/hooks",
|
||||
"companion_pack": {
|
||||
"collections": [
|
||||
{
|
||||
"key": "tickets",
|
||||
"slug": "support-tickets",
|
||||
"name": "Support tickets",
|
||||
"schema": {
|
||||
"fields": [
|
||||
{ "key": "status", "label": "Status", "type": "select", "options": ["open", "solved"] }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Fuller manifest
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "acme/support-portal",
|
||||
"version": "1.2.0",
|
||||
"min_contract": { "apps": 1, "events": 1 },
|
||||
"title": "Support Portal",
|
||||
"description": "Customer tickets from the Acme portal.",
|
||||
"publisher": "Acme",
|
||||
"homepage": "https://portal.example.com/about",
|
||||
"base_url": "https://portal.example.com",
|
||||
"redirect_uris": ["https://portal.example.com/oauth/callback"],
|
||||
"scopes": {
|
||||
"service": { "access": "write" },
|
||||
"delegated": { "access": "write" }
|
||||
},
|
||||
"events": [
|
||||
{ "name": "item.created", "collections": ["tickets"] },
|
||||
{ "name": "item.status_changed", "collections": ["tickets"] },
|
||||
{ "name": "comment.created", "collections": ["tickets"] }
|
||||
],
|
||||
"webhook_url": "https://portal.example.com/hooks",
|
||||
"companion_pack": {
|
||||
"collections": [
|
||||
{
|
||||
"key": "tickets",
|
||||
"slug": "support-tickets",
|
||||
"name": "Support tickets",
|
||||
"schema": {
|
||||
"fields": [
|
||||
{ "key": "status", "label": "Status", "type": "select", "options": ["open", "solved"] },
|
||||
{ "key": "customer", "label": "Customer", "type": "text" }
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"key": "triage",
|
||||
"url": "https://portal.example.com/pad/triage-playbook.json",
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
]
|
||||
},
|
||||
"item_actions": [
|
||||
{
|
||||
"key": "open-in-portal",
|
||||
"label": "Open in Support Portal",
|
||||
"collections": ["tickets"],
|
||||
"path": "/pad/open"
|
||||
}
|
||||
],
|
||||
"config_schema": {
|
||||
"type": "object",
|
||||
"properties": { "region": { "type": "string" } }
|
||||
},
|
||||
"docs": "https://portal.example.com/docs"
|
||||
}
|
||||
```
|
||||
|
||||
(The all-zero `sha256` is a placeholder; use the real hash of your artifact file.)
|
||||
|
||||
## Install flow
|
||||
|
||||
Installing is a handshake between the workspace owner and your app. Only a workspace **owner** can install, and an owner whose access is restricted to specific collections cannot (the install grants the app system collections they may not be able to read).
|
||||
|
||||
### Owner side
|
||||
|
||||
All owner routes are under `/api/v1/workspaces/{ws}/apps` and are owner-only. The web UI drives them from workspace Settings → Apps; the same calls are available over the REST API.
|
||||
|
||||
1. **Preview.** `POST /apps/install/preview` with `{"base_url": "https://portal.example.com"}`. Pad fetches `{base_url}/.well-known/pad-app.json` and every artifact once (30 second deadline overall), validates the manifest, checks collection conflicts, and stages the result. The answer is what the owner reviews:
|
||||
- identity: `origin`, `app_id`, `version`, `title`, `description`, `publisher`, `homepage`, `manifest_sha256`;
|
||||
- `reviewed_by_pad: false` with a `notice` ("Not reviewed by Pad. You are installing code published at this origin.") shown for every app;
|
||||
- `service_access` and `delegated_access`, and `reads_system_collections` ("The app can read this workspace's Conventions and Playbooks.");
|
||||
- `collections` (each with `adopt` set if an earlier install's collection will be reused), `events`, `webhook_url`, `item_actions`, `redirect_uris`, `config_schema`, `docs`;
|
||||
- `artifacts`, each with the raw file, the normalized item Pad would store, and a `changes` list of anything the importer dropped or altered.
|
||||
|
||||
A staged preview expires (`expires_at`). `GET /apps/install/pending/{pendingID}` re-reads it and `DELETE` discards it. Preview failures include `400 invalid_base_url`, `502 manifest_fetch_failed`, `422 invalid_manifest`, `502 artifact_fetch_failed`, `422 artifact_hash_mismatch`, `422 invalid_artifact`, `422 artifact_destination_missing` (the workspace has no collection that accepts an artifact's kind), `409 collection_conflict` and `429 pending_install_limit`.
|
||||
|
||||
2. **Confirm.** `POST /apps/install/pending/{pendingID}/confirm` with `{"manifest_sha256": "<hash from the preview>"}`. A `manifest_sha256` that is not the staged manifest's is refused with `409 install_review_mismatch`. Pad then re-normalizes the staged bytes and compares them with what the owner reviewed; if the workspace changed in a way that alters the result, it refuses with `409 install_review_stale` and writes nothing. On success (`201`) it provisions everything in one transaction and answers:
|
||||
|
||||
```json
|
||||
{
|
||||
"install_id": "…",
|
||||
"install_code": "padic_…",
|
||||
"expires_at": "2026-01-01T12:10:00Z",
|
||||
"notice": "Give this install code to the app. It works once, for 10 minutes. …",
|
||||
"items": [{ "key": "triage", "ref": "PLAY-3", "status": "draft" }]
|
||||
}
|
||||
```
|
||||
|
||||
3. **Hand the install code to the app.** How is up to you: show a field in your own UI, or have the owner paste it. The code works once and expires after 10 minutes. If it is lost, the owner issues a new one with `POST /apps/{installID}/install-code` (active installs only).
|
||||
|
||||
`GET /apps` lists the workspace's installs and `GET /apps/{installID}` shows one install's state, its webhook status and the items its artifacts created.
|
||||
|
||||
### App side: redeem the install code
|
||||
|
||||
`POST /api/app/v1/install/redeem` with `{"code": "padic_…"}`. This route needs no authentication; the code is the credential.
|
||||
|
||||
```json
|
||||
{
|
||||
"install_id": "…",
|
||||
"client_id": "…",
|
||||
"client_secret": "padapp_…",
|
||||
"workspace_id": "…",
|
||||
"workspace_slug": "my-workspace",
|
||||
"webhook_secret": "…"
|
||||
}
|
||||
```
|
||||
|
||||
- This is the **only** time the `client_secret` and the `webhook_secret` are returned. Store them.
|
||||
- `webhook_secret` is present only when the manifest declares a webhook.
|
||||
- `workspace_id` is the `{ws}` in every workspace-scoped app route. The app API addresses a workspace by its ID, never its slug.
|
||||
- Every refusal (unknown, used, expired, install disabled) is the same `400 invalid_install_code`.
|
||||
- The route is rate limited to 10 a minute per address and 5 a minute per install (`429`). Redeeming again with a new code replaces both secrets, so the old ones stop working.
|
||||
|
||||
### App side: get a token
|
||||
|
||||
The token endpoint is `{pad-origin}/oauth/token`. Do not discover it from `/.well-known/oauth-authorization-server`: a self-hosted server serves that document only when its OAuth is on for MCP clients, and the document describes MCP clients, not app install clients. Request a service token:
|
||||
|
||||
```
|
||||
POST {token_endpoint}
|
||||
Authorization: Basic base64(urlencode(client_id) ":" urlencode(client_secret))
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=client_credentials&resource=https://<pad-origin>/api/app/v1
|
||||
```
|
||||
|
||||
`resource` is required and must be the app API URL on Pad's public origin (`{origin}/api/app/v1`). An `audience` parameter, if you send one, must name the same resource. The token is granted the `app` scope and is valid only on the app API. Call it as `Authorization: Bearer <access_token>`.
|
||||
|
||||
Call `GET /api/app/v1/me` first: it returns the token's binding, including the `workspace_id` to use in the routes below.
|
||||
|
||||
## Authentication
|
||||
|
||||
### Service tokens
|
||||
|
||||
A service token acts as the app's bot. Its access is the manifest's `scopes.service.access`. A `read` token cannot call a write route (`403 insufficient_access`). The bot sees only its read ceiling (companions plus system collections) and writes only to companions.
|
||||
|
||||
### Delegated tokens (sign in with Pad)
|
||||
|
||||
A delegated token lets a Pad workspace member use your app and act as themselves. It is available only when the manifest's `scopes.delegated.access` is `read` or `write`.
|
||||
|
||||
1. Send the person to Pad's `/oauth/authorize` with your `client_id`, one of your registered `redirect_uris`, `response_type=code`, `resource` set to the app API URL, and a PKCE `code_challenge` with `code_challenge_method=S256`. PKCE with S256 is required.
|
||||
2. Pad shows a consent page fixed to the install's workspace. The person chooses **Read only** (the default) or, if the manifest offers `write`, **Read and write**, and approves.
|
||||
3. Exchange the code at the token endpoint with your client credentials. The `refresh_token` grant is also supported.
|
||||
|
||||
The person must be a human member of the workspace. A delegated request is re-checked on every call against the person's current state: the token stops working if they are removed from the workspace, their account is disabled, or their credentials change. The effective access is the lesser of what the person consented to and what the manifest currently offers. Their role is their membership role (an owner is treated as an editor; a read-only token acts as a viewer), and what they can see is further limited to the install's collections.
|
||||
|
||||
### Revocation
|
||||
|
||||
Tokens are checked against live state on every request. A read's response is also re-validated before it is sent, a write re-checks its authority before it commits, and a download re-checks before its first byte. Disabling, rotating or uninstalling the app invalidates its tokens immediately; see [Lifecycle](#lifecycle). An invalid token gets `401` with `WWW-Authenticate: Bearer error="invalid_token"`.
|
||||
|
||||
## App API reference
|
||||
|
||||
Base: `https://<pad-origin>/api/app/v1`. Bodies and responses are JSON. Workspace-scoped routes live under `/workspaces/{ws}` where `{ws}` is the workspace **ID**; any other value answers `404`. Items are addressed by ID, never by slug or ref.
|
||||
|
||||
| Method | Path | Token | Access | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| GET | `/me` | either | read | The token's binding (install, workspace, auth kind, access). |
|
||||
| POST | `/install/redeem` | none | n/a | Redeem an install code (above). |
|
||||
| POST | `/context/redeem` | service | read | Redeem an item action's context code. |
|
||||
| GET | `/workspaces/{ws}/me` | either | read | The acting identity and its role. |
|
||||
| GET | `/workspaces/{ws}/collections` | either | read | Collections in the read ceiling. |
|
||||
| GET | `/workspaces/{ws}/collections/{collSlug}` | either | read | One collection and its schema. |
|
||||
| GET | `/workspaces/{ws}/collections/{collSlug}/items` | either | read | List items (paged). |
|
||||
| GET | `/workspaces/{ws}/items/{itemID}` | either | read | One item. |
|
||||
| GET | `/workspaces/{ws}/items/{itemID}/comments` | either | read | Comments on an item. |
|
||||
| POST | `/workspaces/{ws}/collections/{collSlug}/items` | either | write | Create an item in a companion collection. |
|
||||
| PATCH | `/workspaces/{ws}/items/{itemID}` | either | write | Patch fields on an item the install created. |
|
||||
| POST | `/workspaces/{ws}/items/{itemID}/comments` | either | write | Add a comment. |
|
||||
| PATCH | `/workspaces/{ws}/items/{itemID}/comments/{commentID}` | either | write | Edit your own comment. |
|
||||
| DELETE | `/workspaces/{ws}/items/{itemID}/comments/{commentID}` | either | write | Delete your own comment (`204`). |
|
||||
| POST | `/workspaces/{ws}/items/{itemID}/attachments` | either | write | Upload a file to an item the install created. |
|
||||
| GET | `/workspaces/{ws}/attachments/{attachmentID}` | either | read | Attachment metadata. |
|
||||
| GET, HEAD | `/workspaces/{ws}/attachments/{attachmentID}/content` | either | read | Download (`?variant=` for a variant). |
|
||||
|
||||
"Either" means a service or a delegated token. Every response sets `Cache-Control: no-store`.
|
||||
|
||||
### Reads
|
||||
|
||||
- **Install binding**, `GET /me`: `{install_id, workspace_id, workspace_slug, auth_kind, access, user_id, display_name, is_app}`. `auth_kind` is `service` or `delegated`.
|
||||
- **Workspace identity**, `GET /workspaces/{ws}/me`: `{user_id, display_name, role, is_app}`.
|
||||
- **Collections**: `{"collections": [...]}`. Each collection is `{slug, name, icon, is_system, schema: {fields: [{key, type, label, options?, required, default?}]}}`. Relation fields are left out of the schema.
|
||||
- **Items**: `{id, collection, title, content, fields, etag, created_at, updated_at, created_by_display, via_app}`. `fields` contains only the collection's declared non-relation keys. Pad does not expose slugs, refs, parents, assignees or other internals.
|
||||
- **Listing items**: `?limit=` (1 to 200, default 50) and `?offset=`; the answer is `{"items": [...], "has_more": bool, "next_offset": n}`.
|
||||
- **Comments**: `{"comments": [{id, item_id, body, author_display, author_kind, parent_comment_id, created_at, updated_at, edited, deleted}]}`. `author_kind` is `app` when an app's bot wrote the comment, `agent` when an agent did, and `user` otherwise, including a person acting through a delegated token.
|
||||
|
||||
### Writes
|
||||
|
||||
Writes go only to companion collections, and a person's delegated token must also pass the normal edit check for that person.
|
||||
|
||||
- **Create an item**: `POST .../collections/{collSlug}/items` with `{"title", "content", "fields"}`. Answers `201` with the item. Field values are validated against the collection's schema; relation fields cannot be set.
|
||||
- **Update an item**: `PATCH .../items/{itemID}` with `{"fields_patch": {...}, "expected_etag": "<etag from a read>"}`. Both are required. Only fields can change (no title or content), and only on items this install created. A stale etag answers `412 etag_mismatch`; re-read and retry.
|
||||
- **Create a comment**: `{"body", "parent_comment_id"?}` answers `201`. Only the comment's author can edit (`{"body"}`) or delete it.
|
||||
- **Upload an attachment**: `POST .../items/{itemID}/attachments?filename=name.png` with the raw file as the request body. `Content-Length` is required (chunked bodies are refused with `411`), the body must be non-empty and at most 25 MiB, and the item must have been created by this install. Answers `201` with `{id, item_id, filename, mime_type, size, variant, created_at}`.
|
||||
- **Request bodies reject unknown keys** (`400 bad_request`). The workspace's item limit applies (`403 item_limit_reached`).
|
||||
- Content and field values may not carry `pad-attachment:` references or workspace-qualified links.
|
||||
|
||||
Common errors: `401` for any token problem, `403 insufficient_access` for a read token on a write route, `403 forbidden` for a collection or item the app may not write, `404 not_found` for anything outside the ceiling or workspace, `400 validation_error` for schema violations.
|
||||
|
||||
## Webhooks
|
||||
|
||||
If the manifest declares `events` and a `webhook_url`, Pad posts matching events to that URL. Only events about the app's companion collections, and only for the collections listed in each subscription, are sent.
|
||||
|
||||
### Subscription status
|
||||
|
||||
The owner sees the webhook's status, and so can the app's operator:
|
||||
|
||||
| Status | Meaning |
|
||||
|---|---|
|
||||
| `awaiting_secret` | The webhook exists but the app has not yet redeemed an install code that hands it the signing secret. Nothing is delivered. The owner is told to issue a new install code. |
|
||||
| `active` | The app holds the secret and the install is active. Events are delivered. |
|
||||
| `paused` | The install is not active (for example disabled). Nothing is delivered. |
|
||||
|
||||
`paused` takes precedence: a disabled install's hook reads `paused` even if it is also waiting for a secret, and no held notice is shown. Enabling the install restores the status the hook had: `active` if the app had already redeemed its secret, or `awaiting_secret` with its notice if it had not. Disabling does not take the secret away; only a rotate does.
|
||||
|
||||
Status is reported with `undelivered_dropped`, a count of deliveries dropped after 24 hours (below).
|
||||
|
||||
### Request
|
||||
|
||||
Pad sends `POST` to `webhook_url` with `Content-Type: application/json`, `User-Agent: Pad-Webhook/2.0` and:
|
||||
|
||||
| Header | Value |
|
||||
|---|---|
|
||||
| `X-Pad-Signature-256` | `t=<unix seconds>,v1=<hex HMAC-SHA256>` |
|
||||
| `X-Pad-Install` | The install ID the delivery is for. |
|
||||
|
||||
The MAC is HMAC-SHA256, keyed with the `webhook_secret` from redeem, over the string `<t>` + `.` + the raw request body. Verify against the raw bytes you received, before parsing. Refuse a `t` more than 5 minutes from your clock.
|
||||
|
||||
```js
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
function verify(secret, signatureHeader, rawBody) {
|
||||
const m = /^t=(\d+),v1=([0-9a-f]+)$/.exec(signatureHeader ?? '');
|
||||
if (!m) return false;
|
||||
if (Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false;
|
||||
const mac = createHmac('sha256', secret);
|
||||
mac.update(m[1] + '.');
|
||||
mac.update(rawBody); // the Buffer, exactly as received
|
||||
const want = Buffer.from(mac.digest('hex'));
|
||||
const got = Buffer.from(m[2]);
|
||||
return want.length === got.length && timingSafeEqual(want, got);
|
||||
}
|
||||
```
|
||||
|
||||
### Payloads
|
||||
|
||||
Every payload starts with an envelope: `event`, `id` (unique per event), `occurred_at`, `workspace_id` and, when the change was made through an app, `via_app` (the install ID). The rest depends on the event:
|
||||
|
||||
- `item.created`, `item.updated`, `item.status_changed`, `item.restored`: `item_id`, `collection_id`, `title`, `content`, `fields`, `creator` (`user_id`, `display`, `kind`, `via_app`), `created_at`, `updated_at`. A payload that Pad could only partly reconstruct carries `"partial": true` and omits `title`, `content` and `fields`.
|
||||
- `item.deleted`: `item_id`, `collection_id` only.
|
||||
- `comment.created`, `comment.updated`: `comment_id`, `item_id`, `collection_id`, `parent_comment_id`, `body`, `creator`, `created_at`, `updated_at`.
|
||||
- `comment.deleted`: `comment_id`, `item_id`, `collection_id`, `parent_comment_id` only.
|
||||
|
||||
The payload is built from a snapshot taken when the event happened, not from live state. Members of a bulk operation (a bulk edit or reorder made by a person) are not delivered to apps; resync through the API if you need them.
|
||||
|
||||
### Delivery, retries and drops
|
||||
|
||||
- A `2xx` response is a delivery.
|
||||
- A `5xx`, `408`, `425` or `429` response, a timeout (10 seconds per attempt) or a network error is retried: up to 3 attempts with a growing delay, and if those fail the event stays owed and is tried again on later passes.
|
||||
- Any other `4xx`, or a `3xx` (redirects are never followed), is permanent: the event is not retried.
|
||||
- An event still undelivered **24 hours** after it occurred is dropped and counted in `undelivered_dropped`. A receiver that sees a day-old change should resync through the API.
|
||||
- Deliveries are capped at 600 a minute per install (burst 60). Excess is deferred, not dropped.
|
||||
- Delivery happens only while the install is active. An event processed while the install is disabled is finished without delivery and is not retried after re-enabling, so do not rely on receiving events from a disabled period; resync through the API after re-enabling.
|
||||
- Delivery is at least once. Use the payload `id` to de-duplicate.
|
||||
|
||||
## Item actions and context codes
|
||||
|
||||
An item action adds a button to items in the listed companion collections. Clicking it sends the person to your app along with a short-lived, single-use code that identifies the item, so your app can show the right page without a separate sign-in.
|
||||
|
||||
1. **Pad offers the action.** `GET /api/v1/workspaces/{ws}/items/{ref}/app-actions` lists the actions available on an item, as `{install_id, app_title, action_key, label}`.
|
||||
2. **A person mints a code.** `POST /api/v1/workspaces/{ws}/items/{ref}/app-actions/{installID}/{actionKey}` answers `{"url": "<base_url><path>?pad_context=<code>"}`. The browser is sent to that URL. The code is bound to the install, the item, the action and the person who clicked, expires in 120 seconds, and works once.
|
||||
3. **Your app redeems it.** `POST /api/app/v1/context/redeem` with `{"code": "<pad_context>"}`, authenticated with the app's **service** token (not a delegated one). The answer is `{"action_key": "…", "item": { …app item… }}`. It does not say who clicked.
|
||||
|
||||
Treat the code as untrusted until redeemed. Your page should redeem it server-side and create its own session from the result.
|
||||
|
||||
Mint and redeem both refuse with a single `404 not_found` body for every reason: an unknown, expired or already used code; another install's code; a moved or deleted item; a disabled or upgraded install; a person who can no longer see the item. A redeem consumes the code before checking anything else, so a mismatch burns it. Do not try to tell the reasons apart; ask the person to click again.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
The owner manages an install from workspace Settings → Apps, or over the owner routes under `/api/v1/workspaces/{ws}/apps/{installID}`.
|
||||
|
||||
| Action | Route | Effect |
|
||||
|---|---|---|
|
||||
| Disable | `POST /disable` | Moves the install to `inactive`. All tokens and unredeemed install codes are revoked; the app's API calls get `401`, no new tokens are issued, and webhooks stop (status `paused`). Pad waits for in-flight webhook deliveries to finish first (`503 deliveries_in_flight` with `Retry-After` if they do not; repeat the call). |
|
||||
| Enable | `POST /enable` | Back to `active`. Tokens issued before the disable stay revoked; the app obtains new ones with its existing client credentials. Do not rely on receiving events from the disabled period; resync through the API. |
|
||||
| Rotate | `POST /rotate` | Revokes every token, replaces the client secret and the webhook secret, holds the webhook (`awaiting_secret`) and answers a new `install_code`. The app redeems it to receive the new secrets. |
|
||||
| New install code | `POST /install-code` | Issues a code for an active install without revoking anything. Redeeming it still replaces the client secret and webhook secret. |
|
||||
| Uninstall | `POST /uninstall` | Deletes the OAuth client, the webhook, item actions and their codes, removes the bot's membership and disables the bot. The install remains as a tombstone (`uninstalled`) and the bot's user record is kept so history stays attributed. Companion collections and the items in them are left in the workspace; a later install of the same origin adopts the collections. |
|
||||
|
||||
Disabling or uninstalling does not delete content the app created.
|
||||
|
||||
### Upgrades
|
||||
|
||||
The owner upgrades an install with `POST /apps/{installID}/upgrade/preview` and `/upgrade/confirm`, which fetch the app's current manifest and show a diff of what changed (scopes, events, collections, config). Narrowing delegated access narrows every live delegated token at once.
|
||||
|
||||
## Attribution
|
||||
|
||||
Pad records which app caused a change.
|
||||
|
||||
- Items and comments created by an app's bot show the app's name: the human-facing JSON for items, comments and version history carries `via_app` (the install ID) and `via_app_name`. The bot's display name is the manifest `title`.
|
||||
- Comments written by the bot have `author_kind: "app"` in the app API.
|
||||
- A person who acts through a delegated token keeps their own identity. The write is theirs, recorded as a user action, with `via_app` set to the install so the UI can show "via <app>".
|
||||
- In webhook payloads, `via_app` marks an event caused by an app write; it is absent for a person's or agent's own write. Use it to ignore your own writes.
|
||||
- After an uninstall the bot's record is kept, and the app's name continues to resolve from the install tombstone.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Write scope.** An app can create items only in its own companion collections, and update fields only on items it created. It cannot delete items, change titles or content after creation, set relation fields, or create collections.
|
||||
- **Read scope.** An app reads only its companion collections and the workspace's system collections. Items are DTOs with a fixed set of fields.
|
||||
- **One workspace per install.** A token is bound to a single workspace.
|
||||
- **Owner-only install.** Members cannot install apps, and Pad does not review the apps its owners install.
|
||||
- **No `http`.** Every URL in the manifest, and Pad's own issuer, must be `https`.
|
||||
- **Event set.** Only the eight events listed above can be subscribed to, and only for companion collections.
|
||||
- **Config.** `config_schema` is shown to the owner in the install preview and compared on upgrade; Pad does not evaluate it.
|
||||
- **Manifest size.** 256 KiB for the manifest, 32 collections, 32 artifacts, 64 events, 32 item actions, 16 redirect URIs.
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
// the actions offered on an item, for the item pane's button;
|
||||
// POST /api/v1/workspaces/{ws}/items/{ref}/app-actions/{installID}/{actionKey}
|
||||
// mints a context code for the caller and answers {url};
|
||||
// POST /api/app/v1/workspaces/{ws}/context/redeem {code}
|
||||
// POST /api/app/v1/context/redeem {code}
|
||||
// the app redeems it with its service token and receives the item.
|
||||
//
|
||||
// Mint and redeem refuse with ONE 404 body for every reason, so neither is
|
||||
@@ -201,7 +201,7 @@ type AppContextRedeemed struct {
|
||||
Item AppItem `json:"item"`
|
||||
}
|
||||
|
||||
// appRedeemContext: POST /api/app/v1/workspaces/{ws}/context/redeem.
|
||||
// appRedeemContext: POST /api/app/v1/context/redeem.
|
||||
func (s *Server) appRedeemContext(w http.ResponseWriter, r *http.Request) {
|
||||
ac := appContextFrom(r)
|
||||
var in struct {
|
||||
|
||||
@@ -49,11 +49,6 @@ const appArtifactMaxBytes = defaultImportArtifactMaxBytes
|
||||
// appNotReviewedNotice is shown with every app on every surface (Dave Q3).
|
||||
const appNotReviewedNotice = "Not reviewed by Pad. You are installing code published at this origin."
|
||||
|
||||
// appDeferredNotice explains declared webhook events and item actions that
|
||||
// this server does not provision yet (A5/A6). They are stored with the
|
||||
// install and activated when supported, without a reinstall.
|
||||
const appDeferredNotice = "Webhook events and item actions will activate when this server supports them; they are recorded with the install now."
|
||||
|
||||
// appPreview is what the owner reviews before consenting (§2 step 4).
|
||||
type appPreview struct {
|
||||
PendingID string `json:"pending_id"`
|
||||
@@ -76,7 +71,6 @@ type appPreview struct {
|
||||
Events []appmanifest.Event `json:"events"`
|
||||
WebhookURL string `json:"webhook_url,omitempty"`
|
||||
ItemActions []appmanifest.ItemAction `json:"item_actions"`
|
||||
DeferredNotice string `json:"deferred_notice,omitempty"`
|
||||
Artifacts []appPreviewArtifact `json:"artifacts"`
|
||||
ConfigSchema json.RawMessage `json:"config_schema,omitempty"`
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
@@ -405,9 +399,6 @@ func (s *Server) buildAppPreviewQ(q store.Queryer, r *http.Request, workspaceID
|
||||
Events: m.Events, WebhookURL: m.WebhookURL, ItemActions: m.ItemActions,
|
||||
ConfigSchema: m.ConfigSchema, RedirectURIs: m.RedirectURIs, Docs: m.Docs,
|
||||
}
|
||||
if len(m.Events) > 0 || len(m.ItemActions) > 0 {
|
||||
p.DeferredNotice = appDeferredNotice
|
||||
}
|
||||
|
||||
// Conflict check (§2 step 5, L6).
|
||||
slugs := make([]string, len(m.CompanionPack.Collections))
|
||||
|
||||
@@ -164,6 +164,7 @@ func (e *appsEnv) pendingRows(t *testing.T) int {
|
||||
}
|
||||
|
||||
func TestAppInstallPreview_StagesAndPreviews(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newAppsEnv(t)
|
||||
rr := e.preview(t)
|
||||
if rr.Code != http.StatusOK {
|
||||
@@ -174,9 +175,14 @@ func TestAppInstallPreview_StagesAndPreviews(t *testing.T) {
|
||||
if p.ReviewedByPad || !strings.Contains(p.Notice, "Not reviewed by Pad") {
|
||||
t.Errorf("not-reviewed notice missing: %+v", p.Notice)
|
||||
}
|
||||
if p.Origin != e.origin() || p.ServiceAccess != "write" || p.DelegatedAccess != "read" || p.DeferredNotice == "" {
|
||||
if p.Origin != e.origin() || p.ServiceAccess != "write" || p.DelegatedAccess != "read" {
|
||||
t.Errorf("preview header: %+v", p)
|
||||
}
|
||||
// Events and item actions are live, so the consent screen no longer
|
||||
// says they will activate later (TASK-3980 item 2).
|
||||
if strings.Contains(rr.Body.String(), "deferred_notice") {
|
||||
t.Errorf("preview still carries deferred_notice: %s", rr.Body.String())
|
||||
}
|
||||
if len(p.Collections) != 1 || p.Collections[0].Adopt {
|
||||
t.Errorf("collections: %+v", p.Collections)
|
||||
}
|
||||
|
||||
@@ -112,6 +112,27 @@ func TestAppWebhook_HeldUntilRedeem(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppWebhook_PausedWhileDisabled: Disable stops delivery (app_delivery
|
||||
// sends only for an active install), so the owner's view must say paused,
|
||||
// not active, and Enable must bring active back (TASK-3980 item 4).
|
||||
func TestAppWebhook_PausedWhileDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
u := newUpgradeEnv(t)
|
||||
u.issueAndRedeem(t)
|
||||
if got := u.getInstall(t); got.Webhook == nil || got.Webhook.Status != "active" {
|
||||
t.Fatalf("before disable: %+v", got.Webhook)
|
||||
}
|
||||
for _, step := range []struct{ verb, want string }{{"disable", "paused"}, {"enable", "active"}} {
|
||||
rr := doRequestWithCookie(u.srv, "POST", "/api/v1/workspaces/"+u.ws+"/apps/"+u.installID+"/"+step.verb, nil, u.token)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("%s: %d %s", step.verb, rr.Code, rr.Body.String())
|
||||
}
|
||||
if got := u.getInstall(t); got.Webhook == nil || got.Webhook.Status != step.want {
|
||||
t.Fatalf("after %s: %+v, want status %q", step.verb, got.Webhook, step.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppWebhook_NoEventsNoHook(t *testing.T) {
|
||||
e := newProvisionEnv(t)
|
||||
m := e.manifest(t)
|
||||
|
||||
@@ -41,8 +41,9 @@ type appWebhookSubscription struct {
|
||||
// AppWebhookStatus says what an install's hook is doing, for the owner.
|
||||
type AppWebhookStatus struct {
|
||||
URL string `json:"url"`
|
||||
// Status is "awaiting_secret" (held: no redeem has handed the app its
|
||||
// signing secret yet) or "active" (deliverable while the install is).
|
||||
// Status is "paused" (the install is not active, so nothing delivers;
|
||||
// TASK-3980), else "awaiting_secret" (held: no redeem has handed the app
|
||||
// its signing secret yet) or "active" (deliverable).
|
||||
Status string `json:"status"`
|
||||
// Dropped counts deliveries given up undelivered after
|
||||
// appWebhookDropAfter (TASK-3408 U10c).
|
||||
@@ -52,6 +53,7 @@ type AppWebhookStatus struct {
|
||||
const (
|
||||
AppWebhookAwaitingSecret = "awaiting_secret"
|
||||
AppWebhookActive = "active"
|
||||
AppWebhookPaused = "paused"
|
||||
)
|
||||
|
||||
func newAppWebhookSecret() (string, error) {
|
||||
@@ -177,10 +179,12 @@ func (s *Store) holdAppWebhookTx(tx *sql.Tx, installID string) error {
|
||||
// GetAppWebhookStatus reports an install's hook for the owner; nil when the
|
||||
// install has none.
|
||||
func (s *Store) GetAppWebhookStatus(installID string) (*AppWebhookStatus, error) {
|
||||
var url string
|
||||
var url, state string
|
||||
var delivered sql.NullString
|
||||
var dropped int64
|
||||
err := s.db.QueryRow(s.q(`SELECT url, secret_delivered_at, dropped_count FROM webhooks WHERE app_install_id = ?`), installID).Scan(&url, &delivered, &dropped)
|
||||
err := s.db.QueryRow(s.q(`SELECT w.url, w.secret_delivered_at, w.dropped_count, i.state
|
||||
FROM webhooks w JOIN app_installs i ON i.id = w.app_install_id
|
||||
WHERE w.app_install_id = ?`), installID).Scan(&url, &delivered, &dropped, &state)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -188,7 +192,12 @@ func (s *Store) GetAppWebhookStatus(installID string) (*AppWebhookStatus, error)
|
||||
return nil, fmt.Errorf("app webhook status: %w", err)
|
||||
}
|
||||
st := &AppWebhookStatus{URL: url, Status: AppWebhookAwaitingSecret, Dropped: dropped}
|
||||
if delivered.Valid && delivered.String != "" {
|
||||
switch {
|
||||
case state != "active":
|
||||
// Derived from the install, not stored: Disable pauses delivery
|
||||
// without touching the hook row (TASK-3980 item 4).
|
||||
st.Status = AppWebhookPaused
|
||||
case delivered.Valid && delivered.String != "":
|
||||
st.Status = AppWebhookActive
|
||||
}
|
||||
return st, nil
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CatchUpResponse,
|
||||
BuiltinStateResponse,
|
||||
DecisionSettings,
|
||||
AppsSettings,
|
||||
MCPSettings,
|
||||
DecisionSettingsInput,
|
||||
Workspace,
|
||||
@@ -3625,6 +3626,11 @@ export const api = {
|
||||
getMCPSettings: () => request<MCPSettings>('/admin/mcp'),
|
||||
updateMCPSettings: (enabled: boolean) =>
|
||||
request<MCPSettings>('/admin/mcp', { method: 'PUT', body: JSON.stringify({ enabled }) }),
|
||||
// Apps (TASK-3980). PUT sends only `enabled`, so private_origins
|
||||
// stay as stored; 403 managed_by_operator on Pad Cloud.
|
||||
getAppsSettings: () => request<AppsSettings>('/admin/apps'),
|
||||
updateAppsSettings: (enabled: boolean) =>
|
||||
request<AppsSettings>('/admin/apps', { method: 'PUT', body: JSON.stringify({ enabled }) }),
|
||||
// Decision provider (TASK-3121). The response never carries the API
|
||||
// key — only whether one is set. api_key is write-only.
|
||||
getDecisionSettings: () => request<DecisionSettings>('/admin/decision-provider'),
|
||||
|
||||
@@ -6829,8 +6829,14 @@
|
||||
|
||||
<!-- Meta info -->
|
||||
<div class="meta-info">
|
||||
<span title={new Date(item.created_at).toLocaleString()}>Created {relativeTime(item.created_at)} by {item.created_by || 'unknown'}</span>
|
||||
{#if item.via_app_name}
|
||||
{#if item.created_by === 'agent' && item.via_app_name}
|
||||
<!-- TASK-3980: an app's bot wrote this; name the app, not the
|
||||
generic "agent" that created_by stores. -->
|
||||
<span title={new Date(item.created_at).toLocaleString()}>Created {relativeTime(item.created_at)} by <bdi title={viaAppTitle(item.via_app_name)}>{item.via_app_name}</bdi></span>
|
||||
{:else}
|
||||
<span title={new Date(item.created_at).toLocaleString()}>Created {relativeTime(item.created_at)} by {item.created_by || 'unknown'}</span>
|
||||
{/if}
|
||||
{#if item.via_app_name && item.created_by !== 'agent'}
|
||||
<!-- SPEC-6 U9c: the app the item was created through; a quiet
|
||||
secondary label, never a second author. -->
|
||||
<span class="meta-via-app" title={viaAppTitle(item.via_app_name)}>via <bdi>{item.via_app_name}</bdi></span>
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* TASK-3980 item 3 — an item an app's bot created reads "Created … by
|
||||
* <app name>", not the generic "by agent" that created_by stores. A person
|
||||
* acting through an app keeps their own name with the quiet "via <app>"
|
||||
* label (SPEC-6 U9c), and a plain agent write is unchanged.
|
||||
*
|
||||
* Asserts the rendered meta line of a mounted ItemDetail (CONVE-19), with
|
||||
* the same stubs as itemDetailLoadsOnce.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { cleanup, render, waitFor } from '@testing-library/svelte';
|
||||
import { flushSync, tick } from 'svelte';
|
||||
import { finishBootstrap, resetBootstrap } from '../../../test/reactiveBootstrapMock.svelte';
|
||||
|
||||
vi.mock('$lib/components/editor/Editor.svelte', () => import('../../../test/StubComponent.svelte'));
|
||||
vi.mock('$lib/components/editor/EditorBubbleMenu.svelte', () => import('../../../test/StubComponent.svelte'));
|
||||
vi.mock('$lib/components/editor/EditorLinkPopover.svelte', () => import('../../../test/StubComponent.svelte'));
|
||||
vi.mock('$lib/components/editor/RawMarkdownEditor.svelte', () => import('../../../test/StubComponent.svelte'));
|
||||
vi.mock('$lib/components/fields/FieldEditor.svelte', () => import('../../../test/StubComponent.svelte'));
|
||||
vi.mock('$lib/components/fields/TagInput.svelte', () => import('../../../test/StubComponent.svelte'));
|
||||
vi.mock('$lib/components/timeline/ItemTimeline.svelte', () => import('../../../test/StubComponent.svelte'));
|
||||
vi.mock('$lib/components/timeline/TimelineEntryList.svelte', () => import('../../../test/StubComponent.svelte'));
|
||||
vi.mock('$lib/components/ChildItems.svelte', () => import('../../../test/StubComponent.svelte'));
|
||||
vi.mock('$lib/components/BacklinksPanel.svelte', () => import('../../../test/StubComponent.svelte'));
|
||||
vi.mock('$lib/components/items/DecisionChips.svelte', () => import('../../../test/StubComponent.svelte'));
|
||||
vi.mock('$lib/components/RelationBacklinksPanel.svelte', () => import('../../../test/StubComponent.svelte'));
|
||||
vi.mock('./ItemPicker.svelte', () => import('../../../test/StubComponent.svelte'));
|
||||
vi.mock('$lib/components/common/QuickActionsMenu.svelte', () => import('../../../test/StubComponent.svelte'));
|
||||
vi.mock('$lib/components/common/BottomSheet.svelte', () => import('../../../test/StubComponent.svelte'));
|
||||
vi.mock('$lib/components/collections/EditCollectionModal.svelte', () => import('../../../test/StubComponent.svelte'));
|
||||
vi.mock('$lib/components/ShareDialog.svelte', () => import('../../../test/StubComponent.svelte'));
|
||||
vi.mock('$lib/components/items/CopyItemDialog.svelte', () => import('../../../test/StubComponent.svelte'));
|
||||
vi.mock('$lib/components/items/PushToAgentDialog.svelte', () => import('../../../test/StubComponent.svelte'));
|
||||
vi.mock('$lib/components/items/ItemAttachmentStrip.svelte', () => import('../../../test/StubComponent.svelte'));
|
||||
vi.mock('$lib/components/attachments/AttachmentSurfaceHost.svelte', () => import('../../../test/StubComponent.svelte'));
|
||||
vi.mock('$lib/collab/wsProvider.svelte', () => ({
|
||||
CollabProvider: class {
|
||||
state = 'connecting';
|
||||
synced = false;
|
||||
lastOpLogID = undefined;
|
||||
itemID: string;
|
||||
awareness = { setLocalStateField() {}, on() {}, off() {}, getStates: () => new Map() };
|
||||
constructor(itemId: string) {
|
||||
this.itemID = itemId;
|
||||
}
|
||||
destroy() {}
|
||||
},
|
||||
}));
|
||||
|
||||
const attribution = vi.hoisted(() => ({ created_by: 'agent' as string, via_app_name: undefined as string | undefined }));
|
||||
|
||||
const coll = { id: 'c-tasks', slug: 'tasks', name: 'tasks', prefix: 'TASK', schema: '{"fields":[]}', settings: '{}' };
|
||||
function item(slug: string) {
|
||||
return {
|
||||
id: slug, slug, title: `Item ${slug}`, item_number: 1, collection_slug: 'tasks', collection_id: 'c-tasks',
|
||||
fields: '{}', tags: '[]', content: '', seq: 1,
|
||||
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
|
||||
created_by: attribution.created_by,
|
||||
...(attribution.via_app_name ? { via_app: 'inst-1', via_app_name: attribution.via_app_name } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('$lib/api/client', () => ({
|
||||
api: {
|
||||
items: {
|
||||
get: vi.fn(async (_ws: string, slug: string) => item(slug)),
|
||||
update: vi.fn(async (_ws: string, id: string) => item(id)),
|
||||
flushCollabContent: vi.fn(async (_ws: string, id: string) => item(id)),
|
||||
progress: vi.fn(async () => ({ total: 0, done: 0, percentage: 0 })),
|
||||
},
|
||||
collections: { get: vi.fn(async () => coll), list: vi.fn(async () => [coll]) },
|
||||
links: { list: vi.fn(async () => []) },
|
||||
members: { list: vi.fn(async () => ({ members: [] })) },
|
||||
agentRoles: { list: vi.fn(async () => []) },
|
||||
tags: { list: vi.fn(async () => []) },
|
||||
},
|
||||
PadApiError: class PadApiError extends Error { code = ''; },
|
||||
isUpdateConflictError: () => false,
|
||||
}));
|
||||
vi.mock('$lib/services/sse.svelte', () => ({
|
||||
sseService: {
|
||||
onItemEvent: () => () => {},
|
||||
connect: vi.fn(), disconnect: vi.fn(),
|
||||
get connected() { return true; }, get state() { return 'open'; },
|
||||
},
|
||||
}));
|
||||
vi.mock('$lib/services/sync.svelte', () => ({
|
||||
syncService: { onSync: () => () => {}, markSynced: vi.fn() },
|
||||
}));
|
||||
vi.mock('$lib/stores/localIndex.svelte', async () => {
|
||||
const m = await import('../../../test/reactiveBootstrapMock.svelte');
|
||||
return {
|
||||
localIndex: { bootstrap: vi.fn(() => m.reactiveBootstrap()), getAll: () => [], retagCollection: vi.fn(), bootstrapStateFor: () => 'cold' },
|
||||
};
|
||||
});
|
||||
vi.mock('$lib/stores/workspace.svelte', () => ({
|
||||
workspaceStore: { canEditItem: () => true, get isOwner() { return true; }, setCurrent: vi.fn(async () => {}) },
|
||||
}));
|
||||
vi.mock('$lib/stores/auth.svelte', () => ({
|
||||
authStore: {
|
||||
get identityEpoch() { return 0; },
|
||||
get userId() { return 'u1'; },
|
||||
get user() { return { id: 'u1', name: 'A' }; },
|
||||
get authenticated() { return true; },
|
||||
identityFence: () => () => true,
|
||||
onIdentityChange: () => () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
import ItemDetail from './ItemDetail.svelte';
|
||||
|
||||
async function settle() {
|
||||
flushSync();
|
||||
await tick();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await tick();
|
||||
}
|
||||
|
||||
async function metaLine(createdBy: string, viaAppName?: string) {
|
||||
attribution.created_by = createdBy;
|
||||
attribution.via_app_name = viaAppName;
|
||||
const r = render(ItemDetail, { props: { username: 'u', wsSlug: 'ws', collSlug: 'tasks', ref: 'i1' } });
|
||||
await settle();
|
||||
finishBootstrap();
|
||||
await settle();
|
||||
await waitFor(() => expect(r.container.querySelector('.meta-info')).not.toBeNull());
|
||||
return (r.container.querySelector('.meta-info')?.textContent ?? '').replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
beforeEach(() => resetBootstrap());
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('item meta line names the app that created it (TASK-3980 item 3)', () => {
|
||||
it("an app's bot: by the app, no generic agent and no second via label", async () => {
|
||||
const line = await metaLine('agent', 'Helpdesk Bridge');
|
||||
expect(line).toContain('by Helpdesk Bridge');
|
||||
expect(line).not.toContain('by agent');
|
||||
expect(line).not.toContain('via Helpdesk Bridge');
|
||||
});
|
||||
|
||||
it('a person acting through an app keeps their name, with the via label', async () => {
|
||||
const line = await metaLine('Dana', 'Helpdesk Bridge');
|
||||
expect(line).toContain('by Dana');
|
||||
expect(line).toContain('via Helpdesk Bridge');
|
||||
});
|
||||
|
||||
it('a plain agent write is unchanged', async () => {
|
||||
const line = await metaLine('agent');
|
||||
expect(line).toContain('by agent');
|
||||
expect(line).not.toContain('via');
|
||||
});
|
||||
});
|
||||
@@ -171,7 +171,11 @@
|
||||
<dd>
|
||||
<span class="mono">{install.webhook.url}</span>
|
||||
<span class="hook-status">
|
||||
{install.webhook.status === 'awaiting_secret' ? 'waiting for an install code' : install.webhook.status}
|
||||
{install.webhook.status === 'awaiting_secret'
|
||||
? 'waiting for an install code'
|
||||
: install.webhook.status === 'paused'
|
||||
? 'paused while the app is disabled'
|
||||
: install.webhook.status}
|
||||
</span>
|
||||
{#if install.webhook.notice}
|
||||
<p class="hint">{install.webhook.notice}</p>
|
||||
|
||||
@@ -191,7 +191,6 @@
|
||||
<li class="hint">None.</li>
|
||||
{/if}
|
||||
</ul>
|
||||
{#if preview.deferred_notice}<p class="hint">{preview.deferred_notice}</p>{/if}
|
||||
|
||||
<h4>Playbooks and conventions it adds ({preview.artifacts.length})</h4>
|
||||
<p class="hint">Each lands as a draft. Nothing runs until you activate it.</p>
|
||||
|
||||
@@ -75,8 +75,8 @@
|
||||
{#if list.cloud}
|
||||
Apps are not available here right now.
|
||||
{:else}
|
||||
A server admin has to enable apps before a workspace can install them. Ask your admin to turn them on in the
|
||||
admin settings.
|
||||
A server admin has to enable apps before a workspace can install them. Ask your admin to turn them on under
|
||||
Console → Admin → Settings → Apps.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2784,8 +2784,9 @@ export type AppInstallState = 'active' | 'disabling' | 'inactive' | 'uninstallin
|
||||
/** An install's webhook as its owner sees it (U10a/U10c). */
|
||||
export interface AppWebhookStatus {
|
||||
url: string;
|
||||
/** awaiting_secret: held until the app redeems a new install code. */
|
||||
status: 'awaiting_secret' | 'active' | string;
|
||||
/** awaiting_secret: held until the app redeems a new install code.
|
||||
* paused: the install is disabled, so nothing delivers (TASK-3980). */
|
||||
status: 'awaiting_secret' | 'active' | 'paused' | string;
|
||||
notice?: string;
|
||||
/** Deliveries dropped undelivered after 24 h. */
|
||||
undelivered_dropped: number;
|
||||
@@ -2892,7 +2893,6 @@ export interface AppInstallPreview {
|
||||
events: AppManifestEvent[];
|
||||
webhook_url?: string;
|
||||
item_actions: AppManifestItemAction[];
|
||||
deferred_notice?: string;
|
||||
artifacts: AppPreviewArtifact[];
|
||||
redirect_uris: string[];
|
||||
docs?: string;
|
||||
@@ -3264,6 +3264,16 @@ export interface ResolvedItemIdentity {
|
||||
|
||||
// Decision provider settings (TASK-3121) — GET/PUT /admin/decision-provider.
|
||||
// The API key is never returned, masked or otherwise: only api_key_set.
|
||||
// Apps (SPEC-6; TASK-3980 item 1): GET/PUT /api/v1/admin/apps. `available`
|
||||
// is `enabled` plus the https OAuth issuer apps need; on Cloud the PUT is
|
||||
// operator-managed.
|
||||
export interface AppsSettings {
|
||||
enabled: boolean;
|
||||
available: boolean;
|
||||
https_issuer: boolean;
|
||||
cloud: boolean;
|
||||
}
|
||||
|
||||
// MCP over HTTP (PLAN-2310 DR-2 / DR-7): GET/PUT /api/v1/admin/mcp.
|
||||
export interface MCPSettings {
|
||||
enabled: boolean;
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { flushSync, mount, unmount, tick } from 'svelte';
|
||||
|
||||
/**
|
||||
* TASK-3980 item 1: the admin Settings page's Apps section, the switch the
|
||||
* Apps tab's off-state points at. It renders each state GET
|
||||
* /api/v1/admin/apps can report and writes only `enabled` on Save. These
|
||||
* assert the rendered page (CONVE-19), with the admin store and API client
|
||||
* stubbed.
|
||||
*/
|
||||
const getApps = vi.hoisted(() => vi.fn());
|
||||
const putApps = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('$lib/stores/admin.svelte', () => ({
|
||||
adminFetch: vi.fn(async () => null),
|
||||
adminPatch: vi.fn(),
|
||||
adminPost: vi.fn(),
|
||||
getCSRFToken: () => '',
|
||||
adminStore: { stats: null }
|
||||
}));
|
||||
vi.mock('$lib/api/client', () => ({
|
||||
api: {
|
||||
admin: {
|
||||
getDecisionSettings: vi.fn(() => new Promise(() => {})),
|
||||
getMCPSettings: vi.fn(() => new Promise(() => {})),
|
||||
getAppsSettings: () => getApps(),
|
||||
updateAppsSettings: (enabled: boolean) => putApps(enabled)
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
const { default: SettingsPage } = await import('./settings/+page.svelte');
|
||||
|
||||
const settings = (over: Record<string, unknown> = {}) => ({
|
||||
enabled: true,
|
||||
available: true,
|
||||
https_issuer: true,
|
||||
cloud: false,
|
||||
private_origins: [],
|
||||
...over
|
||||
});
|
||||
|
||||
let host: HTMLElement;
|
||||
let app: Record<string, unknown> | null = null;
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 4; i++) await Promise.resolve();
|
||||
await tick();
|
||||
flushSync();
|
||||
}
|
||||
async function mountWith(body: unknown) {
|
||||
getApps.mockResolvedValue(body);
|
||||
app = mount(SettingsPage, { target: host, props: {} }) as Record<string, unknown>;
|
||||
flushSync();
|
||||
await settle();
|
||||
}
|
||||
const q = (id: string) => host.querySelector(`[data-testid="${id}"]`) as HTMLElement | null;
|
||||
|
||||
beforeEach(() => {
|
||||
host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
getApps.mockReset();
|
||||
putApps.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
if (app) unmount(app as never);
|
||||
app = null;
|
||||
host.remove();
|
||||
});
|
||||
|
||||
describe('admin Apps section', () => {
|
||||
it('on and available: toggle live, state on', async () => {
|
||||
await mountWith(settings());
|
||||
expect((q('apps-enabled') as HTMLInputElement).checked).toBe(true);
|
||||
expect((q('apps-enabled') as HTMLInputElement).disabled).toBe(false);
|
||||
expect(q('apps-state')?.dataset.state).toBe('on');
|
||||
});
|
||||
|
||||
it('enabled without an https issuer: blocked, and says why', async () => {
|
||||
await mountWith(settings({ available: false, https_issuer: false }));
|
||||
expect(q('apps-state')?.dataset.state).toBe('blocked');
|
||||
expect(q('apps-state')?.textContent).toContain('https');
|
||||
});
|
||||
|
||||
it('off', async () => {
|
||||
await mountWith(settings({ enabled: false, available: false }));
|
||||
expect((q('apps-enabled') as HTMLInputElement).checked).toBe(false);
|
||||
expect(q('apps-state')?.dataset.state).toBe('off');
|
||||
});
|
||||
|
||||
it('Pad Cloud: toggle disabled, no Save', async () => {
|
||||
await mountWith(settings({ cloud: true }));
|
||||
expect((q('apps-enabled') as HTMLInputElement).disabled).toBe(true);
|
||||
expect(q('apps-save')).toBeNull();
|
||||
expect(q('apps-section')?.textContent).toContain('managed by the operator');
|
||||
});
|
||||
|
||||
it('the checkbox edits a draft; only Save writes, then renders the answer', async () => {
|
||||
await mountWith(settings({ enabled: false, available: false }));
|
||||
putApps.mockResolvedValue(settings());
|
||||
const save = q('apps-save') as HTMLButtonElement;
|
||||
expect(save.disabled, 'nothing to save before a change').toBe(true);
|
||||
const box = q('apps-enabled') as HTMLInputElement;
|
||||
box.checked = true;
|
||||
box.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
flushSync();
|
||||
expect(putApps, 'a click on the checkbox alone does not write').not.toHaveBeenCalled();
|
||||
expect(save.disabled).toBe(false);
|
||||
save.click();
|
||||
await settle();
|
||||
expect(putApps).toHaveBeenCalledWith(true);
|
||||
expect(q('apps-state')?.dataset.state).toBe('on');
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ vi.mock('$lib/api/client', () => ({
|
||||
api: {
|
||||
admin: {
|
||||
getDecisionSettings: vi.fn(() => new Promise(() => {})),
|
||||
getAppsSettings: vi.fn(() => new Promise(() => {})),
|
||||
getMCPSettings: () => getMCP(),
|
||||
updateMCPSettings: (enabled: boolean) => putMCP(enabled)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
type LimitTiers
|
||||
} from '$lib/stores/admin.svelte';
|
||||
import { api } from '$lib/api/client';
|
||||
import type { DecisionSettings, DecisionSettingsInput, MCPSettings } from '$lib/types';
|
||||
import type { AppsSettings, DecisionSettings, DecisionSettingsInput, MCPSettings } from '$lib/types';
|
||||
|
||||
// Plan limits
|
||||
let limits = $state<LimitTiers | null>(null);
|
||||
@@ -105,6 +105,37 @@
|
||||
const mcpAuthLabel = (methods: string[]) =>
|
||||
methods.includes('oauth') ? 'OAuth and API tokens' : methods.includes('pat') ? 'API tokens only' : 'None';
|
||||
|
||||
// Apps (TASK-3980 item 1): the switch the Apps tab's off-state points at.
|
||||
// Same draft-then-Save shape as MCP above; on Cloud it is read-only.
|
||||
let apps = $state<AppsSettings | null>(null);
|
||||
let appsLoadError = $state('');
|
||||
let savingApps = $state(false);
|
||||
let appsDraft = $state(false);
|
||||
let appsStatus = $state<{ message: string; type: 'saved' | 'error' } | null>(null);
|
||||
|
||||
async function loadApps() {
|
||||
try {
|
||||
apps = await api.admin.getAppsSettings();
|
||||
appsDraft = apps.enabled;
|
||||
} catch (e) {
|
||||
appsLoadError = e instanceof Error ? e.message : 'Failed to load';
|
||||
}
|
||||
}
|
||||
|
||||
async function setAppsEnabled(enabled: boolean) {
|
||||
savingApps = true;
|
||||
appsStatus = null;
|
||||
try {
|
||||
apps = await api.admin.updateAppsSettings(enabled);
|
||||
appsDraft = apps.enabled;
|
||||
appsStatus = { message: 'Saved', type: 'saved' };
|
||||
} catch (e) {
|
||||
appsStatus = { message: e instanceof Error ? e.message : 'Failed to save', type: 'error' };
|
||||
} finally {
|
||||
savingApps = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDecision() {
|
||||
try {
|
||||
applyDecision(await api.admin.getDecisionSettings());
|
||||
@@ -240,6 +271,7 @@
|
||||
loadSettings();
|
||||
loadDecision();
|
||||
loadMcp();
|
||||
loadApps();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -629,6 +661,63 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" data-testid="apps-section">
|
||||
<h2 class="section-title">Apps</h2>
|
||||
<p class="section-desc">
|
||||
Lets workspace owners install apps that connect other tools to a workspace. Off by default on a
|
||||
self-hosted install.
|
||||
</p>
|
||||
|
||||
<div class="email-card">
|
||||
{#if appsLoadError}
|
||||
<p class="save-msg error-msg">Could not load: {appsLoadError}</p>
|
||||
{:else if !apps}
|
||||
<p class="save-msg">Loading…</p>
|
||||
{:else}
|
||||
<label class="toggle-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="apps-enabled"
|
||||
checked={appsDraft}
|
||||
disabled={apps.cloud || savingApps}
|
||||
onchange={(e) => (appsDraft = e.currentTarget.checked)}
|
||||
/>
|
||||
<span>Enable apps</span>
|
||||
{#if apps.cloud}
|
||||
<span class="env-note">managed by the operator</span>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<p class="mcp-state" data-testid="apps-state" data-state={apps.available ? 'on' : apps.enabled ? 'blocked' : 'off'}>
|
||||
{#if apps.available}
|
||||
<span class="mcp-dot on" aria-hidden="true"></span>On: workspace owners can install apps.
|
||||
{:else if apps.enabled}
|
||||
<span class="mcp-dot blocked" aria-hidden="true"></span>On, but blocked: apps sign in through this
|
||||
server's OAuth, which needs an https public origin (see MCP above).
|
||||
{:else}
|
||||
<span class="mcp-dot" aria-hidden="true"></span>Off: workspaces cannot install apps.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if !apps.cloud}
|
||||
<div class="edit-actions">
|
||||
<button
|
||||
class="btn primary"
|
||||
data-testid="apps-save"
|
||||
disabled={savingApps || appsDraft === apps.enabled}
|
||||
onclick={() => setAppsEnabled(appsDraft)}
|
||||
>
|
||||
{savingApps ? 'Saving...' : 'Save Apps Settings'}
|
||||
</button>
|
||||
{#if appsStatus}
|
||||
<span class="save-msg" class:error-msg={appsStatus.type === 'error'}>{appsStatus.message}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section-title">Integrations</h2>
|
||||
<p class="section-desc">
|
||||
|
||||
Reference in New Issue
Block a user