@sprigr/apps-app-sdk 0.8.3 → 0.8.5
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 +2 -1
- package/dist/index.d.ts +74 -17
- package/dist/index.js +75 -45
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -68,11 +68,12 @@ Three platform tiers, and they are not substitutes for each other. Full guide: [
|
|
|
68
68
|
| T3 undo | handler returns `_undo`; manifest `undo.reverse_tool` | platform token | `requireApproval` (capture), `undoEnvelope`, `runUndoApply`, `@sprigr/apps-undo-journal` |
|
|
69
69
|
|
|
70
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
|
+
- `dispatcherApproval(specs, opts)`: the same gate for a one-tool-many-actions dispatcher. Build it beside the action registry and call `gate.run(action, args, env, () => def.execute(state, parsed))` from the dispatcher; params are read from `opts.inputField` (default `input`), the action name is hashed in, and `pinEnv` may return a per-actor token state rather than the env.
|
|
71
72
|
- `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
73
|
- `undoEnvelope({...})` throws on a blank field. The platform drops the whole envelope on a blank and mints nothing, silently.
|
|
73
74
|
- `refuseWithoutForce(name, handler, opts)` / `archiveOfferRefusal(...)`: a delete refuses once and offers the vendor's native archive unless `force: true`.
|
|
74
75
|
- `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
|
+
- `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; a successful restore may return `newId`, `notRestored`, `extra` (spread into the payload) and `note` (appended after the fidelity sentence).
|
|
76
77
|
- `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
78
|
- `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
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1047,7 +1047,12 @@ declare function refuseWithoutForce<E>(deleteAction: string, inner: ToolHandler<
|
|
|
1047
1047
|
|
|
1048
1048
|
/**
|
|
1049
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.
|
|
1050
|
+
* capture for the ones it can partly undo, in ONE wrapper. Two shapes over the
|
|
1051
|
+
* same core:
|
|
1052
|
+
*
|
|
1053
|
+
* requireApproval(handlers, specs, opts) flat tools, one handler per tool
|
|
1054
|
+
* dispatcherApproval(specs, opts) one tool, many actions; the
|
|
1055
|
+
* dispatcher calls gate.run(...)
|
|
1051
1056
|
*
|
|
1052
1057
|
* ## Why this exists when the manifest already declares `confirmation`
|
|
1053
1058
|
*
|
|
@@ -1078,8 +1083,8 @@ declare function refuseWithoutForce<E>(deleteAction: string, inner: ToolHandler<
|
|
|
1078
1083
|
* questions stay in the app, because only the app knows what cannot be walked
|
|
1079
1084
|
* back.
|
|
1080
1085
|
*/
|
|
1081
|
-
/** Per-tool spec: how the card reads, and (optionally) how to journal a before-image. */
|
|
1082
|
-
interface ApprovalSpec<
|
|
1086
|
+
/** Per-tool (or per-action) spec: how the card reads, and (optionally) how to journal a before-image. */
|
|
1087
|
+
interface ApprovalSpec<P = unknown> {
|
|
1083
1088
|
/**
|
|
1084
1089
|
* Arg names that may carry the target id, most specific first. ONE list, used
|
|
1085
1090
|
* for both the card label and the grant hash, so the two cannot drift apart
|
|
@@ -1091,7 +1096,7 @@ interface ApprovalSpec<E = unknown> {
|
|
|
1091
1096
|
* `connection` the resolved store / organisation / business, or '' when the
|
|
1092
1097
|
* app has one. Do not set `hash` here; the wrapper derives it from the raw
|
|
1093
1098
|
* id and the connection so it cannot move between the ask and the retry.
|
|
1094
|
-
*
|
|
1099
|
+
* Extend with `hash` below only when the arguments understate the blast
|
|
1095
1100
|
* radius.
|
|
1096
1101
|
*/
|
|
1097
1102
|
describe: (target: string, args: ToolArgs, connection: string) => Omit<AppApprovalEnvelope, 'hash'>;
|
|
@@ -1101,14 +1106,14 @@ interface ApprovalSpec<E = unknown> {
|
|
|
1101
1106
|
*/
|
|
1102
1107
|
hash?: (args: ToolArgs) => Array<string | number | undefined | null>;
|
|
1103
1108
|
/** Present only for tools whose write can be (partly) reversed. */
|
|
1104
|
-
undo?: UndoCaptureSpec<
|
|
1109
|
+
undo?: UndoCaptureSpec<P>;
|
|
1105
1110
|
}
|
|
1106
|
-
interface UndoCaptureSpec<
|
|
1111
|
+
interface UndoCaptureSpec<P = unknown> {
|
|
1107
1112
|
/** Coarse type surfaced as `_undo.resource`. */
|
|
1108
1113
|
resource: string;
|
|
1109
1114
|
fidelity: UndoFidelity;
|
|
1110
|
-
/** Read the object BEFORE the write, through the PINNED env. Null or throw on miss. */
|
|
1111
|
-
capture: (
|
|
1115
|
+
/** Read the object BEFORE the write, through the PINNED env or state. Null or throw on miss. */
|
|
1116
|
+
capture: (pinned: P, id: string, args: ToolArgs) => Promise<unknown>;
|
|
1112
1117
|
/** How the change reads in `list_undoable_changes`; the connection is appended by the wrapper. */
|
|
1113
1118
|
describe: (before: Record<string, unknown>, id: string) => string;
|
|
1114
1119
|
/** Relayed verbatim; on `'recreated'` it must say the id changes. Never empty. */
|
|
@@ -1125,7 +1130,12 @@ interface CaptureJournal {
|
|
|
1125
1130
|
ref: string;
|
|
1126
1131
|
} | null>;
|
|
1127
1132
|
}
|
|
1128
|
-
|
|
1133
|
+
/**
|
|
1134
|
+
* `E` is the env the tool receives; `P` is what the pinned connection looks
|
|
1135
|
+
* like to `capture` and `describeTarget` (the same env re-pinned for Shopify,
|
|
1136
|
+
* a fresh per-actor token state for Xero or Asana).
|
|
1137
|
+
*/
|
|
1138
|
+
interface ApprovalGateOptions<E, P = E> {
|
|
1129
1139
|
/** Log prefix, e.g. `shopify-undo`. */
|
|
1130
1140
|
scope: string;
|
|
1131
1141
|
/**
|
|
@@ -1135,14 +1145,19 @@ interface RequireApprovalOptions<E = unknown> {
|
|
|
1135
1145
|
* the arguments alone: on the retry the model usually omits it.
|
|
1136
1146
|
*/
|
|
1137
1147
|
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
1148
|
/**
|
|
1141
|
-
*
|
|
1142
|
-
*
|
|
1143
|
-
|
|
1149
|
+
* Re-pin to a resolved connection so lookups and captures hit it. Omit when
|
|
1150
|
+
* `P` is `E` and the env needs no pin (a single-connection app).
|
|
1151
|
+
*/
|
|
1152
|
+
pinEnv?: (env: E, connection: string) => P | Promise<P>;
|
|
1153
|
+
/**
|
|
1154
|
+
* Human label for a raw id, looked up THROUGH the pinned connection. Fall
|
|
1155
|
+
* back to the id on any miss; never guess, a confidently wrong card is worse
|
|
1156
|
+
* than a terse one. Omit to always show the raw id. `name` is the tool, or
|
|
1157
|
+
* for a dispatcher the action, so a lookup can route by resource type when
|
|
1158
|
+
* the id alone does not say what it is (an Asana gid).
|
|
1144
1159
|
*/
|
|
1145
|
-
describeTarget?: (
|
|
1160
|
+
describeTarget?: (pinned: P, id: string, name: string) => Promise<string>;
|
|
1146
1161
|
/**
|
|
1147
1162
|
* Where before-images go. Required when any spec declares `undo`. The
|
|
1148
1163
|
* journal's own capture returns null rather than throwing, and this wrapper
|
|
@@ -1155,8 +1170,29 @@ interface RequireApprovalOptions<E = unknown> {
|
|
|
1155
1170
|
*/
|
|
1156
1171
|
stampConnection?: (result: unknown, connection: string) => unknown;
|
|
1157
1172
|
}
|
|
1173
|
+
/** Flat-tool options: the pinned type is the env itself. */
|
|
1174
|
+
type RequireApprovalOptions<E> = ApprovalGateOptions<E, E>;
|
|
1175
|
+
interface DispatcherApprovalOptions<E, P = E> extends ApprovalGateOptions<E, P> {
|
|
1176
|
+
/**
|
|
1177
|
+
* Where the per-action params live on the dispatched args (`{ action,
|
|
1178
|
+
* input: {...} }` -> 'input'). When the field is absent the top-level args
|
|
1179
|
+
* are used, which is the flat-verb envelope. Default 'input'.
|
|
1180
|
+
*/
|
|
1181
|
+
inputField?: string;
|
|
1182
|
+
}
|
|
1183
|
+
interface DispatcherApprovalGate<E> {
|
|
1184
|
+
/** True when `action` carries an approval spec. */
|
|
1185
|
+
has(action: string): boolean;
|
|
1186
|
+
/**
|
|
1187
|
+
* Run one dispatched action through the gate. With no spec, `write` just
|
|
1188
|
+
* runs. With a spec, the ask pass returns the `_approval` card and never
|
|
1189
|
+
* calls `write`; the granted pass captures, calls `write`, and offers
|
|
1190
|
+
* `_undo` beside its result.
|
|
1191
|
+
*/
|
|
1192
|
+
run(action: string, args: ToolArgs, env: E, write: () => Promise<unknown>): Promise<unknown>;
|
|
1193
|
+
}
|
|
1158
1194
|
/**
|
|
1159
|
-
* Wrap the named tools so each asks a human first, then (if it declares
|
|
1195
|
+
* Wrap the named flat tools so each asks a human first, then (if it declares
|
|
1160
1196
|
* `undo`) journals a before-image on the granted pass and offers `_undo`.
|
|
1161
1197
|
*
|
|
1162
1198
|
* Returns only the wrapped entries. Register them AFTER the originals
|
|
@@ -1168,6 +1204,20 @@ interface RequireApprovalOptions<E = unknown> {
|
|
|
1168
1204
|
* guard exists to prevent, and it would look identical to working.
|
|
1169
1205
|
*/
|
|
1170
1206
|
declare function requireApproval<E>(handlers: Record<string, ToolHandler<E>>, specs: Record<string, ApprovalSpec<E>>, opts: RequireApprovalOptions<E>): Record<string, ToolHandler<E>>;
|
|
1207
|
+
/**
|
|
1208
|
+
* The same gate for a dispatcher tool (one tool, many actions selected by an
|
|
1209
|
+
* input field). Build it once beside the action registry and call `run` from
|
|
1210
|
+
* the dispatcher after the action is resolved and its params parsed:
|
|
1211
|
+
*
|
|
1212
|
+
* const gate = dispatcherApproval(SPECS, { scope, resolveConnection, pinEnv, journal });
|
|
1213
|
+
* ...
|
|
1214
|
+
* return gate.run(action, args, env, () => def.execute(state, parsed));
|
|
1215
|
+
*
|
|
1216
|
+
* The platform stamps `_approval_granted` on the TOP-LEVEL args; the per-action
|
|
1217
|
+
* params are read from `opts.inputField` (default `input`), or from the
|
|
1218
|
+
* top-level args when that field is absent (the flat-verb envelope).
|
|
1219
|
+
*/
|
|
1220
|
+
declare function dispatcherApproval<E, P = E>(specs: Record<string, ApprovalSpec<P>>, opts: DispatcherApprovalOptions<E, P>): DispatcherApprovalGate<E>;
|
|
1171
1221
|
|
|
1172
1222
|
/**
|
|
1173
1223
|
* The reversal entry point every adopting app declares as `undo.reverse_tool`.
|
|
@@ -1221,6 +1271,13 @@ interface RestoreResult {
|
|
|
1221
1271
|
error?: string;
|
|
1222
1272
|
notRestored?: string;
|
|
1223
1273
|
extra?: Record<string, unknown>;
|
|
1274
|
+
/**
|
|
1275
|
+
* Free-form detail appended to the runner's note (the new gid, rows it
|
|
1276
|
+
* could not refile, a sticker the vendor will not remove). The runner's own
|
|
1277
|
+
* sentence on fidelity stays first so the model reads "replacement, not an
|
|
1278
|
+
* undo" before the detail.
|
|
1279
|
+
*/
|
|
1280
|
+
note?: string;
|
|
1224
1281
|
}
|
|
1225
1282
|
interface JournalRowLike<T = unknown> {
|
|
1226
1283
|
entity: string;
|
|
@@ -1767,4 +1824,4 @@ interface ExecutionContextLike {
|
|
|
1767
1824
|
}
|
|
1768
1825
|
type HandlerFn<TArgs, TResult> = (args: TArgs, env: Record<string, unknown>, ctx?: ExecutionContextLike) => Promise<TResult>;
|
|
1769
1826
|
|
|
1770
|
-
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 RestoreResult, 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 };
|
|
1827
|
+
export { APPROVAL_GRANTED_KEY, type Actor, type AppApprovalEnvelope, type AppFileListItem, type AppFileUrlArgs, type AppFileUrlResult, type AppFilesEnv, type AppUndoEnvelope, type ApprovalGateOptions, 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 DispatcherApprovalGate, type DispatcherApprovalOptions, 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 RestoreResult, 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, dispatcherApproval, 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
|
@@ -617,57 +617,41 @@ function rawIdOf(args, keys) {
|
|
|
617
617
|
function isOk(result) {
|
|
618
618
|
return !!result && typeof result === "object" && result.ok !== false;
|
|
619
619
|
}
|
|
620
|
-
function
|
|
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) {
|
|
620
|
+
async function safeLabel(fn, pinned, id, name) {
|
|
655
621
|
try {
|
|
656
|
-
const label = await fn(
|
|
622
|
+
const label = await fn(pinned, id, name);
|
|
657
623
|
return label && label.trim() ? label : id;
|
|
658
624
|
} catch {
|
|
659
625
|
return id;
|
|
660
626
|
}
|
|
661
627
|
}
|
|
662
|
-
async function
|
|
663
|
-
|
|
628
|
+
async function pin(opts, env, connection) {
|
|
629
|
+
if (!connection || !opts.pinEnv) return env;
|
|
630
|
+
return opts.pinEnv(env, connection);
|
|
631
|
+
}
|
|
632
|
+
async function askPass(name, spec, params, env, opts, hashPrefix) {
|
|
633
|
+
const connection = await opts.resolveConnection(env, params);
|
|
634
|
+
const rawId = rawIdOf(params, spec.keys);
|
|
635
|
+
const pinned = await pin(opts, env, connection);
|
|
636
|
+
const target = rawId && opts.describeTarget ? await safeLabel(opts.describeTarget, pinned, rawId, name) : rawId || "(unknown id)";
|
|
637
|
+
const extra = spec.hash ? spec.hash(params) : [];
|
|
638
|
+
return {
|
|
639
|
+
ok: false,
|
|
640
|
+
_approval: {
|
|
641
|
+
...spec.describe(target, params, connection),
|
|
642
|
+
hash: approvalHash(...hashPrefix, rawId, connection, ...extra)
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
async function grantedPass(name, spec, params, env, opts, write) {
|
|
647
|
+
const connection = await opts.resolveConnection(env, params);
|
|
664
648
|
const stamp = (r) => connection && opts.stampConnection ? opts.stampConnection(r, connection) : r;
|
|
665
649
|
const undo = spec.undo;
|
|
666
|
-
const id = rawIdOf(
|
|
667
|
-
if (!undo || !id) return stamp(await
|
|
668
|
-
const pinned =
|
|
669
|
-
const before = await safeCapture(opts.scope, name, id, () => undo.capture(pinned, id,
|
|
670
|
-
const result = await
|
|
650
|
+
const id = rawIdOf(params, spec.keys);
|
|
651
|
+
if (!undo || !id) return stamp(await write());
|
|
652
|
+
const pinned = await pin(opts, env, connection);
|
|
653
|
+
const before = await safeCapture(opts.scope, name, id, () => undo.capture(pinned, id, params));
|
|
654
|
+
const result = await write();
|
|
671
655
|
if (!isOk(result) || !before) return stamp(result);
|
|
672
656
|
const envelope = await offerUndo({
|
|
673
657
|
journal: opts.journal(env),
|
|
@@ -683,6 +667,50 @@ async function granted(name, spec, args, env, ctx, inner, opts) {
|
|
|
683
667
|
if (!envelope) return stamp(result);
|
|
684
668
|
return { ...stamp(result), _undo: envelope };
|
|
685
669
|
}
|
|
670
|
+
function assertJournal(name, spec, opts, who) {
|
|
671
|
+
if (spec.undo && !opts.journal) {
|
|
672
|
+
throw new Error(`${who}: "${name}" declares undo but no journal was supplied.`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
function requireApproval(handlers, specs, opts) {
|
|
676
|
+
const gated = {};
|
|
677
|
+
for (const [name, spec] of Object.entries(specs)) {
|
|
678
|
+
const inner = handlers[name];
|
|
679
|
+
if (!inner) {
|
|
680
|
+
throw new Error(
|
|
681
|
+
`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.`
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
assertJournal(name, spec, opts, "requireApproval");
|
|
685
|
+
gated[name] = async (args, env, ctx) => {
|
|
686
|
+
const a = args ?? {};
|
|
687
|
+
if (a[APPROVAL_GRANTED_KEY] === true) {
|
|
688
|
+
const { [APPROVAL_GRANTED_KEY]: _granted, ...rest } = a;
|
|
689
|
+
return grantedPass(name, spec, rest, env, opts, () => inner(rest, env, ctx));
|
|
690
|
+
}
|
|
691
|
+
return askPass(name, spec, a, env, opts, []);
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
return gated;
|
|
695
|
+
}
|
|
696
|
+
function dispatcherApproval(specs, opts) {
|
|
697
|
+
for (const [name, spec] of Object.entries(specs)) assertJournal(name, spec, opts, "dispatcherApproval");
|
|
698
|
+
const field = opts.inputField ?? "input";
|
|
699
|
+
return {
|
|
700
|
+
has: (action) => Object.prototype.hasOwnProperty.call(specs, action),
|
|
701
|
+
async run(action, args, env, write) {
|
|
702
|
+
const spec = specs[action];
|
|
703
|
+
if (!spec) return write();
|
|
704
|
+
const a = args ?? {};
|
|
705
|
+
const nested = a[field];
|
|
706
|
+
const params = nested && typeof nested === "object" && !Array.isArray(nested) ? nested : a;
|
|
707
|
+
if (a[APPROVAL_GRANTED_KEY] === true) {
|
|
708
|
+
return grantedPass(action, spec, params, env, opts, write);
|
|
709
|
+
}
|
|
710
|
+
return askPass(action, spec, params, env, opts, [action]);
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
}
|
|
686
714
|
|
|
687
715
|
// src/write-protection/undo-apply.ts
|
|
688
716
|
async function runUndoApply(args, opts) {
|
|
@@ -705,7 +733,7 @@ async function runUndoApply(args, opts) {
|
|
|
705
733
|
};
|
|
706
734
|
}
|
|
707
735
|
const before = row.before;
|
|
708
|
-
if (!before || typeof before !== "object") {
|
|
736
|
+
if (!before || typeof before !== "object" || Array.isArray(before)) {
|
|
709
737
|
return { ok: false, error: "before_image_unreadable", note: "The saved copy could not be read back. Nothing was changed." };
|
|
710
738
|
}
|
|
711
739
|
let pinned;
|
|
@@ -741,6 +769,7 @@ async function runUndoApply(args, opts) {
|
|
|
741
769
|
await opts.journal.dropBefore(ref);
|
|
742
770
|
const recreated = spec.fidelity === "recreated";
|
|
743
771
|
const notRestored = result.notRestored ?? spec.notRestored;
|
|
772
|
+
const suffix = result.note && result.note.trim() ? ` ${result.note.trim()}` : "";
|
|
744
773
|
return {
|
|
745
774
|
ok: true,
|
|
746
775
|
...result.extra ?? {},
|
|
@@ -750,7 +779,7 @@ async function runUndoApply(args, opts) {
|
|
|
750
779
|
new_id: result.newId ?? null,
|
|
751
780
|
fidelity: spec.fidelity,
|
|
752
781
|
...notRestored ? { not_restored: notRestored } : {},
|
|
753
|
-
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.${notRestored ? ` Not restored: ${notRestored}.` : ""} Report this as a replacement, not as an undo
|
|
782
|
+
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.${notRestored ? ` Not restored: ${notRestored}.` : ""} Report this as a replacement, not as an undo.${suffix}` : `Restored ${spec.resource} ${row.original_id} to its saved values.${notRestored ? ` Not restored: ${notRestored}.` : ""} Anything changed since the capture was overwritten.${suffix}`
|
|
754
783
|
};
|
|
755
784
|
}
|
|
756
785
|
|
|
@@ -937,6 +966,7 @@ export {
|
|
|
937
966
|
decodeState,
|
|
938
967
|
deleteAppFile,
|
|
939
968
|
describeMissingBridge,
|
|
969
|
+
dispatcherApproval,
|
|
940
970
|
emitMarketplaceEvent,
|
|
941
971
|
encodeState,
|
|
942
972
|
fetchFileAsBase64,
|