@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,318 @@
1
+ import { verifyWithDeadline } from './verification'
2
+ import { z } from 'zod'
3
+ import {
4
+ AskAiInputSchema,
5
+ AiRatingInputSchema,
6
+ AiConversationPageSchema,
7
+ AI_RECOVERY_MAX_BYTES,
8
+ FeedbackSubmissionSchema,
9
+ FeedbackVerificationSchema,
10
+ parseSearchQuery,
11
+ SearchResponseSchema,
12
+ SEARCH_RESPONSE_BYTES,
13
+ FeedbackWidgetSettingsSchema,
14
+ AskAiWidgetSettingsSchema,
15
+ PublicFeatureBaseSchema,
16
+ FeatureErrorCodeSchema,
17
+ } from '../contracts'
18
+ import {
19
+ failure,
20
+ FeatureFailure,
21
+ FEATURE_PREFIX,
22
+ readBoundedJSON,
23
+ upstreamFetch,
24
+ matchAskAiRoute,
25
+ } from './http'
26
+ import { SessionInputSchema, sessionCookie, visitorCookie, TOKEN_PATTERN } from './session'
27
+ export type FeatureEnvironment = { PLATFORM_URL: string; PAYLOAD_API_KEY: string; SITE_URL: string }
28
+ export type VerifyVisitor = (
29
+ request: Request,
30
+ ) => Promise<{ ok: true } | { ok: false; retryable: boolean }>
31
+ export type SiteFeaturesOptions = {
32
+ env: FeatureEnvironment
33
+ /** Set only for server-verified test traffic, never from browser input. */
34
+ testPreview?: boolean
35
+ /** Server-verified environment namespace; never derived from browser input. */
36
+ visitorCookieName?: string
37
+ fetch?: typeof fetch
38
+ verifyVisitor?: VerifyVisitor
39
+ resolveClientIdentity?: (request: Request) => Promise<string | undefined>
40
+ }
41
+ export function createSiteFeatures(options: SiteFeaturesOptions) {
42
+ const { env } = options
43
+ const handle = async (request: Request): Promise<Response> => {
44
+ let requestId: string | undefined
45
+ try {
46
+ const url = new URL(request.url)
47
+ const aiRoute = matchAskAiRoute(request.method, url)
48
+ const operation = aiRoute
49
+ ? 'ask-ai'
50
+ : url.pathname === FEATURE_PREFIX + '/verify' && request.method === 'POST'
51
+ ? 'verify'
52
+ : url.pathname === FEATURE_PREFIX + '/config' && request.method === 'GET'
53
+ ? 'config'
54
+ : url.pathname === FEATURE_PREFIX + '/session' && request.method === 'POST'
55
+ ? 'session'
56
+ : url.pathname === FEATURE_PREFIX + '/feedback' && request.method === 'POST'
57
+ ? 'feedback'
58
+ : url.pathname === FEATURE_PREFIX + '/search' && request.method === 'GET'
59
+ ? 'search'
60
+ : undefined
61
+ if (!operation || (operation !== 'search' && operation !== 'ask-ai' && url.search))
62
+ return failure('not_found')
63
+ const query = operation === 'search' ? parseSearchQuery(url.searchParams) : null
64
+ if (operation === 'search' && !query) return failure('invalid_request')
65
+ const origin = new URL(env.SITE_URL).origin
66
+ const platform = new URL(env.PLATFORM_URL)
67
+ if (
68
+ !['http:', 'https:'].includes(platform.protocol) ||
69
+ platform.username ||
70
+ platform.password ||
71
+ !env.PAYLOAD_API_KEY
72
+ )
73
+ throw new FeatureFailure('unavailable')
74
+ if (
75
+ operation !== 'config' &&
76
+ operation !== 'search' &&
77
+ !(aiRoute?.operation === 'conversation' && !request.headers.has('origin')) &&
78
+ request.headers.get('origin') !== origin
79
+ )
80
+ return failure('forbidden')
81
+ const headers = new Headers({
82
+ authorization: `Bearer ${env.PAYLOAD_API_KEY}`,
83
+ 'x-u90-feature-audience': origin,
84
+ })
85
+ if (aiRoute) {
86
+ const cookie = visitorCookie(request, options.visitorCookieName)
87
+ if (!cookie) throw new FeatureFailure('unauthorized')
88
+ headers.set('x-u90-feature-session', cookie)
89
+ let body: unknown
90
+ if (aiRoute.operation !== 'conversation') {
91
+ if (request.headers.get('content-type')?.split(';')[0].trim() !== 'application/json')
92
+ throw new FeatureFailure('invalid_request')
93
+ const raw = await readBoundedJSON(request)
94
+ const parsed =
95
+ aiRoute.operation === 'message'
96
+ ? AskAiInputSchema.safeParse(raw)
97
+ : AiRatingInputSchema.safeParse(raw)
98
+ if (!parsed.success) throw new FeatureFailure('invalid_request')
99
+ body = parsed.data
100
+ requestId = parsed.data.requestId.toLowerCase()
101
+ headers.set('content-type', 'application/json')
102
+ }
103
+ const response = await upstreamFetch(
104
+ options.fetch ?? fetch,
105
+ new URL(url.pathname + url.search, platform).href,
106
+ {
107
+ method: request.method,
108
+ headers,
109
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
110
+ },
111
+ AbortSignal.any([request.signal, AbortSignal.timeout(95000)]),
112
+ )
113
+ if (!response.ok) {
114
+ const data = await readBoundedJSON(response)
115
+ const error = (data as { error?: { code?: unknown; retryAfter?: number } })?.error
116
+ const parsed = FeatureErrorCodeSchema.safeParse(error?.code)
117
+ throw new FeatureFailure(parsed.success ? parsed.data : 'unavailable', error?.retryAfter)
118
+ }
119
+ if (aiRoute.operation === 'message') {
120
+ if (
121
+ response.status !== 200 ||
122
+ response.headers.get('content-type')?.split(';')[0].trim() !== 'text/event-stream' ||
123
+ !response.body
124
+ ) {
125
+ await response.body?.cancel()
126
+ throw new FeatureFailure('unavailable')
127
+ }
128
+ return new Response(response.body, {
129
+ headers: {
130
+ 'content-type': 'text/event-stream; charset=utf-8',
131
+ 'cache-control': 'no-store',
132
+ 'x-accel-buffering': 'no',
133
+ },
134
+ })
135
+ }
136
+ let raw: unknown
137
+ try {
138
+ raw = await readBoundedJSON(
139
+ response,
140
+ aiRoute.operation === 'conversation' ? AI_RECOVERY_MAX_BYTES : undefined,
141
+ )
142
+ } catch {
143
+ throw new FeatureFailure('unavailable')
144
+ }
145
+ const parsed =
146
+ aiRoute.operation === 'conversation'
147
+ ? AiConversationPageSchema.safeParse(raw)
148
+ : z.strictObject({ version: z.literal(1), ok: z.literal(true) }).safeParse(raw)
149
+ if (!parsed.success || response.status !== 200) throw new FeatureFailure('unavailable')
150
+ return Response.json(parsed.data, { headers: { 'cache-control': 'no-store' } })
151
+ }
152
+ let upstreamStatus = 200
153
+ const exchange = async (body?: unknown) => {
154
+ const response = await upstreamFetch(
155
+ options.fetch ?? fetch,
156
+ new URL(
157
+ FEATURE_PREFIX + '/' + operation + (query ? '?' + new URLSearchParams(query) : ''),
158
+ platform,
159
+ ).href,
160
+ {
161
+ method: request.method,
162
+ headers,
163
+ body: body === undefined ? undefined : JSON.stringify(body),
164
+ },
165
+ request.signal,
166
+ )
167
+ upstreamStatus = response.status
168
+ let data: unknown
169
+ try {
170
+ data = await readBoundedJSON(
171
+ response,
172
+ operation === 'search' ? SEARCH_RESPONSE_BYTES : undefined,
173
+ )
174
+ } catch {
175
+ throw new FeatureFailure('unavailable')
176
+ }
177
+ if (!response.ok) {
178
+ const error = (data as { error?: { code?: unknown; retryAfter?: number } })?.error
179
+ const parsed = FeatureErrorCodeSchema.safeParse(error?.code)
180
+ const code = parsed.success ? parsed.data : 'unavailable'
181
+ throw new FeatureFailure(code, error?.retryAfter)
182
+ }
183
+ return data
184
+ }
185
+ if (operation === 'config') {
186
+ const parsed = PublicFeatureBaseSchema.safeParse(await exchange())
187
+ if (!parsed.success) throw new FeatureFailure('unavailable')
188
+ const { version, revision, enabled, feedback, consent, askAi, verification } = parsed.data
189
+ return Response.json(
190
+ {
191
+ version,
192
+ revision,
193
+ ...(verification ? { verification } : {}),
194
+ ...(consent ? { consent } : {}),
195
+ feedback: FeedbackWidgetSettingsSchema.parse(feedback ?? {}),
196
+ askAi: AskAiWidgetSettingsSchema.parse(askAi ?? {}),
197
+ enabled: {
198
+ search: enabled.search,
199
+ askAi: enabled.askAi,
200
+ feedback: enabled.feedback,
201
+ consent: enabled.consent,
202
+ },
203
+ },
204
+ { headers: { 'cache-control': 'no-store' } },
205
+ )
206
+ }
207
+ if (operation === 'search') {
208
+ const identity = await options.resolveClientIdentity?.(request)
209
+ if (identity !== undefined) {
210
+ if (!identity || new TextEncoder().encode(identity).length > 512)
211
+ throw new FeatureFailure('unavailable')
212
+ headers.set('x-u90-feature-client', encodeURIComponent(identity))
213
+ }
214
+ const parsed = SearchResponseSchema.safeParse(await exchange())
215
+ if (!parsed.success || upstreamStatus !== 200) throw new FeatureFailure('unavailable')
216
+ return Response.json(parsed.data, { headers: { 'cache-control': 'no-store' } })
217
+ }
218
+ if (request.headers.get('content-type')?.split(';')[0].trim() !== 'application/json')
219
+ throw new FeatureFailure('invalid_request')
220
+ if (operation === 'feedback' || operation === 'verify') {
221
+ const input = (
222
+ operation === 'feedback' ? FeedbackSubmissionSchema : FeedbackVerificationSchema
223
+ ).safeParse(await readBoundedJSON(request))
224
+ if (!input.success) throw new FeatureFailure('invalid_request')
225
+ requestId = input.data.body.requestId
226
+ const cookie = visitorCookie(request, options.visitorCookieName)
227
+ if (!cookie) throw new FeatureFailure('unauthorized')
228
+ headers.set('content-type', 'application/json')
229
+ headers.set('x-u90-feature-session', cookie)
230
+ if (operation === 'verify') {
231
+ const result = z
232
+ .strictObject({ grant: z.string().min(1).max(4096) })
233
+ .safeParse(await exchange(input.data))
234
+ if (!result.success) throw new FeatureFailure('unavailable')
235
+ return Response.json(result.data, { headers: { 'cache-control': 'no-store' } })
236
+ }
237
+ const receipt = z
238
+ .object({ submissionId: z.string().min(1).max(128), receivedAt: z.iso.datetime() })
239
+ .safeParse(await exchange(input.data))
240
+ if (!receipt.success || ![200, 201].includes(upstreamStatus))
241
+ throw new FeatureFailure('unavailable')
242
+ return Response.json(receipt.data, {
243
+ status: upstreamStatus,
244
+ headers: { 'cache-control': 'no-store' },
245
+ })
246
+ }
247
+ const input = SessionInputSchema.safeParse(await readBoundedJSON(request))
248
+ if (!input.success) throw new FeatureFailure('invalid_request')
249
+ requestId = input.data.requestId
250
+ headers.set('content-type', 'application/json')
251
+ const cookie = visitorCookie(request, options.visitorCookieName)
252
+ if (cookie) {
253
+ headers.set('x-u90-feature-session', cookie)
254
+ try {
255
+ const resumed = await exchange({
256
+ requestId,
257
+ mode: input.data.mode === 'identity' ? 'identity' : 'resume',
258
+ })
259
+ if ((resumed as { ok?: unknown })?.ok !== true) throw new FeatureFailure('unavailable')
260
+ const token = (resumed as { token?: unknown }).token
261
+ return Response.json(
262
+ { version: 1, ok: true },
263
+ {
264
+ headers: {
265
+ 'cache-control': 'no-store',
266
+ ...(typeof token === 'string' && TOKEN_PATTERN.test(token)
267
+ ? { 'set-cookie': sessionCookie(token, origin, options.visitorCookieName, options.testPreview) }
268
+ : {}),
269
+ },
270
+ },
271
+ )
272
+ } catch (error) {
273
+ if (!(error instanceof FeatureFailure) || error.code !== 'unauthorized') throw error
274
+ }
275
+ }
276
+ if (input.data.proof === undefined && input.data.mode !== 'identity')
277
+ throw new FeatureFailure('verification_required')
278
+ if (options.verifyVisitor && input.data.proof) {
279
+ const result = await verifyWithDeadline(
280
+ options.verifyVisitor,
281
+ request.url,
282
+ input.data.proof,
283
+ request.signal,
284
+ )
285
+ if (!result.ok)
286
+ throw new FeatureFailure(result.retryable ? 'unavailable' : 'verification_required')
287
+ }
288
+ const issued = (await exchange({
289
+ requestId,
290
+ mode: input.data.mode === 'identity' ? 'identity' : 'issue',
291
+ proof: input.data.proof,
292
+ })) as {
293
+ ok?: unknown
294
+ token?: unknown
295
+ }
296
+ if (
297
+ issued?.ok !== true ||
298
+ typeof issued.token !== 'string' ||
299
+ !TOKEN_PATTERN.test(issued.token)
300
+ )
301
+ throw new FeatureFailure('unavailable')
302
+ return Response.json(
303
+ { version: 1, ok: true },
304
+ {
305
+ headers: {
306
+ 'cache-control': 'no-store',
307
+ 'set-cookie': sessionCookie(issued.token, origin, options.visitorCookieName, options.testPreview),
308
+ },
309
+ },
310
+ )
311
+ } catch (error) {
312
+ return error instanceof FeatureFailure
313
+ ? failure(error.code, requestId, error.retryAfter)
314
+ : failure('unavailable', requestId)
315
+ }
316
+ }
317
+ return { GET: handle, POST: handle }
318
+ }
@@ -0,0 +1,179 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import type { FeatureErrorCode } from '../contracts'
3
+ export const FEATURE_PREFIX = '/api/u90/features/v1'
4
+ export const STATUS: Record<FeatureErrorCode, number> = {
5
+ invalid_request: 400,
6
+ unauthorized: 401,
7
+ verification_required: 403,
8
+ forbidden: 403,
9
+ not_found: 404,
10
+ conflict: 409,
11
+ too_large: 413,
12
+ rate_limited: 429,
13
+ budget_exceeded: 429,
14
+ unavailable: 503,
15
+ incompatible_version: 426,
16
+ }
17
+ export class FeatureFailure extends Error {
18
+ constructor(
19
+ public code: FeatureErrorCode,
20
+ public retryAfter?: number,
21
+ ) {
22
+ super(code)
23
+ }
24
+ }
25
+ export function failure(
26
+ code: FeatureErrorCode,
27
+ requestId: string = randomUUID(),
28
+ retryAfter?: number,
29
+ ): Response {
30
+ const retry =
31
+ Number.isSafeInteger(retryAfter) && retryAfter! >= 0 && retryAfter! <= 86400
32
+ ? retryAfter
33
+ : undefined
34
+ return Response.json(
35
+ {
36
+ version: 1,
37
+ error: {
38
+ code,
39
+ message: code.replaceAll('_', ' '),
40
+ requestId,
41
+ ...(retry === undefined ? {} : { retryAfter: retry }),
42
+ },
43
+ },
44
+ {
45
+ status: STATUS[code],
46
+ headers: {
47
+ 'cache-control': 'no-store',
48
+ ...(retry === undefined ? {} : { 'retry-after': String(retry) }),
49
+ },
50
+ },
51
+ )
52
+ }
53
+ /** Count actual bytes; a missing or dishonest Content-Length cannot bypass the cap. */
54
+ export async function readBoundedJSON(input: Request | Response, cap = 16384, signal?: AbortSignal): Promise<unknown> {
55
+ const reader = input.body?.getReader()
56
+ if (!reader) throw new FeatureFailure('invalid_request')
57
+ const abort = () => { void reader.cancel().catch(() => {}) }
58
+ signal?.addEventListener('abort', abort, { once: true })
59
+ if (signal?.aborted) abort()
60
+ let size = 0
61
+ const chunks: Uint8Array[] = []
62
+ try {
63
+ while (true) {
64
+ const { done, value } = await reader.read()
65
+ if (signal?.aborted) throw new FeatureFailure('unavailable')
66
+ if (done) break
67
+ size += value.byteLength
68
+ if (size > cap) throw new FeatureFailure('too_large')
69
+ chunks.push(value)
70
+ }
71
+ } catch (error) {
72
+ await reader.cancel().catch(() => {})
73
+ throw error
74
+ } finally {
75
+ signal?.removeEventListener('abort', abort)
76
+ reader.releaseLock()
77
+ }
78
+ const bytes = new Uint8Array(size)
79
+ let offset = 0
80
+ for (const chunk of chunks) {
81
+ bytes.set(chunk, offset)
82
+ offset += chunk.byteLength
83
+ }
84
+ try {
85
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
86
+ } catch {
87
+ throw new FeatureFailure('invalid_request')
88
+ }
89
+ }
90
+ /** First-response deadline only. Cancellation stays attached until the returned body finishes. */
91
+ export async function upstreamFetch(
92
+ fetcher: typeof fetch,
93
+ url: string,
94
+ init: RequestInit,
95
+ signal: AbortSignal,
96
+ ): Promise<Response> {
97
+ const controller = new AbortController()
98
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
99
+ const abort = () => {
100
+ controller.abort()
101
+ void reader?.cancel().catch(() => {})
102
+ }
103
+ signal.addEventListener('abort', abort, { once: true })
104
+ if (signal.aborted) abort()
105
+ const timer = setTimeout(() => controller.abort(), 30000)
106
+ const cleanup = () => {
107
+ clearTimeout(timer)
108
+ signal.removeEventListener('abort', abort)
109
+ }
110
+ try {
111
+ if (controller.signal.aborted) throw new FeatureFailure('unavailable')
112
+ const response = await fetcher(url, {
113
+ ...init,
114
+ signal: controller.signal,
115
+ redirect: 'error',
116
+ cache: 'no-store',
117
+ })
118
+ clearTimeout(timer)
119
+ if (response.status >= 300 && response.status < 400) throw new FeatureFailure('unavailable')
120
+ if (!response.body) {
121
+ cleanup()
122
+ return response
123
+ }
124
+ reader = response.body.getReader()
125
+ const stream = new ReadableStream<Uint8Array>({
126
+ async pull(out) {
127
+ try {
128
+ const next = await reader!.read()
129
+ if (controller.signal.aborted) throw new FeatureFailure('unavailable')
130
+ if (next.done) {
131
+ cleanup()
132
+ out.close()
133
+ } else out.enqueue(next.value)
134
+ } catch {
135
+ cleanup()
136
+ out.error(new FeatureFailure('unavailable'))
137
+ }
138
+ },
139
+ async cancel() {
140
+ controller.abort()
141
+ try {
142
+ await reader!.cancel()
143
+ } finally {
144
+ cleanup()
145
+ }
146
+ },
147
+ })
148
+ return new Response(stream, { status: response.status, headers: response.headers })
149
+ } catch {
150
+ cleanup()
151
+ controller.abort()
152
+ throw new FeatureFailure('unavailable')
153
+ }
154
+ }
155
+
156
+ export type AskAiRoute =
157
+ { operation: 'message' } | { operation: 'conversation' | 'rating'; id: string }
158
+ /** Shared approved allowlist only. A3/A5 wire authentication and unbuffered SSE separately. */
159
+ export function matchAskAiRoute(method: string, url: URL): AskAiRoute | null {
160
+ if (url.hash) return null
161
+ const path = url.pathname.slice(FEATURE_PREFIX.length)
162
+ if (!url.pathname.startsWith(FEATURE_PREFIX + '/')) return null
163
+ if (method === 'POST' && !url.search && path === '/ask-ai/messages')
164
+ return { operation: 'message' }
165
+ const conversation = /^\/ask-ai\/conversations\/([a-zA-Z0-9_-]{1,128})$/.exec(path)
166
+ if (method === 'GET' && conversation) {
167
+ const query = [...url.searchParams.entries()]
168
+ if (
169
+ query.length &&
170
+ (query.length !== 1 ||
171
+ query[0][0] !== 'cursor' ||
172
+ !/^[A-Za-z0-9_-]{1,1024}$/.test(query[0][1]))
173
+ )
174
+ return null
175
+ return { operation: 'conversation', id: conversation[1] }
176
+ }
177
+ const rating = /^\/ask-ai\/messages\/([a-zA-Z0-9_-]{1,128})\/rating$/.exec(path)
178
+ return method === 'POST' && !url.search && rating ? { operation: 'rating', id: rating[1] } : null
179
+ }
@@ -0,0 +1,6 @@
1
+ import 'server-only'
2
+ export { createSiteFeatures } from './gateway'
3
+ export type { FeatureEnvironment, VerifyVisitor, SiteFeaturesOptions } from './gateway'
4
+ export { createRecaptchaVerifier } from './verifyVisitor'
5
+
6
+ export { ConsentDefaults } from './ConsentDefaults'
@@ -0,0 +1,23 @@
1
+ import { z } from 'zod'
2
+ import { ActionProofSchema } from '../contracts'
3
+ import { FEATURE_PREFIX } from './http'
4
+ export const SESSION_COOKIE = 'u90_feature_session'
5
+ export const SessionInputSchema = z.strictObject({
6
+ requestId: z.uuid(),
7
+ mode: z.enum(['identity', 'verified']).optional(),
8
+ proof: ActionProofSchema.optional(),
9
+ })
10
+ export const TOKEN_PATTERN = /^[A-Za-z0-9_.-]{1,2048}$/
11
+ export function visitorCookie(request: Request, cookieName = SESSION_COOKIE): string | undefined {
12
+ const values = (request.headers.get('cookie') ?? '')
13
+ .split(';')
14
+ .map((v) => v.trim())
15
+ .filter((v) => v.startsWith(cookieName + '='))
16
+ if (values.length !== 1) return undefined
17
+ const token = values[0].slice(cookieName.length + 1)
18
+ return TOKEN_PATTERN.test(token) ? token : undefined
19
+ }
20
+ export function sessionCookie(token: string, origin: string, cookieName = SESSION_COOKIE, testPreview = false): string {
21
+ if (!TOKEN_PATTERN.test(token)) throw Error('Invalid credential')
22
+ return `${cookieName}=${token}; HttpOnly; SameSite=${testPreview ? 'None' : 'Lax'}; Path=${FEATURE_PREFIX}${testPreview || new URL(origin).protocol === 'https:' ? '; Secure' : ''}`
23
+ }
@@ -0,0 +1,39 @@
1
+ import type { VerifyVisitor } from './gateway'
2
+ /** Bound custom adapters as well as network adapters. Proof stays in this server-only request. */
3
+ export async function verifyWithDeadline(
4
+ verifyVisitor: VerifyVisitor,
5
+ url: string,
6
+ proof: string,
7
+ signal?: AbortSignal,
8
+ ): Promise<Awaited<ReturnType<VerifyVisitor>>> {
9
+ const controller = new AbortController()
10
+ const abort = () => controller.abort()
11
+ signal?.addEventListener('abort', abort, { once: true })
12
+ if (signal?.aborted) abort()
13
+ let timer: ReturnType<typeof setTimeout> | undefined
14
+ try {
15
+ return await Promise.race([
16
+ new Promise<never>((_resolve, reject) => {
17
+ const fail = () => reject(new Error('Verification unavailable.'))
18
+ controller.signal.addEventListener('abort', fail, { once: true })
19
+ if (controller.signal.aborted) fail()
20
+ timer = setTimeout(() => controller.abort(), 10000)
21
+ }),
22
+ Promise.resolve().then(() => {
23
+ controller.signal.throwIfAborted()
24
+ return verifyVisitor(
25
+ new Request(url, {
26
+ method: 'POST',
27
+ body: JSON.stringify({ proof }),
28
+ headers: { 'content-type': 'application/json' },
29
+ signal: controller.signal,
30
+ }),
31
+ )
32
+ }),
33
+ ])
34
+ } finally {
35
+ clearTimeout(timer)
36
+ controller.abort()
37
+ signal?.removeEventListener('abort', abort)
38
+ }
39
+ }
@@ -0,0 +1,69 @@
1
+ import type { VerifyVisitor } from './gateway'
2
+ import { readBoundedJSON } from './http'
3
+ /** Server-only reCAPTCHA v3 adapter. Never resolve client identity from browser forwarding headers. */
4
+ export function createRecaptchaVerifier(options: {
5
+ secret: string
6
+ hostname: string
7
+ action: string
8
+ minimumScore?: number
9
+ fetch?: typeof fetch
10
+ }): VerifyVisitor {
11
+ return async (request) => {
12
+ const minimum = options.minimumScore ?? 0.5
13
+ if (
14
+ !options.secret ||
15
+ !options.hostname ||
16
+ !options.action ||
17
+ !Number.isFinite(minimum) ||
18
+ minimum < 0 ||
19
+ minimum > 1
20
+ )
21
+ return { ok: false, retryable: true }
22
+ try {
23
+ const body = (await readBoundedJSON(request)) as { proof?: unknown }
24
+ if (
25
+ typeof body.proof !== 'string' ||
26
+ !body.proof ||
27
+ new TextEncoder().encode(body.proof).length > 4096
28
+ )
29
+ return { ok: false, retryable: false }
30
+ const response = await (options.fetch ?? fetch)(
31
+ 'https://www.google.com/recaptcha/api/siteverify',
32
+ {
33
+ method: 'POST',
34
+ redirect: 'error',
35
+ cache: 'no-store',
36
+ signal: AbortSignal.any([request.signal, AbortSignal.timeout(10000)]),
37
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
38
+ body: new URLSearchParams({ secret: options.secret, response: body.proof }).toString(),
39
+ },
40
+ )
41
+ if (!response.ok) return { ok: false, retryable: true }
42
+ const result = (await readBoundedJSON(response)) as {
43
+ success?: unknown
44
+ hostname?: unknown
45
+ action?: unknown
46
+ score?: unknown
47
+ 'error-codes'?: unknown
48
+ }
49
+ if (
50
+ Array.isArray(result['error-codes']) &&
51
+ result['error-codes'].some((e) =>
52
+ ['missing-input-secret', 'invalid-input-secret', 'bad-request'].includes(e),
53
+ )
54
+ )
55
+ return { ok: false, retryable: true }
56
+ return result.success === true &&
57
+ result.hostname === options.hostname &&
58
+ result.action === options.action &&
59
+ typeof result.score === 'number' &&
60
+ Number.isFinite(result.score) &&
61
+ result.score >= minimum &&
62
+ result.score <= 1
63
+ ? { ok: true }
64
+ : { ok: false, retryable: false }
65
+ } catch {
66
+ return { ok: false, retryable: true }
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,5 @@
1
+ /** Adds `env.U90_LANGUAGES_SNAPSHOT` to a Next config: the site's languages as `/site` answered them at build time. */
2
+ export function withLanguagesSnapshot<C extends { env?: Record<string, string | undefined> }>(
3
+ config: C,
4
+ options?: { env?: NodeJS.ProcessEnv; fetch?: typeof fetch },
5
+ ): Promise<C>