@unitninety/site-kit 0.0.0-stage → 1.6.2

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 (206) hide show
  1. package/README.md +30 -2
  2. package/package.json +79 -4
  3. package/src/bridge/DraftBlocks.tsx +70 -0
  4. package/src/bridge/DraftEntries.tsx +41 -0
  5. package/src/bridge/PreviewCommitBoundary.tsx +19 -0
  6. package/src/bridge/U90Bridge.tsx +54 -0
  7. package/src/bridge/caret.ts +317 -0
  8. package/src/bridge/client.tsx +66 -0
  9. package/src/bridge/createBridge.ts +1015 -0
  10. package/src/bridge/hooks.tsx +56 -0
  11. package/src/bridge/index.ts +9 -0
  12. package/src/bridge/lexical.ts +177 -0
  13. package/src/bridge/lexicalDom.ts +212 -0
  14. package/src/bridge/preserveBlockMoves.ts +88 -0
  15. package/src/bridge/preview.ts +179 -0
  16. package/src/bridge/protocol.ts +273 -0
  17. package/src/bridge/registry.ts +30 -0
  18. package/src/bridge/styles.ts +137 -0
  19. package/src/components/DateText.tsx +9 -0
  20. package/src/components/Entries.tsx +16 -0
  21. package/src/components/File.tsx +9 -0
  22. package/src/components/Image.tsx +30 -0
  23. package/src/components/Link.tsx +24 -0
  24. package/src/components/NumberText.tsx +9 -0
  25. package/src/components/RichText.tsx +47 -0
  26. package/src/components/Text.tsx +15 -0
  27. package/src/components/Video.tsx +12 -0
  28. package/src/components/VideoPlayback.tsx +19 -0
  29. package/src/components/draft.ts +9 -0
  30. package/src/components/imageProps.ts +26 -0
  31. package/src/components/index.ts +13 -0
  32. package/src/components/videoProps.ts +6 -0
  33. package/src/elements/containers/group.ts +33 -0
  34. package/src/elements/containers/list.ts +31 -0
  35. package/src/elements/containers/zone.ts +41 -0
  36. package/src/elements/declaration.ts +118 -0
  37. package/src/elements/define.ts +195 -0
  38. package/src/elements/ids.ts +50 -0
  39. package/src/elements/index.ts +35 -0
  40. package/src/elements/jsonSchema.ts +36 -0
  41. package/src/elements/leaf.ts +98 -0
  42. package/src/elements/leaves/boolean.ts +30 -0
  43. package/src/elements/leaves/choice.ts +53 -0
  44. package/src/elements/leaves/date.ts +39 -0
  45. package/src/elements/leaves/file.ts +56 -0
  46. package/src/elements/leaves/image.ts +160 -0
  47. package/src/elements/leaves/link.ts +149 -0
  48. package/src/elements/leaves/number.ts +52 -0
  49. package/src/elements/leaves/query.ts +101 -0
  50. package/src/elements/leaves/relation.ts +69 -0
  51. package/src/elements/leaves/rich-text.ts +155 -0
  52. package/src/elements/leaves/selection.ts +40 -0
  53. package/src/elements/leaves/single.ts +29 -0
  54. package/src/elements/leaves/text.ts +103 -0
  55. package/src/elements/leaves/video.ts +108 -0
  56. package/src/elements/ops/expand.ts +94 -0
  57. package/src/elements/ops/overlay.ts +59 -0
  58. package/src/elements/ops/refs.ts +45 -0
  59. package/src/elements/ops/seed.ts +185 -0
  60. package/src/elements/ops/text.ts +20 -0
  61. package/src/elements/ops/translatable.ts +19 -0
  62. package/src/elements/ops/upgrade.ts +43 -0
  63. package/src/elements/ops/validate.ts +56 -0
  64. package/src/elements/order.ts +10 -0
  65. package/src/elements/path.ts +43 -0
  66. package/src/elements/prefix.ts +2 -0
  67. package/src/elements/registry.ts +48 -0
  68. package/src/elements/tokens.ts +14 -0
  69. package/src/elements/walk.ts +42 -0
  70. package/src/features/client/AskAiProvider.tsx +551 -0
  71. package/src/features/client/AskAiWidget.tsx +222 -0
  72. package/src/features/client/ConsentRuntime.tsx +95 -0
  73. package/src/features/client/FeedbackProvider.tsx +231 -0
  74. package/src/features/client/FeedbackWidget.tsx +101 -0
  75. package/src/features/client/PageReporter.tsx +26 -0
  76. package/src/features/client/README.md +131 -0
  77. package/src/features/client/SiteFeatures.tsx +250 -0
  78. package/src/features/client/askAiRecovery.ts +92 -0
  79. package/src/features/client/askAiStream.ts +59 -0
  80. package/src/features/client/context.tsx +24 -0
  81. package/src/features/client/index.ts +24 -0
  82. package/src/features/client/overlay.tsx +129 -0
  83. package/src/features/client/styles.css +337 -0
  84. package/src/features/client/transport.ts +310 -0
  85. package/src/features/client/useAskAI.ts +45 -0
  86. package/src/features/client/useConsent.ts +15 -0
  87. package/src/features/client/useFeedback.ts +33 -0
  88. package/src/features/client/useSiteSearch.ts +50 -0
  89. package/src/features/client/verification.ts +141 -0
  90. package/src/features/consent/README.md +70 -0
  91. package/src/features/consent/cookieScript.ts +53 -0
  92. package/src/features/consent/custom.ts +89 -0
  93. package/src/features/consent/defaults.ts +40 -0
  94. package/src/features/consent/drivers.ts +233 -0
  95. package/src/features/consent/loader.ts +248 -0
  96. package/src/features/consent/registry.ts +398 -0
  97. package/src/features/contracts/askAi.ts +166 -0
  98. package/src/features/contracts/askAiSettings.ts +42 -0
  99. package/src/features/contracts/base.ts +107 -0
  100. package/src/features/contracts/consent.ts +150 -0
  101. package/src/features/contracts/feedback.ts +22 -0
  102. package/src/features/contracts/feedbackSettings.ts +49 -0
  103. package/src/features/contracts/index.ts +61 -0
  104. package/src/features/contracts/knowledge.ts +99 -0
  105. package/src/features/contracts/knowledgePolicy.ts +249 -0
  106. package/src/features/contracts/search.ts +24 -0
  107. package/src/features/contracts/verification.ts +52 -0
  108. package/src/features/contracts/verificationConfig.ts +9 -0
  109. package/src/features/server/ConsentDefaults.tsx +15 -0
  110. package/src/features/server/README.md +185 -0
  111. package/src/features/server/gateway.ts +318 -0
  112. package/src/features/server/http.ts +179 -0
  113. package/src/features/server/index.ts +6 -0
  114. package/src/features/server/session.ts +23 -0
  115. package/src/features/server/verification.ts +39 -0
  116. package/src/features/server/verifyVisitor.ts +69 -0
  117. package/src/languages/config.d.mts +5 -0
  118. package/src/languages/config.mjs +53 -0
  119. package/src/languages/decide.ts +72 -0
  120. package/src/languages/index.ts +4 -0
  121. package/src/languages/proxy.ts +108 -0
  122. package/src/languages/routing.ts +93 -0
  123. package/src/languages/wire.ts +19 -0
  124. package/src/legacy/bridge.tsx +1987 -0
  125. package/src/legacy/caret.ts +246 -0
  126. package/src/legacy/cli.mjs +189 -0
  127. package/src/legacy/content.ts +993 -0
  128. package/src/legacy/contract.ts +664 -0
  129. package/src/legacy/defineBlocks.ts +1602 -0
  130. package/src/legacy/defineSite.ts +623 -0
  131. package/src/legacy/defineSite.types.ts +270 -0
  132. package/src/legacy/draft.tsx +128 -0
  133. package/src/legacy/image.ts +433 -0
  134. package/src/legacy/index.ts +209 -0
  135. package/src/legacy/language-switcher.tsx +58 -0
  136. package/src/legacy/languageHints.ts +153 -0
  137. package/src/legacy/languages/fields.ts +131 -0
  138. package/src/legacy/languages/hash.ts +98 -0
  139. package/src/legacy/languages/health.ts +97 -0
  140. package/src/legacy/languages/identity.ts +247 -0
  141. package/src/legacy/languages/links.ts +81 -0
  142. package/src/legacy/languages/resolution.ts +56 -0
  143. package/src/legacy/languages/routing.ts +169 -0
  144. package/src/legacy/languages/runtime.ts +216 -0
  145. package/src/legacy/languages/seo.ts +95 -0
  146. package/src/legacy/languages/settings.ts +211 -0
  147. package/src/legacy/languages/types.ts +85 -0
  148. package/src/legacy/languages.ts +8 -0
  149. package/src/legacy/lexical.ts +170 -0
  150. package/src/legacy/lexicalDom.ts +166 -0
  151. package/src/legacy/media.ts +272 -0
  152. package/src/legacy/nav.ts +176 -0
  153. package/src/legacy/preview.ts +79 -0
  154. package/src/legacy/redirects.ts +474 -0
  155. package/src/legacy/richText.tsx +104 -0
  156. package/src/legacy/routes.ts +414 -0
  157. package/src/legacy/seo.ts +327 -0
  158. package/src/legacy/shape.ts +412 -0
  159. package/src/legacy/uploads.ts +233 -0
  160. package/src/legacy/verification.ts +42 -0
  161. package/src/preview-lock/contracts.ts +42 -0
  162. package/src/preview-lock/index.ts +3 -0
  163. package/src/preview-lock/screen.tsx +244 -0
  164. package/src/preview-lock/server.ts +416 -0
  165. package/src/redirects/config.d.mts +5 -0
  166. package/src/redirects/config.mjs +22 -0
  167. package/src/redirects/index.ts +3 -0
  168. package/src/redirects/location.ts +31 -0
  169. package/src/redirects/match.ts +199 -0
  170. package/src/redirects/proxy.ts +91 -0
  171. package/src/redirects/wire.ts +30 -0
  172. package/src/seo/jsonLd.ts +70 -0
  173. package/src/seo/metadata.ts +127 -0
  174. package/src/seo/robots.ts +14 -0
  175. package/src/seo/sitemap.ts +22 -0
  176. package/src/seo/title.ts +22 -0
  177. package/src/server/adapters.ts +73 -0
  178. package/src/server/createU90.ts +96 -0
  179. package/src/server/handlers.ts +164 -0
  180. package/src/server/index.ts +6 -0
  181. package/src/server/read.ts +207 -0
  182. package/src/server/render.tsx +46 -0
  183. package/src/server/routes.tsx +171 -0
  184. package/src/site/assemble.ts +109 -0
  185. package/src/site/defineSite.ts +132 -0
  186. package/src/site/documents.ts +18 -0
  187. package/src/site/fields.ts +59 -0
  188. package/src/site/index.ts +14 -0
  189. package/src/site/replies.ts +118 -0
  190. package/src/site/store.ts +45 -0
  191. package/src/site/types.ts +75 -0
  192. package/src/site/version.ts +1 -0
  193. package/src/site/wire.ts +125 -0
  194. package/src/switcher/LanguageSwitcher.tsx +42 -0
  195. package/src/test-preview/README.md +90 -0
  196. package/src/test-preview/binding.ts +54 -0
  197. package/src/test-preview/bridge.ts +16 -0
  198. package/src/test-preview/guard.ts +186 -0
  199. package/src/test-preview/media.ts +23 -0
  200. package/src/test-preview/protocol.d.mts +67 -0
  201. package/src/test-preview/protocol.mjs +222 -0
  202. package/src/test-preview/runtime.ts +168 -0
  203. package/src/test-preview/server.ts +10 -0
  204. package/src/testing/fake.ts +148 -0
  205. package/src/testing/index.ts +2 -0
  206. package/src/testing/seed.ts +2 -0
