@openparachute/agent 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/docs/design/2026-05-02-channel-policy-and-approval-routing.md +1 -1
- package/package.json +1 -1
- package/scripts/init-cli-agent.ts +2 -1
- package/scripts/init-first-agent.ts +2 -1
- package/scripts/seed-discord.ts +2 -1
- package/src/channels/api-translator.test.ts +306 -0
- package/src/channels/api-translator.ts +214 -0
- package/src/config.ts +23 -3
- package/src/container-runtime.test.ts +101 -1
- package/src/container-runtime.ts +76 -1
- package/src/db/connection.migrate.test.ts +35 -2
- package/src/db/connection.ts +40 -5
- package/src/index.ts +6 -1
- package/src/mcp/tools/channels.test.ts +126 -0
- package/src/mcp/tools/channels.ts +33 -98
- package/src/modules/mount-security/expand-path.test.ts +82 -0
- package/src/modules/mount-security/index.ts +21 -10
- package/src/modules/permissions/sender-approval.test.ts +171 -0
- package/src/secrets/index.ts +127 -21
- package/src/secrets/secrets.test.ts +301 -4
- package/src/session-manager.attachments.test.ts +171 -0
- package/src/session-manager.dup-skip.test.ts +173 -0
- package/src/session-manager.ts +22 -4
- package/src/types.ts +4 -1
- package/src/web/routes/channels-mga-detail.test.ts +49 -2
- package/src/web/routes/channels.ts +25 -203
- package/src/web/routes/secrets.test.ts +46 -1
- package/src/web/routes/secrets.ts +35 -0
- package/src/web/server.ts +34 -13
- package/src/web/services-manifest.test.ts +37 -9
- package/src/web/services-manifest.ts +14 -9
- package/web/ui/index.html +2 -2
- package/web/ui/src/App.tsx +1 -1
- package/web/ui/src/lib/api.test.ts +2 -2
- package/web/ui/src/lib/api.ts +40 -2
- package/web/ui/src/lib/auth.test.ts +214 -1
- package/web/ui/src/lib/auth.ts +79 -22
- package/web/ui/src/routes/ChannelWireDetail.test.tsx +2 -2
- package/web/ui/src/routes/ChannelWireDetail.tsx +1 -1
- package/web/ui/src/routes/GroupDetail.test.tsx +206 -0
- package/web/ui/src/routes/GroupDetail.tsx +126 -1
- package/web/ui/src/routes/MessagingGroupDetail.test.tsx +1 -1
- package/web/ui/src/routes/SecretsList.tsx +22 -1
- package/web/ui/src/routes/VaultDetail.test.tsx +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,48 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to parachute-agent will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.1.2] - 2026-05-05
|
|
6
|
+
|
|
7
|
+
The first patch series after the 0.1.0 paraclaw → parachute-agent rename. Fourteen iterative cuts (rc.1 through rc.14) collapsed into one stable. No operator action required: every change is either a transparent fix, an additive UI affordance, or a behind-the-scenes test addition.
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Master-key migration: detect the both-exist split-state explicitly.** `migrateMasterKeyLocation` previously silent-no-op'd when both `<PARACHUTE_DIR>/claw/master.key` and `<PARACHUTE_DIR>/agent/master.key` existed — masking the case where an earlier 0.1.x boot generated a fresh key at the new path before the legacy was copied (so encrypted secrets sealed under the legacy key became undecryptable). The function now logs a `warn` with both paths and copy-pasteable recovery commands. Standalone scripts (`init-cli-agent`, `init-first-agent`, `seed-discord`) that ran `migrateCentralDbLocation` now also run `migrateMasterKeyLocation` before opening the DB, so a script-driven first touch no longer skips the key copy. Also: SPA browser title `<title>Paraclaw</title>` → `<title>Parachute Agent</title>` and two stale GitHub repo links pointing at the renamed-from `paraclaw` URL — small follow-ups to the 0.1.0 brand sweep that landed in the same cut.
|
|
12
|
+
|
|
13
|
+
- **Auto-retag the per-install container image when `INSTALL_SLUG` shifts (paraclaw#114).** `INSTALL_SLUG = sha1(process.cwd())[:8]`, so an operator dir-rename (the trigger that exposed this: `mv paraclaw parachute-agent`) flips the slug. Previously-built images carried the old slug; new container spawns went out under the new slug; `docker run` returned `code=125` ("image not found") and every Telegram message produced a silent crashloop. New `ensureContainerImage()` step at boot detects the mismatch and `docker tag`s any `parachute-agent-image-<peer-slug>:latest` it finds onto the expected name. Pre-0.1.0 `paraclaw-agent-<slug>:latest` peers also match (one cycle of compat). When no peer is on disk, the daemon now fails visibly at startup with an actionable error instead of crashlooping silently.
|
|
14
|
+
|
|
15
|
+
- **Inbound: extract attachment files only after the row commits (paraclaw#96).** `writeSessionMessage` previously decoded base64 attachments and wrote files to `inbox/<messageId>/` *before* `INSERT … ON CONFLICT(id) DO NOTHING` returned. Once duplicate dispatch became a warm code path (sender-approval replay, Telegram getUpdates retry, chat-sdk re-emit), a replay carrying the same `messages_in.id` but mutated bytes silently clobbered the on-disk file under the original message id while the DB row stayed unchanged — divergent state with no audit trail. Reordered: insert with raw inline-base64 content, check `inserted`, and only when `inserted === true` extract files and `UPDATE messages_in SET content = ?` with the path-replaced form. Disk state now stays strictly downstream of the row commit.
|
|
16
|
+
|
|
17
|
+
- **Wire-side `senderScope` vocabulary clash (paraclaw#94).** The wire vocab `'allowlist' | 'all'` shared the literal `'all'` with the DB-side `'all' | 'known'` — both meant "no sender filter", but the literal collision meant a grep-based rename of either side would silently break translation without a compile error. Renamed wire-side `'all'` → `'unrestricted'` so the two unions are now literal-disjoint; DB schema untouched (no migration). Touchpoints: HTTP + MCP translators, MCP `update-channel-wire` schema enum (now `['allowlist', 'unrestricted']`), `web/ui/src/lib/api.ts:SenderScope`, and the dropdown copy in `ChannelWireDetail.tsx`. Plus a defensive validation gate on the MCP handler — the SDK does not enforce `inputSchema` against `tools/call` arguments, so a stale-schema client sending the legacy `senderScope: 'all'` (or `ignoredMessagePolicy: 'accumulate'`, or a typo'd `engageMode`) would previously land past the rename gate, never match any branch, and silently no-op. Now explicitly rejected with a diagnostic error. **Breaking change to the API/MCP wire vocabulary** — pre-1.0, no operator-data risk.
|
|
18
|
+
|
|
19
|
+
- **Mount-security imports `HOME_DIR` from `src/config.ts` (paraclaw#99).** `expandPath` in `src/modules/mount-security/index.ts` previously called `process.env.HOME || os.homedir()` directly — the only remaining offender after the rest of the host's HOME-derived paths routed through `config.ts`. Now imports the canonical `HOME_DIR`, so a future precedence-rule refactor (e.g. add a `PARACHUTE_AGENT_HOME` override) is one edit upstream. Default behavior unchanged. Mount-allowlist's on-disk location intentionally stays at `<HOME>/.config/parachute-agent/` (operator-host policy, not per-install runtime state) — pinned with a regression test.
|
|
20
|
+
|
|
21
|
+
- **`putSecret` auto-seeds the owner assignment for scoped creates (paraclaw#127).** The default `agent_groups.secret_mode` is `selective` (migration 023). Before this fix, `putSecret(name, value, { agent_group_id })` inserted the `secrets` row without writing the matching `secret_assignments` row — leaving the row silently invisible to `resolveInjectableSecrets` (which gates on `secret_mode='all' OR assignment row exists`). The "+ New secret" → CredentialForm "free" mode in the SPA called only `putSecret` with no follow-up `setSecretAssignments`, so the standard create flow produced orphan rows whose values would never reach the agent container. Fix: `putSecret` writes the (id, owning_group) assignment row in the same transaction on INSERT (idempotent via `ON CONFLICT … DO NOTHING`); UPDATE/rotate leaves the assignment set alone (operator may have deliberately revoked an assignment, and a value rotation must not undo that).
|
|
22
|
+
|
|
23
|
+
- **SPA OAuth bootstrap — three narrowing fixes (paraclaw#136, #137, #138).** (1) Drop `vault:read vault:write` from `REQUESTED_SCOPES` — the agent SPA is self-contained, every vault flow already runs the per-vault re-consent pattern (`vault:<name>:admin` via `extraScopes`), so the broad bootstrap scopes were dead weight on the consent screen ("this app wants to read/write all your vaults" — wrong story for an SPA whose vault touches are narrowly per-vault and on-demand). (2) Regression-pin OAuth `client_name` in the registerClient body — the hub renders this string verbatim on its DCR consent screen; the 0.1.0 brand sweep renamed it from `Paraclaw web UI` to `Parachute Agent web UI`, this pins the wire-level test. (3) Re-register OAuth client when `redirect_uri` changes — the hub binds each DCR `client_id` to the redirect_uri it registered with; if the SPA's mount path changes (operator flips `PARACHUTE_AGENT_WEB_MOUNT` from `/claw/` → `/agent/`, or any custom remount), the cached client_id stops matching and `/oauth/authorize` errors out before the consent screen. Extended `ClientRecord` to `{ client_id, redirect_uri }`, compare in `ensureClient`, treat mismatch (or legacy missing-field record) as cache miss → re-register. Legacy records self-heal on first 0.1.x reload.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- **`services.json` self-registers `installDir` (paraclaw#115).** The agent's startup self-registration into `~/.parachute/services.json` now includes `installDir: process.cwd()` alongside the existing `name`/`port`/`paths`/`health`/`version` fields. Without it, hub's third-party-module lifecycle resolution (parachute-hub#84) couldn't locate the start command for `parachute restart agent` — the agent had a `.parachute/module.json` with `startCmd`, but hub needed `installDir` to know which checkout to drive.
|
|
28
|
+
|
|
29
|
+
- **GroupDetail "Secrets" panel — what the agent will receive at next session spawn (paraclaw#104).** `/agent/groups/:folder` now surfaces a read-only Secrets section showing the same set `resolveInjectableSecrets()` would inject into a new container, with three scope badges that explain *why* each row is included: `scoped` (owned by this group), `assigned` (global with explicit assignment row), `global` (global reaching the group only because `secret_mode='all'`). On a name collision the scoped row wins and reports `scoped`, mirroring the host's resolution rule. Click-through routes to `/secrets?edit=<id>` with a deep-link param for SecretEditor. New `GET /api/groups/:folder/secrets` endpoint (scope `agent:read`) — metadata only, never decrypts. Empty state distinguishes between mode='selective' (reads as "by design") and mode='all' (suggests creating a secret).
|
|
30
|
+
|
|
31
|
+
- **GroupDetail Secrets section — Retry button on error state (paraclaw#128).** Mirrors the existing AgentProviderSection pattern: the error banner now renders a Retry button bound to the same fetch callback so operators don't have to navigate away after a transient API failure.
|
|
32
|
+
|
|
33
|
+
- **Channel-wire translator extracted into a single shared module (paraclaw#123).** `src/web/routes/channels.ts` and `src/mcp/tools/channels.ts` each maintained their own copy of the `Api*` types, the `VALID_API_*` enum arrays, the `dbToApi*` translator pair, and the `ChannelWireView` shape. That duplication was the structural drift hazard paraclaw#94 surfaced concretely. Lifted everything into `src/channels/api-translator.ts`; the HTTP route file now owns only the transport layer, the MCP file only the tool-def plumbing. A future enum change touches one file and both surfaces pick it up automatically. (Behavioral side note: the inline MCP handler used to silently *drop* `engagePattern='.'` because the DB sentinel for `engageMode='all'` would round-trip back as `'all'` on the next read; the shared validator now hard-rejects that input identically on both surfaces. Use `'\\.'` to match a literal dot.)
|
|
34
|
+
|
|
35
|
+
- **Depersonalize test fixtures + comments.** Removed a real install-slug (`16f7e9e8`, the sha1 prefix of one operator's specific path) that had snuck into `src/container-runtime.test.ts` peer-image fixtures, plus a comment in `src/container-runtime.ts` that named the specific `mv` command from one operator's environment. Codebase should be operator-agnostic. Replaced with synthetic `cafef00d`. No behavior change.
|
|
36
|
+
|
|
37
|
+
### Tests
|
|
38
|
+
|
|
39
|
+
- **Integration coverage for `writeSessionMessage` dup-skip + sender-approval replay (paraclaw#97).** The unit test added with #95 proved `insertMessage` returns `inserted=false` on a duplicate id, but the write-path side effects layered above it were never asserted at the integration level. New `src/session-manager.dup-skip.test.ts` (4 tests using real session DBs and real fs: dup dispatch doesn't bump `sessions.last_active`, log payload shape, N-concurrent same-id absorption to one row + one inbox file, distinct ids in the same burst still land), plus 2 new tests in `src/modules/permissions/sender-approval.test.ts` exercising the approval-replay chain end-to-end (file at `inbox/<id>:<agentGroupId>/photo.jpg`, byte-preserved on `original_message` mutation under accumulate-mode wiring). Stash-and-rerun confirmed both regression tests catch the underlying #92/#95/#96 bugs.
|
|
40
|
+
|
|
41
|
+
- **Parallel-equality lockstep guard for `resolveInjectableSecrets ↔ listInjectableSecretsForGroup` (paraclaw#129).** The two functions in `src/secrets/index.ts` are SQL-identical mirrors with a load-bearing doc-comment requiring lockstep edits — previously preserved only by careful reading and a #126-era reviewer note. Adds a `describe('… lockstep …')` block with an `expectLockstep` helper that calls both functions, asserts name-set equality, and walks each name through `getSecret` to verify the chosen row id (the `ORDER BY s.agent_group_id IS NULL` scoped-wins ordering) agrees with the plaintext returned. Five fixtures cover the rich-mix (scoped+all + global+assigned + global+mode=all + name collision), mode=selective, the orphaned-scoped corner, the unknown-agent-group selective-default path, and an empty store. Mechanical guard, no production code change.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
For per-rc commit-level detail of the 0.1.2 patch series, see `git log v0.1.1..v0.1.2 -- src/ web/ui/src/` or the merged PRs (#113 through #139).
|
|
46
|
+
|
|
5
47
|
## [0.1.1] - 2026-05-05
|
|
6
48
|
|
|
7
49
|
### Changed
|
|
@@ -23,7 +23,7 @@ This doc proposes how to fix all three together. No impl until Aaron reads it.
|
|
|
23
23
|
| `sender_scope` | `all` \| `known` | `all` = no-op; `known` = `canAccessAgentGroup(userId, agent_group_id)` must allow. Enforced via the `senderScopeGate` hook the permissions module registers. | `src/modules/permissions/index.ts:175-183` |
|
|
24
24
|
| `ignored_message_policy` | `drop` \| `accumulate` | Branch on the *non-engaging* path: `drop` = silently skip; `accumulate` = still write the inbound row to the agent's session DB with `trigger=0`, so context is available next time it does engage | `src/router.ts:355-358` |
|
|
25
25
|
|
|
26
|
-
Note the API surface (`src/web/routes/channels.ts:8-22`) uses different enum names that translate at the route boundary — `engageMode='all'` collapses to DB `engage_mode='pattern'` + `engage_pattern='.'`; `senderScope='allowlist'` ↔ `sender_scope='known'
|
|
26
|
+
Note the API surface (`src/web/routes/channels.ts:8-22`) uses different enum names that translate at the route boundary — `engageMode='all'` collapses to DB `engage_mode='pattern'` + `engage_pattern='.'`; `senderScope='allowlist'` ↔ `sender_scope='known'` and `senderScope='unrestricted'` ↔ `sender_scope='all'` (paraclaw#94 renamed wire-side `'all'` → `'unrestricted'` so the two `SenderScope` unions are literal-disjoint); `ignoredMessagePolicy='silent'` ↔ `ignored_message_policy='accumulate'`. The UI sees the API names. The DB keeps the original ones. The translator is lossy on the `mention` ↔ `mention-sticky` distinction (renders both as `mention`); the `apiToDbPatch` at `src/web/routes/channels.ts:97-127` carefully preserves sticky on round-trip.
|
|
27
27
|
|
|
28
28
|
### 1b. The MG-level knob
|
|
29
29
|
|
package/package.json
CHANGED
|
@@ -21,7 +21,7 @@ import path from 'path';
|
|
|
21
21
|
|
|
22
22
|
import { CENTRAL_DB_PATH } from '../src/config.js';
|
|
23
23
|
import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
|
|
24
|
-
import { initDb, migrateCentralDbLocation } from '../src/db/connection.js';
|
|
24
|
+
import { initDb, migrateCentralDbLocation, migrateMasterKeyLocation } from '../src/db/connection.js';
|
|
25
25
|
import {
|
|
26
26
|
createMessagingGroup,
|
|
27
27
|
createMessagingGroupAgent,
|
|
@@ -78,6 +78,7 @@ async function main(): Promise<void> {
|
|
|
78
78
|
const args = parseArgs(process.argv.slice(2));
|
|
79
79
|
|
|
80
80
|
migrateCentralDbLocation();
|
|
81
|
+
migrateMasterKeyLocation();
|
|
81
82
|
const db = initDb(CENTRAL_DB_PATH);
|
|
82
83
|
runMigrations(db);
|
|
83
84
|
|
|
@@ -35,7 +35,7 @@ import path from 'path';
|
|
|
35
35
|
|
|
36
36
|
import { CENTRAL_DB_PATH, DATA_DIR } from '../src/config.js';
|
|
37
37
|
import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
|
|
38
|
-
import { initDb, migrateCentralDbLocation } from '../src/db/connection.js';
|
|
38
|
+
import { initDb, migrateCentralDbLocation, migrateMasterKeyLocation } from '../src/db/connection.js';
|
|
39
39
|
import {
|
|
40
40
|
createMessagingGroup,
|
|
41
41
|
createMessagingGroupAgent,
|
|
@@ -170,6 +170,7 @@ async function main(): Promise<void> {
|
|
|
170
170
|
const args = parseArgs(process.argv.slice(2));
|
|
171
171
|
|
|
172
172
|
migrateCentralDbLocation();
|
|
173
|
+
migrateMasterKeyLocation();
|
|
173
174
|
const db = initDb(CENTRAL_DB_PATH);
|
|
174
175
|
runMigrations(db); // idempotent
|
|
175
176
|
|
package/scripts/seed-discord.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Usage: pnpm exec tsx scripts/seed-discord.ts
|
|
5
5
|
*/
|
|
6
6
|
import { CENTRAL_DB_PATH } from '../src/config.js';
|
|
7
|
-
import { initDb, migrateCentralDbLocation } from '../src/db/connection.js';
|
|
7
|
+
import { initDb, migrateCentralDbLocation, migrateMasterKeyLocation } from '../src/db/connection.js';
|
|
8
8
|
import { runMigrations } from '../src/db/migrations/index.js';
|
|
9
9
|
import { createAgentGroup, getAgentGroup } from '../src/db/agent-groups.js';
|
|
10
10
|
import {
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from '../src/db/messaging-groups.js';
|
|
15
15
|
|
|
16
16
|
migrateCentralDbLocation();
|
|
17
|
+
migrateMasterKeyLocation();
|
|
17
18
|
const db = initDb(CENTRAL_DB_PATH);
|
|
18
19
|
runMigrations(db);
|
|
19
20
|
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Round-trip + validator coverage for the shared channel-wire translator
|
|
3
|
+
* (paraclaw#123). The HTTP and MCP surfaces both depend on this module to
|
|
4
|
+
* keep the wire ↔ DB enums in lockstep — paraclaw#94/#122 was the drift
|
|
5
|
+
* incident that motivated extracting these tests behind one file.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import type { MessagingGroupAgent } from '../types.js';
|
|
10
|
+
import {
|
|
11
|
+
ALL_MESSAGES_PATTERN_SENTINEL,
|
|
12
|
+
apiToDbPatch,
|
|
13
|
+
dbToApiEngage,
|
|
14
|
+
dbToApiIgnoredPolicy,
|
|
15
|
+
dbToApiSenderScope,
|
|
16
|
+
rowToView,
|
|
17
|
+
validatePatchInput,
|
|
18
|
+
type WireJoinRow,
|
|
19
|
+
} from './api-translator.js';
|
|
20
|
+
|
|
21
|
+
function baseRow(overrides: Partial<WireJoinRow> = {}): WireJoinRow {
|
|
22
|
+
return {
|
|
23
|
+
id: 'mga-1',
|
|
24
|
+
messaging_group_id: 'mg-1',
|
|
25
|
+
agent_group_id: 'ag-1',
|
|
26
|
+
engage_mode: 'mention',
|
|
27
|
+
engage_pattern: null,
|
|
28
|
+
sender_scope: 'all',
|
|
29
|
+
ignored_message_policy: 'drop',
|
|
30
|
+
session_mode: 'shared',
|
|
31
|
+
priority: 0,
|
|
32
|
+
created_at: '2026-05-05T00:00:00Z',
|
|
33
|
+
mg_channel_type: 'discord',
|
|
34
|
+
mg_platform_id: 'guild-123',
|
|
35
|
+
mg_name: 'general',
|
|
36
|
+
ag_folder: 'research',
|
|
37
|
+
ag_name: 'Research',
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function baseCurrent(overrides: Partial<MessagingGroupAgent> = {}): MessagingGroupAgent {
|
|
43
|
+
return {
|
|
44
|
+
id: 'mga-1',
|
|
45
|
+
messaging_group_id: 'mg-1',
|
|
46
|
+
agent_group_id: 'ag-1',
|
|
47
|
+
engage_mode: 'mention',
|
|
48
|
+
engage_pattern: null,
|
|
49
|
+
sender_scope: 'all',
|
|
50
|
+
ignored_message_policy: 'drop',
|
|
51
|
+
session_mode: 'shared',
|
|
52
|
+
priority: 0,
|
|
53
|
+
created_at: '2026-05-05T00:00:00Z',
|
|
54
|
+
...overrides,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('dbToApiEngage', () => {
|
|
59
|
+
it('mention + null → mention', () => {
|
|
60
|
+
expect(dbToApiEngage('mention', null)).toBe('mention');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('mention-sticky collapses to mention on the wire', () => {
|
|
64
|
+
// The wire deliberately doesn't expose sticky — see api-translator.ts
|
|
65
|
+
// docblock. apiToDbPatch's mention-sticky preservation is what keeps
|
|
66
|
+
// sticky-mode rows from silently flattening on PATCHes that don't
|
|
67
|
+
// touch the engagement fields.
|
|
68
|
+
expect(dbToApiEngage('mention-sticky', null)).toBe('mention');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("pattern + '.' sentinel → all", () => {
|
|
72
|
+
expect(dbToApiEngage('pattern', ALL_MESSAGES_PATTERN_SENTINEL)).toBe('all');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('pattern + real regex body → pattern', () => {
|
|
76
|
+
expect(dbToApiEngage('pattern', '\\bdeploy\\b')).toBe('pattern');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('pattern + null → pattern (defensive — schema disallows but translator must not crash)', () => {
|
|
80
|
+
expect(dbToApiEngage('pattern', null)).toBe('pattern');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('dbToApiSenderScope', () => {
|
|
85
|
+
it("DB 'known' → wire 'allowlist'", () => {
|
|
86
|
+
expect(dbToApiSenderScope('known')).toBe('allowlist');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("DB 'all' → wire 'unrestricted' (paraclaw#94 — disjoint literals)", () => {
|
|
90
|
+
expect(dbToApiSenderScope('all')).toBe('unrestricted');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('dbToApiIgnoredPolicy', () => {
|
|
95
|
+
it("DB 'accumulate' → wire 'silent'", () => {
|
|
96
|
+
expect(dbToApiIgnoredPolicy('accumulate')).toBe('silent');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("DB 'drop' → wire 'drop'", () => {
|
|
100
|
+
expect(dbToApiIgnoredPolicy('drop')).toBe('drop');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('rowToView', () => {
|
|
105
|
+
it('projects every join column onto the wire view', () => {
|
|
106
|
+
const view = rowToView(
|
|
107
|
+
baseRow({
|
|
108
|
+
engage_mode: 'pattern',
|
|
109
|
+
engage_pattern: '\\bping\\b',
|
|
110
|
+
sender_scope: 'known',
|
|
111
|
+
ignored_message_policy: 'accumulate',
|
|
112
|
+
priority: 5,
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
expect(view).toEqual({
|
|
116
|
+
id: 'mga-1',
|
|
117
|
+
channelType: 'discord',
|
|
118
|
+
messagingGroupId: 'mg-1',
|
|
119
|
+
platformId: 'guild-123',
|
|
120
|
+
displayName: 'general',
|
|
121
|
+
agentGroupId: 'ag-1',
|
|
122
|
+
agentGroupFolder: 'research',
|
|
123
|
+
agentGroupName: 'Research',
|
|
124
|
+
engageMode: 'pattern',
|
|
125
|
+
engagePattern: '\\bping\\b',
|
|
126
|
+
senderScope: 'allowlist',
|
|
127
|
+
ignoredMessagePolicy: 'silent',
|
|
128
|
+
priority: 5,
|
|
129
|
+
createdAt: '2026-05-05T00:00:00Z',
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("collapses pattern + '.' to engageMode='all' and nulls engagePattern on the wire", () => {
|
|
134
|
+
const view = rowToView(baseRow({ engage_mode: 'pattern', engage_pattern: ALL_MESSAGES_PATTERN_SENTINEL }));
|
|
135
|
+
expect(view.engageMode).toBe('all');
|
|
136
|
+
expect(view.engagePattern).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('mention mode never leaks the engage_pattern column to the wire', () => {
|
|
140
|
+
// In practice the schema keeps engage_pattern null for mention rows, but
|
|
141
|
+
// the projection must not surface stale pattern bodies if a row drifts.
|
|
142
|
+
const view = rowToView(baseRow({ engage_mode: 'mention', engage_pattern: 'leftover' }));
|
|
143
|
+
expect(view.engageMode).toBe('mention');
|
|
144
|
+
expect(view.engagePattern).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('apiToDbPatch — engageMode encoding', () => {
|
|
149
|
+
it("engageMode='all' → mode=pattern + pattern='.'", () => {
|
|
150
|
+
const out = apiToDbPatch({ engageMode: 'all' }, baseCurrent());
|
|
151
|
+
expect(out.engage_mode).toBe('pattern');
|
|
152
|
+
expect(out.engage_pattern).toBe(ALL_MESSAGES_PATTERN_SENTINEL);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('engageMode=pattern + engagePattern → both written', () => {
|
|
156
|
+
const out = apiToDbPatch({ engageMode: 'pattern', engagePattern: '\\bdeploy\\b' }, baseCurrent());
|
|
157
|
+
expect(out.engage_mode).toBe('pattern');
|
|
158
|
+
expect(out.engage_pattern).toBe('\\bdeploy\\b');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('engageMode=pattern without engagePattern → only mode set, pattern preserved on the row', () => {
|
|
162
|
+
// The PATCH-shape semantic: an undefined field means "leave it alone."
|
|
163
|
+
// The DB-side row already has the prior pattern; we don't overwrite it.
|
|
164
|
+
const out = apiToDbPatch({ engageMode: 'pattern' }, baseCurrent({ engage_pattern: 'old' }));
|
|
165
|
+
expect(out.engage_mode).toBe('pattern');
|
|
166
|
+
expect(out).not.toHaveProperty('engage_pattern');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("engageMode='mention' nulls engage_pattern", () => {
|
|
170
|
+
const out = apiToDbPatch({ engageMode: 'mention' }, baseCurrent());
|
|
171
|
+
expect(out.engage_mode).toBe('mention');
|
|
172
|
+
expect(out.engage_pattern).toBeNull();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("engageMode='mention' preserves mention-sticky when current row is sticky", () => {
|
|
176
|
+
// Wire doesn't expose sticky → both mention + mention-sticky show as
|
|
177
|
+
// 'mention' on the read side. A PATCH that flips back to 'mention' on
|
|
178
|
+
// the wire shouldn't silently demote the sticky bit on the row.
|
|
179
|
+
const out = apiToDbPatch({ engageMode: 'mention' }, baseCurrent({ engage_mode: 'mention-sticky' }));
|
|
180
|
+
expect(out.engage_mode).toBe('mention-sticky');
|
|
181
|
+
expect(out.engage_pattern).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('engagePattern alone (no engageMode) → only pattern body changes', () => {
|
|
185
|
+
const out = apiToDbPatch({ engagePattern: '\\bnew\\b' }, baseCurrent({ engage_mode: 'pattern' }));
|
|
186
|
+
expect(out).not.toHaveProperty('engage_mode');
|
|
187
|
+
expect(out.engage_pattern).toBe('\\bnew\\b');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('apiToDbPatch — sender scope and ignored policy', () => {
|
|
192
|
+
it("senderScope 'allowlist' → DB 'known'", () => {
|
|
193
|
+
expect(apiToDbPatch({ senderScope: 'allowlist' }, baseCurrent()).sender_scope).toBe('known');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("senderScope 'unrestricted' → DB 'all'", () => {
|
|
197
|
+
expect(apiToDbPatch({ senderScope: 'unrestricted' }, baseCurrent()).sender_scope).toBe('all');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("ignoredMessagePolicy 'silent' → DB 'accumulate'", () => {
|
|
201
|
+
expect(apiToDbPatch({ ignoredMessagePolicy: 'silent' }, baseCurrent()).ignored_message_policy).toBe('accumulate');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("ignoredMessagePolicy 'drop' → DB 'drop'", () => {
|
|
205
|
+
expect(apiToDbPatch({ ignoredMessagePolicy: 'drop' }, baseCurrent()).ignored_message_policy).toBe('drop');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('priority passes through unchanged', () => {
|
|
209
|
+
expect(apiToDbPatch({ priority: 7 }, baseCurrent()).priority).toBe(7);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('empty input → empty patch', () => {
|
|
213
|
+
expect(apiToDbPatch({}, baseCurrent())).toEqual({});
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('validatePatchInput', () => {
|
|
218
|
+
it('rejects non-object body', () => {
|
|
219
|
+
expect(validatePatchInput(null)).toEqual({ ok: false, reason: 'body must be an object' });
|
|
220
|
+
expect(validatePatchInput('string')).toEqual({ ok: false, reason: 'body must be an object' });
|
|
221
|
+
expect(validatePatchInput(42)).toEqual({ ok: false, reason: 'body must be an object' });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('passes a fully-populated valid body', () => {
|
|
225
|
+
const result = validatePatchInput({
|
|
226
|
+
engageMode: 'pattern',
|
|
227
|
+
engagePattern: '\\bping\\b',
|
|
228
|
+
senderScope: 'allowlist',
|
|
229
|
+
ignoredMessagePolicy: 'silent',
|
|
230
|
+
priority: 3,
|
|
231
|
+
});
|
|
232
|
+
expect(result).toEqual({
|
|
233
|
+
ok: true,
|
|
234
|
+
input: {
|
|
235
|
+
engageMode: 'pattern',
|
|
236
|
+
engagePattern: '\\bping\\b',
|
|
237
|
+
senderScope: 'allowlist',
|
|
238
|
+
ignoredMessagePolicy: 'silent',
|
|
239
|
+
priority: 3,
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("rejects legacy wire-side senderScope='all' (paraclaw#94 rename)", () => {
|
|
245
|
+
// Pre-paraclaw#94 the wire used 'all' on both axes; the rename to
|
|
246
|
+
// 'unrestricted' was specifically to make a grep-refactor unable to
|
|
247
|
+
// conflate the wire and DB unions. The validator must now reject the
|
|
248
|
+
// old literal.
|
|
249
|
+
const result = validatePatchInput({ senderScope: 'all' });
|
|
250
|
+
expect(result.ok).toBe(false);
|
|
251
|
+
if (!result.ok) expect(result.reason).toContain('senderScope');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("rejects legacy ignoredMessagePolicy='accumulate' on the wire", () => {
|
|
255
|
+
// 'accumulate' is the DB-side spelling. The wire spelling is 'silent'.
|
|
256
|
+
const result = validatePatchInput({ ignoredMessagePolicy: 'accumulate' });
|
|
257
|
+
expect(result.ok).toBe(false);
|
|
258
|
+
if (!result.ok) expect(result.reason).toContain('ignoredMessagePolicy');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("rejects engageMode='mention-sticky' (DB-only literal)", () => {
|
|
262
|
+
const result = validatePatchInput({ engageMode: 'mention-sticky' });
|
|
263
|
+
expect(result.ok).toBe(false);
|
|
264
|
+
if (!result.ok) expect(result.reason).toContain('engageMode');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("rejects bare '.' as engagePattern (sentinel reservation)", () => {
|
|
268
|
+
// Storing '.' would silently round-trip back as engageMode='all' on the
|
|
269
|
+
// next read. The fix landed in paraclaw#122 — keep the regression here.
|
|
270
|
+
const result = validatePatchInput({ engagePattern: ALL_MESSAGES_PATTERN_SENTINEL });
|
|
271
|
+
expect(result.ok).toBe(false);
|
|
272
|
+
if (!result.ok) expect(result.reason).toMatch(/sentinel/);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("accepts escaped literal-dot pattern '\\\\.'", () => {
|
|
276
|
+
// The error message above tells the caller to escape; that escaped
|
|
277
|
+
// form must round-trip cleanly.
|
|
278
|
+
const result = validatePatchInput({ engagePattern: '\\.' });
|
|
279
|
+
expect(result.ok).toBe(true);
|
|
280
|
+
if (result.ok) expect(result.input.engagePattern).toBe('\\.');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('accepts engagePattern=null (clear-the-pattern PATCH)', () => {
|
|
284
|
+
const result = validatePatchInput({ engagePattern: null });
|
|
285
|
+
expect(result.ok).toBe(true);
|
|
286
|
+
if (result.ok) expect(result.input.engagePattern).toBeNull();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('rejects non-string non-null engagePattern', () => {
|
|
290
|
+
expect(validatePatchInput({ engagePattern: 5 }).ok).toBe(false);
|
|
291
|
+
expect(validatePatchInput({ engagePattern: {} }).ok).toBe(false);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('rejects non-finite priority', () => {
|
|
295
|
+
expect(validatePatchInput({ priority: Infinity }).ok).toBe(false);
|
|
296
|
+
expect(validatePatchInput({ priority: NaN }).ok).toBe(false);
|
|
297
|
+
expect(validatePatchInput({ priority: '5' }).ok).toBe(false);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('drops unknown keys silently (forward-compat)', () => {
|
|
301
|
+
// The validator only inspects fields it knows; unknown keys aren't an
|
|
302
|
+
// error, they just don't make it into the typed output.
|
|
303
|
+
const result = validatePatchInput({ engageMode: 'mention', futureField: 'nope' });
|
|
304
|
+
expect(result).toEqual({ ok: true, input: { engageMode: 'mention' } });
|
|
305
|
+
});
|
|
306
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared translator between the storage shape (`messaging_group_agents` row,
|
|
3
|
+
* snake_case + legacy enum names) and the API contract that both `/api/channels`
|
|
4
|
+
* and the MCP `*-channel-wire` tools speak.
|
|
5
|
+
*
|
|
6
|
+
* Background. Both surfaces used to maintain their own copy of these types,
|
|
7
|
+
* constants, translators, and the patch validator. The duplication was a
|
|
8
|
+
* structural drift hazard: paraclaw#94 / PR #122 surfaced exactly that class
|
|
9
|
+
* — the rename of wire-side `'all'` → `'unrestricted'` initially landed only
|
|
10
|
+
* the HTTP-side validator and missed the MCP-side silent-no-op. Extracting
|
|
11
|
+
* here makes the drift class structurally impossible: a future enum change
|
|
12
|
+
* touches one file, both surfaces pick it up. paraclaw#123.
|
|
13
|
+
*
|
|
14
|
+
* API contract: engageMode = mention | pattern | all, senderScope = allowlist
|
|
15
|
+
* | unrestricted, ignoredMessagePolicy = drop | silent.
|
|
16
|
+
*
|
|
17
|
+
* DB shape (still pre-rebuild): engage_mode = mention | pattern |
|
|
18
|
+
* mention-sticky (with engage_pattern='.' as the "match every message"
|
|
19
|
+
* sentinel), sender_scope = all | known, ignored_message_policy = drop |
|
|
20
|
+
* accumulate. The translator collapses pattern + '.' into the API's `all`,
|
|
21
|
+
* lossy on mention-sticky (rendered as `mention` to the wire).
|
|
22
|
+
*
|
|
23
|
+
* The validator returns a discriminated result rather than throwing so each
|
|
24
|
+
* caller can pick its own error idiom: HTTP wraps `{ ok: false }` into a
|
|
25
|
+
* 400 + JSON error; MCP throws on `{ ok: false }`.
|
|
26
|
+
*/
|
|
27
|
+
import type {
|
|
28
|
+
EngageMode as DbEngageMode,
|
|
29
|
+
IgnoredMessagePolicy as DbIgnoredMessagePolicy,
|
|
30
|
+
MessagingGroupAgent,
|
|
31
|
+
SenderScope as DbSenderScope,
|
|
32
|
+
} from '../types.js';
|
|
33
|
+
|
|
34
|
+
export type ApiEngageMode = 'mention' | 'pattern' | 'all';
|
|
35
|
+
export type ApiSenderScope = 'allowlist' | 'unrestricted';
|
|
36
|
+
export type ApiIgnoredMessagePolicy = 'drop' | 'silent';
|
|
37
|
+
|
|
38
|
+
export const ALL_MESSAGES_PATTERN_SENTINEL = '.';
|
|
39
|
+
|
|
40
|
+
export const VALID_API_ENGAGE_MODES: ApiEngageMode[] = ['mention', 'pattern', 'all'];
|
|
41
|
+
export const VALID_API_SENDER_SCOPES: ApiSenderScope[] = ['allowlist', 'unrestricted'];
|
|
42
|
+
export const VALID_API_IGNORED_POLICIES: ApiIgnoredMessagePolicy[] = ['drop', 'silent'];
|
|
43
|
+
|
|
44
|
+
export interface ChannelWireView {
|
|
45
|
+
id: string;
|
|
46
|
+
channelType: string;
|
|
47
|
+
messagingGroupId: string;
|
|
48
|
+
platformId: string;
|
|
49
|
+
displayName: string | null;
|
|
50
|
+
agentGroupId: string;
|
|
51
|
+
agentGroupFolder: string;
|
|
52
|
+
agentGroupName: string;
|
|
53
|
+
engageMode: ApiEngageMode;
|
|
54
|
+
engagePattern: string | null;
|
|
55
|
+
senderScope: ApiSenderScope;
|
|
56
|
+
ignoredMessagePolicy: ApiIgnoredMessagePolicy;
|
|
57
|
+
priority: number;
|
|
58
|
+
createdAt: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface WireJoinRow extends MessagingGroupAgent {
|
|
62
|
+
mg_channel_type: string;
|
|
63
|
+
mg_platform_id: string;
|
|
64
|
+
mg_name: string | null;
|
|
65
|
+
ag_folder: string;
|
|
66
|
+
ag_name: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface PatchInput {
|
|
70
|
+
engageMode?: ApiEngageMode;
|
|
71
|
+
engagePattern?: string | null;
|
|
72
|
+
senderScope?: ApiSenderScope;
|
|
73
|
+
ignoredMessagePolicy?: ApiIgnoredMessagePolicy;
|
|
74
|
+
priority?: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface DbPatch {
|
|
78
|
+
engage_mode?: DbEngageMode;
|
|
79
|
+
engage_pattern?: string | null;
|
|
80
|
+
sender_scope?: DbSenderScope;
|
|
81
|
+
ignored_message_policy?: DbIgnoredMessagePolicy;
|
|
82
|
+
priority?: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function dbToApiEngage(mode: DbEngageMode, pattern: string | null): ApiEngageMode {
|
|
86
|
+
if (mode === 'pattern') {
|
|
87
|
+
return pattern === ALL_MESSAGES_PATTERN_SENTINEL ? 'all' : 'pattern';
|
|
88
|
+
}
|
|
89
|
+
// mention + mention-sticky both render as 'mention' on the wire today.
|
|
90
|
+
return 'mention';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function dbToApiSenderScope(s: DbSenderScope): ApiSenderScope {
|
|
94
|
+
return s === 'known' ? 'allowlist' : 'unrestricted';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function dbToApiIgnoredPolicy(p: DbIgnoredMessagePolicy): ApiIgnoredMessagePolicy {
|
|
98
|
+
return p === 'accumulate' ? 'silent' : 'drop';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function rowToView(row: WireJoinRow): ChannelWireView {
|
|
102
|
+
return {
|
|
103
|
+
id: row.id,
|
|
104
|
+
channelType: row.mg_channel_type,
|
|
105
|
+
messagingGroupId: row.messaging_group_id,
|
|
106
|
+
platformId: row.mg_platform_id,
|
|
107
|
+
displayName: row.mg_name,
|
|
108
|
+
agentGroupId: row.agent_group_id,
|
|
109
|
+
agentGroupFolder: row.ag_folder,
|
|
110
|
+
agentGroupName: row.ag_name,
|
|
111
|
+
engageMode: dbToApiEngage(row.engage_mode, row.engage_pattern),
|
|
112
|
+
engagePattern:
|
|
113
|
+
row.engage_mode === 'pattern' && row.engage_pattern !== ALL_MESSAGES_PATTERN_SENTINEL ? row.engage_pattern : null,
|
|
114
|
+
senderScope: dbToApiSenderScope(row.sender_scope),
|
|
115
|
+
ignoredMessagePolicy: dbToApiIgnoredPolicy(row.ignored_message_policy),
|
|
116
|
+
priority: row.priority,
|
|
117
|
+
createdAt: row.created_at,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function apiToDbPatch(input: PatchInput, current: MessagingGroupAgent): DbPatch {
|
|
122
|
+
const out: DbPatch = {};
|
|
123
|
+
|
|
124
|
+
// engageMode is paired with engagePattern: 'all' encodes as
|
|
125
|
+
// mode='pattern' + pattern='.', which the router treats as match-every.
|
|
126
|
+
if (input.engageMode !== undefined) {
|
|
127
|
+
if (input.engageMode === 'all') {
|
|
128
|
+
out.engage_mode = 'pattern';
|
|
129
|
+
out.engage_pattern = ALL_MESSAGES_PATTERN_SENTINEL;
|
|
130
|
+
} else if (input.engageMode === 'pattern') {
|
|
131
|
+
out.engage_mode = 'pattern';
|
|
132
|
+
// Pattern body comes from input.engagePattern when present; otherwise
|
|
133
|
+
// preserve what's already on the row. validatePatchInput already
|
|
134
|
+
// rejects bare '.' here so the next read can't silently collapse to
|
|
135
|
+
// 'all'.
|
|
136
|
+
if (input.engagePattern !== undefined) {
|
|
137
|
+
out.engage_pattern = input.engagePattern;
|
|
138
|
+
}
|
|
139
|
+
} else if (input.engageMode === 'mention') {
|
|
140
|
+
// Preserve mention-sticky if that's what's currently on the row;
|
|
141
|
+
// collapsing it to plain mention here would silently change router
|
|
142
|
+
// behavior (sticky engagement persists across replies). The wire
|
|
143
|
+
// doesn't expose sticky → it sees `mention` for both, but a PATCH
|
|
144
|
+
// that doesn't touch the sticky distinction shouldn't lose it.
|
|
145
|
+
out.engage_mode = current.engage_mode === 'mention-sticky' ? 'mention-sticky' : 'mention';
|
|
146
|
+
out.engage_pattern = null;
|
|
147
|
+
}
|
|
148
|
+
} else if (input.engagePattern !== undefined) {
|
|
149
|
+
// pattern body changed without changing the mode.
|
|
150
|
+
out.engage_pattern = input.engagePattern;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (input.senderScope !== undefined) {
|
|
154
|
+
// wire 'unrestricted' → DB 'all'. validatePatchInput has already gated
|
|
155
|
+
// the union to the two known values, so the binary mapping is safe.
|
|
156
|
+
out.sender_scope = input.senderScope === 'allowlist' ? 'known' : 'all';
|
|
157
|
+
}
|
|
158
|
+
if (input.ignoredMessagePolicy !== undefined) {
|
|
159
|
+
out.ignored_message_policy = input.ignoredMessagePolicy === 'silent' ? 'accumulate' : 'drop';
|
|
160
|
+
}
|
|
161
|
+
if (input.priority !== undefined) {
|
|
162
|
+
out.priority = input.priority;
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type ValidatePatchResult = { ok: true; input: PatchInput } | { ok: false; reason: string };
|
|
168
|
+
|
|
169
|
+
export function validatePatchInput(body: unknown): ValidatePatchResult {
|
|
170
|
+
if (!body || typeof body !== 'object') return { ok: false, reason: 'body must be an object' };
|
|
171
|
+
const b = body as Record<string, unknown>;
|
|
172
|
+
const out: PatchInput = {};
|
|
173
|
+
if ('engageMode' in b) {
|
|
174
|
+
if (!VALID_API_ENGAGE_MODES.includes(b.engageMode as ApiEngageMode)) {
|
|
175
|
+
return { ok: false, reason: `invalid engageMode: ${String(b.engageMode)}` };
|
|
176
|
+
}
|
|
177
|
+
out.engageMode = b.engageMode as ApiEngageMode;
|
|
178
|
+
}
|
|
179
|
+
if ('engagePattern' in b) {
|
|
180
|
+
if (b.engagePattern !== null && typeof b.engagePattern !== 'string') {
|
|
181
|
+
return { ok: false, reason: 'engagePattern must be string or null' };
|
|
182
|
+
}
|
|
183
|
+
// Bare '.' is the wire-format sentinel for engageMode='all' — accepting
|
|
184
|
+
// it as a literal pattern would silently round-trip back as 'all' on the
|
|
185
|
+
// next read and lose the user's intent. Force the caller to disambiguate.
|
|
186
|
+
if (b.engagePattern === ALL_MESSAGES_PATTERN_SENTINEL) {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
reason:
|
|
190
|
+
"engagePattern '.' is reserved as the 'all' sentinel — use '\\\\.' (escaped) to match a literal dot, or set engageMode to 'all'",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
out.engagePattern = b.engagePattern as string | null;
|
|
194
|
+
}
|
|
195
|
+
if ('senderScope' in b) {
|
|
196
|
+
if (!VALID_API_SENDER_SCOPES.includes(b.senderScope as ApiSenderScope)) {
|
|
197
|
+
return { ok: false, reason: `invalid senderScope: ${String(b.senderScope)}` };
|
|
198
|
+
}
|
|
199
|
+
out.senderScope = b.senderScope as ApiSenderScope;
|
|
200
|
+
}
|
|
201
|
+
if ('ignoredMessagePolicy' in b) {
|
|
202
|
+
if (!VALID_API_IGNORED_POLICIES.includes(b.ignoredMessagePolicy as ApiIgnoredMessagePolicy)) {
|
|
203
|
+
return { ok: false, reason: `invalid ignoredMessagePolicy: ${String(b.ignoredMessagePolicy)}` };
|
|
204
|
+
}
|
|
205
|
+
out.ignoredMessagePolicy = b.ignoredMessagePolicy as ApiIgnoredMessagePolicy;
|
|
206
|
+
}
|
|
207
|
+
if ('priority' in b) {
|
|
208
|
+
if (typeof b.priority !== 'number' || !Number.isFinite(b.priority)) {
|
|
209
|
+
return { ok: false, reason: 'priority must be a finite number' };
|
|
210
|
+
}
|
|
211
|
+
out.priority = b.priority;
|
|
212
|
+
}
|
|
213
|
+
return { ok: true, input: out };
|
|
214
|
+
}
|