@vxil/feature-configs 0.7.1 → 0.8.0

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.
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export * from './hooks.js';
3
3
  export * from './readmodels.js';
4
4
  export * from './apiState.js';
5
5
  export * from './canonicalJson.js';
6
+ export * from './publicAssets.js';
6
7
  export declare const RESERVED_CREDIT_TYPES: ReadonlySet<string>;
7
8
  /** A function binding's `retry.maxAttempts` ceiling, and the
8
9
  * binding kinds that may carry `retry` — the platform-delivered event lanes
@@ -378,6 +379,17 @@ export declare const FilesConfigSchema: import("@sinclair/typebox").TObject<{
378
379
  asyncOverJobs: import("@sinclair/typebox").TBoolean;
379
380
  boundingBoxes: import("@sinclair/typebox").TBoolean;
380
381
  }>>;
382
+ publicAssets: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
383
+ enabled: import("@sinclair/typebox").TBoolean;
384
+ corsOrigins: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>;
385
+ variants: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
386
+ w: import("@sinclair/typebox").TInteger;
387
+ h: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
388
+ fit: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"scale-down">, import("@sinclair/typebox").TLiteral<"contain">, import("@sinclair/typebox").TLiteral<"cover">, import("@sinclair/typebox").TLiteral<"crop">, import("@sinclair/typebox").TLiteral<"pad">]>>;
389
+ fmt: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"webp">, import("@sinclair/typebox").TLiteral<"avif">, import("@sinclair/typebox").TLiteral<"jpeg">, import("@sinclair/typebox").TLiteral<"png">]>;
390
+ q: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
391
+ }>>>;
392
+ }>>;
381
393
  }>;
382
394
  export type FilesConfig = Static<typeof FilesConfigSchema>;
383
395
  export declare const DeclaredWebhookSubscriptionSchema: import("@sinclair/typebox").TObject<{
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { Value } from '@sinclair/typebox/value';
8
8
  import { validateHooksConfig } from './hooks.js';
9
9
  import { validateCdcConfig, validateReadModelsConfig } from './readmodels.js';
10
10
  import { isFnTriggerSubscriptionUrl } from './apiState.js';
11
+ import { PublicAssetsConfigSchema, VARIANT_PRESET_NAME_PATTERN } from './publicAssets.js';
11
12
  // Re-export the CMS lifecycle-hook engine so feature workers (cms-v1, runtime
12
13
  // eval) and the control plane (config-time validation) share one definition.
13
14
  export * from './hooks.js';
@@ -21,6 +22,10 @@ export * from './apiState.js';
21
22
  // Key-order-insensitive JSON: the one declared-vs-stored manifest compare
22
23
  // (control-plane shallowDiff, CLI diffManifest).
23
24
  export * from './canonicalJson.js';
25
+ // Public asset delivery (files.publicAssets): the publishable content-type
26
+ // allowlist, the URL/key shape and the variant presets — shared by files-v1
27
+ // (publish) and files-cdn (serve), which never import each other.
28
+ export * from './publicAssets.js';
24
29
  // TypeBox validates `format:` only for registered formats — register the ones
25
30
  // our schemas use (pragmatic RFC-lite email check; providers do the real one).
26
31
  if (!FormatRegistry.Has('email')) {
@@ -712,6 +717,11 @@ export const FilesConfigSchema = Type.Object({
712
717
  asyncOverJobs: Type.Boolean({ default: true }), // large/multi-page → jobs
713
718
  boundingBoxes: Type.Boolean({ default: false }),
714
719
  })),
720
+ // Public asset delivery (2026-10-03, guide ch. 6 files): publish
721
+ // an object to the cache-forever public host (POST /v1/files/{id}/publish).
722
+ // ONE optional bag: { enabled, corsOrigins, variants }. The published-bytes
723
+ // ceiling is a PLAN line (control-plane plans.ts), never a tenant knob.
724
+ publicAssets: Type.Optional(PublicAssetsConfigSchema),
715
725
  });
716
726
  // webhooks-out (wishlist feature): outbound event fan-out over the jobs delivery
717
727
  // engine. Subscriptions live in their own store (one writer per datum); config is just the capability gate + a cap.
@@ -1810,6 +1820,13 @@ export function validateDeclaredAiTemplates(templates) {
1810
1820
  });
1811
1821
  return errs;
1812
1822
  }
1823
+ function badVariantPresetNames(raw) {
1824
+ const variants = raw?.publicAssets?.variants;
1825
+ if (!variants || typeof variants !== 'object' || Array.isArray(variants))
1826
+ return [];
1827
+ const re = new RegExp(VARIANT_PRESET_NAME_PATTERN);
1828
+ return Object.keys(variants).filter((k) => !re.test(k));
1829
+ }
1813
1830
  export function validateFeatureConfig(feature, raw) {
1814
1831
  const schema = FEATURE_SCHEMAS[feature];
1815
1832
  if (!schema) {
@@ -1818,6 +1835,18 @@ export function validateFeatureConfig(feature, raw) {
1818
1835
  if (countLeaves(schema) > CONFIG_FLAG_CAP) {
1819
1836
  return { ok: false, errors: [`schema for '${feature}' exceeds the ${CONFIG_FLAG_CAP}-flag cap`] };
1820
1837
  }
1838
+ // files.publicAssets.variants is a pattern-keyed record: Value.Clean below
1839
+ // would silently DROP a preset whose name is not URL-safe, and the tenant
1840
+ // would wonder why `/v/My Thumb` 404s. Refuse it by name instead.
1841
+ if (feature === 'files') {
1842
+ const bad = badVariantPresetNames(raw);
1843
+ if (bad.length > 0) {
1844
+ return {
1845
+ ok: false,
1846
+ errors: bad.map((n) => `/publicAssets/variants/${n}: a preset name is lower-case letters, numbers, '-' or '_' (at most 32 characters, starting with a letter or number)`),
1847
+ };
1848
+ }
1849
+ }
1821
1850
  // Apply defaults to a clone, then STRIP any property the schema does not
1822
1851
  // declare, then check. Value.Clean makes the validator TOTAL:
1823
1852
  // the schemas are open Type.Object()s, so without it Value.Check passes on —
@@ -0,0 +1,73 @@
1
+ import { type Static } from '@sinclair/typebox';
2
+ /** extension → the exact Content-Type served for it. */
3
+ export declare const PUBLIC_ASSET_TYPES: Readonly<Record<string, string>>;
4
+ /** The extension a stored content type publishes under, or null when the type
5
+ * may not be published (html, svg, pdf, zip, octet-stream, …). */
6
+ export declare function publicExtForContentType(contentType: string | null | undefined): string | null;
7
+ /** Raster image extensions a variant preset may transform. */
8
+ export declare const VARIANT_SOURCE_EXTS: ReadonlySet<string>;
9
+ /** Video/audio types served `inline` (playable) by shared links and the public
10
+ * host — media formats a browser plays in a player, never as a document. */
11
+ export declare const INLINE_MEDIA_TYPES: ReadonlySet<string>;
12
+ /** The public-asset URL path shape: `/<tenant_id>/<sha256>.<ext>` with an
13
+ * optional `/v/<preset>` variant suffix. The tenant is bound from THIS path
14
+ * (never a Host header). */
15
+ export declare const PUBLIC_ASSET_PATH_RE: RegExp;
16
+ /** The object key in the public bucket: `<tenant_id>/<sha256>.<ext>` —
17
+ * tenant-prefixed (no cross-tenant dedupe, so no hash-existence oracle across
18
+ * tenants) and content-addressed (immutable: a URL never changes meaning). */
19
+ export declare function publicAssetKey(tenantId: string, sha256: string, ext: string): string;
20
+ /** Most variant presets one tenant may declare (the schema bound; the plan
21
+ * ceiling in control-plane plans.ts may be lower). */
22
+ export declare const MAX_VARIANT_PRESETS = 8;
23
+ /** Most CORS origins one tenant may declare. */
24
+ export declare const MAX_CORS_ORIGINS = 20;
25
+ /** Most object ids one bulk publish accepts (the batch download-urls bound). */
26
+ export declare const MAX_BULK_PUBLISH = 100;
27
+ /** Largest object that may be published (bytes): 512 MiB — the largest
28
+ * object the public host's edge cache holds, so every published object
29
+ * (and every Range of it) is served from the cache after its first read.
30
+ * Bigger media belongs on a streaming/transcoding vendor, not a
31
+ * cache-forever host. */
32
+ export declare const MAX_PUBLISH_OBJECT_BYTES: number;
33
+ /** Most bytes ONE bulk publish call may stream (hashing an object without a
34
+ * recorded checksum reads it once, copying reads it again). Ids past the
35
+ * budget land in `errors[]` as `batch_budget_exceeded` — publish them in a
36
+ * further call. */
37
+ export declare const MAX_BULK_PUBLISH_BYTES: number;
38
+ /** Largest source image a variant preset transforms (bytes). */
39
+ export declare const MAX_VARIANT_SOURCE_BYTES: number;
40
+ /** A CORS origin: `*`, an https origin, or a localhost http origin (dev). No
41
+ * path, no trailing slash, no wildcard sub-domains. */
42
+ export declare const CORS_ORIGIN_PATTERN = "^(\\*|https://[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*(:[0-9]{1,5})?|http://(localhost|127\\.0\\.0\\.1)(:[0-9]{1,5})?)$";
43
+ /** A preset name: lower-case, URL-safe, ≤ 32 chars (it is a path segment). */
44
+ export declare const VARIANT_PRESET_NAME_PATTERN = "^[a-z0-9][a-z0-9_-]{0,31}$";
45
+ export declare const VariantPresetSchema: import("@sinclair/typebox").TObject<{
46
+ w: import("@sinclair/typebox").TInteger;
47
+ h: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
48
+ fit: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"scale-down">, import("@sinclair/typebox").TLiteral<"contain">, import("@sinclair/typebox").TLiteral<"cover">, import("@sinclair/typebox").TLiteral<"crop">, import("@sinclair/typebox").TLiteral<"pad">]>>;
49
+ fmt: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"webp">, import("@sinclair/typebox").TLiteral<"avif">, import("@sinclair/typebox").TLiteral<"jpeg">, import("@sinclair/typebox").TLiteral<"png">]>;
50
+ q: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
51
+ }>;
52
+ export type VariantPreset = Static<typeof VariantPresetSchema>;
53
+ /** `files.publicAssets` — ONE optional bag (one leaf against the 15-leaf cap).
54
+ * `enabled` is the tenant's own kill switch: false ⇒ publish refuses and the
55
+ * public host answers 404 for every one of the tenant's assets. */
56
+ export declare const PublicAssetsConfigSchema: import("@sinclair/typebox").TObject<{
57
+ enabled: import("@sinclair/typebox").TBoolean;
58
+ corsOrigins: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>;
59
+ variants: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
60
+ w: import("@sinclair/typebox").TInteger;
61
+ h: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
62
+ fit: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"scale-down">, import("@sinclair/typebox").TLiteral<"contain">, import("@sinclair/typebox").TLiteral<"cover">, import("@sinclair/typebox").TLiteral<"crop">, import("@sinclair/typebox").TLiteral<"pad">]>>;
63
+ fmt: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"webp">, import("@sinclair/typebox").TLiteral<"avif">, import("@sinclair/typebox").TLiteral<"jpeg">, import("@sinclair/typebox").TLiteral<"png">]>;
64
+ q: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
65
+ }>>>;
66
+ }>;
67
+ export type PublicAssetsConfig = Static<typeof PublicAssetsConfigSchema>;
68
+ /** The MIME type a variant `fmt` outputs. */
69
+ export declare function variantOutputType(fmt: VariantPreset['fmt']): 'image/webp' | 'image/avif' | 'image/jpeg' | 'image/png';
70
+ /** A stable short fingerprint of a preset definition — part of the variant
71
+ * cache key, so editing a preset never serves the old rendition. FNV-1a over
72
+ * the canonical field order (no crypto needed: it only has to change). */
73
+ export declare function presetFingerprint(p: VariantPreset): string;
@@ -0,0 +1,130 @@
1
+ // files PUBLIC ASSET DELIVERY (2026-10-03) — the one definition of
2
+ // what may be published to the public asset host, shared by the publish path
3
+ // (files-v1), the serving Worker (files-cdn), the config schema below and the
4
+ // docs gates. A feature worker never imports another worker's code, so the
5
+ // constants that BOTH sides must agree on live in this shared package.
6
+ //
7
+ // THE CONTENT-TYPE ALLOWLIST is the security boundary of a public, cookie-less,
8
+ // cache-forever host: only types a browser never executes as a document.
9
+ // `text/html` is absent by construction, and `image/svg+xml` is REFUSED (an SVG
10
+ // is a document that can run script; serving it under a CSP sandbox was the
11
+ // alternative, refusing is the smaller surface). The serving Worker derives the
12
+ // served Content-Type from the URL's extension through THIS map — never from
13
+ // stored metadata — and adds `X-Content-Type-Options: nosniff`, so bytes that
14
+ // lie about their type are still never rendered as HTML.
15
+ import { Type } from '@sinclair/typebox';
16
+ /** extension → the exact Content-Type served for it. */
17
+ export const PUBLIC_ASSET_TYPES = {
18
+ png: 'image/png',
19
+ jpg: 'image/jpeg',
20
+ webp: 'image/webp',
21
+ avif: 'image/avif',
22
+ gif: 'image/gif',
23
+ mp4: 'video/mp4',
24
+ webm: 'video/webm',
25
+ mp3: 'audio/mpeg',
26
+ m4a: 'audio/mp4',
27
+ ogg: 'audio/ogg',
28
+ woff2: 'font/woff2',
29
+ woff: 'font/woff',
30
+ json: 'application/json',
31
+ };
32
+ /** Stored content types (lower-cased, parameters stripped) accepted at publish
33
+ * → the extension they are published under. The canonical type of each
34
+ * extension plus a few common aliases uploaders send. */
35
+ const TYPE_TO_EXT = {
36
+ ...Object.fromEntries(Object.entries(PUBLIC_ASSET_TYPES).map(([ext, ct]) => [ct, ext])),
37
+ 'image/jpg': 'jpg',
38
+ 'image/pjpeg': 'jpg',
39
+ 'audio/mp3': 'mp3',
40
+ 'audio/x-m4a': 'm4a',
41
+ 'audio/m4a': 'm4a',
42
+ 'application/font-woff': 'woff',
43
+ 'application/font-woff2': 'woff2',
44
+ };
45
+ /** The extension a stored content type publishes under, or null when the type
46
+ * may not be published (html, svg, pdf, zip, octet-stream, …). */
47
+ export function publicExtForContentType(contentType) {
48
+ if (!contentType)
49
+ return null;
50
+ const base = contentType.split(';')[0].trim().toLowerCase();
51
+ return TYPE_TO_EXT[base] ?? null;
52
+ }
53
+ /** Raster image extensions a variant preset may transform. */
54
+ export const VARIANT_SOURCE_EXTS = new Set(['png', 'jpg', 'webp', 'avif', 'gif']);
55
+ /** Video/audio types served `inline` (playable) by shared links and the public
56
+ * host — media formats a browser plays in a player, never as a document. */
57
+ export const INLINE_MEDIA_TYPES = new Set([
58
+ 'video/mp4', 'video/webm', 'audio/mpeg', 'audio/mp4', 'audio/ogg',
59
+ ]);
60
+ /** The public-asset URL path shape: `/<tenant_id>/<sha256>.<ext>` with an
61
+ * optional `/v/<preset>` variant suffix. The tenant is bound from THIS path
62
+ * (never a Host header). */
63
+ export const PUBLIC_ASSET_PATH_RE = /^\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/([0-9a-f]{64})\.([a-z0-9]{2,5})(?:\/v\/([a-z0-9][a-z0-9_-]{0,31}))?$/;
64
+ /** The object key in the public bucket: `<tenant_id>/<sha256>.<ext>` —
65
+ * tenant-prefixed (no cross-tenant dedupe, so no hash-existence oracle across
66
+ * tenants) and content-addressed (immutable: a URL never changes meaning). */
67
+ export function publicAssetKey(tenantId, sha256, ext) {
68
+ return `${tenantId}/${sha256}.${ext}`;
69
+ }
70
+ /** Most variant presets one tenant may declare (the schema bound; the plan
71
+ * ceiling in control-plane plans.ts may be lower). */
72
+ export const MAX_VARIANT_PRESETS = 8;
73
+ /** Most CORS origins one tenant may declare. */
74
+ export const MAX_CORS_ORIGINS = 20;
75
+ /** Most object ids one bulk publish accepts (the batch download-urls bound). */
76
+ export const MAX_BULK_PUBLISH = 100;
77
+ /** Largest object that may be published (bytes): 512 MiB — the largest
78
+ * object the public host's edge cache holds, so every published object
79
+ * (and every Range of it) is served from the cache after its first read.
80
+ * Bigger media belongs on a streaming/transcoding vendor, not a
81
+ * cache-forever host. */
82
+ export const MAX_PUBLISH_OBJECT_BYTES = 512 * 1024 * 1024;
83
+ /** Most bytes ONE bulk publish call may stream (hashing an object without a
84
+ * recorded checksum reads it once, copying reads it again). Ids past the
85
+ * budget land in `errors[]` as `batch_budget_exceeded` — publish them in a
86
+ * further call. */
87
+ export const MAX_BULK_PUBLISH_BYTES = 2 * 1024 * 1024 * 1024;
88
+ /** Largest source image a variant preset transforms (bytes). */
89
+ export const MAX_VARIANT_SOURCE_BYTES = 25 * 1024 * 1024;
90
+ /** A CORS origin: `*`, an https origin, or a localhost http origin (dev). No
91
+ * path, no trailing slash, no wildcard sub-domains. */
92
+ export const CORS_ORIGIN_PATTERN = '^(\\*|https://[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*(:[0-9]{1,5})?|http://(localhost|127\\.0\\.0\\.1)(:[0-9]{1,5})?)$';
93
+ /** A preset name: lower-case, URL-safe, ≤ 32 chars (it is a path segment). */
94
+ export const VARIANT_PRESET_NAME_PATTERN = '^[a-z0-9][a-z0-9_-]{0,31}$';
95
+ export const VariantPresetSchema = Type.Object({
96
+ w: Type.Integer({ minimum: 1, maximum: 4096 }),
97
+ h: Type.Optional(Type.Integer({ minimum: 1, maximum: 4096 })),
98
+ fit: Type.Optional(Type.Union([
99
+ Type.Literal('scale-down'), Type.Literal('contain'), Type.Literal('cover'),
100
+ Type.Literal('crop'), Type.Literal('pad'),
101
+ ])),
102
+ fmt: Type.Union([Type.Literal('webp'), Type.Literal('avif'), Type.Literal('jpeg'), Type.Literal('png')]),
103
+ q: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
104
+ });
105
+ /** `files.publicAssets` — ONE optional bag (one leaf against the 15-leaf cap).
106
+ * `enabled` is the tenant's own kill switch: false ⇒ publish refuses and the
107
+ * public host answers 404 for every one of the tenant's assets. */
108
+ export const PublicAssetsConfigSchema = Type.Object({
109
+ enabled: Type.Boolean({ default: false }),
110
+ corsOrigins: Type.Array(Type.String({ pattern: CORS_ORIGIN_PATTERN, maxLength: 253 }), {
111
+ default: [], maxItems: MAX_CORS_ORIGINS,
112
+ }),
113
+ variants: Type.Optional(Type.Record(Type.String({ pattern: VARIANT_PRESET_NAME_PATTERN }), VariantPresetSchema, { maxProperties: MAX_VARIANT_PRESETS })),
114
+ });
115
+ /** The MIME type a variant `fmt` outputs. */
116
+ export function variantOutputType(fmt) {
117
+ return `image/${fmt}`;
118
+ }
119
+ /** A stable short fingerprint of a preset definition — part of the variant
120
+ * cache key, so editing a preset never serves the old rendition. FNV-1a over
121
+ * the canonical field order (no crypto needed: it only has to change). */
122
+ export function presetFingerprint(p) {
123
+ const s = `${p.w}|${p.h ?? ''}|${p.fit ?? ''}|${p.fmt}|${p.q ?? ''}`;
124
+ let h = 0x811c9dc5;
125
+ for (let i = 0; i < s.length; i++) {
126
+ h ^= s.charCodeAt(i);
127
+ h = Math.imul(h, 0x01000193) >>> 0;
128
+ }
129
+ return h.toString(16).padStart(8, '0');
130
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/feature-configs",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "The per-feature configuration schemas and validators behind vxil.config.ts (published for @vxil/cli and @vxil/config).",
5
5
  "license": "MIT",
6
6
  "homepage": "https://vxil.com",
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ import { Value } from '@sinclair/typebox/value';
8
8
  import { validateHooksConfig, type HookDef } from './hooks.js';
9
9
  import { validateCdcConfig, validateReadModelsConfig } from './readmodels.js';
10
10
  import { isFnTriggerSubscriptionUrl } from './apiState.js';
11
+ import { PublicAssetsConfigSchema, VARIANT_PRESET_NAME_PATTERN } from './publicAssets.js';
11
12
 
12
13
  // Re-export the CMS lifecycle-hook engine so feature workers (cms-v1, runtime
13
14
  // eval) and the control plane (config-time validation) share one definition.
@@ -22,6 +23,10 @@ export * from './apiState.js';
22
23
  // Key-order-insensitive JSON: the one declared-vs-stored manifest compare
23
24
  // (control-plane shallowDiff, CLI diffManifest).
24
25
  export * from './canonicalJson.js';
26
+ // Public asset delivery (files.publicAssets): the publishable content-type
27
+ // allowlist, the URL/key shape and the variant presets — shared by files-v1
28
+ // (publish) and files-cdn (serve), which never import each other.
29
+ export * from './publicAssets.js';
25
30
 
26
31
  // TypeBox validates `format:` only for registered formats — register the ones
27
32
  // our schemas use (pragmatic RFC-lite email check; providers do the real one).
@@ -844,8 +849,13 @@ export const FilesConfigSchema = Type.Object({
844
849
  asyncOverJobs: Type.Boolean({ default: true }), // large/multi-page → jobs
845
850
  boundingBoxes: Type.Boolean({ default: false }),
846
851
  })),
