Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -962,7 +962,7 @@ Templates live in a three-tier registry:

**Org-admin visibility**: Org-admins can see all templates in the org (global + org + user-created) in a read-only list for security/compliance — they need to know what external APIs their users are connecting to.

**Layers (`extends`/`delta`).** Each org/user template row is a **layer**. A **standalone** layer (`extends` NULL) holds a full OpenAPI doc (the classic org/user template). A **derived** layer (`extends` set) holds a *delta* over a base template named by `extends` (resolved by key: DB rows then the global registry), and its effective template is the **fold** `resolve(layer) = apply(delta, resolve(extends))`. The delta's **masks** are restrictive and order-independent — action `allowlist` (∩) / `denylist` (\), per-action `risk` clamp-**up**-only, additive `disclose`, relabel, template `hidden` — so `resolve(child) ⊆ resolve(base)` (a child can never re-expose what a parent hid); its **extensions** add new actions/hosts only (no auth, no rebinding). An **org** layer may additionally carry `instance_defaults` — non-secret presets (endpoint `url` + `x-overslash-instance-config` values) that every service instance created from the layer inherits unless it sets its own (every template's endpoint is overridable per instance; `x-overslash-promoted` on `servers[0]` or on a config field only decides whether the instance form shows it up front or behind "Show more options"), so an org running its own deployment (its overfwd Mailbox Gateway, its self-hosted MCP server) names it once instead of on every user's instance. `extends` is a **live pointer**: editing a base (or Overslash shipping a new global version) propagates to every descendant immediately. `extends` and the layer's own `key` are decoupled — reusing the base key shadows it (curation that agents get transparently), a distinct key is a separate catalog entry alongside the base. Discovery, instantiation, and **execution** all resolve through the fold, so a masked-out action is unreachable everywhere. See [DECISIONS.md D29](DECISIONS.md) and [docs/design/layered-service-templates.md](docs/design/layered-service-templates.md).
**Layers (`extends`/`delta`).** Each org/user template row is a **layer**. A **standalone** layer (`extends` NULL) holds a full OpenAPI doc (the classic org/user template). A **derived** layer (`extends` set) holds a *delta* over a base template named by `extends` (resolved by key: DB rows then the global registry), and its effective template is the **fold** `resolve(layer) = apply(delta, resolve(extends))`. The delta's **masks** are restrictive and order-independent — action `allowlist` (∩) / `denylist` (\), per-action `risk` clamp-**up**-only, additive `disclose`, relabel (display name, description, summary), template `hidden` — so `resolve(child) ⊆ resolve(base)` (a child can never re-expose what a parent hid); its **extensions** add new actions/hosts only (no auth, no rebinding). An **org** layer may additionally carry `instance_defaults` — non-secret presets (endpoint `url` + `x-overslash-instance-config` values) that every service instance created from the layer inherits unless it sets its own (every template's endpoint is overridable per instance; `x-overslash-promoted` on `servers[0]` or on a config field only decides whether the instance form shows it up front or behind "Show more options"), so an org running its own deployment (its overfwd Mailbox Gateway, its self-hosted MCP server) names it once instead of on every user's instance. `extends` is a **live pointer**: editing a base (or Overslash shipping a new global version) propagates to every descendant immediately. `extends` and the layer's own `key` are decoupled — reusing the base key shadows it (curation that agents get transparently), a distinct key is a separate catalog entry alongside the base. Discovery, instantiation, and **execution** all resolve through the fold, so a masked-out action is unreachable everywhere. See [DECISIONS.md D29](DECISIONS.md) and [docs/design/layered-service-templates.md](docs/design/layered-service-templates.md).

### Template Definition

