@aglyn/aglyn 1.0.0-beta.266 → 1.0.0-beta.269

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.
Files changed (37) hide show
  1. package/package.json +12 -12
  2. package/src/lib/app-utils/docs-index.generated.js +1 -1
  3. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  4. package/src/lib/app-utils/element-motion.d.ts +14 -2
  5. package/src/lib/app-utils/element-motion.js +33 -13
  6. package/src/lib/app-utils/element-motion.js.map +1 -1
  7. package/src/lib/app-utils/health-report.d.ts +78 -6
  8. package/src/lib/app-utils/health-report.js +113 -6
  9. package/src/lib/app-utils/health-report.js.map +1 -1
  10. package/src/lib/app-utils/notifications.js +6 -10
  11. package/src/lib/app-utils/notifications.js.map +1 -1
  12. package/src/lib/app-utils/upload-inspection.d.ts +9 -0
  13. package/src/lib/app-utils/upload-inspection.js +27 -0
  14. package/src/lib/app-utils/upload-inspection.js.map +1 -1
  15. package/src/lib/plugin-manager/operator-alerts.js +10 -1
  16. package/src/lib/plugin-manager/operator-alerts.js.map +1 -1
  17. package/src/lib/plugin-manager/plugin-config.js +10 -1
  18. package/src/lib/plugin-manager/plugin-config.js.map +1 -1
  19. package/src/lib/plugin-manager/plugin-consent-group-change.js +11 -2
  20. package/src/lib/plugin-manager/plugin-consent-group-change.js.map +1 -1
  21. package/src/lib/plugin-manager/plugin-console-crons.js +10 -1
  22. package/src/lib/plugin-manager/plugin-console-crons.js.map +1 -1
  23. package/src/lib/plugin-manager/plugin-events.js +11 -2
  24. package/src/lib/plugin-manager/plugin-events.js.map +1 -1
  25. package/src/lib/plugin-manager/plugin-lead-conversion.js +10 -1
  26. package/src/lib/plugin-manager/plugin-lead-conversion.js.map +1 -1
  27. package/src/lib/plugin-manager/plugin-membership-detach.js +10 -1
  28. package/src/lib/plugin-manager/plugin-membership-detach.js.map +1 -1
  29. package/src/lib/plugin-manager/plugin-person-matches.js +10 -1
  30. package/src/lib/plugin-manager/plugin-person-matches.js.map +1 -1
  31. package/src/lib/plugin-manager/plugin-site-erase.d.ts +81 -0
  32. package/src/lib/plugin-manager/plugin-site-erase.js +56 -0
  33. package/src/lib/plugin-manager/plugin-site-erase.js.map +1 -0
  34. package/src/lib/plugin-manager/plugin-usage-meters.js +10 -1
  35. package/src/lib/plugin-manager/plugin-usage-meters.js.map +1 -1
  36. package/src/lib/plugin-manager/usage-alert-contributors.js +10 -1
  37. package/src/lib/plugin-manager/usage-alert-contributors.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/upload-inspection.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * STRUCTURAL upload inspection (AGL-1475).\n *\n * ## What this is, and what it is emphatically not\n *\n * This is **not** an antivirus scanner and no code in this module should ever\n * be described as one. It does not have malware signatures, it does not\n * emulate, unpack or detonate anything, and a novel trojan with a correct PDF\n * header passes it without a murmur. It is *structural validation*: it reads\n * the first few kilobytes of an upload and answers three questions the\n * platform previously could not ask at all.\n *\n * 1. **Are these bytes an executable?** A Windows PE, an ELF binary, a Mach-O\n * binary or an installer package is refused whatever it claims to be. This\n * is the single highest-value check here: the Sept-1 risk is a customer's\n * domain re-serving a trojan to a visitor's browser, and the overwhelming\n * majority of that population is a plain executable with a lying name.\n * 2. **Do the bytes match the declared type?** Media ingress trusted the\n * caller's content type completely — AGL-2463 wrote that gap down. The\n * allowlist bounded what a file *claimed* to be and nothing bounded what it\n * *was*, so `Content-Type: application/pdf` over a `.exe` was stored,\n * hashed, given a CDN path and served. It now has to be a PDF.\n * 3. **Does an Office document carry a macro project?** AGL-1465 kept\n * `.docm`/`.xlsm`/`.pptm` out **by content type**, which is a gate on a\n * string the uploader chooses. Renaming a macro-enabled document to\n * `.docx` walked straight past it. The archive is now checked for a\n * `vbaProject.bin` entry, which is the thing that made those extensions\n * worth excluding in the first place.\n *\n * Plus one signature, EICAR — see {@link EICAR_TEST_SIGNATURE}.\n *\n * ## What it therefore does not catch\n *\n * A malicious PDF that is a real PDF. A macro-free document that exploits a\n * reader. Anything inside a plain `application/zip`, which is a brand kit and\n * may legitimately contain a build tool. An obfuscated dropper in a JSON\n * file. Real malware detection needs signatures and an engine, which needs a\n * ClamAV service, which does not fit the budget this platform runs on — the\n * cost work is in the issue. Nothing here should be allowed to read as though\n * that decision went the other way.\n *\n * ## Why it is pure, and takes a window rather than a file\n *\n * Three of the four media chokepoints hold the whole file in memory and can\n * pass it straight in. The fourth — signed direct-to-storage upload — never\n * sees the bytes, because that is the entire reason it exists: a 200 MB video\n * goes browser → bucket and downloading it back into the function to look at\n * it would cost more than the feature. So that caller does two RANGED reads\n * instead, a few KB from each end, and passes them as {@link\n * InspectUploadInput.bytes} and {@link InspectUploadInput.tail}. Every\n * signature this module knows lives in one of those two windows, so the\n * signed route gets the same verdict for the price of a rounding error.\n */\n\n/**\n * The EICAR standard antivirus test string.\n *\n * A 68-byte printable string, defined by the European Institute for Computer\n * Antivirus Research, that every antivirus product agrees to flag and that is\n * completely inert. It exists so that a pipeline can be tested end to end\n * without anyone handling a live sample.\n *\n * It is matched here for exactly two reasons, and neither is malware\n * detection. First, it makes this module's own tests honest without checking\n * a real sample into the repository. Second, it is the probe a security\n * reviewer or a customer will actually try, and answering it with silence\n * would be a worse lie than answering it with a refusal — a refusal that says\n * \"structural check\" is true, where a silent accept invites the reader to\n * conclude nothing is checked at all.\n *\n * **This is a signature list of one. It is not an engine.** Do not add a\n * second entry here and start calling the result malware scanning; if real\n * signatures are wanted, they belong in a scanner service, not in a string\n * constant.\n */\nexport const EICAR_TEST_SIGNATURE =\n 'X5O!P%@AP[4\\\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*'\n\n/**\n * How many leading bytes an inspection needs.\n *\n * Every magic number checked here sits in the first 16 bytes; the window is\n * far larger so that the EICAR match and the small-file macro scan have room,\n * and because the signed route's ranged GET costs the same for 4 KB as for\n * 64 bytes.\n */\nexport const UPLOAD_INSPECTION_HEAD_BYTES = 4096\n\n/**\n * How many trailing bytes an inspection wants for an Office document.\n *\n * A ZIP's central directory — the authoritative list of what is inside it —\n * lives at the end of the file. 64 KB covers the directory of any document\n * archive this platform accepts (the largest ceiling is a 50 MB `.pptx`, and\n * its directory is a few KB even with hundreds of embedded images). A\n * document whose directory is somehow larger than this window degrades to\n * \"macro not found\", which is the pre-existing behaviour, not a regression.\n */\nexport const UPLOAD_INSPECTION_TAIL_BYTES = 65536\n\nexport type UploadInspectionCode =\n | 'signature_match'\n | 'executable_bytes'\n | 'type_mismatch'\n | 'macro_payload'\n | 'empty_file'\n\nexport interface UploadInspectionRefusal {\n /** Stable machine code — for the API error body, logs and tests. */\n code: UploadInspectionCode\n /** What the bytes turned out to be, in words rather than hex. */\n detected: string\n /**\n * The sentence the uploader sees. Names the file and what was found, so\n * that a merchant who did nothing wrong can tell which of fifty dropped\n * files was the problem and why.\n */\n message: string\n}\n\nexport interface InspectUploadInput {\n /**\n * The whole file, or its leading {@link UPLOAD_INSPECTION_HEAD_BYTES}.\n * Callers holding the whole file pass it and omit `tail`.\n */\n bytes: Uint8Array\n /**\n * The file's trailing bytes, supplied ONLY by a caller that passed a head\n * window rather than the whole file. When absent, `bytes` is searched for\n * the trailing structures too — which is correct precisely because a caller\n * that omits it is one that handed over the entire file.\n */\n tail?: Uint8Array | null\n /** The type the upload claims to be, already normalized by the caller. */\n contentType: string\n /** For the message only. Never used to decide anything. */\n fileName?: string | null\n}\n\ninterface Signature {\n /** Bytes to match. */\n magic: readonly number[]\n /** Where they start. */\n offset?: number\n /** Human name for the refusal message. */\n label: string\n}\n\nconst at = (bytes: Uint8Array, signature: Signature): boolean => {\n const offset = signature.offset ?? 0\n if (bytes.length < offset + signature.magic.length) return false\n for (let i = 0; i < signature.magic.length; i++) {\n if (bytes[offset + i] !== signature.magic[i]) return false\n }\n return true\n}\n\nconst ascii = (text: string): number[] =>\n Array.from(text, (character) => character.charCodeAt(0))\n\n/**\n * Executable and installer containers, refused whatever the upload claims to\n * be. This list is deliberately about CONTAINERS, not content: each entry is\n * a format whose entire purpose is to be run, and none of them has any\n * business in a media library under any content type.\n *\n * `#!` is NOT here on purpose. A shebang is two characters that a legitimate\n * `text/plain` upload can genuinely open with, it is not executable in a\n * browser, and the type-mismatch check below already refuses it under every\n * binary type. Refusing it outright would buy nothing and cost real uploads.\n */\nconst EXECUTABLE_SIGNATURES: readonly Signature[] = [\n { magic: ascii('MZ'), label: 'a Windows executable' },\n { magic: [0x7f, 0x45, 0x4c, 0x46], label: 'a Linux executable (ELF)' },\n { magic: [0xfe, 0xed, 0xfa, 0xce], label: 'a macOS executable (Mach-O)' },\n { magic: [0xfe, 0xed, 0xfa, 0xcf], label: 'a macOS executable (Mach-O)' },\n { magic: [0xce, 0xfa, 0xed, 0xfe], label: 'a macOS executable (Mach-O)' },\n { magic: [0xcf, 0xfa, 0xed, 0xfe], label: 'a macOS executable (Mach-O)' },\n // 0xCAFEBABE is both a Java class file and a multi-architecture Mach-O\n // binary. Both are code; the label names the likelier one for a DAM.\n { magic: [0xca, 0xfe, 0xba, 0xbe], label: 'a compiled program' },\n { magic: ascii('!<arch>'), label: 'an installer package' },\n { magic: [0xed, 0xab, 0xee, 0xdb], label: 'an installer package (RPM)' },\n // Windows shortcut — a .lnk is a launcher, and a classic phishing payload.\n { magic: [0x4c, 0x00, 0x00, 0x00, 0x01, 0x14, 0x02, 0x00], label: 'a Windows shortcut' },\n]\n\n/** ZIP, in its three legal opening forms. Every OOXML document is one. */\nconst ZIP_SIGNATURES: readonly Signature[] = [\n { magic: [0x50, 0x4b, 0x03, 0x04], label: 'ZIP' },\n { magic: [0x50, 0x4b, 0x05, 0x06], label: 'ZIP' },\n { magic: [0x50, 0x4b, 0x07, 0x08], label: 'ZIP' },\n]\n\n/** Microsoft's pre-2007 container: legacy `.doc`, `.xls`, `.ppt` and `.msi`. */\nconst OLE_SIGNATURE: Signature = {\n magic: [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1],\n label: 'a legacy Office document',\n}\n\n/** ISO base media (`....ftyp`) — mp4, QuickTime, and the modern image codecs. */\nconst ISO_BMFF: Signature = { magic: ascii('ftyp'), offset: 4, label: 'ISO media' }\n\n/**\n * Accepted content type → the signatures its bytes may legally start with.\n *\n * A type ABSENT from this table is not checked for a match, and that is a\n * deliberate, load-bearing property rather than a gap to be filled with\n * guesses. `text/plain`, `text/csv`, `text/markdown`, `application/json` and\n * `image/svg+xml` are text: they have no magic number, any leading bytes are\n * legal, and inventing a heuristic for them would refuse real files. They are\n * still covered by the executable check above, which is the check that\n * matters for them — an `.exe` renamed `notes.txt` is refused; a CSV that is\n * merely unusual is not.\n */\nconst TYPE_SIGNATURES: Readonly<Record<string, readonly Signature[]>> = {\n 'application/pdf': [{ magic: ascii('%PDF-'), label: 'PDF' }],\n 'application/zip': ZIP_SIGNATURES,\n 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ZIP_SIGNATURES,\n 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ZIP_SIGNATURES,\n 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ZIP_SIGNATURES,\n // Word writes RTF under a `.doc` name often enough that refusing it would\n // be a real false positive, so both containers are legal for this type.\n 'application/msword': [OLE_SIGNATURE, { magic: ascii('{\\\\rtf'), label: 'RTF' }],\n 'application/vnd.ms-excel': [OLE_SIGNATURE],\n 'application/vnd.ms-powerpoint': [OLE_SIGNATURE],\n 'application/rtf': [{ magic: ascii('{\\\\rtf'), label: 'RTF' }],\n\n 'image/png': [{ magic: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], label: 'PNG' }],\n 'image/jpeg': [{ magic: [0xff, 0xd8, 0xff], label: 'JPEG' }],\n 'image/gif': [\n { magic: ascii('GIF87a'), label: 'GIF' },\n { magic: ascii('GIF89a'), label: 'GIF' },\n ],\n 'image/webp': [{ magic: ascii('RIFF'), label: 'WebP' }],\n 'image/bmp': [{ magic: ascii('BM'), label: 'BMP' }],\n 'image/tiff': [\n { magic: [0x49, 0x49, 0x2a, 0x00], label: 'TIFF' },\n { magic: [0x4d, 0x4d, 0x00, 0x2a], label: 'TIFF' },\n ],\n 'image/x-icon': [{ magic: [0x00, 0x00, 0x01, 0x00], label: 'icon' }],\n 'image/vnd.microsoft.icon': [{ magic: [0x00, 0x00, 0x01, 0x00], label: 'icon' }],\n 'image/avif': [ISO_BMFF],\n 'image/heic': [ISO_BMFF],\n 'image/heif': [ISO_BMFF],\n\n 'video/mp4': [ISO_BMFF],\n 'video/quicktime': [ISO_BMFF],\n 'video/webm': [{ magic: [0x1a, 0x45, 0xdf, 0xa3], label: 'Matroska/WebM' }],\n\n // Audio for the Music player (AGL-3716). An MP3 opens with an ID3 tag or\n // straight on an MPEG audio frame (sync bits, then the layer III and\n // MPEG-2/2.5 headers encoders actually write); an AAC file is ADTS frames\n // or the same ID3 tag; M4A is ISO media like MP4; OGG and WAV name their\n // containers.\n 'audio/mpeg': [\n { magic: ascii('ID3'), label: 'MP3' },\n { magic: [0xff, 0xfb], label: 'MP3' },\n { magic: [0xff, 0xfa], label: 'MP3' },\n { magic: [0xff, 0xf3], label: 'MP3' },\n { magic: [0xff, 0xf2], label: 'MP3' },\n { magic: [0xff, 0xe3], label: 'MP3' },\n { magic: [0xff, 0xe2], label: 'MP3' },\n ],\n 'audio/aac': [\n { magic: [0xff, 0xf1], label: 'AAC' },\n { magic: [0xff, 0xf9], label: 'AAC' },\n { magic: ascii('ID3'), label: 'AAC' },\n ],\n 'audio/mp4': [ISO_BMFF],\n 'audio/ogg': [{ magic: ascii('OggS'), label: 'OGG' }],\n 'audio/wav': [{ magic: ascii('RIFF'), label: 'WAV' }],\n\n // A web font (AGL-3656): the theme's font installer stores WOFF2 only.\n 'font/woff2': [{ magic: ascii('wOF2'), label: 'WOFF2' }],\n}\n\n/** The OOXML document types whose archive is scanned for a macro project. */\nconst OOXML_DOCUMENT_TYPES = new Set([\n 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n])\n\n/** The pre-2007 Office types whose OLE directory is scanned for the same. */\nconst LEGACY_OFFICE_TYPES = new Set([\n 'application/msword',\n 'application/vnd.ms-excel',\n 'application/vnd.ms-powerpoint',\n])\n\n/**\n * Does a caller holding only a head window need to fetch the tail as well?\n *\n * Only the macro scan reads the end of a file, and only for document\n * archives, so this is what lets the signed-upload route skip a second ranged\n * GET for the 200 MB videos that route exists to carry — which is every\n * object on it bar a handful of documents.\n */\nexport function uploadInspectionNeedsTail(contentType: string): boolean {\n return (\n OOXML_DOCUMENT_TYPES.has(contentType) || LEGACY_OFFICE_TYPES.has(contentType)\n )\n}\n\nconst indexOfBytes = (haystack: Uint8Array, needle: Uint8Array): number => {\n if (!needle.length || haystack.length < needle.length) return -1\n const last = haystack.length - needle.length\n outer: for (let i = 0; i <= last; i++) {\n for (let j = 0; j < needle.length; j++) {\n if (haystack[i + j] !== needle[j]) continue outer\n }\n return i\n }\n return -1\n}\n\nconst contains = (haystack: Uint8Array, needle: Uint8Array): boolean =>\n indexOfBytes(haystack, needle) >= 0\n\nconst EICAR_BYTES = new Uint8Array(ascii(EICAR_TEST_SIGNATURE))\n\n/** `vbaProject.bin` as an OOXML archive entry name. */\nconst VBA_ENTRY = new Uint8Array(ascii('vbaProject.bin'))\n\n/**\n * `_VBA_PROJECT` as it appears in an OLE directory, which stores stream names\n * as UTF-16LE. Matching the encoded form rather than the ASCII one is what\n * keeps this from firing on a document that merely contains the words.\n */\nconst OLE_VBA_STREAM = new Uint8Array(\n ascii('_VBA_PROJECT').flatMap((code) => [code, 0x00]),\n)\n\nconst describeFile = (fileName?: string | null): string => {\n const name = String(fileName ?? '').trim()\n return name ? `\"${name}\"` : 'This file'\n}\n\n/**\n * Inspect an upload's bytes against the type it claims to be.\n *\n * Returns `null` when nothing structural is wrong — which, to be explicit, is\n * NOT a statement that the file is safe. See the module header.\n */\nexport function inspectUploadBytes(\n input: InspectUploadInput,\n): UploadInspectionRefusal | null {\n const { bytes, contentType, fileName } = input\n const head = bytes ?? new Uint8Array(0)\n // A caller that passed the whole file gets the whole file searched for the\n // trailing structures too; only a ranged caller supplies a separate tail.\n const tail = input.tail ?? head\n const subject = describeFile(fileName)\n\n if (!head.length) {\n return {\n code: 'empty_file',\n detected: 'an empty file',\n message: `${subject} is empty, so there is nothing to store.`,\n }\n }\n\n // 1. The signature list of one. Checked first so that a probe gets the\n // answer it is actually asking for, whatever else is also wrong.\n if (contains(head, EICAR_BYTES) || contains(tail, EICAR_BYTES)) {\n return {\n code: 'signature_match',\n detected: 'the EICAR antivirus test file',\n message:\n `${subject} matches the EICAR test signature and was refused by the ` +\n `upload structure check.`,\n }\n }\n\n // 2. Executable containers, refused under every declared type.\n for (const signature of EXECUTABLE_SIGNATURES) {\n if (at(head, signature)) {\n return {\n code: 'executable_bytes',\n detected: signature.label,\n message:\n `${subject} contains ${signature.label}, which cannot be uploaded ` +\n `whatever it is named or labeled.`,\n }\n }\n }\n\n // 3. The declared type against what the bytes actually are.\n const expected = TYPE_SIGNATURES[contentType]\n if (expected && !expected.some((signature) => at(head, signature))) {\n return {\n code: 'type_mismatch',\n detected: `something other than ${expected[0].label}`,\n message:\n `${subject} is labeled ${contentType} but its contents are not ` +\n `${expected[0].label}. Re-save it in the format it claims to be, or ` +\n `upload it under its real type.`,\n }\n }\n\n // 4. A macro project inside a document whose type is not supposed to have\n // one. This is the check the content-type allowlist could never make.\n if (OOXML_DOCUMENT_TYPES.has(contentType)) {\n if (contains(head, VBA_ENTRY) || contains(tail, VBA_ENTRY)) {\n return {\n code: 'macro_payload',\n detected: 'an embedded macro project',\n message:\n `${subject} contains an embedded macro project. Macro-enabled ` +\n `documents are not accepted — save it without macros and upload ` +\n `it again.`,\n }\n }\n } else if (LEGACY_OFFICE_TYPES.has(contentType)) {\n if (contains(head, OLE_VBA_STREAM) || contains(tail, OLE_VBA_STREAM)) {\n return {\n code: 'macro_payload',\n detected: 'an embedded macro project',\n message:\n `${subject} contains an embedded macro project. Macro-enabled ` +\n `documents are not accepted — save it without macros and upload ` +\n `it again.`,\n }\n }\n }\n\n return null\n}\n\nexport default inspectUploadBytes\n"],"names":["EICAR_TEST_SIGNATURE","UPLOAD_INSPECTION_HEAD_BYTES","UPLOAD_INSPECTION_TAIL_BYTES","at","bytes","signature","offset","length","magic","i","ascii","text","Array","from","character","charCodeAt","EXECUTABLE_SIGNATURES","label","ZIP_SIGNATURES","OLE_SIGNATURE","ISO_BMFF","TYPE_SIGNATURES","OOXML_DOCUMENT_TYPES","Set","LEGACY_OFFICE_TYPES","uploadInspectionNeedsTail","contentType","has","indexOfBytes","haystack","needle","last","outer","j","contains","EICAR_BYTES","Uint8Array","VBA_ENTRY","OLE_VBA_STREAM","flatMap","code","describeFile","fileName","name","String","trim","inspectUploadBytes","input","head","tail","subject","detected","message","expected","some"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/upload-inspection.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * STRUCTURAL upload inspection (AGL-1475).\n *\n * ## What this is, and what it is emphatically not\n *\n * This is **not** an antivirus scanner and no code in this module should ever\n * be described as one. It does not have malware signatures, it does not\n * emulate, unpack or detonate anything, and a novel trojan with a correct PDF\n * header passes it without a murmur. It is *structural validation*: it reads\n * the first few kilobytes of an upload and answers three questions the\n * platform previously could not ask at all.\n *\n * 1. **Are these bytes an executable?** A Windows PE, an ELF binary, a Mach-O\n * binary or an installer package is refused whatever it claims to be. This\n * is the single highest-value check here: the Sept-1 risk is a customer's\n * domain re-serving a trojan to a visitor's browser, and the overwhelming\n * majority of that population is a plain executable with a lying name.\n * 2. **Do the bytes match the declared type?** Media ingress trusted the\n * caller's content type completely — AGL-2463 wrote that gap down. The\n * allowlist bounded what a file *claimed* to be and nothing bounded what it\n * *was*, so `Content-Type: application/pdf` over a `.exe` was stored,\n * hashed, given a CDN path and served. It now has to be a PDF.\n * 3. **Does an Office document carry a macro project?** AGL-1465 kept\n * `.docm`/`.xlsm`/`.pptm` out **by content type**, which is a gate on a\n * string the uploader chooses. Renaming a macro-enabled document to\n * `.docx` walked straight past it. The archive is now checked for a\n * `vbaProject.bin` entry, which is the thing that made those extensions\n * worth excluding in the first place.\n *\n * Plus one signature, EICAR — see {@link EICAR_TEST_SIGNATURE}.\n *\n * ## What it therefore does not catch\n *\n * A malicious PDF that is a real PDF. A macro-free document that exploits a\n * reader. Anything inside a plain `application/zip`, which is a brand kit and\n * may legitimately contain a build tool. An obfuscated dropper in a JSON\n * file. Real malware detection needs signatures and an engine, which needs a\n * ClamAV service, which does not fit the budget this platform runs on — the\n * cost work is in the issue. Nothing here should be allowed to read as though\n * that decision went the other way.\n *\n * ## Why it is pure, and takes a window rather than a file\n *\n * Three of the four media chokepoints hold the whole file in memory and can\n * pass it straight in. The fourth — signed direct-to-storage upload — never\n * sees the bytes, because that is the entire reason it exists: a 200 MB video\n * goes browser → bucket and downloading it back into the function to look at\n * it would cost more than the feature. So that caller does two RANGED reads\n * instead, a few KB from each end, and passes them as {@link\n * InspectUploadInput.bytes} and {@link InspectUploadInput.tail}. Every\n * signature this module knows lives in one of those two windows, so the\n * signed route gets the same verdict for the price of a rounding error.\n */\n\n/**\n * The EICAR standard antivirus test string.\n *\n * A 68-byte printable string, defined by the European Institute for Computer\n * Antivirus Research, that every antivirus product agrees to flag and that is\n * completely inert. It exists so that a pipeline can be tested end to end\n * without anyone handling a live sample.\n *\n * It is matched here for exactly two reasons, and neither is malware\n * detection. First, it makes this module's own tests honest without checking\n * a real sample into the repository. Second, it is the probe a security\n * reviewer or a customer will actually try, and answering it with silence\n * would be a worse lie than answering it with a refusal — a refusal that says\n * \"structural check\" is true, where a silent accept invites the reader to\n * conclude nothing is checked at all.\n *\n * **This is a signature list of one. It is not an engine.** Do not add a\n * second entry here and start calling the result malware scanning; if real\n * signatures are wanted, they belong in a scanner service, not in a string\n * constant.\n */\nexport const EICAR_TEST_SIGNATURE =\n 'X5O!P%@AP[4\\\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*'\n\n/**\n * How many leading bytes an inspection needs.\n *\n * Every magic number checked here sits in the first 16 bytes; the window is\n * far larger so that the EICAR match and the small-file macro scan have room,\n * and because the signed route's ranged GET costs the same for 4 KB as for\n * 64 bytes.\n */\nexport const UPLOAD_INSPECTION_HEAD_BYTES = 4096\n\n/**\n * How many trailing bytes an inspection wants for an Office document.\n *\n * A ZIP's central directory — the authoritative list of what is inside it —\n * lives at the end of the file. 64 KB covers the directory of any document\n * archive this platform accepts (the largest ceiling is a 50 MB `.pptx`, and\n * its directory is a few KB even with hundreds of embedded images). A\n * document whose directory is somehow larger than this window degrades to\n * \"macro not found\", which is the pre-existing behaviour, not a regression.\n */\nexport const UPLOAD_INSPECTION_TAIL_BYTES = 65536\n\nexport type UploadInspectionCode =\n | 'signature_match'\n | 'executable_bytes'\n | 'type_mismatch'\n | 'macro_payload'\n | 'empty_file'\n\nexport interface UploadInspectionRefusal {\n /** Stable machine code — for the API error body, logs and tests. */\n code: UploadInspectionCode\n /** What the bytes turned out to be, in words rather than hex. */\n detected: string\n /**\n * The sentence the uploader sees. Names the file and what was found, so\n * that a merchant who did nothing wrong can tell which of fifty dropped\n * files was the problem and why.\n */\n message: string\n}\n\nexport interface InspectUploadInput {\n /**\n * The whole file, or its leading {@link UPLOAD_INSPECTION_HEAD_BYTES}.\n * Callers holding the whole file pass it and omit `tail`.\n */\n bytes: Uint8Array\n /**\n * The file's trailing bytes, supplied ONLY by a caller that passed a head\n * window rather than the whole file. When absent, `bytes` is searched for\n * the trailing structures too — which is correct precisely because a caller\n * that omits it is one that handed over the entire file.\n */\n tail?: Uint8Array | null\n /** The type the upload claims to be, already normalized by the caller. */\n contentType: string\n /** For the message only. Never used to decide anything. */\n fileName?: string | null\n}\n\ninterface Signature {\n /** Bytes to match. */\n magic: readonly number[]\n /** Where they start. */\n offset?: number\n /** Human name for the refusal message. */\n label: string\n}\n\nconst at = (bytes: Uint8Array, signature: Signature): boolean => {\n const offset = signature.offset ?? 0\n if (bytes.length < offset + signature.magic.length) return false\n for (let i = 0; i < signature.magic.length; i++) {\n if (bytes[offset + i] !== signature.magic[i]) return false\n }\n return true\n}\n\nconst ascii = (text: string): number[] =>\n Array.from(text, (character) => character.charCodeAt(0))\n\n/**\n * Executable and installer containers, refused whatever the upload claims to\n * be. This list is deliberately about CONTAINERS, not content: each entry is\n * a format whose entire purpose is to be run, and none of them has any\n * business in a media library under any content type.\n *\n * `#!` is NOT here on purpose. A shebang is two characters that a legitimate\n * `text/plain` upload can genuinely open with, it is not executable in a\n * browser, and the type-mismatch check below already refuses it under every\n * binary type. Refusing it outright would buy nothing and cost real uploads.\n */\nconst EXECUTABLE_SIGNATURES: readonly Signature[] = [\n { magic: ascii('MZ'), label: 'a Windows executable' },\n { magic: [0x7f, 0x45, 0x4c, 0x46], label: 'a Linux executable (ELF)' },\n { magic: [0xfe, 0xed, 0xfa, 0xce], label: 'a macOS executable (Mach-O)' },\n { magic: [0xfe, 0xed, 0xfa, 0xcf], label: 'a macOS executable (Mach-O)' },\n { magic: [0xce, 0xfa, 0xed, 0xfe], label: 'a macOS executable (Mach-O)' },\n { magic: [0xcf, 0xfa, 0xed, 0xfe], label: 'a macOS executable (Mach-O)' },\n // 0xCAFEBABE is both a Java class file and a multi-architecture Mach-O\n // binary. Both are code; the label names the likelier one for a DAM.\n { magic: [0xca, 0xfe, 0xba, 0xbe], label: 'a compiled program' },\n { magic: ascii('!<arch>'), label: 'an installer package' },\n { magic: [0xed, 0xab, 0xee, 0xdb], label: 'an installer package (RPM)' },\n // Windows shortcut — a .lnk is a launcher, and a classic phishing payload.\n { magic: [0x4c, 0x00, 0x00, 0x00, 0x01, 0x14, 0x02, 0x00], label: 'a Windows shortcut' },\n]\n\n/** ZIP, in its three legal opening forms. Every OOXML document is one. */\nconst ZIP_SIGNATURES: readonly Signature[] = [\n { magic: [0x50, 0x4b, 0x03, 0x04], label: 'ZIP' },\n { magic: [0x50, 0x4b, 0x05, 0x06], label: 'ZIP' },\n { magic: [0x50, 0x4b, 0x07, 0x08], label: 'ZIP' },\n]\n\n/** Microsoft's pre-2007 container: legacy `.doc`, `.xls`, `.ppt` and `.msi`. */\nconst OLE_SIGNATURE: Signature = {\n magic: [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1],\n label: 'a legacy Office document',\n}\n\n/** ISO base media (`....ftyp`) — mp4, QuickTime, and the modern image codecs. */\nconst ISO_BMFF: Signature = { magic: ascii('ftyp'), offset: 4, label: 'ISO media' }\n\n/**\n * Accepted content type → the signatures its bytes may legally start with.\n *\n * A type ABSENT from this table is not checked for a match, and that is a\n * deliberate, load-bearing property rather than a gap to be filled with\n * guesses. `text/plain`, `text/csv`, `text/markdown`, `application/json` and\n * `image/svg+xml` are text: they have no magic number, any leading bytes are\n * legal, and inventing a heuristic for them would refuse real files. They are\n * still covered by the executable check above, which is the check that\n * matters for them — an `.exe` renamed `notes.txt` is refused; a CSV that is\n * merely unusual is not.\n */\nconst TYPE_SIGNATURES: Readonly<Record<string, readonly Signature[]>> = {\n 'application/pdf': [{ magic: ascii('%PDF-'), label: 'PDF' }],\n 'application/zip': ZIP_SIGNATURES,\n 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ZIP_SIGNATURES,\n 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ZIP_SIGNATURES,\n 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ZIP_SIGNATURES,\n // Word writes RTF under a `.doc` name often enough that refusing it would\n // be a real false positive, so both containers are legal for this type.\n 'application/msword': [OLE_SIGNATURE, { magic: ascii('{\\\\rtf'), label: 'RTF' }],\n 'application/vnd.ms-excel': [OLE_SIGNATURE],\n 'application/vnd.ms-powerpoint': [OLE_SIGNATURE],\n 'application/rtf': [{ magic: ascii('{\\\\rtf'), label: 'RTF' }],\n\n 'image/png': [{ magic: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], label: 'PNG' }],\n 'image/jpeg': [{ magic: [0xff, 0xd8, 0xff], label: 'JPEG' }],\n 'image/gif': [\n { magic: ascii('GIF87a'), label: 'GIF' },\n { magic: ascii('GIF89a'), label: 'GIF' },\n ],\n 'image/webp': [{ magic: ascii('RIFF'), label: 'WebP' }],\n 'image/bmp': [{ magic: ascii('BM'), label: 'BMP' }],\n 'image/tiff': [\n { magic: [0x49, 0x49, 0x2a, 0x00], label: 'TIFF' },\n { magic: [0x4d, 0x4d, 0x00, 0x2a], label: 'TIFF' },\n ],\n 'image/x-icon': [{ magic: [0x00, 0x00, 0x01, 0x00], label: 'icon' }],\n 'image/vnd.microsoft.icon': [{ magic: [0x00, 0x00, 0x01, 0x00], label: 'icon' }],\n 'image/avif': [ISO_BMFF],\n 'image/heic': [ISO_BMFF],\n 'image/heif': [ISO_BMFF],\n\n 'video/mp4': [ISO_BMFF],\n 'video/quicktime': [ISO_BMFF],\n 'video/webm': [{ magic: [0x1a, 0x45, 0xdf, 0xa3], label: 'Matroska/WebM' }],\n\n // Audio for the Music player (AGL-3716). An MP3 opens with an ID3 tag or\n // straight on an MPEG audio frame (sync bits, then the layer III and\n // MPEG-2/2.5 headers encoders actually write); an AAC file is ADTS frames\n // or the same ID3 tag; M4A is ISO media like MP4; OGG and WAV name their\n // containers.\n 'audio/mpeg': [\n { magic: ascii('ID3'), label: 'MP3' },\n { magic: [0xff, 0xfb], label: 'MP3' },\n { magic: [0xff, 0xfa], label: 'MP3' },\n { magic: [0xff, 0xf3], label: 'MP3' },\n { magic: [0xff, 0xf2], label: 'MP3' },\n { magic: [0xff, 0xe3], label: 'MP3' },\n { magic: [0xff, 0xe2], label: 'MP3' },\n ],\n 'audio/aac': [\n { magic: [0xff, 0xf1], label: 'AAC' },\n { magic: [0xff, 0xf9], label: 'AAC' },\n { magic: ascii('ID3'), label: 'AAC' },\n ],\n 'audio/mp4': [ISO_BMFF],\n 'audio/ogg': [{ magic: ascii('OggS'), label: 'OGG' }],\n 'audio/wav': [{ magic: ascii('RIFF'), label: 'WAV' }],\n\n // A web font (AGL-3656): the theme's font installer stores WOFF2 only.\n 'font/woff2': [{ magic: ascii('wOF2'), label: 'WOFF2' }],\n}\n\n/** The OOXML document types whose archive is scanned for a macro project. */\nconst OOXML_DOCUMENT_TYPES = new Set([\n 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n])\n\n/** The pre-2007 Office types whose OLE directory is scanned for the same. */\nconst LEGACY_OFFICE_TYPES = new Set([\n 'application/msword',\n 'application/vnd.ms-excel',\n 'application/vnd.ms-powerpoint',\n])\n\n/** The photo types {@link sniffPhotoContentType} tells apart, in the order it checks them. */\nconst SNIFFED_PHOTO_TYPES = ['image/jpeg', 'image/png', 'image/gif'] as const\n\n/** WebP's own mark after its RIFF header: a RIFF file is a WebP only where it says so. */\nconst WEBP_MARK: Signature = { magic: ascii('WEBP'), offset: 8, label: 'WebP' }\n\n/**\n * The photo type a file's own bytes say it is (AGL-3923): JPEG, PNG, GIF or\n * WebP, by the signatures {@link inspectUploadBytes} holds a declared type to,\n * so a file labeled with what this returns passes the type check. `null` for\n * any other bytes. A library's label is not proof: Pixabay served photo\n * 10157445 as image/png, the upload check refused it as not PNG, and the\n * beta.267 Brightvale start lost every stock photo it had chosen.\n */\nexport function sniffPhotoContentType(bytes: Uint8Array): string | null {\n for (const type of SNIFFED_PHOTO_TYPES) {\n if (TYPE_SIGNATURES[type]?.some((signature) => at(bytes, signature))) return type\n }\n const riff = TYPE_SIGNATURES['image/webp']?.[0]\n if (riff && at(bytes, riff) && at(bytes, WEBP_MARK)) return 'image/webp'\n return null\n}\n\n/**\n * Does a caller holding only a head window need to fetch the tail as well?\n *\n * Only the macro scan reads the end of a file, and only for document\n * archives, so this is what lets the signed-upload route skip a second ranged\n * GET for the 200 MB videos that route exists to carry — which is every\n * object on it bar a handful of documents.\n */\nexport function uploadInspectionNeedsTail(contentType: string): boolean {\n return (\n OOXML_DOCUMENT_TYPES.has(contentType) || LEGACY_OFFICE_TYPES.has(contentType)\n )\n}\n\nconst indexOfBytes = (haystack: Uint8Array, needle: Uint8Array): number => {\n if (!needle.length || haystack.length < needle.length) return -1\n const last = haystack.length - needle.length\n outer: for (let i = 0; i <= last; i++) {\n for (let j = 0; j < needle.length; j++) {\n if (haystack[i + j] !== needle[j]) continue outer\n }\n return i\n }\n return -1\n}\n\nconst contains = (haystack: Uint8Array, needle: Uint8Array): boolean =>\n indexOfBytes(haystack, needle) >= 0\n\nconst EICAR_BYTES = new Uint8Array(ascii(EICAR_TEST_SIGNATURE))\n\n/** `vbaProject.bin` as an OOXML archive entry name. */\nconst VBA_ENTRY = new Uint8Array(ascii('vbaProject.bin'))\n\n/**\n * `_VBA_PROJECT` as it appears in an OLE directory, which stores stream names\n * as UTF-16LE. Matching the encoded form rather than the ASCII one is what\n * keeps this from firing on a document that merely contains the words.\n */\nconst OLE_VBA_STREAM = new Uint8Array(\n ascii('_VBA_PROJECT').flatMap((code) => [code, 0x00]),\n)\n\nconst describeFile = (fileName?: string | null): string => {\n const name = String(fileName ?? '').trim()\n return name ? `\"${name}\"` : 'This file'\n}\n\n/**\n * Inspect an upload's bytes against the type it claims to be.\n *\n * Returns `null` when nothing structural is wrong — which, to be explicit, is\n * NOT a statement that the file is safe. See the module header.\n */\nexport function inspectUploadBytes(\n input: InspectUploadInput,\n): UploadInspectionRefusal | null {\n const { bytes, contentType, fileName } = input\n const head = bytes ?? new Uint8Array(0)\n // A caller that passed the whole file gets the whole file searched for the\n // trailing structures too; only a ranged caller supplies a separate tail.\n const tail = input.tail ?? head\n const subject = describeFile(fileName)\n\n if (!head.length) {\n return {\n code: 'empty_file',\n detected: 'an empty file',\n message: `${subject} is empty, so there is nothing to store.`,\n }\n }\n\n // 1. The signature list of one. Checked first so that a probe gets the\n // answer it is actually asking for, whatever else is also wrong.\n if (contains(head, EICAR_BYTES) || contains(tail, EICAR_BYTES)) {\n return {\n code: 'signature_match',\n detected: 'the EICAR antivirus test file',\n message:\n `${subject} matches the EICAR test signature and was refused by the ` +\n `upload structure check.`,\n }\n }\n\n // 2. Executable containers, refused under every declared type.\n for (const signature of EXECUTABLE_SIGNATURES) {\n if (at(head, signature)) {\n return {\n code: 'executable_bytes',\n detected: signature.label,\n message:\n `${subject} contains ${signature.label}, which cannot be uploaded ` +\n `whatever it is named or labeled.`,\n }\n }\n }\n\n // 3. The declared type against what the bytes actually are.\n const expected = TYPE_SIGNATURES[contentType]\n if (expected && !expected.some((signature) => at(head, signature))) {\n return {\n code: 'type_mismatch',\n detected: `something other than ${expected[0].label}`,\n message:\n `${subject} is labeled ${contentType} but its contents are not ` +\n `${expected[0].label}. Re-save it in the format it claims to be, or ` +\n `upload it under its real type.`,\n }\n }\n\n // 4. A macro project inside a document whose type is not supposed to have\n // one. This is the check the content-type allowlist could never make.\n if (OOXML_DOCUMENT_TYPES.has(contentType)) {\n if (contains(head, VBA_ENTRY) || contains(tail, VBA_ENTRY)) {\n return {\n code: 'macro_payload',\n detected: 'an embedded macro project',\n message:\n `${subject} contains an embedded macro project. Macro-enabled ` +\n `documents are not accepted — save it without macros and upload ` +\n `it again.`,\n }\n }\n } else if (LEGACY_OFFICE_TYPES.has(contentType)) {\n if (contains(head, OLE_VBA_STREAM) || contains(tail, OLE_VBA_STREAM)) {\n return {\n code: 'macro_payload',\n detected: 'an embedded macro project',\n message:\n `${subject} contains an embedded macro project. Macro-enabled ` +\n `documents are not accepted — save it without macros and upload ` +\n `it again.`,\n }\n }\n }\n\n return null\n}\n\nexport default inspectUploadBytes\n"],"names":["EICAR_TEST_SIGNATURE","UPLOAD_INSPECTION_HEAD_BYTES","UPLOAD_INSPECTION_TAIL_BYTES","at","bytes","signature","offset","length","magic","i","ascii","text","Array","from","character","charCodeAt","EXECUTABLE_SIGNATURES","label","ZIP_SIGNATURES","OLE_SIGNATURE","ISO_BMFF","TYPE_SIGNATURES","OOXML_DOCUMENT_TYPES","Set","LEGACY_OFFICE_TYPES","SNIFFED_PHOTO_TYPES","WEBP_MARK","sniffPhotoContentType","type","some","riff","uploadInspectionNeedsTail","contentType","has","indexOfBytes","haystack","needle","last","outer","j","contains","EICAR_BYTES","Uint8Array","VBA_ENTRY","OLE_VBA_STREAM","flatMap","code","describeFile","fileName","name","String","trim","inspectUploadBytes","input","head","tail","subject","detected","message","expected"],"mappings":"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"}
