@vxil/feature-configs 0.7.1 → 0.9.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,11 +3,15 @@ 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
- * binding kinds that may carry `retry` — the platform-delivered event lanes
9
- * (an http invoke returns its own status; a cron tick's retry would overlap
10
- * the next tick). Read by the schema, the deploy clamp and the CLI. */
9
+ * binding kinds that may carry `retry` — every lane the platform delivers
10
+ * through jobs. Since 2026-10-03 (JT-9) that includes `cron` (a failed tick is
11
+ * re-delivered on the ladder; pair it with `overlap: 'skip'` so a retrying
12
+ * tick never runs beside the next one) and `http` (the ASYNC lane only — a
13
+ * synchronous invoke always hands its caller the function's own status).
14
+ * Read by the schema, the deploy clamp and the CLI. */
11
15
  export declare const FN_RETRY_MAX_ATTEMPTS = 5;
12
16
  export declare const FN_RETRY_BINDING_KINDS: ReadonlySet<string>;
13
17
  /** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
@@ -378,6 +382,17 @@ export declare const FilesConfigSchema: import("@sinclair/typebox").TObject<{
378
382
  asyncOverJobs: import("@sinclair/typebox").TBoolean;
379
383
  boundingBoxes: import("@sinclair/typebox").TBoolean;
380
384
  }>>;
385
+ publicAssets: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
386
+ enabled: import("@sinclair/typebox").TBoolean;
387
+ corsOrigins: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>;
388
+ variants: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
389
+ w: import("@sinclair/typebox").TInteger;
390
+ h: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
391
+ 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">]>>;
392
+ 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">]>;
393
+ q: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
394
+ }>>>;
395
+ }>>;
381
396
  }>;
382
397
  export type FilesConfig = Static<typeof FilesConfigSchema>;
383
398
  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')) {
@@ -37,11 +42,14 @@ if (!FormatRegistry.Has('email')) {
37
42
  // config-write refusal share ONE list. payments-v1/core.ts re-exports these.
38
43
  export const RESERVED_CREDIT_TYPES = new Set(['fn_cpu_ms']);
39
44
  /** A function binding's `retry.maxAttempts` ceiling, and the
40
- * binding kinds that may carry `retry` — the platform-delivered event lanes
41
- * (an http invoke returns its own status; a cron tick's retry would overlap
42
- * the next tick). Read by the schema, the deploy clamp and the CLI. */
45
+ * binding kinds that may carry `retry` — every lane the platform delivers
46
+ * through jobs. Since 2026-10-03 (JT-9) that includes `cron` (a failed tick is
47
+ * re-delivered on the ladder; pair it with `overlap: 'skip'` so a retrying
48
+ * tick never runs beside the next one) and `http` (the ASYNC lane only — a
49
+ * synchronous invoke always hands its caller the function's own status).
50
+ * Read by the schema, the deploy clamp and the CLI. */
43
51
  export const FN_RETRY_MAX_ATTEMPTS = 5;
44
- export const FN_RETRY_BINDING_KINDS = new Set(['queue', 'webhook', 'cmsHook', 'authHook']);
52
+ export const FN_RETRY_BINDING_KINDS = new Set(['queue', 'webhook', 'cmsHook', 'authHook', 'cron', 'http']);
45
53
  /** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
46
54
  * of which is restricted to internal platform machinery). */
47
55
  export function isReservedCreditType(creditType) {
@@ -336,7 +344,24 @@ export const JobsConfigSchema = Type.Object({
336
344
  * pathological target (e.g. an always-throwing function) can generate. */
337
345
  dlqDailyQuota: Type.Integer({ default: 0, minimum: 0, maximum: 100_000 }),
338
346
  schedules: Type.Object({ maxPerTenant: Type.Integer({ default: 50, minimum: 1, maximum: 1000 }) }, { default: {} }),
339
- concurrency: Type.Object({ maxConcurrent: Type.Integer({ default: 10, minimum: 1, maximum: 100 }) }, { default: {} }),
347
+ concurrency: Type.Object(
348
+ /** Per-tenant cap on PLAIN runs (queue / cron / event deliveries,
349
+ * incl. platform-delivered function triggers) in flight at once; generation
350
+ * runs have their own `generation.maxConcurrent`. EFFECTIVE value =
351
+ * min(this, the tenant's fair share of the shared delivery consumer): the
352
+ * platform runs at most 75 queue-lane deliveries in flight for ALL tenants
353
+ * together, and one tenant may hold at most 25 of them (jobs-v1 core.ts
354
+ * TENANT_FAIR_SHARE). Values 26..100 still validate (existing configs keep
355
+ * working) but buy nothing past the share. Over-cap runs wait (deferred,
356
+ * never rejected). The schema `description` below is the tenant-facing
357
+ * copy of this (planner catalog, generated config schema) — keep both in
358
+ * step. */
359
+ {
360
+ maxConcurrent: Type.Integer({
361
+ default: 10, minimum: 1, maximum: 100,
362
+ description: 'Plain runs (queued jobs, cron fires, event deliveries, platform-delivered function triggers) delivered at once; generation runs have their own generation.maxConcurrent. Effective at most 25 (the per-project share of the shared delivery capacity): 26..100 validate but buy nothing more. Over-cap runs wait, never rejected.',
363
+ }),
364
+ }, { default: {} }),
340
365
  // Generation lifecycle knobs (guide ch. 6, jobs). The defaults and bounds are
341
366
  // declared ONCE, as GENERATION_DEFAULTS / GENERATION_BOUNDS below this schema
342
367
  // (2026-09-25): jobs-v1's resolveGenerationConfig imports them and clamps a
@@ -712,6 +737,11 @@ export const FilesConfigSchema = Type.Object({
712
737
  asyncOverJobs: Type.Boolean({ default: true }), // large/multi-page → jobs
713
738
  boundingBoxes: Type.Boolean({ default: false }),
714
739
  })),
740
+ // Public asset delivery (2026-10-03, guide ch. 6 files): publish
741
+ // an object to the cache-forever public host (POST /v1/files/{id}/publish).
742
+ // ONE optional bag: { enabled, corsOrigins, variants }. The published-bytes
743
+ // ceiling is a PLAN line (control-plane plans.ts), never a tenant knob.
744
+ publicAssets: Type.Optional(PublicAssetsConfigSchema),
715
745
  });
716
746
  // webhooks-out (wishlist feature): outbound event fan-out over the jobs delivery
717
747
  // engine. Subscriptions live in their own store (one writer per datum); config is just the capability gate + a cap.
@@ -1403,8 +1433,8 @@ export const FunctionsConfigSchema = Type.Object({
1403
1433
  collection: Type.Optional(Type.String()), // cmsHook: the CMS collection slug
1404
1434
  event: Type.Optional(Type.String()), // cmsHook: beforeCreate|beforeUpdate|beforeWrite · authHook: 'user.created'
1405
1435
  // (2026-09-25) the per-binding opt-in to re-delivery on
1406
- // queue / webhook / cmsHook / authHook (the cross-field rule
1407
- // rejects it on http / cron). Absent = the ACK-200 default. The
1436
+ // queue / webhook / cmsHook / authHook, and since 2026-10-03 on
1437
+ // cron and http (the async lane only). Absent = the ACK-200 default. The
1408
1438
  // receiver answers a failed attempt as an enveloped 503 (ladder)
1409
1439
  // and the last one as a terminal 409 (dead + job.dead_lettered);
1410
1440
  // effective attempts = min(maxAttempts, jobs.retry.defaultMaxAttempts).
@@ -1810,6 +1840,13 @@ export function validateDeclaredAiTemplates(templates) {
1810
1840
  });
1811
1841
  return errs;
1812
1842
  }
1843
+ function badVariantPresetNames(raw) {
1844
+ const variants = raw?.publicAssets?.variants;
1845
+ if (!variants || typeof variants !== 'object' || Array.isArray(variants))
1846
+ return [];
1847
+ const re = new RegExp(VARIANT_PRESET_NAME_PATTERN);
1848
+ return Object.keys(variants).filter((k) => !re.test(k));
1849
+ }
1813
1850
  export function validateFeatureConfig(feature, raw) {
1814
1851
  const schema = FEATURE_SCHEMAS[feature];
1815
1852
  if (!schema) {
@@ -1818,6 +1855,18 @@ export function validateFeatureConfig(feature, raw) {
1818
1855
  if (countLeaves(schema) > CONFIG_FLAG_CAP) {
1819
1856
  return { ok: false, errors: [`schema for '${feature}' exceeds the ${CONFIG_FLAG_CAP}-flag cap`] };
1820
1857
  }
1858
+ // files.publicAssets.variants is a pattern-keyed record: Value.Clean below
1859
+ // would silently DROP a preset whose name is not URL-safe, and the tenant
1860
+ // would wonder why `/v/My Thumb` 404s. Refuse it by name instead.
1861
+ if (feature === 'files') {
1862
+ const bad = badVariantPresetNames(raw);
1863
+ if (bad.length > 0) {
1864
+ return {
1865
+ ok: false,
1866
+ 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)`),
1867
+ };
1868
+ }
1869
+ }
1821
1870
  // Apply defaults to a clone, then STRIP any property the schema does not
1822
1871
  // declare, then check. Value.Clean makes the validator TOTAL:
1823
1872
  // the schemas are open Type.Object()s, so without it Value.Check passes on —
@@ -2098,9 +2147,8 @@ export function validateFeatureConfig(feature, raw) {
2098
2147
  if (b.overlap !== undefined && b.kind !== 'cron') {
2099
2148
  errs.push(`/functions/${name}/bindings/${i}: 'overlap' applies to cron bindings only (not '${b.kind}')`);
2100
2149
  }
2101
- // retry is an opt-in for the platform-delivered event lanes only —
2102
- // an http invoke returns its real status to its caller, and a cron
2103
- // tick's retry would overlap the next tick.
2150
+ // retry is an opt-in for the platform-delivered lanes
2151
+ // (FN_RETRY_BINDING_KINDS — every binding kind since JT-9)
2104
2152
  if (b.retry !== undefined && !FN_RETRY_BINDING_KINDS.has(b.kind)) {
2105
2153
  errs.push(`/functions/${name}/bindings/${i}: 'retry' applies to ${[...FN_RETRY_BINDING_KINDS].join(' | ')} bindings only (not '${b.kind}')`);
2106
2154
  }
@@ -2194,15 +2242,15 @@ export const FEATURE_KEYS = [
2194
2242
  ];
2195
2243
  export const FEATURE_HINTS = {
2196
2244
  notifications: {
2197
- whenToUse: 'Sending email (transactional or broadcast) or in-app inbox messages to end-users: welcomes, receipts, reminders, alerts, digests.',
2245
+ whenToUse: 'Sending email (transactional or broadcast) or in-app inbox messages to end-users: welcomes, receipts, reminders, alerts, digests. A send can be scheduled for a known time (send_at), so a one-off reminder needs no job or function.',
2198
2246
  signals: ['email', 'notify', 'alert', 'reminder', 'welcome', 'receipt', 'digest', 'inbox'],
2199
2247
  notFor: 'Live in-page updates (realtime) or activity timelines (activity-feed).',
2200
2248
  dependsOn: [],
2201
2249
  },
2202
2250
  jobs: {
2203
- whenToUse: 'Background work: scheduled/cron tasks, delayed sends, retries, queues, long-running or periodic processing, durable multi-step waits.',
2204
- signals: ['schedule', 'cron', 'daily', 'background', 'queue', 'later', 'recurring', 'batch'],
2205
- notFor: 'Simple request-response logic that completes inline.',
2251
+ whenToUse: 'Background work: scheduled/cron tasks, delayed runs, retries, queues, runs that wait for an event, per-user serialisation (concurrency keys), debounced bursts, start deadlines, fan-in (a batch completion event), per-user schedules in their own time zone. Also the bookkeeping for LONG work that runs on the tenant\'s own runtime (renders, transcodes, model calls): a generation run holds reserved credits, a deadline, a signed completion callback and live progress mirrored onto a cms row.',
2252
+ signals: ['schedule', 'cron', 'daily', 'background', 'queue', 'later', 'recurring', 'batch', 'render', 'transcode', 'progress', 'long-running'],
2253
+ notFor: 'Simple request-response logic that completes inline; it is a queue with controls, not a workflow engine, and it never runs the heavy compute itself.',
2206
2254
  dependsOn: [],
2207
2255
  },
2208
2256
  auth: {
@@ -2218,8 +2266,8 @@ export const FEATURE_HINTS = {
2218
2266
  dependsOn: [],
2219
2267
  },
2220
2268
  files: {
2221
- whenToUse: 'End-users upload or download files/images/documents: avatars, attachments, photos, PDFs, media libraries.',
2222
- signals: ['upload', 'image', 'photo', 'file', 'attachment', 'document', 'avatar', 'media'],
2269
+ whenToUse: 'End-users upload or download files/images/documents: avatars, attachments, photos, PDFs, media libraries. Media shown publicly (galleries, product photos, video, audio, fonts) is published to a public CDN URL, with named image variant presets such as thumbnails (publicAssets).',
2270
+ signals: ['upload', 'image', 'photo', 'file', 'attachment', 'document', 'avatar', 'media', 'gallery', 'thumbnail', 'video', 'CDN'],
2223
2271
  notFor: 'Structured records (cms) or text content authored in-app.',
2224
2272
  dependsOn: [],
2225
2273
  },
@@ -2236,7 +2284,7 @@ export const FEATURE_HINTS = {
2236
2284
  dependsOn: [],
2237
2285
  },
2238
2286
  cms: {
2239
- whenToUse: 'The data spine: typed record collections with fields, relations, owner-scoping, draft/publish, and optional keyless public reads. Most apps are, underneath, cms collections.',
2287
+ whenToUse: 'The data spine: typed record collections with fields, relations, owner-scoping, draft/publish, indexed filters, and optional keyless public reads. Most apps are, underneath, cms collections.',
2240
2288
  signals: ['store', 'list', 'catalog', 'posts', 'products', 'records', 'items', 'content', 'blog', 'tasks', 'listings'],
2241
2289
  notFor: 'End-user identity (auth) or file bytes (files).',
2242
2290
  dependsOn: [],
@@ -2298,7 +2346,7 @@ export const FEATURE_HINTS = {
2298
2346
  functions: {
2299
2347
  whenToUse: 'ONLY for truly-unique server logic no feature or cms rule can express: bespoke sagas, custom integrations over the egress guard, computed endpoints. Prefer features/cms first; functions are the escape hatch.',
2300
2348
  signals: ['custom logic', 'custom backend', 'integration with', 'workflow', 'saga'],
2301
- notFor: 'Anything a shipped feature or a cms lifecycle hook already covers.',
2349
+ notFor: 'Anything a shipped feature or a cms lifecycle hook already covers, and heavy or long compute (renders, ffmpeg, ML) — that runs on the tenant\'s own runtime, tracked by a jobs generation run.',
2302
2350
  dependsOn: [],
2303
2351
  },
2304
2352
  copilot: {
@@ -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.9.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).
@@ -41,11 +46,14 @@ if (!FormatRegistry.Has('email')) {
41
46
  export const RESERVED_CREDIT_TYPES: ReadonlySet<string> = new Set<string>(['fn_cpu_ms']);
42
47
 
43
48
  /** A function binding's `retry.maxAttempts` ceiling, and the
44
- * binding kinds that may carry `retry` — the platform-delivered event lanes
45
- * (an http invoke returns its own status; a cron tick's retry would overlap
46
- * the next tick). Read by the schema, the deploy clamp and the CLI. */
49
+ * binding kinds that may carry `retry` — every lane the platform delivers
50
+ * through jobs. Since 2026-10-03 (JT-9) that includes `cron` (a failed tick is
51
+ * re-delivered on the ladder; pair it with `overlap: 'skip'` so a retrying
52
+ * tick never runs beside the next one) and `http` (the ASYNC lane only — a
53
+ * synchronous invoke always hands its caller the function's own status).
54
+ * Read by the schema, the deploy clamp and the CLI. */
47
55
  export const FN_RETRY_MAX_ATTEMPTS = 5;
48
- export const FN_RETRY_BINDING_KINDS: ReadonlySet<string> = new Set(['queue', 'webhook', 'cmsHook', 'authHook']);
56
+ export const FN_RETRY_BINDING_KINDS: ReadonlySet<string> = new Set(['queue', 'webhook', 'cmsHook', 'authHook', 'cron', 'http']);
49
57
 
50
58
  /** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
51
59
  * of which is restricted to internal platform machinery). */
@@ -394,7 +402,23 @@ export const JobsConfigSchema = Type.Object({
394
402
  { default: {} },
395
403
  ),
396
404
  concurrency: Type.Object(
397
- { maxConcurrent: Type.Integer({ default: 10, minimum: 1, maximum: 100 }) },
405
+ /** Per-tenant cap on PLAIN runs (queue / cron / event deliveries,
406
+ * incl. platform-delivered function triggers) in flight at once; generation
407
+ * runs have their own `generation.maxConcurrent`. EFFECTIVE value =
408
+ * min(this, the tenant's fair share of the shared delivery consumer): the
409
+ * platform runs at most 75 queue-lane deliveries in flight for ALL tenants
410
+ * together, and one tenant may hold at most 25 of them (jobs-v1 core.ts
411
+ * TENANT_FAIR_SHARE). Values 26..100 still validate (existing configs keep
412
+ * working) but buy nothing past the share. Over-cap runs wait (deferred,
413
+ * never rejected). The schema `description` below is the tenant-facing
414
+ * copy of this (planner catalog, generated config schema) — keep both in
415
+ * step. */
416
+ {
417
+ maxConcurrent: Type.Integer({
418
+ default: 10, minimum: 1, maximum: 100,
419
+ description: 'Plain runs (queued jobs, cron fires, event deliveries, platform-delivered function triggers) delivered at once; generation runs have their own generation.maxConcurrent. Effective at most 25 (the per-project share of the shared delivery capacity): 26..100 validate but buy nothing more. Over-cap runs wait, never rejected.',
420
+ }),
421
+ },
398
422
  { default: {} },
399
423
  ),
400
424
  // Generation lifecycle knobs (guide ch. 6, jobs). The defaults and bounds are
@@ -844,8 +868,13 @@ export const FilesConfigSchema = Type.Object({
844
868
  asyncOverJobs: Type.Boolean({ default: true }), // large/multi-page → jobs
845
869
  boundingBoxes: Type.Boolean({ default: false }),
846
870
  })),
871
+ // Public asset delivery (2026-10-03, guide ch. 6 files): publish
872
+ // an object to the cache-forever public host (POST /v1/files/{id}/publish).
873
+ // ONE optional bag: { enabled, corsOrigins, variants }. The published-bytes
874
+ // ceiling is a PLAN line (control-plane plans.ts), never a tenant knob.
875
+ publicAssets: Type.Optional(PublicAssetsConfigSchema),
847
876
  });
848
- // Leaves: 9 + ttl(1, optional bag) + extractText(1, optional bag) = 11. Cap = 15.
877
+ // Leaves: 9 + ttl(1) + extractText(1) + publicAssets(1, optional bags) = 12. Cap = 15.
849
878
  // (was 14 — 2026-09-11 deleted the inert bucketRef leaf and the inert contentScan bag.)
850
879
 
851
880
  export type FilesConfig = Static<typeof FilesConfigSchema>;
@@ -1744,8 +1773,8 @@ export const FunctionsConfigSchema = Type.Object({
1744
1773
  collection: Type.Optional(Type.String()), // cmsHook: the CMS collection slug
1745
1774
  event: Type.Optional(Type.String()), // cmsHook: beforeCreate|beforeUpdate|beforeWrite · authHook: 'user.created'
1746
1775
  // (2026-09-25) the per-binding opt-in to re-delivery on
1747
- // queue / webhook / cmsHook / authHook (the cross-field rule
1748
- // rejects it on http / cron). Absent = the ACK-200 default. The
1776
+ // queue / webhook / cmsHook / authHook, and since 2026-10-03 on
1777
+ // cron and http (the async lane only). Absent = the ACK-200 default. The
1749
1778
  // receiver answers a failed attempt as an enveloped 503 (ladder)
1750
1779
  // and the last one as a terminal 409 (dead + job.dead_lettered);
1751
1780
  // effective attempts = min(maxAttempts, jobs.retry.defaultMaxAttempts).
@@ -2210,6 +2239,13 @@ export function validateDeclaredAiTemplates(templates: readonly DeclaredAiTempla
2210
2239
  return errs;
2211
2240
  }
2212
2241
 
2242
+ function badVariantPresetNames(raw: unknown): string[] {
2243
+ const variants = (raw as { publicAssets?: { variants?: unknown } } | null)?.publicAssets?.variants;
2244
+ if (!variants || typeof variants !== 'object' || Array.isArray(variants)) return [];
2245
+ const re = new RegExp(VARIANT_PRESET_NAME_PATTERN);
2246
+ return Object.keys(variants).filter((k) => !re.test(k));
2247
+ }
2248
+
2213
2249
  export function validateFeatureConfig(feature: string, raw: unknown): ConfigValidation {
2214
2250
  const schema = FEATURE_SCHEMAS[feature];
2215
2251
  if (!schema) {
@@ -2218,6 +2254,18 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2218
2254
  if (countLeaves(schema) > CONFIG_FLAG_CAP) {
2219
2255
  return { ok: false, errors: [`schema for '${feature}' exceeds the ${CONFIG_FLAG_CAP}-flag cap`] };
2220
2256
  }
2257
+ // files.publicAssets.variants is a pattern-keyed record: Value.Clean below
2258
+ // would silently DROP a preset whose name is not URL-safe, and the tenant
2259
+ // would wonder why `/v/My Thumb` 404s. Refuse it by name instead.
2260
+ if (feature === 'files') {
2261
+ const bad = badVariantPresetNames(raw);
2262
+ if (bad.length > 0) {
2263
+ return {
2264
+ ok: false,
2265
+ 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)`),
2266
+ };
2267
+ }
2268
+ }
2221
2269
  // Apply defaults to a clone, then STRIP any property the schema does not
2222
2270
  // declare, then check. Value.Clean makes the validator TOTAL:
2223
2271
  // the schemas are open Type.Object()s, so without it Value.Check passes on —
@@ -2538,9 +2586,8 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2538
2586
  if (b.overlap !== undefined && b.kind !== 'cron') {
2539
2587
  errs.push(`/functions/${name}/bindings/${i}: 'overlap' applies to cron bindings only (not '${b.kind}')`);
2540
2588
  }
2541
- // retry is an opt-in for the platform-delivered event lanes only —
2542
- // an http invoke returns its real status to its caller, and a cron
2543
- // tick's retry would overlap the next tick.
2589
+ // retry is an opt-in for the platform-delivered lanes
2590
+ // (FN_RETRY_BINDING_KINDS — every binding kind since JT-9)
2544
2591
  if (b.retry !== undefined && !FN_RETRY_BINDING_KINDS.has(b.kind)) {
2545
2592
  errs.push(`/functions/${name}/bindings/${i}: 'retry' applies to ${[...FN_RETRY_BINDING_KINDS].join(' | ')} bindings only (not '${b.kind}')`);
2546
2593
  }
@@ -2654,15 +2701,15 @@ export interface PlannerHint {
2654
2701
 
2655
2702
  export const FEATURE_HINTS: Record<FeatureKey, PlannerHint> = {
2656
2703
  notifications: {
2657
- whenToUse: 'Sending email (transactional or broadcast) or in-app inbox messages to end-users: welcomes, receipts, reminders, alerts, digests.',
2704
+ whenToUse: 'Sending email (transactional or broadcast) or in-app inbox messages to end-users: welcomes, receipts, reminders, alerts, digests. A send can be scheduled for a known time (send_at), so a one-off reminder needs no job or function.',
2658
2705
  signals: ['email', 'notify', 'alert', 'reminder', 'welcome', 'receipt', 'digest', 'inbox'],
2659
2706
  notFor: 'Live in-page updates (realtime) or activity timelines (activity-feed).',
2660
2707
  dependsOn: [],
2661
2708
  },
2662
2709
  jobs: {
2663
- whenToUse: 'Background work: scheduled/cron tasks, delayed sends, retries, queues, long-running or periodic processing, durable multi-step waits.',
2664
- signals: ['schedule', 'cron', 'daily', 'background', 'queue', 'later', 'recurring', 'batch'],
2665
- notFor: 'Simple request-response logic that completes inline.',
2710
+ whenToUse: 'Background work: scheduled/cron tasks, delayed runs, retries, queues, runs that wait for an event, per-user serialisation (concurrency keys), debounced bursts, start deadlines, fan-in (a batch completion event), per-user schedules in their own time zone. Also the bookkeeping for LONG work that runs on the tenant\'s own runtime (renders, transcodes, model calls): a generation run holds reserved credits, a deadline, a signed completion callback and live progress mirrored onto a cms row.',
2711
+ signals: ['schedule', 'cron', 'daily', 'background', 'queue', 'later', 'recurring', 'batch', 'render', 'transcode', 'progress', 'long-running'],
2712
+ notFor: 'Simple request-response logic that completes inline; it is a queue with controls, not a workflow engine, and it never runs the heavy compute itself.',
2666
2713
  dependsOn: [],
2667
2714
  },
2668
2715
  auth: {
@@ -2678,8 +2725,8 @@ export const FEATURE_HINTS: Record<FeatureKey, PlannerHint> = {
2678
2725
  dependsOn: [],
2679
2726
  },
2680
2727
  files: {
2681
- whenToUse: 'End-users upload or download files/images/documents: avatars, attachments, photos, PDFs, media libraries.',
2682
- signals: ['upload', 'image', 'photo', 'file', 'attachment', 'document', 'avatar', 'media'],
2728
+ whenToUse: 'End-users upload or download files/images/documents: avatars, attachments, photos, PDFs, media libraries. Media shown publicly (galleries, product photos, video, audio, fonts) is published to a public CDN URL, with named image variant presets such as thumbnails (publicAssets).',
2729
+ signals: ['upload', 'image', 'photo', 'file', 'attachment', 'document', 'avatar', 'media', 'gallery', 'thumbnail', 'video', 'CDN'],
2683
2730
  notFor: 'Structured records (cms) or text content authored in-app.',
2684
2731
  dependsOn: [],
2685
2732
  },
@@ -2696,7 +2743,7 @@ export const FEATURE_HINTS: Record<FeatureKey, PlannerHint> = {
2696
2743
  dependsOn: [],
2697
2744
  },
2698
2745
  cms: {
2699
- whenToUse: 'The data spine: typed record collections with fields, relations, owner-scoping, draft/publish, and optional keyless public reads. Most apps are, underneath, cms collections.',
2746
+ whenToUse: 'The data spine: typed record collections with fields, relations, owner-scoping, draft/publish, indexed filters, and optional keyless public reads. Most apps are, underneath, cms collections.',
2700
2747
  signals: ['store', 'list', 'catalog', 'posts', 'products', 'records', 'items', 'content', 'blog', 'tasks', 'listings'],
2701
2748
  notFor: 'End-user identity (auth) or file bytes (files).',
2702
2749
  dependsOn: [],
@@ -2758,7 +2805,7 @@ export const FEATURE_HINTS: Record<FeatureKey, PlannerHint> = {
2758
2805
  functions: {
2759
2806
  whenToUse: 'ONLY for truly-unique server logic no feature or cms rule can express: bespoke sagas, custom integrations over the egress guard, computed endpoints. Prefer features/cms first; functions are the escape hatch.',
2760
2807
  signals: ['custom logic', 'custom backend', 'integration with', 'workflow', 'saga'],
2761
- notFor: 'Anything a shipped feature or a cms lifecycle hook already covers.',
2808
+ notFor: 'Anything a shipped feature or a cms lifecycle hook already covers, and heavy or long compute (renders, ffmpeg, ML) — that runs on the tenant\'s own runtime, tracked by a jobs generation run.',
2762
2809
  dependsOn: [],
2763
2810
  },
2764
2811
  copilot: {
@@ -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
+ }