@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 +18 -3
- package/dist/index.js +66 -18
- package/dist/publicAssets.d.ts +73 -0
- package/dist/publicAssets.js +130 -0
- package/package.json +1 -1
- package/src/index.ts +66 -19
- package/src/publicAssets.ts +151 -0
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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(
|
|
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
|
|
1407
|
-
//
|
|
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
|
|
2102
|
-
//
|
|
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
|
|
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.
|
|
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
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
1748
|
-
//
|
|
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
|
|
2542
|
-
//
|
|
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
|
|
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
|
+
}
|