@jini-ai/cms 0.5.5 → 0.5.7

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 (138) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/consent/express/index.d.ts +53 -0
  3. package/dist/consent/express/index.js +159 -0
  4. package/dist/consent/express/response.d.ts +21 -0
  5. package/dist/consent/express/response.js +89 -0
  6. package/dist/consent/html/client.d.ts +6 -0
  7. package/dist/consent/html/client.js +135 -0
  8. package/dist/consent/html/gate.d.ts +29 -0
  9. package/dist/consent/html/gate.js +104 -0
  10. package/dist/consent/html/index.d.ts +4 -0
  11. package/dist/consent/html/index.js +3 -0
  12. package/dist/consent/html/render.d.ts +27 -0
  13. package/dist/consent/html/render.js +62 -0
  14. package/dist/consent/html/static.d.ts +7 -0
  15. package/dist/consent/html/static.js +25 -0
  16. package/dist/consent/html/styles.d.ts +3 -0
  17. package/dist/consent/html/styles.js +22 -0
  18. package/dist/consent/index.d.ts +3 -0
  19. package/dist/consent/index.js +3 -0
  20. package/dist/consent/node/index.d.ts +47 -0
  21. package/dist/consent/node/index.js +169 -0
  22. package/dist/consent/policy.d.ts +47 -0
  23. package/dist/consent/policy.js +62 -0
  24. package/dist/consent/types.d.ts +40 -0
  25. package/dist/consent/types.js +5 -0
  26. package/dist/content-types/agent-tools.js +1 -1
  27. package/dist/content-types/tool-registrations.d.ts +2 -0
  28. package/dist/content-types/tool-registrations.js +1 -0
  29. package/dist/content-types/write-service.d.ts +2 -0
  30. package/dist/content-types/write-service.js +3 -0
  31. package/dist/forms/descriptor-rules.d.ts +9 -0
  32. package/dist/forms/descriptor-rules.js +112 -0
  33. package/dist/forms/email-format.d.ts +7 -0
  34. package/dist/forms/email-format.js +11 -0
  35. package/dist/forms/express/handler.js +4 -8
  36. package/dist/forms/field-registry.d.ts +193 -0
  37. package/dist/forms/field-registry.js +231 -0
  38. package/dist/forms/forms.d.ts +8 -5
  39. package/dist/forms/forms.js +29 -46
  40. package/dist/forms/html/html-authoring.js +63 -13
  41. package/dist/forms/html/html-submission-adapter.d.ts +5 -5
  42. package/dist/forms/html/html-submission-adapter.js +6 -13
  43. package/dist/forms/html-render.d.ts +10 -3
  44. package/dist/forms/html-render.js +116 -14
  45. package/dist/forms/index.d.ts +1 -0
  46. package/dist/forms/index.js +1 -0
  47. package/dist/forms/repo.memory.js +4 -4
  48. package/dist/forms/submission-attempts.d.ts +1 -1
  49. package/dist/forms/submission-attempts.js +13 -2
  50. package/dist/forms/submission-email-validation.js +2 -4
  51. package/dist/forms/submit-service.d.ts +3 -0
  52. package/dist/forms/submit-service.js +38 -5
  53. package/dist/forms/types.d.ts +50 -2
  54. package/dist/listing/batch.d.ts +53 -0
  55. package/dist/listing/batch.js +57 -0
  56. package/dist/listing/html.d.ts +72 -0
  57. package/dist/listing/html.js +109 -0
  58. package/dist/listing/index.d.ts +5 -0
  59. package/dist/listing/index.js +5 -0
  60. package/dist/listing/paths.d.ts +32 -0
  61. package/dist/listing/paths.js +37 -0
  62. package/dist/listing/see-more-script.d.ts +16 -0
  63. package/dist/listing/see-more-script.js +62 -0
  64. package/dist/media/content-type-sniffer.d.ts +1 -1
  65. package/dist/media/content-type-sniffer.js +15 -0
  66. package/dist/media/image-transformer.sharp.js +4 -1
  67. package/dist/media/media-service.d.ts +5 -3
  68. package/dist/media/media-service.js +10 -26
  69. package/dist/media/node.d.ts +1 -1
  70. package/dist/media/node.js +1 -1
  71. package/dist/media/slug.d.ts +8 -0
  72. package/dist/media/slug.js +32 -0
  73. package/dist/media/transform-registry.js +1 -1
  74. package/dist/media/transform-types.d.ts +2 -0
  75. package/dist/media/transform-types.js +3 -0
  76. package/dist/media/video-frames.ffmpeg.d.ts +15 -2
  77. package/dist/media/video-frames.ffmpeg.js +28 -13
  78. package/dist/navigation/dropdown.d.ts +41 -0
  79. package/dist/navigation/dropdown.js +85 -0
  80. package/dist/navigation/index.d.ts +2 -0
  81. package/dist/navigation/index.js +2 -0
  82. package/dist/redirects/capture.d.ts +2 -0
  83. package/dist/redirects/capture.js +4 -0
  84. package/dist/redirects/redirects.d.ts +8 -0
  85. package/dist/redirects/redirects.js +14 -0
  86. package/dist/routing/index.d.ts +5 -0
  87. package/dist/routing/index.js +2 -0
  88. package/dist/routing/ports.d.ts +51 -0
  89. package/dist/routing/ports.js +2 -0
  90. package/dist/routing/routing.d.ts +138 -0
  91. package/dist/routing/routing.js +443 -0
  92. package/dist/routing/types.d.ts +164 -0
  93. package/dist/routing/types.js +2 -0
  94. package/dist/seo/media.d.ts +23 -0
  95. package/dist/seo/media.js +30 -5
  96. package/dist/seo/page-head.js +8 -1
  97. package/dist/seo/seo.d.ts +2 -0
  98. package/dist/seo/seo.js +11 -6
  99. package/dist/seo/types.d.ts +8 -0
  100. package/dist/seo/xml-escape.d.ts +1 -3
  101. package/dist/seo/xml-escape.js +1 -6
  102. package/dist/widgets/concurrency.d.ts +27 -1
  103. package/dist/widgets/concurrency.js +12 -23
  104. package/dist/widgets/config-validation.js +6 -0
  105. package/dist/widgets/gallery-resolver.d.ts +23 -0
  106. package/dist/widgets/gallery-resolver.js +80 -0
  107. package/dist/widgets/html/gallery-render.d.ts +24 -0
  108. package/dist/widgets/html/gallery-render.js +65 -0
  109. package/dist/widgets/html/html-embeds.d.ts +4 -0
  110. package/dist/widgets/html/html-embeds.js +7 -1
  111. package/dist/widgets/html/index.d.ts +3 -0
  112. package/dist/widgets/html/index.js +3 -0
  113. package/dist/widgets/html/server-fragment.d.ts +9 -0
  114. package/dist/widgets/html/server-fragment.js +38 -0
  115. package/dist/widgets/markers/gallery.d.ts +50 -0
  116. package/dist/widgets/markers/gallery.js +164 -0
  117. package/dist/widgets/markers/html-attributes.d.ts +6 -1
  118. package/dist/widgets/markers/html-attributes.js +24 -9
  119. package/dist/widgets/markers/index.d.ts +2 -0
  120. package/dist/widgets/markers/index.js +2 -0
  121. package/dist/widgets/markers/marker.d.ts +4 -0
  122. package/dist/widgets/markers/marker.js +6 -0
  123. package/dist/widgets/markers/media-reference.d.ts +11 -0
  124. package/dist/widgets/markers/media-reference.js +11 -0
  125. package/dist/widgets/page-embed-types.d.ts +1 -1
  126. package/dist/widgets/page-embed-types.js +1 -1
  127. package/dist/widgets/registry.d.ts +144 -10
  128. package/dist/widgets/registry.js +40 -0
  129. package/dist/widgets/resolver-service.d.ts +10 -0
  130. package/dist/widgets/resolver-service.js +73 -29
  131. package/dist/widgets/resolvers/create-core-resolvers.d.ts +3 -0
  132. package/dist/widgets/resolvers/create-core-resolvers.js +2 -0
  133. package/dist/widgets/resolvers/gallery.d.ts +12 -0
  134. package/dist/widgets/resolvers/gallery.js +33 -0
  135. package/dist/widgets/resolvers/index.d.ts +1 -0
  136. package/dist/widgets/resolvers/index.js +1 -0
  137. package/dist/widgets/types.d.ts +3 -1
  138. package/package.json +100 -7
