@aglyn/aglyn 1.0.0-beta.235 → 1.0.0-beta.237

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 (36) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/docs-help-sections.generated.js +6 -3
  3. package/src/lib/app-utils/docs-help-sections.generated.js.map +1 -1
  4. package/src/lib/app-utils/docs-help.generated.d.ts +2 -2
  5. package/src/lib/app-utils/docs-help.generated.js +5 -0
  6. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  7. package/src/lib/app-utils/docs-index.generated.js +119 -14
  8. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  9. package/src/lib/app-utils/element-ui.d.ts +21 -2
  10. package/src/lib/app-utils/element-ui.js +29 -2
  11. package/src/lib/app-utils/element-ui.js.map +1 -1
  12. package/src/lib/app-utils/foreign-dom-guard.d.ts +17 -0
  13. package/src/lib/app-utils/foreign-dom-guard.js +66 -0
  14. package/src/lib/app-utils/foreign-dom-guard.js.map +1 -0
  15. package/src/lib/app-utils/media-filter.js +5 -1
  16. package/src/lib/app-utils/media-filter.js.map +1 -1
  17. package/src/lib/app-utils/media-metadata.d.ts +3 -2
  18. package/src/lib/app-utils/media-metadata.js +4 -0
  19. package/src/lib/app-utils/media-metadata.js.map +1 -1
  20. package/src/lib/app-utils/media-picker-context.d.ts +5 -1
  21. package/src/lib/app-utils/media-picker-context.js +9 -0
  22. package/src/lib/app-utils/media-picker-context.js.map +1 -1
  23. package/src/lib/app-utils/plugin-api-rate-limit.js +3 -1
  24. package/src/lib/app-utils/plugin-api-rate-limit.js.map +1 -1
  25. package/src/lib/app-utils/release-flags.js +6 -6
  26. package/src/lib/app-utils/release-flags.js.map +1 -1
  27. package/src/lib/app-utils/upload-inspection.js +88 -0
  28. package/src/lib/app-utils/upload-inspection.js.map +1 -1
  29. package/src/lib/foundation/definitions/components.types.d.ts +6 -0
  30. package/src/lib/foundation/definitions/components.types.js.map +1 -1
  31. package/src/lib/foundation/definitions/organization.types.d.ts +7 -0
  32. package/src/lib/foundation/definitions/organization.types.js.map +1 -1
  33. package/src/lib/plugin-manager/first-party-plugins.generated.js +18 -0
  34. package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
  35. package/src/lib/plugin-manager/stock-photo-provider.d.ts +5 -0
  36. package/src/lib/plugin-manager/stock-photo-provider.js.map +1 -1
@@ -418,6 +418,94 @@ const ascii = (text)=>Array.from(text, (character)=>character.charCodeAt(0));
418
418
  label: 'Matroska/WebM'
419
419
  }
420
420
  ],
421
+ // Audio for the Music player (AGL-3716). An MP3 opens with an ID3 tag or
422
+ // straight on an MPEG audio frame (sync bits, then the layer III and
423
+ // MPEG-2/2.5 headers encoders actually write); an AAC file is ADTS frames
424
+ // or the same ID3 tag; M4A is ISO media like MP4; OGG and WAV name their
425
+ // containers.
426
+ 'audio/mpeg': [
427
+ {
428
+ magic: ascii('ID3'),
429
+ label: 'MP3'
430
+ },
431
+ {
432
+ magic: [
433
+ 0xff,
434
+ 0xfb
435
+ ],
436
+ label: 'MP3'
437
+ },
438
+ {
439
+ magic: [
440
+ 0xff,
441
+ 0xfa
442
+ ],
443
+ label: 'MP3'
444
+ },
445
+ {
446
+ magic: [
447
+ 0xff,
448
+ 0xf3
449
+ ],
450
+ label: 'MP3'
451
+ },
452
+ {
453
+ magic: [
454
+ 0xff,
455
+ 0xf2
456
+ ],
457
+ label: 'MP3'
458
+ },
459
+ {
460
+ magic: [
461
+ 0xff,
462
+ 0xe3
463
+ ],
464
+ label: 'MP3'
465
+ },
466
+ {
467
+ magic: [
468
+ 0xff,
469
+ 0xe2
470
+ ],
471
+ label: 'MP3'
472
+ }
473
+ ],
474
+ 'audio/aac': [
475
+ {
476
+ magic: [
477
+ 0xff,
478
+ 0xf1
479
+ ],
480
+ label: 'AAC'
481
+ },
482
+ {
483
+ magic: [
484
+ 0xff,
485
+ 0xf9
486
+ ],
487
+ label: 'AAC'
488
+ },
489
+ {
490
+ magic: ascii('ID3'),
491
+ label: 'AAC'
492
+ }
493
+ ],
494
+ 'audio/mp4': [
495
+ ISO_BMFF
496
+ ],
497
+ 'audio/ogg': [
498
+ {
499
+ magic: ascii('OggS'),
500
+ label: 'OGG'
501
+ }
502
+ ],
503
+ 'audio/wav': [
504
+ {
505
+ magic: ascii('RIFF'),
506
+ label: 'WAV'
507
+ }
508
+ ],
421
509
  // A web font (AGL-3656): the theme's font installer stores WOFF2 only.
