@assetlib/sdk-core 0.0.0-stage → 0.5.3-preview.1
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/LICENSE +21 -0
- package/README.md +236 -2
- package/bin/codegen.mjs +46 -0
- package/dist/accessibility.d.ts +5 -0
- package/dist/accessibility.js +32 -0
- package/dist/animations.d.ts +5 -0
- package/dist/animations.js +28 -0
- package/dist/catalog.d.ts +7 -0
- package/dist/catalog.js +27 -0
- package/dist/delivery.d.ts +12 -0
- package/dist/delivery.js +148 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.js +711 -0
- package/dist/lottie.d.ts +28 -0
- package/dist/lottie.js +230 -0
- package/dist/renditions.d.ts +19 -0
- package/dist/renditions.js +74 -0
- package/dist/telemetry.d.ts +39 -0
- package/dist/telemetry.js +166 -0
- package/dist/types.d.ts +256 -0
- package/dist/types.js +1 -0
- package/package.json +45 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Assetlib contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,237 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @assetlib/sdk-core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An early TypeScript client for signed Assetlib image delivery. Works with the current schema-v1 HTTP delivery API. The Expo adapter supplies native and browser persistence; this package is also usable with your own storage adapter.
|
|
4
|
+
|
|
5
|
+
The additive JavaScript animation preview (browsers, and Expo on web, iOS and Android) supports a restricted, validated Lottie profile with a mandatory still poster. `resolveAnimation` returns verified JSON for a Lottie player that the app supplies; the SDK plays nothing itself. Image components and the native Swift and Kotlin SDKs continue using that poster.
|
|
6
|
+
|
|
7
|
+
This preview is on npm under the `preview` dist-tag, and each GitHub release attaches the same package as a tarball. Install core and Expo at the same exact version. A catalog generation step reads local JSON; app builds do not contact Assetlib.
|
|
8
|
+
|
|
9
|
+
## Client
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { createAssetClient, parsePublicConfig } from '@assetlib/sdk-core';
|
|
13
|
+
import { AppAssets } from './assets.generated';
|
|
14
|
+
|
|
15
|
+
const config = parsePublicConfig(publicConfig);
|
|
16
|
+
const client = createAssetClient(config, { storage: durableStorage });
|
|
17
|
+
await client.initialize(); // Verify persisted state; no network request.
|
|
18
|
+
const release = await client.refresh(); // Fetch and verify the manifest.
|
|
19
|
+
const asset = await client.resolve(AppAssets.Travel.coast, { pixelWidth: 600, pixelHeight: 450 });
|
|
20
|
+
// source is 'remote', 'cache', or 'bundle'. Render a bundled image for 'bundle'.
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`refresh()` returns `{ updated, sequence, error? }`. Errors preserve the previous accepted release. `resolve()` returns `{ source, sequence, message, arm, armSource, bytes?, sha256?, assetId?, mime?, pixelWidth?, pixelHeight?, rendering? }`. `getStatus()` returns `{ initialized, sequence, lastError }`. Use one long-lived client per configured app.
|
|
24
|
+
|
|
25
|
+
Public config: `{ schemaVersion: 1, orgId, appId, environment: 'staging' | 'production', manifestUrl, pinnedPublicKey?, keyId?, pinnedPublicKeys?, keyIds? }`. Supply `pinnedPublicKey` or a `pinnedPublicKeys` array of 1–16 distinct exact-PEM Ed25519 SPKI public keys. Each PEM is limited to 256 UTF-8 bytes. When both are present, the single key must exactly match a member of the set. Configurations with only `pinnedPublicKey` retain their existing behavior and parsed shape.
|
|
26
|
+
|
|
27
|
+
`parsePublicConfig` accepts a JSON string or an object. JSON is limited to 4096 UTF-8 bytes: strings are measured exactly, including whitespace; objects are measured and parsed using their `JSON.stringify` serialization. Unknown fields are ignored after counting their serialized bytes. Explicit `null` values for recognized fields are rejected, including the optional pin and ID fields.
|
|
28
|
+
|
|
29
|
+
`keyId`, when supplied, must match the single key and requires `pinnedPublicKey`. Each ID is the first 16 lowercase hexadecimal characters of SHA-256 over the exact UTF-8 PEM text. Optional `keyIds` must match the pinned keys in array order (or the single key when no set is supplied). `parsePublicConfig` derives missing IDs, omitting added IDs when needed to keep its result within 4096 bytes for reuse by a client. Supplied IDs are preserved. Manifests and asset pages may be signed by any member of the pinned set; each envelope's `publicKey` must exactly match that member and its `keyId` must match that key's derived ID. A supplied ID never authorizes an unpinned key.
|
|
30
|
+
|
|
31
|
+
Use the matching `/api/delivery/{orgId}/{appId}/environments/{environment}/manifest` URL; the legacy `/api/delivery/{orgId}/{appId}/manifest` URL remains valid for production. Signed manifests and asset pages must match the configured environment. Every pinned key must come from a trusted provisioning step independent of the delivery response. Public config contains no admin credentials. Importing it from an untrusted source changes the trust anchor; verification cannot establish that the source belongs to your organization.
|
|
32
|
+
|
|
33
|
+
To prepare a signing-key rotation, ship a trusted configuration containing both the current and next keys before the server starts signing with the next key. Keep retiring keys pinned while retained releases still need to verify. The SDK does not discover or download replacement trust anchors. `storageNamespace(config)` scopes durable replay state to the manifest origin, organization, app, and environment. Key-set changes and switching between the production manifest routes preserve that identity; staging remains separate. Expo migrates verified state from its previous single-key and sorted-key-set namespaces once when the new namespace is empty. Custom durable adapters should use this identity and `verifyStoredState(serialized, config)` when migrating their prior namespace.
|
|
34
|
+
|
|
35
|
+
## Optional runtime observations
|
|
36
|
+
|
|
37
|
+
Telemetry is **off by default**. Opt in when creating the client, and supply your application's registered build metadata:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
const client = createAssetClient(config, {
|
|
41
|
+
storage: durableStorage,
|
|
42
|
+
telemetry: {
|
|
43
|
+
enabled: true,
|
|
44
|
+
build: { platform: 'web', appVersion: '1.6.0', buildNumber: '231' },
|
|
45
|
+
// flushIntervalMs: 60_000, maxBatch: 200
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
const image = await client.resolve(AppAssets.Travel.coast);
|
|
49
|
+
// Call only after your renderer has decoded and attached this remote/cached image.
|
|
50
|
+
client.reportDisplay(AppAssets.Travel.coast, image);
|
|
51
|
+
// On renderer failure: client.reportFallback(AppAssets.Travel.coast, image, 'decode');
|
|
52
|
+
await client.flush();
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Each batch goes to the configured manifest origin's `/api/delivery/{orgId}/{appId}/observations` route without credentials. It contains only schema version, environment, SDK name/version, build platform/version/number, a random install ID, send time, and coalesced event counts. Event coordinates contain the placement key, event kind, effective source, asset ID and release sequence when available, effective arm/appearance when selected, and a fallback reason (`offline`, `verification`, `decode`, `missing`, or `other`). Image bytes, image URLs, hashes, descriptions, diagnostic messages, user accounts, device identifiers, and experiment callback data are not sent. Default metadata is `sdk-core` with this package's version and `{ platform: 'web', appVersion: 'unknown', buildNumber: 'unknown' }`; supply actual build metadata for useful build comparisons.
|
|
56
|
+
|
|
57
|
+
Successful placement resolutions record `resolve`; a successfully resolved state set records its committed members, never a partially discarded family. A bundled outcome records only `fallback`, without an asset ID or sequence. Dynamic catalog references have no placement key and are omitted from observations, including explicit display/fallback reports. `display` is emitted only when the adapter calls `reportDisplay`: it means decoded and attached, **not seen by a person**, and is not experiment exposure or a unique-user count. Animation playback is not observed.
|
|
58
|
+
|
|
59
|
+
Without an explicit `installId`, the SDK generates a random 32-character hexadecimal ID and stores it under a dedicated per-origin, per-organization, per-app key through `storage.getOrCreateInstallId(key, create)`. Custom durable adapters must implement this optional method atomically: return the existing ID or persist the new `create()` value before returning. The same install shares its ID across staging and production. Clearing app data resets it. `createMemoryStorage()` preserves it only for that adapter instance; use durable storage to retain it across app restarts. An adapter without this method, a persistence failure, or an unavailable secure random source suppresses generated-ID telemetry. The app can instead supply its own persistent, random, opaque `installId` of 8–64 characters. Never use a device, account, advertising, or other personal identifier.
|
|
60
|
+
|
|
61
|
+
Events coalesce in memory, with at most 2,000 pending coordinates. `flush()` sends one batch of at most 200 events (or a smaller `maxBatch`), capped at 64 KiB, with each count capped at 10,000. Excess count remains queued for subsequent batches. Pending events flush every 60 seconds by default and when `refresh()` runs; explicit `flush()` is useful before backgrounding. A request has a five-second deadline; failed batches stay in memory for the next flush and are not immediately retried. Reporting never blocks or fails artwork resolution. A successful send removes only the sent counts, preserving events recorded during the request. Pending events are not persisted and may be lost when the process exits; a lost response can result in duplicate counts on a later retry. `dispose()` stops telemetry timers and discards its pending observations when the client is no longer needed.
|
|
62
|
+
|
|
63
|
+
Nonpositive or noninteger interval/batch settings use the defaults; `maxBatch` is capped at 200. Build versions and SDK names/versions accept 1–64 characters from letters, digits, `.`, `_`, `+`, and `-`. Invalid build, SDK, or install-ID metadata disables reporting without changing artwork resolution.
|
|
64
|
+
|
|
65
|
+
The hosted Assetlib service validates each batch as a whole, accepts at most 64 KiB per request, and stores no raw event log and no raw install ID. Accepted counts are added to daily aggregates keyed by the UTC day of receipt, kept for 90 UTC days. For install coverage it keeps a SHA-256 hash of the install ID and UTC day, per app, environment and day, for 7 UTC days. Expired rows are removed when later batches arrive, not on a fixed schedule. Reporting installs summed over several days are install-days, not unique installations or people. Request infrastructure handles network addresses and headers; they are not stored in the aggregates.
|
|
66
|
+
|
|
67
|
+
Enabling reporting changes your app's data collection. Review Google Play Data safety disclosures, App Store privacy disclosures and any applicable privacy manifest requirements against your app's actual use. A random install identifier is still sent to the service; enabling this option does not automatically update those declarations or establish consent. Hashing an identifier does not by itself make it anonymous or remove disclosure requirements. Suggested wording for an app team's data inventory, to adapt to your actual configuration:
|
|
68
|
+
|
|
69
|
+
> When enabled, Assetlib sends app/build version information, placement and asset identifiers, image resolution/display/fallback counts, and a random app-install identifier to our configured Assetlib service for asset-delivery diagnostics. Telemetry is disabled by default. The service stores daily aggregates and day-specific install hashes, not raw event logs or the raw install identifier. A display report means the renderer decoded and attached an image, not that a person viewed it.
|
|
70
|
+
|
|
71
|
+
## Image descriptions
|
|
72
|
+
|
|
73
|
+
Image descriptors optionally carry signed `accessibility: { defaultLocale, descriptions }` metadata. Descriptions travel with the image descriptor in the release, including state members and dynamic collection images. `resolve()` returns the metadata for the image it actually selects: a cached image from an older release has that older description. Missing remote metadata never inherits the bundled image's description. Existing descriptors without the field remain valid; no extension version is required.
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { resolveAccessibilityDescription } from '@assetlib/sdk-core';
|
|
77
|
+
|
|
78
|
+
const result = await client.resolve(AppAssets.Travel.coast);
|
|
79
|
+
const description = resolveAccessibilityDescription(result.accessibility, 'en-GB');
|
|
80
|
+
// The application decides whether this usage needs a description or is decorative.
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The pure resolver tries a case-insensitive exact locale, progressively removes subtags, then uses `defaultLocale`. There is no filename-derived description or automatic accessibility behavior. Metadata requires 1–32 unique locale tags (case-insensitive), each at most 63 characters and matching `[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*`. The default must name an existing locale. Descriptions must contain non-whitespace text and be at most 1,000 UTF-16 code units. A present null or invalid metadata field rejects the descriptor.
|
|
84
|
+
|
|
85
|
+
App-owned catalog placements may include `bundledAccessibility` with the same shape. It is returned only for bundled fallback artwork. For state sets, `bundledStateAccessibility` maps declared states to their own bundled metadata for the adapter. Offline code generation validates and preserves both fields. Keep these descriptions synchronized with checked-in bundled images. App controls should describe their action; descriptive artwork and decorative artwork remain choices made by each usage.
|
|
86
|
+
|
|
87
|
+
## Image formats and sizes
|
|
88
|
+
|
|
89
|
+
Preview 0.2 accepts the optional signed `renditionSchemaVersion: 1` extension. Existing schema-1 manifests still work; older clients can use the mandatory WebP fallback in new manifests. By default, the client selects PNG or WebP. Ask for physical pixels with both `pixelWidth` and `pixelHeight`; without a target, selection uses the generated placement dimensions. Layout and display density remain the application's responsibility.
|
|
90
|
+
|
|
91
|
+
Selection uses the smallest rendition covering both dimensions, then byte size and hash. If every rendition is smaller, the largest is preferred. Failed candidates advance to the next candidate, then the legacy fallback. Historical releases remain cache-only. The result describes the actual selected MIME, hash and pixel dimensions. It does not convert one format into another on the device.
|
|
92
|
+
|
|
93
|
+
A browser renderer can opt into normalized SVG with `formats: ['image/webp', 'image/png', 'image/svg+xml']`. SVG is then preferred. Generic/native clients should retain the raster defaults. The core validates signed metadata, hash, byte length and basic headers; adapters must decode images and verify dimensions before rendering. The Expo web adapter performs browser decoding, while the standalone Swift/Kotlin SDKs use native decoders.
|
|
94
|
+
|
|
95
|
+
## Tintable icons
|
|
96
|
+
|
|
97
|
+
A catalog placement can declare `"rendering": "template"`. Assetlib then delivers a single-color shape, and the app supplies the color when it draws it, for example from its theme or a selected state. Values are `original` (the default when absent) and `template`. Code generation adds `rendering: 'template'` to that placement's reference and omits the field otherwise, so catalogs without it generate the same output as before.
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{ "key": "tab.trips", "symbol": ["Tabs", "trips"], "width": 24, "height": 24, "rendering": "template" }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Signed image descriptors (placements, state members, variant cells and catalog page images) may carry `rendering`. A present value that is not a string matching `^[a-z][a-z0-9-]{0,31}$` rejects the release; absent means `original`. Resolution compares the reference's rendering with the descriptor actually selected after arm, appearance and state selection. If they differ, or the descriptor names a value this client does not know, that release's descriptor is skipped as for an incompatible size: its cache entry is not read and nothing is downloaded. Resolution continues with the usual order, a compatible verified cached image from an older retained release and then the bundled fallback (`fallbackReason: 'missing'`). Other placements are unaffected. Catalog page images resolve as `original`. `ResolvedAsset.rendering` reports the delivered descriptor's rendering, `'original'` or `'template'`.
|
|
104
|
+
|
|
105
|
+
The bundled fallback must also be a single-color shape, such as a PNG with alpha, because the app applies the same tint to remote and bundled artwork. Remote masks are rasters, so request physical pixels with `pixelWidth` and `pixelHeight` (logical size × display scale); the Expo adapter does this for template references. Template rendering needs this SDK and CLI or newer: older code generation drops the field, and older runtimes do not check it. Publishing template descriptors from the hosted console is planned; until then releases carry no `rendering` field.
|
|
106
|
+
|
|
107
|
+
## Offline typed references
|
|
108
|
+
|
|
109
|
+
Check a catalog into your application:
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"schemaVersion": 1,
|
|
114
|
+
"placements": [
|
|
115
|
+
{ "key": "travel.coast", "symbol": ["Travel", "coast"], "width": 1200, "height": 900 }
|
|
116
|
+
]
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
node node_modules/@assetlib/sdk-core/bin/codegen.mjs assetlib.catalog.json > src/assets.generated.ts
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Use `AppAssets.Travel.coast` in application code. Existing compatible artwork updates do not require regenerating this catalog. Adding a new application placement does. Schema v1 identifies placements by key and exact width/height; stable placement IDs and separately versioned contracts are not implemented yet. Renaming a key requires a coordinated migration, not simply renaming it in the console.
|
|
125
|
+
|
|
126
|
+
## Appearance and arm variants
|
|
127
|
+
|
|
128
|
+
A catalog placement can declare either or both axes: `"variants": { "appearance": ["dark"], "arm": ["b", "c"] }`. Code generation preserves this declaration on `AssetRef` and `StateSetRef`; `assetlib sync` includes it in build registration. Appearance accepts one or two unique `light`/`dark` values. Arms accept one to four unique strings matching `^[a-z][a-z0-9_-]{0,19}$`, excluding `control`, `any`, `constructor`, `prototype`, and `__proto__`. Unknown axes and empty declarations are rejected. Control is implicit and is never listed in `variants.arm`.
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
const image = await client.resolve(AppAssets.Travel.coast, { arm: 'b', appearance: 'dark' });
|
|
132
|
+
const family = await client.resolveStateSet(AppAssets.Tasks.garden, { arm: 'b', appearance: 'dark' });
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Signed manifests retain `variantSchemaVersion: 1` and provide each placement's `variants` and bound `cells`. Cells omit `arm` for control and omit `appearance` for Any; at least one coordinate must be present. Duplicate coordinates, undeclared values, and incomplete state families reject the manifest. Resolution selects the first bound coordinate in this order:
|
|
136
|
+
|
|
137
|
+
1. Requested arm and appearance.
|
|
138
|
+
2. Requested arm and Any appearance.
|
|
139
|
+
3. Control arm and requested appearance.
|
|
140
|
+
4. The placement's legacy fields (Control/Any).
|
|
141
|
+
|
|
142
|
+
Omitting `appearance` requests Any. An explicit `arm: 'control'` bypasses decisions and requests control. An undeclared explicit arm inherits control through the same order. No other arm or appearance supplies artwork. Each stateful coordinate supplies an entire family; individual states are never mixed between coordinates or releases. Descriptions and renditions belong to the selected descriptor.
|
|
143
|
+
|
|
144
|
+
Image cache keys include the requested arm and appearance, even when the artwork inherits control. Retained releases use the same selection order and the same decision, with cached bytes only. `ResolvedAsset.sha256` remains the content hash; `cacheKey`, when present, identifies the storage entry. Custom adapters that open cached files should use `cacheKey ?? sha256`. Existing manifests and control cache keys remain supported. `resolveAnimation` continues to use Control/Any and never calls `decide`; asset-page payloads remain unchanged.
|
|
145
|
+
|
|
146
|
+
## App-supplied decisions
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
const client = createAssetClient(config, {
|
|
150
|
+
storage: durableStorage,
|
|
151
|
+
decide: async ({ key, arms, appearance }) => {
|
|
152
|
+
// Read an assignment from your app's experiment tool without logging exposure.
|
|
153
|
+
return experimentAssignments[key];
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
const image = await client.resolve(AppAssets.Travel.coast, { appearance: 'dark' });
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`decide` may return a declared arm or a promise for one. It runs once per `resolve` or `resolveStateSet` call when no explicit arm was supplied and the current compatible signed slot declares arms. The callback receives the placement key, an immutable array of declared arms, and the requested appearance when present. It never runs for slots without arms, missing slots, or before any signed manifest is available. The SDK does not persist assignment decisions; the app's experiment tool controls assignment stability.
|
|
160
|
+
|
|
161
|
+
The callback runs outside the client's operation queue with a 1,500 millisecond deadline. Set `decisionTimeoutMs` to an integer from 100 through 10,000 to change it. A pending callback never blocks refresh, initialization, or explicit arm/control requests. After it settles, the client revalidates durable state and selects from the current accepted release, including a newer release accepted by another client sharing storage. Failed revalidation uses the bundled fallback.
|
|
162
|
+
|
|
163
|
+
An undefined, undeclared, throwing/rejected, or timed-out result selects control, returns `armSource: 'invalid-decision'`, and explains why in `message`. Arm declarations are checked against the current release after the callback. Without a callback, omitted arms use `armSource: 'control'`. Explicit requests use `'explicit'`; accepted callback assignments use `'decision'`.
|
|
164
|
+
|
|
165
|
+
Every resolved image and state family reports `arm: string | null`: the arm of the artwork actually selected, or `null` for control or bundled fallback. `armSource` records how the request was chosen even when it inherits control or uses the bundle. State members carry the family's arm metadata. These are resolution diagnostics. **Exposure must be logged by the app's experiment tool after the intended artwork is rendered, never from this callback.** A decision can lead to a cache lookup, fallback, cancellation, or artwork that is never displayed.
|
|
166
|
+
|
|
167
|
+
## Select only the user's assigned catalog entries
|
|
168
|
+
|
|
169
|
+
The host backend determines card ownership/assignment. Match its artwork keys against references checked into the application, then resolve only those references:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
import { selectCatalogReferences } from '@assetlib/sdk-core';
|
|
173
|
+
|
|
174
|
+
const catalog = [AppAssets.Cards.gold, AppAssets.Cards.blue, AppAssets.Cards.green];
|
|
175
|
+
const { references, unknownKeys } = selectCatalogReferences(catalog, backendArtworkKeys);
|
|
176
|
+
await client.refresh(); // Downloads a signed manifest, no image or animation bodies.
|
|
177
|
+
const selectedImages = await Promise.all(references.map(ref => client.resolve(ref)));
|
|
178
|
+
// Show a generic bundled placeholder for unknownKeys; never construct URLs from them.
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The helper preserves assignment order, removes duplicates, and accepts at most 100 catalog entries and 100 keys. Unknown strings produce no reference and no request. It does not establish authorization. Public artwork must not contain account data; the app renders customer labels and handles assignment independently. Ship a small generic bundled fallback rather than every catalog image if install size matters. Measure app binary size separately from downloaded payload bytes.
|
|
182
|
+
|
|
183
|
+
## Explicit Lottie resolution
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
const animation = await client.resolveAnimation(AppAssets.Cards.gold);
|
|
187
|
+
if (animation.source === 'poster') {
|
|
188
|
+
// Use ordinary client.resolve(ref), then the generic bundled placeholder if needed.
|
|
189
|
+
} else {
|
|
190
|
+
// animation.data is validated JSON for a compatible Lottie player that the app supplies.
|
|
191
|
+
// animation.source is 'remote' or 'cache'; bytes and verified metadata are included.
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`resolveAnimation(ref)` never plays content automatically. Respect reduced motion, display the still poster while loading or on playback failure, and dispose the player on selection changes/unmount. Ordinary `resolve(ref)` never fetches animation bytes. Use one client and a storage adapter accepting `StorageMime` (`AssetMime | 'application/json'`); JSON is not an image format candidate. Under the `disk` policy the Expo adapter caches verified JSON in the same bounded store as images: IndexedDB on web and `<sha256>.json` files on iOS and Android.
|
|
196
|
+
|
|
197
|
+
The optional signed `animationSchemaVersion: 1` enables a per-slot `animation` descriptor containing `format: 'lottie'`, `profile: 'vector-v1'`, `mime: 'application/json'`, SHA-256, URL, bytes, width/height, frameRate, inPoint and outPoint. The SDK verifies same-origin app-scoped URLs, exact aspect ratio, signed byte count/hash, and equality between the signed metadata and parsed JSON before exposing data. Unknown schema/profile versions fail closed. Existing image-only clients can ignore the extension and use the mandatory WebP poster.
|
|
198
|
+
|
|
199
|
+
`vector-v1` accepts self-contained 2D shape layers with groups, paths, rectangles, ellipses, fills, strokes, transforms and trim paths. Static and bounded numeric/shape keyframes are supported. Animated properties require at least two strictly ordered keyframes, array-valued starts/ends (one element for scalars), consistent dimensions/path topology and complete easing for interpolated segments. Transforms require opacity, rotation, position, anchor and scale. It rejects images, fonts/text, precompositions, external resources, expressions, effects, masks, 3D content, unusual blending/time stretching and unknown extensions. `validateLottie(bytes)` exposes the same parser for tools and upload services, returning `{ data, width, height, frameRate, inPoint, outPoint }` without rewriting the original bytes. Rejection messages identify unsupported structure.
|
|
200
|
+
|
|
201
|
+
Limits: UTF-8 JSON up to 512 KiB, dimensions 1–2048, at most 4,194,304 pixels, 1–60 fps, duration up to 30 seconds / 1,800 frames, 32 layers, 2,048 shapes, 4,096 keyframes/vertices, 30,000 document values and nesting depth 32. An arbitrary hash-valid JSON file is never sufficient for playback.
|
|
202
|
+
|
|
203
|
+
Verified cached animation works offline. Only the current descriptor may trigger a download; if it fails, retained historical releases may supply an already cached compatible animation. Historical bytes are reverified and never downloaded. If the current release removes animation or its placement, resolution returns `source: 'poster'` immediately instead of reviving an old animation. First-use offline and unsupported/failed animations also return that poster outcome.
|
|
204
|
+
|
|
205
|
+
## Verification and operating limits
|
|
206
|
+
|
|
207
|
+
- Ed25519 signatures are checked against an independently pinned key from the configured single key or set using strict RFC 8032 verification. An envelope cannot add or replace a pinned key.
|
|
208
|
+
- Schema, app, organization, environment, key ID, sequence, placement dimensions, and delivery URLs are checked. Image bytes must match the signed SHA-256 and byte count.
|
|
209
|
+
- Public config: at most 4096 UTF-8 JSON bytes, 1–16 distinct exact-PEM pins, and 256 UTF-8 bytes per Ed25519 SPKI PEM. These limits are exported as `SDK_LIMITS.configBytes`, `pinnedKeys`, and `publicKeyBytes`.
|
|
210
|
+
- HTTPS is required. `{ allowInsecureLoopback: true }` permits HTTP only for `localhost`, `127.0.0.1`, or `::1`; it does not permit arbitrary LAN hosts.
|
|
211
|
+
- Delivery is credentialless. Redirects are rejected. A streaming Fetch implementation is required so chunked responses can be bounded. The Expo adapter uses `expo/fetch` on native.
|
|
212
|
+
- Default deadline: 8 seconds per request; configurable from 20 milliseconds to 30 seconds. Maximum manifest response: 256 KiB. Maximum image: 8 MiB. Maximum placements: 100.
|
|
213
|
+
- Persistent state keeps the highest accepted sequence and eight signed releases, up to 3 MiB. A lower sequence or different payload reusing a sequence is rejected. Legitimate server rollback must publish a new, higher sequence.
|
|
214
|
+
- A failed current image resolves to a compatible cached image from the retained release history, then to the app's bundled fallback. Older releases never cause a new download. Eviction or more than eight accepted releases can remove a previous fallback.
|
|
215
|
+
- Cache bytes are rehashed before use. The supplied adapters bound image storage to 50 MiB and 100 entries per configuration. Persistence is required before activating a new release. Corrupt replay state fails closed; it is not automatically discarded.
|
|
216
|
+
|
|
217
|
+
Clearing app data, uninstalling the app, or browser storage eviction can reset local replay protection across restarts. Changing pinned keys or the production manifest route does not reset it. An active client fails closed if accepted durable state disappears, moves backwards, conflicts, or cannot be verified. This preview does not claim protection against a compromised device or browser. Storage adapters must enforce atomic, nondecreasing state replacement. No background manifest refresh, resumable download, HTTP content negotiation, experiment assignment, or automatic signing-key distribution is implemented. Opt-in observation counts do not establish experiment exposure. Rendering support is supplied by the platform; a valid hash does not prove an image is decodable.
|
|
218
|
+
|
|
219
|
+
## Development
|
|
220
|
+
|
|
221
|
+
```sh
|
|
222
|
+
npm ci
|
|
223
|
+
npm test
|
|
224
|
+
npm pack
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Tests include the shared cross-platform contract corpus (`test/fixtures/shared`, byte-identical with the Swift and Kotlin SDKs' copies: 115 signed manifest cases, 43 public config cases exercised as strings and objects, 18 appearance/arm resolution cases, 10 tintable icon rendering cases, staging configuration, stateful replay, byte-failure and rendition selection cases), separate JavaScript key-set fixtures for a second trusted signer and an untrusted signer, catalog subset requests, Lottie profile rejection/metadata verification, animation cache/offline/rollback/removal behavior, selection, PNG/SVG bytes and offline fallback, plus real Node Ed25519 signatures and bounded mock delivery streams. They cover signature and byte tampering, independent pins, replay across client restarts, corrupt storage, offline cache fallback, contract mismatch, URL boundaries, deadlines, and offline code generation. The npm pack contains built JavaScript and declarations; it does not require TypeScript compilation when installed.
|
|
228
|
+
|
|
229
|
+
## State sets, catalog pages, and cache policies
|
|
230
|
+
|
|
231
|
+
Release 0.4.0-preview.1 adds `StateSetRef` / `resolveStateSet`, signed `loadAssetPage` / `resolveAsset` runtime handles, and `cachePolicy: 'disk' | 'memory' | 'none'`. The default remains `disk`; ephemeral image policies preserve durable signed release state. Image requests are cancellable and bounded to four concurrent transfers by default. `clearMemoryCache()` releases retained session bytes.
|
|
232
|
+
|
|
233
|
+
A state set declares 2–16 unique states in the checked-in catalog (`placements[].states`); code generation preserves those literal names. Resolve the complete family once and select its returned state. If any required member is unavailable, the whole family uses an eligible older cached release or the bundled family. Do not substitute one progress stage for another.
|
|
234
|
+
|
|
235
|
+
Catalog metadata pages are signed, scoped to the app/release/request cursor, and contain at most 50 image references. They fetch no image bodies. Preserve the first page's `sequence` on continuation requests. Runtime handles are bound to their issuing client and cannot be serialized or reconstructed as trusted references. Use the Expo virtualized-list example for visible-only image resolution.
|
|
236
|
+
|
|
237
|
+
These additions currently apply to JavaScript/Expo. Swift/Kotlin retain legacy image APIs. Catalog-only manifests with no slots are unsupported by those older clients. No production-scale catalog capacity is implied.
|
package/bin/codegen.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { validateAccessibility } from '../dist/accessibility.js';
|
|
4
|
+
|
|
5
|
+
try {
|
|
6
|
+
if (process.argv.includes('--help')) {
|
|
7
|
+
console.log('Usage: node codegen.mjs <checked-in-catalog.json>\nPrints typed AppAssets to stdout. No network calls or automatic file writes.');
|
|
8
|
+
} else {
|
|
9
|
+
if (process.argv.length !== 3) throw new Error('Provide one checked-in catalog JSON path. Use --help.');
|
|
10
|
+
const text = await readFile(process.argv[2], 'utf8');
|
|
11
|
+
if (Buffer.byteLength(text) > 128 * 1024) throw new Error('Catalog exceeds 128 KiB.');
|
|
12
|
+
const catalog = JSON.parse(text);
|
|
13
|
+
if (catalog.schemaVersion !== 1 || !Array.isArray(catalog.placements) || !catalog.placements.length || catalog.placements.length > 100) throw new Error('Expected catalog schemaVersion 1 with 1–100 placements.');
|
|
14
|
+
const tree = Object.create(null);
|
|
15
|
+
const keys = new Set();
|
|
16
|
+
for (const placement of catalog.placements) {
|
|
17
|
+
if (typeof placement.key !== 'string' || !/^[a-zA-Z][a-zA-Z0-9_.-]{0,119}$/.test(placement.key) || keys.has(placement.key) || ![placement.width, placement.height].every(value => Number.isSafeInteger(value) && value > 0 && value <= 8192) || !Array.isArray(placement.symbol) || placement.symbol.length < 1 || placement.symbol.length > 5 || placement.symbol.some(value => typeof value !== 'string' || !/^[A-Za-z_$][\w$]{0,63}$/.test(value) || ['__proto__', 'constructor', 'prototype'].includes(value))) throw new Error('Invalid or duplicate catalog placement.');
|
|
18
|
+
keys.add(placement.key);
|
|
19
|
+
if ('variants' in placement) {
|
|
20
|
+
const variants = placement.variants;
|
|
21
|
+
if (!variants || typeof variants !== 'object' || Array.isArray(variants) || !Object.keys(variants).length || Object.keys(variants).some(axis => axis !== 'appearance' && axis !== 'arm')) throw new Error('Variants support only appearance and arm axes.');
|
|
22
|
+
if ('appearance' in variants && (!Array.isArray(variants.appearance) || !variants.appearance.length || new Set(variants.appearance).size !== variants.appearance.length || variants.appearance.some(value => value !== 'light' && value !== 'dark'))) throw new Error('Variants appearance requires unique light or dark values.');
|
|
23
|
+
if ('arm' in variants && (!Array.isArray(variants.arm) || variants.arm.length < 1 || variants.arm.length > 4 || new Set(variants.arm).size !== variants.arm.length || variants.arm.some(value => typeof value !== 'string' || value !== value.trim() || !/^[a-z][a-z0-9_-]{0,19}$/.test(value) || ['control', 'any', 'constructor', 'prototype', '__proto__'].includes(value)))) throw new Error('Variants arm requires 1–4 unique valid arm names.');
|
|
24
|
+
}
|
|
25
|
+
if (placement.states !== undefined && (!Array.isArray(placement.states) || placement.states.length < 2 || placement.states.length > 16 || new Set(placement.states).size !== placement.states.length || placement.states.some(value => typeof value !== 'string' || !/^[a-z][a-z0-9_-]{0,39}$/.test(value) || ['constructor', 'prototype', '__proto__'].includes(value)))) throw new Error('State sets require 2–16 unique named states.');
|
|
26
|
+
if ('rendering' in placement && placement.rendering !== 'original' && placement.rendering !== 'template') throw new Error(`Placement ${placement.key} rendering must be "original" or "template".`);
|
|
27
|
+
if ('bundledAccessibility' in placement) validateAccessibility(placement.bundledAccessibility);
|
|
28
|
+
if ('bundledStateAccessibility' in placement) {
|
|
29
|
+
if (!placement.states || !placement.bundledStateAccessibility || typeof placement.bundledStateAccessibility !== 'object' || Array.isArray(placement.bundledStateAccessibility)) throw new Error('Bundled state descriptions require a state set.');
|
|
30
|
+
for (const [state, metadata] of Object.entries(placement.bundledStateAccessibility)) {
|
|
31
|
+
if (!placement.states.includes(state)) throw new Error('Bundled description has an unknown state.');
|
|
32
|
+
validateAccessibility(metadata);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
let node = tree;
|
|
36
|
+
for (const name of placement.symbol.slice(0, -1)) {
|
|
37
|
+
if (node[name]?.key) throw new Error('Catalog symbols overlap.');
|
|
38
|
+
node = node[name] ??= Object.create(null);
|
|
39
|
+
}
|
|
40
|
+
const leaf = placement.symbol.at(-1);
|
|
41
|
+
if (Object.hasOwn(node, leaf)) throw new Error('Catalog symbols overlap.');
|
|
42
|
+
node[leaf] = { key: placement.key, width: placement.width, height: placement.height, ...(placement.rendering === 'template' ? { rendering: 'template' } : {}), ...(placement.states ? { states: placement.states } : {}), ...(placement.variants ? { variants: placement.variants } : {}), ...(placement.bundledAccessibility ? { bundledAccessibility: placement.bundledAccessibility } : {}), ...(placement.bundledStateAccessibility ? { bundledStateAccessibility: placement.bundledStateAccessibility } : {}) };
|
|
43
|
+
}
|
|
44
|
+
console.log('// Generated from the checked-in Assetlib catalog. Regenerate instead of editing.\n// No network dependency during compilation.\nexport const AppAssets = ' + JSON.stringify(tree, null, 2) + ' as const;\n');
|
|
45
|
+
}
|
|
46
|
+
} catch (error) { console.error(`Assetlib codegen: ${error.message}`); process.exitCode = 1; }
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AssetAccessibility } from './types.js';
|
|
2
|
+
/** Validate metadata before accepting it as part of a signed image descriptor. */
|
|
3
|
+
export declare function validateAccessibility(value: unknown): asserts value is AssetAccessibility;
|
|
4
|
+
/** Exact locale, then progressively less specific subtags, then the explicit default. */
|
|
5
|
+
export declare function resolveAccessibilityDescription(metadata: AssetAccessibility | undefined, locale?: string): string | undefined;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const record = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
2
|
+
const validLocale = (value) => typeof value === 'string' && value.length <= 63 && /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*(?![\s\S])/.test(value);
|
|
3
|
+
/** Validate metadata before accepting it as part of a signed image descriptor. */
|
|
4
|
+
export function validateAccessibility(value) {
|
|
5
|
+
if (!record(value) || !validLocale(value.defaultLocale) || !record(value.descriptions))
|
|
6
|
+
throw new Error('Invalid image accessibility metadata.');
|
|
7
|
+
const entries = Object.entries(value.descriptions), locales = new Set();
|
|
8
|
+
if (!entries.length || entries.length > 32)
|
|
9
|
+
throw new Error('Image accessibility requires 1–32 descriptions.');
|
|
10
|
+
for (const [locale, description] of entries) {
|
|
11
|
+
if (!validLocale(locale) || locales.has(locale.toLowerCase()) || typeof description !== 'string' || !description.trim() || description.length > 1000)
|
|
12
|
+
throw new Error('Invalid image accessibility description.');
|
|
13
|
+
locales.add(locale.toLowerCase());
|
|
14
|
+
}
|
|
15
|
+
if (!locales.has(value.defaultLocale.toLowerCase()))
|
|
16
|
+
throw new Error('The default image accessibility locale must have a description.');
|
|
17
|
+
}
|
|
18
|
+
/** Exact locale, then progressively less specific subtags, then the explicit default. */
|
|
19
|
+
export function resolveAccessibilityDescription(metadata, locale) {
|
|
20
|
+
if (!metadata)
|
|
21
|
+
return undefined;
|
|
22
|
+
const descriptions = new Map(Object.entries(metadata.descriptions).map(([key, value]) => [key.toLowerCase(), value]));
|
|
23
|
+
let candidate = locale?.toLowerCase();
|
|
24
|
+
while (candidate) {
|
|
25
|
+
const description = descriptions.get(candidate);
|
|
26
|
+
if (description !== undefined)
|
|
27
|
+
return description;
|
|
28
|
+
const separator = candidate.lastIndexOf('-');
|
|
29
|
+
candidate = separator < 0 ? undefined : candidate.slice(0, separator);
|
|
30
|
+
}
|
|
31
|
+
return descriptions.get(metadata.defaultLocale.toLowerCase());
|
|
32
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type ValidatedLottie } from './lottie.js';
|
|
2
|
+
import type { AnimationDescriptor, AssetlibConfig } from './types.js';
|
|
3
|
+
export declare function validateAnimationExtension(payload: Record<string, unknown>, slot: Record<string, unknown>, config: AssetlibConfig): void;
|
|
4
|
+
/** Call only after byte count and SHA-256 have matched the signed descriptor. */
|
|
5
|
+
export declare function verifiedAnimationData(bytes: Uint8Array, descriptor: AnimationDescriptor): ValidatedLottie;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { LOTTIE_LIMITS, validateLottie, validateLottieMetadata } from './lottie.js';
|
|
2
|
+
const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
|
3
|
+
const record = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
4
|
+
const hashPattern = /^[a-f0-9]{64}$/;
|
|
5
|
+
export function validateAnimationExtension(payload, slot, config) {
|
|
6
|
+
if (own(payload, 'animationSchemaVersion') && payload.animationSchemaVersion !== 1)
|
|
7
|
+
throw new Error('Unsupported animation schema.');
|
|
8
|
+
if (!own(slot, 'animation'))
|
|
9
|
+
return;
|
|
10
|
+
const a = slot.animation;
|
|
11
|
+
if (payload.animationSchemaVersion !== 1 || !record(a) || a.format !== 'lottie' || a.profile !== 'vector-v1' || a.mime !== 'application/json' || typeof a.sha256 !== 'string' || !hashPattern.test(a.sha256) || typeof a.url !== 'string' || !Number.isSafeInteger(a.bytes) || Number(a.bytes) < 1 || Number(a.bytes) > LOTTIE_LIMITS.bytes)
|
|
12
|
+
throw new Error('Invalid signed animation descriptor.');
|
|
13
|
+
validateLottieMetadata(a);
|
|
14
|
+
if (Number(a.width) * Number(slot.height) !== Number(a.height) * Number(slot.width))
|
|
15
|
+
throw new Error('Animation aspect ratio does not exactly match the placement.');
|
|
16
|
+
const url = new URL(a.url, config.manifestUrl);
|
|
17
|
+
if (url.origin !== new URL(config.manifestUrl).origin || url.username || url.password || url.search || url.hash || url.pathname !== `/api/delivery/${config.orgId}/${config.appId}/assets/${slot.assetId}/animations/${a.sha256}`)
|
|
18
|
+
throw new Error('Animation URL is outside the configured app.');
|
|
19
|
+
}
|
|
20
|
+
/** Call only after byte count and SHA-256 have matched the signed descriptor. */
|
|
21
|
+
export function verifiedAnimationData(bytes, descriptor) {
|
|
22
|
+
const parsed = validateLottie(bytes);
|
|
23
|
+
for (const key of ['width', 'height', 'frameRate', 'inPoint', 'outPoint']) {
|
|
24
|
+
if (parsed[key] !== descriptor[key])
|
|
25
|
+
throw new Error('Animation JSON metadata does not match the signed descriptor.');
|
|
26
|
+
}
|
|
27
|
+
return parsed;
|
|
28
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AssetRef } from './types.js';
|
|
2
|
+
/** Match backend assignments against app-owned, checked-in references. This
|
|
3
|
+
* performs no requests and does not establish customer authorization. */
|
|
4
|
+
export declare function selectCatalogReferences(catalog: readonly AssetRef[], keys: readonly string[]): {
|
|
5
|
+
references: AssetRef[];
|
|
6
|
+
unknownKeys: string[];
|
|
7
|
+
};
|
package/dist/catalog.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Match backend assignments against app-owned, checked-in references. This
|
|
2
|
+
* performs no requests and does not establish customer authorization. */
|
|
3
|
+
export function selectCatalogReferences(catalog, keys) {
|
|
4
|
+
if (!Array.isArray(catalog) || catalog.length > 100 || !Array.isArray(keys) || keys.length > 100)
|
|
5
|
+
throw new Error('Catalog selections must be arrays of at most 100 items.');
|
|
6
|
+
const byKey = new Map();
|
|
7
|
+
const validKey = (key) => typeof key === 'string' && /^[a-zA-Z][a-zA-Z0-9_.-]{0,119}$/.test(key);
|
|
8
|
+
for (const ref of catalog) {
|
|
9
|
+
if (!ref || !validKey(ref.key) || !Number.isSafeInteger(ref.width) || ref.width < 1 || ref.width > 8192 || !Number.isSafeInteger(ref.height) || ref.height < 1 || ref.height > 8192 || byKey.has(ref.key))
|
|
10
|
+
throw new Error('The trusted catalog has an invalid or duplicate reference.');
|
|
11
|
+
byKey.set(ref.key, ref);
|
|
12
|
+
}
|
|
13
|
+
const references = [], unknownKeys = [], seen = new Set();
|
|
14
|
+
for (const key of keys) {
|
|
15
|
+
if (typeof key !== 'string' || key.length > 120)
|
|
16
|
+
throw new Error('Assignment keys must be strings no longer than 120 characters.');
|
|
17
|
+
if (seen.has(key))
|
|
18
|
+
continue;
|
|
19
|
+
seen.add(key);
|
|
20
|
+
const ref = validKey(key) ? byKey.get(key) : undefined;
|
|
21
|
+
if (ref)
|
|
22
|
+
references.push(ref);
|
|
23
|
+
else
|
|
24
|
+
unknownKeys.push(key);
|
|
25
|
+
}
|
|
26
|
+
return { references, unknownKeys };
|
|
27
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AssetlibConfig, AssetPagePayload, ManifestPayload } from './types.js';
|
|
2
|
+
export declare const stateName: (value: unknown) => value is string;
|
|
3
|
+
export declare const assetIdPattern: RegExp;
|
|
4
|
+
/** A malformed rendering rejects the release. A well-formed unknown value is resolved as incompatible, per descriptor. */
|
|
5
|
+
export declare function validateRendering(value: Record<string, unknown>): void;
|
|
6
|
+
export declare function validateDescriptor(value: unknown, width: number, height: number, config: AssetlibConfig, renditionSchemaVersion?: unknown): void;
|
|
7
|
+
export declare function validateDeliveryExtensions(payload: Record<string, unknown>, config: AssetlibConfig): void;
|
|
8
|
+
export declare function validateAssetPage(payload: unknown, config: AssetlibConfig): AssetPagePayload;
|
|
9
|
+
export declare function validStateRef(states: readonly string[]): boolean;
|
|
10
|
+
export declare function catalogForSequence(history: readonly {
|
|
11
|
+
payload: ManifestPayload;
|
|
12
|
+
}[], sequence: number | undefined): ManifestPayload | undefined;
|
package/dist/delivery.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { validateRenditions } from './renditions.js';
|
|
2
|
+
import { validateAccessibility } from './accessibility.js';
|
|
3
|
+
export const stateName = (value) => typeof value === 'string' && /^[a-z][a-z0-9_-]{0,39}$/.test(value) && !['constructor', 'prototype', '__proto__'].includes(value);
|
|
4
|
+
const record = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
5
|
+
const integer = (value, min, max) => Number.isSafeInteger(value) && Number(value) >= min && Number(value) <= max;
|
|
6
|
+
export const assetIdPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
7
|
+
/** A malformed rendering rejects the release. A well-formed unknown value is resolved as incompatible, per descriptor. */
|
|
8
|
+
export function validateRendering(value) {
|
|
9
|
+
if ('rendering' in value && (typeof value.rendering !== 'string' || !/^[a-z][a-z0-9-]{0,31}$/.test(value.rendering)))
|
|
10
|
+
throw new Error('Invalid image rendering.');
|
|
11
|
+
}
|
|
12
|
+
export function validateDescriptor(value, width, height, config, renditionSchemaVersion) {
|
|
13
|
+
if (!record(value) || typeof value.assetId !== 'string' || !assetIdPattern.test(value.assetId) || typeof value.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.sha256) || value.mime !== 'image/webp' || !integer(value.bytes, 1, 8 * 1024 * 1024) || typeof value.url !== 'string')
|
|
14
|
+
throw new Error('Invalid image descriptor.');
|
|
15
|
+
const url = new URL(value.url, config.manifestUrl);
|
|
16
|
+
if (url.origin !== new URL(config.manifestUrl).origin || url.username || url.password || url.search || url.hash || url.pathname !== `/api/delivery/${config.orgId}/${config.appId}/assets/${value.assetId}`)
|
|
17
|
+
throw new Error('Image descriptor URL is outside the configured app.');
|
|
18
|
+
validateRenditions(renditionSchemaVersion === undefined ? {} : { renditionSchemaVersion }, { ...value, width, height }, config);
|
|
19
|
+
if ('accessibility' in value)
|
|
20
|
+
validateAccessibility(value.accessibility);
|
|
21
|
+
validateRendering(value);
|
|
22
|
+
}
|
|
23
|
+
function validateStateImages(states, slot, config, renditionSchemaVersion) {
|
|
24
|
+
for (const value of Object.values(states))
|
|
25
|
+
validateDescriptor(value, Number(slot.width), Number(slot.height), config, renditionSchemaVersion);
|
|
26
|
+
const maximumBytes = Object.values(states).reduce((sum, value) => {
|
|
27
|
+
const descriptor = value;
|
|
28
|
+
return sum + Math.max(descriptor.bytes, ...(descriptor.renditions ?? []).map(item => item.bytes));
|
|
29
|
+
}, 0);
|
|
30
|
+
if (maximumBytes > 50 * 1024 * 1024)
|
|
31
|
+
throw new Error('State set exceeds the complete-group byte budget.');
|
|
32
|
+
}
|
|
33
|
+
function equivalent(left, right) {
|
|
34
|
+
if (left === right)
|
|
35
|
+
return true;
|
|
36
|
+
if (Array.isArray(left) && Array.isArray(right))
|
|
37
|
+
return left.length === right.length && left.every((item, index) => equivalent(item, right[index]));
|
|
38
|
+
if (!record(left) || !record(right))
|
|
39
|
+
return false;
|
|
40
|
+
const names = Object.keys(left);
|
|
41
|
+
return names.length === Object.keys(right).length && names.every(name => Object.hasOwn(right, name) && equivalent(left[name], right[name]));
|
|
42
|
+
}
|
|
43
|
+
function validateCells(slot, payload, config) {
|
|
44
|
+
if (!('variants' in slot)) {
|
|
45
|
+
if ('cells' in slot)
|
|
46
|
+
throw new Error('Variant cells require declared variants.');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const variants = slot.variants;
|
|
50
|
+
if (payload.variantSchemaVersion !== 1 || !record(variants) || !Object.keys(variants).length || Object.keys(variants).some(name => name !== 'appearance' && name !== 'arm'))
|
|
51
|
+
throw new Error('Invalid variant axes.');
|
|
52
|
+
if ('appearance' in variants && (!Array.isArray(variants.appearance) || variants.appearance.length < 1 || variants.appearance.length > 2 || variants.appearance.some(value => value !== 'light' && value !== 'dark') || new Set(variants.appearance).size !== variants.appearance.length))
|
|
53
|
+
throw new Error('Invalid appearance variants.');
|
|
54
|
+
if ('arm' in variants && (!Array.isArray(variants.arm) || variants.arm.length < 1 || variants.arm.length > 4 || variants.arm.some(value => typeof value !== 'string' || value !== value.trim() || !/^[a-z][a-z0-9_-]{0,19}$/.test(value) || ['control', 'any', 'constructor', 'prototype', '__proto__'].includes(value)) || new Set(variants.arm).size !== variants.arm.length))
|
|
55
|
+
throw new Error('Invalid arm variants.');
|
|
56
|
+
const appearances = Array.isArray(variants.appearance) ? variants.appearance : [];
|
|
57
|
+
const arms = Array.isArray(variants.arm) ? variants.arm : [];
|
|
58
|
+
if (!('cells' in slot))
|
|
59
|
+
return;
|
|
60
|
+
if (!Array.isArray(slot.cells) || slot.cells.length > (arms.length + 1) * (appearances.length + 1) - 1)
|
|
61
|
+
throw new Error('Invalid variant cells.');
|
|
62
|
+
const coordinates = new Set();
|
|
63
|
+
for (const cell of slot.cells) {
|
|
64
|
+
if (!record(cell) || !('appearance' in cell || 'arm' in cell))
|
|
65
|
+
throw new Error('Variant cells require at least one arm or appearance coordinate.');
|
|
66
|
+
if ('appearance' in cell && !appearances.includes(cell.appearance))
|
|
67
|
+
throw new Error('Variant cell appearance is not declared.');
|
|
68
|
+
if ('arm' in cell && !arms.includes(cell.arm))
|
|
69
|
+
throw new Error('Variant cell arm is not declared.');
|
|
70
|
+
const coordinate = JSON.stringify([cell.arm ?? null, cell.appearance ?? null]);
|
|
71
|
+
if (coordinates.has(coordinate))
|
|
72
|
+
throw new Error('Duplicate variant cell coordinates.');
|
|
73
|
+
coordinates.add(coordinate);
|
|
74
|
+
validateDescriptor(cell, Number(slot.width), Number(slot.height), config, payload.renditionSchemaVersion);
|
|
75
|
+
if ('defaultState' in cell)
|
|
76
|
+
throw new Error('Variant cells use the placement default state.');
|
|
77
|
+
if (record(slot.states)) {
|
|
78
|
+
if (!record(cell.states) || Object.keys(cell.states).length !== Object.keys(slot.states).length || Object.keys(slot.states).some(name => !Object.hasOwn(cell.states, name)))
|
|
79
|
+
throw new Error('Variant state family must contain every declared state.');
|
|
80
|
+
validateStateImages(cell.states, slot, config, payload.renditionSchemaVersion);
|
|
81
|
+
const fallback = cell.states[String(slot.defaultState)];
|
|
82
|
+
if (['assetId', 'sha256', 'url', 'mime', 'bytes', 'renditions', 'accessibility'].some(name => !equivalent(cell[name], fallback[name])))
|
|
83
|
+
throw new Error('Variant artwork must match its default state.');
|
|
84
|
+
}
|
|
85
|
+
else if ('states' in cell)
|
|
86
|
+
throw new Error('A stateless placement cannot have variant states.');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function validateDeliveryExtensions(payload, config) {
|
|
90
|
+
if ('stateSchemaVersion' in payload && payload.stateSchemaVersion !== 1)
|
|
91
|
+
throw new Error('Unsupported state schema.');
|
|
92
|
+
if ('catalogSchemaVersion' in payload && payload.catalogSchemaVersion !== 1)
|
|
93
|
+
throw new Error('Unsupported catalog schema.');
|
|
94
|
+
if ('variantSchemaVersion' in payload && payload.variantSchemaVersion !== 1)
|
|
95
|
+
throw new Error('Unsupported variant schema.');
|
|
96
|
+
if ('catalog' in payload) {
|
|
97
|
+
if (payload.catalogSchemaVersion !== 1 || !record(payload.catalog) || !integer(payload.catalog.count, 0, 1_000_000) || typeof payload.catalog.url !== 'string')
|
|
98
|
+
throw new Error('Invalid catalog descriptor.');
|
|
99
|
+
const url = new URL(payload.catalog.url, config.manifestUrl);
|
|
100
|
+
const deliveryPath = `/api/delivery/${config.orgId}/${config.appId}`;
|
|
101
|
+
const environmentPath = `${deliveryPath}/environments/${config.environment}/releases/${payload.sequence}/assets`;
|
|
102
|
+
const legacyProductionPath = config.environment === 'production' && url.pathname === `${deliveryPath}/releases/${payload.sequence}/assets`;
|
|
103
|
+
if (url.origin !== new URL(config.manifestUrl).origin || url.username || url.password || url.search || url.hash || (url.pathname !== environmentPath && !legacyProductionPath))
|
|
104
|
+
throw new Error('Catalog URL is outside the configured release.');
|
|
105
|
+
}
|
|
106
|
+
for (const slot of payload.slots) {
|
|
107
|
+
if (!('states' in slot)) {
|
|
108
|
+
if ('defaultState' in slot)
|
|
109
|
+
throw new Error('A default state requires a state set.');
|
|
110
|
+
validateCells(slot, payload, config);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (payload.stateSchemaVersion !== 1 || !record(slot.states) || !stateName(slot.defaultState))
|
|
114
|
+
throw new Error('Invalid state set.');
|
|
115
|
+
const names = Object.keys(slot.states);
|
|
116
|
+
if (names.length < 2 || names.length > 16 || names.some(name => !stateName(name)) || !Object.hasOwn(slot.states, slot.defaultState))
|
|
117
|
+
throw new Error('Invalid state declarations.');
|
|
118
|
+
validateStateImages(slot.states, slot, config, payload.renditionSchemaVersion);
|
|
119
|
+
const fallback = slot.states[slot.defaultState];
|
|
120
|
+
if (fallback.assetId !== slot.assetId || fallback.sha256 !== slot.sha256 || fallback.bytes !== slot.bytes || fallback.mime !== slot.mime || fallback.url !== slot.url)
|
|
121
|
+
throw new Error('Legacy artwork must match the default state.');
|
|
122
|
+
validateCells(slot, payload, config);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export function validateAssetPage(payload, config) {
|
|
126
|
+
if (!record(payload) || payload.kind !== 'asset-page' || payload.schemaVersion !== 1 || payload.orgId !== config.orgId || payload.appId !== config.appId || payload.environment !== config.environment || !integer(payload.sequence, 1, 2_147_483_647) || typeof payload.createdAt !== 'string' || !Number.isFinite(Date.parse(payload.createdAt)) || !Array.isArray(payload.assets) || payload.assets.length > 50 || !(payload.nextCursor === null || typeof payload.nextCursor === 'string' && assetIdPattern.test(payload.nextCursor)))
|
|
127
|
+
throw new Error('Invalid or cross-app asset page.');
|
|
128
|
+
if ('renditionSchemaVersion' in payload && payload.renditionSchemaVersion !== 1)
|
|
129
|
+
throw new Error('Unsupported rendition schema.');
|
|
130
|
+
if (!(payload.cursor === null || typeof payload.cursor === 'string' && assetIdPattern.test(payload.cursor)))
|
|
131
|
+
throw new Error('Invalid catalog request cursor.');
|
|
132
|
+
let previous = '';
|
|
133
|
+
for (const asset of payload.assets) {
|
|
134
|
+
if (!record(asset) || !integer(asset.width, 1, 8192) || !integer(asset.height, 1, 8192) || asset.width * asset.height > 16_777_216 || typeof asset.assetId !== 'string' || asset.assetId <= previous || ('name' in asset && (typeof asset.name !== 'string' || asset.name.length > 200)))
|
|
135
|
+
throw new Error('Invalid or unordered catalog asset.');
|
|
136
|
+
validateDescriptor(asset, asset.width, asset.height, config, payload.renditionSchemaVersion);
|
|
137
|
+
previous = asset.assetId;
|
|
138
|
+
}
|
|
139
|
+
if (payload.nextCursor !== null && (!previous || payload.nextCursor !== previous))
|
|
140
|
+
throw new Error('Invalid catalog continuation.');
|
|
141
|
+
return payload;
|
|
142
|
+
}
|
|
143
|
+
export function validStateRef(states) {
|
|
144
|
+
return Array.isArray(states) && states.length >= 2 && states.length <= 16 && new Set(states).size === states.length && states.every(stateName);
|
|
145
|
+
}
|
|
146
|
+
export function catalogForSequence(history, sequence) {
|
|
147
|
+
return sequence === undefined ? history[0]?.payload : history.find(entry => entry.payload.sequence === sequence)?.payload;
|
|
148
|
+
}
|