@@ -1,4 +1,5 @@
1
1
  import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ var _globalScope_REGISTRY_KEY;
2
3
  /**
3
4
  * @license
4
5
  * Copyright 2026 Aglyn LLC
@@ -28,7 +29,15 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
28
29
  * two plugins, or a plugin and core, from claiming one settings row.
29
30
  * Registering the same type again replaces it in place, so a declarations
30
31
  * module evaluated twice registers once.
31
- */ const registrations = new Map();
32
+ */ /**
33
+ * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).
34
+ * The alert types are registered at boot, from `instrumentation.ts`, and Next
35
+ * compiles that file apart from the routes, so the boot and a route each hold
36
+ * their own copy of this module. At module scope, the boot filled its copy and
37
+ * every route read an empty one (AGL-3412, AGL-3868).
38
+ */ const REGISTRY_KEY = Symbol.for('@aglyn/aglyn:operator-alerts');
39
+ const globalScope = globalThis;
40
+ const registrations = (_globalScope_REGISTRY_KEY = globalScope[REGISTRY_KEY]) != null ? _globalScope_REGISTRY_KEY : globalScope[REGISTRY_KEY] = new Map();
32
41
  const CORE_TYPES = new Set(CORE_OPERATOR_ALERTS.map((entry)=>entry.type));
