@gradial/aci 0.1.52-rc.2 → 0.1.52-rc.21

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.
Files changed (123) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +4 -4
  3. package/dist/astro/preview.js +2 -17
  4. package/dist/audience-data-models.d.ts +289 -16
  5. package/dist/audience-data-models.js +99 -2
  6. package/dist/cli/compile-registry.d.mts +5 -1
  7. package/dist/cli/compile-registry.mjs +25 -10
  8. package/dist/cli/validate-content.d.mts +31 -1
  9. package/dist/cli/validate-content.mjs +50 -12
  10. package/dist/collect/aci.js +1 -1
  11. package/dist/collect/aci.js.sha256 +1 -1
  12. package/dist/collect/api.d.ts +3 -7
  13. package/dist/collect/api.js +2 -8
  14. package/dist/collect/batcher.d.ts +6 -5
  15. package/dist/collect/batcher.js +6 -5
  16. package/dist/collect/index.js +1 -1
  17. package/dist/content/provider.d.ts +1 -0
  18. package/dist/content/resolve-slots.d.ts +9 -2
  19. package/dist/content/resolve-slots.js +5 -3
  20. package/dist/content/types.d.ts +2 -0
  21. package/dist/edge/expression-capabilities.v1.d.ts +106 -0
  22. package/dist/edge/expression-capabilities.v1.js +165 -0
  23. package/dist/internal/domain/edge/compose.d.ts +24 -0
  24. package/dist/internal/domain/edge/compose.js +89 -0
  25. package/dist/internal/domain/edge/identity.js +1 -1
  26. package/dist/internal/domain/edge/index.d.ts +2 -0
  27. package/dist/internal/domain/edge/index.js +2 -0
  28. package/dist/internal/domain/edge/mimic_token.d.ts +13 -0
  29. package/dist/internal/domain/edge/mimic_token.js +58 -0
  30. package/dist/internal/domain/edge/personalization-core.d.ts +69 -5
  31. package/dist/internal/domain/edge/personalization-core.js +693 -58
  32. package/dist/internal/domain/edge/record/codec.d.ts +5 -2
  33. package/dist/internal/domain/edge/record/codec.js +85 -12
  34. package/dist/internal/domain/edge/resolve.js +38 -18
  35. package/dist/internal/domain/edge/testdata/flat_only_validator_v1.d.ts +10 -0
  36. package/dist/internal/domain/edge/testdata/flat_only_validator_v1.js +253 -0
  37. package/dist/internal/domain/edge/types.d.ts +52 -9
  38. package/dist/internal/domain/edge/types.js +3 -3
  39. package/dist/mimic/core.d.ts +2 -0
  40. package/dist/mimic/core.js +31 -0
  41. package/dist/next/error-seat-payload.js +1 -1
  42. package/dist/next/hosted-page-target.d.ts +7 -0
  43. package/dist/next/hosted-page-target.js +39 -0
  44. package/dist/next/index.d.ts +0 -1
  45. package/dist/next/index.js +0 -1
  46. package/dist/next/page.d.ts +29 -6
  47. package/dist/next/page.js +47 -25
  48. package/dist/next/server.js +28 -1
  49. package/dist/next/vercel-edge-projection-v1.d.ts +0 -9
  50. package/dist/next/vercel-edge-projection-v1.js +9 -539
  51. package/dist/preview/core.d.ts +1 -0
  52. package/dist/preview/core.js +27 -0
  53. package/dist/providers/s3.js +4 -3
  54. package/dist/react/VideoPlayer.d.ts +1 -1
  55. package/dist/react/index.d.ts +1 -1
  56. package/dist/types/block-ref.d.ts +2 -0
  57. package/dist/types/block-ref.js +1 -0
  58. package/package.json +16 -7
  59. package/src/cli/compile-registry.mjs +24 -10
  60. package/src/cli/validate-content.mjs +52 -13
  61. package/src/types/block-ref.test.ts +45 -0
  62. package/src/types/block-ref.ts +2 -0
  63. package/templates/astro/starter-content/src/fixtures/atomicBlocks.ts +7 -6
  64. package/templates/astro/starter-content/src/fixtures/composition.ts +3 -2
  65. package/templates/astro/starter-content/src/fixtures/sections.ts +2 -1
  66. package/templates/astro/template/package.json.tmpl +1 -1
  67. package/templates/astro/template/src/layouts/SiteLayout.astro +2 -2
  68. package/templates/common/.agents/skills/aci/SKILL.md +6 -0
  69. package/templates/common/.agents/skills/authoring/SKILL.md +11 -3
  70. package/templates/common/.agents/skills/authoring/references/block-slots.md +3 -0
  71. package/templates/common/.agents/skills/dam-delivery/SKILL.md +228 -0
  72. package/templates/common/.agents/skills/dam-delivery/references/parameter-contract.md +107 -0
  73. package/templates/common/.agents/skills/dam-delivery/references/permalink-grammar.md +131 -0
  74. package/templates/common/.content/pages/{home/_index.json → _index.json} +10 -0
  75. package/templates/common/.content/schemas/site.schema.json +1 -1
  76. package/templates/common/.nvmrc +1 -1
  77. package/templates/common/starter-content/.content/.rules-baseline.json +8 -0
  78. package/templates/common/starter-content/.content/config/site.json +1 -1
  79. package/templates/common/starter-content/.content/content-rules/no-public-resource-paths.json +6 -0
  80. package/templates/common/starter-content/.content/fragments/navbar/_index.json +23 -20
  81. package/templates/common/starter-content/.content/fragments/site-footer/_index.json +59 -56
  82. package/templates/common/starter-content/.content/pages/404/_index.json +1 -0
  83. package/templates/common/starter-content/.content/pages/about/_index.json +3 -0
  84. package/templates/common/starter-content/.content/pages/error/_index.json +1 -0
  85. package/templates/common/starter-content/.content/pages/home/_index.json +9 -0
  86. package/templates/common/starter-content/.content/patterns/components/button.json +55 -0
  87. package/templates/common/starter-content/.content/patterns/components/container.json +62 -0
  88. package/templates/common/starter-content/.content/patterns/components/feature_card.json +30 -0
  89. package/templates/common/starter-content/.content/patterns/components/feature_grid.json +57 -0
  90. package/templates/common/starter-content/.content/patterns/components/home_hero.json +142 -0
  91. package/templates/common/starter-content/.content/patterns/components/personalization_choice.json +42 -0
  92. package/templates/common/starter-content/.content/patterns/components/site-footer.json +203 -0
  93. package/templates/common/starter-content/.content/patterns/components/site-navigation.json +115 -0
  94. package/templates/common/starter-content/.content/patterns/components/stats_bar.json +45 -0
  95. package/templates/common/starter-content/.content/patterns/layouts/default.json +14 -0
  96. package/templates/common/starter-content/.content/schemas/site.schema.json +1 -1
  97. package/templates/nextjs/template/.aci.yaml +10 -0
  98. package/templates/nextjs/template/package.json.tmpl +4 -3
  99. package/templates/nextjs/template/src/app/_components/c15t-bridge.ts +30 -0
  100. package/templates/nextjs/template/src/app/_components/c15t-consent-ui.tsx +37 -0
  101. package/templates/nextjs/template/src/app/_components/consent-manager.css +63 -0
  102. package/templates/nextjs/template/src/app/_components/consent-manager.tsx +76 -0
  103. package/templates/nextjs/template/src/app/layout.tsx +2 -0
  104. package/dist/edge/personalization-core.d.ts +0 -11
  105. package/dist/edge/personalization-core.js +0 -269
  106. package/dist/next/collect-route.d.ts +0 -19
  107. package/dist/next/collect-route.js +0 -459
  108. package/dist/next/envelope-digest.d.ts +0 -21
  109. package/dist/next/envelope-digest.js +0 -58
  110. package/dist/next/middleware.d.ts +0 -40
  111. package/dist/next/middleware.js +0 -475
  112. package/dist/next/personalization-host.d.ts +0 -134
  113. package/dist/next/personalization-host.js +0 -270
  114. package/dist/next/root-layout.d.ts +0 -4
  115. package/dist/next/root-layout.js +0 -5
  116. package/dist/next/vercel-collect-sdk-route.d.ts +0 -27
  117. package/dist/next/vercel-collect-sdk-route.js +0 -181
  118. package/dist/next/vercel-personalization.d.ts +0 -4
  119. package/dist/next/vercel-personalization.js +0 -4
  120. package/dist/next/vercel-s3-personalization.d.ts +0 -79
  121. package/dist/next/vercel-s3-personalization.js +0 -570
  122. /package/templates/common/.content/overlays/audience/preference-blue/pages/{home/_index.json → _index.json} +0 -0
  123. /package/templates/common/.content/overlays/audience/preference-red/pages/{home/_index.json → _index.json} +0 -0
