@sprigr/apps-app-sdk 0.7.0 → 0.8.1
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/README.md +21 -0
- package/bin/check-write-protection.mjs +190 -0
- package/dist/index.d.ts +535 -1
- package/dist/index.js +443 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -56,3 +56,24 @@ export default { list_things: actorTool(async (env, actor, args) => { /* ... */
|
|
|
56
56
|
- `fetchFileBytes` / `fetchFileAsBase64` / `bytesToBase64` / `base64ToBytes` — file byte helpers
|
|
57
57
|
|
|
58
58
|
Full platform semantics: [`docs/platform-reference.md`](../../docs/platform-reference.md).
|
|
59
|
+
|
|
60
|
+
## Write protection
|
|
61
|
+
|
|
62
|
+
Three platform tiers, and they are not substitutes for each other. Full guide: [`docs/write-protection.md`](../../docs/write-protection.md).
|
|
63
|
+
|
|
64
|
+
| Tier | Where | Who attests | Helper |
|
|
65
|
+
|---|---|---|---|
|
|
66
|
+
| T1 confirmation policy | manifest `tools[].confirmation` | the model (`confirm: true`) | `buildConfirmationPolicy` + `checkConfirmationPolicy`, `sprigr-check-write-protection` |
|
|
67
|
+
| T2 approval | handler returns `_approval` | a human tap | `requireApproval`, `approvalHash` / `set` / `seq` |
|
|
68
|
+
| T3 undo | handler returns `_undo`; manifest `undo.reverse_tool` | platform token | `requireApproval` (capture), `undoEnvelope`, `runUndoApply`, `@sprigr/apps-undo-journal` |
|
|
69
|
+
|
|
70
|
+
- `requireApproval(handlers, specs, opts)` wraps the named tools so each returns an `_approval` card instead of writing, then on the granted pass captures a before-image through the **pinned** connection and offers `_undo`. It resolves the connection inside itself, before the label lookup, the hash and the capture: a gate that sits outside the app's own pin names the wrong store, hashes the wrong store and captures the wrong store's copy. Register the result after the originals: `Object.assign(registry, requireApproval(registry, SPECS, opts))`.
|
|
71
|
+
- `approvalHash(rawId, connection, ...parts)`, `set(values)`, `seq(values)`: a stable operation identity so a tap survives the model reordering a list or dropping an optional argument on the retry.
|
|
72
|
+
- `undoEnvelope({...})` throws on a blank field. The platform drops the whole envelope on a blank and mints nothing, silently.
|
|
73
|
+
- `refuseWithoutForce(name, handler, opts)` / `archiveOfferRefusal(...)`: a delete refuses once and offers the vendor's native archive unless `force: true`.
|
|
74
|
+
- `buildConfirmationPolicy({ irreversible, always, rules })` declares a dispatcher's policy beside its action registry; `checkConfirmationPolicy({ policy, registry, ungated, requiredInput })` returns every silent failure (dead rule, `(unset)` placeholder, money threshold, unclassified write) for a one-line test.
|
|
75
|
+
- `runUndoApply(args, { env, journal, specs, pin })` is the body of your `internal: true` reverse tool: load, re-pin to the journalled connection, restore, drop. `pin` may hand `restore` a different type from the env (a per-actor token state), may throw (`connection_unavailable`) or return null to refuse (`connection_mismatch`); a restore that throws is `restore_failed` and the copy is kept.
|
|
76
|
+
- `safeCapture(scope, name, id, read)` and `offerUndo({ journal, entity, id, before, connection, fidelity, resource, describe, warning })`: the capture-then-offer halves for a write that is not behind an approval card (a dispatcher `update_*` action). `requireApproval` uses the same two on its granted pass.
|
|
77
|
+
- `sprigr-check-write-protection` (bin): every destructive-by-name tool or enumerated dispatcher action must carry a policy, or a reason in `apps/<app>/write-protection.json`; ratchets on `tools/write-protection-baseline.json`.
|
|
78
|
+
|
|
79
|
+
**Never offer `_undo` for money or messages.** Refunds, captures, cancellations, sent mail: the approval gate is the only control there.
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* sprigr-check-write-protection: every destructive tool declares a policy.
|
|
4
|
+
*
|
|
5
|
+
* The platform cannot tell which of an app's tools are destructive (sprigr-team
|
|
6
|
+
* decisions 0009 and 0011 both say so), so nothing platform-side can enforce
|
|
7
|
+
* that a permanent delete is gated. The app repo can, by name: this scans every
|
|
8
|
+
* `sprigr-app.json` and fails on a tool or enumerated dispatcher action whose
|
|
9
|
+
* name is destructive and that carries no `confirmation` entry.
|
|
10
|
+
*
|
|
11
|
+
* Escape hatches, both explicit and reviewable:
|
|
12
|
+
*
|
|
13
|
+
* apps/<app>/write-protection.json
|
|
14
|
+
* { "allow": { "<tool>": "<reason>", "<tool>:<action>": "<reason>" } }
|
|
15
|
+
* A destructive-by-name entry that is genuinely harmless (a read named
|
|
16
|
+
* `refund_rate`, a fixture reset). Every entry needs a reason. A stale
|
|
17
|
+
* entry fails, so the file cannot silently mask a tool that came back.
|
|
18
|
+
*
|
|
19
|
+
* --baseline <file> (default tools/write-protection-baseline.json if present)
|
|
20
|
+
* The ratchet. Violations listed in the baseline are reported but do not
|
|
21
|
+
* fail; anything new fails; a baseline entry that no longer violates fails
|
|
22
|
+
* too, so headroom is banked with --write-baseline rather than re-spent.
|
|
23
|
+
*
|
|
24
|
+
* A dispatcher tool that does not enumerate its actions (`dispatch.actions`
|
|
25
|
+
* or an `enum` on the action field) cannot be inspected and is itself a
|
|
26
|
+
* violation until enumerated or allowlisted.
|
|
27
|
+
*
|
|
28
|
+
* Dependency-free: Node stdlib only, like the other repo guards.
|
|
29
|
+
*
|
|
30
|
+
* sprigr-check-write-protection [--apps apps] [--warn] [--baseline f] [--write-baseline]
|
|
31
|
+
*/
|
|
32
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A tool or action name that destroys, reverses money, or cannot be walked
|
|
37
|
+
* back. Tested against the bare name and with any leading `<app>_` prefix
|
|
38
|
+
* stripped, so `shopify_delete_product` and `delete_product` both match.
|
|
39
|
+
*/
|
|
40
|
+
export const DESTRUCTIVE_ACTION_PATTERN =
|
|
41
|
+
/(^|_)(delete|remove|destroy|cancel|void|refund|unship|rotate|offboard|purge|wipe|drop|terminate|clear|reset|revoke|unregister|force|deactivate|suspend)(_|$)|(^|_)(capture_payment|complete_draft)/;
|
|
42
|
+
|
|
43
|
+
export function isDestructiveName(name) {
|
|
44
|
+
return DESTRUCTIVE_ACTION_PATTERN.test(name);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function enumeratedActions(tool) {
|
|
48
|
+
const d = tool.dispatch;
|
|
49
|
+
if (d && d.actions) {
|
|
50
|
+
if (Array.isArray(d.actions)) return d.actions.map((a) => (typeof a === 'string' ? a : a && a.name)).filter(Boolean);
|
|
51
|
+
if (typeof d.actions === 'object') return Object.keys(d.actions);
|
|
52
|
+
}
|
|
53
|
+
const field = (d && d.actionField) || 'action';
|
|
54
|
+
const prop = tool.input_schema && tool.input_schema.properties && tool.input_schema.properties[field];
|
|
55
|
+
if (!prop) return null; // not a dispatcher
|
|
56
|
+
if (Array.isArray(prop.enum)) return prop.enum.map(String);
|
|
57
|
+
for (const k of ['oneOf', 'anyOf']) {
|
|
58
|
+
if (Array.isArray(prop[k])) return prop[k].map((o) => o && o.const).filter(Boolean).map(String);
|
|
59
|
+
}
|
|
60
|
+
return []; // a dispatcher, but nothing enumerated
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isGatedRule(rule) {
|
|
64
|
+
return !!rule && typeof rule === 'object' && (rule.always === true || !!rule.when);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Violations for one manifest, as `tool` or `tool:action` strings.
|
|
69
|
+
* Pure, so it is unit-testable without touching the filesystem.
|
|
70
|
+
*/
|
|
71
|
+
export function scanManifest(manifest) {
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const tool of manifest.tools || []) {
|
|
74
|
+
if (!tool || tool.internal) continue;
|
|
75
|
+
const pol = tool.confirmation && typeof tool.confirmation === 'object' ? tool.confirmation : null;
|
|
76
|
+
const toolGated = tool.confirmation_required === true || isGatedRule(pol);
|
|
77
|
+
const actions = enumeratedActions(tool);
|
|
78
|
+
if (actions === null) {
|
|
79
|
+
if (isDestructiveName(tool.name) && !toolGated) out.push(tool.name);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (toolGated) continue;
|
|
83
|
+
if (actions.length === 0) {
|
|
84
|
+
out.push(`${tool.name}:<unenumerated>`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const perAction = (pol && pol.actions) || {};
|
|
88
|
+
for (const a of actions) {
|
|
89
|
+
if (isDestructiveName(a) && !isGatedRule(perAction[a])) out.push(`${tool.name}:${a}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Apply the app's allowlist. Returns { violations, stale, unreasoned }. */
|
|
96
|
+
export function applyAllowlist(violations, allowlist) {
|
|
97
|
+
const allow = (allowlist && allowlist.allow) || {};
|
|
98
|
+
const unreasoned = Object.entries(allow).filter(([, r]) => typeof r !== 'string' || !r.trim()).map(([k]) => k);
|
|
99
|
+
const remaining = violations.filter((v) => !(v in allow));
|
|
100
|
+
const stale = Object.keys(allow).filter((k) => !violations.includes(k));
|
|
101
|
+
return { violations: remaining, stale, unreasoned };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function readJson(path) {
|
|
105
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function run(argv, cwd = process.cwd()) {
|
|
109
|
+
const args = [...argv];
|
|
110
|
+
const opt = (flag, dflt) => {
|
|
111
|
+
const i = args.indexOf(flag);
|
|
112
|
+
if (i === -1) return dflt;
|
|
113
|
+
const v = args[i + 1];
|
|
114
|
+
args.splice(i, 2);
|
|
115
|
+
return v;
|
|
116
|
+
};
|
|
117
|
+
const has = (flag) => {
|
|
118
|
+
const i = args.indexOf(flag);
|
|
119
|
+
if (i === -1) return false;
|
|
120
|
+
args.splice(i, 1);
|
|
121
|
+
return true;
|
|
122
|
+
};
|
|
123
|
+
const warnOnly = has('--warn');
|
|
124
|
+
const writeBaseline = has('--write-baseline');
|
|
125
|
+
const appsDir = opt('--apps', 'apps');
|
|
126
|
+
const defaultBaseline = join(cwd, 'tools', 'write-protection-baseline.json');
|
|
127
|
+
const baselinePath = opt('--baseline', existsSync(defaultBaseline) ? defaultBaseline : null);
|
|
128
|
+
const baseline = baselinePath && existsSync(baselinePath) ? readJson(baselinePath) : {};
|
|
129
|
+
|
|
130
|
+
const root = join(cwd, appsDir);
|
|
131
|
+
const appDirs = existsSync(join(cwd, 'sprigr-app.json'))
|
|
132
|
+
? [{ slug: '.', dir: cwd }]
|
|
133
|
+
: readdirSync(root, { withFileTypes: true })
|
|
134
|
+
.filter((e) => e.isDirectory() && existsSync(join(root, e.name, 'sprigr-app.json')))
|
|
135
|
+
.map((e) => ({ slug: e.name, dir: join(root, e.name) }));
|
|
136
|
+
|
|
137
|
+
const failures = [];
|
|
138
|
+
const report = [];
|
|
139
|
+
const nextBaseline = {};
|
|
140
|
+
|
|
141
|
+
for (const { slug, dir } of appDirs) {
|
|
142
|
+
let manifest;
|
|
143
|
+
try {
|
|
144
|
+
manifest = readJson(join(dir, 'sprigr-app.json'));
|
|
145
|
+
} catch (err) {
|
|
146
|
+
failures.push(`${slug}: sprigr-app.json does not parse (${err.message})`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const allowPath = join(dir, 'write-protection.json');
|
|
150
|
+
const allowlist = existsSync(allowPath) ? readJson(allowPath) : null;
|
|
151
|
+
const raw = scanManifest(manifest);
|
|
152
|
+
const { violations, stale, unreasoned } = applyAllowlist(raw, allowlist);
|
|
153
|
+
for (const k of unreasoned) failures.push(`${slug}: write-protection.json allow entry "${k}" has no reason`);
|
|
154
|
+
for (const k of stale) failures.push(`${slug}: write-protection.json allow entry "${k}" is stale (no longer destructive-and-ungated); remove it`);
|
|
155
|
+
|
|
156
|
+
const known = new Set(baseline[slug] || []);
|
|
157
|
+
const fresh = violations.filter((v) => !known.has(v));
|
|
158
|
+
const banked = [...known].filter((v) => !violations.includes(v));
|
|
159
|
+
if (violations.length) nextBaseline[slug] = violations.sort();
|
|
160
|
+
|
|
161
|
+
for (const v of fresh) failures.push(`${slug}: ${v} is destructive by name and declares no confirmation policy`);
|
|
162
|
+
for (const v of banked) failures.push(`${slug}: baseline lists "${v}" but it is now gated or gone; run --write-baseline to bank the headroom`);
|
|
163
|
+
report.push(`${slug.padEnd(28)} destructive-ungated=${String(violations.length).padStart(3)} (new ${fresh.length}, baselined ${violations.length - fresh.length})`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (writeBaseline && baselinePath) {
|
|
167
|
+
writeFileSync(baselinePath, JSON.stringify(nextBaseline, null, 2) + '\n');
|
|
168
|
+
return { code: 0, lines: [...report, `wrote ${baselinePath}`] };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const lines = [...report];
|
|
172
|
+
if (failures.length) {
|
|
173
|
+
lines.push('', `${failures.length} write-protection finding(s):`, ...failures.map((f) => ` ${f}`));
|
|
174
|
+
lines.push(
|
|
175
|
+
'',
|
|
176
|
+
'Fix: add a `confirmation` policy to the tool (or `confirmation.actions[<action>]` on a dispatcher),',
|
|
177
|
+
'or record a reason in apps/<app>/write-protection.json under "allow". See @sprigr/apps-app-sdk README, "Write protection".',
|
|
178
|
+
);
|
|
179
|
+
return { code: warnOnly ? 0 : 1, lines };
|
|
180
|
+
}
|
|
181
|
+
lines.push(`OK: ${appDirs.length} app manifest(s), no unguarded destructive tools outside the baseline.`);
|
|
182
|
+
return { code: 0, lines };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const invokedDirectly = process.argv[1] && /check-write-protection\.mjs$/.test(process.argv[1]);
|
|
186
|
+
if (invokedDirectly) {
|
|
187
|
+
const { code, lines } = run(process.argv.slice(2));
|
|
188
|
+
for (const l of lines) (code ? console.error : console.log)(l);
|
|
189
|
+
process.exit(code);
|
|
190
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -825,6 +825,540 @@ declare function deleteAppFile(env: AppFilesEnv, key: string): Promise<{
|
|
|
825
825
|
key: string;
|
|
826
826
|
}>;
|
|
827
827
|
|
|
828
|
+
/**
|
|
829
|
+
* Write-protection types, mirrored from the Sprigr platform.
|
|
830
|
+
*
|
|
831
|
+
* Three tiers exist on the platform and they are not substitutes for each
|
|
832
|
+
* other (sprigr-team decisions 0009, 0011, 0012):
|
|
833
|
+
*
|
|
834
|
+
* T1 `confirmation` policy in the manifest. The platform enforces it BEFORE
|
|
835
|
+
* your handler is dispatched, but the MODEL sets `confirm: true`, so it
|
|
836
|
+
* is a reviewable declaration and an audit trail, not a control.
|
|
837
|
+
* T2 `_approval` envelope returned by the handler. The platform pauses the
|
|
838
|
+
* turn on a decision card; only a real human tap mints the grant. This is
|
|
839
|
+
* the only tier that actually stops a write.
|
|
840
|
+
* T3 `_undo` envelope returned beside a successful write. The platform mints
|
|
841
|
+
* the token and surfaces `undo_change`; the app keeps the before-image.
|
|
842
|
+
*
|
|
843
|
+
* Canonical declarations live in sprigr-team:
|
|
844
|
+
* - `AppToolConfirmRule`, `AppToolConfirmationPolicy`, `AppUndoEnvelope`:
|
|
845
|
+
* packages/shared/src/types/marketplace.ts
|
|
846
|
+
* - `AppApprovalEnvelope`: packages/agent-core/src/marketplace-tool-builder.ts
|
|
847
|
+
* Keep the field sets in sync when the platform changes them. Mirrored (not
|
|
848
|
+
* imported) for the same reason `MarketplaceEventSourceIntegration` is: the
|
|
849
|
+
* platform packages are private and this SDK is public.
|
|
850
|
+
*/
|
|
851
|
+
/** One confirmation rule. `always`, or `when` a counted input crosses a threshold. */
|
|
852
|
+
interface ConfirmRule {
|
|
853
|
+
/** Gate every call. Use for permanent deletes, refunds, anything public-facing. */
|
|
854
|
+
always?: boolean;
|
|
855
|
+
/**
|
|
856
|
+
* Gate only when a batch crosses a threshold. `count` names an input key
|
|
857
|
+
* (dotted paths work; on a `{ action, input }` dispatcher it MUST be dotted
|
|
858
|
+
* through `input.`); an array contributes its length, a number its own
|
|
859
|
+
* value, anything else counts as nothing and never trips. Never threshold a
|
|
860
|
+
* money field: a numeric STRING amount is silently no gate at all.
|
|
861
|
+
*/
|
|
862
|
+
when?: ConfirmCondition | ConfirmCondition[];
|
|
863
|
+
/**
|
|
864
|
+
* What the person is asked to approve. `{key}` interpolates from the
|
|
865
|
+
* top-level input. Do NOT end it in a period: the platform renders it as
|
|
866
|
+
* `Action: <text>.`.
|
|
867
|
+
*/
|
|
868
|
+
describe?: string;
|
|
869
|
+
/** Appends "which cannot be undone" so the model cannot undersell it. */
|
|
870
|
+
irreversible?: boolean;
|
|
871
|
+
}
|
|
872
|
+
interface ConfirmCondition {
|
|
873
|
+
count: string;
|
|
874
|
+
atLeast: number;
|
|
875
|
+
}
|
|
876
|
+
/** Tool-level policy, plus per-action rules for a dispatcher tool. */
|
|
877
|
+
interface ConfirmationPolicy extends ConfirmRule {
|
|
878
|
+
/**
|
|
879
|
+
* Per-action rules, keyed by action name (read from `dispatch.actionField`,
|
|
880
|
+
* default `action`). A listed action's rule wins; an unlisted action falls
|
|
881
|
+
* back to the tool-level rule, which is normally absent.
|
|
882
|
+
*/
|
|
883
|
+
actions?: Record<string, ConfirmRule>;
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Returned INSTEAD of doing the work when a write needs a human. The platform
|
|
887
|
+
* replaces your whole result with the decision card on this pass, so nothing
|
|
888
|
+
* beside `_approval` reaches anyone; put everything the person needs to read
|
|
889
|
+
* in `question`, `header` and `summary`.
|
|
890
|
+
*/
|
|
891
|
+
interface AppApprovalEnvelope {
|
|
892
|
+
/** The question on the card, e.g. "Delete 12 products from acme.myshopify.com?" */
|
|
893
|
+
question: string;
|
|
894
|
+
/** Short header above the card, e.g. "Shopify". */
|
|
895
|
+
header: string;
|
|
896
|
+
/** Optional detail under the approve option; the card is the whole preview. */
|
|
897
|
+
summary?: string;
|
|
898
|
+
/**
|
|
899
|
+
* Stable identity of the exact operation. SUPPLY THIS for any tool with
|
|
900
|
+
* optional arguments: the platform's default is derived from whatever the
|
|
901
|
+
* model happened to send, and a hash that moves between the ask and the
|
|
902
|
+
* retry silently discards the tap. See `approvalHash`.
|
|
903
|
+
*/
|
|
904
|
+
hash?: string;
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Platform-stamped marker telling an app that a human approved this call. The
|
|
908
|
+
* platform DELETES any caller-supplied copy before dispatch and sets it only
|
|
909
|
+
* after consuming a real grant, so a handler may trust it.
|
|
910
|
+
*/
|
|
911
|
+
declare const APPROVAL_GRANTED_KEY = "_approval_granted";
|
|
912
|
+
type UndoFidelity = 'full' | 'recreated';
|
|
913
|
+
/**
|
|
914
|
+
* Returned BESIDE the real result after a successful write. Every field is
|
|
915
|
+
* required and non-empty: the platform drops the whole envelope on a blank
|
|
916
|
+
* field and mints nothing, silently. Build it with `undoEnvelope()`, which
|
|
917
|
+
* refuses a blank at construction time.
|
|
918
|
+
*/
|
|
919
|
+
interface AppUndoEnvelope {
|
|
920
|
+
/** `'full'`: same id restored in place. `'recreated'`: a NEW object under a NEW id. */
|
|
921
|
+
fidelity: UndoFidelity;
|
|
922
|
+
/** Relayed to the model verbatim. On `'recreated'` it must say the id changes. */
|
|
923
|
+
warning: string;
|
|
924
|
+
/** Human-readable subject for the list tool, e.g. `collection "Summer Sale" on acme.myshopify.com`. */
|
|
925
|
+
describes: string;
|
|
926
|
+
/** Coarse type, for grouping: "product", "contact". */
|
|
927
|
+
resource: string;
|
|
928
|
+
/** The app's own handle for the before-image (`journal.captureBefore().ref`). */
|
|
929
|
+
ref: string;
|
|
930
|
+
}
|
|
931
|
+
/** Args-first handler signature the marketplace wrapper dispatches with. */
|
|
932
|
+
type ToolArgs = Record<string, unknown>;
|
|
933
|
+
type ToolHandler<E = unknown> = (args: ToolArgs, env: E, ctx?: unknown) => Promise<unknown>;
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* The stable identity of a gated operation, for the approval grant.
|
|
937
|
+
*
|
|
938
|
+
* A grant is bound to a hash so a tap on a small write cannot be spent on a big
|
|
939
|
+
* one. When an app supplies no hash the platform derives one from the arguments
|
|
940
|
+
* the MODEL sent, and the model does not send the same thing twice: an optional
|
|
941
|
+
* param included on the asking call and dropped on the retry, a `store` learned
|
|
942
|
+
* between the two, a tag list in a different order, each mints a different hash.
|
|
943
|
+
* When the hash moves, the tap is silently discarded and the person is asked
|
|
944
|
+
* the identical question again with nothing saying their answer was thrown
|
|
945
|
+
* away.
|
|
946
|
+
*
|
|
947
|
+
* Only the app knows which arguments identify the operation, so only the app
|
|
948
|
+
* can make the identity stable. Put in exactly the things that decide what
|
|
949
|
+
* changes: the resource, the connection, and the payload. NOT `confirm` or
|
|
950
|
+
* `_approval_granted` (attestation, not operation) and not free text the model
|
|
951
|
+
* composes.
|
|
952
|
+
*
|
|
953
|
+
* Extracted unchanged from the Shopify app (sprigr-apps #1197).
|
|
954
|
+
*/
|
|
955
|
+
/**
|
|
956
|
+
* Field separator. A control character, so it cannot occur in a tag, an id or
|
|
957
|
+
* a store domain, which is what stops one combination of values being
|
|
958
|
+
* re-partitioned into another ("a" + "bc" must not collide with "ab" + "c").
|
|
959
|
+
*/
|
|
960
|
+
declare const UNIT_SEP = "\u001F";
|
|
961
|
+
/** A set, order-insensitive: `set(["a","b"])` === `set(["b","a"])`. */
|
|
962
|
+
declare function set(values: unknown): string;
|
|
963
|
+
/** An ordered list, for payloads where position changes the outcome. */
|
|
964
|
+
declare function seq(values: unknown): string;
|
|
965
|
+
/**
|
|
966
|
+
* Join the parts into the `_approval.hash` string. The platform mixes the tool
|
|
967
|
+
* name in before hashing, so this never needs to repeat it.
|
|
968
|
+
*/
|
|
969
|
+
declare function approvalHash(...parts: Array<string | number | undefined | null>): string;
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Thrown by `undoEnvelope` for a field the platform would reject.
|
|
973
|
+
*
|
|
974
|
+
* The platform's `extractAppUndo` requires `fidelity` in {full, recreated} and
|
|
975
|
+
* every string non-empty after trim; on any miss it logs a warning in the
|
|
976
|
+
* PLATFORM worker and mints no token. The app sees nothing wrong: the write
|
|
977
|
+
* succeeds and the journal row is there. The only symptom is that the model
|
|
978
|
+
* never gets an undo affordance, which reads as a discoverability problem and
|
|
979
|
+
* sends you into the tool registry instead of your own envelope. Xero 0.1.1
|
|
980
|
+
* shipped `warning: ''` this way. Failing here, in a unit test, is cheaper.
|
|
981
|
+
*/
|
|
982
|
+
declare class InvalidUndoEnvelopeError extends Error {
|
|
983
|
+
constructor(message: string);
|
|
984
|
+
}
|
|
985
|
+
/** Build an `_undo` envelope, refusing anything the platform would drop. */
|
|
986
|
+
declare function undoEnvelope(input: {
|
|
987
|
+
fidelity: UndoFidelity;
|
|
988
|
+
warning: string;
|
|
989
|
+
describes: string;
|
|
990
|
+
resource: string;
|
|
991
|
+
ref: string;
|
|
992
|
+
}): AppUndoEnvelope;
|
|
993
|
+
/**
|
|
994
|
+
* The honest warning for a `'recreated'` reversal. Always names the new-id
|
|
995
|
+
* consequence, then whatever the rebuild cannot bring back.
|
|
996
|
+
*/
|
|
997
|
+
declare function recreatedWarning(resource: string, notRestored?: string): string;
|
|
998
|
+
/**
|
|
999
|
+
* The honest warning for a `'full'` reversal. Even a perfect revert overwrites
|
|
1000
|
+
* anything changed since the capture, so this is never empty.
|
|
1001
|
+
*/
|
|
1002
|
+
declare function fullWarning(notRestored?: string): string;
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Archive-first: a delete refuses ONCE and offers the reversible option.
|
|
1006
|
+
*
|
|
1007
|
+
* Where the vendor has a real archived state, a native archive beats any
|
|
1008
|
+
* journal: same id, real restore, no fidelity caveat. So a `delete_*` on such
|
|
1009
|
+
* an entity does not delete unless the caller passes `force: true`; it returns
|
|
1010
|
+
* a refusal pointing at the archive tool. Automation that genuinely means to
|
|
1011
|
+
* delete adds `force: true` once; a person who said "get rid of it" is offered
|
|
1012
|
+
* the thing they almost certainly meant.
|
|
1013
|
+
*
|
|
1014
|
+
* The refusal shape is shared by simPRO (five deletes) and Shopify
|
|
1015
|
+
* (`shopify_delete_product`) so an agent that has learned one has learned both:
|
|
1016
|
+
* "I did NOT write; here is the reversible option; pass force: true if you
|
|
1017
|
+
* really mean it".
|
|
1018
|
+
*/
|
|
1019
|
+
interface ArchiveOfferOptions {
|
|
1020
|
+
/** Human noun: "product", "customer". */
|
|
1021
|
+
resource: string;
|
|
1022
|
+
/** Tool or action name the caller should reach for instead. */
|
|
1023
|
+
archiveAction: string;
|
|
1024
|
+
/** Arg names that may carry the target id, most specific first. */
|
|
1025
|
+
idKeys?: string[];
|
|
1026
|
+
/** Deep link to the record, when a verified single-record URL shape exists. */
|
|
1027
|
+
link?: (args: ToolArgs, id: string) => string | undefined;
|
|
1028
|
+
}
|
|
1029
|
+
interface ArchiveOfferRefusal {
|
|
1030
|
+
ok: false;
|
|
1031
|
+
deleted: false;
|
|
1032
|
+
archive_available: true;
|
|
1033
|
+
resource: string;
|
|
1034
|
+
record_id: string | null;
|
|
1035
|
+
delete_action: string;
|
|
1036
|
+
archive_action: string;
|
|
1037
|
+
link?: string;
|
|
1038
|
+
note: string;
|
|
1039
|
+
}
|
|
1040
|
+
/** The refusal payload, for dispatcher apps that wrap at the action layer. */
|
|
1041
|
+
declare function archiveOfferRefusal(deleteAction: string, args: ToolArgs, opts: ArchiveOfferOptions): ArchiveOfferRefusal;
|
|
1042
|
+
/**
|
|
1043
|
+
* Wrap a flat delete handler so an un-forced call returns the offer instead.
|
|
1044
|
+
* `force` is consumed here and never forwarded to the provider.
|
|
1045
|
+
*/
|
|
1046
|
+
declare function refuseWithoutForce<E>(deleteAction: string, inner: ToolHandler<E>, opts: ArchiveOfferOptions): ToolHandler<E>;
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* Human approval for the writes a vendor cannot undo, plus the before-image
|
|
1050
|
+
* capture for the ones it can partly undo, in ONE wrapper.
|
|
1051
|
+
*
|
|
1052
|
+
* ## Why this exists when the manifest already declares `confirmation`
|
|
1053
|
+
*
|
|
1054
|
+
* The manifest tier gates on `confirm: true`, which the MODEL sets. Measured on
|
|
1055
|
+
* staging 2026-08-19: an agent wrote `confirm: true` into its own code_mode
|
|
1056
|
+
* source before any prompt, because the user's request ("add these five tags")
|
|
1057
|
+
* reads as the confirmation for that action. That tier is an audit trail; it is
|
|
1058
|
+
* not a control. `_approval` is the tier a model cannot satisfy alone: the
|
|
1059
|
+
* platform pauses the turn on a decision card, and only a real tap makes the
|
|
1060
|
+
* DecisionRouter write the grant that lets the retry through.
|
|
1061
|
+
*
|
|
1062
|
+
* ## The two rules baked in, both learned live
|
|
1063
|
+
*
|
|
1064
|
+
* 1. THE CONNECTION IS RESOLVED AND PINNED INSIDE THIS WRAPPER, before the card
|
|
1065
|
+
* label lookup, the hash, and the capture. Apps establish their store /
|
|
1066
|
+
* organisation / business pin inside their own inner wrapper; a gate that
|
|
1067
|
+
* sits outside it sees an UNPINNED env, resolves the install's default
|
|
1068
|
+
* connection, and (a) names the wrong store on the card, (b) hashes the
|
|
1069
|
+
* wrong store so the retry misses the grant, and (c) captures the wrong
|
|
1070
|
+
* store's copy so a permanent delete succeeds and mints no token. Shopify
|
|
1071
|
+
* shipped all three from one cause (sprigr-team decision 0011 addendum).
|
|
1072
|
+
*
|
|
1073
|
+
* 2. CAPTURE BEFORE THE WRITE, MINT ONLY AFTER IT SUCCEEDS, AND NEVER RETURN
|
|
1074
|
+
* `_undo` ON A NULL CAPTURE. A failed capture does not fail the write: the
|
|
1075
|
+
* user asked for it and a human approved it. It degrades to no token.
|
|
1076
|
+
*
|
|
1077
|
+
* Generalised from the Shopify app's `require-approval.ts`; the per-tool
|
|
1078
|
+
* questions stay in the app, because only the app knows what cannot be walked
|
|
1079
|
+
* back.
|
|
1080
|
+
*/
|
|
1081
|
+
/** Per-tool spec: how the card reads, and (optionally) how to journal a before-image. */
|
|
1082
|
+
interface ApprovalSpec<E = unknown> {
|
|
1083
|
+
/**
|
|
1084
|
+
* Arg names that may carry the target id, most specific first. ONE list, used
|
|
1085
|
+
* for both the card label and the grant hash, so the two cannot drift apart
|
|
1086
|
+
* and describe different resources.
|
|
1087
|
+
*/
|
|
1088
|
+
keys: string[];
|
|
1089
|
+
/**
|
|
1090
|
+
* The card. `target` is the already-resolved human label (or the raw id),
|
|
1091
|
+
* `connection` the resolved store / organisation / business, or '' when the
|
|
1092
|
+
* app has one. Do not set `hash` here; the wrapper derives it from the raw
|
|
1093
|
+
* id and the connection so it cannot move between the ask and the retry.
|
|
1094
|
+
* Override with `hash` below only when the arguments understate the blast
|
|
1095
|
+
* radius.
|
|
1096
|
+
*/
|
|
1097
|
+
describe: (target: string, args: ToolArgs, connection: string) => Omit<AppApprovalEnvelope, 'hash'>;
|
|
1098
|
+
/**
|
|
1099
|
+
* Extra identity parts beyond `rawId + connection`, e.g. a sorted tag set.
|
|
1100
|
+
* Use `set()` / `seq()` from `approval-hash`. Never include `confirm`.
|
|
1101
|
+
*/
|
|
1102
|
+
hash?: (args: ToolArgs) => Array<string | number | undefined | null>;
|
|
1103
|
+
/** Present only for tools whose write can be (partly) reversed. */
|
|
1104
|
+
undo?: UndoCaptureSpec<E>;
|
|
1105
|
+
}
|
|
1106
|
+
interface UndoCaptureSpec<E = unknown> {
|
|
1107
|
+
/** Coarse type surfaced as `_undo.resource`. */
|
|
1108
|
+
resource: string;
|
|
1109
|
+
fidelity: UndoFidelity;
|
|
1110
|
+
/** Read the object BEFORE the write, through the PINNED env. Null or throw on miss. */
|
|
1111
|
+
capture: (pinnedEnv: E, id: string, args: ToolArgs) => Promise<unknown>;
|
|
1112
|
+
/** How the change reads in `list_undoable_changes`; the connection is appended by the wrapper. */
|
|
1113
|
+
describe: (before: Record<string, unknown>, id: string) => string;
|
|
1114
|
+
/** Relayed verbatim; on `'recreated'` it must say the id changes. Never empty. */
|
|
1115
|
+
warning: (id: string, before: Record<string, unknown>) => string;
|
|
1116
|
+
}
|
|
1117
|
+
/** The journal surface this wrapper needs; `createUndoJournal()` satisfies it. */
|
|
1118
|
+
interface CaptureJournal {
|
|
1119
|
+
captureBefore(args: {
|
|
1120
|
+
entity: string;
|
|
1121
|
+
originalId: string;
|
|
1122
|
+
before: unknown;
|
|
1123
|
+
connection: string | null;
|
|
1124
|
+
}): Promise<{
|
|
1125
|
+
ref: string;
|
|
1126
|
+
} | null>;
|
|
1127
|
+
}
|
|
1128
|
+
interface RequireApprovalOptions<E = unknown> {
|
|
1129
|
+
/** Log prefix, e.g. `shopify-undo`. */
|
|
1130
|
+
scope: string;
|
|
1131
|
+
/**
|
|
1132
|
+
* Resolve the connection this call will ACTUALLY hit, from the caller's
|
|
1133
|
+
* arguments and the install, canonicalised (a label like "EU store" becomes
|
|
1134
|
+
* the real domain). Return '' for a single-connection app. Never read it off
|
|
1135
|
+
* the arguments alone: on the retry the model usually omits it.
|
|
1136
|
+
*/
|
|
1137
|
+
resolveConnection: (env: E, args: ToolArgs) => Promise<string>;
|
|
1138
|
+
/** Re-pin the env to a resolved connection so lookups and captures hit it. */
|
|
1139
|
+
pinEnv: (env: E, connection: string) => E;
|
|
1140
|
+
/**
|
|
1141
|
+
* Human label for a raw id, looked up THROUGH the pinned env. Fall back to
|
|
1142
|
+
* the id on any miss; never guess, a confidently wrong card is worse than a
|
|
1143
|
+
* terse one. Omit to always show the raw id.
|
|
1144
|
+
*/
|
|
1145
|
+
describeTarget?: (pinnedEnv: E, id: string) => Promise<string>;
|
|
1146
|
+
/**
|
|
1147
|
+
* Where before-images go. Required when any spec declares `undo`. The
|
|
1148
|
+
* journal's own capture returns null rather than throwing, and this wrapper
|
|
1149
|
+
* treats null as "no undo offered".
|
|
1150
|
+
*/
|
|
1151
|
+
journal?: (env: E) => CaptureJournal;
|
|
1152
|
+
/**
|
|
1153
|
+
* Stamp the resolved connection onto a successful result (e.g. `{ store }`)
|
|
1154
|
+
* so an agent reporting "deleted it" can name where. Optional.
|
|
1155
|
+
*/
|
|
1156
|
+
stampConnection?: (result: unknown, connection: string) => unknown;
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Wrap the named tools so each asks a human first, then (if it declares
|
|
1160
|
+
* `undo`) journals a before-image on the granted pass and offers `_undo`.
|
|
1161
|
+
*
|
|
1162
|
+
* Returns only the wrapped entries. Register them AFTER the originals
|
|
1163
|
+
* (`Object.assign(registry, requireApproval(registry, specs, opts))`) so
|
|
1164
|
+
* last-write-wins puts the gated version in front.
|
|
1165
|
+
*
|
|
1166
|
+
* A spec naming a tool absent from `handlers` throws rather than skipping: a
|
|
1167
|
+
* rename that quietly ungated a permanent delete is exactly the failure this
|
|
1168
|
+
* guard exists to prevent, and it would look identical to working.
|
|
1169
|
+
*/
|
|
1170
|
+
declare function requireApproval<E>(handlers: Record<string, ToolHandler<E>>, specs: Record<string, ApprovalSpec<E>>, opts: RequireApprovalOptions<E>): Record<string, ToolHandler<E>>;
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* The reversal entry point every adopting app declares as `undo.reverse_tool`.
|
|
1174
|
+
*
|
|
1175
|
+
* Declared `internal: true` in the manifest, so the platform can dispatch it
|
|
1176
|
+
* but no agent ever sees it: an agent that can see a reversal entry point can
|
|
1177
|
+
* call it with an invented handle, and an agent inventing a plausible undo
|
|
1178
|
+
* identifier is a thing that happened on the simPRO shakedown. The platform
|
|
1179
|
+
* resolves its own token first and only then dispatches here with
|
|
1180
|
+
* `{ ref, undo_token }`.
|
|
1181
|
+
*
|
|
1182
|
+
* By the time this runs the platform has checked that the token exists,
|
|
1183
|
+
* belongs to this actor and company, has not expired, and has been claimed
|
|
1184
|
+
* exactly once. This runner re-checks none of that, because it cannot. It does
|
|
1185
|
+
* one thing: load the copy, re-pin the connection the original write hit,
|
|
1186
|
+
* hand both to the app's restore, and report honestly.
|
|
1187
|
+
*
|
|
1188
|
+
* THE RE-PIN IS THE WHOLE GAME. The platform dispatches with no memory of
|
|
1189
|
+
* which store / organisation / business the original write targeted. Without
|
|
1190
|
+
* `pin`, a replay lands on the install's CURRENT default and rebuilds the
|
|
1191
|
+
* object in the wrong customer account, and nothing errors.
|
|
1192
|
+
*
|
|
1193
|
+
* `E` is the env the tool receives; `P` is what `pin` hands to `restore`. For
|
|
1194
|
+
* a store-pinned env they are the same type (Shopify). For an app that
|
|
1195
|
+
* re-authenticates per actor and organisation, `P` is the fresh token state
|
|
1196
|
+
* (Xero), which is why they are separate parameters.
|
|
1197
|
+
*
|
|
1198
|
+
* Extracted from the Shopify and Xero `undo-apply.ts` handlers, which were
|
|
1199
|
+
* byte-similar apart from their restore call and their pin.
|
|
1200
|
+
*/
|
|
1201
|
+
|
|
1202
|
+
interface RestoreSpec<P, T = Record<string, unknown>> {
|
|
1203
|
+
resource: string;
|
|
1204
|
+
fidelity: UndoFidelity;
|
|
1205
|
+
/**
|
|
1206
|
+
* Rebuild or write back, through the PINNED connection. May return
|
|
1207
|
+
* `{ ok: false, error }` or throw; both become `restore_failed` and the
|
|
1208
|
+
* before-image is kept.
|
|
1209
|
+
*/
|
|
1210
|
+
restore: (pinned: P, before: T, row: JournalRowLike<T>) => Promise<{
|
|
1211
|
+
ok: boolean;
|
|
1212
|
+
newId?: string;
|
|
1213
|
+
error?: string;
|
|
1214
|
+
} | void>;
|
|
1215
|
+
/** What the restore does NOT bring back, folded into the note verbatim. */
|
|
1216
|
+
notRestored?: string;
|
|
1217
|
+
}
|
|
1218
|
+
interface JournalRowLike<T = unknown> {
|
|
1219
|
+
entity: string;
|
|
1220
|
+
original_id: string;
|
|
1221
|
+
connection: string | null;
|
|
1222
|
+
before: T;
|
|
1223
|
+
}
|
|
1224
|
+
interface UndoApplyOptions<E, P = E> {
|
|
1225
|
+
env: E;
|
|
1226
|
+
journal: {
|
|
1227
|
+
loadBefore<T = unknown>(ref: string): Promise<JournalRowLike<T> | null>;
|
|
1228
|
+
dropBefore(ref: string): Promise<void>;
|
|
1229
|
+
};
|
|
1230
|
+
specs: Record<string, RestoreSpec<P>>;
|
|
1231
|
+
/**
|
|
1232
|
+
* Re-pin to the journalled connection. May be async (re-auth per actor, as
|
|
1233
|
+
* Xero does). Throw to report `connection_unavailable`; return null to
|
|
1234
|
+
* REFUSE with `connection_mismatch`, e.g. a session-stateful app whose
|
|
1235
|
+
* current business differs from the row's.
|
|
1236
|
+
*/
|
|
1237
|
+
pin: (env: E, connection: string | null, args: ToolArgs) => Promise<P | null> | P | null;
|
|
1238
|
+
}
|
|
1239
|
+
type UndoApplyResult = {
|
|
1240
|
+
ok: true;
|
|
1241
|
+
resource: string;
|
|
1242
|
+
original_id: string;
|
|
1243
|
+
connection: string | null;
|
|
1244
|
+
new_id: string | null;
|
|
1245
|
+
fidelity: UndoFidelity;
|
|
1246
|
+
not_restored?: string;
|
|
1247
|
+
note: string;
|
|
1248
|
+
} | {
|
|
1249
|
+
ok: false;
|
|
1250
|
+
error: string;
|
|
1251
|
+
note: string;
|
|
1252
|
+
};
|
|
1253
|
+
/** Run the reversal. Every failure names what it was and states that nothing changed. */
|
|
1254
|
+
declare function runUndoApply<E, P = E>(args: ToolArgs, opts: UndoApplyOptions<E, P>): Promise<UndoApplyResult>;
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* Declare a dispatcher tool's confirmation policy beside its action registry,
|
|
1258
|
+
* emit it into the manifest with a `gen:` script, and let a test prove that
|
|
1259
|
+
* every write action is either gated or knowingly exempt.
|
|
1260
|
+
*
|
|
1261
|
+
* This is the Xero app's `confirmation-policy.ts` + its test, generalised. The
|
|
1262
|
+
* two things that fail SILENTLY are what the checker exists for: a rule keyed
|
|
1263
|
+
* on an action that does not exist (dead rule, no gate, no error) and a
|
|
1264
|
+
* placeholder path that renders "(unset)" in the prompt a human is asked to
|
|
1265
|
+
* approve.
|
|
1266
|
+
*/
|
|
1267
|
+
interface PolicySource {
|
|
1268
|
+
/** Money has left, a customer has been mailed, or the vendor offers no way back. */
|
|
1269
|
+
irreversible?: Record<string, string>;
|
|
1270
|
+
/** Gated on every call, reversible but real. */
|
|
1271
|
+
always?: Record<string, string>;
|
|
1272
|
+
/** Full rules, for thresholds and anything the two maps above cannot express. */
|
|
1273
|
+
rules?: Record<string, ConfirmRule>;
|
|
1274
|
+
}
|
|
1275
|
+
/** Build a sorted `{ actions }` policy. An action named in two groups throws. */
|
|
1276
|
+
declare function buildConfirmationPolicy(src: PolicySource): ConfirmationPolicy;
|
|
1277
|
+
/**
|
|
1278
|
+
* Action-name prefixes that mean "this writes". Used only for coverage: every
|
|
1279
|
+
* registry action with one of these prefixes must be gated or listed as
|
|
1280
|
+
* deliberately ungated. Reads (`get_`, `list_`, `search_`) are not here.
|
|
1281
|
+
*/
|
|
1282
|
+
declare const DEFAULT_WRITE_PREFIXES: readonly string[];
|
|
1283
|
+
/** Fields a threshold must never be placed on: a numeric string never trips. */
|
|
1284
|
+
declare const DEFAULT_MONEY_FIELDS: readonly string[];
|
|
1285
|
+
interface PolicyCheckInput {
|
|
1286
|
+
policy: ConfirmationPolicy;
|
|
1287
|
+
/** Every dispatchable action name (the registry KEYS). */
|
|
1288
|
+
registry: Iterable<string>;
|
|
1289
|
+
/** Write actions deliberately left ungated, each a conscious choice. */
|
|
1290
|
+
ungated?: readonly string[];
|
|
1291
|
+
writePrefixes?: readonly string[];
|
|
1292
|
+
/**
|
|
1293
|
+
* The input fields an action REQUIRES. A `describe` may interpolate only
|
|
1294
|
+
* those (or a field named by its own `when.count`): an optional field is
|
|
1295
|
+
* absent on some calls by definition and renders as "(unset)". Omit to skip
|
|
1296
|
+
* this check.
|
|
1297
|
+
*/
|
|
1298
|
+
requiredInput?: (action: string) => readonly string[] | undefined;
|
|
1299
|
+
/**
|
|
1300
|
+
* True (default) when the tool nests params under `input`: every placeholder
|
|
1301
|
+
* and count path must then be dotted through `input.`, because the platform
|
|
1302
|
+
* evaluates against the TOP-LEVEL args it reads `action` from.
|
|
1303
|
+
*/
|
|
1304
|
+
nestedUnderInput?: boolean;
|
|
1305
|
+
moneyFields?: readonly string[];
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Every way a policy can be wrong without erroring, as a list of findings.
|
|
1309
|
+
* An app's test is `expect(checkConfirmationPolicy(...)).toEqual([])`.
|
|
1310
|
+
*/
|
|
1311
|
+
declare function checkConfirmationPolicy(input: PolicyCheckInput): string[];
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* The two halves of offering an undo, for handlers that are not behind an
|
|
1315
|
+
* approval card (a dispatcher's `update_*` actions, for instance). The order
|
|
1316
|
+
* is the whole contract:
|
|
1317
|
+
*
|
|
1318
|
+
* const before = await safeCapture(scope, action, id, () => read(pinned, id));
|
|
1319
|
+
* const result = await write(pinned, input); // happens regardless
|
|
1320
|
+
* const _undo = await offerUndo({ journal, entity: action, id, before, connection, ... });
|
|
1321
|
+
* return { ok: true, result, ...(_undo ? { _undo } : {}) };
|
|
1322
|
+
*
|
|
1323
|
+
* `requireApproval` uses the same two functions on its granted pass.
|
|
1324
|
+
*/
|
|
1325
|
+
/**
|
|
1326
|
+
* Read the before-image, degrading to null on any failure. A capture that
|
|
1327
|
+
* throws (404, scope denied, network) is not a reason to refuse a write the
|
|
1328
|
+
* user asked for; it is a reason to offer no undo, and to say so in the log.
|
|
1329
|
+
*/
|
|
1330
|
+
declare function safeCapture(scope: string, name: string, id: string, capture: () => Promise<unknown>): Promise<Record<string, unknown> | null>;
|
|
1331
|
+
interface OfferUndoArgs {
|
|
1332
|
+
journal: CaptureJournal;
|
|
1333
|
+
/** The tool or action that captured; the reverse tool looks the spec up by it. */
|
|
1334
|
+
entity: string;
|
|
1335
|
+
id: string;
|
|
1336
|
+
/** From `safeCapture`. Null means no undo is offered, deliberately. */
|
|
1337
|
+
before: Record<string, unknown> | null;
|
|
1338
|
+
/** The resolved store / organisation / business, or null for a single-connection app. */
|
|
1339
|
+
connection: string | null;
|
|
1340
|
+
fidelity: UndoFidelity;
|
|
1341
|
+
resource: string;
|
|
1342
|
+
/** How the change reads in `list_undoable_changes`; the connection is appended when present. */
|
|
1343
|
+
describe: (before: Record<string, unknown>, id: string) => string;
|
|
1344
|
+
/** Relayed verbatim; never empty. */
|
|
1345
|
+
warning: string;
|
|
1346
|
+
/** Word between the description and the connection: "on" for a store, "in" for an organisation. */
|
|
1347
|
+
connectionPreposition?: 'on' | 'in';
|
|
1348
|
+
/**
|
|
1349
|
+
* What `describes` calls the connection, when the journalled id is not
|
|
1350
|
+
* readable by a person (a Xero tenantId). Defaults to `connection`. The
|
|
1351
|
+
* journal row always stores `connection` itself, because that is the pin.
|
|
1352
|
+
*/
|
|
1353
|
+
connectionLabel?: string;
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Store the before-image and build the `_undo` envelope, or return undefined
|
|
1357
|
+
* when there is nothing to offer. Never returns an envelope for a null capture
|
|
1358
|
+
* or a journal that could not store the copy.
|
|
1359
|
+
*/
|
|
1360
|
+
declare function offerUndo(args: OfferUndoArgs): Promise<AppUndoEnvelope | undefined>;
|
|
1361
|
+
|
|
828
1362
|
/**
|
|
829
1363
|
* @sprigr/apps-app-sdk
|
|
830
1364
|
*
|
|
@@ -1225,4 +1759,4 @@ interface ExecutionContextLike {
|
|
|
1225
1759
|
}
|
|
1226
1760
|
type HandlerFn<TArgs, TResult> = (args: TArgs, env: Record<string, unknown>, ctx?: ExecutionContextLike) => Promise<TResult>;
|
|
1227
1761
|
|
|
1228
|
-
export { type Actor, type AppFileListItem, type AppFileUrlArgs, type AppFileUrlResult, type AppFilesEnv, type D1Like, type D1PreparedStatementLike, DEFAULT_EMIT_TIMEOUT_MS, DEFAULT_MAX_FILE_BYTES, type EmitMarketplaceEventOptions, type EmitResult, type EmitTransport, type EventArgs, type ExecutionContextLike, type FetchFileResult, type FetchWithRetryOptions, type GetAppFileResult, type HandlerFn, type InboxOwner, type InstallBridge, type MarketplaceEventEmitOpts, type MarketplaceEventSourceIntegration, NotConnectedError, type OAuthBouncerSignals, type OAuthState, type PlatformHostEnv, type PutAppFileArgs, type PutAppFileResult, type PutAppFileStreamArgs, type ScheduleArgs, type SprigrDataAggregationCell, type SprigrDataAggregationResult, type SprigrDataAggregationSpec, type SprigrDataApi, type SprigrDataIndexOpts, type SprigrDataSearchOpts, type SprigrDataSearchResult, type SprigrFilesApi, type SprigrFilesApiWithEdit, type SprigrFilesCreateInput, type SprigrFilesCreateResult, type SprigrFilesEditInput, type SprigrFilesEditResult, type SprigrFilesJobResult, type ToolResult, type ToolWrapperOptions, type WebhookArgs, type WfpBridgeEnv, type WfpEmitReply, actorKey, actorTool, appFileUrl, base64ToBytes, buildMarketplaceWebhookUrl, bytesToBase64, canEmit, constantTimeEqual, createMarketplaceEmitter, createToolWrappers, decodeState, deleteAppFile, describeMissingBridge, emitMarketplaceEvent, encodeState, fetchFileAsBase64, fetchFileBytes, fetchWithRetry, getAppFile, hmacSha256Hex, installTokenPost, isNotConnectedError, isStagingHost, listAppFiles, overlaySprigr, ownerFromActorKey, parseActor, putAppFile, putAppFileStream, randomHex, resolveInstallBridge, resolveOAuthBouncerBase, resolveOAuthBouncerCallbackUrl, resolvePlatformWebhookBase, tool, withSprigrEmitFallback };
|
|
1762
|
+
export { APPROVAL_GRANTED_KEY, type Actor, type AppApprovalEnvelope, type AppFileListItem, type AppFileUrlArgs, type AppFileUrlResult, type AppFilesEnv, type AppUndoEnvelope, type ApprovalSpec, type ArchiveOfferOptions, type ArchiveOfferRefusal, type CaptureJournal, type ConfirmCondition, type ConfirmRule, type ConfirmationPolicy, type D1Like, type D1PreparedStatementLike, DEFAULT_EMIT_TIMEOUT_MS, DEFAULT_MAX_FILE_BYTES, DEFAULT_MONEY_FIELDS, DEFAULT_WRITE_PREFIXES, type EmitMarketplaceEventOptions, type EmitResult, type EmitTransport, type EventArgs, type ExecutionContextLike, type FetchFileResult, type FetchWithRetryOptions, type GetAppFileResult, type HandlerFn, type InboxOwner, type InstallBridge, InvalidUndoEnvelopeError, type JournalRowLike, type MarketplaceEventEmitOpts, type MarketplaceEventSourceIntegration, NotConnectedError, type OAuthBouncerSignals, type OAuthState, type OfferUndoArgs, type PlatformHostEnv, type PolicyCheckInput, type PolicySource, type PutAppFileArgs, type PutAppFileResult, type PutAppFileStreamArgs, type RequireApprovalOptions, type RestoreSpec, type ScheduleArgs, type SprigrDataAggregationCell, type SprigrDataAggregationResult, type SprigrDataAggregationSpec, type SprigrDataApi, type SprigrDataIndexOpts, type SprigrDataSearchOpts, type SprigrDataSearchResult, type SprigrFilesApi, type SprigrFilesApiWithEdit, type SprigrFilesCreateInput, type SprigrFilesCreateResult, type SprigrFilesEditInput, type SprigrFilesEditResult, type SprigrFilesJobResult, type ToolArgs, type ToolHandler, type ToolResult, type ToolWrapperOptions, UNIT_SEP, type UndoApplyOptions, type UndoApplyResult, type UndoCaptureSpec, type UndoFidelity, type WebhookArgs, type WfpBridgeEnv, type WfpEmitReply, actorKey, actorTool, appFileUrl, approvalHash, archiveOfferRefusal, base64ToBytes, buildConfirmationPolicy, buildMarketplaceWebhookUrl, bytesToBase64, canEmit, checkConfirmationPolicy, constantTimeEqual, createMarketplaceEmitter, createToolWrappers, decodeState, deleteAppFile, describeMissingBridge, emitMarketplaceEvent, encodeState, fetchFileAsBase64, fetchFileBytes, fetchWithRetry, fullWarning, getAppFile, hmacSha256Hex, installTokenPost, isNotConnectedError, isStagingHost, listAppFiles, offerUndo, overlaySprigr, ownerFromActorKey, parseActor, putAppFile, putAppFileStream, randomHex, recreatedWarning, refuseWithoutForce, requireApproval, resolveInstallBridge, resolveOAuthBouncerBase, resolveOAuthBouncerCallbackUrl, resolvePlatformWebhookBase, runUndoApply, safeCapture, seq, set, tool, undoEnvelope, withSprigrEmitFallback };
|
package/dist/index.js
CHANGED
|
@@ -485,17 +485,450 @@ function listAppFiles(env, prefix) {
|
|
|
485
485
|
function deleteAppFile(env, key) {
|
|
486
486
|
return call(env, "delete", { key });
|
|
487
487
|
}
|
|
488
|
+
|
|
489
|
+
// src/write-protection/types.ts
|
|
490
|
+
var APPROVAL_GRANTED_KEY = "_approval_granted";
|
|
491
|
+
|
|
492
|
+
// src/write-protection/approval-hash.ts
|
|
493
|
+
var UNIT_SEP = "";
|
|
494
|
+
function set(values) {
|
|
495
|
+
if (!Array.isArray(values)) return "";
|
|
496
|
+
return [...values].map((v) => String(v)).sort().join(UNIT_SEP);
|
|
497
|
+
}
|
|
498
|
+
function seq(values) {
|
|
499
|
+
if (!Array.isArray(values)) return "";
|
|
500
|
+
return values.map((v) => String(v)).join(UNIT_SEP);
|
|
501
|
+
}
|
|
502
|
+
function approvalHash(...parts) {
|
|
503
|
+
return parts.map((p) => p === void 0 || p === null ? "" : String(p)).join(UNIT_SEP);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// src/write-protection/undo-envelope.ts
|
|
507
|
+
var InvalidUndoEnvelopeError = class extends Error {
|
|
508
|
+
constructor(message) {
|
|
509
|
+
super(message);
|
|
510
|
+
this.name = "InvalidUndoEnvelopeError";
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
function undoEnvelope(input) {
|
|
514
|
+
if (input.fidelity !== "full" && input.fidelity !== "recreated") {
|
|
515
|
+
throw new InvalidUndoEnvelopeError(
|
|
516
|
+
`_undo.fidelity must be 'full' or 'recreated', got ${JSON.stringify(input.fidelity)}`
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
for (const key of ["warning", "describes", "resource", "ref"]) {
|
|
520
|
+
const v = input[key];
|
|
521
|
+
if (typeof v !== "string" || v.trim() === "") {
|
|
522
|
+
throw new InvalidUndoEnvelopeError(
|
|
523
|
+
`_undo.${key} must be a non-empty string; the platform drops the whole envelope and mints no token on a blank field`
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return {
|
|
528
|
+
fidelity: input.fidelity,
|
|
529
|
+
warning: input.warning.trim(),
|
|
530
|
+
describes: input.describes.trim(),
|
|
531
|
+
resource: input.resource.trim(),
|
|
532
|
+
ref: input.ref
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
function recreatedWarning(resource, notRestored) {
|
|
536
|
+
const base = `Not a true undo: the original ${resource} is permanently gone and its id will never be reissued. Reversing creates a NEW ${resource} from the saved copy, under a new id, so anything that referenced the old id stays broken.`;
|
|
537
|
+
return notRestored && notRestored.trim() ? `${base} Not restored: ${notRestored.trim()}.` : base;
|
|
538
|
+
}
|
|
539
|
+
function fullWarning(notRestored) {
|
|
540
|
+
const base = "Reversing writes the previously saved values back over the record, so any change made to it since then is overwritten.";
|
|
541
|
+
return notRestored && notRestored.trim() ? `${base} It does not restore ${notRestored.trim()}.` : base;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// src/write-protection/refuse-without-force.ts
|
|
545
|
+
function idOf(args, keys) {
|
|
546
|
+
for (const k of keys) {
|
|
547
|
+
const v = args[k];
|
|
548
|
+
if (typeof v === "string" && v.trim() || typeof v === "number") return String(v);
|
|
549
|
+
}
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
function archiveOfferRefusal(deleteAction, args, opts) {
|
|
553
|
+
const id = idOf(args, opts.idKeys ?? ["id"]);
|
|
554
|
+
const link = id && opts.link ? opts.link(args, id) : void 0;
|
|
555
|
+
return {
|
|
556
|
+
ok: false,
|
|
557
|
+
deleted: false,
|
|
558
|
+
archive_available: true,
|
|
559
|
+
resource: opts.resource,
|
|
560
|
+
record_id: id,
|
|
561
|
+
delete_action: deleteAction,
|
|
562
|
+
archive_action: opts.archiveAction,
|
|
563
|
+
...link ? { link } : {},
|
|
564
|
+
note: `Nothing was deleted. This ${opts.resource}${id ? ` (${id})` : ""} can be ARCHIVED instead with ${opts.archiveAction}, which keeps its id and is reversible; a delete throws the id away for good and cannot be undone. Offer the archive to the person first. If they explicitly want it destroyed even though it cannot be recovered, call ${deleteAction} again with force: true.`
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
function refuseWithoutForce(deleteAction, inner, opts) {
|
|
568
|
+
return async (args, env, ctx) => {
|
|
569
|
+
const a = args ?? {};
|
|
570
|
+
if (a.force === true) {
|
|
571
|
+
const { force: _force, ...rest } = a;
|
|
572
|
+
return inner(rest, env, ctx);
|
|
573
|
+
}
|
|
574
|
+
return archiveOfferRefusal(deleteAction, a, opts);
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// src/write-protection/undo-capture.ts
|
|
579
|
+
async function safeCapture(scope, name, id, capture) {
|
|
580
|
+
try {
|
|
581
|
+
const got = await capture();
|
|
582
|
+
return got && typeof got === "object" ? got : null;
|
|
583
|
+
} catch (err) {
|
|
584
|
+
console.warn(`[${scope}] ${name} ${id}: capture failed, writing without undo: ${String(err)}`);
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
async function offerUndo(args) {
|
|
589
|
+
if (!args.before) return void 0;
|
|
590
|
+
const captured = await args.journal.captureBefore({
|
|
591
|
+
entity: args.entity,
|
|
592
|
+
originalId: args.id,
|
|
593
|
+
before: args.before,
|
|
594
|
+
connection: args.connection
|
|
595
|
+
});
|
|
596
|
+
if (!captured) return void 0;
|
|
597
|
+
const base = args.describe(args.before, args.id);
|
|
598
|
+
const prep = args.connectionPreposition ?? "on";
|
|
599
|
+
const label = args.connectionLabel ?? args.connection;
|
|
600
|
+
return undoEnvelope({
|
|
601
|
+
fidelity: args.fidelity,
|
|
602
|
+
warning: args.warning,
|
|
603
|
+
describes: label ? `${base} ${prep} ${label}` : base,
|
|
604
|
+
resource: args.resource,
|
|
605
|
+
ref: captured.ref
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// src/write-protection/require-approval.ts
|
|
610
|
+
function rawIdOf(args, keys) {
|
|
611
|
+
for (const k of keys) {
|
|
612
|
+
const v = args[k];
|
|
613
|
+
if (typeof v === "string" && v || typeof v === "number") return String(v);
|
|
614
|
+
}
|
|
615
|
+
return "";
|
|
616
|
+
}
|
|
617
|
+
function isOk(result) {
|
|
618
|
+
return !!result && typeof result === "object" && result.ok !== false;
|
|
619
|
+
}
|
|
620
|
+
function requireApproval(handlers, specs, opts) {
|
|
621
|
+
const gated = {};
|
|
622
|
+
for (const [name, spec] of Object.entries(specs)) {
|
|
623
|
+
const inner = handlers[name];
|
|
624
|
+
if (!inner) {
|
|
625
|
+
throw new Error(
|
|
626
|
+
`requireApproval: "${name}" has an approval spec but is not in the handler registry. It was probably renamed or removed; update the specs in the same change, or a destructive tool ships ungated.`
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
if (spec.undo && !opts.journal) {
|
|
630
|
+
throw new Error(`requireApproval: "${name}" declares undo but no journal was supplied.`);
|
|
631
|
+
}
|
|
632
|
+
gated[name] = async (args, env, ctx) => {
|
|
633
|
+
const a = args ?? {};
|
|
634
|
+
if (a[APPROVAL_GRANTED_KEY] === true) {
|
|
635
|
+
const { [APPROVAL_GRANTED_KEY]: _granted, ...rest } = a;
|
|
636
|
+
return granted(name, spec, rest, env, ctx, inner, opts);
|
|
637
|
+
}
|
|
638
|
+
const connection = await opts.resolveConnection(env, a);
|
|
639
|
+
const rawId = rawIdOf(a, spec.keys);
|
|
640
|
+
const pinned = connection ? opts.pinEnv(env, connection) : env;
|
|
641
|
+
const target = rawId && opts.describeTarget ? await safeLabel(opts.describeTarget, pinned, rawId) : rawId || "(unknown id)";
|
|
642
|
+
const extra = spec.hash ? spec.hash(a) : [];
|
|
643
|
+
return {
|
|
644
|
+
ok: false,
|
|
645
|
+
_approval: {
|
|
646
|
+
...spec.describe(target, a, connection),
|
|
647
|
+
hash: approvalHash(rawId, connection, ...extra)
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
return gated;
|
|
653
|
+
}
|
|
654
|
+
async function safeLabel(fn, env, id) {
|
|
655
|
+
try {
|
|
656
|
+
const label = await fn(env, id);
|
|
657
|
+
return label && label.trim() ? label : id;
|
|
658
|
+
} catch {
|
|
659
|
+
return id;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
async function granted(name, spec, args, env, ctx, inner, opts) {
|
|
663
|
+
const connection = await opts.resolveConnection(env, args);
|
|
664
|
+
const stamp = (r) => connection && opts.stampConnection ? opts.stampConnection(r, connection) : r;
|
|
665
|
+
const undo = spec.undo;
|
|
666
|
+
const id = rawIdOf(args, spec.keys);
|
|
667
|
+
if (!undo || !id) return stamp(await inner(args, env, ctx));
|
|
668
|
+
const pinned = connection ? opts.pinEnv(env, connection) : env;
|
|
669
|
+
const before = await safeCapture(opts.scope, name, id, () => undo.capture(pinned, id, args));
|
|
670
|
+
const result = await inner(args, env, ctx);
|
|
671
|
+
if (!isOk(result) || !before) return stamp(result);
|
|
672
|
+
const envelope = await offerUndo({
|
|
673
|
+
journal: opts.journal(env),
|
|
674
|
+
entity: name,
|
|
675
|
+
id,
|
|
676
|
+
before,
|
|
677
|
+
connection: connection || null,
|
|
678
|
+
fidelity: undo.fidelity,
|
|
679
|
+
resource: undo.resource,
|
|
680
|
+
describe: undo.describe,
|
|
681
|
+
warning: undo.warning(id, before)
|
|
682
|
+
});
|
|
683
|
+
if (!envelope) return stamp(result);
|
|
684
|
+
return { ...stamp(result), _undo: envelope };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// src/write-protection/undo-apply.ts
|
|
688
|
+
async function runUndoApply(args, opts) {
|
|
689
|
+
const ref = typeof args.ref === "string" ? args.ref.trim() : "";
|
|
690
|
+
if (!ref) return { ok: false, error: "missing_ref", note: "No before-image reference was supplied. Nothing was changed." };
|
|
691
|
+
const row = await opts.journal.loadBefore(ref);
|
|
692
|
+
if (!row) {
|
|
693
|
+
return {
|
|
694
|
+
ok: false,
|
|
695
|
+
error: "before_image_missing",
|
|
696
|
+
note: "The saved copy of this record is no longer available, so it cannot be restored. Nothing was changed."
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
const spec = opts.specs[row.entity];
|
|
700
|
+
if (!spec) {
|
|
701
|
+
return {
|
|
702
|
+
ok: false,
|
|
703
|
+
error: "undo_no_longer_supported",
|
|
704
|
+
note: `This app no longer knows how to reverse a "${row.entity}" change. Nothing was changed.`
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
const before = row.before;
|
|
708
|
+
if (!before || typeof before !== "object") {
|
|
709
|
+
return { ok: false, error: "before_image_unreadable", note: "The saved copy could not be read back. Nothing was changed." };
|
|
710
|
+
}
|
|
711
|
+
let pinned;
|
|
712
|
+
try {
|
|
713
|
+
pinned = await opts.pin(opts.env, row.connection, args);
|
|
714
|
+
} catch (err) {
|
|
715
|
+
return {
|
|
716
|
+
ok: false,
|
|
717
|
+
error: "connection_unavailable",
|
|
718
|
+
note: `Could not reach the connection this change was made in${row.connection ? ` (${row.connection})` : ""}: ${String(err)}. Nothing was changed.`
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
if (pinned === null || pinned === void 0) {
|
|
722
|
+
return {
|
|
723
|
+
ok: false,
|
|
724
|
+
error: "connection_mismatch",
|
|
725
|
+
note: `This change was made in ${row.connection ?? "a different connection"}, which is not the one currently selected. Switch to it and retry. Nothing was changed.`
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
let result;
|
|
729
|
+
try {
|
|
730
|
+
result = await spec.restore(pinned, before, row) ?? { ok: true };
|
|
731
|
+
} catch (err) {
|
|
732
|
+
result = { ok: false, error: String(err) };
|
|
733
|
+
}
|
|
734
|
+
if (!result.ok) {
|
|
735
|
+
return {
|
|
736
|
+
ok: false,
|
|
737
|
+
error: "restore_failed",
|
|
738
|
+
note: `The provider refused to restore the ${spec.resource}: ${result.error ?? "unknown error"}. Nothing was restored.`
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
await opts.journal.dropBefore(ref);
|
|
742
|
+
const recreated = spec.fidelity === "recreated";
|
|
743
|
+
return {
|
|
744
|
+
ok: true,
|
|
745
|
+
resource: spec.resource,
|
|
746
|
+
original_id: row.original_id,
|
|
747
|
+
connection: row.connection,
|
|
748
|
+
new_id: result.newId ?? null,
|
|
749
|
+
fidelity: spec.fidelity,
|
|
750
|
+
...spec.notRestored ? { not_restored: spec.notRestored } : {},
|
|
751
|
+
note: recreated ? `Created a NEW ${spec.resource}${result.newId ? ` (${result.newId})` : ""} from the saved copy. The original ${row.original_id} is still gone and its id will never be reissued.${spec.notRestored ? ` Not restored: ${spec.notRestored}.` : ""} Report this as a replacement, not as an undo.` : `Restored ${spec.resource} ${row.original_id} to its saved values.${spec.notRestored ? ` Not restored: ${spec.notRestored}.` : ""} Anything changed since the capture was overwritten.`
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// src/write-protection/confirmation-policy.ts
|
|
756
|
+
function buildConfirmationPolicy(src) {
|
|
757
|
+
const actions = {};
|
|
758
|
+
const put = (name, rule, group) => {
|
|
759
|
+
if (actions[name]) throw new Error(`buildConfirmationPolicy: "${name}" is declared twice (second time in ${group})`);
|
|
760
|
+
actions[name] = rule;
|
|
761
|
+
};
|
|
762
|
+
for (const [name, describe] of Object.entries(src.irreversible ?? {})) put(name, { always: true, describe, irreversible: true }, "irreversible");
|
|
763
|
+
for (const [name, describe] of Object.entries(src.always ?? {})) put(name, { always: true, describe }, "always");
|
|
764
|
+
for (const [name, rule] of Object.entries(src.rules ?? {})) put(name, rule, "rules");
|
|
765
|
+
return { actions: Object.fromEntries(Object.entries(actions).sort(([a], [b]) => a.localeCompare(b))) };
|
|
766
|
+
}
|
|
767
|
+
var DEFAULT_WRITE_PREFIXES = [
|
|
768
|
+
"create_",
|
|
769
|
+
"update_",
|
|
770
|
+
"delete_",
|
|
771
|
+
"remove_",
|
|
772
|
+
"set_",
|
|
773
|
+
"add_",
|
|
774
|
+
"archive_",
|
|
775
|
+
"unarchive_",
|
|
776
|
+
"void_",
|
|
777
|
+
"refund_",
|
|
778
|
+
"cancel_",
|
|
779
|
+
"send_",
|
|
780
|
+
"email_",
|
|
781
|
+
"post_",
|
|
782
|
+
"publish_",
|
|
783
|
+
"unpublish_",
|
|
784
|
+
"approve_",
|
|
785
|
+
"reject_",
|
|
786
|
+
"submit_",
|
|
787
|
+
"allocate_",
|
|
788
|
+
"mark_",
|
|
789
|
+
"complete_",
|
|
790
|
+
"reopen_",
|
|
791
|
+
"convert_",
|
|
792
|
+
"clone_",
|
|
793
|
+
"upload_",
|
|
794
|
+
"attach_",
|
|
795
|
+
"detach_",
|
|
796
|
+
"move_",
|
|
797
|
+
"merge_",
|
|
798
|
+
"assign_",
|
|
799
|
+
"unassign_",
|
|
800
|
+
"pay_",
|
|
801
|
+
"capture_",
|
|
802
|
+
"process_",
|
|
803
|
+
"apply_",
|
|
804
|
+
"authorise_",
|
|
805
|
+
"authorize_",
|
|
806
|
+
"register_",
|
|
807
|
+
"unregister_",
|
|
808
|
+
"rotate_",
|
|
809
|
+
"revoke_",
|
|
810
|
+
"clear_",
|
|
811
|
+
"reset_",
|
|
812
|
+
"purge_",
|
|
813
|
+
"force_",
|
|
814
|
+
"import_",
|
|
815
|
+
"push_",
|
|
816
|
+
"invite_",
|
|
817
|
+
"swap_",
|
|
818
|
+
"bulk_",
|
|
819
|
+
"batch_",
|
|
820
|
+
"unship_",
|
|
821
|
+
"offboard_",
|
|
822
|
+
"hold_",
|
|
823
|
+
"release_",
|
|
824
|
+
"retry_",
|
|
825
|
+
"trigger_",
|
|
826
|
+
"record_",
|
|
827
|
+
"adjust_",
|
|
828
|
+
"receive_",
|
|
829
|
+
"dispatch_",
|
|
830
|
+
"transfer_",
|
|
831
|
+
"link_",
|
|
832
|
+
"unlink_",
|
|
833
|
+
"enable_",
|
|
834
|
+
"disable_",
|
|
835
|
+
"pause_",
|
|
836
|
+
"resume_",
|
|
837
|
+
"restore_",
|
|
838
|
+
"replace_",
|
|
839
|
+
"insert_",
|
|
840
|
+
"patch_",
|
|
841
|
+
"put_",
|
|
842
|
+
"write_",
|
|
843
|
+
"save_",
|
|
844
|
+
"book_",
|
|
845
|
+
"schedule_",
|
|
846
|
+
"reschedule_",
|
|
847
|
+
"escalate_",
|
|
848
|
+
"resolve_",
|
|
849
|
+
"dismiss_",
|
|
850
|
+
"accept_",
|
|
851
|
+
"decline_",
|
|
852
|
+
"follow_",
|
|
853
|
+
"unfollow_",
|
|
854
|
+
"like_",
|
|
855
|
+
"comment_",
|
|
856
|
+
"deactivate_",
|
|
857
|
+
"activate_",
|
|
858
|
+
"suspend_",
|
|
859
|
+
"generate_",
|
|
860
|
+
"issue_",
|
|
861
|
+
"charge_"
|
|
862
|
+
];
|
|
863
|
+
var DEFAULT_MONEY_FIELDS = ["amount", "total", "unit_amount", "sub_total", "price", "budget"];
|
|
864
|
+
var PLACEHOLDER = /\{([a-zA-Z0-9_.]+)\}/g;
|
|
865
|
+
function conditions(rule) {
|
|
866
|
+
return !rule.when ? [] : Array.isArray(rule.when) ? rule.when : [rule.when];
|
|
867
|
+
}
|
|
868
|
+
function checkConfirmationPolicy(input) {
|
|
869
|
+
const registry = new Set(input.registry);
|
|
870
|
+
const ungated = input.ungated ?? [];
|
|
871
|
+
const prefixes = input.writePrefixes ?? DEFAULT_WRITE_PREFIXES;
|
|
872
|
+
const nested = input.nestedUnderInput ?? true;
|
|
873
|
+
const money = new Set(input.moneyFields ?? DEFAULT_MONEY_FIELDS);
|
|
874
|
+
const actions = input.policy.actions ?? {};
|
|
875
|
+
const out = [];
|
|
876
|
+
for (const [name, rule] of Object.entries(actions)) {
|
|
877
|
+
if (!registry.has(name)) out.push(`dead rule: "${name}" is not in the registry, so it gates nothing`);
|
|
878
|
+
if (!rule.always && conditions(rule).length === 0) out.push(`${name}: rule says nothing (no always, no when)`);
|
|
879
|
+
if (!rule.describe || !rule.describe.trim()) out.push(`${name}: describe missing`);
|
|
880
|
+
else if (rule.describe.trim().endsWith(".")) out.push(`${name}: describe must not end in a period (platform renders "Action: <text>.")`);
|
|
881
|
+
const required = new Set(input.requiredInput?.(name) ?? []);
|
|
882
|
+
for (const c of conditions(rule)) {
|
|
883
|
+
if (!(c.atLeast >= 1)) out.push(`${name}: atLeast must be >= 1 (${c.atLeast} is always true; use always: true)`);
|
|
884
|
+
if (nested && !c.count.startsWith("input.")) out.push(`${name}: count "${c.count}" must be dotted through input.`);
|
|
885
|
+
const leaf = c.count.split(".").pop() ?? "";
|
|
886
|
+
if (money.has(leaf)) out.push(`${name}: threshold on money field "${c.count}" is bypassed by a numeric string; use always`);
|
|
887
|
+
required.add(c.count.replace(/^input\./, "").split(".")[0]);
|
|
888
|
+
}
|
|
889
|
+
for (const m of (rule.describe ?? "").matchAll(PLACEHOLDER)) {
|
|
890
|
+
const path = m[1];
|
|
891
|
+
if (nested && !path.startsWith("input.")) {
|
|
892
|
+
out.push(`${name}: placeholder {${path}} is not under input. and will render as (unset)`);
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
if (input.requiredInput) {
|
|
896
|
+
const field = path.replace(/^input\./, "").replace(/\.length$/, "").split(".")[0];
|
|
897
|
+
if (!required.has(field)) out.push(`${name}: placeholder {${path}} reads "${field}", which the action does not require, so it renders as (unset) when omitted`);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
for (const name of ungated) {
|
|
902
|
+
if (!registry.has(name)) out.push(`ungated list: "${name}" is not in the registry`);
|
|
903
|
+
if (actions[name]) out.push(`ungated list: "${name}" is also gated; remove one`);
|
|
904
|
+
}
|
|
905
|
+
for (const name of registry) {
|
|
906
|
+
if (!prefixes.some((p) => name.startsWith(p))) continue;
|
|
907
|
+
if (actions[name] || ungated.includes(name)) continue;
|
|
908
|
+
out.push(`unclassified write: "${name}" is neither gated nor listed as deliberately ungated`);
|
|
909
|
+
}
|
|
910
|
+
return out.sort();
|
|
911
|
+
}
|
|
488
912
|
export {
|
|
913
|
+
APPROVAL_GRANTED_KEY,
|
|
489
914
|
DEFAULT_EMIT_TIMEOUT_MS,
|
|
490
915
|
DEFAULT_MAX_FILE_BYTES,
|
|
916
|
+
DEFAULT_MONEY_FIELDS,
|
|
917
|
+
DEFAULT_WRITE_PREFIXES,
|
|
918
|
+
InvalidUndoEnvelopeError,
|
|
491
919
|
NotConnectedError,
|
|
920
|
+
UNIT_SEP,
|
|
492
921
|
actorKey,
|
|
493
922
|
actorTool,
|
|
494
923
|
appFileUrl,
|
|
924
|
+
approvalHash,
|
|
925
|
+
archiveOfferRefusal,
|
|
495
926
|
base64ToBytes,
|
|
927
|
+
buildConfirmationPolicy,
|
|
496
928
|
buildMarketplaceWebhookUrl,
|
|
497
929
|
bytesToBase64,
|
|
498
930
|
canEmit,
|
|
931
|
+
checkConfirmationPolicy,
|
|
499
932
|
constantTimeEqual,
|
|
500
933
|
createMarketplaceEmitter,
|
|
501
934
|
createToolWrappers,
|
|
@@ -507,22 +940,32 @@ export {
|
|
|
507
940
|
fetchFileAsBase64,
|
|
508
941
|
fetchFileBytes,
|
|
509
942
|
fetchWithRetry,
|
|
943
|
+
fullWarning,
|
|
510
944
|
getAppFile,
|
|
511
945
|
hmacSha256Hex,
|
|
512
946
|
installTokenPost,
|
|
513
947
|
isNotConnectedError,
|
|
514
948
|
isStagingHost,
|
|
515
949
|
listAppFiles,
|
|
950
|
+
offerUndo,
|
|
516
951
|
overlaySprigr,
|
|
517
952
|
ownerFromActorKey,
|
|
518
953
|
parseActor,
|
|
519
954
|
putAppFile,
|
|
520
955
|
putAppFileStream,
|
|
521
956
|
randomHex,
|
|
957
|
+
recreatedWarning,
|
|
958
|
+
refuseWithoutForce,
|
|
959
|
+
requireApproval,
|
|
522
960
|
resolveInstallBridge,
|
|
523
961
|
resolveOAuthBouncerBase,
|
|
524
962
|
resolveOAuthBouncerCallbackUrl,
|
|
525
963
|
resolvePlatformWebhookBase,
|
|
964
|
+
runUndoApply,
|
|
965
|
+
safeCapture,
|
|
966
|
+
seq,
|
|
967
|
+
set,
|
|
526
968
|
tool,
|
|
969
|
+
undoEnvelope,
|
|
527
970
|
withSprigrEmitFallback
|
|
528
971
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sprigr/apps-app-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
},
|
|
31
31
|
"files": [
|
|
32
32
|
"dist",
|
|
33
|
+
"bin",
|
|
33
34
|
"README.md"
|
|
34
|
-
]
|
|
35
|
+
],
|
|
36
|
+
"bin": {
|
|
37
|
+
"sprigr-check-write-protection": "./bin/check-write-protection.mjs"
|
|
38
|
+
}
|
|
35
39
|
}
|