852
+ // Public asset delivery (2026-10-03, guide ch. 6 files): publish
853
+ // an object to the cache-forever public host (POST /v1/files/{id}/publish).
854
+ // ONE optional bag: { enabled, corsOrigins, variants }. The published-bytes
855
+ // ceiling is a PLAN line (control-plane plans.ts), never a tenant knob.
856
+ publicAssets: Type.Optional(PublicAssetsConfigSchema),
847
857
  });
848
- // Leaves: 9 + ttl(1, optional bag) + extractText(1, optional bag) = 11. Cap = 15.
858
+ // Leaves: 9 + ttl(1) + extractText(1) + publicAssets(1, optional bags) = 12. Cap = 15.
849
859
  // (was 14 — 2026-09-11 deleted the inert bucketRef leaf and the inert contentScan bag.)
850
860
 
851
861
  export type FilesConfig = Static<typeof FilesConfigSchema>;
@@ -2210,6 +2220,13 @@ export function validateDeclaredAiTemplates(templates: readonly DeclaredAiTempla
2210
2220
  return errs;
2211
2221
  }
2212
2222
 
2223
+ function badVariantPresetNames(raw: unknown): string[] {
2224
+ const variants = (raw as { publicAssets?: { variants?: unknown } } | null)?.publicAssets?.variants;
2225
+ if (!variants || typeof variants !== 'object' || Array.isArray(variants)) return [];
2226
+ const re = new RegExp(VARIANT_PRESET_NAME_PATTERN);
2227
+ return Object.keys(variants).filter((k) => !re.test(k));
2228
+ }
2229
+
2213
2230
  export function validateFeatureConfig(feature: string, raw: unknown): ConfigValidation {
2214
2231
  const schema = FEATURE_SCHEMAS[feature];
2215
2232
  if (!schema) {
@@ -2218,6 +2235,18 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2218
2235
  if (countLeaves(schema) > CONFIG_FLAG_CAP) {
2219
2236
  return { ok: false, errors: [`schema for '${feature}' exceeds the ${CONFIG_FLAG_CAP}-flag cap`] };
2220
2237
  }
2238
+ // files.publicAssets.variants is a pattern-keyed record: Value.Clean below
2239
+ // would silently DROP a preset whose name is not URL-safe, and the tenant
2240
+ // would wonder why `/v/My Thumb` 404s. Refuse it by name instead.
2241
+ if (feature === 'files') {
2242
+ const bad = badVariantPresetNames(raw);
2243
+ if (bad.length > 0) {
2244
+ return {
2245
+ ok: false,
2246
+ errors: bad.map((n) => `/publicAssets/variants/${n}: a preset name is lower-case letters, numbers, '-' or '_' (at most 32 characters, starting with a letter or number)`),
2247
+ };
2248
+ }
2249
+ }
2221
2250
  // Apply defaults to a clone, then STRIP any property the schema does not
2222
2251
  // declare, then check. Value.Clean makes the validator TOTAL:
2223
2252
  // the schemas are open Type.Object()s, so without it Value.Check passes on —
@@ -0,0 +1,151 @@
1
+ // files PUBLIC ASSET DELIVERY (2026-10-03) — the one definition of
2
+ // what may be published to the public asset host, shared by the publish path
3
+ // (files-v1), the serving Worker (files-cdn), the config schema below and the
4
+ // docs gates. A feature worker never imports another worker's code, so the
5
+ // constants that BOTH sides must agree on live in this shared package.
6
+ //
7
+ // THE CONTENT-TYPE ALLOWLIST is the security boundary of a public, cookie-less,
8
+ // cache-forever host: only types a browser never executes as a document.
9
+ // `text/html` is absent by construction, and `image/svg+xml` is REFUSED (an SVG
10
+ // is a document that can run script; serving it under a CSP sandbox was the
11
+ // alternative, refusing is the smaller surface). The serving Worker derives the
12
+ // served Content-Type from the URL's extension through THIS map — never from
13
+ // stored metadata — and adds `X-Content-Type-Options: nosniff`, so bytes that
14
+ // lie about their type are still never rendered as HTML.
15
+ import { Type, type Static } from '@sinclair/typebox';
16
+
17
+ /** extension → the exact Content-Type served for it. */
18
+ export const PUBLIC_ASSET_TYPES: Readonly<Record<string, string>> = {
19
+ png: 'image/png',
20
+ jpg: 'image/jpeg',
21
+ webp: 'image/webp',
22
+ avif: 'image/avif',
23
+ gif: 'image/gif',
24
+ mp4: 'video/mp4',
25
+ webm: 'video/webm',
26
+ mp3: 'audio/mpeg',
27
+ m4a: 'audio/mp4',
28
+ ogg: 'audio/ogg',
29
+ woff2: 'font/woff2',
30
+ woff: 'font/woff',
31
+ json: 'application/json',
32
+ };
33
+
34
+ /** Stored content types (lower-cased, parameters stripped) accepted at publish
35
+ * → the extension they are published under. The canonical type of each
36
+ * extension plus a few common aliases uploaders send. */
37
+ const TYPE_TO_EXT: Readonly<Record<string, string>> = {
38
+ ...Object.fromEntries(Object.entries(PUBLIC_ASSET_TYPES).map(([ext, ct]) => [ct, ext])),
39
+ 'image/jpg': 'jpg',
40
+ 'image/pjpeg': 'jpg',
41
+ 'audio/mp3': 'mp3',
42
+ 'audio/x-m4a': 'm4a',
43
+ 'audio/m4a': 'm4a',
44
+ 'application/font-woff': 'woff',
45
+ 'application/font-woff2': 'woff2',
46
+ };
47
+
48
+ /** The extension a stored content type publishes under, or null when the type
49
+ * may not be published (html, svg, pdf, zip, octet-stream, …). */
50
+ export function publicExtForContentType(contentType: string | null | undefined): string | null {
51
+ if (!contentType) return null;
52
+ const base = contentType.split(';')[0]!.trim().toLowerCase();
53
+ return TYPE_TO_EXT[base] ?? null;
54
+ }
55
+
56
+ /** Raster image extensions a variant preset may transform. */
57
+ export const VARIANT_SOURCE_EXTS: ReadonlySet<string> = new Set(['png', 'jpg', 'webp', 'avif', 'gif']);
58
+
59
+ /** Video/audio types served `inline` (playable) by shared links and the public
60
+ * host — media formats a browser plays in a player, never as a document. */
61
+ export const INLINE_MEDIA_TYPES: ReadonlySet<string> = new Set([
62
+ 'video/mp4', 'video/webm', 'audio/mpeg', 'audio/mp4', 'audio/ogg',
63
+ ]);
64
+
65
+ /** The public-asset URL path shape: `/<tenant_id>/<sha256>.<ext>` with an
66
+ * optional `/v/<preset>` variant suffix. The tenant is bound from THIS path
67
+ * (never a Host header). */
68
+ export const PUBLIC_ASSET_PATH_RE =
69
+ /^\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/([0-9a-f]{64})\.([a-z0-9]{2,5})(?:\/v\/([a-z0-9][a-z0-9_-]{0,31}))?$/;
70
+
71
+ /** The object key in the public bucket: `<tenant_id>/<sha256>.<ext>` —
72
+ * tenant-prefixed (no cross-tenant dedupe, so no hash-existence oracle across
73
+ * tenants) and content-addressed (immutable: a URL never changes meaning). */
74
+ export function publicAssetKey(tenantId: string, sha256: string, ext: string): string {
75
+ return `${tenantId}/${sha256}.${ext}`;
76
+ }
77
+
78
+ /** Most variant presets one tenant may declare (the schema bound; the plan
79
+ * ceiling in control-plane plans.ts may be lower). */
80
+ export const MAX_VARIANT_PRESETS = 8;
81
+ /** Most CORS origins one tenant may declare. */
82
+ export const MAX_CORS_ORIGINS = 20;
83
+ /** Most object ids one bulk publish accepts (the batch download-urls bound). */
84
+ export const MAX_BULK_PUBLISH = 100;
85
+ /** Largest object that may be published (bytes): 512 MiB — the largest
86
+ * object the public host's edge cache holds, so every published object
87
+ * (and every Range of it) is served from the cache after its first read.
88
+ * Bigger media belongs on a streaming/transcoding vendor, not a
89
+ * cache-forever host. */
90
+ export const MAX_PUBLISH_OBJECT_BYTES = 512 * 1024 * 1024;
91
+ /** Most bytes ONE bulk publish call may stream (hashing an object without a
92
+ * recorded checksum reads it once, copying reads it again). Ids past the
93
+ * budget land in `errors[]` as `batch_budget_exceeded` — publish them in a
94
+ * further call. */
95
+ export const MAX_BULK_PUBLISH_BYTES = 2 * 1024 * 1024 * 1024;
96
+ /** Largest source image a variant preset transforms (bytes). */
97
+ export const MAX_VARIANT_SOURCE_BYTES = 25 * 1024 * 1024;
98
+
99
+ /** A CORS origin: `*`, an https origin, or a localhost http origin (dev). No
100
+ * path, no trailing slash, no wildcard sub-domains. */
101
+ export const CORS_ORIGIN_PATTERN =
102
+ '^(\\*|https://[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*(:[0-9]{1,5})?|http://(localhost|127\\.0\\.0\\.1)(:[0-9]{1,5})?)$';
103
+
104
+ /** A preset name: lower-case, URL-safe, ≤ 32 chars (it is a path segment). */
105
+ export const VARIANT_PRESET_NAME_PATTERN = '^[a-z0-9][a-z0-9_-]{0,31}$';
106
+
107
+ export const VariantPresetSchema = Type.Object({
108
+ w: Type.Integer({ minimum: 1, maximum: 4096 }),
109
+ h: Type.Optional(Type.Integer({ minimum: 1, maximum: 4096 })),
110
+ fit: Type.Optional(Type.Union([
111
+ Type.Literal('scale-down'), Type.Literal('contain'), Type.Literal('cover'),
112
+ Type.Literal('crop'), Type.Literal('pad'),
113
+ ])),
114
+ fmt: Type.Union([Type.Literal('webp'), Type.Literal('avif'), Type.Literal('jpeg'), Type.Literal('png')]),
115
+ q: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
116
+ });
117
+ export type VariantPreset = Static<typeof VariantPresetSchema>;
118
+
119
+ /** `files.publicAssets` — ONE optional bag (one leaf against the 15-leaf cap).
120
+ * `enabled` is the tenant's own kill switch: false ⇒ publish refuses and the
121
+ * public host answers 404 for every one of the tenant's assets. */
122
+ export const PublicAssetsConfigSchema = Type.Object({
123
+ enabled: Type.Boolean({ default: false }),
124
+ corsOrigins: Type.Array(Type.String({ pattern: CORS_ORIGIN_PATTERN, maxLength: 253 }), {
125
+ default: [], maxItems: MAX_CORS_ORIGINS,
126
+ }),
127
+ variants: Type.Optional(Type.Record(
128
+ Type.String({ pattern: VARIANT_PRESET_NAME_PATTERN }),
129
+ VariantPresetSchema,
130
+ { maxProperties: MAX_VARIANT_PRESETS },
131
+ )),
132
+ });
133
+ export type PublicAssetsConfig = Static<typeof PublicAssetsConfigSchema>;
134
+
135
+ /** The MIME type a variant `fmt` outputs. */
136
+ export function variantOutputType(fmt: VariantPreset['fmt']): 'image/webp' | 'image/avif' | 'image/jpeg' | 'image/png' {
137
+ return `image/${fmt}` as 'image/webp' | 'image/avif' | 'image/jpeg' | 'image/png';
138
+ }
139
+
140
+ /** A stable short fingerprint of a preset definition — part of the variant
141
+ * cache key, so editing a preset never serves the old rendition. FNV-1a over
142
+ * the canonical field order (no crypto needed: it only has to change). */
143
+ export function presetFingerprint(p: VariantPreset): string {
144
+ const s = `${p.w}|${p.h ?? ''}|${p.fit ?? ''}|${p.fmt}|${p.q ?? ''}`;
145
+ let h = 0x811c9dc5;
146
+ for (let i = 0; i < s.length; i++) {
147
+ h ^= s.charCodeAt(i);
148
+ h = Math.imul(h, 0x01000193) >>> 0;
149
+ }
150
+ return h.toString(16).padStart(8, '0');
151
+ }