33
42
  /**
34
43
  * Registers a plugin's alert types. Throws on a type outside the plugin's
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/operator-alerts.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n CORE_OPERATOR_ALERTS,\n type OperatorAlertDefinition,\n} from '../app-utils/operator-alerts'\n\n/**\n * Operator alerts a PLUGIN contributes (AGL-3377), beside core's own\n * `CORE_OPERATOR_ALERTS`.\n *\n * A plugin declares its alert types here, from its server declarations, in\n * the same shape core uses, and raises them through `raiseOperatorAlert`.\n * Core lists no plugin's types, so a plugin that is not installed adds\n * nothing to Staff → Operator alerts and a new plugin needs no core change.\n *\n * A plugin's types must start with its own id and a dot, which is what keeps\n * two plugins, or a plugin and core, from claiming one settings row.\n * Registering the same type again replaces it in place, so a declarations\n * module evaluated twice registers once.\n */\n\nconst registrations = new Map<string, OperatorAlertDefinition>()\n\nconst CORE_TYPES = new Set(CORE_OPERATOR_ALERTS.map((entry) => entry.type))\n\n/**\n * Registers a plugin's alert types. Throws on a type outside the plugin's\n * namespace or one core already owns: both are a programming error the\n * plugin's own tests should meet, not something to discover in production.\n */\nexport function registerOperatorAlerts(\n definitions: readonly OperatorAlertDefinition[],\n options: { pluginId: string },\n): void {\n const pluginId = String(options?.pluginId ?? '').trim()\n if (!pluginId) throw new Error('operator alerts need a pluginId')\n for (const definition of definitions) {\n const type = String(definition?.type ?? '').trim()\n if (!type.startsWith(`${pluginId}.`) || type.length <= pluginId.length + 1) {\n throw new Error(\n `operator alert \"${type}\" must be named \"${pluginId}.<name>\" by the plugin that registers it`,\n )\n }\n if (CORE_TYPES.has(type)) {\n throw new Error(`operator alert \"${type}\" is core's`)\n }\n registrations.set(type, { ...definition, type, pluginId })\n }\n}\n\n/** Every alert type: core's, then each plugin's in type order. */\nexport function listOperatorAlerts(): OperatorAlertDefinition[] {\n const contributed = [...registrations.values()].sort((a, b) =>\n a.type < b.type ? -1 : a.type > b.type ? 1 : 0,\n )\n return [...CORE_OPERATOR_ALERTS, ...contributed]\n}\n\n/** One alert type, core's or a registered plugin's. */\nexport function getOperatorAlert(\n type: string,\n): OperatorAlertDefinition | undefined {\n return (\n CORE_OPERATOR_ALERTS.find((entry) => entry.type === type) ??\n registrations.get(type)\n )\n}\n\n/**\n * The alert a staff notification of this type is, if any: the registry\n * entry that names it as its `notificationType`. How `notifyStaff` routes a\n * notification it was handed into the alert pipeline.\n */\nexport function operatorAlertForNotificationType(\n notificationType: string,\n): OperatorAlertDefinition | undefined {\n return listOperatorAlerts().find(\n (entry) => entry.notificationType === notificationType && entry.type === notificationType,\n )\n}\n\n/** Test seam: forget every plugin's registration. */\nexport function resetOperatorAlertsForTests(): void {\n registrations.clear()\n}\n"],"names":["CORE_OPERATOR_ALERTS","registrations","Map","CORE_TYPES","Set","map","entry","type","registerOperatorAlerts","definitions","options","pluginId","String","trim","Error","definition","startsWith","length","has","set","listOperatorAlerts","contributed","values","sort","a","b","getOperatorAlert","find","get","operatorAlertForNotificationType","notificationType","resetOperatorAlertsForTests","clear"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,oBAAoB,QAEf,kCAA8B;AAErC;;;;;;;;;;;;;CAaC,GAED,MAAMC,gBAAgB,IAAIC;AAE1B,MAAMC,aAAa,IAAIC,IAAIJ,qBAAqBK,GAAG,CAAC,CAACC,QAAUA,MAAMC,IAAI;AAEzE;;;;CAIC,GACD,OAAO,SAASC,uBACdC,WAA+C,EAC/CC,OAA6B;;IAE7B,MAAMC,WAAWC,eAAOF,2BAAAA,QAASC,QAAQ,mBAAI,IAAIE,IAAI;IACrD,IAAI,CAACF,UAAU,MAAM,IAAIG,MAAM;IAC/B,KAAK,MAAMC,cAAcN,YAAa;;QACpC,MAAMF,OAAOK,gBAAOG,8BAAAA,WAAYR,IAAI,oBAAI,IAAIM,IAAI;QAChD,IAAI,CAACN,KAAKS,UAAU,CAAC,GAAGL,SAAS,CAAC,CAAC,KAAKJ,KAAKU,MAAM,IAAIN,SAASM,MAAM,GAAG,GAAG;YAC1E,MAAM,IAAIH,MACR,CAAC,gBAAgB,EAAEP,KAAK,iBAAiB,EAAEI,SAAS,wCAAwC,CAAC;QAEjG;QACA,IAAIR,WAAWe,GAAG,CAACX,OAAO;YACxB,MAAM,IAAIO,MAAM,CAAC,gBAAgB,EAAEP,KAAK,WAAW,CAAC;QACtD;QACAN,cAAckB,GAAG,CAACZ,MAAM,aAAKQ;YAAYR;YAAMI;;IACjD;AACF;AAEA,gEAAgE,GAChE,OAAO,SAASS;IACd,MAAMC,cAAc;WAAIpB,cAAcqB,MAAM;KAAG,CAACC,IAAI,CAAC,CAACC,GAAGC,IACvDD,EAAEjB,IAAI,GAAGkB,EAAElB,IAAI,GAAG,CAAC,IAAIiB,EAAEjB,IAAI,GAAGkB,EAAElB,IAAI,GAAG,IAAI;IAE/C,OAAO;WAAIP;WAAyBqB;KAAY;AAClD;AAEA,qDAAqD,GACrD,OAAO,SAASK,iBACdnB,IAAY;QAGVP;IADF,QACEA,6BAAAA,qBAAqB2B,IAAI,CAAC,CAACrB,QAAUA,MAAMC,IAAI,KAAKA,iBAApDP,6BACAC,cAAc2B,GAAG,CAACrB;AAEtB;AAEA;;;;CAIC,GACD,OAAO,SAASsB,iCACdC,gBAAwB;IAExB,OAAOV,qBAAqBO,IAAI,CAC9B,CAACrB,QAAUA,MAAMwB,gBAAgB,KAAKA,oBAAoBxB,MAAMC,IAAI,KAAKuB;AAE7E;AAEA,mDAAmD,GACnD,OAAO,SAASC;IACd9B,cAAc+B,KAAK;AACrB"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/operator-alerts.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n CORE_OPERATOR_ALERTS,\n type OperatorAlertDefinition,\n} from '../app-utils/operator-alerts'\n\n/**\n * Operator alerts a PLUGIN contributes (AGL-3377), beside core's own\n * `CORE_OPERATOR_ALERTS`.\n *\n * A plugin declares its alert types here, from its server declarations, in\n * the same shape core uses, and raises them through `raiseOperatorAlert`.\n * Core lists no plugin's types, so a plugin that is not installed adds\n * nothing to Staff → Operator alerts and a new plugin needs no core change.\n *\n * A plugin's types must start with its own id and a dot, which is what keeps\n * two plugins, or a plugin and core, from claiming one settings row.\n * Registering the same type again replaces it in place, so a declarations\n * module evaluated twice registers once.\n */\n\n/**\n * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).\n * The alert types are registered at boot, from `instrumentation.ts`, and Next\n * compiles that file apart from the routes, so the boot and a route each hold\n * their own copy of this module. At module scope, the boot filled its copy and\n * every route read an empty one (AGL-3412, AGL-3868).\n */\nconst REGISTRY_KEY = Symbol.for('@aglyn/aglyn:operator-alerts')\nconst globalScope = globalThis as typeof globalThis & {\n [REGISTRY_KEY]?: Map<string, OperatorAlertDefinition>\n}\nconst registrations: Map<string, OperatorAlertDefinition> =\n globalScope[REGISTRY_KEY] ?? (globalScope[REGISTRY_KEY] = new Map())\n\nconst CORE_TYPES = new Set(CORE_OPERATOR_ALERTS.map((entry) => entry.type))\n\n/**\n * Registers a plugin's alert types. Throws on a type outside the plugin's\n * namespace or one core already owns: both are a programming error the\n * plugin's own tests should meet, not something to discover in production.\n */\nexport function registerOperatorAlerts(\n definitions: readonly OperatorAlertDefinition[],\n options: { pluginId: string },\n): void {\n const pluginId = String(options?.pluginId ?? '').trim()\n if (!pluginId) throw new Error('operator alerts need a pluginId')\n for (const definition of definitions) {\n const type = String(definition?.type ?? '').trim()\n if (!type.startsWith(`${pluginId}.`) || type.length <= pluginId.length + 1) {\n throw new Error(\n `operator alert \"${type}\" must be named \"${pluginId}.<name>\" by the plugin that registers it`,\n )\n }\n if (CORE_TYPES.has(type)) {\n throw new Error(`operator alert \"${type}\" is core's`)\n }\n registrations.set(type, { ...definition, type, pluginId })\n }\n}\n\n/** Every alert type: core's, then each plugin's in type order. */\nexport function listOperatorAlerts(): OperatorAlertDefinition[] {\n const contributed = [...registrations.values()].sort((a, b) =>\n a.type < b.type ? -1 : a.type > b.type ? 1 : 0,\n )\n return [...CORE_OPERATOR_ALERTS, ...contributed]\n}\n\n/** One alert type, core's or a registered plugin's. */\nexport function getOperatorAlert(\n type: string,\n): OperatorAlertDefinition | undefined {\n return (\n CORE_OPERATOR_ALERTS.find((entry) => entry.type === type) ??\n registrations.get(type)\n )\n}\n\n/**\n * The alert a staff notification of this type is, if any: the registry\n * entry that names it as its `notificationType`. How `notifyStaff` routes a\n * notification it was handed into the alert pipeline.\n */\nexport function operatorAlertForNotificationType(\n notificationType: string,\n): OperatorAlertDefinition | undefined {\n return listOperatorAlerts().find(\n (entry) => entry.notificationType === notificationType && entry.type === notificationType,\n )\n}\n\n/** Test seam: forget every plugin's registration. */\nexport function resetOperatorAlertsForTests(): void {\n registrations.clear()\n}\n"],"names":["globalScope","CORE_OPERATOR_ALERTS","REGISTRY_KEY","Symbol","for","globalThis","registrations","Map","CORE_TYPES","Set","map","entry","type","registerOperatorAlerts","definitions","options","pluginId","String","trim","Error","definition","startsWith","length","has","set","listOperatorAlerts","contributed","values","sort","a","b","getOperatorAlert","find","get","operatorAlertForNotificationType","notificationType","resetOperatorAlertsForTests","clear"],"mappings":";IAiDEA;AAjDF;;;;;;;;;;;;;;;CAeC,GAED,SACEC,oBAAoB,QAEf,kCAA8B;AAErC;;;;;;;;;;;;;CAaC,GAED;;;;;;CAMC,GACD,MAAMC,eAAeC,OAAOC,GAAG,CAAC;AAChC,MAAMJ,cAAcK;AAGpB,MAAMC,iBACJN,4BAAAA,WAAW,CAACE,aAAa,YAAzBF,4BAA8BA,WAAW,CAACE,aAAa,GAAG,IAAIK;AAEhE,MAAMC,aAAa,IAAIC,IAAIR,qBAAqBS,GAAG,CAAC,CAACC,QAAUA,MAAMC,IAAI;AAEzE;;;;CAIC,GACD,OAAO,SAASC,uBACdC,WAA+C,EAC/CC,OAA6B;;IAE7B,MAAMC,WAAWC,eAAOF,2BAAAA,QAASC,QAAQ,mBAAI,IAAIE,IAAI;IACrD,IAAI,CAACF,UAAU,MAAM,IAAIG,MAAM;IAC/B,KAAK,MAAMC,cAAcN,YAAa;;QACpC,MAAMF,OAAOK,gBAAOG,8BAAAA,WAAYR,IAAI,oBAAI,IAAIM,IAAI;QAChD,IAAI,CAACN,KAAKS,UAAU,CAAC,GAAGL,SAAS,CAAC,CAAC,KAAKJ,KAAKU,MAAM,IAAIN,SAASM,MAAM,GAAG,GAAG;YAC1E,MAAM,IAAIH,MACR,CAAC,gBAAgB,EAAEP,KAAK,iBAAiB,EAAEI,SAAS,wCAAwC,CAAC;QAEjG;QACA,IAAIR,WAAWe,GAAG,CAACX,OAAO;YACxB,MAAM,IAAIO,MAAM,CAAC,gBAAgB,EAAEP,KAAK,WAAW,CAAC;QACtD;QACAN,cAAckB,GAAG,CAACZ,MAAM,aAAKQ;YAAYR;YAAMI;;IACjD;AACF;AAEA,gEAAgE,GAChE,OAAO,SAASS;IACd,MAAMC,cAAc;WAAIpB,cAAcqB,MAAM;KAAG,CAACC,IAAI,CAAC,CAACC,GAAGC,IACvDD,EAAEjB,IAAI,GAAGkB,EAAElB,IAAI,GAAG,CAAC,IAAIiB,EAAEjB,IAAI,GAAGkB,EAAElB,IAAI,GAAG,IAAI;IAE/C,OAAO;WAAIX;WAAyByB;KAAY;AAClD;AAEA,qDAAqD,GACrD,OAAO,SAASK,iBACdnB,IAAY;QAGVX;IADF,QACEA,6BAAAA,qBAAqB+B,IAAI,CAAC,CAACrB,QAAUA,MAAMC,IAAI,KAAKA,iBAApDX,6BACAK,cAAc2B,GAAG,CAACrB;AAEtB;AAEA;;;;CAIC,GACD,OAAO,SAASsB,iCACdC,gBAAwB;IAExB,OAAOV,qBAAqBO,IAAI,CAC9B,CAACrB,QAAUA,MAAMwB,gBAAgB,KAAKA,oBAAoBxB,MAAMC,IAAI,KAAKuB;AAE7E;AAEA,mDAAmD,GACnD,OAAO,SAASC;IACd9B,cAAc+B,KAAK;AACrB"}
@@ -31,7 +31,16 @@
31
31
  * there. Values are coerced defensively on merge — the settings doc is