@@ -0,0 +1,27 @@
1
+ import { type ConsentConfig, type ConsentRecord, type ConsentRegionMode } from '../types.js';
2
+ /** Refuse executable/data/protocol-relative privacy links; local pages are the common case.
3
+ * @complexity O(url length). */
4
+ export declare function consentPolicyHref({ url }: {
5
+ url: string;
6
+ }, _options?: {}): string | null;
7
+ /** Render one accessible progressive form. Config text is always text, never trusted HTML.
8
+ * Necessary cookies/default rejection work without JS. @complexity O(config text length). */
9
+ export declare function renderConsentUi({ config, record, regionMode, gpc, endpoint }: {
10
+ config: ConsentConfig;
11
+ record: ConsentRecord | null;
12
+ regionMode: ConsentRegionMode;
13
+ gpc: boolean;
14
+ endpoint: string;
15
+ }, _options?: {}): string;
16
+ /** Bootstrap must be inserted before every site script; JSON cannot terminate its script element.
17
+ * @complexity O(config text length). */
18
+ export declare function renderConsentHead({ record, gpc, regionMode }: {
19
+ record: ConsentRecord | null;
20
+ gpc: boolean;
21
+ regionMode?: ConsentRegionMode;
22
+ }, { nonce, runtimeToken, resourceOrigins }?: {
23
+ nonce?: string;
24
+ runtimeToken?: string;
25
+ resourceOrigins?: readonly string[];
26
+ }): string;
27
+ //# sourceMappingURL=render.d.ts.map
@@ -0,0 +1,62 @@
1
+ import { escapeHtml } from '@jini-ai/core/text';
2
+ import { DENIED_CHOICES } from '../types.js';
3
+ import { googleConsentSignals, resolveConsentChoices } from '../policy.js';
4
+ import { CONSENT_CLIENT } from './client.js';
5
+ import { CONSENT_CSS } from './styles.js';
6
+ /** Refuse executable/data/protocol-relative privacy links; local pages are the common case.
7
+ * @complexity O(url length). */
8
+ export function consentPolicyHref({ url }, _options = {}) {
9
+ if (/^\/(?!\/)/.test(url) && !/[\\\x00-\x20]/.test(url))
10
+ return url;
11
+ try {
12
+ const parsed = new URL(url);
13
+ return parsed.protocol === 'https:' && !parsed.username && !parsed.password ? parsed.href : null;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ /** Render one accessible progressive form. Config text is always text, never trusted HTML.
20
+ * Necessary cookies/default rejection work without JS. @complexity O(config text length). */
21
+ export function renderConsentUi({ config, record, regionMode, gpc, endpoint }, _options = {}) {
22
+ const e = (text) => escapeHtml({ text });
23
+ const choices = resolveConsentChoices({ choices: record?.choices, gpc, regionMode });
24
+ const href = consentPolicyHref({ url: config.privacyPolicyUrl });
25
+ const categories = ['necessary', 'functional', 'analytics', 'marketing'].map(category => {
26
+ const locked = category === 'necessary' || (gpc && (category === 'analytics' || category === 'marketing'));
27
+ return `<label class="jini-consent-category"><input type="checkbox" name="${category}"${choices[category] ? ' checked' : ''}${locked ? ' disabled' : ''}>${category[0].toUpperCase() + category.slice(1)}${category === 'necessary' ? ' (always on)' : ''}</label>`;
28
+ }).join('');
29
+ return `<section id="jini-consent" class="jini-consent" data-has-record="${record !== null}" aria-label="Cookie preferences">
30
+ <div class="jini-consent-initial"><h2>${e(config.title)}</h2><p>${e(config.message)}</p>
31
+ <p>${regionMode === 'opt-out' ? 'You may opt out of optional cookies at any time.' : 'Optional cookies and scripts stay paused until you agree.'}</p></div>
32
+ ${href ? `<a href="${e(href)}">Privacy policy</a> · ` : ''}<a href="#jini-consent-preferences" data-jini-do-not-sell>Do Not Sell or Share</a>
33
+ <p data-jini-gpc-note${gpc ? '' : ' hidden'}>Global Privacy Control is on. Analytics and marketing are disabled.</p>
34
+ <form method="post" action="${e(endpoint)}"><input type="hidden" name="policyVersion" value="${e(config.policyVersion)}"><input type="hidden" name="gpc" value="${gpc ? '1' : '0'}">
35
+ <p role="alert" data-jini-consent-error hidden></p>
36
+ <div class="jini-consent-actions jini-consent-initial"><button name="action" value="reject">${e(config.rejectLabel)}</button><button name="action" value="accept">${e(config.acceptLabel)}</button></div>
37
+ <button id="jini-consent-do-not-sell" name="action" value="do-not-sell" data-jini-do-not-sell-button>Do Not Sell or Share</button>
38
+ <details id="jini-consent-preferences"><summary>${e(config.preferencesLabel)}</summary><fieldset><legend>Cookie categories</legend>${categories}</fieldset>
39
+ <div class="jini-consent-actions"><button name="action" value="save">Save preferences</button><button name="action" value="reject">${e(config.rejectLabel)}</button></div></details></form></section>`;
40
+ }
41
+ /** Bootstrap must be inserted before every site script; JSON cannot terminate its script element.
42
+ * @complexity O(config text length). */
43
+ export function renderConsentHead({ record, gpc, regionMode = 'opt-in' }, { nonce, runtimeToken, resourceOrigins = [] } = {}) {
44
+ const choices = resolveConsentChoices({ choices: record?.choices, gpc, regionMode });
45
+ const gpcChoices = resolveConsentChoices({ choices: record?.choices, gpc: true, regionMode });
46
+ // Origins are a host contract, never extracted from authored resource URLs.
47
+ const origins = resourceOrigins.map(value => {
48
+ const url = new URL(value);
49
+ if (!['http:', 'https:'].includes(url.protocol) || url.origin !== value)
50
+ throw new Error('Consent resource origins must be HTTP(S) origins.');
51
+ return url.origin;
52
+ });
53
+ const sources = ["'self'", ...origins].join(' ');
54
+ const preConsentPolicy = `style-src ${sources} 'unsafe-inline'; font-src ${sources} data:; img-src ${sources} data: blob:; media-src ${sources}; frame-src ${sources}; connect-src ${sources}`;
55
+ const state = JSON.stringify({ resourcePolicy: preConsentPolicy, choices, gpcChoices, signals: googleConsentSignals({ choices }), gpcSignals: googleConsentSignals({ choices: gpcChoices }), deniedSignals: googleConsentSignals({ choices: DENIED_CHOICES }), hasRecord: record !== null, optOut: regionMode === 'opt-out', gpc }).replace(/</g, '\\u003c');
56
+ // A local stylesheet can import remote CSS/fonts that HTML traversal cannot see.
57
+ // Each choice reloads, so restrictive policy can be removed only after a saved grant.
58
+ const resourcePolicy = choices.marketing ? '' : `<meta http-equiv="Content-Security-Policy" content="${escapeHtml({ text: preConsentPolicy })}">`;
59
+ const nonceAttr = nonce === undefined ? '' : ` nonce="${escapeHtml({ text: nonce })}"`;
60
+ return `${resourcePolicy}<style data-jini-consent-style${nonceAttr}>${CONSENT_CSS}</style><script type="application/json" id="jini-consent-config"${nonceAttr}>${state}</script><script data-jini-consent-runtime="${escapeHtml({ text: runtimeToken ?? '' })}"${nonceAttr}>${CONSENT_CLIENT}</script>`;
61
+ }
62
+ //# sourceMappingURL=render.js.map
@@ -0,0 +1,7 @@
1
+ /** Static exports keep optional content inert, without forms posting to a nonexistent server.
2
+ * The same parse5 owner supplies source locations; no competing HTML parsing lifecycle.
3
+ * @complexity O(n + s log s) time/O(n) space for n bytes and s removed runtime elements. */
4
+ export declare function stripConsentRuntime({ html }: {
5
+ html: string;
6
+ }, _options?: {}): string;
7
+ //# sourceMappingURL=static.d.ts.map
@@ -0,0 +1,25 @@
1
+ import { parse } from 'parse5';
2
+ /** Static exports keep optional content inert, without forms posting to a nonexistent server.
3
+ * The same parse5 owner supplies source locations; no competing HTML parsing lifecycle.
4
+ * @complexity O(n + s log s) time/O(n) space for n bytes and s removed runtime elements. */
5
+ export function stripConsentRuntime({ html }, _options = {}) {
6
+ const stack = [...parse(html, { sourceCodeLocationInfo: true, scriptingEnabled: false }).childNodes];
7
+ const edits = [];
8
+ while (stack.length) {
9
+ const node = stack.pop();
10
+ if (!('tagName' in node))
11
+ continue;
12
+ const attrs = new Map(node.attrs.map(attr => [attr.name, attr.value]));
13
+ const location = node.sourceCodeLocation;
14
+ if (location && (attrs.get('id') === 'jini-consent' || attrs.get('id') === 'jini-consent-config' || attrs.has('data-jini-consent-runtime') || attrs.has('data-jini-consent-style'))) {
15
+ edits.push({ start: location.startOffset, end: location.endOffset });
16
+ continue;
17
+ }
18
+ if (node.tagName !== 'template')
19
+ stack.push(...node.childNodes);
20
+ }
21
+ for (const edit of edits.sort((a, b) => b.start - a.start))
22
+ html = html.slice(0, edit.start) + html.slice(edit.end);
23
+ return html;
24
+ }
25
+ //# sourceMappingURL=static.js.map
@@ -0,0 +1,3 @@
1
+ /** Scoped fallback styles use inherited theme typography and overridable jini tokens. */
2
+ export declare const CONSENT_CSS = "\n.jini-consent{position:fixed;inset:auto 1rem 1rem;z-index:2147483000;margin:auto;max-width:46rem;max-height:85dvh;overflow:auto;padding:1.25rem;border:1px solid var(--jini-border,var(--border,#d4d4d4));border-radius:var(--jini-radius,12px);background:var(--jini-surface,var(--bg,#fff));color:var(--jini-text,var(--fg,#222));box-shadow:0 8px 32px #0002;font:inherit;line-height:1.5;text-align:start;box-sizing:border-box}\n.jini-consent *, .jini-consent *::before, .jini-consent *::after{box-sizing:border-box}\n.jini-consent h2{font:inherit;font-weight:650;margin:0 0 .5rem;color:inherit}\n.jini-consent p{margin:.5rem 0;color:inherit}\n.jini-consent-actions{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:1rem}\n.jini-consent button,.jini-consent summary{font:inherit;cursor:pointer;min-height:44px}\n.jini-consent button{padding:.55rem .85rem;border:1px solid currentColor;border-radius:var(--jini-radius,8px);background:transparent;color:inherit}\n.jini-consent button:disabled{cursor:default;opacity:.65}\n.jini-consent a{color:inherit;text-decoration:underline}\n.jini-consent :focus-visible{outline:2px solid currentColor;outline-offset:3px}\n.jini-consent details{margin-top:.75rem}\n.jini-consent summary{padding:.5rem 0}\n.jini-consent-category{display:flex;align-items:center;gap:.6rem;padding:.35rem 0;min-height:44px}\n.jini-consent-category input{width:1.15rem;height:1.15rem;accent-color:var(--jini-accent,#333)}\n.jini-consent[data-has-record=\"true\"]{position:static;box-shadow:none;margin:1rem auto;max-height:none}\n.jini-consent[data-has-record=\"true\"] .jini-consent-initial{display:none}\n.jini-consent [hidden]{display:none!important}\n@media(max-width:480px){.jini-consent{inset:auto .5rem .5rem;padding:1rem;max-height:80dvh}.jini-consent-actions button{flex:1 1 auto}.jini-consent[data-has-record=\"true\"]{margin:1rem .5rem}}\n";
3
+ //# sourceMappingURL=styles.d.ts.map
@@ -0,0 +1,22 @@
1
+ /** Scoped fallback styles use inherited theme typography and overridable jini tokens. */
2
+ export const CONSENT_CSS = `
3
+ .jini-consent{position:fixed;inset:auto 1rem 1rem;z-index:2147483000;margin:auto;max-width:46rem;max-height:85dvh;overflow:auto;padding:1.25rem;border:1px solid var(--jini-border,var(--border,#d4d4d4));border-radius:var(--jini-radius,12px);background:var(--jini-surface,var(--bg,#fff));color:var(--jini-text,var(--fg,#222));box-shadow:0 8px 32px #0002;font:inherit;line-height:1.5;text-align:start;box-sizing:border-box}
4
+ .jini-consent *, .jini-consent *::before, .jini-consent *::after{box-sizing:border-box}
5
+ .jini-consent h2{font:inherit;font-weight:650;margin:0 0 .5rem;color:inherit}
6
+ .jini-consent p{margin:.5rem 0;color:inherit}
7
+ .jini-consent-actions{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:1rem}
8
+ .jini-consent button,.jini-consent summary{font:inherit;cursor:pointer;min-height:44px}
9
+ .jini-consent button{padding:.55rem .85rem;border:1px solid currentColor;border-radius:var(--jini-radius,8px);background:transparent;color:inherit}
10
+ .jini-consent button:disabled{cursor:default;opacity:.65}
11
+ .jini-consent a{color:inherit;text-decoration:underline}
12
+ .jini-consent :focus-visible{outline:2px solid currentColor;outline-offset:3px}
13
+ .jini-consent details{margin-top:.75rem}
14
+ .jini-consent summary{padding:.5rem 0}
15
+ .jini-consent-category{display:flex;align-items:center;gap:.6rem;padding:.35rem 0;min-height:44px}
16
+ .jini-consent-category input{width:1.15rem;height:1.15rem;accent-color:var(--jini-accent,#333)}
17
+ .jini-consent[data-has-record="true"]{position:static;box-shadow:none;margin:1rem auto;max-height:none}
18
+ .jini-consent[data-has-record="true"] .jini-consent-initial{display:none}
19
+ .jini-consent [hidden]{display:none!important}
20
+ @media(max-width:480px){.jini-consent{inset:auto .5rem .5rem;padding:1rem;max-height:80dvh}.jini-consent-actions button{flex:1 1 auto}.jini-consent[data-has-record="true"]{margin:1rem .5rem}}
21
+ `;
22
+ //# sourceMappingURL=styles.js.map
@@ -0,0 +1,3 @@
1
+ export * from './types.js';
2
+ export * from './policy.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,3 @@
1
+ export * from './types.js';
2
+ export * from './policy.js';
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,47 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import { type ConsentRecord, type ConsentRecordPort } from '../types.js';
3
+ /** Atomic writer owns flush/rename/permissions; the host binds its installed platform writer. */
4
+ export interface ConsentFilePorts {
5
+ readText(required: {
6
+ filePath: string;
7
+ }, optional?: Record<string, never>): Promise<string | null>;
8
+ writeJson(required: {
9
+ filePath: string;
10
+ record: ConsentRecord;
11
+ }, optional?: Record<string, never>): Promise<void>;
12
+ }
13
+ /** Host injects the existing platform/fs writer rather than copying its durability lifecycle. */
14
+ export type ConsentAtomicWriter = (required: {
15
+ filePath: string;
16
+ data: unknown;
17
+ }, optional: {
18
+ createParent: boolean;
19
+ mode: number;
20
+ refuseSymlink: boolean;
21
+ }) => Promise<void>;
22
+ /** Publish a flushed staging file with an exclusive hard link, then flush the directory.
23
+ * Filesystem/link failures propagate. No reader ever sees a partially-written proof file.
24
+ * @complexity Startup O(r log r) time/O(r) space; subsequent writes amortized O(1) plus bounded receipt I/O. */
25
+ export declare function createNodeConsentFiles({ atomicWrite }: {
26
+ atomicWrite: ConsentAtomicWriter;
27
+ }, { filesystem, uniqueId, maximumRecords, retentionMs, nowMs }?: {
28
+ filesystem?: Pick<typeof fs, 'link' | 'unlink' | 'open' | 'readdir' | 'stat'>;
29
+ uniqueId?: () => string;
30
+ maximumRecords?: number;
31
+ retentionMs?: number;
32
+ nowMs?: () => number;
33
+ }): ConsentFilePorts;
34
+ /** Per-site opaque filenames; IDs cannot navigate out of the configured proof directory.
35
+ * One immutable file per choice keeps historic policy versions without schema changes.
36
+ * @complexity O(1) record lookup; publication uses the injected writer's cached capacity/retention inventory.
37
+ * @throws Invalid ID, filesystem and malformed JSON errors; only absence returns null. */
38
+ export declare function createConsentFileRecords({ directory, files }: {
39
+ directory: string;
40
+ files: ConsentFilePorts;
41
+ }, _options?: {}): ConsentRecordPort;
42
+ /** Group temporary IPv6 addresses by /64 using native address validation; mapped IPv4 keeps
43
+ * its IPv4 budget. The host still owns trusted-proxy address selection. @complexity O(1). */
44
+ export declare function consentRateLimitKey({ address }: {
45
+ address: string;
46
+ }, _options?: {}): string;
47
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,169 @@
1
+ import path from 'node:path';
2
+ import * as fs from 'node:fs/promises';
3
+ import { isIP } from 'node:net';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { CONSENT_MAX_AGE_MS } from '../types.js';
6
+ /** Publish a flushed staging file with an exclusive hard link, then flush the directory.
7
+ * Filesystem/link failures propagate. No reader ever sees a partially-written proof file.
8
+ * @complexity Startup O(r log r) time/O(r) space; subsequent writes amortized O(1) plus bounded receipt I/O. */
9
+ export function createNodeConsentFiles({ atomicWrite }, { filesystem = fs, uniqueId = randomUUID, maximumRecords = 10000, retentionMs = CONSENT_MAX_AGE_MS, nowMs = Date.now } = {}) {
10
+ if (!Number.isSafeInteger(maximumRecords) || maximumRecords < 1)
11
+ throw new Error('Consent receipt capacity must be a positive integer.');
12
+ if (!Number.isFinite(retentionMs) || retentionMs < 0)
13
+ throw new Error('Consent receipt retention must be nonnegative.');
14
+ const writes = new Map();
15
+ const inventories = new Map();
16
+ /** Serialize capacity checks/publication in this adapter. The host selects proof retention;
17
+ * cookie expiry is not a legal proof-erasure policy. This bounded default is configurable,
18
+ * and capacity eviction may shorten it; hosts needing longer proof retention must size accordingly.
19
+ * Scan once at startup, then maintain an oldest-first inventory rather than stat every save.
20
+ * @complexity Initial O(r log r); cached lookup O(1), pruning amortized O(1) per receipt. */
21
+ async function inventory({ directory }, _options = {}) {
22
+ const cached = inventories.get(directory);
23
+ if (cached)
24
+ return cached;
25
+ let names;
26
+ try {
27
+ names = await filesystem.readdir(directory);
28
+ }
29
+ catch (error) {
30
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
31
+ names = [];
32
+ else
33
+ throw error;
34
+ }
35
+ const entries = [];
36
+ for (const name of names) {
37
+ if (/^[a-zA-Z0-9_-]{16,128}\.json$/.test(name))
38
+ entries.push([name, (await filesystem.stat(path.join(directory, name))).mtimeMs]);
39
+ }
40
+ entries.sort((left, right) => left[1] - right[1]);
41
+ const retained = new Map(entries);
42
+ inventories.set(directory, retained);
43
+ return retained;
44
+ }
45
+ /** Expiry and capacity remove only owned receipts; unrelated files stay intact. */
46
+ async function capacity({ directory, retained }, _options = {}) {
47
+ const cutoff = nowMs() - retentionMs;
48
+ for (const [name, timestamp] of retained) {
49
+ if (retained.size < maximumRecords && timestamp >= cutoff)
50
+ break;
51
+ try {
52
+ await filesystem.unlink(path.join(directory, name));
53
+ }
54
+ catch (error) {
55
+ if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'))
56
+ throw error;
57
+ }
58
+ retained.delete(name);
59
+ }
60
+ }
61
+ return {
62
+ async readText({ filePath }) {
63
+ try {
64
+ const handle = await filesystem.open(filePath, 'r');
65
+ try {
66
+ if ((await handle.stat()).size > 4096)
67
+ throw new Error('Consent receipt exceeds 4096 bytes.');
68
+ return await handle.readFile('utf8');
69
+ }
70
+ finally {
71
+ await handle.close();
72
+ }
73
+ }
74
+ catch (error) {
75
+ if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT')
76
+ return null;
77
+ throw error;
78
+ }
79
+ },
80
+ async writeJson({ filePath, record }) {
81
+ const directoryPath = path.dirname(filePath);
82
+ const pending = (writes.get(directoryPath) ?? Promise.resolve()).catch(() => undefined).then(async () => {
83
+ if (JSON.stringify(record).length > 4096)
84
+ throw new Error('Consent receipt exceeds 4096 bytes.');
85
+ const retained = await inventory({ directory: directoryPath });
86
+ if (retained.has(path.basename(filePath)))
87
+ throw new Error('Consent receipt ID already exists.');
88
+ const staging = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${uniqueId()}.pending`);
89
+ await atomicWrite({ filePath: staging, data: record }, { createParent: true, mode: 0o600, refuseSymlink: true });
90
+ try {
91
+ await capacity({ directory: directoryPath, retained });
92
+ await filesystem.link(staging, filePath);
93
+ retained.set(path.basename(filePath), nowMs());
94
+ const directory = await filesystem.open(path.dirname(filePath), 'r');
95
+ try {
96
+ await directory.sync();
97
+ }
98
+ finally {
99
+ await directory.close();
100
+ }
101
+ }
102
+ finally {
103
+ await filesystem.unlink(staging);
104
+ }
105
+ });
106
+ writes.set(directoryPath, pending);
107
+ try {
108
+ await pending;
109
+ }
110
+ catch (error) {
111
+ inventories.delete(directoryPath);
112
+ throw error;
113
+ }
114
+ finally {
115
+ if (writes.get(directoryPath) === pending)
116
+ writes.delete(directoryPath);
117
+ }
118
+ },
119
+ };
120
+ }
121
+ /** Per-site opaque filenames; IDs cannot navigate out of the configured proof directory.
122
+ * One immutable file per choice keeps historic policy versions without schema changes.
123
+ * @complexity O(1) record lookup; publication uses the injected writer's cached capacity/retention inventory.
124
+ * @throws Invalid ID, filesystem and malformed JSON errors; only absence returns null. */
125
+ export function createConsentFileRecords({ directory, files }, _options = {}) {
126
+ const filePath = (id) => {
127
+ if (!/^[a-zA-Z0-9_-]{16,128}$/.test(id))
128
+ throw new Error('Invalid consent receipt ID.');
129
+ return path.join(directory, `${id}.json`);
130
+ };
131
+ return {
132
+ async read({ id }) {
133
+ const text = await files.readText({ filePath: filePath(id) });
134
+ if (text === null)
135
+ return null;
136
+ if (text.length > 4096)
137
+ throw new Error('Consent receipt exceeds 4096 bytes.');
138
+ return JSON.parse(text);
139
+ },
140
+ async save({ record }) {
141
+ const destination = filePath(record.id);
142
+ // The injected writer must also enforce exclusive publication against concurrent collisions.
143
+ if (await files.readText({ filePath: destination }) !== null)
144
+ throw new Error('Consent receipt ID already exists.');
145
+ await files.writeJson({ filePath: destination, record });
146
+ },
147
+ };
148
+ }
149
+ /** Group temporary IPv6 addresses by /64 using native address validation; mapped IPv4 keeps
150
+ * its IPv4 budget. The host still owns trusted-proxy address selection. @complexity O(1). */
151
+ export function consentRateLimitKey({ address }, _options = {}) {
152
+ if (isIP(address) !== 6)
153
+ return address;
154
+ let normalized = address.split('%')[0];
155
+ if (normalized.includes('.')) {
156
+ const at = normalized.lastIndexOf(':');
157
+ const octets = normalized.slice(at + 1).split('.').map(Number);
158
+ normalized = normalized.slice(0, at + 1) + ((octets[0] << 8) + octets[1]).toString(16) + ':' + ((octets[2] << 8) + octets[3]).toString(16);
159
+ }
160
+ const [left = '', right = ''] = normalized.toLowerCase().split('::');
161
+ const before = left ? left.split(':') : [];
162
+ const after = right ? right.split(':') : [];
163
+ const groups = normalized.includes('::') ? [...before, ...Array(8 - before.length - after.length).fill('0'), ...after] : before;
164
+ const values = groups.map(group => parseInt(group, 16));
165
+ if (values.slice(0, 5).every(value => value === 0) && values[5] === 65535)
166
+ return `${values[6] >> 8}.${values[6] & 255}.${values[7] >> 8}.${values[7] & 255}`;
167
+ return values.slice(0, 4).map(value => value.toString(16)).join(':') + '::/64';
168
+ }
169
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,47 @@
1
+ import { type ConsentChoices, type ConsentConfig, type ConsentRecord, type ConsentRegionMode } from './types.js';
2
+ /** Only California has opt-out defaults; missing/unrecognized regions use opt-in.
3
+ * The host supplies a trusted header value, never a paid geo dependency. @complexity O(1). */
4
+ export declare function resolveConsentRegion({ configuredMode, region }: {
5
+ configuredMode: ConsentConfig['regionMode'];
6
+ region: string | null;
7
+ }, _options?: {}): ConsentRegionMode;
8
+ /** GPC wins over stored or new grants. Conservative analytics denial covers tags that share data.
9
+ * Opt-out regions allow optional categories until refusal; GPC still takes precedence. @complexity O(1). */
10
+ export declare function resolveConsentChoices({ choices, gpc, regionMode }: {
11
+ choices?: ConsentChoices | undefined;
12
+ gpc: boolean;
13
+ regionMode?: ConsentRegionMode;
14
+ }, _options?: {}): ConsentChoices;
15
+ /** Validate stored evidence at the trust boundary; invalid, stale, or future evidence is absent.
16
+ * @complexity O(1); receipt size is bounded by storage/HTTP adapters. */
17
+ export declare function readConsentRecord({ value, policyVersion, nowMs }: {
18
+ value: unknown;
19
+ policyVersion: string;
20
+ nowMs: number;
21
+ }, { maxAgeMs }?: {
22
+ maxAgeMs?: number;
23
+ }): ConsentRecord | null;
24
+ /** Parse form fields without truthy coercion; reject invalid actions and policy races.
25
+ * @throws Error with an actionable input message. @complexity O(1). */
26
+ export declare function createConsentRecord({ fields, config, id, timestamp, regionMode, gpc }: {
27
+ fields: Record<string, unknown>;
28
+ config: ConsentConfig;
29
+ id: string;
30
+ timestamp: string;
31
+ regionMode: ConsentRegionMode;
32
+ gpc: boolean;
33
+ }, _options?: {}): ConsentRecord;
34
+ /** All v2 advertising signals map to marketing; this enqueues signals, never loads a Google tag.
35
+ * @complexity O(1). */
36
+ export declare function googleConsentSignals({ choices }: {
37
+ choices: ConsentChoices;
38
+ }, _options?: {}): {
39
+ ad_storage: string;
40
+ ad_user_data: string;
41
+ ad_personalization: string;
42
+ analytics_storage: string;
43
+ functionality_storage: string;
44
+ personalization_storage: string;
45
+ security_storage: string;
46
+ };
47
+ //# sourceMappingURL=policy.d.ts.map
@@ -0,0 +1,62 @@
1
+ import { CONSENT_MAX_AGE_MS, DENIED_CHOICES } from './types.js';
2
+ /** Only California has opt-out defaults; missing/unrecognized regions use opt-in.
3
+ * The host supplies a trusted header value, never a paid geo dependency. @complexity O(1). */
4
+ export function resolveConsentRegion({ configuredMode, region }, _options = {}) {
5
+ if (configuredMode !== 'auto')
6
+ return configuredMode;
7
+ return region?.trim().toUpperCase() === 'US-CA' ? 'opt-out' : 'opt-in';
8
+ }
9
+ /** GPC wins over stored or new grants. Conservative analytics denial covers tags that share data.
10
+ * Opt-out regions allow optional categories until refusal; GPC still takes precedence. @complexity O(1). */
11
+ export function resolveConsentChoices({ choices, gpc, regionMode = 'opt-in' }, _options = {}) {
12
+ const effective = { ...(choices ?? (regionMode === 'opt-out' ? { necessary: true, functional: true, analytics: true, marketing: true } : DENIED_CHOICES)), necessary: true };
13
+ if (gpc)
14
+ return { ...effective, analytics: false, marketing: false };
15
+ return effective;
16
+ }
17
+ /** Validate stored evidence at the trust boundary; invalid, stale, or future evidence is absent.
18
+ * @complexity O(1); receipt size is bounded by storage/HTTP adapters. */
19
+ export function readConsentRecord({ value, policyVersion, nowMs }, { maxAgeMs = CONSENT_MAX_AGE_MS } = {}) {
20
+ if (!value || typeof value !== 'object')
21
+ return null;
22
+ const record = value;
23
+ if (typeof record.id !== 'string' || !/^[a-zA-Z0-9_-]{16,128}$/.test(record.id))
24
+ return null;
25
+ if (record.policyVersion !== policyVersion || typeof record.timestamp !== 'string')
26
+ return null;
27
+ const time = Date.parse(record.timestamp);
28
+ if (!Number.isFinite(time) || time > nowMs || nowMs - time > maxAgeMs)
29
+ return null;
30
+ if (record.regionMode !== 'opt-in' && record.regionMode !== 'opt-out')
31
+ return null;
32
+ if (typeof record.gpc !== 'boolean' || !['accept', 'reject', 'save', 'do-not-sell'].includes(record.action ?? ''))
33
+ return null;
34
+ const choices = record.choices;
35
+ if (!choices || choices.necessary !== true || ['functional', 'analytics', 'marketing'].some(key => typeof choices[key] !== 'boolean'))
36
+ return null;
37
+ return { id: record.id, timestamp: record.timestamp, policyVersion, regionMode: record.regionMode, gpc: record.gpc, action: record.action, choices: resolveConsentChoices({ choices, gpc: record.gpc }) };
38
+ }
39
+ /** Parse form fields without truthy coercion; reject invalid actions and policy races.
40
+ * @throws Error with an actionable input message. @complexity O(1). */
41
+ export function createConsentRecord({ fields, config, id, timestamp, regionMode, gpc }, _options = {}) {
42
+ if (fields.policyVersion !== config.policyVersion)
43
+ throw new Error('The privacy policy changed. Reload this page and choose again.');
44
+ const action = fields.action;
45
+ if (typeof action !== 'string' || !['accept', 'reject', 'save', 'do-not-sell'].includes(action))
46
+ throw new Error('Choose accept, reject, save, or do-not-sell.');
47
+ let choices = { ...DENIED_CHOICES };
48
+ if (action === 'accept')
49
+ choices = { necessary: true, functional: true, analytics: true, marketing: true };
50
+ if (action === 'do-not-sell')
51
+ choices = { necessary: true, functional: fields.functional === 'on', analytics: false, marketing: false };
52
+ if (action === 'save')
53
+ choices = { necessary: true, functional: fields.functional === 'on', analytics: fields.analytics === 'on', marketing: fields.marketing === 'on' };
54
+ return { id, timestamp, policyVersion: config.policyVersion, regionMode, gpc, action: action, choices: resolveConsentChoices({ choices, gpc }) };
55
+ }
56
+ /** All v2 advertising signals map to marketing; this enqueues signals, never loads a Google tag.
57
+ * @complexity O(1). */
58
+ export function googleConsentSignals({ choices }, _options = {}) {
59
+ const marketing = choices.marketing ? 'granted' : 'denied';
60
+ return { ad_storage: marketing, ad_user_data: marketing, ad_personalization: marketing, analytics_storage: choices.analytics ? 'granted' : 'denied', functionality_storage: choices.functional ? 'granted' : 'denied', personalization_storage: marketing, security_storage: 'granted' };
61
+ }
62
+ //# sourceMappingURL=policy.js.map
@@ -0,0 +1,40 @@
1
+ /** Optional scripts require a stored visitor choice; necessary is never optional. */
2
+ export declare const CONSENT_CATEGORIES: readonly ["necessary", "functional", "analytics", "marketing"];
3
+ export type ConsentCategory = typeof CONSENT_CATEGORIES[number];
4
+ export type ConsentChoices = Record<ConsentCategory, boolean>;
5
+ export type ConsentRegionMode = 'opt-in' | 'opt-out';
6
+ export type ConsentAction = 'accept' | 'reject' | 'save' | 'do-not-sell';
7
+ /** Text/policy content belongs to the host, not the mechanism. */
8
+ export interface ConsentConfig {
9
+ enabled: boolean;
10
+ regionMode: 'auto' | ConsentRegionMode;
11
+ policyVersion: string;
12
+ privacyPolicyUrl: string;
13
+ title: string;
14
+ message: string;
15
+ acceptLabel: string;
16
+ rejectLabel: string;
17
+ preferencesLabel: string;
18
+ }
19
+ /** Immutable evidence; no IP, user-agent, or principal identity is collected. */
20
+ export interface ConsentRecord {
21
+ id: string;
22
+ timestamp: string;
23
+ policyVersion: string;
24
+ choices: ConsentChoices;
25
+ regionMode: ConsentRegionMode;
26
+ gpc: boolean;
27
+ action: ConsentAction;
28
+ }
29
+ /** save must be durable before resolving, and must not overwrite an existing ID. */
30
+ export interface ConsentRecordPort {
31
+ read(required: {
32
+ id: string;
33
+ }, optional?: Record<string, never>): Promise<unknown>;
34
+ save(required: {
35
+ record: ConsentRecord;
36
+ }, optional?: Record<string, never>): Promise<void>;
37
+ }
38
+ export declare const CONSENT_MAX_AGE_MS: number;
39
+ export declare const DENIED_CHOICES: Readonly<ConsentChoices>;
40
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,5 @@
1
+ /** Optional scripts require a stored visitor choice; necessary is never optional. */
2
+ export const CONSENT_CATEGORIES = ['necessary', 'functional', 'analytics', 'marketing'];
3
+ export const CONSENT_MAX_AGE_MS = 180 * 24 * 60 * 60 * 1000;
4
+ export const DENIED_CHOICES = Object.freeze({ necessary: true, functional: false, analytics: false, marketing: false });
5
+ //# sourceMappingURL=types.js.map
@@ -140,7 +140,7 @@ export const contentTypesAgentToolCatalog = [
140
140
  key: {
141
141
  type: "string",
142
142
  pattern: IDENTIFIER_GRAMMAR_PATTERN,
143
- description: "Permanent identifier for the content type. Cannot be changed later. 'post' and 'page' are permanently reserved and will be rejected.",
143
+ description: "Permanent identifier for the content type. Cannot be changed later. 'post' and 'page' are permanently reserved and will be rejected. Host-reserved route keys (such as 'admin' or 'api') are also rejected when configured.",
144
144
  },
145
145
  label: { type: "string", description: "Human-readable display name shown in the admin UI." },
146
146
  fields: FIELDS_SCHEMA,
@@ -52,6 +52,8 @@ export interface ContentTypesToolDeps {
52
52
  outbox: OutboxPort;
53
53
  contentTypeRepo: ContentTypeRepoPort & ContentTypeListPort;
54
54
  contentTypeIndexProvisioner: IndexProvisionerPort & TeardownIndexProvisionerPort;
55
+ /** The same host route policy applied to direct content-type registration. */
56
+ reservedContentTypeKeys?: ReadonlySet<string>;
55
57
  }
56
58
  /**
57
59
  * This wiring layer's OWN risk classification, authored from what each handler below actually
@@ -87,6 +87,7 @@ function contentTypesDeps(routeDeps) {
87
87
  authorize: adaptLegacyAuthorize({ authorize: routeDeps.authorize }),
88
88
  outbox: routeDeps.outbox,
89
89
  indexProvisioner: routeDeps.contentTypeIndexProvisioner,
90
+ ...(routeDeps.reservedContentTypeKeys !== undefined ? { reservedKeys: routeDeps.reservedContentTypeKeys } : {}),
90
91
  };
91
92
  }
92
93
  export function buildContentTypesRegistrations(routeDeps) {
@@ -109,6 +109,8 @@ export interface ContentTypeWriteServiceDeps {
109
109
  indexProvisioner: IndexProvisionerPort;
110
110
  outbox: OutboxPort;
111
111
  watermark?: WatermarkPort;
112
+ /** Host-owned public route segments which a collection key cannot claim. */
113
+ reservedKeys?: ReadonlySet<string>;
112
114
  }
113
115
  export interface RegisterContentTypeRequired {
114
116
  deps: ContentTypeWriteServiceDeps;
@@ -66,6 +66,9 @@ export async function registerContentType(required) {
66
66
  if (RESERVED_CONTENT_TYPE_KEYS.has(input.key)) {
67
67
  return { ok: false, error: new ReservedContentTypeKeyError({ message: `key '${input.key}' is permanently reserved for the legacy 'posts' table` }) };
68
68
  }
69
+ if (deps.reservedKeys?.has(input.key.toLowerCase())) {
70
+ return { ok: false, error: new ReservedContentTypeKeyError({ message: `key '${input.key}' is reserved for a host route` }) };
71
+ }
69
72
  // Guard 3: field-name grammar, then uniqueness (every field, before any kind/cap check).
70
73
  const nameErrorOnRegister = fieldNameError(input.fields);
71
74
  if (nameErrorOnRegister)
@@ -0,0 +1,9 @@
1
+ /** Definition constraints beyond the original four-type schema; every invalid definition fails closed. */
2
+ import type { FieldDescriptor } from "./types.js";
3
+ import type { FieldError } from "./forms.js";
4
+ /** Collect definition errors without coercing an untrusted descriptor into a valid one.
5
+ * @complexity O(k + o) for descriptor keys and at most 100 options. */
6
+ export declare function commonDescriptorErrors({ field }: {
7
+ field: FieldDescriptor;
8
+ }, _optional?: {}): FieldError[];
9
+ //# sourceMappingURL=descriptor-rules.d.ts.map