@blamejs/core 0.11.23 → 0.11.25
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 +4 -0
- package/index.js +8 -0
- package/lib/auth/bot-challenge.js +573 -0
- package/lib/framework-error.js +6 -0
- package/lib/fsm.js +469 -0
- package/lib/guard-mail-query.js +14 -0
- package/lib/mail-agent.js +24 -10
- package/lib/mail-send-deliver.js +629 -0
- package/lib/mail-store-fts.js +394 -0
- package/lib/mail-store.js +142 -4
- package/lib/money.js +699 -0
- package/lib/webhook.js +229 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/fsm.js
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.fsm
|
|
4
|
+
* @nav Agent
|
|
5
|
+
* @title FSM
|
|
6
|
+
* @order 76
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Auditable in-process finite-state machine. Declare states +
|
|
10
|
+
* transitions at construction time; guards + on-enter / on-exit
|
|
11
|
+
* side-effects fire on every transition; every transition lands
|
|
12
|
+
* in the audit chain.
|
|
13
|
+
*
|
|
14
|
+
* `b.fsm` is the lighter sibling of `b.agent.saga`. Saga handles
|
|
15
|
+
* distributed multi-step transactions with compensation across
|
|
16
|
+
* process / network boundaries (composes outbox + idempotency +
|
|
17
|
+
* persisted state). `b.fsm` handles in-process state lifecycles —
|
|
18
|
+
* order placed → paid → shipped → delivered, subscription
|
|
19
|
+
* trialing → active → past-due → canceled, refund requested →
|
|
20
|
+
* approved → processed → settled. They're complementary; reach
|
|
21
|
+
* for fsm when the lifecycle lives inside one process and saga
|
|
22
|
+
* when it spans multiple steps that each need their own
|
|
23
|
+
* compensation.
|
|
24
|
+
*
|
|
25
|
+
* ```js
|
|
26
|
+
* var orderFsm = b.fsm.define({
|
|
27
|
+
* name: "order",
|
|
28
|
+
* initial: "placed",
|
|
29
|
+
* states: {
|
|
30
|
+
* placed: {},
|
|
31
|
+
* paid: { onEnter: function (ctx) { ctx.paidAt = Date.now(); } },
|
|
32
|
+
* shipped: {},
|
|
33
|
+
* delivered: {},
|
|
34
|
+
* canceled: {},
|
|
35
|
+
* },
|
|
36
|
+
* transitions: [
|
|
37
|
+
* { from: "placed", to: "paid", on: "pay" },
|
|
38
|
+
* { from: "paid", to: "shipped", on: "ship",
|
|
39
|
+
* guard: function (ctx) { return ctx.address != null; } },
|
|
40
|
+
* { from: "shipped", to: "delivered", on: "deliver" },
|
|
41
|
+
* { from: "placed", to: "canceled", on: "cancel" },
|
|
42
|
+
* { from: "paid", to: "canceled", on: "cancel" },
|
|
43
|
+
* ],
|
|
44
|
+
* });
|
|
45
|
+
* var order = orderFsm.create({ initialContext: { address: "..." } });
|
|
46
|
+
* await order.transition("pay");
|
|
47
|
+
* await order.transition("ship");
|
|
48
|
+
* order.state; // → "shipped"
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* ## Scope
|
|
52
|
+
*
|
|
53
|
+
* v1 ships the flat statechart variant — every state lives at the
|
|
54
|
+
* same level. Hierarchical (nested) states, parallel regions, and
|
|
55
|
+
* history pseudo-states are deferred-with-condition: re-open when
|
|
56
|
+
* an operator surfaces a lifecycle that the flat-variant
|
|
57
|
+
* workaround (compose multiple FSMs) can't express. References:
|
|
58
|
+
* Harel statecharts (1987); UML State Machine (OMG UML 2.5.1 §14);
|
|
59
|
+
* ISO/IEC 19505 (UML).
|
|
60
|
+
*
|
|
61
|
+
* ## Transition discipline
|
|
62
|
+
*
|
|
63
|
+
* * Guards are pure predicates — no side effects. A guard that
|
|
64
|
+
* returns false refuses the transition with `fsm/guard-refused`.
|
|
65
|
+
* * `onExit` on the current state runs before `onEnter` on the
|
|
66
|
+
* next state. Both may be sync or return a Promise; the
|
|
67
|
+
* primitive awaits the promise before returning from
|
|
68
|
+
* `.transition()`.
|
|
69
|
+
* * Concurrent `.transition()` calls serialize through an
|
|
70
|
+
* in-instance lock — `transition()` returns a Promise that
|
|
71
|
+
* other concurrent calls await before they start.
|
|
72
|
+
* * Every transition emits `fsm.<machineName>.transition` via
|
|
73
|
+
* `audit.safeEmit` (drop-silent — operator audit-sink failures
|
|
74
|
+
* don't crash the caller).
|
|
75
|
+
*
|
|
76
|
+
* ## Serialization
|
|
77
|
+
*
|
|
78
|
+
* `.toJSON()` returns `{ state, history, context }`. The factory
|
|
79
|
+
* returned from `define()` exposes `.restore(snapshot)` which
|
|
80
|
+
* rebuilds an Instance with the captured state + history +
|
|
81
|
+
* context. The Machine definition is NOT in the snapshot — the
|
|
82
|
+
* operator pairs the snapshot with the same definition they used
|
|
83
|
+
* to create it. This avoids snapshot-rollover-on-definition-edit
|
|
84
|
+
* ambiguity.
|
|
85
|
+
*
|
|
86
|
+
* @card
|
|
87
|
+
* Auditable in-process FSM with guards, on-enter / on-exit
|
|
88
|
+
* side-effects, concurrent-transition serialization, and
|
|
89
|
+
* toJSON / restore round-trip. Lighter sibling of b.agent.saga.
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
var lazyRequire = require("./lazy-require");
|
|
93
|
+
var { defineClass } = require("./framework-error");
|
|
94
|
+
var safeSql = require("./safe-sql");
|
|
95
|
+
|
|
96
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
97
|
+
|
|
98
|
+
var FsmError = defineClass("FsmError", { alwaysPermanent: true });
|
|
99
|
+
|
|
100
|
+
// Identifier-shape only. State + transition names are emitted in
|
|
101
|
+
// audit metadata and could end up in operator-side SQL / dashboards;
|
|
102
|
+
// refuse arbitrary strings at define-time so injection-shaped names
|
|
103
|
+
// (`order; DROP TABLE`, `<script>`, control bytes) can never reach
|
|
104
|
+
// downstream sinks. Routes through safeSql.DEFAULT_IDENTIFIER_RE +
|
|
105
|
+
// MAX_IDENTIFIER_LENGTH so the framework-canonical identifier shape
|
|
106
|
+
// is the only declared identifier shape across primitives.
|
|
107
|
+
var IDENT_RE = safeSql.DEFAULT_IDENTIFIER_RE;
|
|
108
|
+
var IDENT_MAX_LEN = safeSql.MAX_IDENTIFIER_LENGTH;
|
|
109
|
+
|
|
110
|
+
function _assertIdent(value, label) {
|
|
111
|
+
if (typeof value !== "string" || value.length === 0 ||
|
|
112
|
+
value.length > IDENT_MAX_LEN || !IDENT_RE.test(value)) {
|
|
113
|
+
throw new FsmError("fsm/bad-name",
|
|
114
|
+
label + " must match " + IDENT_RE +
|
|
115
|
+
" and be <= " + IDENT_MAX_LEN + " chars (got " +
|
|
116
|
+
JSON.stringify(value) + ")");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @primitive b.fsm.define
|
|
122
|
+
* @signature b.fsm.define(definition)
|
|
123
|
+
* @since 0.11.25
|
|
124
|
+
* @status stable
|
|
125
|
+
* @related b.agent.saga.create
|
|
126
|
+
*
|
|
127
|
+
* Compile a machine definition. Returns a frozen factory exposing
|
|
128
|
+
* `create({ initialContext? })` to instantiate new machines and
|
|
129
|
+
* `restore(snapshot)` to rebuild from a `.toJSON()` output.
|
|
130
|
+
*
|
|
131
|
+
* Throws `FsmError` on any malformed definition: missing name /
|
|
132
|
+
* initial / states / transitions, state name or transition name
|
|
133
|
+
* that isn't identifier-shape, transition referencing an unknown
|
|
134
|
+
* `from` / `to` state, duplicate (from, on) pair, or initial state
|
|
135
|
+
* not declared in `states`.
|
|
136
|
+
*
|
|
137
|
+
* @opts
|
|
138
|
+
* name: string, // required (identifier-shape)
|
|
139
|
+
* initial: string, // required; must be a key of states
|
|
140
|
+
* states: object, // { <name>: { onEnter?, onExit? }, ... }
|
|
141
|
+
* transitions: Array<{ from, to, on, guard? }>,
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* var fsm = b.fsm.define({
|
|
145
|
+
* name: "door", initial: "closed",
|
|
146
|
+
* states: { closed: {}, open: {} },
|
|
147
|
+
* transitions: [
|
|
148
|
+
* { from: "closed", to: "open", on: "open" },
|
|
149
|
+
* { from: "open", to: "closed", on: "close" },
|
|
150
|
+
* ],
|
|
151
|
+
* });
|
|
152
|
+
* var door = fsm.create();
|
|
153
|
+
* await door.transition("open");
|
|
154
|
+
*/
|
|
155
|
+
function define(definition) {
|
|
156
|
+
if (!definition || typeof definition !== "object") {
|
|
157
|
+
throw new FsmError("fsm/bad-input", "define: definition must be an object");
|
|
158
|
+
}
|
|
159
|
+
_assertIdent(definition.name, "define: definition.name");
|
|
160
|
+
if (typeof definition.initial !== "string") {
|
|
161
|
+
throw new FsmError("fsm/bad-input", "define: definition.initial required");
|
|
162
|
+
}
|
|
163
|
+
if (!definition.states || typeof definition.states !== "object") {
|
|
164
|
+
throw new FsmError("fsm/bad-input", "define: definition.states must be an object");
|
|
165
|
+
}
|
|
166
|
+
if (!Array.isArray(definition.transitions)) {
|
|
167
|
+
throw new FsmError("fsm/bad-input", "define: definition.transitions must be an array");
|
|
168
|
+
}
|
|
169
|
+
var stateNames = Object.keys(definition.states);
|
|
170
|
+
if (stateNames.length === 0) {
|
|
171
|
+
throw new FsmError("fsm/bad-input", "define: at least one state required");
|
|
172
|
+
}
|
|
173
|
+
for (var si = 0; si < stateNames.length; si++) {
|
|
174
|
+
_assertIdent(stateNames[si], "define: state name");
|
|
175
|
+
var sBody = definition.states[stateNames[si]];
|
|
176
|
+
if (sBody && typeof sBody === "object") {
|
|
177
|
+
if (sBody.onEnter !== undefined && typeof sBody.onEnter !== "function") {
|
|
178
|
+
throw new FsmError("fsm/bad-input",
|
|
179
|
+
"define: states." + stateNames[si] + ".onEnter must be a function");
|
|
180
|
+
}
|
|
181
|
+
if (sBody.onExit !== undefined && typeof sBody.onExit !== "function") {
|
|
182
|
+
throw new FsmError("fsm/bad-input",
|
|
183
|
+
"define: states." + stateNames[si] + ".onExit must be a function");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (!Object.prototype.hasOwnProperty.call(definition.states, definition.initial)) {
|
|
188
|
+
throw new FsmError("fsm/bad-input",
|
|
189
|
+
"define: initial state '" + definition.initial + "' not declared in states");
|
|
190
|
+
}
|
|
191
|
+
if (definition.transitions.length === 0) {
|
|
192
|
+
throw new FsmError("fsm/bad-input", "define: at least one transition required");
|
|
193
|
+
}
|
|
194
|
+
// (from, on) pair must be unique. Walking the array twice is cheap;
|
|
195
|
+
// the alternative (silently take the last-declared) hides a
|
|
196
|
+
// definition bug that would manifest at runtime as the "wrong"
|
|
197
|
+
// transition firing.
|
|
198
|
+
var seenPairs = Object.create(null);
|
|
199
|
+
var transitionsByName = Object.create(null);
|
|
200
|
+
for (var ti = 0; ti < definition.transitions.length; ti++) {
|
|
201
|
+
var t = definition.transitions[ti];
|
|
202
|
+
if (!t || typeof t !== "object") {
|
|
203
|
+
throw new FsmError("fsm/bad-input",
|
|
204
|
+
"define: transitions[" + ti + "] must be an object");
|
|
205
|
+
}
|
|
206
|
+
_assertIdent(t.from, "define: transitions[" + ti + "].from");
|
|
207
|
+
_assertIdent(t.to, "define: transitions[" + ti + "].to");
|
|
208
|
+
_assertIdent(t.on, "define: transitions[" + ti + "].on");
|
|
209
|
+
if (!Object.prototype.hasOwnProperty.call(definition.states, t.from)) {
|
|
210
|
+
throw new FsmError("fsm/bad-input",
|
|
211
|
+
"define: transition '" + t.on + "' references unknown from-state '" + t.from + "'");
|
|
212
|
+
}
|
|
213
|
+
if (!Object.prototype.hasOwnProperty.call(definition.states, t.to)) {
|
|
214
|
+
throw new FsmError("fsm/bad-input",
|
|
215
|
+
"define: transition '" + t.on + "' references unknown to-state '" + t.to + "'");
|
|
216
|
+
}
|
|
217
|
+
if (t.guard !== undefined && typeof t.guard !== "function") {
|
|
218
|
+
throw new FsmError("fsm/bad-input",
|
|
219
|
+
"define: transition '" + t.on + "'.guard must be a function");
|
|
220
|
+
}
|
|
221
|
+
var pairKey = t.from + "→" + t.on;
|
|
222
|
+
if (seenPairs[pairKey]) {
|
|
223
|
+
throw new FsmError("fsm/bad-input",
|
|
224
|
+
"define: duplicate transition (from='" + t.from + "', on='" + t.on + "')");
|
|
225
|
+
}
|
|
226
|
+
seenPairs[pairKey] = true;
|
|
227
|
+
if (!transitionsByName[t.on]) transitionsByName[t.on] = [];
|
|
228
|
+
transitionsByName[t.on].push({ from: t.from, to: t.to, guard: t.guard || null });
|
|
229
|
+
}
|
|
230
|
+
// Deep-clone the caller-provided `states` + `transitions` objects
|
|
231
|
+
// before freezing. The shallow freeze on the outer object alone
|
|
232
|
+
// leaves the inner references mutable by the caller, so a post-
|
|
233
|
+
// define mutation of `definition.states.foo.onEnter` would silently
|
|
234
|
+
// change runtime behaviour across every Instance the factory built.
|
|
235
|
+
// Clone-then-freeze cuts the reference link.
|
|
236
|
+
var clonedStates = Object.create(null);
|
|
237
|
+
var stateKeys = Object.keys(definition.states);
|
|
238
|
+
for (var sk = 0; sk < stateKeys.length; sk += 1) {
|
|
239
|
+
var sName = stateKeys[sk];
|
|
240
|
+
var sClonedBody = definition.states[sName] || {};
|
|
241
|
+
clonedStates[sName] = Object.freeze({
|
|
242
|
+
onEnter: sClonedBody.onEnter || null,
|
|
243
|
+
onExit: sClonedBody.onExit || null,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
Object.freeze(clonedStates);
|
|
247
|
+
var clonedTransitions = definition.transitions.map(function (t) {
|
|
248
|
+
return Object.freeze({
|
|
249
|
+
from: t.from,
|
|
250
|
+
to: t.to,
|
|
251
|
+
on: t.on,
|
|
252
|
+
guard: t.guard || null,
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
Object.freeze(clonedTransitions);
|
|
256
|
+
var frozenDef = Object.freeze({
|
|
257
|
+
name: definition.name,
|
|
258
|
+
initial: definition.initial,
|
|
259
|
+
states: clonedStates,
|
|
260
|
+
transitions: clonedTransitions,
|
|
261
|
+
_byName: transitionsByName,
|
|
262
|
+
});
|
|
263
|
+
var factory = {
|
|
264
|
+
name: definition.name,
|
|
265
|
+
define: frozenDef,
|
|
266
|
+
create: function (opts) { return _createInstance(frozenDef, opts || {}); },
|
|
267
|
+
restore: function (snapshot) { return _restoreInstance(frozenDef, snapshot); },
|
|
268
|
+
FsmError: FsmError,
|
|
269
|
+
};
|
|
270
|
+
return Object.freeze(factory);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function _createInstance(def, opts) {
|
|
274
|
+
var initialContext = (opts.initialContext && typeof opts.initialContext === "object")
|
|
275
|
+
? Object.assign({}, opts.initialContext)
|
|
276
|
+
: {};
|
|
277
|
+
return _buildInstance(def, def.initial, [], initialContext);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function _restoreInstance(def, snapshot) {
|
|
281
|
+
if (!snapshot || typeof snapshot !== "object") {
|
|
282
|
+
throw new FsmError("fsm/bad-input", "restore: snapshot must be an object");
|
|
283
|
+
}
|
|
284
|
+
if (typeof snapshot.state !== "string" ||
|
|
285
|
+
!Object.prototype.hasOwnProperty.call(def.states, snapshot.state)) {
|
|
286
|
+
throw new FsmError("fsm/bad-input",
|
|
287
|
+
"restore: snapshot.state '" + snapshot.state + "' not declared in machine '" + def.name + "'");
|
|
288
|
+
}
|
|
289
|
+
var hist = Array.isArray(snapshot.history) ? snapshot.history.slice() : [];
|
|
290
|
+
var ctx = (snapshot.context && typeof snapshot.context === "object")
|
|
291
|
+
? Object.assign({}, snapshot.context)
|
|
292
|
+
: {};
|
|
293
|
+
return _buildInstance(def, snapshot.state, hist, ctx);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function _buildInstance(def, initialState, initialHistory, initialContext) {
|
|
297
|
+
// Concurrent .transition() calls chain off this promise. Every
|
|
298
|
+
// transition replaces _lock with its own resolution promise so
|
|
299
|
+
// subsequent calls await the in-flight transition before starting.
|
|
300
|
+
// This is the in-memory equivalent of "single-flight" — operators
|
|
301
|
+
// who need cross-process serialization use b.agent.idempotency or
|
|
302
|
+
// b.agent.orchestrator's leader-elected singleton.
|
|
303
|
+
var instance = {
|
|
304
|
+
state: initialState,
|
|
305
|
+
context: initialContext,
|
|
306
|
+
history: initialHistory,
|
|
307
|
+
_lock: Promise.resolve(),
|
|
308
|
+
_def: def,
|
|
309
|
+
};
|
|
310
|
+
instance.allowed = function () { return _allowed(instance); };
|
|
311
|
+
instance.can = function (name) { return _can(instance, name); };
|
|
312
|
+
instance.transition = function (name, opts) { return _enqueueTransition(instance, name, opts || {}); };
|
|
313
|
+
instance.toJSON = function () { return _toJSON(instance); };
|
|
314
|
+
return instance;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function _allowed(instance) {
|
|
318
|
+
var byName = instance._def._byName;
|
|
319
|
+
var out = [];
|
|
320
|
+
var seen = Object.create(null);
|
|
321
|
+
var keys = Object.keys(byName);
|
|
322
|
+
for (var i = 0; i < keys.length; i++) {
|
|
323
|
+
var defs = byName[keys[i]];
|
|
324
|
+
for (var j = 0; j < defs.length; j++) {
|
|
325
|
+
if (defs[j].from === instance.state && !seen[keys[i]]) {
|
|
326
|
+
out.push(keys[i]);
|
|
327
|
+
seen[keys[i]] = true;
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function _can(instance, name) {
|
|
336
|
+
if (typeof name !== "string") return false;
|
|
337
|
+
var defs = instance._def._byName[name];
|
|
338
|
+
if (!defs) return false;
|
|
339
|
+
for (var i = 0; i < defs.length; i++) {
|
|
340
|
+
var t = defs[i];
|
|
341
|
+
if (t.from !== instance.state) continue;
|
|
342
|
+
if (t.guard) {
|
|
343
|
+
var verdict;
|
|
344
|
+
try { verdict = t.guard(instance.context); }
|
|
345
|
+
catch (_e) { return false; }
|
|
346
|
+
if (verdict !== true) return false;
|
|
347
|
+
}
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function _enqueueTransition(instance, name, opts) {
|
|
354
|
+
// Chain the new transition onto the existing lock — concurrent
|
|
355
|
+
// calls serialize. Errors from the previous transition do not
|
|
356
|
+
// prevent the next from running (operator semantics: a failed
|
|
357
|
+
// transition leaves state unchanged; the next operator-requested
|
|
358
|
+
// transition still gets to try).
|
|
359
|
+
var next = instance._lock.then(
|
|
360
|
+
function () { return _runTransition(instance, name, opts); },
|
|
361
|
+
function () { return _runTransition(instance, name, opts); }
|
|
362
|
+
);
|
|
363
|
+
// The lock tracks completion regardless of outcome so the NEXT
|
|
364
|
+
// caller waits, but bury the rejection on the lock itself so an
|
|
365
|
+
// unhandled-rejection warning doesn't fire on a stray transition
|
|
366
|
+
// that nobody else awaits.
|
|
367
|
+
instance._lock = next.then(function () {}, function () {});
|
|
368
|
+
return next;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function _runTransition(instance, name, opts) {
|
|
372
|
+
if (typeof name !== "string") {
|
|
373
|
+
throw new FsmError("fsm/bad-input", "transition: name must be a string");
|
|
374
|
+
}
|
|
375
|
+
var defs = instance._def._byName[name];
|
|
376
|
+
if (!defs) {
|
|
377
|
+
throw new FsmError("fsm/illegal-transition",
|
|
378
|
+
"transition: '" + name + "' is not declared in machine '" + instance._def.name + "'");
|
|
379
|
+
}
|
|
380
|
+
// Find a matching transition for the current state.
|
|
381
|
+
var matched = null;
|
|
382
|
+
for (var i = 0; i < defs.length; i++) {
|
|
383
|
+
if (defs[i].from === instance.state) { matched = defs[i]; break; }
|
|
384
|
+
}
|
|
385
|
+
if (!matched) {
|
|
386
|
+
throw new FsmError("fsm/illegal-transition",
|
|
387
|
+
"transition: '" + name + "' not allowed from state '" + instance.state +
|
|
388
|
+
"' (machine '" + instance._def.name + "')");
|
|
389
|
+
}
|
|
390
|
+
if (matched.guard) {
|
|
391
|
+
var verdict;
|
|
392
|
+
try { verdict = matched.guard(instance.context); }
|
|
393
|
+
catch (guardErr) {
|
|
394
|
+
throw new FsmError("fsm/guard-threw",
|
|
395
|
+
"transition: '" + name + "' guard threw: " +
|
|
396
|
+
((guardErr && guardErr.message) || String(guardErr)));
|
|
397
|
+
}
|
|
398
|
+
if (verdict !== true) {
|
|
399
|
+
throw new FsmError("fsm/guard-refused",
|
|
400
|
+
"transition: '" + name + "' refused by guard (from state '" +
|
|
401
|
+
instance.state + "')");
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
var fromState = instance.state;
|
|
405
|
+
var toState = matched.to;
|
|
406
|
+
var fromBody = instance._def.states[fromState];
|
|
407
|
+
var toBody = instance._def.states[toState];
|
|
408
|
+
// onExit runs FIRST so the operator's exit cleanup completes
|
|
409
|
+
// before the new state's onEnter starts. A throw from either side-
|
|
410
|
+
// effect propagates to the caller; the state still advances after
|
|
411
|
+
// onExit (because onExit committed the cleanup the operator
|
|
412
|
+
// intended) but onEnter throws leave the state in the new value
|
|
413
|
+
// with an exception surfaced — operators wrap onEnter in their own
|
|
414
|
+
// try/catch when they want to roll back to the prior state.
|
|
415
|
+
if (fromBody && typeof fromBody.onExit === "function") {
|
|
416
|
+
var exitResult = fromBody.onExit(instance.context);
|
|
417
|
+
if (exitResult && typeof exitResult.then === "function") {
|
|
418
|
+
await exitResult;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
instance.state = toState;
|
|
422
|
+
var historyEntry = {
|
|
423
|
+
from: fromState,
|
|
424
|
+
to: toState,
|
|
425
|
+
on: name,
|
|
426
|
+
at: Date.now(),
|
|
427
|
+
};
|
|
428
|
+
if (opts.actor != null) historyEntry.actor = opts.actor;
|
|
429
|
+
if (opts.metadata != null) historyEntry.metadata = opts.metadata;
|
|
430
|
+
instance.history.push(historyEntry);
|
|
431
|
+
if (toBody && typeof toBody.onEnter === "function") {
|
|
432
|
+
var enterResult = toBody.onEnter(instance.context);
|
|
433
|
+
if (enterResult && typeof enterResult.then === "function") {
|
|
434
|
+
await enterResult;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// Audit emission is drop-silent — operator audit-sink failures
|
|
438
|
+
// never crash the caller. The .safeEmit wrapper is itself
|
|
439
|
+
// drop-silent; the additional try/catch protects against the
|
|
440
|
+
// lazy-loaded audit module throwing at first-access time.
|
|
441
|
+
try {
|
|
442
|
+
audit().safeEmit({
|
|
443
|
+
action: "fsm." + instance._def.name + ".transition",
|
|
444
|
+
actor: opts.actor ? { id: opts.actor } : { id: "<system>" },
|
|
445
|
+
outcome: "success",
|
|
446
|
+
metadata: {
|
|
447
|
+
from: fromState,
|
|
448
|
+
to: toState,
|
|
449
|
+
transition: name,
|
|
450
|
+
machine: instance._def.name,
|
|
451
|
+
callerMeta: opts.metadata || null,
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
} catch (_e) { /* drop-silent — audit best-effort */ }
|
|
455
|
+
return { from: fromState, to: toState, on: name };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function _toJSON(instance) {
|
|
459
|
+
return {
|
|
460
|
+
state: instance.state,
|
|
461
|
+
history: instance.history.slice(),
|
|
462
|
+
context: Object.assign({}, instance.context),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
module.exports = {
|
|
467
|
+
define: define,
|
|
468
|
+
FsmError: FsmError,
|
|
469
|
+
};
|
package/lib/guard-mail-query.js
CHANGED
|
@@ -61,6 +61,20 @@ var FILTERABLE_COLUMNS = Object.freeze({
|
|
|
61
61
|
from_addr: { kind: "sealed", ops: ["eq", "in"] },
|
|
62
62
|
subject: { kind: "sealed", ops: ["eq"] },
|
|
63
63
|
flag: { kind: "join", ops: ["eq", "in"] },
|
|
64
|
+
// v0.11.25 — sealed-token FTS filter keys. These accept a literal
|
|
65
|
+
// string value; the agent layer hands them to `b.mailStore.search`
|
|
66
|
+
// which tokenizes + vault-salt-hashes them before issuing the FTS5
|
|
67
|
+
// MATCH. Bounded by `maxStringBytes` via `_checkScalar` so a single
|
|
68
|
+
// term cannot carry a tokenizer-bomb shape.
|
|
69
|
+
text: { kind: "fts", ops: ["eq"] },
|
|
70
|
+
body: { kind: "fts", ops: ["eq"] },
|
|
71
|
+
from: { kind: "fts", ops: ["eq"] },
|
|
72
|
+
to: { kind: "fts", ops: ["eq"] },
|
|
73
|
+
// Modseq + limit shortcuts so callers can pass `{ sinceModseq, limit,
|
|
74
|
+
// text }` directly instead of the verbose `{ and:[{modseq:{gt}}, ...] }`
|
|
75
|
+
// shape.
|
|
76
|
+
sinceModseq: { kind: "plaintext", ops: ["eq"] },
|
|
77
|
+
limit: { kind: "plaintext", ops: ["eq"] },
|
|
64
78
|
});
|
|
65
79
|
|
|
66
80
|
var ALLOWED_OPS = Object.freeze({
|
package/lib/mail-agent.js
CHANGED
|
@@ -379,16 +379,30 @@ async function _search(ctx, args) {
|
|
|
379
379
|
_entry(ctx, "search", args);
|
|
380
380
|
guardMailQuery.validate(args.filter || {}, { profile: _profileFor(ctx), posture: ctx.posture, project: args.project });
|
|
381
381
|
var folder = args.folder || "INBOX";
|
|
382
|
-
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
var
|
|
389
|
-
var
|
|
390
|
-
|
|
391
|
-
|
|
382
|
+
var filter = args.filter || {};
|
|
383
|
+
// Compose the sealed-token FTS index via the store's `search` method.
|
|
384
|
+
// The store honours full-text filters (text / subject / body / from /
|
|
385
|
+
// to) AND the modseq cursor symmetrically; when no text-side filter
|
|
386
|
+
// is present it falls through to the bare modseq scan so pre-FTS
|
|
387
|
+
// callers see no behaviour change.
|
|
388
|
+
var sinceModseq = (filter.modseq && filter.modseq.gt) || filter.sinceModseq || 0;
|
|
389
|
+
var limit = args.limit || filter.limit || 100;
|
|
390
|
+
var storeFilter = {
|
|
391
|
+
sinceModseq: sinceModseq,
|
|
392
|
+
limit: limit,
|
|
393
|
+
text: filter.text,
|
|
394
|
+
subject: filter.subject,
|
|
395
|
+
body: filter.body,
|
|
396
|
+
from: filter.from,
|
|
397
|
+
to: filter.to,
|
|
398
|
+
};
|
|
399
|
+
var result = ctx.store.search(folder, storeFilter);
|
|
400
|
+
ctx.auditEmit("mail.agent.search.success", args.actor, {
|
|
401
|
+
folder: folder,
|
|
402
|
+
rowCount: result.rows.length,
|
|
403
|
+
hasText: Boolean(filter.text || filter.subject || filter.body || filter.from || filter.to),
|
|
404
|
+
});
|
|
405
|
+
return { rows: result.rows, nextModseq: result.nextModseq };
|
|
392
406
|
}
|
|
393
407
|
|
|
394
408
|
async function _fetch(ctx, args) {
|