32
32
  * client-writable (org managers), so readers never trust its types.
33
33
  */ import { _ as _extends } from "@swc/helpers/_/_extends";
34
- const schemas = new Map();
34
+ var _globalScope_REGISTRY_KEY;
35
+ /**
36
+ * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).
37
+ * The config schemas are registered at boot, from `instrumentation.ts`, and Next
38
+ * compiles that file apart from the routes, so the boot and a route each hold
39
+ * their own copy of this module. At module scope, the boot filled its copy and
40
+ * every route read an empty one (AGL-3412, AGL-3868).
41
+ */ const REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-config-schemas');
42
+ const globalScope = globalThis;
43
+ const schemas = (_globalScope_REGISTRY_KEY = globalScope[REGISTRY_KEY]) != null ? _globalScope_REGISTRY_KEY : globalScope[REGISTRY_KEY] = new Map();
35
44
  /** Idempotent by pluginId — re-registration replaces the schema. */ export function registerPluginConfigSchema(schema) {
36
45
  schemas.set(schema.pluginId, schema);
37
46
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-config.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Per-plugin configuration (AGL-428) — Strapi `config` parity. A plugin\n * DECLARES its settings schema (fields + defaults + validation) at module\n * scope; the org stores overrides in `orgs/{orgId}/pluginSettings/\n * {pluginId}`; every consumer reads the DEFAULTS-MERGED view:\n *\n * - server handlers via `getPluginConfig(orgId, pluginId)`\n * (tenant-data-admin);\n * - client surfaces via `usePluginConfig(orgId, pluginId)`\n * (tenant-feature-instance);\n * - the console's Plugins & add-ons hub renders a generic settings form\n * from the schema, so a plugin gets a settings UI without writing one.\n *\n * Register at MODULE SCOPE in a file imported by both the client barrel\n * and the `/server` entry, so whichever surface loads first the schema is\n * there. Values are coerced defensively on merge — the settings doc is\n * client-writable (org managers), so readers never trust its types.\n */\n\nexport interface PluginConfigField {\n key: string\n label: string\n type: 'string' | 'number' | 'boolean' | 'select'\n description?: string\n /** For `select` fields. */\n options?: Array<{ value: string; label: string }>\n /** For `number` fields. */\n min?: number\n max?: number\n}\n\nexport interface PluginConfigSchema {\n pluginId: string\n fields: PluginConfigField[]\n defaults: Record<string, unknown>\n /**\n * What an operator must read before filling the form in (AGL-3700) — a\n * limit the settings cannot express, such as what a translation does NOT\n * do for search. Shown above the fields at both scopes.\n */\n notice?: string\n /**\n * The settings change what a published page serves (AGL-3700). A site-scope\n * save then drops that site's cached pages, so the change is live at once\n * rather than within the page cache's window.\n */\n affectsPublishedPages?: boolean\n /**\n * The settings card's own docs heading (AGL-3700): a docs help topic key and\n * one of that page's anchors. Each card's `?` names its own section, so a\n * plugin that documents its settings points here; without it the card links\n * the generic plugin-settings section. Names only — the console resolves\n * them against its registry, so no docs prose rides a site bundle.\n */\n help?: { topic: string; anchor: `#${string}` }\n /** Cross-field validation; returns an error message or null. */\n validate?: (values: Record<string, unknown>) => string | null\n}\n\nconst schemas = new Map<string, PluginConfigSchema>()\n\n/** Idempotent by pluginId — re-registration replaces the schema. */\nexport function registerPluginConfigSchema(schema: PluginConfigSchema): void {\n schemas.set(schema.pluginId, schema)\n}\n\nexport function getPluginConfigSchema(\n pluginId: string,\n): PluginConfigSchema | undefined {\n return schemas.get(pluginId)\n}\n\nexport function listPluginConfigSchemas(): PluginConfigSchema[] {\n return [...schemas.values()]\n}\n\nconst coerce = (\n field: PluginConfigField,\n raw: unknown,\n fallback: unknown,\n): unknown => {\n switch (field.type) {\n case 'boolean':\n return typeof raw === 'boolean' ? raw : fallback\n case 'number': {\n const value = typeof raw === 'number' && Number.isFinite(raw) ? raw : NaN\n if (Number.isNaN(value)) return fallback\n if (field.min != null && value < field.min) return field.min\n if (field.max != null && value > field.max) return field.max\n return value\n }\n case 'select': {\n const value = typeof raw === 'string' ? raw : undefined\n return field.options?.some((option) => option.value === value)\n ? value\n : fallback\n }\n default:\n return typeof raw === 'string' ? raw : fallback\n }\n}\n\n/**\n * The defaults-merged, type-coerced config view. Safe against a missing\n * or junk settings doc — unknown keys are dropped, wrong-typed values\n * fall back to the declared default.\n */\nexport function mergePluginConfig(\n schema: PluginConfigSchema,\n stored: Record<string, unknown> | null | undefined,\n): Record<string, unknown> {\n const merged: Record<string, unknown> = { ...schema.defaults }\n if (!stored) return merged\n for (const field of schema.fields) {\n if (field.key in stored) {\n merged[field.key] = coerce(\n field,\n stored[field.key],\n schema.defaults[field.key],\n )\n }\n }\n return merged\n}\n\n/** Pre-save validation for the settings UI (coerce + custom validate). */\nexport function validatePluginConfigValues(\n schema: PluginConfigSchema,\n values: Record<string, unknown>,\n): { ok: boolean; error?: string } {\n const merged = mergePluginConfig(schema, values)\n const error = schema.validate?.(merged) ?? null\n return error ? { ok: false, error } : { ok: true }\n}\n\nconst MANIFEST_CONFIG_FIELD_LIMIT = 50\nconst FIELD_TYPES: ReadonlySet<string> = new Set([\n 'string',\n 'number',\n 'boolean',\n 'select',\n])\n\n/**\n * A plugin's declared fields, read from its MANIFEST rather than the registry\n * (AGL-428).\n *\n * The registry is a module-scope map, filled by `registerPluginConfigSchema`\n * at import time. A marketplace plugin cannot fill it: its bundle runs in a\n * sandboxed frame on a separate origin and never executes in the console\n * process, so there is no moment at which its registration could happen. The\n * settings form therefore rendered NOTHING for every plugin a workspace\n * installed, and `resolvePluginConfig` — which is entirely plugin-agnostic and\n * would have layered a third-party plugin's org default under a per-site\n * override without changing a line — was never reached for one.\n *\n * The manifest is denormalized onto every install pin, so it is the one\n * declaration of a marketplace plugin the console already holds.\n *\n * Everything here is re-validated rather than trusted. The manifest is\n * publisher-authored, it is stored on a client-readable document, and a form\n * built from it is a form an org manager saves through — so a junk `type`\n * drops the field rather than rendering an unknown control, and the field\n * count is capped so one manifest cannot turn a settings page into thousands\n * of inputs. Being reviewed once is not the same as being safe to render\n * verbatim forever.\n *\n * Returns `undefined` when the manifest declares nothing usable, which callers\n * must treat as \"this plugin has no settings\" rather than as an error: most\n * plugins take none.\n */\nexport function pluginConfigSchemaFromManifest(\n pluginId: string,\n manifest: unknown,\n): PluginConfigSchema | undefined {\n if (!pluginId) return undefined\n const declared = (manifest as { config?: unknown } | null | undefined)?.config\n const rawFields = (declared as { fields?: unknown } | undefined)?.fields\n if (!Array.isArray(rawFields)) return undefined\n const seen = new Set<string>()\n const fields: PluginConfigField[] = []\n for (const raw of rawFields) {\n if (fields.length >= MANIFEST_CONFIG_FIELD_LIMIT) break\n const entry = raw as Record<string, unknown> | null\n const key = typeof entry?.['key'] === 'string' ? entry['key'].trim() : ''\n // A duplicate key would give two controls one storage slot, so the second\n // is dropped rather than allowed to shadow the first.\n if (!key || seen.has(key)) continue\n const type = String(entry?.['type'] ?? 'string')\n if (!FIELD_TYPES.has(type)) continue\n const options = Array.isArray(entry?.['options'])\n ? (entry['options'] as unknown[])\n .map((option) => option as Record<string, unknown> | null)\n .filter(\n (option) =>\n typeof option?.['value'] === 'string' &&\n typeof option?.['label'] === 'string',\n )\n .map((option) => ({\n value: String(option?.['value']),\n label: String(option?.['label']),\n }))\n : undefined\n // A `select` with nothing to select is a dropdown that cannot be answered,\n // so it is dropped rather than rendered empty.\n if (type === 'select' && !options?.length) continue\n seen.add(key)\n fields.push({\n key,\n label:\n typeof entry?.['label'] === 'string' && entry['label'].trim()\n ? String(entry['label']).slice(0, 120)\n : key,\n type: type as PluginConfigField['type'],\n description:\n typeof entry?.['description'] === 'string'\n ? String(entry['description']).slice(0, 400)\n : undefined,\n options,\n min: typeof entry?.['min'] === 'number' ? entry['min'] : undefined,\n max: typeof entry?.['max'] === 'number' ? entry['max'] : undefined,\n })\n }\n if (!fields.length) return undefined\n const rawDefaults = (declared as { defaults?: unknown } | undefined)?.defaults\n const defaults: Record<string, unknown> = {}\n for (const field of fields) {\n // Defaults are coerced through the same path stored values take, so a\n // manifest default of the wrong type cannot become the value a site\n // inherits. An absent one falls back to the type's empty value rather than\n // `undefined`, which `pluginConfigOverrides` reads as \"not answered\".\n const supplied =\n rawDefaults && typeof rawDefaults === 'object'\n ? (rawDefaults as Record<string, unknown>)[field.key]\n : undefined\n const empty =\n field.type === 'boolean'\n ? false\n : field.type === 'number'\n ? (field.min ?? 0)\n : field.type === 'select'\n ? (field.options?.[0]?.value ?? '')\n : ''\n defaults[field.key] = coerce(field, supplied, empty)\n }\n return { pluginId, fields, defaults }\n}\n\n/**\n * The three levels a plugin setting can be answered at.\n *\n * A workspace sets a value once and every site it enabled the plugin on\n * follows it; a site that needs a different answer overrides that one field\n * and keeps inheriting the rest. Bookings is the shape this is for — one\n * booking horizon across a chain, with the flagship branch taking bookings\n * further out — and it generalizes to every plugin because none of them get\n * to invent their own inheritance.\n */\nexport interface PluginConfigSources {\n /** `orgs/{orgId}/pluginSettings/{pluginId}` — the workspace's answer. */\n org?: Record<string, unknown> | null\n /** `hosts/{hostId}/pluginSettings/{pluginId}` — only the keys this site overrides. */\n host?: Record<string, unknown> | null\n}\n\n/**\n * Which keys a site is answering for itself.\n *\n * A key is an override when it is PRESENT on the host document, and inherited\n * when it is absent. `undefined` counts as absent, deliberately: a UI that\n * writes `{key: undefined}` to mean \"stop overriding\" is doing the same thing\n * as one that never wrote the key, and treating the two differently would make\n * \"revert to the workspace value\" depend on which code path cleared it.\n *\n * ⚠️ Which is also why clearing an override must DELETE the key rather than\n * write an empty value. `setDoc(..., {merge: true})` leaves an existing field\n * exactly as it is when the new object omits it, so a form that drops empty\n * inputs cannot clear anything by saving — the override survives, invisibly,\n * and the site keeps ignoring a workspace value the operator has since\n * changed. Deleting is the only write that returns a field to inherited.\n *\n * Keys the schema does not declare are ignored, so a stale field left behind\n * by a plugin update does not read as an override of a setting that no longer\n * exists.\n */\nexport function pluginConfigOverrides(\n schema: PluginConfigSchema,\n host: Record<string, unknown> | null | undefined,\n): string[] {\n if (!host) return []\n return schema.fields\n .filter((field) => field.key in host && host[field.key] !== undefined)\n .map((field) => field.key)\n}\n\n/**\n * What a plugin actually runs with on one site.\n *\n * Schema defaults, then the workspace's stored values, then only the keys the\n * site overrides — each layer narrowing the one before it. Type coercion\n * happens once at the end rather than per level, so a site override of the\n * wrong type falls back to the WORKSPACE value it was trying to replace\n * instead of skipping past it to the schema default. Getting that backwards\n * would have one malformed site setting silently discard a workspace answer\n * the operator can see in their own console.\n */\nexport function resolvePluginConfig(\n schema: PluginConfigSchema,\n sources: PluginConfigSources,\n): Record<string, unknown> {\n const org = mergePluginConfig(schema, sources.org)\n const overrides = pluginConfigOverrides(schema, sources.host)\n if (!overrides.length) return org\n const host = sources.host as Record<string, unknown>\n const merged: Record<string, unknown> = { ...org }\n for (const field of schema.fields) {\n if (!overrides.includes(field.key)) continue\n merged[field.key] = coerce(field, host[field.key], org[field.key])\n }\n return merged\n}\n"],"names":["schemas","Map","registerPluginConfigSchema","schema","set","pluginId","getPluginConfigSchema","get","listPluginConfigSchemas","values","coerce","field","raw","fallback","type","value","Number","isFinite","NaN","isNaN","min","max","undefined","options","some","option","mergePluginConfig","stored","merged","defaults","fields","key","validatePluginConfigValues","error","validate","ok","MANIFEST_CONFIG_FIELD_LIMIT","FIELD_TYPES","Set","pluginConfigSchemaFromManifest","manifest","declared","config","rawFields","Array","isArray","seen","length","entry","trim","has","String","map","filter","label","add","push","slice","description","rawDefaults","supplied","empty","pluginConfigOverrides","host","resolvePluginConfig","sources","org","overrides","includes"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-config.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Per-plugin configuration (AGL-428) — Strapi `config` parity. A plugin\n * DECLARES its settings schema (fields + defaults + validation) at module\n * scope; the org stores overrides in `orgs/{orgId}/pluginSettings/\n * {pluginId}`; every consumer reads the DEFAULTS-MERGED view:\n *\n * - server handlers via `getPluginConfig(orgId, pluginId)`\n * (tenant-data-admin);\n * - client surfaces via `usePluginConfig(orgId, pluginId)`\n * (tenant-feature-instance);\n * - the console's Plugins & add-ons hub renders a generic settings form\n * from the schema, so a plugin gets a settings UI without writing one.\n *\n * Register at MODULE SCOPE in a file imported by both the client barrel\n * and the `/server` entry, so whichever surface loads first the schema is\n * there. Values are coerced defensively on merge — the settings doc is\n * client-writable (org managers), so readers never trust its types.\n */\n\nexport interface PluginConfigField {\n key: string\n label: string\n type: 'string' | 'number' | 'boolean' | 'select'\n description?: string\n /** For `select` fields. */\n options?: Array<{ value: string; label: string }>\n /** For `number` fields. */\n min?: number\n max?: number\n}\n\nexport interface PluginConfigSchema {\n pluginId: string\n fields: PluginConfigField[]\n defaults: Record<string, unknown>\n /**\n * What an operator must read before filling the form in (AGL-3700) — a\n * limit the settings cannot express, such as what a translation does NOT\n * do for search. Shown above the fields at both scopes.\n */\n notice?: string\n /**\n * The settings change what a published page serves (AGL-3700). A site-scope\n * save then drops that site's cached pages, so the change is live at once\n * rather than within the page cache's window.\n */\n affectsPublishedPages?: boolean\n /**\n * The settings card's own docs heading (AGL-3700): a docs help topic key and\n * one of that page's anchors. Each card's `?` names its own section, so a\n * plugin that documents its settings points here; without it the card links\n * the generic plugin-settings section. Names only — the console resolves\n * them against its registry, so no docs prose rides a site bundle.\n */\n help?: { topic: string; anchor: `#${string}` }\n /** Cross-field validation; returns an error message or null. */\n validate?: (values: Record<string, unknown>) => string | null\n}\n\n/**\n * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).\n * The config schemas are registered at boot, from `instrumentation.ts`, and Next\n * compiles that file apart from the routes, so the boot and a route each hold\n * their own copy of this module. At module scope, the boot filled its copy and\n * every route read an empty one (AGL-3412, AGL-3868).\n */\nconst REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-config-schemas')\nconst globalScope = globalThis as typeof globalThis & {\n [REGISTRY_KEY]?: Map<string, PluginConfigSchema>\n}\nconst schemas: Map<string, PluginConfigSchema> =\n globalScope[REGISTRY_KEY] ?? (globalScope[REGISTRY_KEY] = new Map())\n\n/** Idempotent by pluginId — re-registration replaces the schema. */\nexport function registerPluginConfigSchema(schema: PluginConfigSchema): void {\n schemas.set(schema.pluginId, schema)\n}\n\nexport function getPluginConfigSchema(\n pluginId: string,\n): PluginConfigSchema | undefined {\n return schemas.get(pluginId)\n}\n\nexport function listPluginConfigSchemas(): PluginConfigSchema[] {\n return [...schemas.values()]\n}\n\nconst coerce = (\n field: PluginConfigField,\n raw: unknown,\n fallback: unknown,\n): unknown => {\n switch (field.type) {\n case 'boolean':\n return typeof raw === 'boolean' ? raw : fallback\n case 'number': {\n const value = typeof raw === 'number' && Number.isFinite(raw) ? raw : NaN\n if (Number.isNaN(value)) return fallback\n if (field.min != null && value < field.min) return field.min\n if (field.max != null && value > field.max) return field.max\n return value\n }\n case 'select': {\n const value = typeof raw === 'string' ? raw : undefined\n return field.options?.some((option) => option.value === value)\n ? value\n : fallback\n }\n default:\n return typeof raw === 'string' ? raw : fallback\n }\n}\n\n/**\n * The defaults-merged, type-coerced config view. Safe against a missing\n * or junk settings doc — unknown keys are dropped, wrong-typed values\n * fall back to the declared default.\n */\nexport function mergePluginConfig(\n schema: PluginConfigSchema,\n stored: Record<string, unknown> | null | undefined,\n): Record<string, unknown> {\n const merged: Record<string, unknown> = { ...schema.defaults }\n if (!stored) return merged\n for (const field of schema.fields) {\n if (field.key in stored) {\n merged[field.key] = coerce(\n field,\n stored[field.key],\n schema.defaults[field.key],\n )\n }\n }\n return merged\n}\n\n/** Pre-save validation for the settings UI (coerce + custom validate). */\nexport function validatePluginConfigValues(\n schema: PluginConfigSchema,\n values: Record<string, unknown>,\n): { ok: boolean; error?: string } {\n const merged = mergePluginConfig(schema, values)\n const error = schema.validate?.(merged) ?? null\n return error ? { ok: false, error } : { ok: true }\n}\n\nconst MANIFEST_CONFIG_FIELD_LIMIT = 50\nconst FIELD_TYPES: ReadonlySet<string> = new Set([\n 'string',\n 'number',\n 'boolean',\n 'select',\n])\n\n/**\n * A plugin's declared fields, read from its MANIFEST rather than the registry\n * (AGL-428).\n *\n * The registry is a module-scope map, filled by `registerPluginConfigSchema`\n * at import time. A marketplace plugin cannot fill it: its bundle runs in a\n * sandboxed frame on a separate origin and never executes in the console\n * process, so there is no moment at which its registration could happen. The\n * settings form therefore rendered NOTHING for every plugin a workspace\n * installed, and `resolvePluginConfig` — which is entirely plugin-agnostic and\n * would have layered a third-party plugin's org default under a per-site\n * override without changing a line — was never reached for one.\n *\n * The manifest is denormalized onto every install pin, so it is the one\n * declaration of a marketplace plugin the console already holds.\n *\n * Everything here is re-validated rather than trusted. The manifest is\n * publisher-authored, it is stored on a client-readable document, and a form\n * built from it is a form an org manager saves through — so a junk `type`\n * drops the field rather than rendering an unknown control, and the field\n * count is capped so one manifest cannot turn a settings page into thousands\n * of inputs. Being reviewed once is not the same as being safe to render\n * verbatim forever.\n *\n * Returns `undefined` when the manifest declares nothing usable, which callers\n * must treat as \"this plugin has no settings\" rather than as an error: most\n * plugins take none.\n */\nexport function pluginConfigSchemaFromManifest(\n pluginId: string,\n manifest: unknown,\n): PluginConfigSchema | undefined {\n if (!pluginId) return undefined\n const declared = (manifest as { config?: unknown } | null | undefined)?.config\n const rawFields = (declared as { fields?: unknown } | undefined)?.fields\n if (!Array.isArray(rawFields)) return undefined\n const seen = new Set<string>()\n const fields: PluginConfigField[] = []\n for (const raw of rawFields) {\n if (fields.length >= MANIFEST_CONFIG_FIELD_LIMIT) break\n const entry = raw as Record<string, unknown> | null\n const key = typeof entry?.['key'] === 'string' ? entry['key'].trim() : ''\n // A duplicate key would give two controls one storage slot, so the second\n // is dropped rather than allowed to shadow the first.\n if (!key || seen.has(key)) continue\n const type = String(entry?.['type'] ?? 'string')\n if (!FIELD_TYPES.has(type)) continue\n const options = Array.isArray(entry?.['options'])\n ? (entry['options'] as unknown[])\n .map((option) => option as Record<string, unknown> | null)\n .filter(\n (option) =>\n typeof option?.['value'] === 'string' &&\n typeof option?.['label'] === 'string',\n )\n .map((option) => ({\n value: String(option?.['value']),\n label: String(option?.['label']),\n }))\n : undefined\n // A `select` with nothing to select is a dropdown that cannot be answered,\n // so it is dropped rather than rendered empty.\n if (type === 'select' && !options?.length) continue\n seen.add(key)\n fields.push({\n key,\n label:\n typeof entry?.['label'] === 'string' && entry['label'].trim()\n ? String(entry['label']).slice(0, 120)\n : key,\n type: type as PluginConfigField['type'],\n description:\n typeof entry?.['description'] === 'string'\n ? String(entry['description']).slice(0, 400)\n : undefined,\n options,\n min: typeof entry?.['min'] === 'number' ? entry['min'] : undefined,\n max: typeof entry?.['max'] === 'number' ? entry['max'] : undefined,\n })\n }\n if (!fields.length) return undefined\n const rawDefaults = (declared as { defaults?: unknown } | undefined)?.defaults\n const defaults: Record<string, unknown> = {}\n for (const field of fields) {\n // Defaults are coerced through the same path stored values take, so a\n // manifest default of the wrong type cannot become the value a site\n // inherits. An absent one falls back to the type's empty value rather than\n // `undefined`, which `pluginConfigOverrides` reads as \"not answered\".\n const supplied =\n rawDefaults && typeof rawDefaults === 'object'\n ? (rawDefaults as Record<string, unknown>)[field.key]\n : undefined\n const empty =\n field.type === 'boolean'\n ? false\n : field.type === 'number'\n ? (field.min ?? 0)\n : field.type === 'select'\n ? (field.options?.[0]?.value ?? '')\n : ''\n defaults[field.key] = coerce(field, supplied, empty)\n }\n return { pluginId, fields, defaults }\n}\n\n/**\n * The three levels a plugin setting can be answered at.\n *\n * A workspace sets a value once and every site it enabled the plugin on\n * follows it; a site that needs a different answer overrides that one field\n * and keeps inheriting the rest. Bookings is the shape this is for — one\n * booking horizon across a chain, with the flagship branch taking bookings\n * further out — and it generalizes to every plugin because none of them get\n * to invent their own inheritance.\n */\nexport interface PluginConfigSources {\n /** `orgs/{orgId}/pluginSettings/{pluginId}` — the workspace's answer. */\n org?: Record<string, unknown> | null\n /** `hosts/{hostId}/pluginSettings/{pluginId}` — only the keys this site overrides. */\n host?: Record<string, unknown> | null\n}\n\n/**\n * Which keys a site is answering for itself.\n *\n * A key is an override when it is PRESENT on the host document, and inherited\n * when it is absent. `undefined` counts as absent, deliberately: a UI that\n * writes `{key: undefined}` to mean \"stop overriding\" is doing the same thing\n * as one that never wrote the key, and treating the two differently would make\n * \"revert to the workspace value\" depend on which code path cleared it.\n *\n * ⚠️ Which is also why clearing an override must DELETE the key rather than\n * write an empty value. `setDoc(..., {merge: true})` leaves an existing field\n * exactly as it is when the new object omits it, so a form that drops empty\n * inputs cannot clear anything by saving — the override survives, invisibly,\n * and the site keeps ignoring a workspace value the operator has since\n * changed. Deleting is the only write that returns a field to inherited.\n *\n * Keys the schema does not declare are ignored, so a stale field left behind\n * by a plugin update does not read as an override of a setting that no longer\n * exists.\n */\nexport function pluginConfigOverrides(\n schema: PluginConfigSchema,\n host: Record<string, unknown> | null | undefined,\n): string[] {\n if (!host) return []\n return schema.fields\n .filter((field) => field.key in host && host[field.key] !== undefined)\n .map((field) => field.key)\n}\n\n/**\n * What a plugin actually runs with on one site.\n *\n * Schema defaults, then the workspace's stored values, then only the keys the\n * site overrides — each layer narrowing the one before it. Type coercion\n * happens once at the end rather than per level, so a site override of the\n * wrong type falls back to the WORKSPACE value it was trying to replace\n * instead of skipping past it to the schema default. Getting that backwards\n * would have one malformed site setting silently discard a workspace answer\n * the operator can see in their own console.\n */\nexport function resolvePluginConfig(\n schema: PluginConfigSchema,\n sources: PluginConfigSources,\n): Record<string, unknown> {\n const org = mergePluginConfig(schema, sources.org)\n const overrides = pluginConfigOverrides(schema, sources.host)\n if (!overrides.length) return org\n const host = sources.host as Record<string, unknown>\n const merged: Record<string, unknown> = { ...org }\n for (const field of schema.fields) {\n if (!overrides.includes(field.key)) continue\n merged[field.key] = coerce(field, host[field.key], org[field.key])\n }\n return merged\n}\n"],"names":["globalScope","REGISTRY_KEY","Symbol","for","globalThis","schemas","Map","registerPluginConfigSchema","schema","set","pluginId","getPluginConfigSchema","get","listPluginConfigSchemas","values","coerce","field","raw","fallback","type","value","Number","isFinite","NaN","isNaN","min","max","undefined","options","some","option","mergePluginConfig","stored","merged","defaults","fields","key","validatePluginConfigValues","error","validate","ok","MANIFEST_CONFIG_FIELD_LIMIT","FIELD_TYPES","Set","pluginConfigSchemaFromManifest","manifest","declared","config","rawFields","Array","isArray","seen","length","entry","trim","has","String","map","filter","label","add","push","slice","description","rawDefaults","supplied","empty","pluginConfigOverrides","host","resolvePluginConfig","sources","org","overrides","includes"],"mappings":"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"}
@@ -54,8 +54,17 @@
54
54
  *
55
55
  * Registered from a plugin's declarations, like the erasure seams, so the
56
56
  * participant is in place in a process that never loaded the plugin's API.
57
- */ import { getRegisteringPluginId } from "../app-utils/registering-plugin.js";
58
- const registrations = [];
57
+ */ var _globalScope_REGISTRY_KEY;
58
+ import { getRegisteringPluginId } from "../app-utils/registering-plugin.js";
59
+ /**
60
+ * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).
61
+ * The participants are registered at boot, from `instrumentation.ts`, and Next
62
+ * compiles that file apart from the routes, so the boot and a route each hold
63
+ * their own copy of this module. At module scope, the boot filled its copy and
64
+ * every route read an empty one (AGL-3412, AGL-3868).
65
+ */ const REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-consent-group-participants');
66
+ const globalScope = globalThis;
67
+ const registrations = (_globalScope_REGISTRY_KEY = globalScope[REGISTRY_KEY]) != null ? _globalScope_REGISTRY_KEY : globalScope[REGISTRY_KEY] = [];
59
68
  /**
60
69
  * Registers a plugin's participant. Owner = the loader's marker inside a
61
70
  * register fn, else `options.pluginId`; a participant with neither throws.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-consent-group-change.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A plugin's share of a CONSENT GROUP CHANGE (AGL-3320).\n *\n * The executor in `libs/tenant/data/admin` carries the refusals the platform\n * stores per site — the suppression list, the topic opt-outs, the pace — and\n * flips the declaration. A plugin that keeps its own per-site refusals, or\n * records keyed by a consent group's id, registers a participant here, and\n * every change runs it at three points:\n *\n * - `carry`, BEFORE the flip: copy the plugin's refusals onto the sites the\n * plan's carries name, so no site stops honoring an opt-out it was reading\n * across its group. Run twice — once, and again as the catch-up.\n * - `rehome`, AFTER the flip: move the records keyed by the plan's holder\n * flows. After, because every writer keys by the declaration in force, so\n * new writes land on the final key while the move folds the old one in.\n * - `sweep`, once the sweep delay has passed: the whole job again,\n * idempotently, for anything a long job wrote under the old declaration.\n *\n * ## NOT ISOLATED, on purpose\n *\n * The erasure seams log a throw and go on (`plugin-person-erasure.ts`), because\n * a partial erasure is better than none. Here the opposite holds: a skipped\n * carry is a refusal nobody honors once the flip happens. So a participant's\n * throw propagates, the executor records it against the job, and the change\n * does not flip until the participant succeeds — five failures in a row mark\n * the job stalled, and it keeps retrying.\n *\n * The PREVIEW is the exception, because it decides nothing: a participant\n * whose preview throws shows `null` lines and the review step says so.\n *\n * ## The contract a `run` keeps\n *\n * Resumable and idempotent. The executor hands back the `cursor` the last\n * call returned (`null` to start), and stops calling once `done` is true. A\n * run must return before `deadlineMs`, keep every write create-if-absent or a\n * restrictive merge, and write nothing on `dryRun`. A second full run over a\n * finished change must write nothing, because the catch-up and the sweep ARE\n * second runs.\n *\n * Registered from a plugin's declarations, like the erasure seams, so the\n * participant is in place in a process that never loaded the plugin's API.\n */\n\nimport type {\n ConsentGroupChangePlan,\n ConsentGroupChangePreviewLine,\n} from '../app-utils/consent-group-change'\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** Which point of the change a `run` is called for. */\nexport type ConsentGroupChangeParticipantPhase = 'carry' | 'rehome' | 'sweep'\n\nexport interface ConsentGroupChangePreviewRequest {\n orgId: string\n plan: ConsentGroupChangePlan\n}\n\nexport interface ConsentGroupChangeRunRequest {\n orgId: string\n changeId: string\n plan: ConsentGroupChangePlan\n phase: ConsentGroupChangeParticipantPhase\n /** Where the previous call stopped, or `null` to start the phase. */\n cursor: string | null\n /** Return by this instant; the executor calls again from the cursor. */\n deadlineMs: number\n /** Count, write nothing. */\n dryRun: boolean\n}\n\nexport interface ConsentGroupChangeRunResult {\n done: boolean\n cursor: string | null\n /** What THIS call did — the executor adds them up across calls. */\n counts: Record<string, number>\n}\n\nexport interface ConsentGroupChangeParticipant {\n preview(request: ConsentGroupChangePreviewRequest): Promise<ConsentGroupChangePreviewLine[]>\n run(request: ConsentGroupChangeRunRequest): Promise<ConsentGroupChangeRunResult>\n /**\n * The participant's clause of the \"Finished a consent group change\" line,\n * from its counts totaled over the whole change, or `null` to add nothing.\n */\n summarize?(counts: Readonly<Record<string, number>>): string | null\n}\n\ninterface Registration {\n pluginId: string\n participant: ConsentGroupChangeParticipant\n}\n\nconst registrations: Registration[] = []\n\n/**\n * Registers a plugin's participant. Owner = the loader's marker inside a\n * register fn, else `options.pluginId`; a participant with neither throws.\n * One per plugin: registering again replaces it in place, so a module\n * evaluated twice does not run twice.\n */\nexport function registerPluginConsentGroupParticipant(\n participant: ConsentGroupChangeParticipant,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n 'a consent group participant was registered with no owner: pass ' +\n '{ pluginId } when registering outside a plugin register fn',\n )\n }\n const index = registrations.findIndex((entry) => entry.pluginId === pluginId)\n if (index >= 0) registrations[index] = { pluginId, participant }\n else registrations.push({ pluginId, participant })\n}\n\n/** Every participant, in registration order — the order they run in. */\nexport function listPluginConsentGroupParticipants(): Array<{\n pluginId: string\n participant: ConsentGroupChangeParticipant\n}> {\n return registrations.map(({ pluginId, participant }) => ({ pluginId, participant }))\n}\n\n/**\n * Every participant's preview, by plugin id, in registration order: its\n * lines, or `null` for one that threw. Never throws.\n */\nexport async function previewPluginConsentGroupChange(\n request: ConsentGroupChangePreviewRequest,\n): Promise<Array<{ pluginId: string; lines: ConsentGroupChangePreviewLine[] | null }>> {\n const out: Array<{ pluginId: string; lines: ConsentGroupChangePreviewLine[] | null }> = []\n for (const { pluginId, participant } of [...registrations]) {\n try {\n out.push({ pluginId, lines: await participant.preview(request) })\n } catch (error) {\n out.push({ pluginId, lines: null })\n console.error(\n `[plugins] ${pluginId} failed to preview a consent group change in org ${request.orgId}`,\n error,\n )\n }\n }\n return out\n}\n\n/** Test seam: forget every participant. */\nexport function resetPluginConsentGroupParticipantsForTests(): void {\n registrations.length = 0\n}\n"],"names":["getRegisteringPluginId","registrations","registerPluginConsentGroupParticipant","participant","options","pluginId","trim","Error","index","findIndex","entry","push","listPluginConsentGroupParticipants","map","previewPluginConsentGroupChange","request","out","lines","preview","error","console","orgId","resetPluginConsentGroupParticipantsForTests","length"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyCC,GAMD,SAASA,sBAAsB,QAAQ,qCAAiC;AA6CxE,MAAMC,gBAAgC,EAAE;AAExC;;;;;CAKC,GACD,OAAO,SAASC,sCACdC,WAA0C,EAC1CC,OAA+B;QAEbJ,MAAAA;IAAlB,MAAMK,WAAW,EAACL,QAAAA,0BAAAA,oCAAAA,0BAA4BI,2BAAAA,QAASC,QAAQ,YAA7CL,OAAiD,IAAIM,IAAI;IAC3E,IAAI,CAACD,UAAU;QACb,MAAM,IAAIE,MACR,oEACE;IAEN;IACA,MAAMC,QAAQP,cAAcQ,SAAS,CAAC,CAACC,QAAUA,MAAML,QAAQ,KAAKA;IACpE,IAAIG,SAAS,GAAGP,aAAa,CAACO,MAAM,GAAG;QAAEH;QAAUF;IAAY;SAC1DF,cAAcU,IAAI,CAAC;QAAEN;QAAUF;IAAY;AAClD;AAEA,sEAAsE,GACtE,OAAO,SAASS;IAId,OAAOX,cAAcY,GAAG,CAAC,CAAC,EAAER,QAAQ,EAAEF,WAAW,EAAE,GAAM,CAAA;YAAEE;YAAUF;QAAY,CAAA;AACnF;AAEA;;;CAGC,GACD,OAAO,eAAeW,gCACpBC,OAAyC;IAEzC,MAAMC,MAAkF,EAAE;IAC1F,KAAK,MAAM,EAAEX,QAAQ,EAAEF,WAAW,EAAE,IAAI;WAAIF;KAAc,CAAE;QAC1D,IAAI;YACFe,IAAIL,IAAI,CAAC;gBAAEN;gBAAUY,OAAO,MAAMd,YAAYe,OAAO,CAACH;YAAS;QACjE,EAAE,OAAOI,OAAO;YACdH,IAAIL,IAAI,CAAC;gBAAEN;gBAAUY,OAAO;YAAK;YACjCG,QAAQD,KAAK,CACX,CAAC,UAAU,EAAEd,SAAS,iDAAiD,EAAEU,QAAQM,KAAK,EAAE,EACxFF;QAEJ;IACF;IACA,OAAOH;AACT;AAEA,yCAAyC,GACzC,OAAO,SAASM;IACdrB,cAAcsB,MAAM,GAAG;AACzB"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-consent-group-change.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A plugin's share of a CONSENT GROUP CHANGE (AGL-3320).\n *\n * The executor in `libs/tenant/data/admin` carries the refusals the platform\n * stores per site — the suppression list, the topic opt-outs, the pace — and\n * flips the declaration. A plugin that keeps its own per-site refusals, or\n * records keyed by a consent group's id, registers a participant here, and\n * every change runs it at three points:\n *\n * - `carry`, BEFORE the flip: copy the plugin's refusals onto the sites the\n * plan's carries name, so no site stops honoring an opt-out it was reading\n * across its group. Run twice — once, and again as the catch-up.\n * - `rehome`, AFTER the flip: move the records keyed by the plan's holder\n * flows. After, because every writer keys by the declaration in force, so\n * new writes land on the final key while the move folds the old one in.\n * - `sweep`, once the sweep delay has passed: the whole job again,\n * idempotently, for anything a long job wrote under the old declaration.\n *\n * ## NOT ISOLATED, on purpose\n *\n * The erasure seams log a throw and go on (`plugin-person-erasure.ts`), because\n * a partial erasure is better than none. Here the opposite holds: a skipped\n * carry is a refusal nobody honors once the flip happens. So a participant's\n * throw propagates, the executor records it against the job, and the change\n * does not flip until the participant succeeds — five failures in a row mark\n * the job stalled, and it keeps retrying.\n *\n * The PREVIEW is the exception, because it decides nothing: a participant\n * whose preview throws shows `null` lines and the review step says so.\n *\n * ## The contract a `run` keeps\n *\n * Resumable and idempotent. The executor hands back the `cursor` the last\n * call returned (`null` to start), and stops calling once `done` is true. A\n * run must return before `deadlineMs`, keep every write create-if-absent or a\n * restrictive merge, and write nothing on `dryRun`. A second full run over a\n * finished change must write nothing, because the catch-up and the sweep ARE\n * second runs.\n *\n * Registered from a plugin's declarations, like the erasure seams, so the\n * participant is in place in a process that never loaded the plugin's API.\n */\n\nimport type {\n ConsentGroupChangePlan,\n ConsentGroupChangePreviewLine,\n} from '../app-utils/consent-group-change'\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** Which point of the change a `run` is called for. */\nexport type ConsentGroupChangeParticipantPhase = 'carry' | 'rehome' | 'sweep'\n\nexport interface ConsentGroupChangePreviewRequest {\n orgId: string\n plan: ConsentGroupChangePlan\n}\n\nexport interface ConsentGroupChangeRunRequest {\n orgId: string\n changeId: string\n plan: ConsentGroupChangePlan\n phase: ConsentGroupChangeParticipantPhase\n /** Where the previous call stopped, or `null` to start the phase. */\n cursor: string | null\n /** Return by this instant; the executor calls again from the cursor. */\n deadlineMs: number\n /** Count, write nothing. */\n dryRun: boolean\n}\n\nexport interface ConsentGroupChangeRunResult {\n done: boolean\n cursor: string | null\n /** What THIS call did — the executor adds them up across calls. */\n counts: Record<string, number>\n}\n\nexport interface ConsentGroupChangeParticipant {\n preview(request: ConsentGroupChangePreviewRequest): Promise<ConsentGroupChangePreviewLine[]>\n run(request: ConsentGroupChangeRunRequest): Promise<ConsentGroupChangeRunResult>\n /**\n * The participant's clause of the \"Finished a consent group change\" line,\n * from its counts totaled over the whole change, or `null` to add nothing.\n */\n summarize?(counts: Readonly<Record<string, number>>): string | null\n}\n\ninterface Registration {\n pluginId: string\n participant: ConsentGroupChangeParticipant\n}\n\n/**\n * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).\n * The participants are registered at boot, from `instrumentation.ts`, and Next\n * compiles that file apart from the routes, so the boot and a route each hold\n * their own copy of this module. At module scope, the boot filled its copy and\n * every route read an empty one (AGL-3412, AGL-3868).\n */\nconst REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-consent-group-participants')\nconst globalScope = globalThis as typeof globalThis & {\n [REGISTRY_KEY]?: Registration[]\n}\nconst registrations: Registration[] =\n globalScope[REGISTRY_KEY] ?? (globalScope[REGISTRY_KEY] = [])\n\n/**\n * Registers a plugin's participant. Owner = the loader's marker inside a\n * register fn, else `options.pluginId`; a participant with neither throws.\n * One per plugin: registering again replaces it in place, so a module\n * evaluated twice does not run twice.\n */\nexport function registerPluginConsentGroupParticipant(\n participant: ConsentGroupChangeParticipant,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n 'a consent group participant was registered with no owner: pass ' +\n '{ pluginId } when registering outside a plugin register fn',\n )\n }\n const index = registrations.findIndex((entry) => entry.pluginId === pluginId)\n if (index >= 0) registrations[index] = { pluginId, participant }\n else registrations.push({ pluginId, participant })\n}\n\n/** Every participant, in registration order — the order they run in. */\nexport function listPluginConsentGroupParticipants(): Array<{\n pluginId: string\n participant: ConsentGroupChangeParticipant\n}> {\n return registrations.map(({ pluginId, participant }) => ({ pluginId, participant }))\n}\n\n/**\n * Every participant's preview, by plugin id, in registration order: its\n * lines, or `null` for one that threw. Never throws.\n */\nexport async function previewPluginConsentGroupChange(\n request: ConsentGroupChangePreviewRequest,\n): Promise<Array<{ pluginId: string; lines: ConsentGroupChangePreviewLine[] | null }>> {\n const out: Array<{ pluginId: string; lines: ConsentGroupChangePreviewLine[] | null }> = []\n for (const { pluginId, participant } of [...registrations]) {\n try {\n out.push({ pluginId, lines: await participant.preview(request) })\n } catch (error) {\n out.push({ pluginId, lines: null })\n console.error(\n `[plugins] ${pluginId} failed to preview a consent group change in org ${request.orgId}`,\n error,\n )\n }\n }\n return out\n}\n\n/** Test seam: forget every participant. */\nexport function resetPluginConsentGroupParticipantsForTests(): void {\n registrations.length = 0\n}\n"],"names":["globalScope","getRegisteringPluginId","REGISTRY_KEY","Symbol","for","globalThis","registrations","registerPluginConsentGroupParticipant","participant","options","pluginId","trim","Error","index","findIndex","entry","push","listPluginConsentGroupParticipants","map","previewPluginConsentGroupChange","request","out","lines","preview","error","console","orgId","resetPluginConsentGroupParticipantsForTests","length"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyCC,OA+DCA;AAzDF,SAASC,sBAAsB,QAAQ,qCAAiC;AA6CxE;;;;;;CAMC,GACD,MAAMC,eAAeC,OAAOC,GAAG,CAAC;AAChC,MAAMJ,cAAcK;AAGpB,MAAMC,iBACJN,4BAAAA,WAAW,CAACE,aAAa,YAAzBF,4BAA8BA,WAAW,CAACE,aAAa,GAAG,EAAE;AAE9D;;;;;CAKC,GACD,OAAO,SAASK,sCACdC,WAA0C,EAC1CC,OAA+B;QAEbR,MAAAA;IAAlB,MAAMS,WAAW,EAACT,QAAAA,0BAAAA,oCAAAA,0BAA4BQ,2BAAAA,QAASC,QAAQ,YAA7CT,OAAiD,IAAIU,IAAI;IAC3E,IAAI,CAACD,UAAU;QACb,MAAM,IAAIE,MACR,oEACE;IAEN;IACA,MAAMC,QAAQP,cAAcQ,SAAS,CAAC,CAACC,QAAUA,MAAML,QAAQ,KAAKA;IACpE,IAAIG,SAAS,GAAGP,aAAa,CAACO,MAAM,GAAG;QAAEH;QAAUF;IAAY;SAC1DF,cAAcU,IAAI,CAAC;QAAEN;QAAUF;IAAY;AAClD;AAEA,sEAAsE,GACtE,OAAO,SAASS;IAId,OAAOX,cAAcY,GAAG,CAAC,CAAC,EAAER,QAAQ,EAAEF,WAAW,EAAE,GAAM,CAAA;YAAEE;YAAUF;QAAY,CAAA;AACnF;AAEA;;;CAGC,GACD,OAAO,eAAeW,gCACpBC,OAAyC;IAEzC,MAAMC,MAAkF,EAAE;IAC1F,KAAK,MAAM,EAAEX,QAAQ,EAAEF,WAAW,EAAE,IAAI;WAAIF;KAAc,CAAE;QAC1D,IAAI;YACFe,IAAIL,IAAI,CAAC;gBAAEN;gBAAUY,OAAO,MAAMd,YAAYe,OAAO,CAACH;YAAS;QACjE,EAAE,OAAOI,OAAO;YACdH,IAAIL,IAAI,CAAC;gBAAEN;gBAAUY,OAAO;YAAK;YACjCG,QAAQD,KAAK,CACX,CAAC,UAAU,EAAEd,SAAS,iDAAiD,EAAEU,QAAQM,KAAK,EAAE,EACxFF;QAEJ;IACF;IACA,OAAOH;AACT;AAEA,yCAAyC,GACzC,OAAO,SAASM;IACdrB,cAAcsB,MAAM,GAAG;AACzB"}