@@ -0,0 +1,228 @@
1
+ ---
2
+ name: dam-delivery
3
+ description: Mint and use signed DAM delivery URLs for on-demand image transforms, and manage public permanent URLs (permalinks) for CMS fields and email. Use when a page or server function needs a resized, cropped, or format-converted image at render time, when choosing between pre-baked derivatives and dynamic transforms, when an image URL must be pasted somewhere that cannot call a mint API (Contentful-class CMS fields, Marketo/SFMC email), or when a delivery URL returns 403/404 unexpectedly.
4
+ ---
5
+
6
+ # DAM Delivery — Signed Transform URLs
7
+
8
+ The DAM serves parameterized image transforms (resize, crop, quality,
9
+ webp/avif) through signed URLs under `/dam/i/{assetId}/…`. The API mints
10
+ them; clients can neither construct nor alter them — every URL is
11
+ HMAC-signed server-side over a closed, validated parameter contract.
12
+
13
+ ## The One Rule: URLs Are Render-Time Artifacts
14
+
15
+ Delivery URLs **expire** (24h maximum by default) and are **banned from
16
+ published payloads**. The publish validator rejects any payload containing
17
+ the `/dam/i/` path marker, exactly as it rejects raw `dam/blobs` pointers.
18
+
19
+ - **DO** mint at render time: SSR handlers, server functions, API-backed
20
+ routes, previews.
21
+ - **DO NOT** bake minted URLs into content JSON, compiled payloads, or
22
+ anything that gets published — a frozen release would carry URLs that
23
+ permanently 404 within a day.
24
+ - For content baked into releases, keep using the pre-baked derivative
25
+ pipeline (`imageSlots` in contracts → `dam/derivatives/…` URLs via
26
+ `imageAttrs()`/`Picture`). Delivery URLs complement it; they do not
27
+ replace it.
28
+
29
+ ## Minting
30
+
31
+ One endpoint mints single or batch; the request is always an array of
32
+ parameter sets and one signed URL comes back per set, in order.
33
+
34
+ ```
35
+ POST /v1/dam/assets/{assetId}/delivery-urls
36
+ {
37
+ "requests": [
38
+ { "width": 800, "height": 600, "fit": "fill", "gravity": "noea",
39
+ "quality": 90, "dpr": 2, "format": "avif" },
40
+ { "width": 400 }
41
+ ]
42
+ }
43
+ ```
44
+
45
+ Response — exactly one of `url`/`error` per item:
46
+
47
+ ```json
48
+ {
49
+ "results": [
50
+ { "url": "/dam/i/ast_…/…", "expiresAt": "2026-08-16T12:00:00Z" },
51
+ { "error": { "status": 400, "code": "dam.delivery.invalid_param",
52
+ "message": "width must be 1-4096", "details": { "param": "width" } } }
53
+ ]
54
+ }
55
+ ```
56
+
57
+ Asset-level failures (asset not found, unavailable, permission denied)
58
+ fail the whole request; parameter failures are per-item so one bad set
59
+ cannot sink its siblings. Batches cap at 100 parameter sets.
60
+
61
+ CLI equivalents:
62
+
63
+ ```bash
64
+ # Single mint from flags
65
+ aci dam delivery-url ast_2E9qL0001aBcDeF --width 800 --height 600 \
66
+ --fit fill --gravity noea --quality 90 --format avif --dpr 2
67
+
68
+ # Batch from a JSON array (prints one URL<TAB>expiresAt line per mint)
69
+ aci dam delivery-url ast_2E9qL0001aBcDeF \
70
+ --params '[{"width":400},{"width":800,"dpr":2}]'
71
+ ```
72
+
73
+ ## The Parameter Contract (Closed)
74
+
75
+ Everything outside this table is rejected. Full ranges, interactions, and
76
+ error codes: `references/parameter-contract.md`.
77
+
78
+ | Param | Values | Default | Notes |
79
+ | --- | --- | --- | --- |
80
+ | `width`, `height` | 1–4096 | none | `width·dpr` and `height·dpr` must each stay ≤ 4096 |
81
+ | `fit` | `fit` \| `fill` | `fit` | requires `width` or `height` |
82
+ | `gravity` | `ce`, `no`, `so`, `ea`, `we`, `noea`, `nowe`, `soea`, `sowe`, or `fp:{x}:{y}` (x,y ∈ [0,1]) | `ce` | non-default gravity requires `fit: "fill"` |
83
+ | `quality` | 1–100 | 82 | |
84
+ | `dpr` | 1–3 | 1 | multiplies output dimensions |
85
+ | `format` | `webp` \| `avif` \| `jpeg` \| `png` | `webp` | becomes the URL extension |
86
+
87
+ Special cases:
88
+
89
+ - **SVG** is passthrough-only: only the empty parameter set `{}` is
90
+ accepted; any transform param is rejected.
91
+ - **Non-image assets** are never served through `/dam/i/*`; use the
92
+ download endpoints instead.
93
+ - Oversized blobs (over the origin's 200 MB streaming envelope) refuse to
94
+ mint with `dam.delivery.source_too_large`.
95
+
96
+ ## Expiry Semantics
97
+
98
+ - Every URL carries a signed expiry; `expiresAt` in the response is that
99
+ instant. After it, the URL 404s at the origin and cached copies die at
100
+ the CDN.
101
+ - The bound is quantized (default 1h): identical requests minted within
102
+ the same quantum return **byte-equal URLs**, so re-minting per render is
103
+ cheap and cache-friendly. Effective lifetime is between `maxTTL − 1h`
104
+ and `maxTTL` (default 24h).
105
+ - A version whose scheduled expiry revokes delivery gets URLs that expire
106
+ exactly at that boundary, which may be much sooner. Never assume a
107
+ fixed TTL — read `expiresAt`.
108
+ - Re-mint instead of caching URLs long-term. Caching a minted URL for the
109
+ duration of one render/request is fine.
110
+
111
+ ## Availability: Why Mints Get Denied
112
+
113
+ Minting serves **only the latest approved, publishable version** and fails
114
+ closed. Expect typed errors (HTTP 422 unless noted):
115
+
116
+ | Code | Meaning |
117
+ | --- | --- |
118
+ | `dam.asset.no_approved_version` | draft-only, embargoed, expired, or usage-restricted versions — nothing publishable |
119
+ | `dam.asset.not_active` | archived or deleted |
120
+ | `dam.asset.legal_hold` | under legal hold |
121
+ | `dam.asset.blocked` | asset-wide publication block (usage restriction) |
122
+ | `dam.permission_denied` | caller lacks download permission (403) |
123
+ | `dam.delivery.not_image` | non-image MIME |
124
+
125
+ There is no fallback to unapproved versions anywhere. Approval changes URL
126
+ identity (new blob → new URLs on next mint); already-minted URLs keep
127
+ serving what they named until they expire.
128
+
129
+ ## Public Permanent URLs (Permalinks)
130
+
131
+ Assets flagged `lifecycle.publicDelivery` also serve through a **permanent,
132
+ public URL** — Sanity-style, deterministic from the assetId alone, no mint
133
+ API in the loop:
134
+
135
+ ```
136
+ https://{site}/dam/a/{assetId}?w=640&h=384&fit=crop&fm=jpg&q=80
137
+ ```
138
+
139
+ It always resolves to the **current approved version** (approve a new
140
+ version and pasted URLs pick it up within ~30s, no re-paste), and it dies
141
+ within the revocation budget (≤ 30s for fresh resolution) on embargo,
142
+ expiry, archive, or unpublish. Transform parameters are
143
+ client-constructable but **validated and quantized** at the edge — `w`/`h`
144
+ snap down to a fixed ladder, `q` to steps of 5 — and unknown parameter
145
+ names (`utm_*`, `mkt_tok`, any ESP decoration) are **stripped, never
146
+ errors**, so tracking-decorated links keep working. Invalid *values* of
147
+ the contract params (`fm=bmp`, `w=abc`) are a 400: author error fails
148
+ loudly. Full grammar, quantization ladder, and Sanity-style examples:
149
+ `references/permalink-grammar.md`.
150
+
151
+ **When to use which lane:**
152
+
153
+ - **Signed delivery URLs** (`/dam/i/*`, above): everything that renders
154
+ server-side and can re-mint — SSR, server functions, previews.
155
+ - **Permalinks** (`/dam/a/*`): places where a URL is pasted once and must
156
+ live for years with no code in the loop — CMS fields, Marketo/SFMC
157
+ email. Requires the asset to be flagged public.
158
+
159
+ ### Getting the URL
160
+
161
+ ```
162
+ GET /v1/dam/assets/{assetId}/permalinks
163
+ ```
164
+
165
+ Returns the base permalink and per-recipe URLs (path + absolute URL), the
166
+ grammar constants (width ladder, quality step, defaults), the
167
+ `publicDelivery` flag state, and a `warning` field (below). CLI:
168
+
169
+ ```bash
170
+ aci dam permalink ast_2E9qL0001aBcDeF # base URL
171
+ aci dam permalink ast_2E9qL0001aBcDeF --recipe email-hero
172
+ ```
173
+
174
+ The permalink path is deterministic, so the endpoint answers even before
175
+ the flag is flipped — check `publicDelivery` to know whether it resolves.
176
+
177
+ ### Flipping the Flag
178
+
179
+ `publicDelivery` is patched through the governance plane:
180
+
181
+ ```
182
+ PATCH /v1/dam/assets/{assetId}/governance
183
+ { "expectedHead": "…", "lifecycle": { "publicDelivery": true } }
184
+ ```
185
+
186
+ Flipping **on** is a "make public" act and passes the eligibility gate
187
+ against the version the permalink would serve. A refusal is a typed 400,
188
+ code `dam.delivery.public_flip_denied`, with `details.reason` carrying the
189
+ gate's own reason — e.g. `no_approved_version`, `not_image`, a
190
+ `block_new_usage` restriction, a publication-less license, or an expiry
191
+ the lane cannot enforce. Fix the governance fact; do not retry blind.
192
+ Flipping **off** is a plain patch and kills public serving within the
193
+ revocation budget.
194
+
195
+ ### Doctrine for Email and CMS (the two rules)
196
+
197
+ 1. **Paste the permalink; never let the ESP import the bytes.** Many email
198
+ tools offer to "import" or "copy" images to their own CDN — refuse. An
199
+ imported copy escapes governance and revocation forever; the permalink
200
+ is the governed pointer, and it is precisely the ≤ 30s revocation
201
+ budget that makes it safe to paste.
202
+ 2. **Assets needing `block_new_usage` semantics do not get flagged
203
+ public.** The permalink lane cannot tell an old placement from a new
204
+ one, so `block_new_usage` cannot be enforced there — the flip gate
205
+ refuses such assets at authoring time. Use `unpublish` or
206
+ `block_all_usage` expiry actions (the lane enforces those boundaries
207
+ exactly), or keep the asset on the signed lane. If `block_new_usage`
208
+ governance lands on an *already-public* asset, the permalink keeps
209
+ serving and the permalinks API surfaces
210
+ `warning: "block_new_usage_in_force"` — treat that warning as "review
211
+ this asset's public status now".
212
+
213
+ Email format tip: Outlook desktop cannot render WebP — use `fm=jpg` or
214
+ `fm=png` in email permalinks.
215
+
216
+ ## Draft Previews (Management Surfaces Only)
217
+
218
+ Drafts are reachable only by naming their version — authenticated, never
219
+ CDN-cached, never via `/dam/i/*`:
220
+
221
+ ```
222
+ GET /v1/dam/assets/{assetId}/versions/{versionId}/preview?width=512
223
+ GET /v1/dam/assets/{assetId}/versions/{versionId}/download
224
+ ```
225
+
226
+ The preview takes the same transform parameters as query strings and
227
+ streams the transformed image with `Cache-Control: private, no-store`.
228
+ Use these for editorial UIs; never for site rendering.
@@ -0,0 +1,107 @@
1
+ # Delivery Parameter Contract Reference
2
+
3
+ The mint API accepts exactly this parameter set. Anything else — unknown
4
+ fields, out-of-range values, invalid combinations — is rejected with a
5
+ typed error. Parameters are canonicalized server-side (fixed order,
6
+ defaults elided) so numerically equal requests produce byte-equal URLs.
7
+
8
+ ## Parameters
9
+
10
+ ### width, height
11
+
12
+ - Integers, 1–4096. Omit (or 0) for "not constrained".
13
+ - The **effective** output dimension is capped, not the raw value:
14
+ `width·dpr ≤ 4096` and `height·dpr ≤ 4096`. `width: 4096, dpr: 2` is
15
+ rejected even though each value is individually legal.
16
+ - Supplying only one dimension scales the other to preserve aspect ratio.
17
+
18
+ ### fit
19
+
20
+ - `"fit"` (default): scale to fit inside width×height, preserving aspect
21
+ ratio, never cropping.
22
+ - `"fill"`: scale and crop to exactly fill width×height.
23
+ - Requires at least one of `width`/`height` when set explicitly.
24
+
25
+ ### gravity
26
+
27
+ - Controls which region survives a `fill` crop.
28
+ - Compass points: `ce` (center, default), `no`, `so`, `ea`, `we`, `noea`,
29
+ `nowe`, `soea`, `sowe`.
30
+ - Focal point: `fp:{x}:{y}` with x and y in `[0,1]` — `fp:0.5:0.25` keeps
31
+ the point half across, a quarter down. Numerically equal focal points
32
+ canonicalize identically (`fp:0.50:0.250` ≡ `fp:0.5:0.25`).
33
+ - **Non-default gravity requires `fit: "fill"`** — gravity only means
34
+ something when cropping. `{ "width": 100, "gravity": "noea" }` is
35
+ rejected; add `"fit": "fill"`.
36
+
37
+ ### quality
38
+
39
+ - Integer 1–100, default 82. Applies to lossy output formats.
40
+
41
+ ### dpr
42
+
43
+ - Integer 1–3, default 1. Multiplies the output dimensions for
44
+ high-density displays; see the effective-dimension cap above.
45
+
46
+ ### format
47
+
48
+ - `"webp"` (default), `"avif"`, `"jpeg"`, `"png"`. Becomes the URL's file
49
+ extension.
50
+
51
+ ## Special Cases
52
+
53
+ - **SVG assets**: passthrough only. The empty parameter set `{}` mints a
54
+ passthrough URL serving the original SVG; any transform parameter is
55
+ rejected with `dam.delivery.svg_transform`.
56
+ - **Non-image assets**: never served through delivery URLs
57
+ (`dam.delivery.not_image`). Use the download endpoints.
58
+ - **Oversized blobs**: blobs over the origin's 200 MB streaming envelope
59
+ refuse to mint (`dam.delivery.source_too_large`).
60
+
61
+ ## Error Codes
62
+
63
+ Per-item (inside `results[i].error`, HTTP 200 on the envelope):
64
+
65
+ | Code | Cause |
66
+ | --- | --- |
67
+ | `dam.delivery.invalid_param` | range or combination violation; `details.param` names the offender |
68
+ | `dam.delivery.svg_transform` | transform params on an SVG asset |
69
+ | `dam.delivery.not_image` | non-image MIME |
70
+ | `dam.delivery.source_too_large` | blob over the streaming envelope |
71
+
72
+ Request-level (problem+json):
73
+
74
+ | Code | HTTP | Cause |
75
+ | --- | --- | --- |
76
+ | `dam.asset.no_approved_version` | 422 | no publishable approved version (draft-only, embargoed, expired, or version-scoped restriction) |
77
+ | `dam.asset.not_active` | 422 | archived / deleted asset |
78
+ | `dam.asset.legal_hold` | 422 | legal hold active |
79
+ | `dam.asset.blocked` | 422 | asset-wide publication block (usage restriction) |
80
+ | `dam.permission_denied` | 403 | caller lacks download permission |
81
+ | `NOT_FOUND` | 404 | unknown asset |
82
+
83
+ ## Worked Examples
84
+
85
+ ```jsonc
86
+ // Hero image, art-directed crop on a focal point, retina
87
+ { "width": 1440, "height": 600, "fit": "fill", "gravity": "fp:0.62:0.3", "dpr": 2 }
88
+
89
+ // Thumbnail grid cell, default gravity center-crop
90
+ { "width": 320, "height": 320, "fit": "fill" }
91
+
92
+ // Constrain width only, keep aspect ratio, high quality JPEG
93
+ { "width": 1200, "quality": 95, "format": "jpeg" }
94
+
95
+ // SVG passthrough (the ONLY valid SVG request)
96
+ { }
97
+ ```
98
+
99
+ Rejected examples and why:
100
+
101
+ ```jsonc
102
+ { "width": 4096, "dpr": 3 } // effective width 12288 > 4096
103
+ { "width": 100, "gravity": "noea" } // gravity requires fit "fill"
104
+ { "fit": "fill" } // fit requires width or height
105
+ { "gravity": "fp:1.5:0" } // focal coords must be in [0,1]
106
+ { "format": "gif" } // outside the closed format set
107
+ ```
@@ -0,0 +1,131 @@
1
+ # Public Permalink Grammar Reference (`/dam/a/*`)
2
+
3
+ The public permanent URL for an asset:
4
+
5
+ ```
6
+ /dam/a/{assetId}[/{slug-or-recipe}[.{ext}]]?w=400&h=300&fit=crop&fm=jpg&q=80
7
+ ```
8
+
9
+ (The query above shows the *shape* only — `w=400`/`h=300` are off-ladder
10
+ and would serve 384x256 after snapping. The recommended examples below
11
+ sit exactly on rungs; pick rung values when you control the URL.)
12
+
13
+ Permanent, deterministic from the assetId alone, resolving to the current
14
+ approved version while the asset is flagged `publicDelivery` and available
15
+ for delivery. Everything below is edge-enforced; the API is never in the
16
+ per-view path. The grammar constants (ladder, steps, defaults) are also
17
+ served machine-readably by `GET /v1/dam/assets/{assetId}/permalinks`.
18
+
19
+ ## Parameters (open but quantized)
20
+
21
+ Names are Sanity-compatible. Unknown parameter *names* are stripped —
22
+ never errors — so ESP tracking decorations (`utm_*`, `mkt_tok`) cannot
23
+ break an image. Invalid *values* of these contract params are a 400.
24
+
25
+ | Param | Values | Default | Quantization |
26
+ | --- | --- | --- | --- |
27
+ | `w`, `h` | positive integers | none | snapped DOWN to the ladder `16, 32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840`; below 16 snaps up to 16, above 3840 down to 3840 |
28
+ | `fit` | `crop` \| `max` | `max` | `crop` requires **both** `w` and `h` |
29
+ | `fm` | `webp` \| `avif` \| `jpg` \| `png` | `webp` | — |
30
+ | `q` | 1–100 | absent → renders at 82 | snapped DOWN to steps of 5; below 5 snaps up to 5; an explicit `q=80` serves 80, never the default — explicit quality is honored after snapping, not silently inflated |
31
+
32
+ The ladder is the Next.js image `srcset` ladder verbatim, so breakpoints
33
+ authored against any modern framework land on rungs instead of snapping
34
+ visibly. Quantization is deliberate abuse control: it bounds the
35
+ transform cache keyspace per asset.
36
+
37
+ Deliberately **not** in the grammar:
38
+
39
+ - No `dpr` — pick the right `w` rung instead (the ladder already covers
40
+ device densities the way `srcset` consumes them).
41
+ - No `Accept`-header format negotiation — format is always the explicit
42
+ `fm`. For email use `fm=jpg` or `fm=png` (Outlook desktop has no WebP).
43
+ - `crop=focal` is reserved, not shipped.
44
+
45
+ ## Canonical Form, Slugs, and Recipes
46
+
47
+ The canonical URL is **extension-less**: `/dam/a/{assetId}?…`. The
48
+ optional second path segment (extension stripped) is either:
49
+
50
+ - **A registered public recipe** (`/dam/a/{id}/email-hero.jpg`): expands
51
+ server-side into a fixed parameter set from the public recipe registry.
52
+ Recipe URLs are a second canonical form — no redirect hop. Adding
53
+ transform query params alongside a recipe is a 400 (ambiguity is author
54
+ error); ESP decorations are stripped first, as everywhere. The
55
+ extension is readability only — the recipe's format wins. The registry
56
+ ships empty; `GET …/permalinks` lists what is registered.
57
+ - **Anything else — a readable slug** (`/dam/a/{id}/spring-sale.jpg`):
58
+ answers a 301 to the canonical no-slug URL with the same (canonicalized)
59
+ query. Use the slug form when a tool insists on an extension-bearing
60
+ image URL; it costs one cached redirect and is stable forever.
61
+
62
+ ## Response Semantics
63
+
64
+ | Response | Meaning |
65
+ | --- | --- |
66
+ | 302 → `/dam/i/…` | serving: a short-lived signed inner URL (≤ 2h), re-minted per redirect |
67
+ | 301 | slug normalization to the canonical URL |
68
+ | 400 `no-store` | invalid value of a contract param, transform params on an SVG or alongside a recipe, malformed assetId |
69
+ | 404 `no-store` | asset is not publicly served: flag off, not yet approved, embargoed, expired, archived, legal hold |
70
+ | 503 `no-store` | resolution state unreadable — fail-closed, never a guess |
71
+
72
+ The 302 carries a short cache TTL (default 30s) — the revocation dial.
73
+ On unpublish/archive/embargo/revoking-expiry, fresh resolution dies
74
+ within ≤ 30s; a flip **to** serving may additionally ride a ~10s
75
+ error-caching echo. Approving a new version re-points the permalink
76
+ within the same budget; already-fetched inner URLs keep serving the bytes
77
+ they named until they expire (≤ 2h).
78
+
79
+ ## SVG
80
+
81
+ SVG permalinks are passthrough-only: the bare permalink 302s to the SVG
82
+ passthrough; any transform parameter (or recipe) is a 400.
83
+
84
+ ## Sanity-Style Examples
85
+
86
+ ```html
87
+ <!-- Fixed-size email hero, JPEG for Outlook, explicit quality.
88
+ 640 and 384 are ladder rungs; keep the HTML width/height on the same
89
+ rungs so the served crop and the layout box agree exactly -->
90
+ <img src="https://www.example.com/dam/a/ast_2E9qL0001aBcDeF?w=640&h=384&fit=crop&fm=jpg&q=80"
91
+ width="640" height="384" alt="Spring sale hero">
92
+
93
+ <!-- Responsive srcset on ladder rungs (no dpr param — use w) -->
94
+ <img src="https://www.example.com/dam/a/ast_2E9qL0001aBcDeF?w=828"
95
+ srcset="https://www.example.com/dam/a/ast_2E9qL0001aBcDeF?w=640 640w,
96
+ https://www.example.com/dam/a/ast_2E9qL0001aBcDeF?w=828 828w,
97
+ https://www.example.com/dam/a/ast_2E9qL0001aBcDeF?w=1200 1200w"
98
+ sizes="(max-width: 640px) 100vw, 640px" alt="…">
99
+
100
+ <!-- Extension-bearing slug form for ESP editors that validate by extension:
101
+ one cached 301 to the canonical URL, stable forever -->
102
+ https://www.example.com/dam/a/ast_2E9qL0001aBcDeF/spring-sale.jpg?w=640&fm=jpg
103
+ ```
104
+
105
+ Quantization in practice: `?w=333&q=77` and `?w=256&q=75` serve the
106
+ identical rendition (333 snaps to 256, 77 snaps to 75) — do not expect
107
+ pixel-exact widths off the ladder.
108
+
109
+ ## The Flag, the Gate, and the Warning
110
+
111
+ - `lifecycle.publicDelivery` (asset-level, governance PATCH) is the one
112
+ admission act. Flip-on passes the eligibility gate against the version
113
+ the permalink would serve; refusal is a typed 400
114
+ `dam.delivery.public_flip_denied` with `details.reason` (e.g.
115
+ `no_approved_version`, `not_image`, `source_too_large`, a
116
+ `block_new_usage` restriction, a publication-less license, an
117
+ unenforceable expiry action).
118
+ - Version selection is automatic and fail-closed: the latest approved,
119
+ deliverable version whose license (when present) grants `publication`
120
+ and whose `expiresAt` (when set) the lane can actually enforce
121
+ (`unpublish`/`block_all_usage`). Versions failing those rows are
122
+ skipped in favor of the previous passing one.
123
+ - `GET …/permalinks` returns `warning: "block_new_usage_in_force"` when
124
+ `block_new_usage` governance landed on an already-public asset (the
125
+ flip gate refuses it up front, so this only happens for governance
126
+ added after the flip). The permalink keeps serving — the lane cannot
127
+ distinguish existing placements from new ones — so treat the warning as
128
+ a prompt to review the asset's public status.
129
+ - Legal hold: flipping a legal-held asset on succeeds but the permalink
130
+ 404s for the hold's duration and starts serving the moment the hold
131
+ lifts — no re-flip needed.
@@ -11,6 +11,7 @@
11
11
  "regions": {
12
12
  "main": [
13
13
  {
14
+ "$type": "component",
14
15
  "id": "hero",
15
16
  "component": "home_hero",
16
17
  "props": {
@@ -27,6 +28,7 @@
27
28
  }
28
29
  },
29
30
  {
31
+ "$type": "component",
30
32
  "id": "why-meridian",
31
33
  "component": "feature_grid",
32
34
  "props": {
@@ -34,6 +36,7 @@
34
36
  "subtitle": "Engineered for the edges of the world.",
35
37
  "features": [
36
38
  {
39
+ "$type": "component",
37
40
  "id": "global-coverage",
38
41
  "component": "feature_card",
39
42
  "props": {
@@ -43,6 +46,7 @@
43
46
  }
44
47
  },
45
48
  {
49
+ "$type": "component",
46
50
  "id": "low-latency",
47
51
  "component": "feature_card",
48
52
  "props": {
@@ -52,6 +56,7 @@
52
56
  }
53
57
  },
54
58
  {
59
+ "$type": "component",
55
60
  "id": "easy-setup",
56
61
  "component": "feature_card",
57
62
  "props": {
@@ -64,6 +69,7 @@
64
69
  }
65
70
  },
66
71
  {
72
+ "$type": "component",
67
73
  "id": "personalization-choice",
68
74
  "component": "personalization_choice",
69
75
  "props": {
@@ -75,6 +81,7 @@
75
81
  }
76
82
  },
77
83
  {
84
+ "$type": "component",
78
85
  "id": "stats",
79
86
  "component": "stats_bar",
80
87
  "props": {
@@ -87,6 +94,7 @@
87
94
  }
88
95
  },
89
96
  {
97
+ "$type": "component",
90
98
  "id": "cta",
91
99
  "component": "container",
92
100
  "props": {
@@ -94,6 +102,7 @@
94
102
  "className": "items-center justify-center text-center",
95
103
  "content": [
96
104
  {
105
+ "$type": "component",
97
106
  "id": "cta-primary",
98
107
  "component": "button",
99
108
  "props": {
@@ -105,6 +114,7 @@
105
114
  }
106
115
  },
107
116
  {
117
+ "$type": "component",
108
118
  "id": "cta-secondary",
109
119
  "component": "button",
110
120
  "props": {
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "overlayResolution": {
29
29
  "type": "array",
30
- "items": { "enum": ["locale", "audience"] }
30
+ "items": { "enum": ["locale", "audience", "segment", "experiment", "experimentArm"] }
31
31
  },
32
32
  "brandHref": { "type": "string" },
33
33
  "brandLogo": {
@@ -1 +1 @@
1
- 22
1
+ 24
@@ -0,0 +1,8 @@
1
+ {
2
+ "version": 1,
3
+ "rules": {
4
+ "no-public-resource-paths": [
5
+ "/assets/meridian-logo.svg"
6
+ ]
7
+ }
8
+ }
@@ -10,7 +10,7 @@
10
10
  "notFound": "/404/",
11
11
  "catchAll": "/error/"
12
12
  },
13
- "overlayResolution": ["locale", "audience"],
13
+ "overlayResolution": ["locale", "audience", "segment"],
14
14
  "brandHref": "/",
15
15
  "brandLogo": {
16
16
  "src": "/assets/meridian-logo.svg",
@@ -0,0 +1,6 @@
1
+ {
2
+ "kind": "forbid-string-pattern",
3
+ "pattern": "^/[^\\s]+\\.(png|jpe?g|webp|gif|svg|avif|mp4|webm|ico)$",
4
+ "appliesTo": "all-string-props",
5
+ "message": "Content must not reference front-end public/ resources."
6
+ }
@@ -1,25 +1,28 @@
1
1
  {
2
2
  "$type": "fragment",
3
3
  "fragmentId": "navbar",
4
- "component": "site-navigation",
5
- "props": {
6
- "brandHref": "/",
7
- "brandLabel": "Meridian",
8
- "brandLogo": {
9
- "src": "/assets/meridian-logo.svg",
10
- "alt": "Meridian"
11
- },
12
- "navigation": [
13
- { "id": "home", "label": "Home", "href": "/" },
14
- { "id": "plans", "label": "Plans", "href": "/plans/" },
15
- { "id": "devices", "label": "Devices", "href": "/devices/" },
16
- { "id": "business", "label": "Business", "href": "/business/" },
17
- { "id": "blog", "label": "Blog", "href": "/blog/" },
18
- { "id": "about", "label": "About", "href": "/about/" }
19
- ],
20
- "utilities": [
21
- { "id": "sign-in", "label": "Sign in", "href": "/sign-in/", "variant": "ghost" },
22
- { "id": "contact-sales", "label": "Contact sales", "href": "/contact-sales/", "variant": "primary" }
23
- ]
4
+ "block": {
5
+ "$type": "component",
6
+ "component": "site-navigation",
7
+ "props": {
8
+ "brandHref": "/",
9
+ "brandLabel": "Meridian",
10
+ "brandLogo": {
11
+ "src": "/assets/meridian-logo.svg",
12
+ "alt": "Meridian"
13
+ },
14
+ "navigation": [
15
+ { "id": "home", "label": "Home", "href": "/" },
16
+ { "id": "plans", "label": "Plans", "href": "/plans/" },
17
+ { "id": "devices", "label": "Devices", "href": "/devices/" },
18
+ { "id": "business", "label": "Business", "href": "/business/" },
19
+ { "id": "blog", "label": "Blog", "href": "/blog/" },
20
+ { "id": "about", "label": "About", "href": "/about/" }
21
+ ],
22
+ "utilities": [
23
+ { "id": "sign-in", "label": "Sign in", "href": "/sign-in/", "variant": "ghost" },
24
+ { "id": "contact-sales", "label": "Contact sales", "href": "/contact-sales/", "variant": "primary" }
25
+ ]
26
+ }
24
27
  }
25
28
  }