Expand Down Expand Up @@ -1041,6 +1041,7 @@ paths:
- **`x-overslash-pagination` / `pagination:`** — on an operation (or MCP tool), that this action returns one *page* of a larger collection, and how to reach the next one. Two halves. `page_size: {param, default, max}` names the declared numeric parameter that bounds a page; `default` **seeds that parameter's own `default:`** at compile time when it declares none, so the bound reaches the wire through `apply_defaults` like any other default (and appears on `/v1/search` rows for the same reason) rather than through a second injection path — a parameter that already declares one keeps it, and the two disagreeing is a warning. `max` is declarative: it bounds `default` at validation time and is *not* enforced against a caller who explicitly asks for more. `next: {style, param, from}` names the continuation: `cursor` (an opaque value read from the dotted body path in `from` — Slack's `cursor`, Notion's `start_cursor`, Google's `pageToken`), `offset` (advance `param` by the effective page size), `page` (increment `param` — whose own `default:` is required, since it is the only place a template says whether pages count from 0 or from 1), or `link` (the response names the whole next URL and the gateway lifts the declared query keys out of it — from the RFC 8288 `Link: <…>; rel="next"` header by default, or from the dotted body path in `from`, which is where Shortcut, Zendesk and Twilio put theirs; `param` there names the single query key the next URL may introduce although page one never sent it. Needs `from` on an MCP tool, whose results carry no headers). Optional `items` (dotted path to the principal collection) and `has_more` (dotted path to an explicit boolean) are how a last page is recognised. A **paged result** carries `_pagination: {has_more, next: {service, action, params}}`, where `params` is the *difference* between the call just made and the next one, in the action's own parameter names — merge it into what you sent. The gateway never follows a page itself. A malformed block is a template *error* and the action goes back to being unpaged; a `page_size.param` naming a parameter the action does not declare is an error too, since it reads as a bound and applies none. See §12b.
- **`x-overslash-default_timeout_ms` / `default_timeout_ms:`** — under `info`, the same thing one rung less specific: the timeout every action of this service inherits unless it declares its own. The one-line answer to "this whole upstream is slow".
- **`x-overslash-icon` / `icon:`** — under `info`, the mark the dashboard shows for this service. Two forms: `builtin:<name>`, an asset Overslash ships and serves at `/icons/<name>.svg`, and an `https://` URL hosted elsewhere. **Usually omitted**: a template whose key matches a shipped asset resolves to `builtin:<key>` implicitly, which is why the shipped templates declare nothing. Explicit values are for the two cases the convention can't express — a key that deliberately differs from the asset it reuses, or a remote URL. Resolved server-side and surfaced as an absolute `icon_url` on the template and service-instance responses; a template with nothing renderable omits the field, and the dashboard falls back to a letter tile. Only `https://` ever reaches a browser — `http:`, `data:` and `javascript:` are template *errors*, checked both at write time and again when the response is built. Overslash never fetches a remote icon (that would be an SSRF vector and a boot-time network dependency), so there is no size or format validation of one. `${VAR}` expansion applies like anywhere else, which is how a self-hoster points the set at their own CDN. Icons are deliberately **not** on `/v1/search`: it fans out up to 100 rows per (instance × action) and the field would cost an agent's context window for something no model can render.
- **`summary:`** (standard OpenAPI 3.1 `info.summary`, not an extension) — under `info`, a one-line tagline for UI catalogs: one plain sentence, at most 80 characters, no trailing period. `info.description` is written for agents (it is folded into the text every action is scored against) and runs to paragraphs; `summary` is what a catalog tile shows, and consumers render `summary ?? description`. Surfaced as `summary` on `GET /v1/templates` (list, search, detail, admin, draft preview) and omitted when unset. Deliberately **not** on `/v1/search` or MCP: agents keep `description`. A multi-line or over-long value compiles with an `openapi_invalid` warning (an imported spec still loads); a derived layer may relabel it through `delta.summary`, which takes the same warnings. Every shipped template carries one, enforced by `every_shipped_template_has_a_short_summary`.
- **`x-overslash-additional-properties` / `additional-properties:`** — on an operation or MCP tool, that the action's declared parameter set is a **floor, not a fence**: an argument the template never declared is forwarded upstream instead of returning `invalid_action_args`, and a declared `enum` names the members we know of rather than all of them. `required` is still enforced either way — it is a statement about the operation, not about how completely we transcribed it. Also settable under `info` as a service-wide default, where the nearest value wins, so an operation may write `false` to re-tighten under a service that relaxed globally. **Defaults to `false`**, and a malformed value (`additional-properties: "true"`) warns and stays strict, so a quoting mistake cannot grant the relaxation by accident. The name is borrowed from JSON Schema, where it governs undeclared keys only; here it deliberately covers `enum` too, because the same template that cannot name every parameter is the one whose enum snapshot has gone stale. For a relaxed action whose operation declares no `requestBody`, a non-`GET` call synthesizes a JSON body so the accepted argument actually reaches the wire; declaring the key on a body-carrying method whose `requestBody` is **non-JSON** is a template *error* (`additional_properties_needs_a_json_body`), since only JSON bodies are serialised and the argument would otherwise pass validation and then be dropped. **Not read on a platform action**: those answer from this process against a param set Overslash owns, so an undeclared argument would pass the gate and then be silently dropped — strictly worse than the 400. What the author gives up is the Levenshtein "did you mean" on a typo: a misspelled optional param now reaches the upstream instead of returning a self-correctable 400. Two security notes, both consequences of the gateway not knowing a name exists: permission-key derivation (`x-overslash-scope_param`) reads declared params only, so an undeclared argument can change what a call does without changing the key the chain checks; and `x-overslash-redact` cannot cover a path it has never seen, so a relaxed action's approval payload and audit row may carry undeclared values in the clear. Surfaced to agents as `additional_properties` on `/v1/search` rows, and to the dashboard on the action-detail response, which is what turns the API Explorer's enum `<select>` into an open combobox and adds its free-form argument rows.
- **`x-overslash-success` / `success:`** — a jq predicate over the parsed response body that says whether a below-400 response actually succeeded, for upstreams that report failure in-band (Slack answers `200 {"ok": false, "error": …}`; `services/slack.yaml` declares `success: ".ok == true"` under `info`). It can be set under `info` as a service-wide default, which an operation's own value overrides, and is read on **HTTP operations only**: an MCP tool already reports `isError`, and a platform action answers with a status it chose. The verdict passes only when the predicate emits at least one value and every value is exactly `true`. `false`, a truthy non-boolean, no output, or a jq runtime error mark the result `body_failed`, which counts as an upstream error for the call's `is_error`, the `action.executed` audit row, `errors_only` response capture, and pagination (no `next` is offered). The HTTP status reported to the caller stays the upstream's real one, and the body is returned unchanged, so the agent still reads the upstream's error code. A body that is not JSON, or a predicate that times out (bounded by `FILTER_TIMEOUT_MS`), keeps the status-only verdict. Streamed responses are never judged, because their body is not buffered. The predicate is snapshotted onto the stored replay payload, so a gated or async call is judged by the rule that was in force when it was made. A malformed value warns at `info` and is an error on an operation, and a predicate that is not valid jq is refused at the template register gate (`success_invalid_jq`).
- **`x-overslash-subactions` / `subactions:`** — on an operation or MCP tool, the **variants** of one overloaded action, classified per call from its own arguments. `each:` (jq over the params; default `.`, the whole call as one item) splits a call into items — one per object in a batch — and each item is matched to the first variant whose `match:` jq predicate is truthy. A variant carries the controls an action does: `description`, `risk` (defaults to the parent's), `scope_param` (resolved against the item), `disclose` (appended to the parent's; its filters see `.items`, the variant's own items), `redact`, `timeout_ms`, `required_scopes`, and `vars:` (named jq over the item, exposed as `{item.<name>}`). A matched call is keyed `{service}:{parent}/{variant}:{arg}` and needs every matched variant's key; an item no variant claims keeps the parent's key, label and risk, and a classifier error or timeout gates the whole call as the parent. A rule on the parent (`hubspot:manage_crm_objects:*`) **covers every variant**; a rule naming one (`…/create_contact:*`) is narrower. Descriptions compose per item: each variant's `verb` + `phrase` group into clauses (`Create contact 'jane@acme.com' and deal 'Acme renewal'; update deal 'Big Co'`), a variant `summary` overrides when every item is that variant, and a block-level `summary` overrides everything. Label templates add `{item.<path>}`, `{count}` and `{enumerate(<template>)}` (English list, `… and N more` past 5) to the `{param}`/`[optional]` grammar. A variant with `input_schema` + `transform` (jq from its params to the parent's) is also **callable** by its own name; the call is rewritten into the parent, validated as the parent, and must classify entirely as that variant, so it mints the same keys as the raw call. Not allowed on `risk: dynamic` actions. A layer denying the parent hides its callable variants; a risk clamp on either reaches classification. See `docs/design/subactions.md`.
Expand Down
1 change: 1 addition & 0 deletions crates/overslash-api/src/routes/actions/service_resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,7 @@ mod verb_host_path_tests {
key: "github".into(),
display_name: "GitHub".into(),
description: None,
summary: None,
url_promoted: false,
hosts: hosts.into_iter().map(String::from).collect(),
category: None,
Expand Down
1 change: 1 addition & 0 deletions crates/overslash-api/src/routes/approvals/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ mod risk_tests {
key: key.into(),
display_name: key.into(),
description: None,
summary: None,
url_promoted: false,
hosts: vec![],
category: None,
Expand Down
1 change: 1 addition & 0 deletions crates/overslash-api/src/routes/search/setup_hint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ mod tests {
key: "acme".into(),
display_name: "Acme".into(),
description: None,
summary: None,
url_promoted: false,
hosts: vec!["api.acme.test".into()],
category: None,
Expand Down
2 changes: 2 additions & 0 deletions crates/overslash-api/src/routes/templates/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub(super) async fn list_templates_admin(
key: svc.key.clone(),
display_name: svc.display_name.clone(),
description: svc.description.clone(),
summary: svc.summary.clone(),
category: svc.category.clone(),
hosts: svc.hosts.clone(),
action_count: svc.actions.len(),
Expand Down Expand Up @@ -61,6 +62,7 @@ pub(super) async fn list_templates_admin(
key: t.key,
display_name: s.display_name,
description: s.description,
summary: s.summary,
category: s.category,
hosts: s.hosts,
action_count: s.action_count,
Expand Down
3 changes: 3 additions & 0 deletions crates/overslash-api/src/routes/templates/drafts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ struct TemplatePreview {
key: String,
display_name: String,
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<String>,
category: Option<String>,
hosts: Vec<String>,
auth: Vec<serde_json::Value>,
Expand Down Expand Up @@ -516,6 +518,7 @@ fn preview_from_compiled(def: &ServiceDefinition) -> TemplatePreview {
key: def.key.clone(),
display_name: def.display_name.clone(),
description: def.description.clone(),
summary: def.summary.clone(),
category: def.category.clone(),
hosts: def.hosts.clone(),
auth: serde_json::to_value(&def.auth)
Expand Down
11 changes: 11 additions & 0 deletions crates/overslash-api/src/routes/templates/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ pub(super) struct TemplateSummary {
pub(super) key: String,
pub(super) display_name: String,
pub(super) description: Option<String>,
/// `info.summary` — one-line catalog tagline. UI catalogs show it in
/// place of `description`, which is written for agents. Omitted when unset.
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) summary: Option<String>,
pub(super) category: Option<String>,
pub(super) hosts: Vec<String>,
pub(super) action_count: usize,
Expand Down Expand Up @@ -41,6 +45,10 @@ pub(super) struct TemplateDetail {
pub(super) key: String,
pub(super) display_name: String,
pub(super) description: Option<String>,
/// `info.summary` — one-line catalog tagline. UI catalogs show it in
/// place of `description`, which is written for agents. Omitted when unset.
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) summary: Option<String>,
pub(super) category: Option<String>,
pub(super) hosts: Vec<String>,
/// Absolute URL of the catalog icon, resolved from the template's
Expand Down Expand Up @@ -179,6 +187,9 @@ pub(super) struct AdminTemplateSummary {
pub(super) key: String,
pub(super) display_name: String,
pub(super) description: Option<String>,
/// See [`TemplateSummary::summary`].
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) summary: Option<String>,
pub(super) category: Option<String>,
pub(super) hosts: Vec<String>,
pub(super) action_count: usize,
Expand Down
5 changes: 5 additions & 0 deletions crates/overslash-api/src/routes/templates/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,7 @@ async fn db_row_to_detail(
key: t.key,
display_name: def.display_name.clone(),
description: def.description.clone().filter(|s| !s.is_empty()),
summary: def.summary.clone().filter(|s| !s.is_empty()),
category: def.category.clone().filter(|s| !s.is_empty()),
hosts: def.hosts.clone(),
icon_url: resolve_icon_url(def.icon.as_ref(), &state.config.public_url),
Expand Down Expand Up @@ -585,6 +586,7 @@ fn mcp_detail_from(def: &ServiceDefinition, openapi: &serde_json::Value) -> Opti
struct RowSummary {
display_name: String,
description: Option<String>,
summary: Option<String>,
category: Option<String>,
hosts: Vec<String>,
action_count: usize,
Expand All @@ -604,6 +606,7 @@ async fn resolved_summary(
RowSummary {
display_name: d.display_name,
description: d.description.filter(|s| !s.is_empty()),
summary: d.summary.filter(|s| !s.is_empty()),
category: d.category.filter(|s| !s.is_empty()),
hosts: d.hosts,
action_count: d.actions.len(),
Expand All @@ -616,6 +619,8 @@ async fn resolved_summary(
Err(_) => RowSummary {
display_name: t.display_name.clone(),
description: Some(t.description.clone()).filter(|s| !s.is_empty()),
// No denormalized column: the summary lives only in the openapi doc.
summary: None,
category: Some(t.category.clone()).filter(|s| !s.is_empty()),
hosts: t.hosts.clone(),
action_count: 0,
Expand Down
Loading
Loading