@@ -1,3 +1,4 @@
1
+ var _globalScope_REGISTRY_KEY;
1
2
  /**
2
3
  * @license
3
4
  * Copyright 2026 Aglyn LLC
@@ -66,7 +67,15 @@ import { getRegisteringPluginId } from "../app-utils/registering-plugin.js";
66
67
  * fifteen-minute sweep (`CONSOLE_FAST_CRON_SCHEDULE` in `cloud/functions`).
67
68
  */ export const PLUGIN_CONSOLE_CRON_SCHEDULE = '*/15 * * * *';
68
69
  /** How late a declared job may be before its row goes red: three missed ticks. */ export const PLUGIN_CONSOLE_CRON_GRACE_MINUTES = 45;
69
- const registrations = [];
70
+ /**
71
+ * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).
72
+ * The jobs are registered at boot, from `instrumentation.ts`, and Next
73
+ * compiles that file apart from the routes, so the boot and a route each hold
74
+ * their own copy of this module. At module scope, the boot filled its copy and
75
+ * every route read an empty one (AGL-3412, AGL-3868).
76
+ */ const REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-console-crons');
77
+ const globalScope = globalThis;
78
+ const registrations = (_globalScope_REGISTRY_KEY = globalScope[REGISTRY_KEY]) != null ? _globalScope_REGISTRY_KEY : globalScope[REGISTRY_KEY] = [];
70
79
  /** A job id as a beat document and a health row can carry it. */ const JOB_ID = /^[a-z][a-z0-9-]*$/;