422
510
  'font/woff2': [
423
511
  {
@@ -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 // 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/**\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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"}
@@ -360,6 +360,12 @@ export interface AglynAttributeSchema extends Dictionary<any> {
360
360
  actions?: FieldActions;
361
361
  resolveProps?: ResolvePropsFunction;
362
362
  description?: string;
363
+ /**
364
+ * The one kind of media library file this attribute holds (AGL-3716):
365
+ * `image`, `video`, `pdf` or `audio`. Its "Browse media" picker lists and
366
+ * uploads only that kind. Read by the editor, never handed to the field.
367
+ */
368
+ mediaKind?: 'image' | 'video' | 'pdf' | 'audio';
363
369
  }
364
370
  export interface AglynNodeSchema<P = JSX.AnyProps> {
365
371
  $id: NodeId;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/aglyn/src/lib/foundation/definitions/components.types.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 type {\n ConditionDefinition,\n DataType,\n Validator,\n} from '@data-driven-forms/react-form-renderer'\nimport type {\n FieldActions,\n ResolvePropsFunction,\n} from '@data-driven-forms/react-form-renderer/common-types'\nimport type { SvgIconProps } from '@mui/material'\nimport type { MuiStyledOptions } from '@mui/system/createStyled'\nimport type { CANVAS_ROOT_ELEMENT_ID } from '../constants/canvas'\nimport type { ComponentCategory } from '../constants/components'\nimport type { FEATURE_FLAG, RICH_TEXT_COMMANDS } from '../constants/shared'\nimport type { AGLYN_OF, SYMBOL_TYPE } from '../constants/symbol'\n\nexport enum LinealDirectiveFlag {\n LIMIT_TO = 'limitedTo',\n DISALLOW = 'forbid',\n}\n\nexport type BundleId = string\nexport type PluginId = string\nexport type ComponentId = string\nexport type PresetId = string\nexport type NodeId = string\n\nexport type ComponentsLinealOrder = [\n directiveType: LinealDirectiveFlag,\n directiveDefinition:\n | Array<ComponentId>\n | { plugins?: Array<PluginId>; components: Array<ComponentId> }\n | { plugins: Array<PluginId>; components?: Array<ComponentId> },\n]\n\nexport type AglynNodeItemDenormalized<P = JSX.AnyProps> = AglynNodeSchema<P> & {\n nodes?: AglynNodeItemDenormalized[]\n}\n\nexport type AglynNodesList = Array<AglynNodeItemDenormalized>\n\nexport type AglynNodeHierarchy<$ID extends NodeId = NodeId> = [\n root: CANVAS_ROOT_ELEMENT_ID,\n ...nodes: [...ancestors: NodeId[], element: $ID],\n]\n\nexport interface AglynExoticComponent<PROPS = any, REF = any>\n extends JSX.ForwardRefExoticComponent<\n JSX.PropsWithoutRef<PROPS> & JSX.RefAttributes<REF>\n > {\n [AGLYN_OF]?: SYMBOL_TYPE\n aglyn?: boolean\n schema?: AglynComponentSchema<PROPS>\n}\n\nexport interface AglynComponentSchema<P = any> {\n $id: ComponentId\n pluginId?: BundleId\n kind?: 'element' | 'plaintext' | 'markdown'\n\n displayName: string\n title?: string\n subtitle?: string\n description?: string\n\n /**\n * Icon props for display around besigner\n */\n icon?: SvgIconProps\n /**\n * Options to be passed to styled(Component, \\{...styledOptions\\})\n */\n styledOptions?: MuiStyledOptions\n\n /**\n * Define a limitation for nodes allowed as direct descendents\n */\n restrictChildren?: ComponentsLinealOrder\n /**\n * Define a limitation for nodes allowed to be direct ancestors\n */\n restrictParent?: ComponentsLinealOrder\n\n /**\n * Filter props\n */\n resolveProps?: JSX.ResolveProps<AglynNodeItemDenormalized<P>>\n\n /**\n * Attribute fields to modify the contextual properties\n * New version\n */\n attributes?: AglynAttributeSchema[]\n\n /**\n * Which groups of formatting the inline rich-text toolbar offers for this\n * component (AGL-2557). Read only where `flags.richTextEditable` is on.\n *\n * Omitted means every group, which is what leaves Typography exactly as it\n * was. Naming a subset narrows the toolbar AND the commit: a surface that\n * offers neither lists nor links is phrasing-only, and the sanitizer holds\n * it to that whatever an author pastes in.\n */\n richTextCommands?: RICH_TEXT_COMMANDS[]\n\n /**\n * Feature flags\n */\n flags?: {\n /**\n * Disable the use of emotion styled\n */\n emotion?: FEATURE_FLAG\n /**\n * Can the nodes of this component type be cloned?\n */\n cloning?: FEATURE_FLAG\n /**\n * Allow dragging nodes of this component type\n */\n dragging?: FEATURE_FLAG\n /**\n * Allow dropping nodes inside nodes of this component type\n */\n dropping?: FEATURE_FLAG\n /**\n * Allow editing element attributes of this component type\n */\n editing?: FEATURE_FLAG\n /**\n * Allow removing nodes of this component type\n */\n removing?: FEATURE_FLAG\n /**\n * Describe nodes of this component type to be self-closing\n */\n selfClosing?: FEATURE_FLAG\n /**\n * Component renders its `children` prop as text content the editor may\n * edit directly (Attributes \"Text\" field, inline canvas editing).\n */\n textEditable?: FEATURE_FLAG\n /**\n * Component also accepts basic rich text (AGL-54): sanitized HTML in the\n * `html` prop with `children` as the plain-text fallback.\n */\n richTextEditable?: FEATURE_FLAG\n /**\n * Component reads its children POSITIONALLY (AGL-1237), so the renderer\n * must hand it one React child per node child instead of the single\n * `<Branch>` element it normally passes.\n *\n * MUI's Accordion does `const [summary, ...rest] = Children.toArray(children)`,\n * and `toArray` does not traverse into an element — so with the default\n * wrapping the summary swallowed the whole subtree and the Collapse got\n * nothing. Every accordion on every published site expanded to reveal an\n * empty panel while its content rendered unconditionally inside the\n * summary's `<h3>`.\n *\n * Opt in only for components with this contract; the wrapper is what\n * keeps the Branch/Stem/Leaf seam swappable for everything else.\n */\n positionalChildren?: FEATURE_FLAG\n }\n\n /**\n * Preset items are the available items to add to the canvas\n */\n presets?: AglynNodePresetSchema[]\n}\n\nexport type NodePresetData = Omit<AglynNodeSchema, '$id' | 'nodes'> & {\n $id?: NodeId\n nodes?: NodePresetData[]\n}\n\nexport type AglynNodePresetSchema = {\n $id: PresetId\n label: string\n componentId?: ComponentId\n pluginId?: BundleId\n description?: string\n icon?: SvgIconProps\n category?: string | ComponentCategory\n data: NodePresetData\n}\n\nexport enum FieldComponentType {\n /**\n * Per-breakpoint span editor (AGL-2486): a small row of breakpoint\n * controls (All / xs / sm / md / lg / xl), each offering a column count\n * plus MUI's `auto` and `grow` keywords. The persisted prop is still ONE\n * string in the syntax the Grid element already parses (`\"6\"`, `\"auto\"`,\n * `\"xs:12 md:6\"`) — this is an input affordance, not a shape change, so\n * renderers and existing documents stay untouched. A value the row cannot\n * model (a `{{token}}`, an unknown breakpoint) falls back to free text\n * rather than being clobbered.\n */\n BREAKPOINT_SPAN = 'breakpoint-span',\n BUTTON = 'button',\n BUTTON_GROUP = 'button-group',\n CHECKBOX = 'checkbox',\n COLOR_PICKER = 'color-picker',\n /**\n * Border editor (AGL-2486): a thickness box plus a plain-English line-style\n * picker (solid / dashed / dotted / double / no line). The persisted prop is\n * still ONE CSS shorthand string (`\"1px solid\"`, `\"none\"`, `\"\"`), so\n * renderers and existing documents stay untouched. A value the pair cannot\n * model (`thin solid`, `1px solid #f00`, a binding token) falls back to free\n * text rather than being clobbered, and flips back the moment the text is a\n * plain `<width> <style>` again.\n */\n CSS_BORDER = 'css-border',\n /**\n * CSS length editor (AGL-1219): a number box plus a unit picker sharing\n * the styles panel's unit list. The persisted prop is still ONE CSS\n * string (`\"920px\"`, `\"100%\"`, `\"auto\"`, `\"\"`) — this is an input\n * affordance, not a shape change, so renderers stay untouched. Values\n * the picker can't model (`calc(…)`, a binding token) fall back to free\n * text rather than being clobbered.\n */\n CSS_DIMENSION = 'css-dimension',\n /**\n * Background fill editor (AGL-1331): a fill-type switch (default / solid\n * / linear / radial) over an angle box and a colour-stop list, each stop\n * bindable to a palette token or a literal. The persisted prop is ONE CSS\n * string under `backgroundImage` — a solid fill writes the explicit\n * keyword `none` and leaves `backgroundColor` to do the job, while the\n * default choice writes nothing at all (AGL-1338: the two differ on a\n * component instance, where absence means \"keep the component's fill\") —\n * so renderers stay untouched. Values\n * the editor cannot model (`conic-gradient`, `to bottom right`, a stacked\n * image list) fall back to free text rather than being clobbered.\n */\n CSS_GRADIENT = 'css-gradient',\n /**\n * Row-and-column grid editor for the Table element (AGL-2543).\n *\n * The persisted prop is still ONE string — pipe-delimited rows with the\n * markdown divider carrying per-column alignment — so the renderer and\n * existing documents stay untouched, and a comparison table already\n * authored inside a Markdown element pastes straight in. This is the\n * affordance that makes the element no-code: without it an author edits\n * pipe syntax, which is the audience the besigner exists to spare.\n */\n DATA_TABLE = 'data-table',\n DATE_PICKER = 'date-picker',\n DUAL_LIST_SELECT = 'dual-list-select',\n FIELD_ARRAY = 'field-array',\n ICON_PICKER = 'icon-picker',\n /**\n * markdown-lite document editor (AGL-1616): the WYSIWYG the console\n * already ships for blog entries and marketplace listings, rendered as the\n * attribute's field. The persisted prop is still ONE markdown-lite string,\n * so every renderer stays untouched — this is an input affordance, not a\n * shape change. Declared by an attribute whose value is a whole document\n * rather than a line of text; a plain TEXTAREA meant the Privacy Policy\n * body was edited as a 13 KB raw paste (AGL-1594).\n */\n MARKDOWN = 'markdown',\n INPUT_ADDON_BUTTON_GROUP = 'input-addon-button-group',\n INPUT_ADDON_GROUP = 'input-addon-group',\n PLAIN_TEXT = 'plain-text',\n /**\n * Named-preset picker with a raw escape hatch (AGL-2486): the theme's own\n * answers first, then plain-English presets that PREVIEW themselves, then\n * Custom…. The persisted prop is whatever the property already stored — a\n * number where a bare number is a theme multiple, a CSS string otherwise —\n * and a stored value matching no preset opens the field in its custom state\n * holding that value rather than dropping it.\n */\n PRESET_CHOICE = 'preset-choice',\n RADIO = 'radio',\n /**\n * Select listing the host's screens; the editor resolves the options from\n * the host routing map at render time and writes the chosen screen id.\n */\n SCREEN_SELECT = 'screen-select',\n /**\n * Select listing the canvas's other elements (AGL-557): the editor\n * resolves options from the live canvas at edit time and persists the\n * node id — e.g. the form's reveal-on-submit target.\n */\n NODE_SELECT = 'node-select',\n /**\n * Id-based entity pickers (AGL-343/344): the editor resolves options\n * from EntityPickerContext at edit time and persists the entity id —\n * renames never break the reference.\n */\n /**\n * Select listing the plugins installed for this site (AGL-1030): the editor\n * resolves options from the install set the console publishes and persists\n * the listing id. A picker rather than a typed document id — the same reason\n * every other reference in the designer is one, and here it also removes the\n * \"is this installed?\" question by construction.\n */\n PLUGIN_SELECT = 'plugin-select',\n /**\n * A placed plugin's declared settings, rendered as real fields (AGL-1049).\n * Reads the sibling plugin selection to know which manifest to offer.\n */\n PLUGIN_SETTINGS = 'plugin-settings',\n PRODUCT_SELECT = 'product-select',\n COLLECTION_SELECT = 'collection-select',\n CATEGORY_SELECT = 'category-select',\n DATASET_SELECT = 'dataset-select',\n /**\n * Select listing the model fields of the nearest ancestor node's chosen\n * dataset (AGL-556): the editor resolves options from\n * EntityPickerContext.entityFields using the ancestor's `datasetId`\n * (or legacy `datasetName` matched by label) and persists the stable\n * model fieldId — field renames never break the mapping.\n */\n DATASET_FIELD_SELECT = 'dataset-field-select',\n /**\n * Select listing the host's form entities (`docs/specs/reusable-forms.md`\n * §2c): the editor resolves options from EntityPickerContext.forms and\n * persists the form id. The same id-first reasoning as every picker in this\n * family, and here it is load-bearing rather than convenient — the string\n * it replaces was the form's whole identity, so a rename split the\n * submission history it named.\n */\n FORM_SELECT = 'form-select',\n SELECT = 'select',\n SLIDER = 'slider',\n SUB_FORM = 'sub-form',\n SWITCH = 'switch',\n TAB_ITEM = 'tab-item',\n TABS = 'tabs',\n TEXT_FIELD = 'text-field',\n TEXTAREA = 'textarea',\n /**\n * Theme-scale combo box (AGL-2486): offers the THEME's own scale for a\n * property — `theme.typography` for font size and weight,\n * `theme.zIndex` for stacking — while still accepting any raw value\n * (`18px`, `1.25rem`, `700`, `1400`), because arbitrary values are\n * legitimate. The persisted value is a theme token PATH\n * (`h4.fontSize`, `fontWeightBold`, `appBar`), which MUI's sx system\n * resolves against the active theme exactly as it resolves\n * `color: 'primary.main'` — so the element keeps following the theme\n * instead of freezing the number it had when it was styled.\n */\n THEME_SCALE = 'theme-scale',\n TIME_PICKER = 'time-picker',\n TOGGLE_BUTTON = 'toggle-button',\n WIZARD = 'wizard',\n}\n\nexport enum FieldValidatorType {\n EXACT_LENGTH = 'exact-length',\n MAX_LENGTH = 'max-length',\n MAX_NUMBER_VALUE = 'max-number-value',\n MIN_ITEMS = 'min-items',\n MIN_LENGTH = 'min-length',\n MIN_NUMBER_VALUE = 'min-number-value',\n PATTERN = 'pattern',\n REQUIRED = 'required',\n URL = 'url',\n}\n\nexport type FieldDataType =\n | 'boolean'\n | 'float'\n | 'integer'\n | 'number'\n | 'string'\n\nexport interface AglynAttributeSchema extends Dictionary<any> {\n name: string\n dataType?: DataType\n component: string | FieldComponentType\n validate?: Validator[]\n condition?: ConditionDefinition | ConditionDefinition[]\n initializeOnMount?: boolean\n initialValue?: unknown\n clearedValue?: unknown\n clearOnUnmount?: boolean\n actions?: FieldActions\n resolveProps?: ResolvePropsFunction\n description?: string\n}\n\nexport interface AglynNodeSchema<P = JSX.AnyProps> {\n $id: NodeId\n componentId: ComponentId\n pluginId?: BundleId\n parentId?: NodeId\n sx?: JSX.SxProps\n props?: P\n nodes?: NodeId[] | AglynNodeSchema[]\n}\n"],"names":["LinealDirectiveFlag","FieldComponentType","FieldValidatorType"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAkBD,OAAO,IAAA,AAAKA,6CAAAA;;;WAAAA;MAGX;AAwKD,OAAO,IAAA,AAAKC,4CAAAA;IACV;;;;;;;;;GASC;;;;;IAMD;;;;;;;;GAQC;IAED;;;;;;;GAOC;IAED;;;;;;;;;;;GAWC;IAED;;;;;;;;;GASC;;;;;IAMD;;;;;;;;GAQC;;;;IAKD;;;;;;;GAOC;;IAGD;;;GAGC;IAED;;;;GAIC;IAED;;;;GAIC,GACD;;;;;;GAMC;IAED;;;GAGC;;;;;IAMD;;;;;;GAMC;IAED;;;;;;;GAOC;;;;;;;;;IAUD;;;;;;;;;;GAUC;;;;WA1JSA;MA+JX;AAED,OAAO,IAAA,AAAKC,4CAAAA;;;;;;;;;;WAAAA;MAUX"}
1
+ {"version":3,"sources":["../../../../../../../libs/aglyn/src/lib/foundation/definitions/components.types.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 type {\n ConditionDefinition,\n DataType,\n Validator,\n} from '@data-driven-forms/react-form-renderer'\nimport type {\n FieldActions,\n ResolvePropsFunction,\n} from '@data-driven-forms/react-form-renderer/common-types'\nimport type { SvgIconProps } from '@mui/material'\nimport type { MuiStyledOptions } from '@mui/system/createStyled'\nimport type { CANVAS_ROOT_ELEMENT_ID } from '../constants/canvas'\nimport type { ComponentCategory } from '../constants/components'\nimport type { FEATURE_FLAG, RICH_TEXT_COMMANDS } from '../constants/shared'\nimport type { AGLYN_OF, SYMBOL_TYPE } from '../constants/symbol'\n\nexport enum LinealDirectiveFlag {\n LIMIT_TO = 'limitedTo',\n DISALLOW = 'forbid',\n}\n\nexport type BundleId = string\nexport type PluginId = string\nexport type ComponentId = string\nexport type PresetId = string\nexport type NodeId = string\n\nexport type ComponentsLinealOrder = [\n directiveType: LinealDirectiveFlag,\n directiveDefinition:\n | Array<ComponentId>\n | { plugins?: Array<PluginId>; components: Array<ComponentId> }\n | { plugins: Array<PluginId>; components?: Array<ComponentId> },\n]\n\nexport type AglynNodeItemDenormalized<P = JSX.AnyProps> = AglynNodeSchema<P> & {\n nodes?: AglynNodeItemDenormalized[]\n}\n\nexport type AglynNodesList = Array<AglynNodeItemDenormalized>\n\nexport type AglynNodeHierarchy<$ID extends NodeId = NodeId> = [\n root: CANVAS_ROOT_ELEMENT_ID,\n ...nodes: [...ancestors: NodeId[], element: $ID],\n]\n\nexport interface AglynExoticComponent<PROPS = any, REF = any>\n extends JSX.ForwardRefExoticComponent<\n JSX.PropsWithoutRef<PROPS> & JSX.RefAttributes<REF>\n > {\n [AGLYN_OF]?: SYMBOL_TYPE\n aglyn?: boolean\n schema?: AglynComponentSchema<PROPS>\n}\n\nexport interface AglynComponentSchema<P = any> {\n $id: ComponentId\n pluginId?: BundleId\n kind?: 'element' | 'plaintext' | 'markdown'\n\n displayName: string\n title?: string\n subtitle?: string\n description?: string\n\n /**\n * Icon props for display around besigner\n */\n icon?: SvgIconProps\n /**\n * Options to be passed to styled(Component, \\{...styledOptions\\})\n */\n styledOptions?: MuiStyledOptions\n\n /**\n * Define a limitation for nodes allowed as direct descendents\n */\n restrictChildren?: ComponentsLinealOrder\n /**\n * Define a limitation for nodes allowed to be direct ancestors\n */\n restrictParent?: ComponentsLinealOrder\n\n /**\n * Filter props\n */\n resolveProps?: JSX.ResolveProps<AglynNodeItemDenormalized<P>>\n\n /**\n * Attribute fields to modify the contextual properties\n * New version\n */\n attributes?: AglynAttributeSchema[]\n\n /**\n * Which groups of formatting the inline rich-text toolbar offers for this\n * component (AGL-2557). Read only where `flags.richTextEditable` is on.\n *\n * Omitted means every group, which is what leaves Typography exactly as it\n * was. Naming a subset narrows the toolbar AND the commit: a surface that\n * offers neither lists nor links is phrasing-only, and the sanitizer holds\n * it to that whatever an author pastes in.\n */\n richTextCommands?: RICH_TEXT_COMMANDS[]\n\n /**\n * Feature flags\n */\n flags?: {\n /**\n * Disable the use of emotion styled\n */\n emotion?: FEATURE_FLAG\n /**\n * Can the nodes of this component type be cloned?\n */\n cloning?: FEATURE_FLAG\n /**\n * Allow dragging nodes of this component type\n */\n dragging?: FEATURE_FLAG\n /**\n * Allow dropping nodes inside nodes of this component type\n */\n dropping?: FEATURE_FLAG\n /**\n * Allow editing element attributes of this component type\n */\n editing?: FEATURE_FLAG\n /**\n * Allow removing nodes of this component type\n */\n removing?: FEATURE_FLAG\n /**\n * Describe nodes of this component type to be self-closing\n */\n selfClosing?: FEATURE_FLAG\n /**\n * Component renders its `children` prop as text content the editor may\n * edit directly (Attributes \"Text\" field, inline canvas editing).\n */\n textEditable?: FEATURE_FLAG\n /**\n * Component also accepts basic rich text (AGL-54): sanitized HTML in the\n * `html` prop with `children` as the plain-text fallback.\n */\n richTextEditable?: FEATURE_FLAG\n /**\n * Component reads its children POSITIONALLY (AGL-1237), so the renderer\n * must hand it one React child per node child instead of the single\n * `<Branch>` element it normally passes.\n *\n * MUI's Accordion does `const [summary, ...rest] = Children.toArray(children)`,\n * and `toArray` does not traverse into an element — so with the default\n * wrapping the summary swallowed the whole subtree and the Collapse got\n * nothing. Every accordion on every published site expanded to reveal an\n * empty panel while its content rendered unconditionally inside the\n * summary's `<h3>`.\n *\n * Opt in only for components with this contract; the wrapper is what\n * keeps the Branch/Stem/Leaf seam swappable for everything else.\n */\n positionalChildren?: FEATURE_FLAG\n }\n\n /**\n * Preset items are the available items to add to the canvas\n */\n presets?: AglynNodePresetSchema[]\n}\n\nexport type NodePresetData = Omit<AglynNodeSchema, '$id' | 'nodes'> & {\n $id?: NodeId\n nodes?: NodePresetData[]\n}\n\nexport type AglynNodePresetSchema = {\n $id: PresetId\n label: string\n componentId?: ComponentId\n pluginId?: BundleId\n description?: string\n icon?: SvgIconProps\n category?: string | ComponentCategory\n data: NodePresetData\n}\n\nexport enum FieldComponentType {\n /**\n * Per-breakpoint span editor (AGL-2486): a small row of breakpoint\n * controls (All / xs / sm / md / lg / xl), each offering a column count\n * plus MUI's `auto` and `grow` keywords. The persisted prop is still ONE\n * string in the syntax the Grid element already parses (`\"6\"`, `\"auto\"`,\n * `\"xs:12 md:6\"`) — this is an input affordance, not a shape change, so\n * renderers and existing documents stay untouched. A value the row cannot\n * model (a `{{token}}`, an unknown breakpoint) falls back to free text\n * rather than being clobbered.\n */\n BREAKPOINT_SPAN = 'breakpoint-span',\n BUTTON = 'button',\n BUTTON_GROUP = 'button-group',\n CHECKBOX = 'checkbox',\n COLOR_PICKER = 'color-picker',\n /**\n * Border editor (AGL-2486): a thickness box plus a plain-English line-style\n * picker (solid / dashed / dotted / double / no line). The persisted prop is\n * still ONE CSS shorthand string (`\"1px solid\"`, `\"none\"`, `\"\"`), so\n * renderers and existing documents stay untouched. A value the pair cannot\n * model (`thin solid`, `1px solid #f00`, a binding token) falls back to free\n * text rather than being clobbered, and flips back the moment the text is a\n * plain `<width> <style>` again.\n */\n CSS_BORDER = 'css-border',\n /**\n * CSS length editor (AGL-1219): a number box plus a unit picker sharing\n * the styles panel's unit list. The persisted prop is still ONE CSS\n * string (`\"920px\"`, `\"100%\"`, `\"auto\"`, `\"\"`) — this is an input\n * affordance, not a shape change, so renderers stay untouched. Values\n * the picker can't model (`calc(…)`, a binding token) fall back to free\n * text rather than being clobbered.\n */\n CSS_DIMENSION = 'css-dimension',\n /**\n * Background fill editor (AGL-1331): a fill-type switch (default / solid\n * / linear / radial) over an angle box and a colour-stop list, each stop\n * bindable to a palette token or a literal. The persisted prop is ONE CSS\n * string under `backgroundImage` — a solid fill writes the explicit\n * keyword `none` and leaves `backgroundColor` to do the job, while the\n * default choice writes nothing at all (AGL-1338: the two differ on a\n * component instance, where absence means \"keep the component's fill\") —\n * so renderers stay untouched. Values\n * the editor cannot model (`conic-gradient`, `to bottom right`, a stacked\n * image list) fall back to free text rather than being clobbered.\n */\n CSS_GRADIENT = 'css-gradient',\n /**\n * Row-and-column grid editor for the Table element (AGL-2543).\n *\n * The persisted prop is still ONE string — pipe-delimited rows with the\n * markdown divider carrying per-column alignment — so the renderer and\n * existing documents stay untouched, and a comparison table already\n * authored inside a Markdown element pastes straight in. This is the\n * affordance that makes the element no-code: without it an author edits\n * pipe syntax, which is the audience the besigner exists to spare.\n */\n DATA_TABLE = 'data-table',\n DATE_PICKER = 'date-picker',\n DUAL_LIST_SELECT = 'dual-list-select',\n FIELD_ARRAY = 'field-array',\n ICON_PICKER = 'icon-picker',\n /**\n * markdown-lite document editor (AGL-1616): the WYSIWYG the console\n * already ships for blog entries and marketplace listings, rendered as the\n * attribute's field. The persisted prop is still ONE markdown-lite string,\n * so every renderer stays untouched — this is an input affordance, not a\n * shape change. Declared by an attribute whose value is a whole document\n * rather than a line of text; a plain TEXTAREA meant the Privacy Policy\n * body was edited as a 13 KB raw paste (AGL-1594).\n */\n MARKDOWN = 'markdown',\n INPUT_ADDON_BUTTON_GROUP = 'input-addon-button-group',\n INPUT_ADDON_GROUP = 'input-addon-group',\n PLAIN_TEXT = 'plain-text',\n /**\n * Named-preset picker with a raw escape hatch (AGL-2486): the theme's own\n * answers first, then plain-English presets that PREVIEW themselves, then\n * Custom…. The persisted prop is whatever the property already stored — a\n * number where a bare number is a theme multiple, a CSS string otherwise —\n * and a stored value matching no preset opens the field in its custom state\n * holding that value rather than dropping it.\n */\n PRESET_CHOICE = 'preset-choice',\n RADIO = 'radio',\n /**\n * Select listing the host's screens; the editor resolves the options from\n * the host routing map at render time and writes the chosen screen id.\n */\n SCREEN_SELECT = 'screen-select',\n /**\n * Select listing the canvas's other elements (AGL-557): the editor\n * resolves options from the live canvas at edit time and persists the\n * node id — e.g. the form's reveal-on-submit target.\n */\n NODE_SELECT = 'node-select',\n /**\n * Id-based entity pickers (AGL-343/344): the editor resolves options\n * from EntityPickerContext at edit time and persists the entity id —\n * renames never break the reference.\n */\n /**\n * Select listing the plugins installed for this site (AGL-1030): the editor\n * resolves options from the install set the console publishes and persists\n * the listing id. A picker rather than a typed document id — the same reason\n * every other reference in the designer is one, and here it also removes the\n * \"is this installed?\" question by construction.\n */\n PLUGIN_SELECT = 'plugin-select',\n /**\n * A placed plugin's declared settings, rendered as real fields (AGL-1049).\n * Reads the sibling plugin selection to know which manifest to offer.\n */\n PLUGIN_SETTINGS = 'plugin-settings',\n PRODUCT_SELECT = 'product-select',\n COLLECTION_SELECT = 'collection-select',\n CATEGORY_SELECT = 'category-select',\n DATASET_SELECT = 'dataset-select',\n /**\n * Select listing the model fields of the nearest ancestor node's chosen\n * dataset (AGL-556): the editor resolves options from\n * EntityPickerContext.entityFields using the ancestor's `datasetId`\n * (or legacy `datasetName` matched by label) and persists the stable\n * model fieldId — field renames never break the mapping.\n */\n DATASET_FIELD_SELECT = 'dataset-field-select',\n /**\n * Select listing the host's form entities (`docs/specs/reusable-forms.md`\n * §2c): the editor resolves options from EntityPickerContext.forms and\n * persists the form id. The same id-first reasoning as every picker in this\n * family, and here it is load-bearing rather than convenient — the string\n * it replaces was the form's whole identity, so a rename split the\n * submission history it named.\n */\n FORM_SELECT = 'form-select',\n SELECT = 'select',\n SLIDER = 'slider',\n SUB_FORM = 'sub-form',\n SWITCH = 'switch',\n TAB_ITEM = 'tab-item',\n TABS = 'tabs',\n TEXT_FIELD = 'text-field',\n TEXTAREA = 'textarea',\n /**\n * Theme-scale combo box (AGL-2486): offers the THEME's own scale for a\n * property — `theme.typography` for font size and weight,\n * `theme.zIndex` for stacking — while still accepting any raw value\n * (`18px`, `1.25rem`, `700`, `1400`), because arbitrary values are\n * legitimate. The persisted value is a theme token PATH\n * (`h4.fontSize`, `fontWeightBold`, `appBar`), which MUI's sx system\n * resolves against the active theme exactly as it resolves\n * `color: 'primary.main'` — so the element keeps following the theme\n * instead of freezing the number it had when it was styled.\n */\n THEME_SCALE = 'theme-scale',\n TIME_PICKER = 'time-picker',\n TOGGLE_BUTTON = 'toggle-button',\n WIZARD = 'wizard',\n}\n\nexport enum FieldValidatorType {\n EXACT_LENGTH = 'exact-length',\n MAX_LENGTH = 'max-length',\n MAX_NUMBER_VALUE = 'max-number-value',\n MIN_ITEMS = 'min-items',\n MIN_LENGTH = 'min-length',\n MIN_NUMBER_VALUE = 'min-number-value',\n PATTERN = 'pattern',\n REQUIRED = 'required',\n URL = 'url',\n}\n\nexport type FieldDataType =\n | 'boolean'\n | 'float'\n | 'integer'\n | 'number'\n | 'string'\n\nexport interface AglynAttributeSchema extends Dictionary<any> {\n name: string\n dataType?: DataType\n component: string | FieldComponentType\n validate?: Validator[]\n condition?: ConditionDefinition | ConditionDefinition[]\n initializeOnMount?: boolean\n initialValue?: unknown\n clearedValue?: unknown\n clearOnUnmount?: boolean\n actions?: FieldActions\n resolveProps?: ResolvePropsFunction\n description?: string\n /**\n * The one kind of media library file this attribute holds (AGL-3716):\n * `image`, `video`, `pdf` or `audio`. Its \"Browse media\" picker lists and\n * uploads only that kind. Read by the editor, never handed to the field.\n */\n mediaKind?: 'image' | 'video' | 'pdf' | 'audio'\n}\n\nexport interface AglynNodeSchema<P = JSX.AnyProps> {\n $id: NodeId\n componentId: ComponentId\n pluginId?: BundleId\n parentId?: NodeId\n sx?: JSX.SxProps\n props?: P\n nodes?: NodeId[] | AglynNodeSchema[]\n}\n"],"names":["LinealDirectiveFlag","FieldComponentType","FieldValidatorType"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAkBD,OAAO,IAAA,AAAKA,6CAAAA;;;WAAAA;MAGX;AAwKD,OAAO,IAAA,AAAKC,4CAAAA;IACV;;;;;;;;;GASC;;;;;IAMD;;;;;;;;GAQC;IAED;;;;;;;GAOC;IAED;;;;;;;;;;;GAWC;IAED;;;;;;;;;GASC;;;;;IAMD;;;;;;;;GAQC;;;;IAKD;;;;;;;GAOC;;IAGD;;;GAGC;IAED;;;;GAIC;IAED;;;;GAIC,GACD;;;;;;GAMC;IAED;;;GAGC;;;;;IAMD;;;;;;GAMC;IAED;;;;;;;GAOC;;;;;;;;;IAUD;;;;;;;;;;GAUC;;;;WA1JSA;MA+JX;AAED,OAAO,IAAA,AAAKC,4CAAAA;;;;;;;;;;WAAAA;MAUX"}
@@ -159,6 +159,13 @@ export interface AglynOrganization extends AglynDocument {
159
159
  crm?: OrgCrmSettings;
160
160
  /** The plan staff asked the workspace to move to (AGL-3466). */
161
161
  upgradeProposal?: OrgUpgradeProposal;
162
+ /**
163
+ * When any member last used the console inside this organization —
164
+ * stamped at creation, then by `/api/orgs/last-activity` at most once per
165
+ * 15 minutes. Server-owned: the rules deny it to every client write. The
166
+ * staff Organizations list sorts by it.
167
+ */
168
+ lastActivityAt?: ITimestamp;
162
169
  }
163
170
  /**
164
171
  * `orgs/{orgId}/members/{uid}` — THE authorization doc: rules resolve a
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/aglyn/src/lib/foundation/definitions/organization.types.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 * Multi-tenant organizations (AGL-233, docs/MULTI_TENANT_FIRESTORE.md):\n * the org is the tenant boundary — one subscription, one workspace\n * subdomain, one isolation subtree. Billing fields mirror `AglynOrgBilling`\n * so the plan/entitlement resolvers work on either doc during the\n * transition; org-keyed billing lands with AGL-237.\n */\n\nimport type { ITimestamp } from '@aglyn/shared-util-timestamp'\nimport type { AccountAcquisition } from '../../app-utils/account-acquisition'\nimport type {\n OrgCrmSettings,\n OrgEntitlements,\n OrgPlan,\n OrgSeatAddons,\n OrgSubscription,\n OrgUpgradeProposal,\n} from './org-billing.types'\nimport type {\n AglynDocument,\n HostUid,\n OrgUid,\n ScopeToken,\n UserUid,\n} from './platform.types'\n\nexport type { OrgUid } from './platform.types'\n/** Workspace subdomain label: `{slug}.aglyn.com` (Slack-style). */\nexport type OrgSlug = string\n\n/** Org-wide roles, strongest to weakest. */\nexport type OrgRole = 'owner' | 'admin' | 'editor' | 'viewer'\n\n/**\n * Per-host refinement for editor/viewer members (\"3 of 15 sites\").\n *\n * `author` (AGL-2334) is `editor` MINUS publish: it may create and edit\n * every content document on the site, and it may not make any of it live.\n * It exists because the agency guide sells exactly that role — \"a client who\n * may edit content but not publish\" — and until now the narrowest thing we\n * could offer was `viewer`, which cannot edit at all.\n *\n * It is a HOST role rather than a twelfth org permission key deliberately.\n * Publishing is a set of client-direct Firestore writes, so it is enforced\n * in the security rules, and the rules resolve a host request from the\n * `memberRoles` projection with the one `get()` they already do. An org\n * permission key is on the wrong axis: rules cannot evaluate a custom role\n * without a second denormalized projection, and every publish surface would\n * need a server route it does not have.\n *\n * Ordered between `editor` and `viewer` because that is its strength, but\n * NOTHING may treat this union as ordered — `ORG_ROLE_WEIGHT` exists for the\n * org axis and has no counterpart here on purpose. Host roles are compared by\n * membership in a set (`HOST_CONTENT_WRITE_ROLES`, `HOST_PUBLISH_ROLES`), and\n * an author is not \"a weaker editor\" in a way any single number can express.\n */\nexport type HostAccessRole = 'admin' | 'editor' | 'author' | 'viewer'\n\n/**\n * A per-site permission key a collaborator can carry (AGL-2927, AGL-2984).\n * The host role decides what a collaborator may do to the SITE; these keys\n * decide what else they may do on it. Each is a catalog key a plugin\n * declares with host-role defaults, the same dotted key the org catalog\n * names, so one label serves both rosters.\n */\nexport type HostPermissionKey = string\n\n/**\n * Every host role, as a value — for `where('memberRoles.{uid}', 'in', …)`.\n *\n * `/hosts/{hostId}` is gated per document on `memberRoles.{uid}`, and\n * Firestore refuses to run a LIST that could return a denied document. So\n * every client query over `hosts` must carry this filter, and it must name\n * ALL the roles: a role missing from the array is a site the member owns and\n * cannot see, with no error to explain it (AGL-1145).\n *\n * Built from a `Record` rather than written as an array so that adding a role\n * to the union above fails to COMPILE here. A plain `HostAccessRole[]` would\n * happily stay short, which is the silent half of the bug.\n */\nconst HOST_ACCESS_ROLE_KEYS: Record<HostAccessRole, true> = {\n admin: true,\n editor: true,\n author: true,\n viewer: true,\n}\nexport const HOST_ACCESS_ROLES = Object.keys(\n HOST_ACCESS_ROLE_KEYS,\n) as HostAccessRole[]\n\nexport interface AglynOrganization extends AglynDocument {\n $id: OrgUid\n name?: string\n slug?: OrgSlug\n /** Current owner; ownership can move without re-keying anything. */\n ownerUid?: UserUid\n /**\n * Who CREATED the workspace — stamped once inside `createOrganization`'s\n * transaction and mutated by nothing, `transferOrgOwnership` included\n * (AGL-2265).\n *\n * `ownerUid` moves; this does not, and the difference is the whole point.\n * The free-workspace ceiling counts the UNION of the two, so handing a\n * workspace to an alt account, creating a fourth and taking the first one\n * back is not a way past the limit. Denied to client writes in the rules\n * for the same reason — a client that could clear its own attribution could\n * mint free workspaces without limit.\n *\n * Absent on every org created before AGL-2265 shipped, and every reader\n * must tolerate that: those are counted by `ownerUid` exactly as they\n * always were.\n */\n createdByUid?: UserUid\n /**\n * Where the workspace came from (AGL-3289): its creator's acquisition\n * record, copied by `createOrganization` at birth and naming the account it\n * was copied from. Written by nothing else, and denied to client writes on\n * every rules branch, for the reason `createdByUid` is: it is the\n * platform's record about the workspace, not the workspace's about itself.\n */\n acquisition?: AccountAcquisition\n /** Directory of the org's hosts (mirrors AglynOrgBilling.hosts). */\n hosts?: Record<HostUid, true>\n\n // Billing (mirrors AglynOrgBilling; source of truth moves here with AGL-237)\n plan?: OrgPlan\n entitlements?: OrgEntitlements\n /**\n * Per-org plugin switchboard (AGL-416): ids of plugins the workspace\n * loads (see plugin-manager/enabled-plugins). Absent = all first-party\n * plugins; always-on ids (base components) and the ids on for every\n * workspace (AI) are unioned in regardless — a site switches those off for\n * itself instead.\n */\n enabledPlugins?: string[]\n seatAddons?: OrgSeatAddons\n stripeCustomerId?: string\n subscription?: OrgSubscription\n suspendedAt?: ITimestamp | null\n /** Staff-internal rationale (AGL-202). NEVER shown to the customer. */\n suspendedReason?: string\n /**\n * Lockdown extensions (AGL-1501) on the shipped AGL-202 carrier — the org\n * scope of the panic button. `suspendedReasonCode` is the enum the notice\n * copy switches on (`security`/`billing`/`maintenance`/`manual`; absent =\n * `manual`), `suspendedMessage` is the CUSTOMER-FACING notice body (unlike\n * `suspendedReason`), and `suspendedUntilMs` is an optional expiry — once\n * it passes, the suspension is inactive with no write needed (maintenance\n * windows end on their own). Plain epoch ms, not a Timestamp, so every\n * cache serialization reads it back unchanged. All written only by\n * /api/admin/lockdown; normalized by `app-utils/lockdown.ts`.\n */\n suspendedReasonCode?: string\n suspendedMessage?: string\n suspendedUntilMs?: number\n /**\n * Whether that suspension is in force, stored for the staff list's\n * Suspended filter (AGL-3416). See `AglynOrgBilling.suspended`.\n */\n suspended?: boolean\n erasureRequestedAt?: ITimestamp | null\n /**\n * Scope applied to newly created datasets when nobody chooses (AGL-1048). `'org'` — the default\n * and today's behavior — shares them with every site. `'host'` starts them\n * private to the site they were created in, which is what an agency\n * running client sites wants: safe by default rather than safe by\n * discipline.\n *\n * Only meaningful when there IS a site in context. Created from the org\n * Data page there is no host to scope to, so those stay `'org'` either way.\n *\n * New media follows `defaultMediaScope` and new CRM records\n * `crm.defaultRecordScope` instead; each falls back to this only while it\n * is unset, because this one field decided all three before AGL-3662.\n */\n defaultResourceScope?: 'org' | 'host'\n /**\n * The same choice for new uploads and media folders (AGL-3662), set\n * separately from datasets: an org can keep its rate card on one site\n * while every site shares its photos. Unset reads `defaultResourceScope`\n * — see `defaultMediaScopeOf` — which is what every org stored before the\n * two were split.\n */\n defaultMediaScope?: 'org' | 'host'\n /** The CRM's organization-wide settings (AGL-2613) — see `OrgCrmSettings`. */\n crm?: OrgCrmSettings\n /** The plan staff asked the workspace to move to (AGL-3466). */\n upgradeProposal?: OrgUpgradeProposal\n}\n\n/**\n * `orgs/{orgId}/members/{uid}` — THE authorization doc: rules resolve a\n * request with this single read. Owner/admin span every host; editor and\n * viewer see `hostAccess` (or `allHosts`).\n */\nexport interface AglynOrgMember extends AglynDocument {\n $id: UserUid\n role?: OrgRole\n /**\n * Custom role reference (AGL-243): id of an `orgs/{orgId}/roles` doc\n * whose permission map overrides the org role's defaults.\n */\n roleId?: string\n /** Per-member permission overrides (AGL-243); win over every layer. */\n permissions?: Record<string, boolean>\n /** Org-wide host access shortcut; otherwise `hostAccess` decides. */\n allHosts?: boolean\n hostAccess?: Record<HostUid, HostAccessRole>\n /**\n * Per-site permission overrides for a COLLABORATOR (AGL-2927), keyed by\n * the host the grant is for. Absent keys resolve by the host role's\n * default, so a document written before this field existed reads the same\n * as one written after it. Org-wide members are never consulted here:\n * their org role, custom role and `permissions` map decide.\n */\n hostPermissions?: Record<HostUid, Partial<Record<HostPermissionKey, boolean>>>\n /**\n * Denormalized reach as scope tokens (AGL-1038), so rules can intersect\n * it with a resource's `visibleTo` — they cannot derive it from\n * `hostAccess` because the rules language has no `.map()`. Written only\n * by `syncOrgAuthProjections`; never edit it by hand.\n */\n scopeTokens?: ScopeToken[]\n /**\n * Denormalized THREE-LAYER permission verdict, so rules can honor a custom\n * role they cannot resolve.\n *\n * `roleId` points at another document and `permissions` is only the top\n * layer, so a rule reading either alone answers a different question from\n * the server — and reproducing the precedence in CEL needs a second\n * cross-document get() plus correct handling of a dangling id. This is\n * `resolveOrgPermissions(member, customRole)`, already applied.\n *\n * ⚠️ ABSENT IS NOT EMPTY, and the rules must never treat it as either\n * \"everything allowed\" or \"nothing allowed\". Documents predating this\n * field, and any written by a path that bypasses\n * `syncOrgAuthProjections`, carry no map at all; the rules fall back to\n * the layers they CAN read — the role and the per-member overrides — which\n * is exactly the verdict they gave before this field existed. Written only\n * by `syncOrgAuthProjections` and by `createOrganization`'s inline stamp;\n * never edit it by hand.\n */\n resolvedPermissions?: Record<string, boolean>\n /** Denormalized for member lists without N user lookups. */\n displayName?: string\n email?: string\n invitedBy?: UserUid\n joinedAt?: ITimestamp\n /**\n * Denormalized org suspension flag (AGL-210) so host writes stay a\n * single rules read; maintained by the staff suspension API.\n */\n orgSuspended?: boolean\n /**\n * The row belongs to platform staff and takes none of the customer's seats\n * (AGL-3466).\n *\n * Stamped by the server whenever it writes a row for an account that holds\n * the `staff` claim at that moment — `createOrganization`, `upsertOrgMember`,\n * `grantHostAccess` — and cleared on every row the account holds when the\n * claim is revoked. Every seat counter and every seat gate skips a stamped\n * row, so a staff member who builds a workspace for a prospect, or joins one\n * to help, never fills the seat the customer is paying for. The rules refuse\n * client writes to `members`, so nobody can stamp themselves.\n */\n staffSeat?: boolean\n}\n\n/**\n * What happens to the outgoing owner when an owner handoff is accepted\n * (AGL-3466): `stay` keeps them on as an admin, `leave` takes them off the\n * roster.\n */\nexport type OwnerHandoffPreviousOwner = 'stay' | 'leave'\n\n/** The handoff half of an owner-handoff invite (AGL-3466). */\nexport interface AglynOrgOwnerHandoff {\n previousOwner: OwnerHandoffPreviousOwner\n}\n\n/** `orgs/{orgId}/invites/{inviteId}` — pending email invites. */\nexport interface AglynOrgInvite extends AglynDocument {\n $id: string\n email?: string\n /**\n * `owner` only on an owner-handoff invite, which always carries `handoff`\n * beside it (AGL-3466). Every other invite is admin, editor or viewer.\n */\n role?: OrgRole\n allHosts?: boolean\n hostAccess?: Record<HostUid, HostAccessRole>\n /**\n * Accepting this invite moves the workspace to the invitee (AGL-3466). It\n * reserves no seat: the owner seat moves rather than being added, and the\n * send-time check refuses a `stay` that would need a seat the plan lacks.\n */\n handoff?: AglynOrgOwnerHandoff\n /**\n * The address belongs to an account holding the `staff` claim, checked when\n * the invite is sent, so it reserves no seat (AGL-3466). Checked again at\n * acceptance, against the account that actually accepts.\n */\n staffSeat?: boolean\n invitedBy?: UserUid\n createdAt?: ITimestamp\n acceptedAt?: ITimestamp | null\n acceptedBy?: UserUid\n}\n\n/**\n * `orgSlugs/{slug}` — transactional uniqueness reservation; created and\n * deleted only by the org APIs (Admin SDK), publicly readable so the\n * console can resolve a workspace subdomain client-side.\n */\nexport interface OrgSlugReservation {\n orgId: OrgUid\n /**\n * Set when the previous holder renamed away — the tombstone that keeps old\n * workspace URLs redirecting until somebody else wants the name.\n */\n movedTo?: string\n /**\n * Epoch millis at which a PENDING reservation lapses (AGL-2585).\n *\n * Present only on a workspace created by an owner whose email was not yet\n * verified, which is every password signup: the address is held for them,\n * not granted to them, and the hold ends unless they confirm the address.\n * Absent means granted, and a grant never expires — so every workspace made\n * by a verified owner, and every one that predates this field, is outside\n * the rule entirely.\n *\n * Cleared by `reap-unverified-orgs` once the owner verifies. Public, like\n * the rest of this document, which is why nothing identifying the owner is\n * written beside it.\n */\n reservedUntil?: number\n}\n\n/**\n * `hostIndex/{hostId}` — server-written host → org resolver so the tenant\n * renderer and middleware find a host's org without scanning.\n */\nexport interface HostIndexEntry {\n orgId: OrgUid\n subdomain?: string\n}\n\n/**\n * `users/{uid}/orgs/{orgId}` — reverse index for \"my organizations\",\n * maintained transactionally with the member doc by the membership API.\n */\nexport interface UserOrgMembership extends AglynDocument {\n $id: OrgUid\n role?: OrgRole\n orgName?: string\n slug?: OrgSlug\n /**\n * Mirror of `isOrgWideMember(orgs/{orgId}/members/{uid})` (AGL-1032): does\n * this membership reach the whole org, or only a list of sites?\n *\n * `role` alone cannot answer it — `grantHostAccess` writes `role: 'viewer'`\n * here for a site collaborator, exactly what a genuine org-wide viewer\n * carries, so the console could not tell them apart without a second read\n * of the member doc on every org route. Denormalized because the console\n * navigation guard has to be synchronous: an async answer flashes the org\n * chrome before hiding it.\n *\n * ABSENT means org-wide. Rows predating the mirror carry no flag, and a\n * missing field must never lock a real member out of their own workspace\n * (the same legacy shape `isOrgWideMember` handles). Only an explicit\n * `false` scopes the console — see `isOrgWideMembership`.\n *\n * Navigation only. The Firestore rules are the access boundary (AGL-1026);\n * a stale mirror at worst shows a page whose reads then come back empty.\n */\n orgWide?: boolean\n}\n\n/**\n * `users/{uid}/hostMemberships/{hostId}` — reverse index of the sites a user\n * can reach, mirroring `hosts/{hostId}.memberRoles` (AGL-844). Denormalizes the\n * host name so the site switcher and subdomain→id routing query a user's own\n * sites, ordered and name-prefix-searched, without scanning the `hosts`\n * collection. Admin-SDK-maintained beside `memberRoles`; a best-effort\n * convenience index, never an authorization source (the rules still gate host\n * reads on `memberRoles`). NOT named `hosts` — that would collide with the\n * top-level `hosts` collection for collection-group rules/indexes.\n */\nexport interface UserHostMembership extends AglynDocument {\n $id: HostUid\n orgId?: OrgUid\n subdomain?: string\n displayName?: string\n nameLower?: string\n /**\n * Mirror of the host's `seo.favicon` (AGL-1071). The site switcher renders\n * from this projection rather than the host doc, so without the mirror it\n * showed the generic glyph for every site — the favicon feature worked in\n * the sites list and nowhere else. Absent when the site has none; writers\n * must DELETE it on clear rather than omit it, since the rows are written\n * with `{ merge: true }` and an omitted field would keep the old icon.\n */\n favicon?: string\n role?: HostAccessRole\n updatedAt?: ITimestamp\n}\n"],"names":["HOST_ACCESS_ROLE_KEYS","admin","editor","author","viewer","HOST_ACCESS_ROLES","Object","keys"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;CAMC,GA6DD;;;;;;;;;;;;CAYC,GACD,MAAMA,wBAAsD;IAC1DC,OAAO;IACPC,QAAQ;IACRC,QAAQ;IACRC,QAAQ;AACV;AACA,OAAO,MAAMC,oBAAoBC,OAAOC,IAAI,CAC1CP,uBACmB"}
1
+ {"version":3,"sources":["../../../../../../../libs/aglyn/src/lib/foundation/definitions/organization.types.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 * Multi-tenant organizations (AGL-233, docs/MULTI_TENANT_FIRESTORE.md):\n * the org is the tenant boundary — one subscription, one workspace\n * subdomain, one isolation subtree. Billing fields mirror `AglynOrgBilling`\n * so the plan/entitlement resolvers work on either doc during the\n * transition; org-keyed billing lands with AGL-237.\n */\n\nimport type { ITimestamp } from '@aglyn/shared-util-timestamp'\nimport type { AccountAcquisition } from '../../app-utils/account-acquisition'\nimport type {\n OrgCrmSettings,\n OrgEntitlements,\n OrgPlan,\n OrgSeatAddons,\n OrgSubscription,\n OrgUpgradeProposal,\n} from './org-billing.types'\nimport type {\n AglynDocument,\n HostUid,\n OrgUid,\n ScopeToken,\n UserUid,\n} from './platform.types'\n\nexport type { OrgUid } from './platform.types'\n/** Workspace subdomain label: `{slug}.aglyn.com` (Slack-style). */\nexport type OrgSlug = string\n\n/** Org-wide roles, strongest to weakest. */\nexport type OrgRole = 'owner' | 'admin' | 'editor' | 'viewer'\n\n/**\n * Per-host refinement for editor/viewer members (\"3 of 15 sites\").\n *\n * `author` (AGL-2334) is `editor` MINUS publish: it may create and edit\n * every content document on the site, and it may not make any of it live.\n * It exists because the agency guide sells exactly that role — \"a client who\n * may edit content but not publish\" — and until now the narrowest thing we\n * could offer was `viewer`, which cannot edit at all.\n *\n * It is a HOST role rather than a twelfth org permission key deliberately.\n * Publishing is a set of client-direct Firestore writes, so it is enforced\n * in the security rules, and the rules resolve a host request from the\n * `memberRoles` projection with the one `get()` they already do. An org\n * permission key is on the wrong axis: rules cannot evaluate a custom role\n * without a second denormalized projection, and every publish surface would\n * need a server route it does not have.\n *\n * Ordered between `editor` and `viewer` because that is its strength, but\n * NOTHING may treat this union as ordered — `ORG_ROLE_WEIGHT` exists for the\n * org axis and has no counterpart here on purpose. Host roles are compared by\n * membership in a set (`HOST_CONTENT_WRITE_ROLES`, `HOST_PUBLISH_ROLES`), and\n * an author is not \"a weaker editor\" in a way any single number can express.\n */\nexport type HostAccessRole = 'admin' | 'editor' | 'author' | 'viewer'\n\n/**\n * A per-site permission key a collaborator can carry (AGL-2927, AGL-2984).\n * The host role decides what a collaborator may do to the SITE; these keys\n * decide what else they may do on it. Each is a catalog key a plugin\n * declares with host-role defaults, the same dotted key the org catalog\n * names, so one label serves both rosters.\n */\nexport type HostPermissionKey = string\n\n/**\n * Every host role, as a value — for `where('memberRoles.{uid}', 'in', …)`.\n *\n * `/hosts/{hostId}` is gated per document on `memberRoles.{uid}`, and\n * Firestore refuses to run a LIST that could return a denied document. So\n * every client query over `hosts` must carry this filter, and it must name\n * ALL the roles: a role missing from the array is a site the member owns and\n * cannot see, with no error to explain it (AGL-1145).\n *\n * Built from a `Record` rather than written as an array so that adding a role\n * to the union above fails to COMPILE here. A plain `HostAccessRole[]` would\n * happily stay short, which is the silent half of the bug.\n */\nconst HOST_ACCESS_ROLE_KEYS: Record<HostAccessRole, true> = {\n admin: true,\n editor: true,\n author: true,\n viewer: true,\n}\nexport const HOST_ACCESS_ROLES = Object.keys(\n HOST_ACCESS_ROLE_KEYS,\n) as HostAccessRole[]\n\nexport interface AglynOrganization extends AglynDocument {\n $id: OrgUid\n name?: string\n slug?: OrgSlug\n /** Current owner; ownership can move without re-keying anything. */\n ownerUid?: UserUid\n /**\n * Who CREATED the workspace — stamped once inside `createOrganization`'s\n * transaction and mutated by nothing, `transferOrgOwnership` included\n * (AGL-2265).\n *\n * `ownerUid` moves; this does not, and the difference is the whole point.\n * The free-workspace ceiling counts the UNION of the two, so handing a\n * workspace to an alt account, creating a fourth and taking the first one\n * back is not a way past the limit. Denied to client writes in the rules\n * for the same reason — a client that could clear its own attribution could\n * mint free workspaces without limit.\n *\n * Absent on every org created before AGL-2265 shipped, and every reader\n * must tolerate that: those are counted by `ownerUid` exactly as they\n * always were.\n */\n createdByUid?: UserUid\n /**\n * Where the workspace came from (AGL-3289): its creator's acquisition\n * record, copied by `createOrganization` at birth and naming the account it\n * was copied from. Written by nothing else, and denied to client writes on\n * every rules branch, for the reason `createdByUid` is: it is the\n * platform's record about the workspace, not the workspace's about itself.\n */\n acquisition?: AccountAcquisition\n /** Directory of the org's hosts (mirrors AglynOrgBilling.hosts). */\n hosts?: Record<HostUid, true>\n\n // Billing (mirrors AglynOrgBilling; source of truth moves here with AGL-237)\n plan?: OrgPlan\n entitlements?: OrgEntitlements\n /**\n * Per-org plugin switchboard (AGL-416): ids of plugins the workspace\n * loads (see plugin-manager/enabled-plugins). Absent = all first-party\n * plugins; always-on ids (base components) and the ids on for every\n * workspace (AI) are unioned in regardless — a site switches those off for\n * itself instead.\n */\n enabledPlugins?: string[]\n seatAddons?: OrgSeatAddons\n stripeCustomerId?: string\n subscription?: OrgSubscription\n suspendedAt?: ITimestamp | null\n /** Staff-internal rationale (AGL-202). NEVER shown to the customer. */\n suspendedReason?: string\n /**\n * Lockdown extensions (AGL-1501) on the shipped AGL-202 carrier — the org\n * scope of the panic button. `suspendedReasonCode` is the enum the notice\n * copy switches on (`security`/`billing`/`maintenance`/`manual`; absent =\n * `manual`), `suspendedMessage` is the CUSTOMER-FACING notice body (unlike\n * `suspendedReason`), and `suspendedUntilMs` is an optional expiry — once\n * it passes, the suspension is inactive with no write needed (maintenance\n * windows end on their own). Plain epoch ms, not a Timestamp, so every\n * cache serialization reads it back unchanged. All written only by\n * /api/admin/lockdown; normalized by `app-utils/lockdown.ts`.\n */\n suspendedReasonCode?: string\n suspendedMessage?: string\n suspendedUntilMs?: number\n /**\n * Whether that suspension is in force, stored for the staff list's\n * Suspended filter (AGL-3416). See `AglynOrgBilling.suspended`.\n */\n suspended?: boolean\n erasureRequestedAt?: ITimestamp | null\n /**\n * Scope applied to newly created datasets when nobody chooses (AGL-1048). `'org'` — the default\n * and today's behavior — shares them with every site. `'host'` starts them\n * private to the site they were created in, which is what an agency\n * running client sites wants: safe by default rather than safe by\n * discipline.\n *\n * Only meaningful when there IS a site in context. Created from the org\n * Data page there is no host to scope to, so those stay `'org'` either way.\n *\n * New media follows `defaultMediaScope` and new CRM records\n * `crm.defaultRecordScope` instead; each falls back to this only while it\n * is unset, because this one field decided all three before AGL-3662.\n */\n defaultResourceScope?: 'org' | 'host'\n /**\n * The same choice for new uploads and media folders (AGL-3662), set\n * separately from datasets: an org can keep its rate card on one site\n * while every site shares its photos. Unset reads `defaultResourceScope`\n * — see `defaultMediaScopeOf` — which is what every org stored before the\n * two were split.\n */\n defaultMediaScope?: 'org' | 'host'\n /** The CRM's organization-wide settings (AGL-2613) — see `OrgCrmSettings`. */\n crm?: OrgCrmSettings\n /** The plan staff asked the workspace to move to (AGL-3466). */\n upgradeProposal?: OrgUpgradeProposal\n /**\n * When any member last used the console inside this organization —\n * stamped at creation, then by `/api/orgs/last-activity` at most once per\n * 15 minutes. Server-owned: the rules deny it to every client write. The\n * staff Organizations list sorts by it.\n */\n lastActivityAt?: ITimestamp\n}\n\n/**\n * `orgs/{orgId}/members/{uid}` — THE authorization doc: rules resolve a\n * request with this single read. Owner/admin span every host; editor and\n * viewer see `hostAccess` (or `allHosts`).\n */\nexport interface AglynOrgMember extends AglynDocument {\n $id: UserUid\n role?: OrgRole\n /**\n * Custom role reference (AGL-243): id of an `orgs/{orgId}/roles` doc\n * whose permission map overrides the org role's defaults.\n */\n roleId?: string\n /** Per-member permission overrides (AGL-243); win over every layer. */\n permissions?: Record<string, boolean>\n /** Org-wide host access shortcut; otherwise `hostAccess` decides. */\n allHosts?: boolean\n hostAccess?: Record<HostUid, HostAccessRole>\n /**\n * Per-site permission overrides for a COLLABORATOR (AGL-2927), keyed by\n * the host the grant is for. Absent keys resolve by the host role's\n * default, so a document written before this field existed reads the same\n * as one written after it. Org-wide members are never consulted here:\n * their org role, custom role and `permissions` map decide.\n */\n hostPermissions?: Record<HostUid, Partial<Record<HostPermissionKey, boolean>>>\n /**\n * Denormalized reach as scope tokens (AGL-1038), so rules can intersect\n * it with a resource's `visibleTo` — they cannot derive it from\n * `hostAccess` because the rules language has no `.map()`. Written only\n * by `syncOrgAuthProjections`; never edit it by hand.\n */\n scopeTokens?: ScopeToken[]\n /**\n * Denormalized THREE-LAYER permission verdict, so rules can honor a custom\n * role they cannot resolve.\n *\n * `roleId` points at another document and `permissions` is only the top\n * layer, so a rule reading either alone answers a different question from\n * the server — and reproducing the precedence in CEL needs a second\n * cross-document get() plus correct handling of a dangling id. This is\n * `resolveOrgPermissions(member, customRole)`, already applied.\n *\n * ⚠️ ABSENT IS NOT EMPTY, and the rules must never treat it as either\n * \"everything allowed\" or \"nothing allowed\". Documents predating this\n * field, and any written by a path that bypasses\n * `syncOrgAuthProjections`, carry no map at all; the rules fall back to\n * the layers they CAN read — the role and the per-member overrides — which\n * is exactly the verdict they gave before this field existed. Written only\n * by `syncOrgAuthProjections` and by `createOrganization`'s inline stamp;\n * never edit it by hand.\n */\n resolvedPermissions?: Record<string, boolean>\n /** Denormalized for member lists without N user lookups. */\n displayName?: string\n email?: string\n invitedBy?: UserUid\n joinedAt?: ITimestamp\n /**\n * Denormalized org suspension flag (AGL-210) so host writes stay a\n * single rules read; maintained by the staff suspension API.\n */\n orgSuspended?: boolean\n /**\n * The row belongs to platform staff and takes none of the customer's seats\n * (AGL-3466).\n *\n * Stamped by the server whenever it writes a row for an account that holds\n * the `staff` claim at that moment — `createOrganization`, `upsertOrgMember`,\n * `grantHostAccess` — and cleared on every row the account holds when the\n * claim is revoked. Every seat counter and every seat gate skips a stamped\n * row, so a staff member who builds a workspace for a prospect, or joins one\n * to help, never fills the seat the customer is paying for. The rules refuse\n * client writes to `members`, so nobody can stamp themselves.\n */\n staffSeat?: boolean\n}\n\n/**\n * What happens to the outgoing owner when an owner handoff is accepted\n * (AGL-3466): `stay` keeps them on as an admin, `leave` takes them off the\n * roster.\n */\nexport type OwnerHandoffPreviousOwner = 'stay' | 'leave'\n\n/** The handoff half of an owner-handoff invite (AGL-3466). */\nexport interface AglynOrgOwnerHandoff {\n previousOwner: OwnerHandoffPreviousOwner\n}\n\n/** `orgs/{orgId}/invites/{inviteId}` — pending email invites. */\nexport interface AglynOrgInvite extends AglynDocument {\n $id: string\n email?: string\n /**\n * `owner` only on an owner-handoff invite, which always carries `handoff`\n * beside it (AGL-3466). Every other invite is admin, editor or viewer.\n */\n role?: OrgRole\n allHosts?: boolean\n hostAccess?: Record<HostUid, HostAccessRole>\n /**\n * Accepting this invite moves the workspace to the invitee (AGL-3466). It\n * reserves no seat: the owner seat moves rather than being added, and the\n * send-time check refuses a `stay` that would need a seat the plan lacks.\n */\n handoff?: AglynOrgOwnerHandoff\n /**\n * The address belongs to an account holding the `staff` claim, checked when\n * the invite is sent, so it reserves no seat (AGL-3466). Checked again at\n * acceptance, against the account that actually accepts.\n */\n staffSeat?: boolean\n invitedBy?: UserUid\n createdAt?: ITimestamp\n acceptedAt?: ITimestamp | null\n acceptedBy?: UserUid\n}\n\n/**\n * `orgSlugs/{slug}` — transactional uniqueness reservation; created and\n * deleted only by the org APIs (Admin SDK), publicly readable so the\n * console can resolve a workspace subdomain client-side.\n */\nexport interface OrgSlugReservation {\n orgId: OrgUid\n /**\n * Set when the previous holder renamed away — the tombstone that keeps old\n * workspace URLs redirecting until somebody else wants the name.\n */\n movedTo?: string\n /**\n * Epoch millis at which a PENDING reservation lapses (AGL-2585).\n *\n * Present only on a workspace created by an owner whose email was not yet\n * verified, which is every password signup: the address is held for them,\n * not granted to them, and the hold ends unless they confirm the address.\n * Absent means granted, and a grant never expires — so every workspace made\n * by a verified owner, and every one that predates this field, is outside\n * the rule entirely.\n *\n * Cleared by `reap-unverified-orgs` once the owner verifies. Public, like\n * the rest of this document, which is why nothing identifying the owner is\n * written beside it.\n */\n reservedUntil?: number\n}\n\n/**\n * `hostIndex/{hostId}` — server-written host → org resolver so the tenant\n * renderer and middleware find a host's org without scanning.\n */\nexport interface HostIndexEntry {\n orgId: OrgUid\n subdomain?: string\n}\n\n/**\n * `users/{uid}/orgs/{orgId}` — reverse index for \"my organizations\",\n * maintained transactionally with the member doc by the membership API.\n */\nexport interface UserOrgMembership extends AglynDocument {\n $id: OrgUid\n role?: OrgRole\n orgName?: string\n slug?: OrgSlug\n /**\n * Mirror of `isOrgWideMember(orgs/{orgId}/members/{uid})` (AGL-1032): does\n * this membership reach the whole org, or only a list of sites?\n *\n * `role` alone cannot answer it — `grantHostAccess` writes `role: 'viewer'`\n * here for a site collaborator, exactly what a genuine org-wide viewer\n * carries, so the console could not tell them apart without a second read\n * of the member doc on every org route. Denormalized because the console\n * navigation guard has to be synchronous: an async answer flashes the org\n * chrome before hiding it.\n *\n * ABSENT means org-wide. Rows predating the mirror carry no flag, and a\n * missing field must never lock a real member out of their own workspace\n * (the same legacy shape `isOrgWideMember` handles). Only an explicit\n * `false` scopes the console — see `isOrgWideMembership`.\n *\n * Navigation only. The Firestore rules are the access boundary (AGL-1026);\n * a stale mirror at worst shows a page whose reads then come back empty.\n */\n orgWide?: boolean\n}\n\n/**\n * `users/{uid}/hostMemberships/{hostId}` — reverse index of the sites a user\n * can reach, mirroring `hosts/{hostId}.memberRoles` (AGL-844). Denormalizes the\n * host name so the site switcher and subdomain→id routing query a user's own\n * sites, ordered and name-prefix-searched, without scanning the `hosts`\n * collection. Admin-SDK-maintained beside `memberRoles`; a best-effort\n * convenience index, never an authorization source (the rules still gate host\n * reads on `memberRoles`). NOT named `hosts` — that would collide with the\n * top-level `hosts` collection for collection-group rules/indexes.\n */\nexport interface UserHostMembership extends AglynDocument {\n $id: HostUid\n orgId?: OrgUid\n subdomain?: string\n displayName?: string\n nameLower?: string\n /**\n * Mirror of the host's `seo.favicon` (AGL-1071). The site switcher renders\n * from this projection rather than the host doc, so without the mirror it\n * showed the generic glyph for every site — the favicon feature worked in\n * the sites list and nowhere else. Absent when the site has none; writers\n * must DELETE it on clear rather than omit it, since the rows are written\n * with `{ merge: true }` and an omitted field would keep the old icon.\n */\n favicon?: string\n role?: HostAccessRole\n updatedAt?: ITimestamp\n}\n"],"names":["HOST_ACCESS_ROLE_KEYS","admin","editor","author","viewer","HOST_ACCESS_ROLES","Object","keys"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;CAMC,GA6DD;;;;;;;;;;;;CAYC,GACD,MAAMA,wBAAsD;IAC1DC,OAAO;IACPC,QAAQ;IACRC,QAAQ;IACRC,QAAQ;AACV;AACA,OAAO,MAAMC,oBAAoBC,OAAOC,IAAI,CAC1CP,uBACmB"}
@@ -277,6 +277,12 @@
277
277
  "keeps": "The connection is kept, and a courier already on its way still finishes, with its progress on the order."
278
278
  }
279
279
  },
280
+ {
281
+ "id": "lightbox",
282
+ "label": "Lightbox",
283
+ "alwaysOn": true,
284
+ "description": "A lightbox you fill with any elements and open from any button, link or picture."
285
+ },
280
286
  {
281
287
  "id": "live-chat",
282
288
  "label": "Live chat",
@@ -309,6 +315,16 @@
309
315
  "keeps": "Your Weglot settings and your Weglot account's translations are kept."
310
316
  }
311
317
  },
318
+ {
319
+ "id": "music",
320
+ "label": "Music player",
321
+ "alwaysOnForWorkspace": true,
322
+ "description": "Play your own tracks from the media library on your site.",
323
+ "siteOff": {
324
+ "stops": "Switching the Music player off for this site stops its players rendering on its published pages.",
325
+ "keeps": "The audio in your media library is kept, and players keep working on the workspace's other sites."
326
+ }
327
+ },
312
328
  {
313
329
  "id": "ad-conversions",
314
330
  "label": "Ad conversions",
@@ -354,9 +370,11 @@ export const PUBLISHED_SITE_IMPACT = {
354
370
  "delivery-apps": "console-only",
355
371
  "loyalty": "console-only",
356
372
  "couriers": "console-only",
373
+ "lightbox": "elements",
357
374
  "live-chat": "elements",
358
375
  "review-platforms": "console-only",
359
376
  "weglot": "elements",
377
+ "music": "elements",
360
378
  "ad-conversions": "console-only"
361
379
  };
362
380
  /**