@blamejs/core 0.11.24 → 0.11.26

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.
@@ -473,6 +473,11 @@ var DlpError = defineClass("DlpError", { alwaysPermane
473
473
  // b.authBotChallenge when the operator-supplied challengeFn is
474
474
  // missing, returns a non-boolean verdict, or throws. Permanent.
475
475
  var AuthBotChallengeError = defineClass("AuthBotChallengeError", { alwaysPermanent: true });
476
+ // BotChallengeError — verifier-side errors raised by b.auth.botChallenge
477
+ // (Cloudflare Turnstile / hCaptcha / reCAPTCHA-v3 token siteverify):
478
+ // invalid token shape, timeout, hostname / action allowlist mismatch,
479
+ // provider reported success=false, malformed response body. Permanent.
480
+ var BotChallengeError = defineClass("BotChallengeError", { alwaysPermanent: true });
476
481
  // SessionDeviceBindingError — fingerprint-drift refusal raised by
477
482
  // b.sessionDeviceBinding when create-time opts are malformed or the
478
483
  // boundKeyResolver returns a non-Buffer. Permanent.
@@ -696,6 +701,7 @@ module.exports = {
696
701
  SandboxError: SandboxError,
697
702
  DlpError: DlpError,
698
703
  AuthBotChallengeError: AuthBotChallengeError,
704
+ BotChallengeError: BotChallengeError,
699
705
  SessionDeviceBindingError: SessionDeviceBindingError,
700
706
  AcmeError: AcmeError,
701
707
  HpkeError: HpkeError,
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
+ };
@@ -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
- // For v0.9.20: queryByModseq + post-filter against the unsealed row.
383
- // Future v0.9.27 with full-text indexing will replace this with an
384
- // index-side filter. Current scope: sinceModseq + flag filter only,
385
- // with a simple `from_addr` equality match via cryptoField.lookupHash
386
- // (the same hash computed at append time).
387
- var sinceModseq = (args.filter && args.filter.modseq && args.filter.modseq.gt) || 0;
388
- var limit = args.limit || 100;
389
- var rows = ctx.store.queryByModseq(folder, { sinceModseq: sinceModseq, limit: limit });
390
- ctx.auditEmit("mail.agent.search.success", args.actor, { folder: folder, rowCount: rows.length });
391
- return { rows: rows, nextModseq: rows.length > 0 ? rows[rows.length - 1].modseq : sinceModseq };
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) {
@@ -360,7 +360,11 @@ function create(opts) {
360
360
  lastDataByteTime: 0,
361
361
  };
362
362
 
363
- var lineBuffer = "";
363
+ // Raw byte buffer (NOT a string) — DATA bodies under 8BITMIME may
364
+ // carry bytes that are invalid UTF-8; round-tripping through a
365
+ // string decode would replace them with U+FFFD and corrupt the
366
+ // message. Decode to string only for the per-command line parse.
367
+ var lineBuffer = Buffer.alloc(0);
364
368
  var bodyCollector = null;
365
369
  var inDataBody = false;
366
370
 
@@ -447,8 +451,8 @@ function create(opts) {
447
451
  return;
448
452
  }
449
453
 
450
- // Command phase — line-buffered.
451
- lineBuffer += chunk.toString("utf8");
454
+ // Command phase — byte-buffered (8BITMIME-safe).
455
+ lineBuffer = lineBuffer.length === 0 ? chunk : Buffer.concat([lineBuffer, chunk]);
452
456
  if (lineBuffer.length > maxLineBytes * 4) {
453
457
  _writeReply(socket, REPLY_500_SYNTAX,
454
458
  "5.5.6 Line too long (>" + maxLineBytes + " bytes)");
@@ -456,9 +460,10 @@ function create(opts) {
456
460
  return;
457
461
  }
458
462
  var crlf;
459
- while ((crlf = lineBuffer.indexOf("\r\n")) !== -1) {
460
- var line = lineBuffer.slice(0, crlf);
461
- lineBuffer = lineBuffer.slice(crlf + 2);
463
+ var crlfNeedle = Buffer.from("\r\n", "ascii");
464
+ while ((crlf = lineBuffer.indexOf(crlfNeedle)) !== -1) {
465
+ var line = lineBuffer.subarray(0, crlf).toString("utf8");
466
+ lineBuffer = lineBuffer.subarray(crlf + 2);
462
467
  _handleCommand(state, socket, line);
463
468
  if (inDataBody) return;
464
469
  }
@@ -584,7 +589,7 @@ function create(opts) {
584
589
  // (RFC 2920) pre-handshake cannot reach the post-TLS state
585
590
  // machine. Listener-removal + idle-timeout re-arm live in the
586
591
  // shared upgradeSocket helper (b.mail.server.tls.upgradeSocket).
587
- lineBuffer = "";
592
+ lineBuffer = Buffer.alloc(0);
588
593
  bodyCollector = null;
589
594
  inDataBody = false;
590
595
  mailServerTls.upgradeSocket({