@blamejs/core 0.9.19 → 0.9.20

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.
@@ -0,0 +1,296 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.guardMailQuery
4
+ * @nav Guards
5
+ * @title Guard Mail Query
6
+ * @order 430
7
+ *
8
+ * @intro
9
+ * Search / fetch / changes filter validator for `b.mail.agent`. Refuses
10
+ * filter shapes that can't safely cross the worker-thread or queue
11
+ * boundary (functions, regex objects with state, Date objects with
12
+ * non-finite values, cycles), enforces a projection-column allowlist
13
+ * against the mail-store schema, and pins the per-actor fields that
14
+ * the active compliance posture requires (HIPAA → `purposeOfUse`;
15
+ * PCI-DSS → `pciScope`; GDPR → `lawfulBasis`).
16
+ *
17
+ * Composes `b.guardJson` (via `validate(JSON.stringify(filter))`-shape
18
+ * guard) at the structural level and adds mail-specific rules: the
19
+ * filter is recursively walked once with a depth cap, no operator
20
+ * key is allowed outside the documented set, and any field-name
21
+ * used as a comparator key must be in `FILTERABLE_COLUMNS`.
22
+ *
23
+ * @card
24
+ * Validates `b.mail.agent.search` / `Email/query` filter specs.
25
+ * Pure-data only (no functions / regex), bounded depth, projection
26
+ * allowlist, posture-required actor fields.
27
+ */
28
+
29
+ var { defineClass } = require("./framework-error");
30
+
31
+ var GuardMailQueryError = defineClass("GuardMailQueryError", { alwaysPermanent: true });
32
+
33
+ var DEFAULT_PROFILE = "strict";
34
+
35
+ var PROFILES = Object.freeze({
36
+ strict: { maxDepth: 8, maxKeys: 64, maxStringBytes: 8192, maxArrayLen: 256 }, // allow:raw-byte-literal — caps for filter spec
37
+ balanced: { maxDepth: 16, maxKeys: 128, maxStringBytes: 16384, maxArrayLen: 1024 }, // allow:raw-byte-literal
38
+ permissive: { maxDepth: 24, maxKeys: 512, maxStringBytes: 65536, maxArrayLen: 4096 }, // allow:raw-byte-literal
39
+ });
40
+
41
+ var COMPLIANCE_POSTURES = Object.freeze({
42
+ hipaa: "strict",
43
+ "pci-dss": "strict",
44
+ gdpr: "strict",
45
+ soc2: "strict",
46
+ });
47
+
48
+ // Columns the filter may reference and the projection may request.
49
+ // Sealed columns can be `=` / `IN` matched (the mail-store walks
50
+ // derivedHashes for the equality form); range / LIKE matches are only
51
+ // allowed against plaintext columns.
52
+ var FILTERABLE_COLUMNS = Object.freeze({
53
+ objectid: { kind: "plaintext", ops: ["eq", "in"] },
54
+ modseq: { kind: "plaintext", ops: ["eq", "gt", "lt", "ge", "le", "in"] },
55
+ internal_date: { kind: "plaintext", ops: ["eq", "gt", "lt", "ge", "le"] },
56
+ received_at: { kind: "plaintext", ops: ["eq", "gt", "lt", "ge", "le"] },
57
+ size_bytes: { kind: "plaintext", ops: ["eq", "gt", "lt", "ge", "le"] },
58
+ thread_root_id: { kind: "plaintext", ops: ["eq", "in"] },
59
+ legal_hold: { kind: "plaintext", ops: ["eq"] },
60
+ message_id: { kind: "sealed", ops: ["eq", "in"] },
61
+ from_addr: { kind: "sealed", ops: ["eq", "in"] },
62
+ subject: { kind: "sealed", ops: ["eq"] },
63
+ flag: { kind: "join", ops: ["eq", "in"] },
64
+ });
65
+
66
+ var ALLOWED_OPS = Object.freeze({
67
+ eq: true, in: true, gt: true, lt: true, ge: true, le: true,
68
+ and: true, or: true, not: true,
69
+ });
70
+
71
+ // Per-posture actor required fields. Operator must supply these on the
72
+ // agent actor object for any read/write op under the matching posture
73
+ // — refused otherwise.
74
+ var POSTURE_ACTOR_FIELDS = Object.freeze({
75
+ hipaa: ["purposeOfUse"],
76
+ "pci-dss": ["pciScope"],
77
+ gdpr: ["lawfulBasis"],
78
+ soc2: [],
79
+ });
80
+
81
+ /**
82
+ * @primitive b.guardMailQuery.validate
83
+ * @signature b.guardMailQuery.validate(filter, opts?)
84
+ * @since 0.9.20
85
+ * @status stable
86
+ * @related b.guardMailQuery.validateActor, b.mail.agent.create
87
+ *
88
+ * Validate a filter spec. Returns the input on success; throws
89
+ * `GuardMailQueryError` on refusal.
90
+ *
91
+ * @opts
92
+ * profile: "strict" | "balanced" | "permissive", // default "strict"
93
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2", // pins strict
94
+ * project: Array<string>, // projection columns
95
+ *
96
+ * @example
97
+ * b.guardMailQuery.validate({ and: [{ modseq: { gt: 0 } }, { flag: { eq: "\\Seen" } }] });
98
+ */
99
+ function validate(filter, opts) {
100
+ opts = opts || {};
101
+ var profile = PROFILES[_resolveProfile(opts)];
102
+ if (filter === undefined || filter === null) {
103
+ throw new GuardMailQueryError("mail-query/empty",
104
+ "guardMailQuery.validate: filter required");
105
+ }
106
+ if (typeof filter !== "object" || Array.isArray(filter)) {
107
+ throw new GuardMailQueryError("mail-query/bad-input",
108
+ "guardMailQuery.validate: filter must be a plain object");
109
+ }
110
+ _walk(filter, 0, profile, new Set());
111
+
112
+ if (opts.project) {
113
+ if (!Array.isArray(opts.project)) {
114
+ throw new GuardMailQueryError("mail-query/bad-project",
115
+ "guardMailQuery.validate: opts.project must be an array");
116
+ }
117
+ for (var i = 0; i < opts.project.length; i += 1) {
118
+ var col = opts.project[i];
119
+ if (typeof col !== "string" || !Object.prototype.hasOwnProperty.call(FILTERABLE_COLUMNS, col)) {
120
+ throw new GuardMailQueryError("mail-query/bad-projection-column",
121
+ "guardMailQuery.validate: column '" + col + "' not in projection allowlist");
122
+ }
123
+ }
124
+ }
125
+ return filter;
126
+ }
127
+
128
+ /**
129
+ * @primitive b.guardMailQuery.validateActor
130
+ * @signature b.guardMailQuery.validateActor(actor, posture?)
131
+ * @since 0.9.20
132
+ * @status stable
133
+ * @related b.guardMailQuery.validate
134
+ *
135
+ * Validate that `actor` carries the per-posture required fields.
136
+ * Returns `actor` on success; throws on missing field.
137
+ *
138
+ * @example
139
+ * b.guardMailQuery.validateActor({ id: "u1", roles: ["clinician"], purposeOfUse: "TREATMENT" }, "hipaa");
140
+ */
141
+ function validateActor(actor, posture) {
142
+ if (!actor || typeof actor !== "object") {
143
+ throw new GuardMailQueryError("mail-query/no-actor",
144
+ "guardMailQuery.validateActor: actor required");
145
+ }
146
+ if (typeof actor.id !== "string" || actor.id.length === 0) {
147
+ throw new GuardMailQueryError("mail-query/bad-actor",
148
+ "guardMailQuery.validateActor: actor.id must be a non-empty string");
149
+ }
150
+ if (posture && POSTURE_ACTOR_FIELDS[posture]) {
151
+ var required = POSTURE_ACTOR_FIELDS[posture];
152
+ for (var i = 0; i < required.length; i += 1) {
153
+ var f = required[i];
154
+ if (typeof actor[f] !== "string" || actor[f].length === 0) {
155
+ throw new GuardMailQueryError("mail-query/missing-posture-field",
156
+ "guardMailQuery.validateActor: posture '" + posture + "' requires actor." + f);
157
+ }
158
+ }
159
+ }
160
+ return actor;
161
+ }
162
+
163
+ /**
164
+ * @primitive b.guardMailQuery.compliancePosture
165
+ * @signature b.guardMailQuery.compliancePosture(posture)
166
+ * @since 0.9.20
167
+ * @status stable
168
+ *
169
+ * Return the effective profile for a given compliance posture name.
170
+ * Returns `null` when the posture is unknown (operator-supplied typos
171
+ * surface here instead of silently falling back to the default).
172
+ *
173
+ * @example
174
+ * b.guardMailQuery.compliancePosture("hipaa"); // → "strict"
175
+ */
176
+ function compliancePosture(posture) {
177
+ return COMPLIANCE_POSTURES[posture] || null;
178
+ }
179
+
180
+ function _walk(node, depth, profile, visited) {
181
+ if (depth > profile.maxDepth) {
182
+ throw new GuardMailQueryError("mail-query/depth",
183
+ "guardMailQuery.validate: filter depth exceeds maxDepth=" + profile.maxDepth);
184
+ }
185
+ if (node === null || typeof node !== "object") {
186
+ _checkScalar(node, profile);
187
+ return;
188
+ }
189
+ if (typeof node === "function") {
190
+ throw new GuardMailQueryError("mail-query/function-not-allowed",
191
+ "guardMailQuery.validate: functions refused (filter must be pure data)");
192
+ }
193
+ if (node instanceof RegExp) {
194
+ throw new GuardMailQueryError("mail-query/regex-not-allowed",
195
+ "guardMailQuery.validate: RegExp refused (use { like: ... } string predicate)");
196
+ }
197
+ if (node instanceof Date) {
198
+ if (!isFinite(node.getTime())) {
199
+ throw new GuardMailQueryError("mail-query/bad-date",
200
+ "guardMailQuery.validate: invalid Date");
201
+ }
202
+ return;
203
+ }
204
+ if (Buffer.isBuffer(node)) {
205
+ throw new GuardMailQueryError("mail-query/buffer-not-allowed",
206
+ "guardMailQuery.validate: Buffer refused inside filter");
207
+ }
208
+ if (visited.has(node)) {
209
+ throw new GuardMailQueryError("mail-query/cycle",
210
+ "guardMailQuery.validate: cyclic filter refused");
211
+ }
212
+ visited.add(node);
213
+
214
+ if (Array.isArray(node)) {
215
+ if (node.length > profile.maxArrayLen) {
216
+ throw new GuardMailQueryError("mail-query/array-too-long",
217
+ "guardMailQuery.validate: array length " + node.length + " exceeds " + profile.maxArrayLen);
218
+ }
219
+ for (var i = 0; i < node.length; i += 1) {
220
+ _walk(node[i], depth + 1, profile, visited);
221
+ }
222
+ return;
223
+ }
224
+
225
+ var keys = Object.keys(node);
226
+ if (keys.length > profile.maxKeys) {
227
+ throw new GuardMailQueryError("mail-query/too-many-keys",
228
+ "guardMailQuery.validate: " + keys.length + " keys exceeds maxKeys=" + profile.maxKeys);
229
+ }
230
+ for (var ki = 0; ki < keys.length; ki += 1) {
231
+ var k = keys[ki];
232
+ if (k === "__proto__" || k === "constructor" || k === "prototype") {
233
+ throw new GuardMailQueryError("mail-query/proto-key",
234
+ "guardMailQuery.validate: forbidden key '" + k + "'");
235
+ }
236
+ var isOp = Object.prototype.hasOwnProperty.call(ALLOWED_OPS, k);
237
+ var isCol = Object.prototype.hasOwnProperty.call(FILTERABLE_COLUMNS, k);
238
+ if (!isOp && !isCol) {
239
+ throw new GuardMailQueryError("mail-query/unknown-key",
240
+ "guardMailQuery.validate: key '" + k + "' not an allowed operator or column");
241
+ }
242
+ _walk(node[k], depth + 1, profile, visited);
243
+ }
244
+ }
245
+
246
+ function _checkScalar(v, profile) {
247
+ if (typeof v === "string") {
248
+ if (Buffer.byteLength(v, "utf8") > profile.maxStringBytes) {
249
+ throw new GuardMailQueryError("mail-query/string-too-long",
250
+ "guardMailQuery.validate: string exceeds maxStringBytes=" + profile.maxStringBytes);
251
+ }
252
+ return;
253
+ }
254
+ if (typeof v === "number") {
255
+ if (!isFinite(v)) {
256
+ throw new GuardMailQueryError("mail-query/bad-number",
257
+ "guardMailQuery.validate: non-finite number refused");
258
+ }
259
+ return;
260
+ }
261
+ if (typeof v === "boolean") return;
262
+ if (v === null) return;
263
+ if (typeof v === "undefined") {
264
+ throw new GuardMailQueryError("mail-query/undefined-not-allowed",
265
+ "guardMailQuery.validate: undefined refused");
266
+ }
267
+ if (typeof v === "symbol" || typeof v === "bigint" || typeof v === "function") {
268
+ throw new GuardMailQueryError("mail-query/bad-scalar",
269
+ "guardMailQuery.validate: scalar type " + typeof v + " refused");
270
+ }
271
+ }
272
+
273
+ function _resolveProfile(opts) {
274
+ if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
275
+ return COMPLIANCE_POSTURES[opts.posture];
276
+ }
277
+ var p = opts.profile || DEFAULT_PROFILE;
278
+ if (!PROFILES[p]) {
279
+ throw new GuardMailQueryError("mail-query/bad-profile",
280
+ "guardMailQuery: unknown profile '" + p + "'");
281
+ }
282
+ return p;
283
+ }
284
+
285
+ module.exports = {
286
+ validate: validate,
287
+ validateActor: validateActor,
288
+ compliancePosture: compliancePosture,
289
+ PROFILES: PROFILES,
290
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
291
+ FILTERABLE_COLUMNS: FILTERABLE_COLUMNS,
292
+ POSTURE_ACTOR_FIELDS: POSTURE_ACTOR_FIELDS,
293
+ GuardMailQueryError: GuardMailQueryError,
294
+ NAME: "mailQuery",
295
+ KIND: "mail-query",
296
+ };
@@ -0,0 +1,172 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.guardMailReply
4
+ * @nav Guards
5
+ * @title Guard Mail Reply
6
+ * @order 432
7
+ *
8
+ * @intro
9
+ * Reply-thread shape validator for `b.mail.agent.reply` /
10
+ * `b.mail.agent.forward`. Composes `b.guardMessageId` (v0.9.19) for
11
+ * each Message-Id in the chain and adds reply-specific rules:
12
+ *
13
+ * - References-chain cap — `maxChainLength` (default 100) defends
14
+ * infinite-loop forwards and References-bomb DoS
15
+ * - In-Reply-To continuity — when both `inReplyTo` and `references`
16
+ * are supplied, the last element of References must match
17
+ * In-Reply-To (RFC 5322 §3.6.4)
18
+ * - Quoted-original byte cap — when `quotedOriginal` is set, the
19
+ * byte cap defends pathological reply-of-reply chains that grow
20
+ * linearly with each hop
21
+ * - Forwarded-attachment cardinality — forwards may include the
22
+ * original's attachments by reference; cap at `maxForwardedAttachments`
23
+ * (default 32) to prevent attachment-bomb forwards
24
+ *
25
+ * @card
26
+ * Validates reply / forward shape. References-chain cap (defends
27
+ * infinite-loop forwards), In-Reply-To continuity (RFC 5322 §3.6.4),
28
+ * quoted-original byte cap.
29
+ */
30
+
31
+ var { defineClass } = require("./framework-error");
32
+ var guardMessageId = require("./guard-message-id");
33
+
34
+ var GuardMailReplyError = defineClass("GuardMailReplyError", { alwaysPermanent: true });
35
+
36
+ var DEFAULT_PROFILE = "strict";
37
+
38
+ var PROFILES = Object.freeze({
39
+ strict: { maxChainLength: 100, maxQuotedBytes: 524288, maxForwardedAttachments: 32 }, // allow:raw-byte-literal — chain count + 512 KiB
40
+ balanced: { maxChainLength: 500, maxQuotedBytes: 2097152, maxForwardedAttachments: 128 }, // allow:raw-byte-literal — chain count + 2 MiB
41
+ permissive: { maxChainLength: 2000, maxQuotedBytes: 10485760, maxForwardedAttachments: 512 }, // allow:raw-byte-literal — chain count + 10 MiB
42
+ });
43
+
44
+ var COMPLIANCE_POSTURES = Object.freeze({
45
+ hipaa: "strict",
46
+ "pci-dss": "strict",
47
+ gdpr: "strict",
48
+ soc2: "strict",
49
+ });
50
+
51
+ /**
52
+ * @primitive b.guardMailReply.validate
53
+ * @signature b.guardMailReply.validate(reply, opts?)
54
+ * @since 0.9.20
55
+ * @status stable
56
+ * @related b.guardMessageId, b.guardMailCompose
57
+ *
58
+ * Validate a reply / forward envelope. `reply.inReplyTo` is the
59
+ * Message-Id of the parent; `reply.references` is the chain (oldest
60
+ * first); `reply.quotedOriginal` is the optional included original
61
+ * body (already redacted by the caller — guard validates byte cap
62
+ * only, not content).
63
+ *
64
+ * @opts
65
+ * profile: "strict" | "balanced" | "permissive",
66
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
67
+ *
68
+ * @example
69
+ * b.guardMailReply.validate({
70
+ * inReplyTo: "<a@x>",
71
+ * references: ["<root@x>", "<a@x>"],
72
+ * });
73
+ */
74
+ function validate(reply, opts) {
75
+ opts = opts || {};
76
+ var profile = PROFILES[_resolveProfile(opts)];
77
+ if (!reply || typeof reply !== "object") {
78
+ throw new GuardMailReplyError("mail-reply/bad-input",
79
+ "guardMailReply.validate: reply required");
80
+ }
81
+ if (typeof reply.inReplyTo !== "string" || reply.inReplyTo.length === 0) {
82
+ throw new GuardMailReplyError("mail-reply/no-in-reply-to",
83
+ "guardMailReply.validate: inReplyTo required");
84
+ }
85
+ guardMessageId.validate(reply.inReplyTo, { profile: "strict" });
86
+
87
+ if (typeof reply.references !== "undefined") {
88
+ if (!Array.isArray(reply.references)) {
89
+ throw new GuardMailReplyError("mail-reply/bad-references",
90
+ "guardMailReply.validate: references must be an array");
91
+ }
92
+ if (reply.references.length > profile.maxChainLength) {
93
+ throw new GuardMailReplyError("mail-reply/chain-too-long",
94
+ "guardMailReply.validate: chain length " + reply.references.length +
95
+ " exceeds maxChainLength=" + profile.maxChainLength);
96
+ }
97
+ for (var i = 0; i < reply.references.length; i += 1) {
98
+ guardMessageId.validate(reply.references[i], { profile: "strict" });
99
+ }
100
+ if (reply.references.length > 0) {
101
+ var last = reply.references[reply.references.length - 1];
102
+ if (last !== reply.inReplyTo) {
103
+ throw new GuardMailReplyError("mail-reply/discontinuity",
104
+ "guardMailReply.validate: last References '" + last +
105
+ "' does not match inReplyTo '" + reply.inReplyTo + "' (RFC 5322 §3.6.4)");
106
+ }
107
+ }
108
+ }
109
+
110
+ if (typeof reply.quotedOriginal !== "undefined") {
111
+ if (typeof reply.quotedOriginal !== "string") {
112
+ throw new GuardMailReplyError("mail-reply/bad-quoted",
113
+ "guardMailReply.validate: quotedOriginal must be a string");
114
+ }
115
+ if (Buffer.byteLength(reply.quotedOriginal, "utf8") > profile.maxQuotedBytes) {
116
+ throw new GuardMailReplyError("mail-reply/quoted-too-big",
117
+ "guardMailReply.validate: quotedOriginal exceeds maxQuotedBytes=" + profile.maxQuotedBytes);
118
+ }
119
+ }
120
+
121
+ if (typeof reply.forwardedAttachments !== "undefined") {
122
+ if (!Array.isArray(reply.forwardedAttachments)) {
123
+ throw new GuardMailReplyError("mail-reply/bad-fwd-attach",
124
+ "guardMailReply.validate: forwardedAttachments must be an array");
125
+ }
126
+ if (reply.forwardedAttachments.length > profile.maxForwardedAttachments) {
127
+ throw new GuardMailReplyError("mail-reply/too-many-fwd-attach",
128
+ "guardMailReply.validate: forwarded attachment count " +
129
+ reply.forwardedAttachments.length + " exceeds " + profile.maxForwardedAttachments);
130
+ }
131
+ }
132
+ return reply;
133
+ }
134
+
135
+ /**
136
+ * @primitive b.guardMailReply.compliancePosture
137
+ * @signature b.guardMailReply.compliancePosture(posture)
138
+ * @since 0.9.20
139
+ * @status stable
140
+ *
141
+ * Return the effective profile for a given compliance posture name.
142
+ * Returns `null` for unknown posture names so operator typos surface
143
+ * here instead of silently falling through to the default profile.
144
+ *
145
+ * @example
146
+ * b.guardMailReply.compliancePosture("hipaa"); // → "strict"
147
+ */
148
+ function compliancePosture(posture) {
149
+ return COMPLIANCE_POSTURES[posture] || null;
150
+ }
151
+
152
+ function _resolveProfile(opts) {
153
+ if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
154
+ return COMPLIANCE_POSTURES[opts.posture];
155
+ }
156
+ var p = opts.profile || DEFAULT_PROFILE;
157
+ if (!PROFILES[p]) {
158
+ throw new GuardMailReplyError("mail-reply/bad-profile",
159
+ "guardMailReply: unknown profile '" + p + "'");
160
+ }
161
+ return p;
162
+ }
163
+
164
+ module.exports = {
165
+ validate: validate,
166
+ compliancePosture: compliancePosture,
167
+ PROFILES: PROFILES,
168
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
169
+ GuardMailReplyError: GuardMailReplyError,
170
+ NAME: "mailReply",
171
+ KIND: "mail-reply",
172
+ };
@@ -0,0 +1,207 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.guardMailSieve
4
+ * @nav Guards
5
+ * @title Guard Mail Sieve
6
+ * @order 434
7
+ *
8
+ * @intro
9
+ * Validator for `b.mail.agent.sieve.put` / `.activate`. The full
10
+ * Sieve parser + bytecode + runtime lands at v0.9.26 as
11
+ * `b.safeSieve`; this guard handles the agent-side actor + script-
12
+ * envelope checks that apply BEFORE parsing:
13
+ *
14
+ * - actor-scope check — only the owner of a script (or
15
+ * `mailScope: "admin"`) may edit it
16
+ * - script-byte cap — refuses scripts larger than `maxScriptBytes`
17
+ * (default 65536 — same cap v0.9.26's `b.safeSieve` will use)
18
+ * - script-name shape — RFC 5804 §2.3 script names are bounded
19
+ * UTF-8; the guard enforces the byte cap (default 256) and
20
+ * refuses NUL / control / slash / path-traversal shapes
21
+ * - line-count cap — defends scripts that are technically under
22
+ * the byte cap but pathological (one-character lines)
23
+ *
24
+ * When v0.9.26 ships, `b.safeSieve.validate(script)` will be
25
+ * invoked AFTER this guard — operators who want to bytecode-
26
+ * validate at agent.sieve.put time pass `requireParse: true` and
27
+ * the agent calls into v0.9.26's parser.
28
+ *
29
+ * @card
30
+ * Validates `b.mail.agent.sieve` operations. Actor-scope check,
31
+ * script-byte cap, name shape, line-count cap. Pre-parser checks
32
+ * only — full Sieve parse lands at v0.9.26 (`b.safeSieve`).
33
+ */
34
+
35
+ var { defineClass } = require("./framework-error");
36
+
37
+ var GuardMailSieveError = defineClass("GuardMailSieveError", { alwaysPermanent: true });
38
+
39
+ var DEFAULT_PROFILE = "strict";
40
+
41
+ var PROFILES = Object.freeze({
42
+ strict: { maxScriptBytes: 65536, maxNameBytes: 256, maxLines: 2000 }, // allow:raw-byte-literal
43
+ balanced: { maxScriptBytes: 262144, maxNameBytes: 256, maxLines: 10000 }, // allow:raw-byte-literal
44
+ permissive: { maxScriptBytes: 1048576, maxNameBytes: 1024, maxLines: 50000 }, // allow:raw-byte-literal
45
+ });
46
+
47
+ var COMPLIANCE_POSTURES = Object.freeze({
48
+ hipaa: "strict",
49
+ "pci-dss": "strict",
50
+ gdpr: "strict",
51
+ soc2: "strict",
52
+ });
53
+
54
+ /**
55
+ * @primitive b.guardMailSieve.validate
56
+ * @signature b.guardMailSieve.validate(op, opts?)
57
+ * @since 0.9.20
58
+ * @status stable
59
+ * @related b.mail.agent.create
60
+ *
61
+ * Validate a sieve-management op shape. `op.kind` is one of `"put"` /
62
+ * `"activate"` / `"delete"`; `op.actor` carries the actor; for `"put"`
63
+ * the `op.name` and `op.script` are validated; for `"activate"` /
64
+ * `"delete"` only `op.name` is required.
65
+ *
66
+ * @opts
67
+ * profile: "strict" | "balanced" | "permissive",
68
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
69
+ * ownedNames: Array<string>, // names the actor owns (operator-supplied)
70
+ *
71
+ * @example
72
+ * b.guardMailSieve.validate({
73
+ * kind: "put",
74
+ * actor: { id: "u1", mailScope: "user" },
75
+ * name: "my-filter",
76
+ * script: "require [\"fileinto\"];\nif address :is \"From\" \"x@x\" { fileinto \"Junk\"; }",
77
+ * }, { ownedNames: ["my-filter"] });
78
+ */
79
+ function validate(op, opts) {
80
+ opts = opts || {};
81
+ var profile = PROFILES[_resolveProfile(opts)];
82
+ if (!op || typeof op !== "object") {
83
+ throw new GuardMailSieveError("mail-sieve/bad-input",
84
+ "guardMailSieve.validate: op required");
85
+ }
86
+ if (op.kind !== "put" && op.kind !== "activate" && op.kind !== "delete") {
87
+ throw new GuardMailSieveError("mail-sieve/bad-kind",
88
+ "guardMailSieve.validate: op.kind must be 'put' | 'activate' | 'delete'");
89
+ }
90
+ if (!op.actor || typeof op.actor !== "object" || typeof op.actor.id !== "string") {
91
+ throw new GuardMailSieveError("mail-sieve/no-actor",
92
+ "guardMailSieve.validate: op.actor with .id required");
93
+ }
94
+ _checkName(op.name, profile);
95
+
96
+ if (op.kind === "put") {
97
+ if (typeof op.script !== "string") {
98
+ throw new GuardMailSieveError("mail-sieve/bad-script",
99
+ "guardMailSieve.validate: op.script must be a string");
100
+ }
101
+ var bytes = Buffer.byteLength(op.script, "utf8");
102
+ if (bytes === 0) {
103
+ throw new GuardMailSieveError("mail-sieve/empty-script",
104
+ "guardMailSieve.validate: script must be non-empty");
105
+ }
106
+ if (bytes > profile.maxScriptBytes) {
107
+ throw new GuardMailSieveError("mail-sieve/script-too-big",
108
+ "guardMailSieve.validate: script " + bytes + " bytes exceeds maxScriptBytes=" +
109
+ profile.maxScriptBytes);
110
+ }
111
+ // Line-count cap (a one-byte-line bomb stays under maxScriptBytes
112
+ // but blows up later parser stages; refuse here).
113
+ var lineCount = 1;
114
+ for (var i = 0; i < op.script.length; i += 1) {
115
+ if (op.script.charCodeAt(i) === 0x0A) lineCount += 1; // allow:raw-byte-literal — LF
116
+ }
117
+ if (lineCount > profile.maxLines) {
118
+ throw new GuardMailSieveError("mail-sieve/too-many-lines",
119
+ "guardMailSieve.validate: " + lineCount + " lines exceeds maxLines=" + profile.maxLines);
120
+ }
121
+ // Control-char refusal in script (NUL is always refused; other
122
+ // C0 except CR/LF/TAB are refused too — Sieve scripts are
123
+ // text-only per RFC 5228 §1.4).
124
+ for (var j = 0; j < op.script.length; j += 1) {
125
+ var c = op.script.charCodeAt(j);
126
+ if (c === 0x00 || (c < 0x20 && c !== 0x09 && c !== 0x0A && c !== 0x0D) || c === 0x7F) { // allow:raw-byte-literal — NUL / C0 except TAB/LF/CR / DEL refusal
127
+ throw new GuardMailSieveError("mail-sieve/control-char-in-script",
128
+ "guardMailSieve.validate: control char 0x" + c.toString(16) + " at offset " + j);
129
+ }
130
+ }
131
+ }
132
+
133
+ // Actor-scope check — non-admin actors may only edit / activate /
134
+ // delete scripts whose name is in their owned-names list. Operator
135
+ // supplies the list via opts.ownedNames (looked up from RBAC table).
136
+ var isAdmin = op.actor.mailScope === "admin";
137
+ if (!isAdmin) {
138
+ var owned = Array.isArray(opts.ownedNames) ? opts.ownedNames : [];
139
+ if (owned.indexOf(op.name) < 0) {
140
+ throw new GuardMailSieveError("mail-sieve/not-owner",
141
+ "guardMailSieve.validate: actor does not own script '" + op.name +
142
+ "' (use mailScope:'admin' or supply opts.ownedNames)");
143
+ }
144
+ }
145
+ return op;
146
+ }
147
+
148
+ /**
149
+ * @primitive b.guardMailSieve.compliancePosture
150
+ * @signature b.guardMailSieve.compliancePosture(posture)
151
+ * @since 0.9.20
152
+ * @status stable
153
+ *
154
+ * Return the effective profile for a given compliance posture name.
155
+ * Returns `null` for unknown posture names so operator typos surface
156
+ * here instead of silently falling through to the default profile.
157
+ *
158
+ * @example
159
+ * b.guardMailSieve.compliancePosture("hipaa"); // → "strict"
160
+ */
161
+ function compliancePosture(posture) {
162
+ return COMPLIANCE_POSTURES[posture] || null;
163
+ }
164
+
165
+ function _checkName(name, profile) {
166
+ if (typeof name !== "string" || name.length === 0) {
167
+ throw new GuardMailSieveError("mail-sieve/bad-name",
168
+ "guardMailSieve.validate: op.name must be a non-empty string");
169
+ }
170
+ if (Buffer.byteLength(name, "utf8") > profile.maxNameBytes) {
171
+ throw new GuardMailSieveError("mail-sieve/name-too-long",
172
+ "guardMailSieve.validate: op.name exceeds maxNameBytes=" + profile.maxNameBytes);
173
+ }
174
+ if (name.indexOf("..") >= 0) {
175
+ throw new GuardMailSieveError("mail-sieve/path-traversal",
176
+ "guardMailSieve.validate: op.name contains '..'");
177
+ }
178
+ for (var i = 0; i < name.length; i += 1) {
179
+ var c = name.charCodeAt(i);
180
+ if (c < 0x20 || c === 0x7F || c === 0x2F || c === 0x5C) { // allow:raw-byte-literal — C0 / DEL / slash / backslash refusal
181
+ throw new GuardMailSieveError("mail-sieve/bad-name-char",
182
+ "guardMailSieve.validate: op.name contains forbidden char 0x" + c.toString(16));
183
+ }
184
+ }
185
+ }
186
+
187
+ function _resolveProfile(opts) {
188
+ if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
189
+ return COMPLIANCE_POSTURES[opts.posture];
190
+ }
191
+ var p = opts.profile || DEFAULT_PROFILE;
192
+ if (!PROFILES[p]) {
193
+ throw new GuardMailSieveError("mail-sieve/bad-profile",
194
+ "guardMailSieve: unknown profile '" + p + "'");
195
+ }
196
+ return p;
197
+ }
198
+
199
+ module.exports = {
200
+ validate: validate,
201
+ compliancePosture: compliancePosture,
202
+ PROFILES: PROFILES,
203
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
204
+ GuardMailSieveError: GuardMailSieveError,
205
+ NAME: "mailSieve",
206
+ KIND: "mail-sieve",
207
+ };