@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.
- package/README.md +30 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -0
- package/src/bridge/DraftEntries.tsx +41 -0
- package/src/bridge/PreviewCommitBoundary.tsx +19 -0
- package/src/bridge/U90Bridge.tsx +54 -0
- package/src/bridge/caret.ts +317 -0
- package/src/bridge/client.tsx +66 -0
- package/src/bridge/createBridge.ts +1015 -0
- package/src/bridge/hooks.tsx +56 -0
- package/src/bridge/index.ts +9 -0
- package/src/bridge/lexical.ts +177 -0
- package/src/bridge/lexicalDom.ts +212 -0
- package/src/bridge/preserveBlockMoves.ts +88 -0
- package/src/bridge/preview.ts +179 -0
- package/src/bridge/protocol.ts +273 -0
- package/src/bridge/registry.ts +30 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -0
- package/src/components/Entries.tsx +16 -0
- package/src/components/File.tsx +9 -0
- package/src/components/Image.tsx +30 -0
- package/src/components/Link.tsx +24 -0
- package/src/components/NumberText.tsx +9 -0
- package/src/components/RichText.tsx +47 -0
- package/src/components/Text.tsx +15 -0
- package/src/components/Video.tsx +12 -0
- package/src/components/VideoPlayback.tsx +19 -0
- package/src/components/draft.ts +9 -0
- package/src/components/imageProps.ts +26 -0
- package/src/components/index.ts +13 -0
- package/src/components/videoProps.ts +6 -0
- package/src/elements/containers/group.ts +33 -0
- package/src/elements/containers/list.ts +31 -0
- package/src/elements/containers/zone.ts +41 -0
- package/src/elements/declaration.ts +118 -0
- package/src/elements/define.ts +195 -0
- package/src/elements/ids.ts +50 -0
- package/src/elements/index.ts +35 -0
- package/src/elements/jsonSchema.ts +36 -0
- package/src/elements/leaf.ts +98 -0
- package/src/elements/leaves/boolean.ts +30 -0
- package/src/elements/leaves/choice.ts +53 -0
- package/src/elements/leaves/date.ts +39 -0
- package/src/elements/leaves/file.ts +56 -0
- package/src/elements/leaves/image.ts +160 -0
- package/src/elements/leaves/link.ts +149 -0
- package/src/elements/leaves/number.ts +52 -0
- package/src/elements/leaves/query.ts +101 -0
- package/src/elements/leaves/relation.ts +69 -0
- package/src/elements/leaves/rich-text.ts +155 -0
- package/src/elements/leaves/selection.ts +40 -0
- package/src/elements/leaves/single.ts +29 -0
- package/src/elements/leaves/text.ts +103 -0
- package/src/elements/leaves/video.ts +108 -0
- package/src/elements/ops/expand.ts +94 -0
- package/src/elements/ops/overlay.ts +59 -0
- package/src/elements/ops/refs.ts +45 -0
- package/src/elements/ops/seed.ts +185 -0
- package/src/elements/ops/text.ts +20 -0
- package/src/elements/ops/translatable.ts +19 -0
- package/src/elements/ops/upgrade.ts +43 -0
- package/src/elements/ops/validate.ts +56 -0
- package/src/elements/order.ts +10 -0
- package/src/elements/path.ts +43 -0
- package/src/elements/prefix.ts +2 -0
- package/src/elements/registry.ts +48 -0
- package/src/elements/tokens.ts +14 -0
- package/src/elements/walk.ts +42 -0
- package/src/features/client/AskAiProvider.tsx +551 -0
- package/src/features/client/AskAiWidget.tsx +222 -0
- package/src/features/client/ConsentRuntime.tsx +95 -0
- package/src/features/client/FeedbackProvider.tsx +231 -0
- package/src/features/client/FeedbackWidget.tsx +101 -0
- package/src/features/client/PageReporter.tsx +26 -0
- package/src/features/client/README.md +131 -0
- package/src/features/client/SiteFeatures.tsx +250 -0
- package/src/features/client/askAiRecovery.ts +92 -0
- package/src/features/client/askAiStream.ts +59 -0
- package/src/features/client/context.tsx +24 -0
- package/src/features/client/index.ts +24 -0
- package/src/features/client/overlay.tsx +129 -0
- package/src/features/client/styles.css +337 -0
- package/src/features/client/transport.ts +310 -0
- package/src/features/client/useAskAI.ts +45 -0
- package/src/features/client/useConsent.ts +15 -0
- package/src/features/client/useFeedback.ts +33 -0
- package/src/features/client/useSiteSearch.ts +50 -0
- package/src/features/client/verification.ts +141 -0
- package/src/features/consent/README.md +70 -0
- package/src/features/consent/cookieScript.ts +53 -0
- package/src/features/consent/custom.ts +89 -0
- package/src/features/consent/defaults.ts +40 -0
- package/src/features/consent/drivers.ts +233 -0
- package/src/features/consent/loader.ts +248 -0
- package/src/features/consent/registry.ts +398 -0
- package/src/features/contracts/askAi.ts +166 -0
- package/src/features/contracts/askAiSettings.ts +42 -0
- package/src/features/contracts/base.ts +107 -0
- package/src/features/contracts/consent.ts +150 -0
- package/src/features/contracts/feedback.ts +22 -0
- package/src/features/contracts/feedbackSettings.ts +49 -0
- package/src/features/contracts/index.ts +61 -0
- package/src/features/contracts/knowledge.ts +99 -0
- package/src/features/contracts/knowledgePolicy.ts +249 -0
- package/src/features/contracts/search.ts +24 -0
- package/src/features/contracts/verification.ts +52 -0
- package/src/features/contracts/verificationConfig.ts +9 -0
- package/src/features/server/ConsentDefaults.tsx +15 -0
- package/src/features/server/README.md +185 -0
- package/src/features/server/gateway.ts +318 -0
- package/src/features/server/http.ts +179 -0
- package/src/features/server/index.ts +6 -0
- package/src/features/server/session.ts +23 -0
- package/src/features/server/verification.ts +39 -0
- package/src/features/server/verifyVisitor.ts +69 -0
- package/src/languages/config.d.mts +5 -0
- package/src/languages/config.mjs +53 -0
- package/src/languages/decide.ts +72 -0
- package/src/languages/index.ts +4 -0
- package/src/languages/proxy.ts +108 -0
- package/src/languages/routing.ts +93 -0
- package/src/languages/wire.ts +19 -0
- package/src/legacy/bridge.tsx +1987 -0
- package/src/legacy/caret.ts +246 -0
- package/src/legacy/cli.mjs +189 -0
- package/src/legacy/content.ts +993 -0
- package/src/legacy/contract.ts +664 -0
- package/src/legacy/defineBlocks.ts +1602 -0
- package/src/legacy/defineSite.ts +623 -0
- package/src/legacy/defineSite.types.ts +270 -0
- package/src/legacy/draft.tsx +128 -0
- package/src/legacy/image.ts +433 -0
- package/src/legacy/index.ts +209 -0
- package/src/legacy/language-switcher.tsx +58 -0
- package/src/legacy/languageHints.ts +153 -0
- package/src/legacy/languages/fields.ts +131 -0
- package/src/legacy/languages/hash.ts +98 -0
- package/src/legacy/languages/health.ts +97 -0
- package/src/legacy/languages/identity.ts +247 -0
- package/src/legacy/languages/links.ts +81 -0
- package/src/legacy/languages/resolution.ts +56 -0
- package/src/legacy/languages/routing.ts +169 -0
- package/src/legacy/languages/runtime.ts +216 -0
- package/src/legacy/languages/seo.ts +95 -0
- package/src/legacy/languages/settings.ts +211 -0
- package/src/legacy/languages/types.ts +85 -0
- package/src/legacy/languages.ts +8 -0
- package/src/legacy/lexical.ts +170 -0
- package/src/legacy/lexicalDom.ts +166 -0
- package/src/legacy/media.ts +272 -0
- package/src/legacy/nav.ts +176 -0
- package/src/legacy/preview.ts +79 -0
- package/src/legacy/redirects.ts +474 -0
- package/src/legacy/richText.tsx +104 -0
- package/src/legacy/routes.ts +414 -0
- package/src/legacy/seo.ts +327 -0
- package/src/legacy/shape.ts +412 -0
- package/src/legacy/uploads.ts +233 -0
- package/src/legacy/verification.ts +42 -0
- package/src/preview-lock/contracts.ts +42 -0
- package/src/preview-lock/index.ts +3 -0
- package/src/preview-lock/screen.tsx +244 -0
- package/src/preview-lock/server.ts +416 -0
- package/src/redirects/config.d.mts +5 -0
- package/src/redirects/config.mjs +22 -0
- package/src/redirects/index.ts +3 -0
- package/src/redirects/location.ts +31 -0
- package/src/redirects/match.ts +199 -0
- package/src/redirects/proxy.ts +91 -0
- package/src/redirects/wire.ts +30 -0
- package/src/seo/jsonLd.ts +70 -0
- package/src/seo/metadata.ts +127 -0
- package/src/seo/robots.ts +14 -0
- package/src/seo/sitemap.ts +22 -0
- package/src/seo/title.ts +22 -0
- package/src/server/adapters.ts +73 -0
- package/src/server/createU90.ts +96 -0
- package/src/server/handlers.ts +164 -0
- package/src/server/index.ts +6 -0
- package/src/server/read.ts +207 -0
- package/src/server/render.tsx +46 -0
- package/src/server/routes.tsx +171 -0
- package/src/site/assemble.ts +109 -0
- package/src/site/defineSite.ts +132 -0
- package/src/site/documents.ts +18 -0
- package/src/site/fields.ts +59 -0
- package/src/site/index.ts +14 -0
- package/src/site/replies.ts +118 -0
- package/src/site/store.ts +45 -0
- package/src/site/types.ts +75 -0
- package/src/site/version.ts +1 -0
- package/src/site/wire.ts +125 -0
- package/src/switcher/LanguageSwitcher.tsx +42 -0
- package/src/test-preview/README.md +90 -0
- package/src/test-preview/binding.ts +54 -0
- package/src/test-preview/bridge.ts +16 -0
- package/src/test-preview/guard.ts +186 -0
- package/src/test-preview/media.ts +23 -0
- package/src/test-preview/protocol.d.mts +67 -0
- package/src/test-preview/protocol.mjs +222 -0
- package/src/test-preview/runtime.ts +168 -0
- package/src/test-preview/server.ts +10 -0
- package/src/testing/fake.ts +148 -0
- package/src/testing/index.ts +2 -0
- 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>
|