@mercury-fw/confirm-engine 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +5 -0
- package/confirm-flow.ts +100 -0
- package/confirmation-staging.ts +52 -0
- package/confirmation-store.ts +122 -0
- package/dist/confirm-flow.d.ts +47 -0
- package/dist/confirmation-staging.d.ts +31 -0
- package/dist/confirmation-store.d.ts +54 -0
- package/dist/index.d.ts +16 -0
- package/index.ts +26 -0
- package/package.json +39 -0
package/CHANGELOG.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# @mercury-fw/confirm-engine
|
|
2
|
+
|
|
3
|
+
The confirmation mechanism of [Mercury](https://github.com/lucabro81/mercury-fw): stages an action behind a one-time token and runs it when the token comes back. The core uses it and injects it into the channels; you don't depend on it directly.
|
|
4
|
+
|
|
5
|
+
MIT
|
package/confirm-flow.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic text interception for the "confirm" half of the
|
|
3
|
+
* confirm-required flow (something stages an action and hands back a token —
|
|
4
|
+
* see `confirmation-staging.ts` for the "propose" half). `tryConfirm` is called
|
|
5
|
+
* from each channel BEFORE the model ever sees the message, same pattern as
|
|
6
|
+
* `/dump` (`tool-log.ts`) and `NO_REPLY` — running a previously-approved
|
|
7
|
+
* mutation must never depend on the model's own tool-calling judgment.
|
|
8
|
+
*
|
|
9
|
+
* It knows nothing about what the staged action is: it runs the opaque `run`
|
|
10
|
+
* thunk (see `StagedAction`) and reports the outcome. A CLI delete, a future
|
|
11
|
+
* memory purge — same path, because the doing was closed over at stage time.
|
|
12
|
+
*/
|
|
13
|
+
import type { ConfirmOutcome } from "@mercury-fw/channel-types";
|
|
14
|
+
import { isTokenShaped, type ConfirmationStore } from "./confirmation-store.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Signature of the persistent confirmation-note writer the core injects (the
|
|
18
|
+
* app's `writeConfirmationNote`, kept in `wiki/`). Declared here so this library
|
|
19
|
+
* stays free of any app import; the real writer is structurally assignable.
|
|
20
|
+
*/
|
|
21
|
+
export type WriteConfirmationNote = (
|
|
22
|
+
vaultPath: string,
|
|
23
|
+
userId: string,
|
|
24
|
+
token: string,
|
|
25
|
+
fields: { status: "pending" | "confirmed" | "failed"; requestedAt: string; resolvedAt: string | null; command: string },
|
|
26
|
+
) => Promise<void>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Returns `null` if `input` doesn't look like a bare confirmation token —
|
|
30
|
+
* the caller should proceed with its normal flow (`runTurn`, etc.).
|
|
31
|
+
* Otherwise always returns a user-facing string, resolved without ever
|
|
32
|
+
* invoking the model: an unknown/expired/wrong-session token gets a
|
|
33
|
+
* canned message, a valid one actually runs the staged action and
|
|
34
|
+
* reports the outcome. No `conferma ` keyword to type or match — the
|
|
35
|
+
* real gate was always `store.take()`'s existence/session/expiry check,
|
|
36
|
+
* not that prefix (see `isTokenShaped`'s own doc comment). A card button
|
|
37
|
+
* click on Google Chat and a bare token typed on the terminal both resolve
|
|
38
|
+
* through this exact same path.
|
|
39
|
+
*/
|
|
40
|
+
export type ConfirmDeps = {
|
|
41
|
+
store: ConfirmationStore;
|
|
42
|
+
userId: string;
|
|
43
|
+
vaultPath: string;
|
|
44
|
+
writeConfirmationNoteFn: WriteConfirmationNote;
|
|
45
|
+
now?: () => Date;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Resolves a token to a structured {@link ConfirmOutcome} (from `@mercury-fw/channel-types`), running the staged action for a match. See `tryConfirm` for the string-returning wrapper. */
|
|
49
|
+
export async function resolveConfirmation(
|
|
50
|
+
input: string,
|
|
51
|
+
sessionKey: string,
|
|
52
|
+
deps: ConfirmDeps,
|
|
53
|
+
): Promise<ConfirmOutcome> {
|
|
54
|
+
const token = input.trim();
|
|
55
|
+
if (!isTokenShaped(token)) {
|
|
56
|
+
return { status: "not-a-token" };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const staged = deps.store.take(sessionKey, token);
|
|
60
|
+
if (!staged) {
|
|
61
|
+
return { status: "not-found" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const result = await staged.run();
|
|
65
|
+
const resolvedAt = (deps.now?.() ?? new Date()).toISOString();
|
|
66
|
+
// Overwrites the same note the propose half wrote (see
|
|
67
|
+
// `confirmation-staging.ts`) so the persistent record reflects what actually
|
|
68
|
+
// happened, never stuck saying "pending" — see the stale-primer bug this
|
|
69
|
+
// guards against. Same resilience tradeoff as the propose side: a
|
|
70
|
+
// wiki-write failure must not stop the user from getting their result.
|
|
71
|
+
try {
|
|
72
|
+
await deps.writeConfirmationNoteFn(deps.vaultPath, deps.userId, token, {
|
|
73
|
+
status: result.ok ? "confirmed" : "failed",
|
|
74
|
+
requestedAt: staged.requestedAt ?? resolvedAt,
|
|
75
|
+
resolvedAt,
|
|
76
|
+
command: staged.describe,
|
|
77
|
+
});
|
|
78
|
+
} catch (err) {
|
|
79
|
+
console.error(`[confirm-flow] failed to write confirmation note: ${String(err)}`);
|
|
80
|
+
}
|
|
81
|
+
return result.ok ? { status: "ok", data: result.data } : { status: "failed", error: result.error };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function tryConfirm(
|
|
85
|
+
input: string,
|
|
86
|
+
sessionKey: string,
|
|
87
|
+
deps: ConfirmDeps,
|
|
88
|
+
): Promise<string | null> {
|
|
89
|
+
const outcome = await resolveConfirmation(input, sessionKey, deps);
|
|
90
|
+
switch (outcome.status) {
|
|
91
|
+
case "not-a-token":
|
|
92
|
+
return null;
|
|
93
|
+
case "not-found":
|
|
94
|
+
return "Nessuna conferma in sospeso per questo token — potrebbe essere scaduta, già usata, o mai esistita.";
|
|
95
|
+
case "failed":
|
|
96
|
+
return `Confermato, ma l'esecuzione è fallita: ${outcome.error}`;
|
|
97
|
+
case "ok":
|
|
98
|
+
return `Confermato ed eseguito: ${JSON.stringify(outcome.data)}`;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "stage" side of the confirm-required flow, as a core-owned closure handed
|
|
3
|
+
* to whoever needs to defer an irreversible action (today the CLI tool; a
|
|
4
|
+
* future memory-deleting plugin all the same). It mints the confirmation token,
|
|
5
|
+
* stashes the opaque action in the `ConfirmationStore`, and writes the "pending"
|
|
6
|
+
* paper-trail note — so the caller never touches the store internals or the
|
|
7
|
+
* wiki, and the confirmation subsystem stays the one place that knows an action
|
|
8
|
+
* is confirmable. The "resolve" side (running the staged thunk once the token
|
|
9
|
+
* comes back) is `tryConfirm` in `confirm-flow.ts`.
|
|
10
|
+
*
|
|
11
|
+
* Bound per session (sessionKey/userId/vaultPath) at the composition root, so
|
|
12
|
+
* the returned function takes only the action itself. The note lives outside
|
|
13
|
+
* inferred/users/<userId>/ — see `writeConfirmationNote`'s own doc comment for
|
|
14
|
+
* why — and its write is best-effort: a failure is logged, never thrown, so it
|
|
15
|
+
* can't stop the user from seeing and confirming the action.
|
|
16
|
+
*/
|
|
17
|
+
import type { ConfirmationStore } from "./confirmation-store.ts";
|
|
18
|
+
import type { StageConfirmation } from "@mercury-fw/plugin-types";
|
|
19
|
+
import type { WriteConfirmationNote } from "./confirm-flow.ts";
|
|
20
|
+
|
|
21
|
+
export type { StageConfirmation };
|
|
22
|
+
|
|
23
|
+
export function createStageConfirmation(deps: {
|
|
24
|
+
store: ConfirmationStore;
|
|
25
|
+
sessionKey: string;
|
|
26
|
+
/** Where/who the pending confirmation note is written for. */
|
|
27
|
+
userId: string;
|
|
28
|
+
vaultPath: string;
|
|
29
|
+
/** The note writer, injected by the core (the app's `writeConfirmationNote`). */
|
|
30
|
+
writeConfirmationNoteFn: WriteConfirmationNote;
|
|
31
|
+
/** Test seam; defaults to `() => new Date()`. */
|
|
32
|
+
nowFn?: () => Date;
|
|
33
|
+
}): StageConfirmation {
|
|
34
|
+
const write = deps.writeConfirmationNoteFn;
|
|
35
|
+
const nowFn = deps.nowFn ?? (() => new Date());
|
|
36
|
+
|
|
37
|
+
return async (action) => {
|
|
38
|
+
const requestedAt = nowFn().toISOString();
|
|
39
|
+
const token = deps.store.stage(deps.sessionKey, { run: action.run, describe: action.describe, requestedAt });
|
|
40
|
+
try {
|
|
41
|
+
await write(deps.vaultPath, deps.userId, token, {
|
|
42
|
+
status: "pending",
|
|
43
|
+
requestedAt,
|
|
44
|
+
resolvedAt: null,
|
|
45
|
+
command: action.describe,
|
|
46
|
+
});
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error(`[confirmation-staging] failed to write confirmation note: ${String(err)}`);
|
|
49
|
+
}
|
|
50
|
+
return token;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory staging area for an irreversible action that needs explicit
|
|
3
|
+
* confirmation before it runs. Something stages it here, and the channel gets
|
|
4
|
+
* the returned token confirmed back to it — a card button click on Google Chat,
|
|
5
|
+
* a bare token typed on the terminal — before anything actually happens (see
|
|
6
|
+
* `confirm-flow.ts`). Scoped by `sessionKey` so a token proposed to one session
|
|
7
|
+
* (terminal, or a given Google Chat space+sender) can't be confirmed by
|
|
8
|
+
* another.
|
|
9
|
+
*
|
|
10
|
+
* The action is opaque: a `run` thunk that performs it and a `describe` string
|
|
11
|
+
* for the paper trail. The store — and the whole confirmation subsystem — knows
|
|
12
|
+
* nothing about what kind of action it is: whoever stages it closes over the
|
|
13
|
+
* doing, and the core just runs the thunk when the token comes back. */
|
|
14
|
+
import type { ActionResult } from "@mercury-fw/plugin-types";
|
|
15
|
+
export type StagedAction = { run: () => Promise<ActionResult>; describe: string; requestedAt?: string };
|
|
16
|
+
|
|
17
|
+
/** A pending staging as seen from outside, deliberately WITHOUT its token or
|
|
18
|
+
* its executable thunk: a token is a confirm capability, and the HTTP read
|
|
19
|
+
* surface is unauthenticated, so listing pending confirmations must never hand
|
|
20
|
+
* out the tokens (or a way to run the action) — only the human-readable
|
|
21
|
+
* `summary` (the action's `describe`). */
|
|
22
|
+
export type PendingConfirmation = { sessionKey: string; summary: string; expiresAt: number };
|
|
23
|
+
|
|
24
|
+
export type ConfirmationStore = {
|
|
25
|
+
/** Stages `action` for `sessionKey` and returns a fresh token. */
|
|
26
|
+
stage(sessionKey: string, action: StagedAction): string;
|
|
27
|
+
/** Consumes and returns the staged action for `sessionKey`/`token`, or
|
|
28
|
+
* `null` if it doesn't exist, belongs to a different session, or has
|
|
29
|
+
* expired. Always one-shot: a successful take removes the entry. */
|
|
30
|
+
take(sessionKey: string, token: string): StagedAction | null;
|
|
31
|
+
/** The currently staged, non-expired actions, redacted of their tokens — for
|
|
32
|
+
* read-only introspection (see `PendingConfirmation`). */
|
|
33
|
+
pending(): PendingConfirmation[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Full alphanumeric — a token is only ever copy-pasted, never read or
|
|
37
|
+
// typed from memory, so legibility (avoiding 0/O/1/l/I) was never the
|
|
38
|
+
// actual point. What makes a token distinguishable from ordinary text is
|
|
39
|
+
// its shape below (two groups joined by a fixed hyphen), not a restricted
|
|
40
|
+
// character set — a restricted set doesn't help anyway: it still collides
|
|
41
|
+
// with any real short word that happens to avoid the same few excluded
|
|
42
|
+
// characters (found live: "second", used as plain conversational text,
|
|
43
|
+
// was indistinguishable from a real token under the old bare-6-char shape).
|
|
44
|
+
const TOKEN_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
45
|
+
const TOKEN_GROUP_LENGTH = 4;
|
|
46
|
+
const DEFAULT_TTL_MS = 5 * 60_000;
|
|
47
|
+
|
|
48
|
+
function randomGroup(): string {
|
|
49
|
+
const bytes = new Uint8Array(TOKEN_GROUP_LENGTH);
|
|
50
|
+
crypto.getRandomValues(bytes);
|
|
51
|
+
let group = "";
|
|
52
|
+
for (const b of bytes) {
|
|
53
|
+
group += TOKEN_ALPHABET[b % TOKEN_ALPHABET.length];
|
|
54
|
+
}
|
|
55
|
+
return group;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `<4 alphanumeric>-<4 alphanumeric>`, e.g. `k9m2-x7q4` — see `TOKEN_ALPHABET`'s own doc comment for why this shape, not a restricted character set, is what makes a token distinguishable from ordinary text. */
|
|
59
|
+
function defaultTokenFn(): string {
|
|
60
|
+
return `${randomGroup()}-${randomGroup()}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type Entry = { action: StagedAction; sessionKey: string; expiresAt: number };
|
|
64
|
+
|
|
65
|
+
export function createConfirmationStore(
|
|
66
|
+
opts: { now?: () => number; ttlMs?: number; tokenFn?: () => string } = {},
|
|
67
|
+
): ConfirmationStore {
|
|
68
|
+
const now = opts.now ?? (() => Date.now());
|
|
69
|
+
const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
70
|
+
const tokenFn = opts.tokenFn ?? defaultTokenFn;
|
|
71
|
+
const entries = new Map<string, Entry>();
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
stage(sessionKey, action) {
|
|
75
|
+
const token = tokenFn();
|
|
76
|
+
entries.set(token, { sessionKey, action, expiresAt: now() + ttlMs });
|
|
77
|
+
return token;
|
|
78
|
+
},
|
|
79
|
+
take(sessionKey, token) {
|
|
80
|
+
const entry = entries.get(token);
|
|
81
|
+
if (!entry) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
if (entry.expiresAt <= now()) {
|
|
85
|
+
entries.delete(token);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (entry.sessionKey !== sessionKey) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
entries.delete(token);
|
|
92
|
+
return entry.action;
|
|
93
|
+
},
|
|
94
|
+
pending() {
|
|
95
|
+
const t = now();
|
|
96
|
+
const out: PendingConfirmation[] = [];
|
|
97
|
+
for (const entry of entries.values()) {
|
|
98
|
+
if (entry.expiresAt <= t) continue;
|
|
99
|
+
out.push({
|
|
100
|
+
sessionKey: entry.sessionKey,
|
|
101
|
+
summary: entry.action.describe,
|
|
102
|
+
expiresAt: entry.expiresAt,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const TOKEN_SHAPE_RE = new RegExp(`^[${TOKEN_ALPHABET}]{${TOKEN_GROUP_LENGTH}}-[${TOKEN_ALPHABET}]{${TOKEN_GROUP_LENGTH}}$`);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* True if `input` (trimmed) has the exact shape of a token this store
|
|
114
|
+
* mints — same alphabet, same length. Not a security boundary itself
|
|
115
|
+
* (that's `take()`'s existence/session/expiry check) — just enough to
|
|
116
|
+
* tell apart "this looks like a confirmation attempt" from "this is an
|
|
117
|
+
* ordinary message", so a caller (`tryConfirm` in `confirm-flow.ts`) knows
|
|
118
|
+
* whether to intercept at all before ever touching the store.
|
|
119
|
+
*/
|
|
120
|
+
export function isTokenShaped(input: string): boolean {
|
|
121
|
+
return TOKEN_SHAPE_RE.test(input.trim());
|
|
122
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic text interception for the "confirm" half of the
|
|
3
|
+
* confirm-required flow (something stages an action and hands back a token —
|
|
4
|
+
* see `confirmation-staging.ts` for the "propose" half). `tryConfirm` is called
|
|
5
|
+
* from each channel BEFORE the model ever sees the message, same pattern as
|
|
6
|
+
* `/dump` (`tool-log.ts`) and `NO_REPLY` — running a previously-approved
|
|
7
|
+
* mutation must never depend on the model's own tool-calling judgment.
|
|
8
|
+
*
|
|
9
|
+
* It knows nothing about what the staged action is: it runs the opaque `run`
|
|
10
|
+
* thunk (see `StagedAction`) and reports the outcome. A CLI delete, a future
|
|
11
|
+
* memory purge — same path, because the doing was closed over at stage time.
|
|
12
|
+
*/
|
|
13
|
+
import type { ConfirmOutcome } from "@mercury-fw/channel-types";
|
|
14
|
+
import { type ConfirmationStore } from "./confirmation-store.ts";
|
|
15
|
+
/**
|
|
16
|
+
* Signature of the persistent confirmation-note writer the core injects (the
|
|
17
|
+
* app's `writeConfirmationNote`, kept in `wiki/`). Declared here so this library
|
|
18
|
+
* stays free of any app import; the real writer is structurally assignable.
|
|
19
|
+
*/
|
|
20
|
+
export type WriteConfirmationNote = (vaultPath: string, userId: string, token: string, fields: {
|
|
21
|
+
status: "pending" | "confirmed" | "failed";
|
|
22
|
+
requestedAt: string;
|
|
23
|
+
resolvedAt: string | null;
|
|
24
|
+
command: string;
|
|
25
|
+
}) => Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* Returns `null` if `input` doesn't look like a bare confirmation token —
|
|
28
|
+
* the caller should proceed with its normal flow (`runTurn`, etc.).
|
|
29
|
+
* Otherwise always returns a user-facing string, resolved without ever
|
|
30
|
+
* invoking the model: an unknown/expired/wrong-session token gets a
|
|
31
|
+
* canned message, a valid one actually runs the staged action and
|
|
32
|
+
* reports the outcome. No `conferma ` keyword to type or match — the
|
|
33
|
+
* real gate was always `store.take()`'s existence/session/expiry check,
|
|
34
|
+
* not that prefix (see `isTokenShaped`'s own doc comment). A card button
|
|
35
|
+
* click on Google Chat and a bare token typed on the terminal both resolve
|
|
36
|
+
* through this exact same path.
|
|
37
|
+
*/
|
|
38
|
+
export type ConfirmDeps = {
|
|
39
|
+
store: ConfirmationStore;
|
|
40
|
+
userId: string;
|
|
41
|
+
vaultPath: string;
|
|
42
|
+
writeConfirmationNoteFn: WriteConfirmationNote;
|
|
43
|
+
now?: () => Date;
|
|
44
|
+
};
|
|
45
|
+
/** Resolves a token to a structured {@link ConfirmOutcome} (from `@mercury-fw/channel-types`), running the staged action for a match. See `tryConfirm` for the string-returning wrapper. */
|
|
46
|
+
export declare function resolveConfirmation(input: string, sessionKey: string, deps: ConfirmDeps): Promise<ConfirmOutcome>;
|
|
47
|
+
export declare function tryConfirm(input: string, sessionKey: string, deps: ConfirmDeps): Promise<string | null>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "stage" side of the confirm-required flow, as a core-owned closure handed
|
|
3
|
+
* to whoever needs to defer an irreversible action (today the CLI tool; a
|
|
4
|
+
* future memory-deleting plugin all the same). It mints the confirmation token,
|
|
5
|
+
* stashes the opaque action in the `ConfirmationStore`, and writes the "pending"
|
|
6
|
+
* paper-trail note — so the caller never touches the store internals or the
|
|
7
|
+
* wiki, and the confirmation subsystem stays the one place that knows an action
|
|
8
|
+
* is confirmable. The "resolve" side (running the staged thunk once the token
|
|
9
|
+
* comes back) is `tryConfirm` in `confirm-flow.ts`.
|
|
10
|
+
*
|
|
11
|
+
* Bound per session (sessionKey/userId/vaultPath) at the composition root, so
|
|
12
|
+
* the returned function takes only the action itself. The note lives outside
|
|
13
|
+
* inferred/users/<userId>/ — see `writeConfirmationNote`'s own doc comment for
|
|
14
|
+
* why — and its write is best-effort: a failure is logged, never thrown, so it
|
|
15
|
+
* can't stop the user from seeing and confirming the action.
|
|
16
|
+
*/
|
|
17
|
+
import type { ConfirmationStore } from "./confirmation-store.ts";
|
|
18
|
+
import type { StageConfirmation } from "@mercury-fw/plugin-types";
|
|
19
|
+
import type { WriteConfirmationNote } from "./confirm-flow.ts";
|
|
20
|
+
export type { StageConfirmation };
|
|
21
|
+
export declare function createStageConfirmation(deps: {
|
|
22
|
+
store: ConfirmationStore;
|
|
23
|
+
sessionKey: string;
|
|
24
|
+
/** Where/who the pending confirmation note is written for. */
|
|
25
|
+
userId: string;
|
|
26
|
+
vaultPath: string;
|
|
27
|
+
/** The note writer, injected by the core (the app's `writeConfirmationNote`). */
|
|
28
|
+
writeConfirmationNoteFn: WriteConfirmationNote;
|
|
29
|
+
/** Test seam; defaults to `() => new Date()`. */
|
|
30
|
+
nowFn?: () => Date;
|
|
31
|
+
}): StageConfirmation;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory staging area for an irreversible action that needs explicit
|
|
3
|
+
* confirmation before it runs. Something stages it here, and the channel gets
|
|
4
|
+
* the returned token confirmed back to it — a card button click on Google Chat,
|
|
5
|
+
* a bare token typed on the terminal — before anything actually happens (see
|
|
6
|
+
* `confirm-flow.ts`). Scoped by `sessionKey` so a token proposed to one session
|
|
7
|
+
* (terminal, or a given Google Chat space+sender) can't be confirmed by
|
|
8
|
+
* another.
|
|
9
|
+
*
|
|
10
|
+
* The action is opaque: a `run` thunk that performs it and a `describe` string
|
|
11
|
+
* for the paper trail. The store — and the whole confirmation subsystem — knows
|
|
12
|
+
* nothing about what kind of action it is: whoever stages it closes over the
|
|
13
|
+
* doing, and the core just runs the thunk when the token comes back. */
|
|
14
|
+
import type { ActionResult } from "@mercury-fw/plugin-types";
|
|
15
|
+
export type StagedAction = {
|
|
16
|
+
run: () => Promise<ActionResult>;
|
|
17
|
+
describe: string;
|
|
18
|
+
requestedAt?: string;
|
|
19
|
+
};
|
|
20
|
+
/** A pending staging as seen from outside, deliberately WITHOUT its token or
|
|
21
|
+
* its executable thunk: a token is a confirm capability, and the HTTP read
|
|
22
|
+
* surface is unauthenticated, so listing pending confirmations must never hand
|
|
23
|
+
* out the tokens (or a way to run the action) — only the human-readable
|
|
24
|
+
* `summary` (the action's `describe`). */
|
|
25
|
+
export type PendingConfirmation = {
|
|
26
|
+
sessionKey: string;
|
|
27
|
+
summary: string;
|
|
28
|
+
expiresAt: number;
|
|
29
|
+
};
|
|
30
|
+
export type ConfirmationStore = {
|
|
31
|
+
/** Stages `action` for `sessionKey` and returns a fresh token. */
|
|
32
|
+
stage(sessionKey: string, action: StagedAction): string;
|
|
33
|
+
/** Consumes and returns the staged action for `sessionKey`/`token`, or
|
|
34
|
+
* `null` if it doesn't exist, belongs to a different session, or has
|
|
35
|
+
* expired. Always one-shot: a successful take removes the entry. */
|
|
36
|
+
take(sessionKey: string, token: string): StagedAction | null;
|
|
37
|
+
/** The currently staged, non-expired actions, redacted of their tokens — for
|
|
38
|
+
* read-only introspection (see `PendingConfirmation`). */
|
|
39
|
+
pending(): PendingConfirmation[];
|
|
40
|
+
};
|
|
41
|
+
export declare function createConfirmationStore(opts?: {
|
|
42
|
+
now?: () => number;
|
|
43
|
+
ttlMs?: number;
|
|
44
|
+
tokenFn?: () => string;
|
|
45
|
+
}): ConfirmationStore;
|
|
46
|
+
/**
|
|
47
|
+
* True if `input` (trimmed) has the exact shape of a token this store
|
|
48
|
+
* mints — same alphabet, same length. Not a security boundary itself
|
|
49
|
+
* (that's `take()`'s existence/session/expiry check) — just enough to
|
|
50
|
+
* tell apart "this looks like a confirmation attempt" from "this is an
|
|
51
|
+
* ordinary message", so a caller (`tryConfirm` in `confirm-flow.ts`) knows
|
|
52
|
+
* whether to intercept at all before ever touching the store.
|
|
53
|
+
*/
|
|
54
|
+
export declare function isTokenShaped(input: string): boolean;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The confirm-required mechanism the core owns end to end: the stateful
|
|
3
|
+
* `ConfirmationStore` (mints tokens, stashes opaque staged actions with a TTL),
|
|
4
|
+
* the "stage" half (`createStageConfirmation`, handed to whoever defers an
|
|
5
|
+
* irreversible action) and the "resolve" half (`resolveConfirmation`/`tryConfirm`,
|
|
6
|
+
* run before the model ever sees a message so a previously-approved mutation
|
|
7
|
+
* never depends on the model).
|
|
8
|
+
*
|
|
9
|
+
* The core creates the single store instance and binds the store/vault/writer,
|
|
10
|
+
* then hands channels only the injected `confirm`/`resolveConfirmation` closures
|
|
11
|
+
* (via `ChannelRuntimeContext`) — a channel never imports this library. Nothing
|
|
12
|
+
* here imports the app; the note writer is an injected `WriteConfirmationNote`.
|
|
13
|
+
*/
|
|
14
|
+
export { createConfirmationStore, isTokenShaped, type ConfirmationStore, type StagedAction, } from "./confirmation-store.ts";
|
|
15
|
+
export { resolveConfirmation, tryConfirm, type ConfirmDeps, type WriteConfirmationNote, } from "./confirm-flow.ts";
|
|
16
|
+
export { createStageConfirmation, type StageConfirmation } from "./confirmation-staging.ts";
|
package/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The confirm-required mechanism the core owns end to end: the stateful
|
|
3
|
+
* `ConfirmationStore` (mints tokens, stashes opaque staged actions with a TTL),
|
|
4
|
+
* the "stage" half (`createStageConfirmation`, handed to whoever defers an
|
|
5
|
+
* irreversible action) and the "resolve" half (`resolveConfirmation`/`tryConfirm`,
|
|
6
|
+
* run before the model ever sees a message so a previously-approved mutation
|
|
7
|
+
* never depends on the model).
|
|
8
|
+
*
|
|
9
|
+
* The core creates the single store instance and binds the store/vault/writer,
|
|
10
|
+
* then hands channels only the injected `confirm`/`resolveConfirmation` closures
|
|
11
|
+
* (via `ChannelRuntimeContext`) — a channel never imports this library. Nothing
|
|
12
|
+
* here imports the app; the note writer is an injected `WriteConfirmationNote`.
|
|
13
|
+
*/
|
|
14
|
+
export {
|
|
15
|
+
createConfirmationStore,
|
|
16
|
+
isTokenShaped,
|
|
17
|
+
type ConfirmationStore,
|
|
18
|
+
type StagedAction,
|
|
19
|
+
} from "./confirmation-store.ts";
|
|
20
|
+
export {
|
|
21
|
+
resolveConfirmation,
|
|
22
|
+
tryConfirm,
|
|
23
|
+
type ConfirmDeps,
|
|
24
|
+
type WriteConfirmationNote,
|
|
25
|
+
} from "./confirm-flow.ts";
|
|
26
|
+
export { createStageConfirmation, type StageConfirmation } from "./confirmation-staging.ts";
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mercury-fw/confirm-engine",
|
|
3
|
+
"version": "0.25.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/lucabro81/mercury-fw.git",
|
|
8
|
+
"directory": "packages/libs/confirm-engine"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"*.ts",
|
|
12
|
+
"dist",
|
|
13
|
+
"CHANGELOG.md",
|
|
14
|
+
"!**/*.test.ts"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"mercury-fw-source": "./index.ts",
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"default": "./index.ts"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "bun test",
|
|
28
|
+
"typecheck": "tsc --noEmit"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@mercury-fw/channel-types": "0.25.0",
|
|
32
|
+
"@mercury-fw/plugin-types": "0.25.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@mercury-fw/typescript-config": "*",
|
|
36
|
+
"@types/bun": "^1.4.0",
|
|
37
|
+
"typescript": "^6.0.3"
|
|
38
|
+
}
|
|
39
|
+
}
|