automation-ingest-policy.mjs
654 lines 21.6 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * AIP — per-account automation ingest policy (pure).
3 * Router, body/rule validation, execute orchestration via injected I/O.
4 * See docs/AUTOMATION-INGEST-POLICY-FREEZE.md D1–D26.
5 */
6
7 import fs from 'node:fs';
8 import path from 'node:path';
9 import crypto from 'node:crypto';
10 import { fileURLToPath } from 'node:url';
11 import { notePathMatchesPrefix, normalizePathPrefix } from './write.mjs';
12 import { applyReviewTriggers } from './hub-proposal-review-triggers.mjs';
13 import { mergeProvenanceFrontmatter, stripReservedFrontmatterKeys } from './hub-provenance.mjs';
14
15 export const CONTENT_CLASSES = Object.freeze(['research', 'ops', 'general']);
16 export const DISPOSITIONS = Object.freeze(['direct_note', 'proposal_auto_apply', 'review_queue']);
17 export const MAX_USER_RULES = 32;
18 export const IDEMPOTENCY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
19 export const INGEST_BODY_MAX_BYTES = 512 * 1024;
20 export const FINGERPRINT_RE = /^[A-Za-z0-9._:/-]{8,128}$/;
21 export const INGEST_PATH = 'api/v1/automation/ingest';
22 export const INGEST_RULES_PATH = 'api/v1/automation/ingest-rules';
23
24 const MATCH_KEYS = [
25 'credential_id',
26 'credential_name',
27 'credential_name_prefix',
28 'path_prefix',
29 'intent',
30 'content_class',
31 ];
32
33 function packagedDefaultPath() {
34 try {
35 const u = typeof import.meta !== 'undefined' ? import.meta.url : '';
36 if (u) return path.join(path.dirname(fileURLToPath(u)), '..', 'hub', 'automation-ingest-rules-default.json');
37 } catch (_) {}
38 return path.join(process.cwd(), 'hub', 'automation-ingest-rules-default.json');
39 }
40
41 export function ingestHttpError(status, code, error, extra = {}) {
42 const err = new Error(error || code);
43 err.status = status;
44 err.code = code;
45 err.extra = extra;
46 return err;
47 }
48
49 export function isKnownContentClass(value) {
50 return CONTENT_CLASSES.includes(String(value || ''));
51 }
52
53 function predicateActive(value) {
54 return value != null && String(value).trim() !== '';
55 }
56
57 export function activeMatchPredicates(match) {
58 const m = match && typeof match === 'object' ? match : {};
59 return MATCH_KEYS.filter((k) => predicateActive(m[k]));
60 }
61
62 export function mintRuleId() {
63 return `ingr_${crypto.randomBytes(8).toString('hex')}`;
64 }
65
66 /**
67 * @param {unknown} raw
68 * @returns {string}
69 */
70 export function normalizeFingerprint(raw) {
71 return String(raw == null ? '' : raw).trim();
72 }
73
74 export function isValidFingerprint(raw) {
75 return FINGERPRINT_RE.test(normalizeFingerprint(raw));
76 }
77
78 export function idempotencyKeyFromRequest(headerRaw, sourceFingerprint) {
79 const header = String(headerRaw == null ? '' : headerRaw).trim();
80 if (header) {
81 if (!FINGERPRINT_RE.test(header)) {
82 throw ingestHttpError(400, 'INGEST_FINGERPRINT_INVALID', 'idempotency key invalid');
83 }
84 return header;
85 }
86 return sourceFingerprint;
87 }
88
89 export function idempotencyStoreKey(sub, vaultId, key) {
90 return `${sub}\t${vaultId}\t${key}`;
91 }
92
93 /**
94 * D14 — ingest contract marker for the legacy proposals hook.
95 * @param {unknown} body
96 */
97 export function isIngestContractBody(body) {
98 if (!body || typeof body !== 'object' || Array.isArray(body)) return false;
99 if (!isValidFingerprint(body.source_fingerprint)) return false;
100 if (body.ingest === true) return true;
101 return isKnownContentClass(body.content_class);
102 }
103
104 /**
105 * @param {unknown} rawPath
106 * @returns {string}
107 */
108 export function normalizeIngestPath(rawPath) {
109 if (typeof rawPath !== 'string') {
110 throw ingestHttpError(400, 'INGEST_PATH_INVALID', 'path required');
111 }
112 let p = rawPath.trim().replace(/\\/g, '/');
113 while (p.startsWith('/')) p = p.slice(1);
114 if (!p || p.length > 512 || !p.endsWith('.md')) {
115 throw ingestHttpError(400, 'INGEST_PATH_INVALID', 'path must be vault-relative .md (max 512)');
116 }
117 for (const seg of p.split('/')) {
118 if (seg === '..' || seg === '.') {
119 throw ingestHttpError(400, 'INGEST_PATH_INVALID', 'path must not contain ..');
120 }
121 }
122 return p;
123 }
124
125 /**
126 * @param {unknown} raw
127 * @param {{ requireContract?: boolean }} [opts]
128 */
129 export function normalizeIngestBody(raw, opts = {}) {
130 if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
131 throw ingestHttpError(400, 'INGEST_BODY_REQUIRED', 'body required');
132 }
133 if (opts.requireContract && !isIngestContractBody(raw)) {
134 throw ingestHttpError(400, 'INGEST_CONTRACT_REQUIRED', 'ingest contract required');
135 }
136 const notePath = normalizeIngestPath(raw.path);
137 if (typeof raw.body !== 'string') {
138 throw ingestHttpError(400, 'INGEST_BODY_REQUIRED', 'body string required');
139 }
140 if (Buffer.byteLength(raw.body, 'utf8') > INGEST_BODY_MAX_BYTES) {
141 throw ingestHttpError(400, 'INGEST_BODY_REQUIRED', 'body exceeds 512 KiB');
142 }
143 const fp = normalizeFingerprint(raw.source_fingerprint);
144 if (!fp) throw ingestHttpError(400, 'INGEST_FINGERPRINT_REQUIRED', 'source_fingerprint required');
145 if (!FINGERPRINT_RE.test(fp)) {
146 throw ingestHttpError(400, 'INGEST_FINGERPRINT_INVALID', 'source_fingerprint invalid');
147 }
148 let contentClass = null;
149 if (raw.content_class != null && String(raw.content_class).trim() !== '') {
150 const cc = String(raw.content_class).trim().toLowerCase();
151 if (!isKnownContentClass(cc)) {
152 throw ingestHttpError(400, 'INGEST_CONTENT_CLASS_UNKNOWN', 'unknown content_class');
153 }
154 contentClass = cc;
155 }
156 const labels = Array.isArray(raw.labels) ? raw.labels : [];
157 if (labels.length > 32) {
158 throw ingestHttpError(400, 'INGEST_BODY_REQUIRED', 'labels max 32');
159 }
160 const labelOut = labels.map((x) => String(x).slice(0, 64));
161 const intent = raw.intent == null ? '' : String(raw.intent).slice(0, 256);
162 const sourceRaw = raw.source == null || String(raw.source).trim() === '' ? 'automation_ingest' : String(raw.source);
163 const source = sourceRaw.slice(0, 64);
164 const frontmatter =
165 raw.frontmatter && typeof raw.frontmatter === 'object' && !Array.isArray(raw.frontmatter)
166 ? stripReservedFrontmatterKeys(raw.frontmatter)
167 : {};
168 return {
169 path: notePath,
170 body: raw.body,
171 frontmatter,
172 intent,
173 labels: labelOut,
174 source,
175 source_fingerprint: fp,
176 content_class: contentClass,
177 ingest: raw.ingest === true,
178 };
179 }
180
181 function matchRule(rule, input) {
182 const m = rule.match && typeof rule.match === 'object' ? rule.match : {};
183 const active = activeMatchPredicates(m);
184 if (active.length === 0) return false;
185 const requestClass = input.content_class || 'general';
186 for (const key of active) {
187 const expected = String(m[key]).trim();
188 if (key === 'credential_id') {
189 if (!input.credential_id || input.credential_id !== expected) return false;
190 } else if (key === 'credential_name') {
191 if (!input.credential_name || input.credential_name !== expected) return false;
192 } else if (key === 'credential_name_prefix') {
193 if (!input.credential_name || !String(input.credential_name).startsWith(expected)) return false;
194 } else if (key === 'path_prefix') {
195 let prefixNorm;
196 try {
197 prefixNorm = normalizePathPrefix(expected);
198 } catch {
199 return false;
200 }
201 if (!notePathMatchesPrefix(input.path, prefixNorm)) return false;
202 } else if (key === 'intent') {
203 if (String(input.intent || '') !== expected) return false;
204 } else if (key === 'content_class') {
205 if (requestClass !== expected) return false;
206 }
207 }
208 return true;
209 }
210
211 /**
212 * First-match router. Pure aside from applyReviewTriggers (no I/O).
213 * @param {{
214 * sub?: string,
215 * path: string,
216 * body: string,
217 * intent?: string,
218 * labels?: string[],
219 * content_class?: string|null,
220 * credential_id?: string|null,
221 * credential_name?: string|null,
222 * evaluationRequired?: boolean,
223 * sessionBound?: boolean,
224 * triggers?: object,
225 * }} input
226 * @param {object[]} rules
227 */
228 export function routeAutomationIngest(input, rules) {
229 const list = Array.isArray(rules) ? rules.filter((r) => r && r.enabled === true) : [];
230 list.sort((a, b) => {
231 const pa = Number(a.priority) || 0;
232 const pb = Number(b.priority) || 0;
233 if (pa !== pb) return pa - pb;
234 return String(a.rule_id || '').localeCompare(String(b.rule_id || ''));
235 });
236 let candidate = {
237 rule_id: null,
238 disposition: 'review_queue',
239 content_class: input.content_class || 'general',
240 };
241 for (const rule of list) {
242 if (matchRule(rule, input)) {
243 const cc = input.content_class || rule.content_class || 'general';
244 candidate = {
245 rule_id: rule.rule_id || null,
246 disposition: DISPOSITIONS.includes(rule.disposition) ? rule.disposition : 'review_queue',
247 content_class: isKnownContentClass(cc) ? cc : 'general',
248 };
249 break;
250 }
251 }
252 if (!input.content_class && candidate.rule_id) {
253 const hit = list.find((r) => r.rule_id === candidate.rule_id);
254 if (hit && hit.content_class) candidate.content_class = hit.content_class;
255 }
256 const triggers = input.triggers && typeof input.triggers === 'object' ? input.triggers : {
257 literal_phrases: [],
258 path_prefixes: [],
259 label_any: [],
260 };
261 const trigger_result = applyReviewTriggers(triggers, {
262 path: input.path,
263 body: input.body,
264 intent: input.intent,
265 labels: Array.isArray(input.labels) ? input.labels : [],
266 });
267 let elevated_override = false;
268 let evaluation_block = false;
269 const reasons = Array.isArray(trigger_result.auto_flag_reasons) ? trigger_result.auto_flag_reasons : [];
270 if (
271 trigger_result.forcePending ||
272 trigger_result.review_severity === 'elevated' ||
273 reasons.length > 0
274 ) {
275 candidate.disposition = 'review_queue';
276 elevated_override = true;
277 }
278 if (
279 candidate.disposition === 'proposal_auto_apply' &&
280 input.evaluationRequired === true &&
281 input.sessionBound !== true
282 ) {
283 candidate.disposition = 'review_queue';
284 evaluation_block = true;
285 }
286 return {
287 rule_id: candidate.rule_id,
288 disposition: candidate.disposition,
289 content_class: candidate.content_class,
290 elevated_override,
291 evaluation_block,
292 trigger_result,
293 };
294 }
295
296 export function listPackTemplates() {
297 let raw;
298 try {
299 raw = JSON.parse(fs.readFileSync(packagedDefaultPath(), 'utf8'));
300 } catch {
301 raw = { version: 1, templates: [] };
302 }
303 const templates = Array.isArray(raw.templates) ? raw.templates : [];
304 return templates.map((t) => ({ ...t, enabled: false }));
305 }
306
307 export function normalizeRuleForSave(raw, { mintMissingId = true } = {}) {
308 if (!raw || typeof raw !== 'object') {
309 throw ingestHttpError(400, 'INGEST_RULE_MATCH_EMPTY', 'rule required');
310 }
311 const label = String(raw.label || '').trim();
312 if (!label || label.length > 128) {
313 throw ingestHttpError(400, 'INGEST_RULE_MATCH_EMPTY', 'label required (1–128)');
314 }
315 let priority = raw.priority == null ? 100 : Number(raw.priority);
316 if (!Number.isInteger(priority) || priority < 0 || priority > 10000) {
317 throw ingestHttpError(400, 'INGEST_RULE_MATCH_EMPTY', 'priority must be 0–10000');
318 }
319 if (!DISPOSITIONS.includes(raw.disposition)) {
320 throw ingestHttpError(400, 'INGEST_DISPOSITION_UNKNOWN', 'unknown disposition');
321 }
322 let contentClass = null;
323 if (raw.content_class != null && String(raw.content_class).trim() !== '') {
324 const cc = String(raw.content_class).trim().toLowerCase();
325 if (!isKnownContentClass(cc)) {
326 throw ingestHttpError(400, 'INGEST_CONTENT_CLASS_UNKNOWN', 'unknown content_class');
327 }
328 contentClass = cc;
329 }
330 const matchIn = raw.match && typeof raw.match === 'object' ? raw.match : {};
331 const match = {};
332 for (const key of MATCH_KEYS) {
333 const v = matchIn[key];
334 match[key] = predicateActive(v) ? String(v) : null;
335 }
336 if (activeMatchPredicates(match).length === 0) {
337 throw ingestHttpError(400, 'INGEST_RULE_MATCH_EMPTY', 'match requires at least one predicate');
338 }
339 let ruleId = typeof raw.rule_id === 'string' ? raw.rule_id.trim() : '';
340 if (!ruleId || !/^ingr_[0-9a-f]{16}$/.test(ruleId)) {
341 if (!mintMissingId) {
342 throw ingestHttpError(400, 'INGEST_RULE_MATCH_EMPTY', 'rule_id invalid');
343 }
344 ruleId = mintRuleId();
345 }
346 return {
347 rule_id: ruleId,
348 enabled: raw.enabled === true,
349 priority,
350 pack_id: raw.pack_id == null || raw.pack_id === '' ? null : String(raw.pack_id).slice(0, 64),
351 label,
352 match,
353 disposition: raw.disposition,
354 content_class: contentClass,
355 };
356 }
357
358 export function stampIngestFrontmatter(frontmatter, { sub, contentClass, sourceFingerprint, source }) {
359 const merged = mergeProvenanceFrontmatter(frontmatter, { sub, kind: 'agent' });
360 return {
361 ...merged,
362 content_class: contentClass,
363 source_fingerprint: sourceFingerprint,
364 source,
365 };
366 }
367
368 export function buildSuccessEnvelope({
369 disposition,
370 ruleId,
371 outcome,
372 notePath,
373 contentClass,
374 proposalId = null,
375 noteWritten = false,
376 replayed = false,
377 elevatedOverride = false,
378 evaluationBlock = false,
379 }) {
380 return {
381 disposition,
382 rule_id: ruleId,
383 outcome,
384 path: notePath,
385 content_class: contentClass,
386 proposal_id: proposalId,
387 note: noteWritten ? { path: notePath } : null,
388 replayed,
389 elevated_override: elevatedOverride,
390 evaluation_block: evaluationBlock,
391 };
392 }
393
394 export function ingestAuditDetail({
395 ruleId,
396 disposition,
397 sourceFingerprint,
398 notePath,
399 contentClass,
400 vaultId,
401 credentialId,
402 elevatedOverride,
403 evaluationBlock,
404 replayed,
405 }) {
406 return {
407 rule_id: ruleId,
408 disposition,
409 source_fingerprint: sourceFingerprint,
410 path: notePath,
411 content_class: contentClass,
412 vault_id: vaultId,
413 credential_id: credentialId == null ? null : String(credentialId),
414 elevated_override: Boolean(elevatedOverride),
415 evaluation_block: Boolean(evaluationBlock),
416 replayed: Boolean(replayed),
417 };
418 }
419
420 function existingFingerprint(existing) {
421 if (!existing || typeof existing !== 'object') return '';
422 const fm = existing.frontmatter && typeof existing.frontmatter === 'object' ? existing.frontmatter : existing;
423 return fm.source_fingerprint != null ? String(fm.source_fingerprint) : '';
424 }
425
426 /**
427 * Execute I/O via injected adapters (wrappers own writeNote / canister fetch).
428 * @param {object} args
429 */
430 export async function executeAutomationIngest(args) {
431 const {
432 normalized,
433 routed,
434 actor,
435 io,
436 } = args;
437 const vaultId = actor.vaultId || 'default';
438 const detailBase = {
439 ruleId: routed.rule_id,
440 disposition: routed.disposition,
441 sourceFingerprint: normalized.source_fingerprint,
442 notePath: normalized.path,
443 contentClass: routed.content_class,
444 vaultId,
445 credentialId: actor.credentialId || null,
446 elevatedOverride: routed.elevated_override,
447 evaluationBlock: routed.evaluation_block,
448 replayed: false,
449 };
450
451 await io.appendAudit('ingest_routed', ingestAuditDetail(detailBase), '');
452
453 if (routed.elevated_override) {
454 await io.appendAudit('ingest_elevated_override', ingestAuditDetail(detailBase), '');
455 }
456
457 const fm = stampIngestFrontmatter(normalized.frontmatter, {
458 sub: actor.sub,
459 contentClass: routed.content_class,
460 sourceFingerprint: normalized.source_fingerprint,
461 source: normalized.source,
462 });
463
464 const createPayload = {
465 path: normalized.path,
466 body: normalized.body,
467 frontmatter: fm,
468 intent: normalized.intent,
469 labels: normalized.labels,
470 source: normalized.source,
471 proposed_by: actor.sub,
472 };
473
474 if (routed.disposition === 'direct_note') {
475 const existing = await io.readExistingNote(normalized.path);
476 if (existing) {
477 const prev = existingFingerprint(existing);
478 if (prev !== normalized.source_fingerprint) {
479 throw ingestHttpError(409, 'INGEST_PATH_CONFLICT', 'path exists with a different source_fingerprint');
480 }
481 }
482 await io.writeNote(normalized.path, { body: normalized.body, frontmatter: fm });
483 await io.appendAudit('ingest_direct_note', ingestAuditDetail(detailBase), '');
484 return buildSuccessEnvelope({
485 disposition: 'direct_note',
486 ruleId: routed.rule_id,
487 outcome: 'note',
488 notePath: normalized.path,
489 contentClass: routed.content_class,
490 noteWritten: true,
491 elevatedOverride: routed.elevated_override,
492 evaluationBlock: routed.evaluation_block,
493 });
494 }
495
496 if (routed.disposition === 'proposal_auto_apply') {
497 const existing = await io.readExistingNote(normalized.path);
498 if (existing) {
499 const prev = existingFingerprint(existing);
500 if (prev !== normalized.source_fingerprint) {
501 throw ingestHttpError(409, 'INGEST_PATH_CONFLICT', 'path exists with a different source_fingerprint');
502 }
503 }
504 const proposal = await io.createProposal(createPayload);
505 const proposalId = proposal && proposal.proposal_id ? String(proposal.proposal_id) : '';
506 await io.writeNote(normalized.path, { body: normalized.body, frontmatter: fm });
507 const marked = await io.markProposalApproved(proposalId);
508 if (!marked || marked.ok !== true) {
509 await io.appendAudit(
510 'ingest_apply_failed',
511 ingestAuditDetail({ ...detailBase, replayed: false }),
512 proposalId
513 );
514 throw ingestHttpError(500, 'INGEST_APPLY_FAILED', 'auto-apply mark failed', {
515 proposal_id: proposalId,
516 path: normalized.path,
517 });
518 }
519 await io.appendAudit('ingest_auto_applied', ingestAuditDetail(detailBase), proposalId);
520 return buildSuccessEnvelope({
521 disposition: 'proposal_auto_apply',
522 ruleId: routed.rule_id,
523 outcome: 'note_and_proposal',
524 notePath: normalized.path,
525 contentClass: routed.content_class,
526 proposalId,
527 noteWritten: true,
528 elevatedOverride: routed.elevated_override,
529 evaluationBlock: routed.evaluation_block,
530 });
531 }
532
533 const proposal = await io.createProposal(createPayload);
534 const proposalId = proposal && proposal.proposal_id ? String(proposal.proposal_id) : '';
535 await io.appendAudit('ingest_review_queued', ingestAuditDetail(detailBase), proposalId);
536 return buildSuccessEnvelope({
537 disposition: 'review_queue',
538 ruleId: routed.rule_id,
539 outcome: 'proposal',
540 notePath: normalized.path,
541 contentClass: routed.content_class,
542 proposalId,
543 elevatedOverride: routed.elevated_override,
544 evaluationBlock: routed.evaluation_block,
545 });
546 }
547
548 /**
549 * Full ingest pipeline after auth: normalize, idempotency, route, bill, execute.
550 * @param {object} args
551 */
552 export async function processAutomationIngest(args) {
553 const {
554 rawBody,
555 idempotencyHeader,
556 actor,
557 rules,
558 triggers,
559 io,
560 requireContract = false,
561 } = args;
562 const normalized = normalizeIngestBody(rawBody, { requireContract });
563 const key = idempotencyKeyFromRequest(idempotencyHeader, normalized.source_fingerprint);
564 const storeKey = idempotencyStoreKey(actor.sub, actor.vaultId || 'default', key);
565 const now = Date.now();
566 const prior = await io.getIdempotency(storeKey);
567 if (prior && Number(prior.expires_at) > now) {
568 if (prior.source_fingerprint !== normalized.source_fingerprint || prior.path !== normalized.path) {
569 throw ingestHttpError(409, 'INGEST_IDEMPOTENCY_CONFLICT', 'idempotency key reused with different payload');
570 }
571 const result = { ...(prior.result || {}), replayed: true };
572 const routedLite = {
573 rule_id: result.rule_id ?? null,
574 disposition: result.disposition || 'review_queue',
575 content_class: result.content_class || normalized.content_class || 'general',
576 elevated_override: Boolean(result.elevated_override),
577 evaluation_block: Boolean(result.evaluation_block),
578 };
579 await io.appendAudit(
580 'ingest_routed',
581 ingestAuditDetail({
582 ruleId: routedLite.rule_id,
583 disposition: routedLite.disposition,
584 sourceFingerprint: normalized.source_fingerprint,
585 notePath: normalized.path,
586 contentClass: routedLite.content_class,
587 vaultId: actor.vaultId || 'default',
588 credentialId: actor.credentialId || null,
589 elevatedOverride: routedLite.elevated_override,
590 evaluationBlock: routedLite.evaluation_block,
591 replayed: true,
592 }),
593 result.proposal_id || ''
594 );
595 await io.appendAudit(
596 'ingest_idempotent_replay',
597 ingestAuditDetail({
598 ruleId: routedLite.rule_id,
599 disposition: routedLite.disposition,
600 sourceFingerprint: normalized.source_fingerprint,
601 notePath: normalized.path,
602 contentClass: routedLite.content_class,
603 vaultId: actor.vaultId || 'default',
604 credentialId: actor.credentialId || null,
605 elevatedOverride: routedLite.elevated_override,
606 evaluationBlock: routedLite.evaluation_block,
607 replayed: true,
608 }),
609 result.proposal_id || ''
610 );
611 return { status: 200, body: result };
612 }
613
614 const routed = routeAutomationIngest(
615 {
616 path: normalized.path,
617 body: normalized.body,
618 intent: normalized.intent,
619 labels: normalized.labels,
620 content_class: normalized.content_class,
621 credential_id: actor.credentialId || null,
622 credential_name: actor.credentialName || null,
623 evaluationRequired: actor.evaluationRequired === true,
624 sessionBound: actor.sessionBound === true,
625 triggers,
626 },
627 rules
628 );
629
630 const billOp = routed.disposition === 'review_queue' ? 'proposal_write' : 'note_write';
631 const billed = await io.runBilling(billOp);
632 if (billed === false) return { billed: false };
633
634 const body = await executeAutomationIngest({ normalized, routed, actor, io });
635 await io.putIdempotency(storeKey, {
636 source_fingerprint: normalized.source_fingerprint,
637 path: normalized.path,
638 result: body,
639 created_at: now,
640 expires_at: now + IDEMPOTENCY_TTL_MS,
641 });
642 return { status: 201, body };
643 }
644
645 export function sendIngestError(res, err) {
646 const status = Number(err && err.status) || 500;
647 const code = err && err.code ? String(err.code) : 'RUNTIME_ERROR';
648 const payload = {
649 error: err && err.message ? err.message : code,
650 code,
651 ...(err && err.extra && typeof err.extra === 'object' ? err.extra : {}),
652 };
653 return res.status(status).json(payload);
654 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago