@vxil/feature-configs 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/apiState.d.ts +2 -2
- package/dist/apiState.js +2 -2
- package/dist/hooks.d.ts +2 -2
- package/dist/hooks.js +1 -1
- package/dist/index.d.ts +28 -16
- package/dist/index.js +196 -176
- package/dist/publicAssets.d.ts +73 -0
- package/dist/publicAssets.js +130 -0
- package/dist/readmodels.d.ts +1 -1
- package/dist/readmodels.js +1 -1
- package/package.json +1 -1
- package/src/apiState.ts +5 -7
- package/src/hooks.ts +2 -2
- package/src/index.ts +208 -188
- package/src/publicAssets.ts +151 -0
- package/src/readmodels.ts +1 -1
|
@@ -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
|
+
}
|
package/src/readmodels.ts
CHANGED
|
@@ -101,7 +101,7 @@ function filterErrors(where: string, raw: unknown): string[] {
|
|
|
101
101
|
/**
|
|
102
102
|
* Structural validation of a declared read-model `spec` — shared by the
|
|
103
103
|
* config-write gate here and re-exported for anyone needing the pure check.
|
|
104
|
-
* `kind` selects the
|
|
104
|
+
* `kind` selects the aggregate or the rank grammar (guide ch. 4).
|
|
105
105
|
*/
|
|
106
106
|
export function validateReadModelSpecShape(
|
|
107
107
|
where: string,
|