71
80
  /** Job ids the platform's own inventory holds. */ const PLATFORM_JOB_IDS = new Set([
72
81
  ...SCHEDULED_JOBS.map((job)=>job.id),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-console-crons.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A plugin's own scheduled jobs, run by the CONSOLE (AGL-2981).\n *\n * The platform already has a beat for work that belongs on the tenant — the\n * job runner behind `pluginJobsBeat` — and it is the wrong place for a job\n * that holds a credential only the console may hold: a provider key, a\n * sealed grant to a person's mailbox. Those jobs have to run in the console,\n * and until now the only way to get one there was a console route of its own\n * and a literal in the scheduler's route list, which is a plugin's route in\n * the platform's code.\n *\n * So a plugin DECLARES a console job here instead, and the platform runs\n * every declared job from one route, `/api/admin/plugin-crons`, which the\n * Cloud Scheduler job that drives the console's fifteen-minute sweeps posts\n * like any other of its routes. A plugin that adds a job adds a declaration;\n * the scheduler, its firewall rule and its deploy are unchanged.\n *\n * ## One schedule\n *\n * Every declared job runs on that tick: {@link PLUGIN_CONSOLE_CRON_SCHEDULE}.\n * A job that has nothing to do returns at once. A job that needs a slower\n * cadence keeps its own \"last ran\" mark and skips; one that needs a faster\n * one is not a job for this runner.\n *\n * ## Watched like every other job\n *\n * Each job has its own row on `/api/health/crons` —\n * {@link pluginConsoleCronScheduledJobs} — and the runner stamps that row's\n * `platformCronBeats` mark when it invokes the job, BEFORE the work, the rule\n * every console cron follows: the mark answers \"is this job still being\n * scheduled\", and a job that throws was still scheduled. A job that stops\n * being run — its plugin unregistered it, the route stopped being posted —\n * goes red on the board within its grace.\n *\n * ## Isolated, and bounded\n *\n * Jobs run concurrently, each in its own try: a throw is logged against its\n * plugin and reported as `null`, and never stops another job. Each is handed\n * a deadline, and a job that starts no new unit of work after it lets the\n * route answer before its caller gives up.\n *\n * Declared from a plugin's `consoleServerDeclarations` — the console's boot\n * registrations — with the job's body imported lazily, so boot pays for the\n * declaration and the tick pays for the work.\n */\n\nimport { SCHEDULED_JOBS, type ScheduledJob } from '../app-utils/health-report'\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** The console route that runs every declared job, as the scheduler posts it. */\nexport const PLUGIN_CONSOLE_CRONS_ROUTE = '/api/admin/plugin-crons'\n\n/** The runner's own row on `/api/health/crons`: is the route being posted at all. */\nexport const PLUGIN_CONSOLE_CRONS_JOB_ID = 'plugin-console-crons'\n\n/**\n * The tick every declared job runs on, five-field UTC: the console's\n * fifteen-minute sweep (`CONSOLE_FAST_CRON_SCHEDULE` in `cloud/functions`).\n */\nexport const PLUGIN_CONSOLE_CRON_SCHEDULE = '*/15 * * * *'\n\n/** How late a declared job may be before its row goes red: three missed ticks. */\nexport const PLUGIN_CONSOLE_CRON_GRACE_MINUTES = 45\n\n/** What one run of a job is handed. */\nexport interface PluginConsoleCronContext {\n /** When the tick began, epoch ms. */\n nowMs: number\n /** Start no new unit of work after this, epoch ms. */\n deadlineMs: number\n}\n\n/** What a job reports for the tick's answer and log: counts, flags, a word — never content. */\nexport type PluginConsoleCronReport = Readonly<Record<string, number | boolean | string | null>>\n\nexport interface PluginConsoleCronJob {\n /**\n * The job's id: its `platformCronBeats` document and its health row.\n * Starts with the plugin's own id — `acme-mail-sync` for `acme-mail` — so\n * two plugins' jobs, and a plugin's job and the platform's, never share one.\n */\n id: string\n /** What the health board calls it. */\n label: string\n /** What stops happening when it stops, in a sentence the board renders. */\n drives: string\n run(context: PluginConsoleCronContext): Promise<PluginConsoleCronReport>\n}\n\n/** A declared job, without its body. */\nexport interface PluginConsoleCronDeclaration {\n pluginId: string\n id: string\n label: string\n drives: string\n}\n\ninterface Registration extends PluginConsoleCronDeclaration {\n run: PluginConsoleCronJob['run']\n}\n\nconst registrations: Registration[] = []\n\n/** A job id as a beat document and a health row can carry it. */\nconst JOB_ID = /^[a-z][a-z0-9-]*$/\n\n/** Job ids the platform's own inventory holds. */\nconst PLATFORM_JOB_IDS = new Set([...SCHEDULED_JOBS.map((job) => job.id), PLUGIN_CONSOLE_CRONS_JOB_ID])\n\n/**\n * Declares a plugin's console job. Owner = the loader's marker inside a\n * register fn, else `options.pluginId`; a job with neither throws, as does\n * an id that is not the plugin's own (`<pluginId>-…`), one the platform's\n * inventory holds, one another plugin declared, and a job with no label or\n * no consequence. The same plugin declaring the same id again replaces its\n * job in place.\n */\nexport function registerPluginConsoleCron(\n job: PluginConsoleCronJob,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n 'a plugin console job was registered with no owner: pass { pluginId } ' +\n 'when registering outside a plugin register fn',\n )\n }\n const id = String(job?.id ?? '').trim()\n if (!JOB_ID.test(id) || !id.startsWith(`${pluginId}-`) || id === `${pluginId}-`) {\n throw new Error(\n `plugin console job \"${id}\" from \"${pluginId}\" needs an id of its own: ` +\n `\"${pluginId}-\" and a lowercase name`,\n )\n }\n if (PLATFORM_JOB_IDS.has(id)) {\n throw new Error(`plugin console job \"${id}\" is a platform job's id; refused \"${pluginId}\"`)\n }\n const incumbent = registrations.find((entry) => entry.id === id)\n if (incumbent && incumbent.pluginId !== pluginId) {\n throw new Error(\n `plugin console job \"${id}\" is already declared by \"${incumbent.pluginId}\"; refused \"${pluginId}\"`,\n )\n }\n const label = String(job.label ?? '').trim()\n const drives = String(job.drives ?? '').trim()\n if (!label || !drives || typeof job.run !== 'function') {\n throw new Error(`plugin console job \"${id}\" needs a label, what it drives, and a run function`)\n }\n const registration: Registration = { pluginId, id, label, drives, run: job.run }\n const index = registrations.findIndex((entry) => entry.id === id)\n if (index >= 0) registrations[index] = registration\n else registrations.push(registration)\n}\n\n/** Every declared job, in declaration order, without its body. */\nexport function listPluginConsoleCrons(): PluginConsoleCronDeclaration[] {\n return registrations.map(({ pluginId, id, label, drives }) => ({ pluginId, id, label, drives }))\n}\n\n/**\n * The declared jobs as rows of the scheduled-job inventory, for\n * `/api/health/crons` to judge beside the platform's own.\n */\nexport function pluginConsoleCronScheduledJobs(): ScheduledJob[] {\n return registrations.map((entry) => ({\n id: entry.id,\n label: entry.label,\n cron: PLUGIN_CONSOLE_CRON_SCHEDULE,\n runner: 'cloud-scheduler',\n target: `consoleFastCrons → console ${PLUGIN_CONSOLE_CRONS_ROUTE} (${entry.pluginId})`,\n graceMinutes: PLUGIN_CONSOLE_CRON_GRACE_MINUTES,\n drives: entry.drives,\n }))\n}\n\nexport interface RunPluginConsoleCronsOptions {\n nowMs: number\n deadlineMs: number\n /** Only these jobs, by id — a manual re-run of one. Absent runs every job. */\n jobIds?: readonly string[] | null\n /**\n * Stamps a job's `platformCronBeats` mark. Must not throw; the runner\n * awaits it before the job starts.\n */\n beat(jobId: string, nowMs: number): Promise<unknown>\n}\n\n/**\n * Runs the declared jobs — every one, or the ones named — concurrently,\n * each after its beat. Answers each job's report by id, or `null` for one\n * that threw. Never throws.\n */\nexport async function runPluginConsoleCrons(\n options: RunPluginConsoleCronsOptions,\n): Promise<Record<string, PluginConsoleCronReport | null>> {\n const wanted = options.jobIds?.length ? new Set(options.jobIds) : null\n const jobs = [...registrations].filter((entry) => !wanted || wanted.has(entry.id))\n const settled = await Promise.all(\n jobs.map(async (job): Promise<[string, PluginConsoleCronReport | null]> => {\n try {\n await options.beat(job.id, options.nowMs)\n } catch {\n // A beat that cannot be written must not take down the job it\n // describes; a write that keeps failing reads as a silent job.\n }\n try {\n return [job.id, await job.run({ nowMs: options.nowMs, deadlineMs: options.deadlineMs })]\n } catch (error) {\n console.error(`[plugins] ${job.pluginId} console job ${job.id} failed`, error)\n return [job.id, null]\n }\n }),\n )\n // Declaration order, whichever job finished first.\n return Object.fromEntries(settled)\n}\n\n/** Test seam: forget every declared job. */\nexport function resetPluginConsoleCronsForTests(): void {\n registrations.length = 0\n}\n"],"names":["SCHEDULED_JOBS","getRegisteringPluginId","PLUGIN_CONSOLE_CRONS_ROUTE","PLUGIN_CONSOLE_CRONS_JOB_ID","PLUGIN_CONSOLE_CRON_SCHEDULE","PLUGIN_CONSOLE_CRON_GRACE_MINUTES","registrations","JOB_ID","PLATFORM_JOB_IDS","Set","map","job","id","registerPluginConsoleCron","options","pluginId","trim","Error","String","test","startsWith","has","incumbent","find","entry","label","drives","run","registration","index","findIndex","push","listPluginConsoleCrons","pluginConsoleCronScheduledJobs","cron","runner","target","graceMinutes","runPluginConsoleCrons","wanted","jobIds","length","jobs","filter","settled","Promise","all","beat","nowMs","deadlineMs","error","console","Object","fromEntries","resetPluginConsoleCronsForTests"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-console-crons.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A plugin's own scheduled jobs, run by the CONSOLE (AGL-2981).\n *\n * The platform already has a beat for work that belongs on the tenant — the\n * job runner behind `pluginJobsBeat` — and it is the wrong place for a job\n * that holds a credential only the console may hold: a provider key, a\n * sealed grant to a person's mailbox. Those jobs have to run in the console,\n * and until now the only way to get one there was a console route of its own\n * and a literal in the scheduler's route list, which is a plugin's route in\n * the platform's code.\n *\n * So a plugin DECLARES a console job here instead, and the platform runs\n * every declared job from one route, `/api/admin/plugin-crons`, which the\n * Cloud Scheduler job that drives the console's fifteen-minute sweeps posts\n * like any other of its routes. A plugin that adds a job adds a declaration;\n * the scheduler, its firewall rule and its deploy are unchanged.\n *\n * ## One schedule\n *\n * Every declared job runs on that tick: {@link PLUGIN_CONSOLE_CRON_SCHEDULE}.\n * A job that has nothing to do returns at once. A job that needs a slower\n * cadence keeps its own \"last ran\" mark and skips; one that needs a faster\n * one is not a job for this runner.\n *\n * ## Watched like every other job\n *\n * Each job has its own row on `/api/health/crons` —\n * {@link pluginConsoleCronScheduledJobs} — and the runner stamps that row's\n * `platformCronBeats` mark when it invokes the job, BEFORE the work, the rule\n * every console cron follows: the mark answers \"is this job still being\n * scheduled\", and a job that throws was still scheduled. A job that stops\n * being run — its plugin unregistered it, the route stopped being posted —\n * goes red on the board within its grace.\n *\n * ## Isolated, and bounded\n *\n * Jobs run concurrently, each in its own try: a throw is logged against its\n * plugin and reported as `null`, and never stops another job. Each is handed\n * a deadline, and a job that starts no new unit of work after it lets the\n * route answer before its caller gives up.\n *\n * Declared from a plugin's `consoleServerDeclarations` — the console's boot\n * registrations — with the job's body imported lazily, so boot pays for the\n * declaration and the tick pays for the work.\n */\n\nimport { SCHEDULED_JOBS, type ScheduledJob } from '../app-utils/health-report'\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** The console route that runs every declared job, as the scheduler posts it. */\nexport const PLUGIN_CONSOLE_CRONS_ROUTE = '/api/admin/plugin-crons'\n\n/** The runner's own row on `/api/health/crons`: is the route being posted at all. */\nexport const PLUGIN_CONSOLE_CRONS_JOB_ID = 'plugin-console-crons'\n\n/**\n * The tick every declared job runs on, five-field UTC: the console's\n * fifteen-minute sweep (`CONSOLE_FAST_CRON_SCHEDULE` in `cloud/functions`).\n */\nexport const PLUGIN_CONSOLE_CRON_SCHEDULE = '*/15 * * * *'\n\n/** How late a declared job may be before its row goes red: three missed ticks. */\nexport const PLUGIN_CONSOLE_CRON_GRACE_MINUTES = 45\n\n/** What one run of a job is handed. */\nexport interface PluginConsoleCronContext {\n /** When the tick began, epoch ms. */\n nowMs: number\n /** Start no new unit of work after this, epoch ms. */\n deadlineMs: number\n}\n\n/** What a job reports for the tick's answer and log: counts, flags, a word — never content. */\nexport type PluginConsoleCronReport = Readonly<Record<string, number | boolean | string | null>>\n\nexport interface PluginConsoleCronJob {\n /**\n * The job's id: its `platformCronBeats` document and its health row.\n * Starts with the plugin's own id — `acme-mail-sync` for `acme-mail` — so\n * two plugins' jobs, and a plugin's job and the platform's, never share one.\n */\n id: string\n /** What the health board calls it. */\n label: string\n /** What stops happening when it stops, in a sentence the board renders. */\n drives: string\n run(context: PluginConsoleCronContext): Promise<PluginConsoleCronReport>\n}\n\n/** A declared job, without its body. */\nexport interface PluginConsoleCronDeclaration {\n pluginId: string\n id: string\n label: string\n drives: string\n}\n\ninterface Registration extends PluginConsoleCronDeclaration {\n run: PluginConsoleCronJob['run']\n}\n\n/**\n * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).\n * The jobs are registered at boot, from `instrumentation.ts`, and Next\n * compiles that file apart from the routes, so the boot and a route each hold\n * their own copy of this module. At module scope, the boot filled its copy and\n * every route read an empty one (AGL-3412, AGL-3868).\n */\nconst REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-console-crons')\nconst globalScope = globalThis as typeof globalThis & {\n [REGISTRY_KEY]?: Registration[]\n}\nconst registrations: Registration[] =\n globalScope[REGISTRY_KEY] ?? (globalScope[REGISTRY_KEY] = [])\n\n/** A job id as a beat document and a health row can carry it. */\nconst JOB_ID = /^[a-z][a-z0-9-]*$/\n\n/** Job ids the platform's own inventory holds. */\nconst PLATFORM_JOB_IDS = new Set([...SCHEDULED_JOBS.map((job) => job.id), PLUGIN_CONSOLE_CRONS_JOB_ID])\n\n/**\n * Declares a plugin's console job. Owner = the loader's marker inside a\n * register fn, else `options.pluginId`; a job with neither throws, as does\n * an id that is not the plugin's own (`<pluginId>-…`), one the platform's\n * inventory holds, one another plugin declared, and a job with no label or\n * no consequence. The same plugin declaring the same id again replaces its\n * job in place.\n */\nexport function registerPluginConsoleCron(\n job: PluginConsoleCronJob,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n 'a plugin console job was registered with no owner: pass { pluginId } ' +\n 'when registering outside a plugin register fn',\n )\n }\n const id = String(job?.id ?? '').trim()\n if (!JOB_ID.test(id) || !id.startsWith(`${pluginId}-`) || id === `${pluginId}-`) {\n throw new Error(\n `plugin console job \"${id}\" from \"${pluginId}\" needs an id of its own: ` +\n `\"${pluginId}-\" and a lowercase name`,\n )\n }\n if (PLATFORM_JOB_IDS.has(id)) {\n throw new Error(`plugin console job \"${id}\" is a platform job's id; refused \"${pluginId}\"`)\n }\n const incumbent = registrations.find((entry) => entry.id === id)\n if (incumbent && incumbent.pluginId !== pluginId) {\n throw new Error(\n `plugin console job \"${id}\" is already declared by \"${incumbent.pluginId}\"; refused \"${pluginId}\"`,\n )\n }\n const label = String(job.label ?? '').trim()\n const drives = String(job.drives ?? '').trim()\n if (!label || !drives || typeof job.run !== 'function') {\n throw new Error(`plugin console job \"${id}\" needs a label, what it drives, and a run function`)\n }\n const registration: Registration = { pluginId, id, label, drives, run: job.run }\n const index = registrations.findIndex((entry) => entry.id === id)\n if (index >= 0) registrations[index] = registration\n else registrations.push(registration)\n}\n\n/** Every declared job, in declaration order, without its body. */\nexport function listPluginConsoleCrons(): PluginConsoleCronDeclaration[] {\n return registrations.map(({ pluginId, id, label, drives }) => ({ pluginId, id, label, drives }))\n}\n\n/**\n * The declared jobs as rows of the scheduled-job inventory, for\n * `/api/health/crons` to judge beside the platform's own.\n */\nexport function pluginConsoleCronScheduledJobs(): ScheduledJob[] {\n return registrations.map((entry) => ({\n id: entry.id,\n label: entry.label,\n cron: PLUGIN_CONSOLE_CRON_SCHEDULE,\n runner: 'cloud-scheduler',\n target: `consoleFastCrons → console ${PLUGIN_CONSOLE_CRONS_ROUTE} (${entry.pluginId})`,\n graceMinutes: PLUGIN_CONSOLE_CRON_GRACE_MINUTES,\n drives: entry.drives,\n }))\n}\n\nexport interface RunPluginConsoleCronsOptions {\n nowMs: number\n deadlineMs: number\n /** Only these jobs, by id — a manual re-run of one. Absent runs every job. */\n jobIds?: readonly string[] | null\n /**\n * Stamps a job's `platformCronBeats` mark. Must not throw; the runner\n * awaits it before the job starts.\n */\n beat(jobId: string, nowMs: number): Promise<unknown>\n}\n\n/**\n * Runs the declared jobs — every one, or the ones named — concurrently,\n * each after its beat. Answers each job's report by id, or `null` for one\n * that threw. Never throws.\n */\nexport async function runPluginConsoleCrons(\n options: RunPluginConsoleCronsOptions,\n): Promise<Record<string, PluginConsoleCronReport | null>> {\n const wanted = options.jobIds?.length ? new Set(options.jobIds) : null\n const jobs = [...registrations].filter((entry) => !wanted || wanted.has(entry.id))\n const settled = await Promise.all(\n jobs.map(async (job): Promise<[string, PluginConsoleCronReport | null]> => {\n try {\n await options.beat(job.id, options.nowMs)\n } catch {\n // A beat that cannot be written must not take down the job it\n // describes; a write that keeps failing reads as a silent job.\n }\n try {\n return [job.id, await job.run({ nowMs: options.nowMs, deadlineMs: options.deadlineMs })]\n } catch (error) {\n console.error(`[plugins] ${job.pluginId} console job ${job.id} failed`, error)\n return [job.id, null]\n }\n }),\n )\n // Declaration order, whichever job finished first.\n return Object.fromEntries(settled)\n}\n\n/** Test seam: forget every declared job. */\nexport function resetPluginConsoleCronsForTests(): void {\n registrations.length = 0\n}\n"],"names":["globalScope","SCHEDULED_JOBS","getRegisteringPluginId","PLUGIN_CONSOLE_CRONS_ROUTE","PLUGIN_CONSOLE_CRONS_JOB_ID","PLUGIN_CONSOLE_CRON_SCHEDULE","PLUGIN_CONSOLE_CRON_GRACE_MINUTES","REGISTRY_KEY","Symbol","for","globalThis","registrations","JOB_ID","PLATFORM_JOB_IDS","Set","map","job","id","registerPluginConsoleCron","options","pluginId","trim","Error","String","test","startsWith","has","incumbent","find","entry","label","drives","run","registration","index","findIndex","push","listPluginConsoleCrons","pluginConsoleCronScheduledJobs","cron","runner","target","graceMinutes","runPluginConsoleCrons","wanted","jobIds","length","jobs","filter","settled","Promise","all","beat","nowMs","deadlineMs","error","console","Object","fromEntries","resetPluginConsoleCronsForTests"],"mappings":"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"}
@@ -31,8 +31,17 @@
31
31
  * logged and the next one runs, because these are consequences of a write
32
32
  * that has already happened — an activity row, a notification — and a
33
33
  * failure in one must not undo or hide the write for the customer.
34
- */ import { getRegisteringPluginId } from "../app-utils/registering-plugin.js";
35
- const handlers = new Map();
34
+ */ var _globalScope_REGISTRY_KEY;
35
+ import { getRegisteringPluginId } from "../app-utils/registering-plugin.js";
36
+ /**
37
+ * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).
38
+ * The handlers are registered at boot, from `instrumentation.ts`, and Next
39
+ * compiles that file apart from the routes, so the boot and a route each hold
40
+ * their own copy of this module. At module scope, the boot filled its copy and
41
+ * every route read an empty one (AGL-3412, AGL-3868).
42
+ */ const REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-event-handlers');
43
+ const globalScope = globalThis;
44
+ const handlers = (_globalScope_REGISTRY_KEY = globalScope[REGISTRY_KEY]) != null ? _globalScope_REGISTRY_KEY : globalScope[REGISTRY_KEY] = new Map();
36
45
  /**
37
46
  * Subscribes to an event. Owner = the loader's marker inside a register fn,
38
47
  * else `options.pluginId`; a subscription with neither throws. A plugin
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-events.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Platform events a plugin listens to (AGL-2939, AGL-2940).\n *\n * Core raises a small number of facts about the workspace that a plugin\n * may care about — a seat add-on was bought or dropped, a permission key\n * moved on a member, a role or a site — and a plugin subscribes from its\n * declarations rather than core calling the plugin's own writer. The\n * billing-webhook hooks are the precedent for one vendor's events; this is\n * the same shape for the platform's own.\n *\n * Events a PLUGIN raises for other plugins — an order paid, a return\n * refunded — are `plugin-domain-events.ts` (AGL-3611): declared by the plugin,\n * delivered through an outbox, at least once.\n *\n * Handlers run sequentially and are ISOLATED: a plugin that throws is\n * logged and the next one runs, because these are consequences of a write\n * that has already happened — an activity row, a notification — and a\n * failure in one must not undo or hide the write for the customer.\n */\n\nimport type { OrgSeatAddons } from '../foundation'\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** Who did it, as the activity logs record an actor. */\nexport interface PluginEventActor {\n uid: string | null\n email: string | null\n}\n\n/** The payload each platform event carries. */\nexport interface PluginEventPayloads {\n /** An org's purchased seat add-ons were written (a purchase, a removal, a webhook mirror). */\n 'org.seatAddons.changed': {\n orgId: string\n actor: PluginEventActor\n before: OrgSeatAddons | null | undefined\n after: OrgSeatAddons | null | undefined\n }\n /**\n * The workspace's Stripe invoice was paid, in full, by a real charge.\n *\n * Raised from the platform billing webhook once the invoice has resolved\n * to a workspace, so a plugin that bills its own usage learns the outcome\n * of the invoice it asked for without subscribing to Stripe itself. The\n * invoice's own `metadata` travels with it, unread by core: a plugin that\n * stamped `pluginId` on the invoice it created recognizes its own by it,\n * and every other handler ignores the event.\n *\n * `paidOutOfBand` is Stripe's own flag for an invoice marked paid without\n * a charge. A plugin counting payment history must not count one: it is a\n * staff action, not evidence that money moved.\n */\n 'billing.invoice.paid': {\n orgId: string\n invoiceId: string\n amountPaidCents: number\n currency: string\n paidOutOfBand: boolean\n metadata: Readonly<Record<string, string>>\n }\n /** A charge attempt on the workspace's invoice failed or needs action. */\n 'billing.invoice.failed': {\n orgId: string\n invoiceId: string\n amountDueCents: number\n metadata: Readonly<Record<string, string>>\n }\n /**\n * An invoice was closed without being paid — voided, or written off as\n * uncollectible.\n *\n * ONE event for both Stripe events because they are one fact to a plugin:\n * this invoice will never be paid and nothing more is owed on it. A\n * handler that must tell them apart reads `reason`; one that only needs\n * to stop waiting does not.\n */\n 'billing.invoice.closed': {\n orgId: string\n invoiceId: string\n reason: 'voided' | 'uncollectible'\n metadata: Readonly<Record<string, string>>\n }\n /** A cardholder disputed a charge of the workspace's. */\n 'billing.dispute.opened': {\n orgId: string\n chargeId: string\n invoiceId: string | null\n amountCents: number\n }\n /**\n * The workspace's default payment method changed, or its type did.\n *\n * `defaultType` is Stripe's own type string (`card`, `us_bank_account`,\n * `link`, …), or `null` when the customer has no default. Core states the\n * fact and takes no view: which types a plugin will extend credit against\n * is the plugin's decision, not the platform's.\n */\n 'billing.paymentMethod.changed': {\n orgId: string\n defaultType: string | null\n }\n /**\n * Records were REMOVED from a collection, by a path that is not the\n * owning plugin's own (AGL-3330): a deletion through the public API, a\n * person's erasure.\n *\n * Raised after the delete has landed, with each removed document's data\n * as it stood, so a plugin that keeps a figure over those rows — a counter\n * no increment can take back — can recount what they fed. Core names the\n * collection and takes no view of what it holds.\n *\n * `hostIds` are the sites the records were on: the one site for a site's\n * collection, every site of the organization for an organization row.\n */\n 'host.records.removed': {\n orgId: string | null\n hostIds: readonly string[]\n collection: string\n records: ReadonlyArray<{ id: string; data: Readonly<Record<string, unknown>> }>\n }\n /**\n * A site's email was opened, or a link in it clicked (AGL-3605): the\n * delivery events one webhook delivery recorded for a send tagged with the\n * site, the FIRST of each type per message only — the delivery log decides\n * which those are, so a replay or a reader opening one email six times is\n * one event. Raised by the plugin whose webhook reads the provider, after\n * the log has recorded them; a subscriber that counts engagement of its own\n * reads it here rather than subscribing to the provider.\n */\n 'host.email.engaged': {\n hostId: string\n events: ReadonlyArray<{\n /** The recipient, lowercased. */\n to: string\n /** `opened` or `clicked`. */\n type: string\n /** When it happened, epoch ms. */\n at: number\n firstOfType: boolean\n }>\n }\n /**\n * A custom domain was connected to a site and the hosting provider accepted\n * it (AGL-3629). Raised once the claim is written, so `domain` is the site's\n * `cname` and no other site's. The site may still be awaiting a certificate\n * or a DNS change; a plugin that needs the domain registered somewhere — a\n * payment provider, a search console — does that now rather than waiting\n * for traffic.\n */\n 'host.domain.attached': {\n orgId: string | null\n hostId: string\n domain: string\n }\n /**\n * A domain stopped serving a site (AGL-3629): its custom domain was\n * disconnected, or the site was deleted — which releases its platform\n * subdomain too, raised as a second event with that name. A plugin undoes\n * whatever it registered for the name, and must check the name is not\n * still served by ANOTHER site first (a `www.` twin, a reused subdomain).\n */\n 'host.domain.released': {\n orgId: string | null\n hostId: string\n domain: string\n reason: 'detached' | 'site-deleted'\n }\n /** One permission key moved on a subject of the org. */\n 'org.permissions.changed': {\n orgId: string\n actor: PluginEventActor\n subject: {\n type: 'org' | 'role' | 'member' | 'host'\n id?: string | null\n name?: string | null\n }\n permission: string\n granted: boolean\n }\n}\n\nexport type PluginEventName = keyof PluginEventPayloads\n\nexport type PluginEventHandler<E extends PluginEventName> = (\n payload: PluginEventPayloads[E],\n) => void | Promise<void>\n\ninterface Registration {\n pluginId: string\n handler: PluginEventHandler<PluginEventName>\n}\n\nconst handlers = new Map<PluginEventName, Registration[]>()\n\n/**\n * Subscribes to an event. Owner = the loader's marker inside a register fn,\n * else `options.pluginId`; a subscription with neither throws. A plugin\n * re-subscribing replaces its earlier handler for that event, so a module\n * evaluated twice does not write two rows.\n */\nexport function registerPluginEventHandler<E extends PluginEventName>(\n event: E,\n handler: PluginEventHandler<E>,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n `plugin event \"${event}\" subscribed with no owner: pass { pluginId } ` +\n 'when subscribing outside a plugin register fn',\n )\n }\n const list = (handlers.get(event) ?? []).filter((entry) => entry.pluginId !== pluginId)\n list.push({ pluginId, handler: handler as PluginEventHandler<PluginEventName> })\n handlers.set(event, list)\n}\n\n/**\n * Raises an event. Every handler runs, in subscription order; a throw is\n * logged with the plugin's id and does not stop the others.\n */\nexport async function runPluginEventHandlers<E extends PluginEventName>(\n event: E,\n payload: PluginEventPayloads[E],\n): Promise<{ handled: number; failed: string[] }> {\n let handled = 0\n const failed: string[] = []\n for (const { pluginId, handler } of handlers.get(event) ?? []) {\n try {\n await handler(payload)\n handled += 1\n } catch (error) {\n failed.push(pluginId)\n console.error(`[plugins] ${pluginId} failed on event ${event}`, error)\n }\n }\n return { handled, failed }\n}\n\n/** The plugins subscribed to an event, for diagnostics. */\nexport function listPluginEventHandlers(event: PluginEventName): string[] {\n return (handlers.get(event) ?? []).map((entry) => entry.pluginId)\n}\n\n/** Test seam: forget every subscription. */\nexport function resetPluginEventHandlersForTests(): void {\n handlers.clear()\n}\n"],"names":["getRegisteringPluginId","handlers","Map","registerPluginEventHandler","event","handler","options","pluginId","trim","Error","list","get","filter","entry","push","set","runPluginEventHandlers","payload","handled","failed","error","console","listPluginEventHandlers","map","resetPluginEventHandlersForTests","clear"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;CAkBC,GAGD,SAASA,sBAAsB,QAAQ,qCAAiC;AA2KxE,MAAMC,WAAW,IAAIC;AAErB;;;;;CAKC,GACD,OAAO,SAASC,2BACdC,KAAQ,EACRC,OAA8B,EAC9BC,OAA+B;QAEbN,MAAAA,yBAOJC;IAPd,MAAMM,WAAW,EAACP,QAAAA,0BAAAA,oCAAAA,0BAA4BM,2BAAAA,QAASC,QAAQ,YAA7CP,OAAiD,IAAIQ,IAAI;IAC3E,IAAI,CAACD,UAAU;QACb,MAAM,IAAIE,MACR,CAAC,cAAc,EAAEL,MAAM,8CAA8C,CAAC,GACpE;IAEN;IACA,MAAMM,OAAO,EAACT,gBAAAA,SAASU,GAAG,CAACP,kBAAbH,gBAAuB,EAAE,EAAEW,MAAM,CAAC,CAACC,QAAUA,MAAMN,QAAQ,KAAKA;IAC9EG,KAAKI,IAAI,CAAC;QAAEP;QAAUF,SAASA;IAA+C;IAC9EJ,SAASc,GAAG,CAACX,OAAOM;AACtB;AAEA;;;CAGC,GACD,OAAO,eAAeM,uBACpBZ,KAAQ,EACRa,OAA+B;QAIKhB;IAFpC,IAAIiB,UAAU;IACd,MAAMC,SAAmB,EAAE;IAC3B,KAAK,MAAM,EAAEZ,QAAQ,EAAEF,OAAO,EAAE,KAAIJ,gBAAAA,SAASU,GAAG,CAACP,kBAAbH,gBAAuB,EAAE,CAAE;QAC7D,IAAI;YACF,MAAMI,QAAQY;YACdC,WAAW;QACb,EAAE,OAAOE,OAAO;YACdD,OAAOL,IAAI,CAACP;YACZc,QAAQD,KAAK,CAAC,CAAC,UAAU,EAAEb,SAAS,iBAAiB,EAAEH,OAAO,EAAEgB;QAClE;IACF;IACA,OAAO;QAAEF;QAASC;IAAO;AAC3B;AAEA,yDAAyD,GACzD,OAAO,SAASG,wBAAwBlB,KAAsB;QACpDH;IAAR,OAAO,EAACA,gBAAAA,SAASU,GAAG,CAACP,kBAAbH,gBAAuB,EAAE,EAAEsB,GAAG,CAAC,CAACV,QAAUA,MAAMN,QAAQ;AAClE;AAEA,0CAA0C,GAC1C,OAAO,SAASiB;IACdvB,SAASwB,KAAK;AAChB"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-events.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Platform events a plugin listens to (AGL-2939, AGL-2940).\n *\n * Core raises a small number of facts about the workspace that a plugin\n * may care about — a seat add-on was bought or dropped, a permission key\n * moved on a member, a role or a site — and a plugin subscribes from its\n * declarations rather than core calling the plugin's own writer. The\n * billing-webhook hooks are the precedent for one vendor's events; this is\n * the same shape for the platform's own.\n *\n * Events a PLUGIN raises for other plugins — an order paid, a return\n * refunded — are `plugin-domain-events.ts` (AGL-3611): declared by the plugin,\n * delivered through an outbox, at least once.\n *\n * Handlers run sequentially and are ISOLATED: a plugin that throws is\n * logged and the next one runs, because these are consequences of a write\n * that has already happened — an activity row, a notification — and a\n * failure in one must not undo or hide the write for the customer.\n */\n\nimport type { OrgSeatAddons } from '../foundation'\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** Who did it, as the activity logs record an actor. */\nexport interface PluginEventActor {\n uid: string | null\n email: string | null\n}\n\n/** The payload each platform event carries. */\nexport interface PluginEventPayloads {\n /** An org's purchased seat add-ons were written (a purchase, a removal, a webhook mirror). */\n 'org.seatAddons.changed': {\n orgId: string\n actor: PluginEventActor\n before: OrgSeatAddons | null | undefined\n after: OrgSeatAddons | null | undefined\n }\n /**\n * The workspace's Stripe invoice was paid, in full, by a real charge.\n *\n * Raised from the platform billing webhook once the invoice has resolved\n * to a workspace, so a plugin that bills its own usage learns the outcome\n * of the invoice it asked for without subscribing to Stripe itself. The\n * invoice's own `metadata` travels with it, unread by core: a plugin that\n * stamped `pluginId` on the invoice it created recognizes its own by it,\n * and every other handler ignores the event.\n *\n * `paidOutOfBand` is Stripe's own flag for an invoice marked paid without\n * a charge. A plugin counting payment history must not count one: it is a\n * staff action, not evidence that money moved.\n */\n 'billing.invoice.paid': {\n orgId: string\n invoiceId: string\n amountPaidCents: number\n currency: string\n paidOutOfBand: boolean\n metadata: Readonly<Record<string, string>>\n }\n /** A charge attempt on the workspace's invoice failed or needs action. */\n 'billing.invoice.failed': {\n orgId: string\n invoiceId: string\n amountDueCents: number\n metadata: Readonly<Record<string, string>>\n }\n /**\n * An invoice was closed without being paid — voided, or written off as\n * uncollectible.\n *\n * ONE event for both Stripe events because they are one fact to a plugin:\n * this invoice will never be paid and nothing more is owed on it. A\n * handler that must tell them apart reads `reason`; one that only needs\n * to stop waiting does not.\n */\n 'billing.invoice.closed': {\n orgId: string\n invoiceId: string\n reason: 'voided' | 'uncollectible'\n metadata: Readonly<Record<string, string>>\n }\n /** A cardholder disputed a charge of the workspace's. */\n 'billing.dispute.opened': {\n orgId: string\n chargeId: string\n invoiceId: string | null\n amountCents: number\n }\n /**\n * The workspace's default payment method changed, or its type did.\n *\n * `defaultType` is Stripe's own type string (`card`, `us_bank_account`,\n * `link`, …), or `null` when the customer has no default. Core states the\n * fact and takes no view: which types a plugin will extend credit against\n * is the plugin's decision, not the platform's.\n */\n 'billing.paymentMethod.changed': {\n orgId: string\n defaultType: string | null\n }\n /**\n * Records were REMOVED from a collection, by a path that is not the\n * owning plugin's own (AGL-3330): a deletion through the public API, a\n * person's erasure.\n *\n * Raised after the delete has landed, with each removed document's data\n * as it stood, so a plugin that keeps a figure over those rows — a counter\n * no increment can take back — can recount what they fed. Core names the\n * collection and takes no view of what it holds.\n *\n * `hostIds` are the sites the records were on: the one site for a site's\n * collection, every site of the organization for an organization row.\n */\n 'host.records.removed': {\n orgId: string | null\n hostIds: readonly string[]\n collection: string\n records: ReadonlyArray<{ id: string; data: Readonly<Record<string, unknown>> }>\n }\n /**\n * A site's email was opened, or a link in it clicked (AGL-3605): the\n * delivery events one webhook delivery recorded for a send tagged with the\n * site, the FIRST of each type per message only — the delivery log decides\n * which those are, so a replay or a reader opening one email six times is\n * one event. Raised by the plugin whose webhook reads the provider, after\n * the log has recorded them; a subscriber that counts engagement of its own\n * reads it here rather than subscribing to the provider.\n */\n 'host.email.engaged': {\n hostId: string\n events: ReadonlyArray<{\n /** The recipient, lowercased. */\n to: string\n /** `opened` or `clicked`. */\n type: string\n /** When it happened, epoch ms. */\n at: number\n firstOfType: boolean\n }>\n }\n /**\n * A custom domain was connected to a site and the hosting provider accepted\n * it (AGL-3629). Raised once the claim is written, so `domain` is the site's\n * `cname` and no other site's. The site may still be awaiting a certificate\n * or a DNS change; a plugin that needs the domain registered somewhere — a\n * payment provider, a search console — does that now rather than waiting\n * for traffic.\n */\n 'host.domain.attached': {\n orgId: string | null\n hostId: string\n domain: string\n }\n /**\n * A domain stopped serving a site (AGL-3629): its custom domain was\n * disconnected, or the site was deleted — which releases its platform\n * subdomain too, raised as a second event with that name. A plugin undoes\n * whatever it registered for the name, and must check the name is not\n * still served by ANOTHER site first (a `www.` twin, a reused subdomain).\n */\n 'host.domain.released': {\n orgId: string | null\n hostId: string\n domain: string\n reason: 'detached' | 'site-deleted'\n }\n /** One permission key moved on a subject of the org. */\n 'org.permissions.changed': {\n orgId: string\n actor: PluginEventActor\n subject: {\n type: 'org' | 'role' | 'member' | 'host'\n id?: string | null\n name?: string | null\n }\n permission: string\n granted: boolean\n }\n}\n\nexport type PluginEventName = keyof PluginEventPayloads\n\nexport type PluginEventHandler<E extends PluginEventName> = (\n payload: PluginEventPayloads[E],\n) => void | Promise<void>\n\ninterface Registration {\n pluginId: string\n handler: PluginEventHandler<PluginEventName>\n}\n\n/**\n * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).\n * The handlers are registered at boot, from `instrumentation.ts`, and Next\n * compiles that file apart from the routes, so the boot and a route each hold\n * their own copy of this module. At module scope, the boot filled its copy and\n * every route read an empty one (AGL-3412, AGL-3868).\n */\nconst REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-event-handlers')\nconst globalScope = globalThis as typeof globalThis & {\n [REGISTRY_KEY]?: Map<PluginEventName, Registration[]>\n}\nconst handlers: Map<PluginEventName, Registration[]> =\n globalScope[REGISTRY_KEY] ?? (globalScope[REGISTRY_KEY] = new Map())\n\n/**\n * Subscribes to an event. Owner = the loader's marker inside a register fn,\n * else `options.pluginId`; a subscription with neither throws. A plugin\n * re-subscribing replaces its earlier handler for that event, so a module\n * evaluated twice does not write two rows.\n */\nexport function registerPluginEventHandler<E extends PluginEventName>(\n event: E,\n handler: PluginEventHandler<E>,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n `plugin event \"${event}\" subscribed with no owner: pass { pluginId } ` +\n 'when subscribing outside a plugin register fn',\n )\n }\n const list = (handlers.get(event) ?? []).filter((entry) => entry.pluginId !== pluginId)\n list.push({ pluginId, handler: handler as PluginEventHandler<PluginEventName> })\n handlers.set(event, list)\n}\n\n/**\n * Raises an event. Every handler runs, in subscription order; a throw is\n * logged with the plugin's id and does not stop the others.\n */\nexport async function runPluginEventHandlers<E extends PluginEventName>(\n event: E,\n payload: PluginEventPayloads[E],\n): Promise<{ handled: number; failed: string[] }> {\n let handled = 0\n const failed: string[] = []\n for (const { pluginId, handler } of handlers.get(event) ?? []) {\n try {\n await handler(payload)\n handled += 1\n } catch (error) {\n failed.push(pluginId)\n console.error(`[plugins] ${pluginId} failed on event ${event}`, error)\n }\n }\n return { handled, failed }\n}\n\n/** The plugins subscribed to an event, for diagnostics. */\nexport function listPluginEventHandlers(event: PluginEventName): string[] {\n return (handlers.get(event) ?? []).map((entry) => entry.pluginId)\n}\n\n/** Test seam: forget every subscription. */\nexport function resetPluginEventHandlersForTests(): void {\n handlers.clear()\n}\n"],"names":["globalScope","getRegisteringPluginId","REGISTRY_KEY","Symbol","for","globalThis","handlers","Map","registerPluginEventHandler","event","handler","options","pluginId","trim","Error","list","get","filter","entry","push","set","runPluginEventHandlers","payload","handled","failed","error","console","listPluginEventHandlers","map","resetPluginEventHandlersForTests","clear"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;CAkBC,OA0LCA;AAvLF,SAASC,sBAAsB,QAAQ,qCAAiC;AA2KxE;;;;;;CAMC,GACD,MAAMC,eAAeC,OAAOC,GAAG,CAAC;AAChC,MAAMJ,cAAcK;AAGpB,MAAMC,YACJN,4BAAAA,WAAW,CAACE,aAAa,YAAzBF,4BAA8BA,WAAW,CAACE,aAAa,GAAG,IAAIK;AAEhE;;;;;CAKC,GACD,OAAO,SAASC,2BACdC,KAAQ,EACRC,OAA8B,EAC9BC,OAA+B;QAEbV,MAAAA,yBAOJK;IAPd,MAAMM,WAAW,EAACX,QAAAA,0BAAAA,oCAAAA,0BAA4BU,2BAAAA,QAASC,QAAQ,YAA7CX,OAAiD,IAAIY,IAAI;IAC3E,IAAI,CAACD,UAAU;QACb,MAAM,IAAIE,MACR,CAAC,cAAc,EAAEL,MAAM,8CAA8C,CAAC,GACpE;IAEN;IACA,MAAMM,OAAO,EAACT,gBAAAA,SAASU,GAAG,CAACP,kBAAbH,gBAAuB,EAAE,EAAEW,MAAM,CAAC,CAACC,QAAUA,MAAMN,QAAQ,KAAKA;IAC9EG,KAAKI,IAAI,CAAC;QAAEP;QAAUF,SAASA;IAA+C;IAC9EJ,SAASc,GAAG,CAACX,OAAOM;AACtB;AAEA;;;CAGC,GACD,OAAO,eAAeM,uBACpBZ,KAAQ,EACRa,OAA+B;QAIKhB;IAFpC,IAAIiB,UAAU;IACd,MAAMC,SAAmB,EAAE;IAC3B,KAAK,MAAM,EAAEZ,QAAQ,EAAEF,OAAO,EAAE,KAAIJ,gBAAAA,SAASU,GAAG,CAACP,kBAAbH,gBAAuB,EAAE,CAAE;QAC7D,IAAI;YACF,MAAMI,QAAQY;YACdC,WAAW;QACb,EAAE,OAAOE,OAAO;YACdD,OAAOL,IAAI,CAACP;YACZc,QAAQD,KAAK,CAAC,CAAC,UAAU,EAAEb,SAAS,iBAAiB,EAAEH,OAAO,EAAEgB;QAClE;IACF;IACA,OAAO;QAAEF;QAASC;IAAO;AAC3B;AAEA,yDAAyD,GACzD,OAAO,SAASG,wBAAwBlB,KAAsB;QACpDH;IAAR,OAAO,EAACA,gBAAAA,SAASU,GAAG,CAACP,kBAAbH,gBAAuB,EAAE,EAAEsB,GAAG,CAAC,CAACV,QAAUA,MAAMN,QAAQ;AAClE;AAEA,0CAA0C,GAC1C,OAAO,SAASiB;IACdvB,SAASwB,KAAK;AAChB"}
@@ -1,4 +1,5 @@
1
1
  import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ var _globalScope_REGISTRY_KEY;
2
3
  /**
3
4
  * @license
4
5
  * Copyright 2026 Aglyn LLC
@@ -41,7 +42,15 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
41
42
  * the plugin's API surface. The core never imports a plugin; this is how a
42
43
  * conversion the tenancy runtime performs reaches a plugin's records.
43
44
  */ import { getRegisteringPluginId } from "../app-utils/registering-plugin.js";
44
- const registrations = [];
45
+ /**
46
+ * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).
47
+ * The listeners are registered at boot, from `instrumentation.ts`, and Next
48
+ * compiles that file apart from the routes, so the boot and a route each hold
49
+ * their own copy of this module. At module scope, the boot filled its copy and
50
+ * every route read an empty one (AGL-3412, AGL-3868).
51
+ */ const REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-lead-conversion-listeners');
52
+ const globalScope = globalThis;
53
+ const registrations = (_globalScope_REGISTRY_KEY = globalScope[REGISTRY_KEY]) != null ? _globalScope_REGISTRY_KEY : globalScope[REGISTRY_KEY] = [];
45
54
  /**
46
55
  * Registers a plugin's conversion listener. Owner = the loader's marker
47
56
  * inside a register fn, else `options.pluginId`; a listener with neither