@@ -0,0 +1,222 @@
1
+ import { createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto'
2
+ import { isIP } from 'node:net'
3
+
4
+ const MAX_TOKEN_BYTES = 8192
5
+ const CERTIFICATE_PREFIX = 'u90tc1'
6
+ const SESSION_PREFIX = 'u90tp1'
7
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
8
+ const CERTIFICATE_FIELDS = ['version', 'environmentId', 'platformOrigin', 'sourceOrigin', 'publicKey', 'issuedAt', 'expiresAt']
9
+ const SESSION_FIELDS = ['version', 'purpose', 'tenantPublicId', 'nonce', 'issuedAt', 'expiresAt']
10
+
11
+ function invalid() {
12
+ throw new Error('Invalid test preview credential')
13
+ }
14
+
15
+ // Prevent crypto/parser errors from exposing supplied credential or key material.
16
+ function bounded(operation) {
17
+ try {
18
+ return operation()
19
+ } catch {
20
+ return invalid()
21
+ }
22
+ }
23
+
24
+ function record(value, fields) {
25
+ if (!value || typeof value !== 'object' || Array.isArray(value)) invalid()
26
+ const keys = Object.keys(value)
27
+ if (keys.length !== fields.length || fields.some(field => !Object.hasOwn(value, field))) invalid()
28
+ return value
29
+ }
30
+
31
+ function shortString(value) {
32
+ if (typeof value !== 'string' || value.length < 1 || value.length > 256 || value.trim() !== value || /[\u0000-\u001f\u007f]/.test(value)) invalid()
33
+ }
34
+
35
+ function integer(value) {
36
+ if (!Number.isSafeInteger(value) || value < 0) invalid()
37
+ }
38
+
39
+ function lifetime(value, maximum, now) {
40
+ integer(value.issuedAt)
41
+ integer(value.expiresAt)
42
+ if (value.expiresAt <= value.issuedAt || value.expiresAt - value.issuedAt > maximum) invalid()
43
+ if (now !== undefined) {
44
+ integer(now)
45
+ if (value.issuedAt > now || value.expiresAt <= now) invalid()
46
+ }
47
+ }
48
+
49
+ function privateIPv4(address) {
50
+ const [a, b] = address.split('.').map(Number)
51
+ return a === 0 || a === 10 || a === 127 || a >= 224 ||
52
+ (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) ||
53
+ (a === 172 && b >= 16 && b <= 31) || (a === 192 && (b === 168 || b === 0)) ||
54
+ (a === 198 && (b === 18 || b === 19))
55
+ }
56
+
57
+ function privateIPv6(address) {
58
+ const halves = address.split('::')
59
+ const left = halves[0] ? halves[0].split(':') : []
60
+ const right = halves.length === 2 && halves[1] ? halves[1].split(':') : []
61
+ const groups = [...left, ...Array(8 - left.length - right.length).fill('0'), ...right].map(group => parseInt(group, 16))
62
+ // URL canonicalization renders IPv4-mapped literals in hexadecimal form.
63
+ if (groups.slice(0, 5).every(group => group === 0) && (groups[5] === 0xffff || groups[5] === 0)) {
64
+ return privateIPv4(`${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}`)
65
+ }
66
+ return (groups[0] & 0xfe00) === 0xfc00 || (groups[0] & 0xffc0) === 0xfe80 ||
67
+ (groups[0] & 0xffc0) === 0xfec0 || (groups[0] & 0xff00) === 0xff00
68
+ }
69
+
70
+ function origin(value) {
71
+ if (typeof value !== 'string' || value.length > 2048) invalid()
72
+ const url = new URL(value)
73
+ if (url.protocol !== 'https:' || url.username || url.password || url.origin !== value) invalid()
74
+ const hostname = url.hostname.replace(/^\[|\]$/g, '')
75
+ const localName = hostname.replace(/\.$/, '')
76
+ if (localName === 'localhost' || localName.endsWith('.localhost')) invalid()
77
+ const family = isIP(hostname)
78
+ if ((family === 4 && privateIPv4(hostname)) || (family === 6 && privateIPv6(hostname))) invalid()
79
+ }
80
+
81
+ function base64url(value, maximum = MAX_TOKEN_BYTES) {
82
+ if (typeof value !== 'string' || value.length === 0 || value.length > maximum || !/^[A-Za-z0-9_-]+$/.test(value)) invalid()
83
+ const bytes = Buffer.from(value, 'base64url')
84
+ if (bytes.toString('base64url') !== value) invalid()
85
+ return bytes
86
+ }
87
+
88
+ function key(value, type) {
89
+ const der = base64url(value, 4096)
90
+ const options = { key: der, format: 'der', type }
91
+ const parsed = type === 'pkcs8' ? createPrivateKey(options) : createPublicKey(options)
92
+ if (parsed.asymmetricKeyType !== 'ed25519' || !parsed.export({ format: 'der', type }).equals(der)) invalid()
93
+ return parsed
94
+ }
95
+
96
+ function validateCertificate(value, now) {
97
+ record(value, CERTIFICATE_FIELDS)
98
+ if (value.version !== 1 || typeof value.environmentId !== 'string' || !UUID.test(value.environmentId)) invalid()
99
+ origin(value.platformOrigin)
100
+ origin(value.sourceOrigin)
101
+ if (value.platformOrigin === value.sourceOrigin) invalid()
102
+ key(value.publicKey, 'spki')
103
+ lifetime(value, 604800, now)
104
+ return value
105
+ }
106
+
107
+ function validateClaims(value, certificate, now) {
108
+ if (!value || typeof value !== 'object') invalid()
109
+ const fields = value.purpose === 'site' ? [...SESSION_FIELDS, 'siteOrigin', 'branchMediaKey'] : SESSION_FIELDS
110
+ record(value, fields)
111
+ if (value.version !== 1 || (value.purpose !== 'site' && value.purpose !== 'reference')) invalid()
112
+ shortString(value.tenantPublicId)
113
+ if (typeof value.nonce !== 'string' || !UUID.test(value.nonce)) invalid()
114
+ if (value.purpose === 'site') {
115
+ origin(value.siteOrigin)
116
+ shortString(value.branchMediaKey)
117
+ }
118
+ lifetime(value, 900, now)
119
+ if (value.issuedAt < certificate.issuedAt || value.expiresAt > certificate.expiresAt) invalid()
120
+ return value
121
+ }
122
+
123
+ // Both signer and verifier consume exactly these bytes, including the protocol
124
+ // prefix. Sorting recursively gives a single encoding for the signed envelope.
125
+ function canonical(value) {
126
+ if (value && typeof value === 'object') {
127
+ return `{${Object.keys(value).sort().map(field => `${JSON.stringify(field)}:${canonical(value[field])}`).join(',')}}`
128
+ }
129
+ return JSON.stringify(value)
130
+ }
131
+
132
+ function encode(prefix, payload, privateKey) {
133
+ const encoded = Buffer.from(canonical(payload)).toString('base64url')
134
+ const signedBytes = Buffer.from(`${prefix}.${encoded}`)
135
+ const signature = sign(null, signedBytes, privateKey).toString('base64url')
136
+ const token = `${prefix}.${encoded}.${signature}`
137
+ if (Buffer.byteLength(token) > MAX_TOKEN_BYTES) invalid()
138
+ return token
139
+ }
140
+
141
+ function decode(token, prefix) {
142
+ if (typeof token !== 'string' || Buffer.byteLength(token) > MAX_TOKEN_BYTES) invalid()
143
+ const parts = token.split('.')
144
+ if (parts.length !== 3 || parts[0] !== prefix) invalid()
145
+ const bytes = base64url(parts[1])
146
+ // Preserve a leading BOM so decoding cannot turn noncanonical bytes into JSON.
147
+ const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes)
148
+ const payload = JSON.parse(text)
149
+ if (canonical(payload) !== text) invalid()
150
+ const signature = base64url(parts[2])
151
+ if (signature.length !== 64) invalid()
152
+ return { payload, signature, signedBytes: Buffer.from(`${prefix}.${parts[1]}`) }
153
+ }
154
+
155
+ function verifySignature(envelope, publicKey) {
156
+ if (!verify(null, envelope.signedBytes, publicKey, envelope.signature)) invalid()
157
+ }
158
+
159
+ export function generatePreviewKeyPair() {
160
+ return bounded(() => {
161
+ const pair = generateKeyPairSync('ed25519', {
162
+ privateKeyEncoding: { type: 'pkcs8', format: 'der' },
163
+ publicKeyEncoding: { type: 'spki', format: 'der' },
164
+ })
165
+ return { privateKey: pair.privateKey.toString('base64url'), publicKey: pair.publicKey.toString('base64url') }
166
+ })
167
+ }
168
+
169
+ export function signEnvironmentCertificate(claims, privateKey) {
170
+ return bounded(() => encode(CERTIFICATE_PREFIX, validateCertificate(claims), key(privateKey, 'pkcs8')))
171
+ }
172
+
173
+ export function verifyEnvironmentCertificate(certificate, publicKey, options = {}) {
174
+ return bounded(() => {
175
+ const now = options.now === undefined ? Math.floor(Date.now() / 1000) : options.now
176
+ const envelope = decode(certificate, CERTIFICATE_PREFIX)
177
+ verifySignature(envelope, key(publicKey, 'spki'))
178
+ return validateCertificate(envelope.payload, now)
179
+ })
180
+ }
181
+
182
+ export function signPreviewSession(claims, certificate, privateKey) {
183
+ return bounded(() => {
184
+ // The branch can inspect its certificate but does not possess the issuer's
185
+ // public key here. Issuer verification is mandatory at the receiving side.
186
+ const environment = validateCertificate(decode(certificate, CERTIFICATE_PREFIX).payload)
187
+ const parsedKey = key(privateKey, 'pkcs8')
188
+ if (createPublicKey(parsedKey).export({ type: 'spki', format: 'der' }).toString('base64url') !== environment.publicKey) invalid()
189
+ validateClaims(claims, environment)
190
+ return encode(SESSION_PREFIX, { certificate, claims }, parsedKey)
191
+ })
192
+ }
193
+
194
+ export function verifyPreviewSession(token, options) {
195
+ return bounded(() => {
196
+ if (!options || (options.purpose !== 'reference' && options.purpose !== 'site')) invalid()
197
+ const now = options.now === undefined ? Math.floor(Date.now() / 1000) : options.now
198
+ const envelope = decode(token, SESSION_PREFIX)
199
+ record(envelope.payload, ['certificate', 'claims'])
200
+ const certificate = verifyEnvironmentCertificate(envelope.payload.certificate, options.issuerPublicKey, { now })
201
+ verifySignature(envelope, key(certificate.publicKey, 'spki'))
202
+ const claims = validateClaims(envelope.payload.claims, certificate, now)
203
+ if (claims.purpose !== options.purpose) invalid()
204
+ for (const [field, expected] of [
205
+ ['sourceOrigin', certificate.sourceOrigin], ['platformOrigin', certificate.platformOrigin],
206
+ ['siteOrigin', claims.purpose === 'site' ? claims.siteOrigin : undefined], ['tenantPublicId', claims.tenantPublicId],
207
+ ]) {
208
+ if (Object.hasOwn(options, field)) {
209
+ if (field === 'tenantPublicId') shortString(options[field])
210
+ else origin(options[field])
211
+ if (options[field] !== expected) invalid()
212
+ }
213
+ }
214
+ return { certificate, claims }
215
+ })
216
+ }
217
+
218
+ export function isTestPreviewToken(value) {
219
+ // Detect malformed prefixed credentials too, so callers can refuse them
220
+ // instead of passing them through legacy draft/production authentication.
221
+ return typeof value === 'string' && value.startsWith(SESSION_PREFIX)
222
+ }
@@ -0,0 +1,168 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { z } from 'zod'
3
+ import { readBoundedJSON } from '../features/server/http'
4
+ import type { Env } from '../server/read'
5
+ import {
6
+ isTestPreviewToken,
7
+ verifyPreviewSession,
8
+ type EnvironmentCertificate,
9
+ type SitePreviewClaims,
10
+ } from './protocol.mjs'
11
+
12
+ const TrustSchema = z.strictObject({
13
+ version: z.literal(1),
14
+ issuerPublicKey: z.string().min(1).max(1024),
15
+ tenantPublicId: z.string().min(1).max(256),
16
+ siteOrigin: z.string().url(),
17
+ mediaKey: z
18
+ .string()
19
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/)
20
+ .max(256)
21
+ .refine((value) => !value.includes('..')),
22
+ })
23
+ export type TestPreviewTrust = z.infer<typeof TrustSchema>
24
+ export type TestPreviewContext = {
25
+ token: string
26
+ platformOrigin: string
27
+ siteOrigin: string
28
+ environmentId: string
29
+ visitorCookieName: string
30
+ certificate: EnvironmentCertificate
31
+ claims: SitePreviewClaims
32
+ trust: TestPreviewTrust
33
+ }
34
+ export type TestPreviewOptions = {
35
+ env: Env
36
+ fetch?: typeof fetch
37
+ now?: () => number
38
+ buildId?: string
39
+ trustTimeoutMs?: number
40
+ }
41
+ export class TestPreviewRefused extends Error {
42
+ constructor() {
43
+ super('Test preview is unavailable or expired')
44
+ }
45
+ }
46
+ export const previewResponseHeaders = {
47
+ 'cache-control': 'no-store',
48
+ 'referrer-policy': 'no-referrer',
49
+ 'x-robots-tag': 'noindex, nofollow',
50
+ }
51
+ export const EXPECTED_PREVIEW_PARAM = 'u90test'
52
+ /** Cookie parsing refuses duplicates rather than choosing an attacker-controlled value. */
53
+ export function requestCookie(request: Request, name: string): string | null {
54
+ const prefix = name + '='
55
+ const values = (request.headers.get('cookie') ?? '')
56
+ .split(';')
57
+ .map((value) => value.trim())
58
+ .filter((value) => value.startsWith(prefix))
59
+ if (values.length > 1) throw new TestPreviewRefused()
60
+ return values[0]?.slice(prefix.length) ?? null
61
+ }
62
+ export function requestPreviewToken(request: Request): string | null {
63
+ return requestCookie(request, 'u90-preview-token')
64
+ }
65
+
66
+ export function createTestPreviewRuntime(options: TestPreviewOptions) {
67
+ const now = options.now ?? (() => Math.floor(Date.now() / 1000))
68
+ let cached: { trust: TestPreviewTrust; expiresAt: number } | undefined
69
+ let pending: Promise<TestPreviewTrust> | undefined
70
+ const getTrust = async (): Promise<TestPreviewTrust> => {
71
+ if (cached && now() < cached.expiresAt) return cached.trust
72
+ if (pending) return pending
73
+ pending = (async () => {
74
+ const { env } = options
75
+ if (!env.PLATFORM_URL || !env.PAYLOAD_API_KEY || !env.SITE_URL) throw new TestPreviewRefused()
76
+ const source = new URL(env.PLATFORM_URL),
77
+ site = new URL(env.SITE_URL)
78
+ if (
79
+ source.protocol !== 'https:' ||
80
+ source.username ||
81
+ source.password ||
82
+ source.search ||
83
+ source.hash ||
84
+ site.protocol !== 'https:'
85
+ )
86
+ throw new TestPreviewRefused()
87
+ const controller = new AbortController()
88
+ let response: Response | undefined
89
+ let timer: ReturnType<typeof setTimeout> | undefined
90
+ try {
91
+ const timeout = new Promise<never>((_, reject) => {
92
+ timer = setTimeout(() => {
93
+ controller.abort()
94
+ reject(new TestPreviewRefused())
95
+ }, options.trustTimeoutMs ?? 5000)
96
+ })
97
+ const trust = await Promise.race([
98
+ timeout,
99
+ (async () => {
100
+ response = await (options.fetch ?? fetch)(
101
+ `${env.PLATFORM_URL!.replace(/\/+$/, '')}/api/u90/test-preview/trust`,
102
+ {
103
+ headers: { authorization: `Bearer ${env.PAYLOAD_API_KEY}` },
104
+ cache: 'no-store',
105
+ redirect: 'error',
106
+ signal: controller.signal,
107
+ },
108
+ )
109
+ if (!response.ok || response.status >= 300) throw new TestPreviewRefused()
110
+ const parsed = TrustSchema.parse(
111
+ await readBoundedJSON(response, 16384, controller.signal),
112
+ )
113
+ if (parsed.siteOrigin !== site.origin) throw new TestPreviewRefused()
114
+ return parsed
115
+ })(),
116
+ ])
117
+ cached = { trust, expiresAt: now() + 300 }
118
+ return trust
119
+ } finally {
120
+ clearTimeout(timer)
121
+ }
122
+ })().finally(() => {
123
+ pending = undefined
124
+ })
125
+ return pending
126
+ }
127
+ return {
128
+ async resolve(token: string | null): Promise<TestPreviewContext | null> {
129
+ if (!isTestPreviewToken(token)) return null
130
+ try {
131
+ const trust = await getTrust()
132
+ const verified = verifyPreviewSession(token!, {
133
+ issuerPublicKey: trust.issuerPublicKey,
134
+ purpose: 'site',
135
+ sourceOrigin: new URL(options.env.PLATFORM_URL!).origin,
136
+ siteOrigin: trust.siteOrigin,
137
+ tenantPublicId: trust.tenantPublicId,
138
+ now: now(),
139
+ })
140
+ const claims = verified.claims as SitePreviewClaims
141
+ return {
142
+ ...verified,
143
+ claims,
144
+ trust,
145
+ token: token!,
146
+ platformOrigin: verified.certificate.platformOrigin,
147
+ siteOrigin: claims.siteOrigin,
148
+ environmentId: verified.certificate.environmentId,
149
+ visitorCookieName:
150
+ 'u90_feature_test_' +
151
+ createHash('sha256')
152
+ .update(
153
+ verified.certificate.environmentId +
154
+ '\n' +
155
+ verified.certificate.platformOrigin +
156
+ '\n' +
157
+ claims.tenantPublicId,
158
+ )
159
+ .digest('hex')
160
+ .slice(0, 32),
161
+ }
162
+ } catch {
163
+ throw new TestPreviewRefused()
164
+ }
165
+ },
166
+ }
167
+ }
168
+ export type TestPreviewRuntime = ReturnType<typeof createTestPreviewRuntime>
@@ -0,0 +1,10 @@
1
+ export { testPreviewProxy, TEST_PREVIEW_ACTIONS, type TestPreviewCapabilities } from './guard'
2
+ export {
3
+ createTestPreviewRuntime,
4
+ TestPreviewRefused,
5
+ EXPECTED_PREVIEW_PARAM,
6
+ type TestPreviewContext,
7
+ type TestPreviewTrust,
8
+ type TestPreviewOptions,
9
+ type TestPreviewRuntime,
10
+ } from './runtime'
@@ -0,0 +1,148 @@
1
+ import { applyOverlay, newDocumentId, seed, type DocumentDecl, type OverlayValues } from '../elements'
2
+ import { hasTranslatedCopy, type SiteLanguages } from '../languages/routing'
3
+ import { assemble, manifestFrom, manifestHash, replyFrom, type Reply, type ReplySeo, type ReplyTarget, type RouteRead, type Site, type SiteReply } from '../site'
4
+ import type { RedirectsReply } from '../redirects/wire'
5
+ import { seedCtx, seedEntry, seedSite, type Store } from '../site/store'
6
+
7
+ export type { RouteRead }
8
+ const assembled = (site: Site, store: Store, target: ReplyTarget, draft: boolean): RouteRead =>
9
+ assemble(site, replyFrom(site, store, target, ''), { draft, target, stale: false, defaults: null })
10
+ /** A route read against a store, assembled as the site assembles a platform's reply. */
11
+ export const routeRead = (site: Site, store: Store, pathname: string, draft: boolean): RouteRead => assembled(site, store, { route: pathname }, draft)
12
+ /**
13
+ * `/entry/:collection/:slug` of the read contract, as the site assembles it: independent of whether
14
+ * the collection has a public route, so a routeless collection (coastline's `sectors`) is readable too.
15
+ */
16
+ export const entryRead = (site: Site, store: Store, collection: string, slug: string, draft: boolean): RouteRead =>
17
+ assembled(site, store, { entry: { collection, slug } }, draft)
18
+
19
+ /**
20
+ * The read contract, in-process: `/api/u90/v1/*` answered with replies built from a store, as the
21
+ * platform answers. `manifest` overrides the hash it reports, to play a platform behind a deploy.
22
+ * `failSite` makes `/site` answer 500, to play a platform whose site-wide SEO cannot be read.
23
+ * `redirects` are the rules `/redirects` answers with; `setRedirects` replaces them.
24
+ * `languages` plays a platform whose site has them (an object) or none (null); left out, the fake accepts any `locale` and answers the base, as before languages.
25
+ */
26
+ export async function createFakePlatform(site: Site, store?: Store, opts: { manifest?: string; failSite?: boolean; redirects?: RedirectsReply['rules']; languages?: SiteLanguages | null } = {}) {
27
+ const s = store ?? (await seedSite(site))
28
+ const manifest = opts.manifest ?? (await manifestHash(manifestFrom(site)))
29
+ const revalidated: string[] = []
30
+ let redirects: RedirectsReply['rules'] = opts.redirects ?? []
31
+ const ctx = seedCtx(s.media)
32
+ /** Undefined until languages are given: the fake then answers any `locale` with the base, as it did before languages. */
33
+ let languages: SiteLanguages | null | undefined = opts.languages
34
+ /** By document key, then by locale. */
35
+ const translations = new Map<string, Map<string, OverlayValues>>()
36
+ /** The overlay layers of a read, most preferred first, as plan 1's `languageChain` lists them; null for a locale this site cannot answer. */
37
+ const chainOf = (requested: string | null): { locale: string | null; layers: string[] } | null => {
38
+ if (languages === undefined) return { locale: null, layers: [] }
39
+ if (languages === null) return requested === null ? { locale: null, layers: [] } : null
40
+ const locale = requested ?? languages.default
41
+ if (!languages.active.some((language) => language.id === locale)) return null
42
+ return { locale, layers: [locale, languages.default].filter((id, at, all) => id !== languages!.original && all.indexOf(id) === at) }
43
+ }
44
+ /** As the platform answers it (ruling R2): the active languages, other than the original and the default, whose own translation of `document` holds copy. */
45
+ const translatedOf = (document: string): string[] =>
46
+ languages ? languages.active.map((l) => l.id).filter((id) => id !== languages!.default && id !== languages!.original && hasTranslatedCopy(translations.get(document)?.get(id))) : []
47
+ /** A sitemap entry with its languages, as the platform lists them, for a site with languages. */
48
+ const withLanguages = (entry: { path: string; lastModified: string | null }, document: string) =>
49
+ languages ? { ...entry, languages: languages.active.map((l) => l.id).filter((id) => id === languages!.default || id === languages!.original || translatedOf(document).includes(id)) } : entry
50
+ const layoutDecl: DocumentDecl = { kind: 'layout', blocks: { container: 'u90/zone', allow: Object.keys(site.schema.blocks) } }
51
+ /** The store with the layers laid over every page, layout and Single; the store itself is never changed. */
52
+ const overlaid = (layers: string[]): Store => {
53
+ if (layers.length === 0) return s
54
+ const of = (doc: string) => layers.flatMap((locale) => { const values = translations.get(doc)?.get(locale); return values ? [values] : [] })
55
+ const pages = new Map([...s.pages].map(([k, p]) => {
56
+ const blocks = p.key ? site.pages[p.key]?.blocks : p.pageType ? site.pageTypes[p.pageType]?.blocks : undefined
57
+ return [k, blocks ? { ...p, doc: applyOverlay(site.schema, { kind: 'page', blocks }, p.doc, `page:${p.id}`, of(`page:${p.id}`)).doc } : p]
58
+ }))
59
+ const layouts = new Map([...s.layouts].map(([k, l]) => [k, { ...l, doc: applyOverlay(site.schema, layoutDecl, l.doc, `layout:${k}`, of(`layout:${k}`)).doc }]))
60
+ const singles = new Map([...s.singles].map(([k, v]) => [k, { doc: applyOverlay(site.schema, { kind: 'single', key: k }, v.doc, `single:${k}`, of(`single:${k}`)).doc as { value: unknown } }]))
61
+ return { ...s, pages, layouts, singles }
62
+ }
63
+ const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
64
+ /**
65
+ * `/site` by default: an indexed site, with the manifest's title template and nothing else set.
66
+ * The real platform answers `allowIndexing: false` for a tenant that has not saved SEO settings;
67
+ * `setSite` plays that tenant.
68
+ */
69
+ const settings: SiteReply = { allowIndexing: true, siteName: null, titleTemplate: site.seo.titleTemplate ?? null, description: null, ogImage: null, locale: null, twitterCard: 'summary_large_image', twitterHandle: null, googleVerification: null, bingVerification: null, organisation: null }
70
+ /** The read contract carries `Authorization: Bearer` on every read (spec §4): checked before dispatch, regardless of whether the path resolves. */
71
+ const fetchImpl: typeof fetch = async (input, init) => {
72
+ const url = new URL(input instanceof URL ? input.href : typeof input === 'string' ? input : input.url)
73
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined))
74
+ if (!headers.get('authorization')?.startsWith('Bearer ')) return json({ error: 'unauthorised' }, 401)
75
+ // With no languages given, `locale` is accepted and the base answered. With languages, a `locale` must name an active one, and the layers [requested, default] are laid over pages, layouts and Singles; entries are not overlaid.
76
+ const draft = url.searchParams.get('draft') === '1'
77
+ if (draft && !headers.get('x-u90-preview-token')) return json({ error: 'preview token' }, 401)
78
+ const p = url.pathname.replace(/^.*\/api\/u90\/v1/, '')
79
+ const chain = chainOf(url.searchParams.get('locale') || null)
80
+ const reads = p === '/route' || p.startsWith('/page/') || p.startsWith('/entry/')
81
+ if (reads && chain === null) return json({ error: 'Unknown language.' }, 400)
82
+ const view = chain ? overlaid(chain.layers) : s
83
+ // A site with languages hears which language answered, as the platform says it, and which languages have copy of the document (ruling R2).
84
+ const answer = (reply: Reply) =>
85
+ json(reply.status === 'ok' && chain?.locale ? { ...reply, locale: chain.locale, ...(languages ? { translated: translatedOf(`${reply.document.kind}:${reply.document.id}`) } : {}) } : reply)
86
+ if (p === '/route') return answer(replyFrom(site, view, { route: url.searchParams.get('path') ?? '/' }, manifest))
87
+ if (p.startsWith('/page/')) return answer(replyFrom(site, view, { page: decodeURIComponent(p.slice(6)) }, manifest))
88
+ if (p.startsWith('/entry/')) {
89
+ // The read client encodes both segments (and a URL's pathname keeps them encoded), so a
90
+ // non-ASCII slug arrives as `caf%C3%A9`; decoded after the split, so an encoded `/` stays in its segment.
91
+ let segments: string[]
92
+ try { segments = p.split('/').slice(2).map(decodeURIComponent) } catch { return json({ error: 'malformed path' }, 400) }
93
+ return answer(replyFrom(site, view, { entry: { collection: segments[0], slug: segments[1] } }, manifest))
94
+ }
95
+ if (p === '/site') return opts.failSite ? json({ error: 'site settings unavailable' }, 500) : json(languages ? { ...settings, languages } : settings)
96
+ if (p === '/sitemap') {
97
+ // As the platform does: nothing while indexing is off, and no document whose own SEO says noindex.
98
+ if (!settings.allowIndexing) return json({ entries: [] })
99
+ // A site with languages lists each entry in the languages it has copy in (ruling R2).
100
+ const entries = [...s.entries.values()].flatMap((e) => (site.collections[e.collection]?.route && !e.seo?.noindex ? [withLanguages({ path: site.collections[e.collection].route!.replace(':slug', e.slug), lastModified: null }, `entry:${e.id}`)] : []))
101
+ return json({ entries: [...[...s.pages.values()].filter((x) => !x.seo?.noindex).map((x) => withLanguages({ path: x.route, lastModified: null }, `page:${x.id}`)), ...entries] })
102
+ }
103
+ if (p === '/redirects') return json({ rules: redirects, generatedAt: new Date().toISOString() })
104
+ if (p === '/revalidate') { const body = (await (init?.body ? new Response(init.body) : (input as Request)).json()) as { tags: string[] }; revalidated.push(...body.tags); return json({ ok: true }) }
105
+ return json({ error: 'not found' }, 404)
106
+ }
107
+ return {
108
+ fetch: fetchImpl, store: s, revalidated, manifest,
109
+ async addEntry(collection: string, slug: string, fields: Record<string, unknown>, blocks?: unknown[]) {
110
+ await seedEntry(site, s, ctx, collection, slug, fields, blocks)
111
+ },
112
+ async addPage(pageType: string, slug: string, blocks?: unknown[], title?: string) {
113
+ const t = site.pageTypes[pageType]
114
+ if (!t) throw new Error(`No page type named "${pageType}".`)
115
+ const id = newDocumentId()
116
+ const doc = await seed(site.schema, { kind: 'page', blocks: t.blocks }, blocks, ctx, `page:${id}`)
117
+ s.pages.set(id, { id, slug, route: t.route.replace(':slug', slug), layout: t.layout, pageType, title: title ?? slug, doc })
118
+ return id
119
+ },
120
+ setStored(kind: 'page' | 'layout' | 'single', id: string, pointer: string, value: unknown) {
121
+ if (!/^\/[^/]+(\/[^/]+)*$/.test(pointer)) throw new Error(`"${pointer}" is not a valid pointer: expected a leading "/" and at least one segment, e.g. "/blocks/0/headline".`)
122
+ const entry = kind === 'page' ? s.pages.get(id) : kind === 'layout' ? s.layouts.get(id) : s.singles.get(id)
123
+ if (!entry) throw new Error(`No ${kind} named "${id}" is stored.`)
124
+ const segs = pointer.split('/').slice(1); let node: any = entry.doc
125
+ for (const seg of segs.slice(0, -1)) {
126
+ if (node === null || typeof node !== 'object' || !(seg in node)) throw new Error(`"${pointer}" has no node at "${seg}" (in ${kind} "${id}").`)
127
+ node = node[seg]
128
+ }
129
+ node[segs.at(-1)!] = value
130
+ },
131
+ setSeo(target: { page: string } | { entry: { collection: string; slug: string } }, seo: ReplySeo) {
132
+ const doc = 'page' in target ? s.pages.get(target.page) : [...s.entries.values()].find((e) => e.collection === target.entry.collection && e.slug === target.entry.slug)
133
+ if (!doc) throw new Error(`Nothing stored for ${JSON.stringify(target)}.`)
134
+ doc.seo = seo
135
+ },
136
+ setSite(changes: Partial<SiteReply>) { Object.assign(settings, changes) },
137
+ /** Replaces the rules `/redirects` answers with. */
138
+ setRedirects(next: RedirectsReply['rules']) { redirects = next },
139
+ /** Plays a platform whose site has these languages; null plays one whose site has none. */
140
+ setLanguages(next: SiteLanguages | null) { languages = next },
141
+ /** Stores one language's overlay of a page (`page:<id>`), layout (`layout:<key>`) or Single (`single:<key>`). */
142
+ setTranslation(document: string, locale: string, values: OverlayValues) {
143
+ const byLocale = translations.get(document) ?? new Map<string, OverlayValues>()
144
+ byLocale.set(locale, values)
145
+ translations.set(document, byLocale)
146
+ },
147
+ }
148
+ }
@@ -0,0 +1,2 @@
1
+ export { seedSite, seedCtx, seedEntry, type Store, type StoredPage } from './seed'
2
+ export { createFakePlatform, routeRead, entryRead, type RouteRead } from './fake'
@@ -0,0 +1,2 @@
1
+ /** The defaults store moved to `site/store.ts`, where the read path and the fake both take it from. */
2
+ export * from '../site/store'