@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,416 @@
1
+ import {
2
+ createCipheriv,
3
+ createDecipheriv,
4
+ createHash,
5
+ createHmac,
6
+ randomBytes,
7
+ randomUUID,
8
+ timingSafeEqual,
9
+ } from 'node:crypto'
10
+ import { ACCESS_COOKIE, HANDOFF_QUERY, LOCK_ENDPOINT, LOCK_PATH, safeReturnTo } from './contracts'
11
+ import type { PreviewLockAppearance, PreviewLockStatus } from './contracts'
12
+
13
+ export type PreviewLockOptions = {
14
+ platformUrl: string
15
+ apiKey: string
16
+ revalidateSecret: string
17
+ fetch?: typeof fetch
18
+ publicAssets?: readonly string[]
19
+ }
20
+ export type PreviewLockGate =
21
+ | { kind: 'response'; response: Response }
22
+ | { kind: 'next'; headers: Headers; requestHeaders: Headers; url: string }
23
+ const PRIVATE = {
24
+ 'cache-control': 'private, no-store, max-age=0',
25
+ 'x-robots-tag': 'noindex, nofollow, noarchive',
26
+ 'referrer-policy': 'no-referrer',
27
+ }
28
+ const equal = (a: string, b: string) => {
29
+ const x = Buffer.from(a),
30
+ y = Buffer.from(b)
31
+ return x.length === y.length && timingSafeEqual(x, y)
32
+ }
33
+ const json = (body: unknown, status = 200, headers: Record<string, string> = {}) =>
34
+ Response.json(body, { status, headers: { ...PRIVATE, ...headers } })
35
+ const unavailable = () =>
36
+ json({ ok: false, error: 'Preview verification is unavailable. Please try again.' }, 503)
37
+ const tokenLike = (value: unknown): value is string =>
38
+ typeof value === 'string' && /^[A-Za-z0-9_.-]{1,4096}$/.test(value)
39
+ function cookie(request: Request, name: string): string | null {
40
+ const values = (request.headers.get('cookie') ?? '')
41
+ .split(';')
42
+ .map((x) => x.trim())
43
+ .filter((x) => x.startsWith(name + '='))
44
+ if (values.length !== 1) return null
45
+ const value = values[0].slice(name.length + 1)
46
+ return tokenLike(value) ? value : null
47
+ }
48
+ function statusValue(raw: unknown): PreviewLockStatus {
49
+ if (!raw || typeof raw !== 'object') throw Error('Invalid status')
50
+ const v = raw as PreviewLockStatus
51
+ if (
52
+ !['open', 'locked'].includes(v.state) ||
53
+ typeof v.protected !== 'boolean' ||
54
+ (v.state === 'locked' && !v.protected)
55
+ )
56
+ throw Error('Invalid status')
57
+ if (v.canvasVerified !== undefined && typeof v.canvasVerified !== 'boolean')
58
+ throw Error('Invalid Canvas proof')
59
+ if (v.appearance !== undefined) {
60
+ const a = v.appearance
61
+ for (const key of [
62
+ 'partnerName',
63
+ 'prospectName',
64
+ 'heading',
65
+ 'message',
66
+ 'instructions',
67
+ 'backgroundColor',
68
+ 'textColor',
69
+ 'accentColor',
70
+ ] as const)
71
+ if (typeof a[key] !== 'string' || a[key].length > 4096) throw Error('Invalid appearance')
72
+ for (const key of ['partnerLogoUrl', 'prospectLogoUrl'] as const)
73
+ if (a[key] !== null && (typeof a[key] !== 'string' || a[key]!.length > 2048))
74
+ throw Error('Invalid logo')
75
+ for (const key of ['backgroundColor', 'textColor', 'accentColor'] as const)
76
+ if (!/^#[\da-f]{6}$/i.test(a[key])) throw Error('Invalid colour')
77
+ }
78
+ if (v.state === 'locked' && !v.appearance) throw Error('Missing appearance')
79
+ return v
80
+ }
81
+ async function boundedJSON(response: Response, limit = 16384): Promise<unknown> {
82
+ if (Number(response.headers.get('content-length') ?? 0) > limit || !response.body)
83
+ throw Error('Invalid body')
84
+ const reader = response.body.getReader(),
85
+ parts: Uint8Array[] = []
86
+ let length = 0
87
+ try {
88
+ for (;;) {
89
+ const { value, done } = await reader.read()
90
+ if (done) break
91
+ length += value.byteLength
92
+ if (length > limit) {
93
+ await reader.cancel()
94
+ throw Error('Body too large')
95
+ }
96
+ parts.push(value)
97
+ }
98
+ } finally {
99
+ reader.releaseLock()
100
+ }
101
+ return JSON.parse(Buffer.concat(parts).toString('utf8'))
102
+ }
103
+
104
+ /** Opt-in server boundary. Importing ordinary site-kit exports does not install this gate. */
105
+ export function createPreviewLock(options: PreviewLockOptions) {
106
+ const base = new URL(options.platformUrl.replace(/\/$/, '') + '/')
107
+ if (!['https:', 'http:'].includes(base.protocol) || !options.apiKey || !options.revalidateSecret)
108
+ throw Error('Preview lock configuration is incomplete')
109
+ const endpoint = new URL('api/u90/preview-lock', base),
110
+ fetcher = options.fetch ?? globalThis.fetch
111
+ const publicAssets = new Set(options.publicAssets ?? [])
112
+ for (const path of publicAssets)
113
+ if (
114
+ safeReturnTo(path) !== path ||
115
+ path.includes('?') ||
116
+ path.includes('#') ||
117
+ path.startsWith('/api/') ||
118
+ !/\.(?:svg|png|jpe?g|gif|webp|avif|ico|woff2?|ttf|otf)$/i.test(path)
119
+ )
120
+ throw Error('Public branding assets must be exact local asset paths')
121
+ const key = createHash('sha256')
122
+ .update('u90-site-preview-handoff:v1:' + options.revalidateSecret)
123
+ .digest()
124
+ const mac = (v: string) => createHmac('sha256', key).update(v).digest('base64url')
125
+ function visitor(request: Request) {
126
+ const [id, signature] = (cookie(request, 'u90-preview-visitor') ?? '').split('.')
127
+ const authenticated = !!id && !!signature && equal(signature, mac(id))
128
+ const value = authenticated ? id : randomUUID()
129
+ return {
130
+ id: value,
131
+ authenticated,
132
+ cookie: `u90-preview-visitor=${value}.${mac(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400${new URL(request.url).protocol === 'https:' ? '; Secure' : ''}`,
133
+ }
134
+ }
135
+ async function remote(init: RequestInit): Promise<{ response: Response; body: unknown }> {
136
+ const controller = new AbortController()
137
+ let timer: ReturnType<typeof setTimeout> | undefined
138
+ try {
139
+ return await Promise.race([
140
+ (async () => {
141
+ const response = await fetcher(endpoint, {
142
+ ...init,
143
+ cache: 'no-store',
144
+ redirect: 'error',
145
+ signal: controller.signal,
146
+ headers: {
147
+ authorization: `Bearer ${options.apiKey}`,
148
+ ...Object.fromEntries(new Headers(init.headers)),
149
+ },
150
+ })
151
+ return { response, body: await boundedJSON(response) }
152
+ })(),
153
+ new Promise<never>((_, reject) => {
154
+ timer = setTimeout(() => {
155
+ controller.abort()
156
+ reject(Error('Timeout'))
157
+ }, 3000)
158
+ }),
159
+ ])
160
+ } finally {
161
+ if (timer) clearTimeout(timer)
162
+ }
163
+ }
164
+ function handoff(request: Request): string | null {
165
+ const url = new URL(request.url),
166
+ encoded = url.searchParams.get(HANDOFF_QUERY)
167
+ if (!encoded || encoded.length > 8192) return null
168
+ try {
169
+ const [iv, data, tag, ...rest] = encoded.split('.')
170
+ if (rest.length) return null
171
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64url'))
172
+ decipher.setAuthTag(Buffer.from(tag, 'base64url'))
173
+ const payload = JSON.parse(
174
+ Buffer.concat([
175
+ decipher.update(Buffer.from(data, 'base64url')),
176
+ decipher.final(),
177
+ ]).toString(),
178
+ )
179
+ if (
180
+ !tokenLike(payload.token) ||
181
+ !Number.isFinite(payload.exp) ||
182
+ payload.exp <= Date.now() ||
183
+ payload.exp > Date.now() + 60000 ||
184
+ payload.path !== safeReturnTo(url.pathname + url.search)
185
+ )
186
+ return null
187
+ return payload.token
188
+ } catch {
189
+ return null
190
+ }
191
+ }
192
+ async function status(request: Request): Promise<PreviewLockStatus> {
193
+ const preview = handoff(request) ?? cookie(request, 'u90-preview-token'),
194
+ access = cookie(request, ACCESS_COOKIE)
195
+ const { response, body } = await remote({
196
+ method: 'GET',
197
+ headers: {
198
+ ...(preview ? { 'X-U90-Preview-Token': preview } : {}),
199
+ ...(access ? { 'X-U90-Preview-Access': access } : {}),
200
+ },
201
+ })
202
+ if (!response.ok) throw Error('Status unavailable')
203
+ return statusValue(body)
204
+ }
205
+ /** Opt-in draft adapter: verified Canvas access also works without Next's draft-mode cookie. */
206
+ async function draft(request: Request): Promise<{ enabled: boolean; token: string | null }> {
207
+ const token = handoff(request) ?? cookie(request, 'u90-preview-token')
208
+ if (!token?.startsWith('u90pt1.')) return { enabled: false, token: null }
209
+ const current = await status(request)
210
+ return current.canvasVerified === true
211
+ ? { enabled: true, token }
212
+ : { enabled: false, token: null }
213
+ }
214
+ async function gate(request: Request): Promise<PreviewLockGate> {
215
+ const url = new URL(request.url),
216
+ path = url.pathname
217
+ const requestHeaders = new Headers(request.headers)
218
+ for (const name of ['x-u90-preview-token', 'x-u90-preview-access']) requestHeaders.delete(name)
219
+ const next = (headers: Record<string, string> = {}): PreviewLockGate => {
220
+ url.searchParams.delete(HANDOFF_QUERY)
221
+ return { kind: 'next', headers: new Headers(headers), requestHeaders, url: url.href }
222
+ }
223
+ if (path === LOCK_ENDPOINT && request.method === 'GET')
224
+ return {
225
+ kind: 'response',
226
+ response: equal(
227
+ request.headers.get('authorization') ?? '',
228
+ `Bearer ${options.revalidateSecret}`,
229
+ )
230
+ ? json({ ready: true, version: 1 })
231
+ : json({ error: 'Unauthorised' }, 401),
232
+ }
233
+ if (path === LOCK_ENDPOINT && request.method === 'POST') return next(PRIVATE)
234
+ if (
235
+ [
236
+ '/api/u90/preview',
237
+ '/api/u90/revalidate',
238
+ '/api/u90/exit-preview',
239
+ '/api/u90/manifest',
240
+ ].includes(path)
241
+ )
242
+ return next(PRIVATE)
243
+ if (
244
+ (request.method === 'GET' || request.method === 'HEAD') &&
245
+ (path.startsWith('/_next/static/') ||
246
+ path === '/_next/webpack-hmr' ||
247
+ path === '/favicon.ico' ||
248
+ publicAssets.has(path))
249
+ )
250
+ return next()
251
+ let current: PreviewLockStatus
252
+ try {
253
+ current = await status(request)
254
+ } catch {
255
+ return { kind: 'response', response: unavailable() }
256
+ }
257
+ if (path === LOCK_PATH) {
258
+ const entry = next(current.protected ? PRIVATE : {})
259
+ const identity = visitor(request)
260
+ if (current.protected && !identity.authenticated && entry.kind === 'next')
261
+ entry.headers.append('set-cookie', identity.cookie)
262
+ return entry
263
+ }
264
+ if (current.state === 'open') {
265
+ // Preserve verified Canvas context for layout and draft reads when browser cookies are blocked.
266
+ // This is a request-only cookie override, never an outgoing cookie or browser bypass flag.
267
+ const preview = handoff(request)
268
+ if (preview && current.canvasVerified === true) {
269
+ const existing = (requestHeaders.get('cookie') ?? '')
270
+ .split(';')
271
+ .map((x) => x.trim())
272
+ .filter((x) => x && !x.startsWith('u90-preview-token='))
273
+ requestHeaders.set('cookie', [...existing, `u90-preview-token=${preview}`].join('; '))
274
+ }
275
+ return next(current.protected || current.canvasVerified === true ? PRIVATE : {})
276
+ }
277
+ if (path.startsWith('/api/') || !['GET', 'HEAD'].includes(request.method))
278
+ return { kind: 'response', response: json({ error: 'Preview access required' }, 423) }
279
+ const destination = new URL(LOCK_PATH, url.origin)
280
+ destination.searchParams.set('returnTo', safeReturnTo(path + url.search))
281
+ return {
282
+ kind: 'response',
283
+ response: new Response(null, {
284
+ status: 307,
285
+ headers: { ...PRIVATE, location: destination.href },
286
+ }),
287
+ }
288
+ }
289
+ async function unlock(request: Request): Promise<Response> {
290
+ if (request.method !== 'POST') return json({ error: 'Method not allowed' }, 405)
291
+ const origin = request.headers.get('origin')
292
+ if (
293
+ (origin && origin !== new URL(request.url).origin) ||
294
+ request.headers.get('sec-fetch-site') === 'cross-site'
295
+ )
296
+ return json({ error: 'Unauthorised origin' }, 403)
297
+ let input: { code: string; requestId: string; path: string }
298
+ try {
299
+ const raw = (await boundedJSON(
300
+ new Response(request.body, { headers: request.headers }),
301
+ 4096,
302
+ )) as Partial<typeof input> | null
303
+ if (
304
+ !raw ||
305
+ typeof raw.code !== 'string' ||
306
+ !/^\d{6}$/.test(raw.code) ||
307
+ typeof raw.requestId !== 'string' ||
308
+ !/^[A-Za-z0-9_-][A-Za-z0-9_.:-]{7,127}$/.test(raw.requestId) ||
309
+ typeof raw.path !== 'string'
310
+ )
311
+ throw Error('Invalid body')
312
+ input = { code: raw.code, requestId: raw.requestId, path: safeReturnTo(raw.path) }
313
+ } catch {
314
+ return json({ ok: false, error: 'Enter a valid six-digit access code.' }, 400)
315
+ }
316
+ const identity = visitor(request),
317
+ visitorCookie = identity.cookie
318
+ if (!identity.authenticated) {
319
+ const response = json(
320
+ { ok: false, error: 'Allow cookies for this site, then try again.' },
321
+ 428,
322
+ )
323
+ response.headers.append('set-cookie', visitorCookie)
324
+ return response
325
+ }
326
+ try {
327
+ const { response, body } = await remote({
328
+ method: 'POST',
329
+ headers: {
330
+ 'content-type': 'application/json',
331
+ 'X-U90-Visitor': identity.id,
332
+ ...(cookie(request, ACCESS_COOKIE)
333
+ ? { 'X-U90-Preview-Access': cookie(request, ACCESS_COOKIE)! }
334
+ : {}),
335
+ },
336
+ body: JSON.stringify(input),
337
+ })
338
+ if ([400, 401, 429].includes(response.status)) {
339
+ const reply = json(
340
+ {
341
+ ok: false,
342
+ error:
343
+ response.status === 429
344
+ ? 'Please wait before trying again.'
345
+ : 'That code was not accepted.',
346
+ },
347
+ response.status,
348
+ )
349
+ reply.headers.append('set-cookie', visitorCookie)
350
+ const retry = response.headers.get('retry-after')
351
+ if (retry && /^\d{1,4}$/.test(retry)) reply.headers.set('retry-after', retry)
352
+ return reply
353
+ }
354
+ const result = body as { ok?: unknown; token?: unknown; maxAge?: unknown }
355
+ if (
356
+ !response.ok ||
357
+ result?.ok !== true ||
358
+ !tokenLike(result.token) ||
359
+ typeof result.maxAge !== 'number' ||
360
+ !Number.isInteger(result.maxAge) ||
361
+ result.maxAge < 1 ||
362
+ result.maxAge > 3600
363
+ )
364
+ throw Error('Invalid unlock')
365
+ const reply = json({ ok: true, returnTo: input.path })
366
+ reply.headers.append(
367
+ 'set-cookie',
368
+ `${ACCESS_COOKIE}=${result.token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${result.maxAge}${new URL(request.url).protocol === 'https:' ? '; Secure' : ''}`,
369
+ )
370
+ reply.headers.append('set-cookie', visitorCookie)
371
+ return reply
372
+ } catch {
373
+ const reply = unavailable()
374
+ reply.headers.append('set-cookie', visitorCookie)
375
+ return reply
376
+ }
377
+ }
378
+ /** Called only inside the existing authenticated preview handler, before its redirect. */
379
+ async function previewHandoff(request: Request): Promise<string | null> {
380
+ const url = new URL(request.url),
381
+ token = url.searchParams.get('token')
382
+ if (
383
+ url.pathname !== '/api/u90/preview' ||
384
+ !equal(url.searchParams.get('secret') ?? '', options.revalidateSecret) ||
385
+ !tokenLike(token) ||
386
+ !token.startsWith('u90pt1.')
387
+ )
388
+ return null
389
+ const path = safeReturnTo(url.searchParams.get('path'))
390
+ const target = new URL(path, url.origin)
391
+ try {
392
+ const { response, body } = await remote({
393
+ method: 'GET',
394
+ headers: { 'X-U90-Preview-Token': token },
395
+ })
396
+ if (!response.ok || statusValue(body).canvasVerified !== true) return null
397
+ const iv = randomBytes(12),
398
+ cipher = createCipheriv('aes-256-gcm', key, iv)
399
+ const data = Buffer.concat([
400
+ cipher.update(
401
+ JSON.stringify({ token, path: target.pathname + target.search, exp: Date.now() + 60000 }),
402
+ ),
403
+ cipher.final(),
404
+ ])
405
+ target.searchParams.set(
406
+ HANDOFF_QUERY,
407
+ `${iv.toString('base64url')}.${data.toString('base64url')}.${cipher.getAuthTag().toString('base64url')}`,
408
+ )
409
+ return target.pathname + target.search + target.hash
410
+ } catch {
411
+ return null
412
+ }
413
+ }
414
+ return { gate, status, draft, unlock, previewHandoff, safeReturnTo }
415
+ }
416
+ export type { PreviewLockAppearance, PreviewLockStatus }
@@ -0,0 +1,5 @@
1
+ /** Adds `env.U90_REDIRECTS_SNAPSHOT` to a Next config: the platform's redirect rules as read at build time. */
2
+ export function withRedirectsSnapshot<C extends { env?: Record<string, string | undefined> }>(
3
+ config: C,
4
+ options?: { env?: NodeJS.ProcessEnv; fetch?: typeof fetch },
5
+ ): Promise<C>
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Bakes the platform's redirect rules into a build, so a fresh deploy applies them from its first
3
+ * request: `export default async () => withRedirectsSnapshot(nextConfig)`, a form every config file type accepts. The rules are read from the v1
4
+ * read API with the site's key and handed to the proxy as `env.U90_REDIRECTS_SNAPSHOT`. With no
5
+ * `PLATFORM_URL` the snapshot is empty. A platform that will not answer, or takes more than 10 s, stops the build.
6
+ */
7
+ export async function withRedirectsSnapshot(config, { env = process.env, fetch: fetchImpl = globalThis.fetch } = {}) {
8
+ const base = env.PLATFORM_URL ? env.PLATFORM_URL.replace(/\/+$/, '') : ''
9
+ let snapshot = { rules: [], generatedAt: new Date(0).toISOString() }
10
+ if (base) {
11
+ // The timeout covers the body as well as the headers: a platform that stalls mid-reply stops the build the same way.
12
+ try {
13
+ const res = await fetchImpl(`${base}/api/u90/v1/redirects`, { headers: { authorization: `Bearer ${env.PAYLOAD_API_KEY ?? ''}` }, cache: 'no-store', signal: AbortSignal.timeout(10000) })
14
+ if (!res.ok) throw new Error(`The platform answered ${res.status} for the site's redirect rules, so the build stops rather than deploy without them.`)
15
+ snapshot = await res.json()
16
+ } catch (err) {
17
+ if (err instanceof Error && err.name === 'TimeoutError') throw new Error('The platform timed out after 10 s on the site\'s redirect rules, so the build stops rather than deploy without them.')
18
+ throw err
19
+ }
20
+ }
21
+ return { ...config, env: { ...(config.env ?? {}), U90_REDIRECTS_SNAPSHOT: JSON.stringify(snapshot) } }
22
+ }
@@ -0,0 +1,3 @@
1
+ export * from './match'
2
+ export * from './wire'
3
+ export { redirectsProxy } from './proxy'
@@ -0,0 +1,31 @@
1
+ /** Every run of `/` and `\` in a path as one `/`. */
2
+ export const collapseSeparators = (path: string): string => path.replace(/[/\\]+/g, '/')
3
+
4
+ /** A redirect's `Location`, or why none is sent: `unusable` when no URL can be made of the destination, `unsafe` when it would leave the site or land under `/_next/`. */
5
+ export type SiteLocation = { location: URL } | { refused: 'unusable' | 'unsafe' }
6
+
7
+ /**
8
+ * The `Location` a proxy sends for `destination`, made absolute against the request.
9
+ * Next's proxy adapter reads the response's `Location` with `new NextURL(location)`, which throws on a relative one (a 500),
10
+ * so the address is made absolute against the request. Next then rewrites a same-host `Location` back to a relative one:
11
+ * its path, query and hash. A same-origin path that starts with `//` or `/\` therefore reaches the browser as `//host`, another
12
+ * site. Dot segments resolve only here (`/.//evil.com` becomes `//evil.com`), so the resolved path is checked, not the built
13
+ * string. Next also strips a leading part of that path first: a basePath, the default locale (`/en//evil.com`), or
14
+ * `/_next/data/<buildId>` (`/_next/data///evil.com.json`), which turns an interior `//` into a leading one. So every run of
15
+ * `/` and `\` in a same-origin path becomes one `/`, and a same-origin path under `/_next/` is refused.
16
+ * A destination on another origin is returned as given.
17
+ */
18
+ export function siteLocation(destination: string, requestUrl: URL): SiteLocation {
19
+ let location: URL
20
+ try {
21
+ location = new URL(destination, requestUrl)
22
+ } catch {
23
+ return { refused: 'unusable' }
24
+ }
25
+ if (location.origin === requestUrl.origin) {
26
+ const collapsed = collapseSeparators(location.pathname)
27
+ if (collapsed !== location.pathname) location.pathname = collapsed
28
+ if (location.origin !== requestUrl.origin || /[/\\]{2}/.test(location.pathname) || location.pathname.startsWith('/_next/')) return { refused: 'unsafe' }
29
+ }
30
+ return { location }
31
+ }
@@ -0,0 +1,199 @@
1
+ import type { RedirectRule, RedirectStatus } from './wire'
2
+
3
+ /** Drop every trailing `/`. A loop, not `/\/+$/`: that pattern backtracks, so it is quadratic on a long run of slashes in a visitor's path. */
4
+ const dropTrailingSlashes = (path: string): string => {
5
+ let end = path.length
6
+ while (end > 0 && path.charCodeAt(end - 1) === 47) end -= 1
7
+ return path.slice(0, end)
8
+ }
9
+
10
+ /**
11
+ * Normalise a path so both sides of a comparison agree: percent-decode (a malformed escape keeps the input), NFC,
12
+ * strip the trailing slash except at the root, lowercase. The steps are ordered: a percent-encoded combining mark
13
+ * only becomes comparable to its precomposed form once it is decoded. Stored rule sources go through this too.
14
+ */
15
+ export const normalisePath = (input: string): string => {
16
+ let path = input
17
+ try {
18
+ path = decodeURIComponent(path)
19
+ } catch {
20
+ // A bare `%` or `%zz` throws; the input is kept so one bad row does not fail the whole set.
21
+ }
22
+ path = path.normalize('NFC')
23
+ if (path.length > 1 && path.endsWith('/')) path = dropTrailingSlashes(path)
24
+ if (path === '') path = '/'
25
+ return path.toLowerCase()
26
+ }
27
+
28
+ /** Strip the trailing slash as `normalisePath` does and nothing else, so the raw path can be walked segment for segment with the normalised one. */
29
+ const trimTrailingSlash = (path: string): string => {
30
+ if (path.length > 1 && path.endsWith('/')) {
31
+ const trimmed = dropTrailingSlashes(path)
32
+ return trimmed === '' ? '/' : trimmed
33
+ }
34
+ return path
35
+ }
36
+
37
+ /**
38
+ * Make a destination safe for a `Location` header, which is a ByteString: a character above U+00FF or a bare CR or LF throws.
39
+ * Each run outside printable ASCII is percent-encoded; printable ASCII, `%` included, is left alone so an already-encoded
40
+ * remainder is not encoded twice. A run that cannot be encoded (a lone surrogate) is dropped.
41
+ */
42
+ export const encodeLocation = (destination: string): string =>
43
+ destination.replace(/[^!-~]+/gu, (run) => {
44
+ try {
45
+ return encodeURIComponent(run)
46
+ } catch {
47
+ return ''
48
+ }
49
+ })
50
+
51
+ /** Split a request target at the first `?`. `query` keeps its `?`. */
52
+ export const splitQuery = (target: string): { path: string; query: string } => {
53
+ const mark = target.indexOf('?')
54
+ if (mark === -1) return { path: target, query: '' }
55
+ return { path: target.slice(0, mark), query: target.slice(mark) }
56
+ }
57
+
58
+ /** The `:name` and `*` segments a pattern declares, in order. A `*` is listed as `'*'`. */
59
+ export const patternParams = (pattern: string): string[] => {
60
+ const found: string[] = []
61
+ for (const segment of pattern.split('/')) {
62
+ if (segment.startsWith(':') && segment.length > 1) found.push(segment.slice(1))
63
+ else if (segment === '*') found.push('*')
64
+ }
65
+ return found
66
+ }
67
+
68
+ /**
69
+ * A same-site destination (one `/` then anything but another `/`) keeps exactly one leading separator. A remainder or a
70
+ * capture can start with `/` or `\`: a raw `//`, or a `%2F` or `%5C` that decoding turned into one. A browser reads a
71
+ * `Location` of `//host` or `/\host` as another site. Any other destination, an absolute one included, is left as stored.
72
+ * This tidies the built string only: a dot segment (`/.//host`) still resolves to `//host`. The guarantee is the proxy's
73
+ * check of the resolved `Location`.
74
+ */
75
+ const keepOnSite = (stored: string, built: string): string => {
76
+ if (!stored.startsWith('/') || stored.startsWith('//')) return built
77
+ let start = 0
78
+ while (start < built.length && (built[start] === '/' || built[start] === '\\')) start += 1
79
+ return start > 1 ? `/${built.slice(start)}` : built
80
+ }
81
+
82
+ /** Substitute a pattern rule's captures into its destination. */
83
+ export const applyCaptures = (destination: string, captures: Record<string, string>): string =>
84
+ destination
85
+ .split('/')
86
+ .map((segment) => {
87
+ if (segment === '*') return captures['*'] ?? ''
88
+ if (segment.startsWith(':') && segment.length > 1) return captures[segment.slice(1)] ?? segment
89
+ return segment
90
+ })
91
+ .join('/')
92
+
93
+ export type RedirectMatch = {
94
+ destination: string
95
+ status: RedirectStatus
96
+ /** The rule that won. */
97
+ rule: RedirectRule
98
+ }
99
+
100
+ type Compiled = { exact: Map<string, RedirectRule>; prefix: RedirectRule[]; pattern: RedirectRule[] }
101
+
102
+ const compiledFor = new WeakMap<readonly RedirectRule[], Compiled>()
103
+
104
+ /** Sources are normalised again here: a rule stored before a change to `normalisePath` must still be reachable. */
105
+ const compile = (rules: readonly RedirectRule[]): Compiled => {
106
+ const exact = new Map<string, RedirectRule>()
107
+ const prefix: RedirectRule[] = []
108
+ const pattern: RedirectRule[] = []
109
+ for (const rule of rules) {
110
+ const normalised: RedirectRule = { ...rule, source: normalisePath(rule.source) }
111
+ if (normalised.matchType === 'exact') exact.set(normalised.source, normalised)
112
+ else if (normalised.matchType === 'prefix') prefix.push(normalised)
113
+ else pattern.push(normalised)
114
+ }
115
+ // Longest source first, then string order, so the result does not depend on row order.
116
+ prefix.sort((a, b) => b.source.length - a.source.length || a.source.localeCompare(b.source))
117
+ pattern.sort((a, b) => patternParams(a.source).length - patternParams(b.source).length || b.source.length - a.source.length || a.source.localeCompare(b.source))
118
+ return { exact, prefix, pattern }
119
+ }
120
+
121
+ /**
122
+ * Match one pattern against the normalised path, segment by segment, with no regular expression built from the pattern.
123
+ * Captures are taken from `emit`, the raw segments, since normalising decodes and lowercases and a capture is emitted.
124
+ */
125
+ const matchPattern = (pattern: string, path: string, emit: string[]): Record<string, string> | null => {
126
+ const patternSegments = pattern.split('/')
127
+ const pathSegments = path.split('/')
128
+ const captures: Record<string, string> = {}
129
+ for (let index = 0; index < patternSegments.length; index += 1) {
130
+ const expected = patternSegments[index] ?? ''
131
+ if (expected === '*' && index === patternSegments.length - 1) {
132
+ captures['*'] = emit.slice(index).join('/')
133
+ return captures
134
+ }
135
+ const actual = pathSegments[index]
136
+ if (actual === undefined) return null
137
+ if (expected.startsWith(':') && expected.length > 1) {
138
+ if (actual === '') return null
139
+ captures[expected.slice(1)] = emit[index] ?? actual
140
+ continue
141
+ }
142
+ if (expected === '*') {
143
+ if (actual === '') return null
144
+ captures['*'] = emit[index] ?? actual
145
+ continue
146
+ }
147
+ if (expected !== actual) return null
148
+ }
149
+ return pathSegments.length === patternSegments.length ? captures : null
150
+ }
151
+
152
+ /**
153
+ * Resolve a request target (a path with an optional query) against a rule set. Exact rules win, then prefix rules
154
+ * (longest first), then patterns (fewest parameters, then longest source). The rule set is compiled once per array.
155
+ *
156
+ * Matching reads the normalised path; the destination is built from the raw one, so captures and a prefix remainder
157
+ * keep their encoding and case. When decoding introduces a separator (`%2F`) the segment counts differ and the
158
+ * normalised segments are used.
159
+ */
160
+ export const matchRedirect = (rules: readonly RedirectRule[], target: string): RedirectMatch | null => {
161
+ let compiled = compiledFor.get(rules)
162
+ if (!compiled) {
163
+ compiled = compile(rules)
164
+ compiledFor.set(rules, compiled)
165
+ }
166
+ const { path, query } = splitQuery(target)
167
+ const normalised = normalisePath(path)
168
+ const rawSegments = trimTrailingSlash(path).split('/')
169
+ const normalisedSegments = normalised.split('/')
170
+ const outputSegments = rawSegments.length === normalisedSegments.length ? rawSegments : normalisedSegments
171
+
172
+ const carry = (rule: RedirectRule, destination: string): RedirectMatch => {
173
+ if (rule.status === 410) return { destination: '', status: 410, rule }
174
+ const onSite = keepOnSite(rule.destination, destination)
175
+ // A destination with its own query keeps it and drops the request's: merging two query strings gives duplicate keys.
176
+ const withQuery = rule.preserveQuery && query && !onSite.includes('?') ? onSite + query : onSite
177
+ return { destination: encodeLocation(withQuery), status: rule.status, rule }
178
+ }
179
+
180
+ const exact = compiled.exact.get(normalised)
181
+ if (exact) return carry(exact, exact.destination)
182
+
183
+ for (const rule of compiled.prefix) {
184
+ // A prefix ends at a segment boundary: `/blog` does not match `/blogroll`.
185
+ if (normalised === rule.source || normalised.startsWith(`${rule.source}/`)) {
186
+ const consumed = rule.source.split('/').length
187
+ const rest = outputSegments.length > consumed ? `/${outputSegments.slice(consumed).join('/')}` : ''
188
+ const destination = rule.destination.endsWith('/') && rest.startsWith('/') ? rule.destination.slice(0, -1) + rest : rule.destination + rest
189
+ return carry(rule, destination)
190
+ }
191
+ }
192
+
193
+ for (const rule of compiled.pattern) {
194
+ const captures = matchPattern(rule.source, normalised, outputSegments)
195
+ if (captures) return carry(rule, applyCaptures(rule.destination, captures))
196
+ }
197
+
198
+ return null
199
+ }