@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,310 @@
1
+ import { acquireVerificationProof } from './verification'
2
+ import { z } from 'zod'
3
+ import { consumeAiStream } from './askAiStream'
4
+ import {
5
+ AiConversationPageSchema,
6
+ AI_RECOVERY_MAX_BYTES,
7
+ AskAiInputSchema,
8
+ AiRatingInputSchema,
9
+ type AskAiInput,
10
+ type AiStreamEvent,
11
+ } from '../contracts'
12
+ import {
13
+ FeatureErrorSchema,
14
+ SearchQuerySchema,
15
+ SearchResponseSchema,
16
+ SEARCH_RESPONSE_BYTES,
17
+ FeedbackInputSchema,
18
+ type FeedbackInput,
19
+ type FeedbackReceipt,
20
+ PublicFeatureBaseSchema,
21
+ type FeatureErrorCode,
22
+ type PublicFeatureBase,
23
+ } from '../contracts'
24
+
25
+ export type VisitorVerification = {
26
+ acquireProof: (options: {
27
+ signal: AbortSignal
28
+ action?: import('../contracts').VerificationAction
29
+ }) => Promise<string | null>
30
+ }
31
+ export class FeatureClientError extends Error {
32
+ constructor(public readonly code: FeatureErrorCode = 'unavailable') {
33
+ super(
34
+ code === 'verification_required'
35
+ ? 'Verification required. Please retry.'
36
+ : 'Site features unavailable. Please retry.',
37
+ )
38
+ this.name = 'FeatureClientError'
39
+ }
40
+ }
41
+ export type FeatureTransportOptions = {
42
+ endpoint?: string
43
+ verification?: VisitorVerification
44
+ fetch?: typeof fetch
45
+ }
46
+
47
+ /** Same-origin browser transport. It never exposes or stores the HttpOnly credential. */
48
+ export function createFeatureTransport(options: FeatureTransportOptions = {}) {
49
+ const endpoint = options.endpoint ?? '/api/u90/features/v1'
50
+ if (!/^\/(?!\/)[A-Za-z0-9/_-]+$/.test(endpoint))
51
+ throw new Error('Expected a same-origin feature endpoint.')
52
+ const request = async (operation: string, signal: AbortSignal, body?: unknown, cap = 65536) => {
53
+ signal.throwIfAborted()
54
+ const response = await (options.fetch ?? fetch)(`${endpoint}/${operation}`, {
55
+ method: body === undefined ? 'GET' : 'POST',
56
+ credentials: 'same-origin',
57
+ cache: 'no-store',
58
+ headers: body === undefined ? undefined : { 'content-type': 'application/json' },
59
+ body: body === undefined ? undefined : JSON.stringify(body),
60
+ signal,
61
+ })
62
+ return readJson(response, cap)
63
+ }
64
+ const readJson = async (response: Response, cap: number) => {
65
+ // Bound even a misconfigured same-origin endpoint's response.
66
+ const reader = response.body?.getReader()
67
+ const chunks: Uint8Array[] = []
68
+ let bytes = 0
69
+ if (!reader) throw new FeatureClientError()
70
+ try {
71
+ for (;;) {
72
+ const { done, value } = await reader.read()
73
+ if (done) break
74
+ bytes += value.byteLength
75
+ if (bytes > cap) throw new FeatureClientError()
76
+ chunks.push(value)
77
+ }
78
+ } finally {
79
+ await reader.cancel().catch(() => {})
80
+ }
81
+ const all = new Uint8Array(bytes)
82
+ let offset = 0
83
+ for (const chunk of chunks) {
84
+ all.set(chunk, offset)
85
+ offset += chunk.byteLength
86
+ }
87
+ let data: unknown
88
+ try {
89
+ data = JSON.parse(new TextDecoder().decode(all))
90
+ } catch {
91
+ throw new FeatureClientError()
92
+ }
93
+ if (!response.ok) {
94
+ const parsed = FeatureErrorSchema.safeParse(data)
95
+ throw new FeatureClientError(parsed.success ? parsed.data.error.code : 'unavailable')
96
+ }
97
+ return data
98
+ }
99
+ const boundedSignal = (signal?: AbortSignal) =>
100
+ signal ? AbortSignal.any([signal, AbortSignal.timeout(10000)]) : AbortSignal.timeout(10000)
101
+ let pending: Promise<void> | undefined
102
+ const grants = new Map<string, { body: string; grant: string }>()
103
+ const proof = async (action: import('../contracts').VerificationAction, signal: AbortSignal) => {
104
+ const config = PublicFeatureBaseSchema.parse(await request('config', boundedSignal(signal)))
105
+ if (config.verification?.mode === 'disabled')
106
+ return acquireVerificationProof(config.verification, action, { signal })
107
+ if (options.verification)
108
+ return waitForSignal(options.verification.acquireProof({ signal, action }), signal)
109
+ if (!config.verification) throw new FeatureClientError('verification_required')
110
+ return acquireVerificationProof(config.verification, action, { signal })
111
+ }
112
+ return {
113
+ async aiMessage(
114
+ input: AskAiInput,
115
+ signal: AbortSignal,
116
+ receive: (event: AiStreamEvent) => void,
117
+ ) {
118
+ const response = await waitForSignal(
119
+ (options.fetch ?? fetch)(`${endpoint}/ask-ai/messages`, {
120
+ method: 'POST',
121
+ credentials: 'same-origin',
122
+ cache: 'no-store',
123
+ headers: { 'content-type': 'application/json' },
124
+ body: JSON.stringify(AskAiInputSchema.parse(input)),
125
+ signal,
126
+ }),
127
+ signal,
128
+ )
129
+ if (!response.ok) {
130
+ await readJson(response, 16384)
131
+ throw new FeatureClientError()
132
+ }
133
+ await consumeAiStream(response, input.requestId, receive, signal)
134
+ },
135
+ async aiConversation(id: string, cursor: string | null, signal: AbortSignal) {
136
+ if (
137
+ !/^[A-Za-z0-9_-]{1,128}$/.test(id) ||
138
+ (cursor !== null && !/^[A-Za-z0-9_-]{1,1024}$/.test(cursor))
139
+ )
140
+ throw new FeatureClientError('invalid_request')
141
+ const bounded = boundedSignal(signal)
142
+ return AiConversationPageSchema.parse(
143
+ await waitForSignal(
144
+ request(
145
+ `ask-ai/conversations/${id}${cursor ? '?cursor=' + cursor : ''}`,
146
+ bounded,
147
+ undefined,
148
+ AI_RECOVERY_MAX_BYTES,
149
+ ),
150
+ bounded,
151
+ ),
152
+ )
153
+ },
154
+ async aiRating(
155
+ id: string,
156
+ input: { requestId: string; rating: 'helpful' | 'unhelpful' },
157
+ signal: AbortSignal,
158
+ ) {
159
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new FeatureClientError('invalid_request')
160
+ const bounded = boundedSignal(signal)
161
+ const result = await waitForSignal(
162
+ request(`ask-ai/messages/${id}/rating`, bounded, AiRatingInputSchema.parse(input), 16384),
163
+ bounded,
164
+ )
165
+ if (!z.object({ version: z.literal(1), ok: z.literal(true) }).safeParse(result).success)
166
+ throw new FeatureClientError()
167
+ },
168
+ async search(query: string, signal: AbortSignal) {
169
+ const input = SearchQuerySchema.parse({ q: query })
170
+ const bounded = boundedSignal(signal)
171
+ const result = SearchResponseSchema.safeParse(
172
+ await waitForSignal(
173
+ request(
174
+ 'search?' + new URLSearchParams(input),
175
+ bounded,
176
+ undefined,
177
+ SEARCH_RESPONSE_BYTES,
178
+ ),
179
+ bounded,
180
+ ),
181
+ )
182
+ if (!result.success) throw new FeatureClientError()
183
+ return result.data.results
184
+ },
185
+ async feedback(input: FeedbackInput, signal: AbortSignal): Promise<FeedbackReceipt> {
186
+ const body = FeedbackInputSchema.parse(input)
187
+ const serialized = JSON.stringify(body)
188
+ let prior = grants.get(body.requestId)
189
+ if (prior && prior.body !== serialized) throw new FeatureClientError('conflict')
190
+ if (!prior) {
191
+ await request('session', boundedSignal(signal), {
192
+ requestId: crypto.randomUUID(),
193
+ mode: 'identity',
194
+ })
195
+ const token = await proof('feedback_submit', signal)
196
+ const result = z
197
+ .object({ grant: z.string().min(1).max(4096) })
198
+ .parse(await request('verify', boundedSignal(signal), { body, proof: token }))
199
+ prior = { body: serialized, grant: result.grant }
200
+ if (grants.size >= 100) grants.delete(grants.keys().next().value!)
201
+ grants.set(body.requestId, prior)
202
+ }
203
+ const receipt = z
204
+ .object({ submissionId: z.string().min(1).max(128), receivedAt: z.iso.datetime() })
205
+ .safeParse(
206
+ await waitForSignal(
207
+ request('feedback', boundedSignal(signal), { body, grant: prior.grant }).catch(
208
+ (error) => {
209
+ if (
210
+ error instanceof FeatureClientError &&
211
+ ['forbidden', 'conflict'].includes(error.code)
212
+ )
213
+ grants.delete(body.requestId)
214
+ throw error
215
+ },
216
+ ),
217
+ signal,
218
+ ),
219
+ )
220
+ if (!receipt.success) throw new FeatureClientError()
221
+ return receipt.data
222
+ },
223
+ async config(signal?: AbortSignal): Promise<PublicFeatureBase> {
224
+ const result = PublicFeatureBaseSchema.safeParse(
225
+ await request('config', boundedSignal(signal)),
226
+ )
227
+ if (!result.success) throw new FeatureClientError()
228
+ return result.data
229
+ },
230
+ ensureSession(signal?: AbortSignal): Promise<void> {
231
+ if (signal?.aborted) return Promise.reject(signal.reason)
232
+ if (pending) return pending
233
+ const bounded = signal
234
+ ? AbortSignal.any([signal, AbortSignal.timeout(120000)])
235
+ : AbortSignal.timeout(120000)
236
+ const requestId = crypto.randomUUID()
237
+ const post = async (proof?: string | null) => {
238
+ const data = (await request('session', boundedSignal(bounded), {
239
+ requestId,
240
+ ...(proof === undefined ? {} : { proof }),
241
+ })) as { version?: unknown; ok?: unknown }
242
+ if (data?.version !== 1 || data.ok !== true) throw new FeatureClientError()
243
+ }
244
+ pending = (async () => {
245
+ const attempt = async (proof?: string | null) => {
246
+ try {
247
+ await post(proof)
248
+ } catch (error) {
249
+ if (bounded.aborted || error instanceof FeatureClientError) throw error
250
+ // Uncertain issuance gets one cookie-only probe, with the same logical ID.
251
+ await post()
252
+ }
253
+ }
254
+ try {
255
+ await attempt()
256
+ return
257
+ } catch (error) {
258
+ if (!(error instanceof FeatureClientError) || error.code !== 'verification_required')
259
+ throw error
260
+ }
261
+ let token: string | null
262
+ try {
263
+ token = await new Promise<string | null>((resolve, reject) => {
264
+ const aborted = () => reject(bounded.reason)
265
+ bounded.addEventListener('abort', aborted, { once: true })
266
+ Promise.resolve()
267
+ .then(() => {
268
+ bounded.throwIfAborted()
269
+ return proof('ask_ai_session', bounded)
270
+ })
271
+ .then(resolve, reject)
272
+ .finally(() => bounded.removeEventListener('abort', aborted))
273
+ })
274
+ } catch {
275
+ throw new FeatureClientError('verification_required')
276
+ }
277
+ bounded.throwIfAborted()
278
+ if (token !== null && (!token || new TextEncoder().encode(token).length > 4096))
279
+ throw new FeatureClientError('verification_required')
280
+ await attempt(token)
281
+ })().finally(() => {
282
+ pending = undefined
283
+ })
284
+ return pending
285
+ },
286
+ }
287
+ }
288
+
289
+ /** Caller cancellation must not own root-coalesced session/proof work. */
290
+ export function waitForSignal<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
291
+ return new Promise((resolve, reject) => {
292
+ const aborted = () => {
293
+ cleanup()
294
+ reject(signal.reason)
295
+ }
296
+ const cleanup = () => signal.removeEventListener('abort', aborted)
297
+ signal.addEventListener('abort', aborted, { once: true })
298
+ promise.then(
299
+ (value) => {
300
+ cleanup()
301
+ resolve(value)
302
+ },
303
+ (error) => {
304
+ cleanup()
305
+ reject(error)
306
+ },
307
+ )
308
+ if (signal.aborted) aborted()
309
+ })
310
+ }
@@ -0,0 +1,45 @@
1
+ 'use client'
2
+ import { createContext, useCallback, useContext, useRef, type RefCallback } from 'react'
3
+ import type { AskAiWidgetSettings } from '../contracts'
4
+ import type { AskAiMessage } from './askAiRecovery'
5
+ import { useSiteFeatures } from './context'
6
+ export type AskAiController = {
7
+ open: boolean
8
+ messages: AskAiMessage[]
9
+ status: 'idle' | 'sending' | 'streaming' | 'error'
10
+ error: string | null
11
+ ratingError: string | null
12
+ settings: AskAiWidgetSettings
13
+ draft: string
14
+ setDraft(value: string): void
15
+ recovering: boolean
16
+ hasEarlier: boolean
17
+ canRecover: boolean
18
+ awaitingAnswer: boolean
19
+ canRetry: boolean
20
+ canNewAttempt: boolean
21
+ ratings: Record<string, 'helpful' | 'unhelpful'>
22
+ show(): void
23
+ hide(): void
24
+ send(message: string): Promise<void>
25
+ stop(): void
26
+ startNewConversation(): void
27
+ retry(): Promise<void>
28
+ retryAnswer(): Promise<void>
29
+ recover(): Promise<void>
30
+ loadEarlier(): Promise<void>
31
+ rate(messageId: string, rating: 'helpful' | 'unhelpful'): Promise<void>
32
+ launcherRef: RefCallback<HTMLButtonElement>
33
+ }
34
+ export const AskAiContext = createContext<Omit<AskAiController, 'launcherRef'> | null>(null)
35
+ export function useAskAI(): AskAiController {
36
+ const value = useContext(AskAiContext),
37
+ { registerLauncher } = useSiteFeatures(),
38
+ owner = useRef(Symbol('Ask AI launcher'))
39
+ const launcherRef = useCallback<RefCallback<HTMLButtonElement>>(
40
+ (node) => registerLauncher('askAi', owner.current, node),
41
+ [registerLauncher],
42
+ )
43
+ if (!value) throw Error('useAskAI requires a SiteFeatures root.')
44
+ return { ...value, launcherRef }
45
+ }
@@ -0,0 +1,15 @@
1
+ 'use client'
2
+ import { createContext, useContext } from 'react'
3
+ import type { ConsentState } from '../contracts/consent'
4
+ export type ConsentController = {
5
+ state: ConsentState
6
+ ready: boolean
7
+ error: string | null
8
+ openPreferences(): void
9
+ }
10
+ export const ConsentContext = createContext<ConsentController | null>(null)
11
+ export function useConsent(): ConsentController {
12
+ const value = useContext(ConsentContext)
13
+ if (!value) throw new Error('Consent hooks require a SiteFeatures root.')
14
+ return value
15
+ }
@@ -0,0 +1,33 @@
1
+ 'use client'
2
+ import { createContext, useCallback, useContext, useRef, type RefCallback } from 'react'
3
+ import type { PageContext } from '../contracts'
4
+ import { useSiteFeatures } from './context'
5
+ export type FeedbackAnswer = { rating?: 1 | 2 | 3 | 4 | 5; comment?: string }
6
+ export type FeedbackController = {
7
+ open: boolean
8
+ status: 'idle' | 'submitting' | 'success' | 'error'
9
+ error: string | null
10
+ page: Readonly<PageContext> | null
11
+ currentPage: Readonly<PageContext> | null
12
+ show(): void
13
+ hide(): void
14
+ submit(answer: FeedbackAnswer): Promise<void>
15
+ retry(): Promise<void>
16
+ startNew(): void
17
+ launcherRef: RefCallback<HTMLButtonElement>
18
+ }
19
+ export const FeedbackContext = createContext<Omit<FeedbackController, 'launcherRef'> | null>(null)
20
+ /** Submission ownership is shared; register complete presentations via components.Feedback. */
21
+ export function useFeedback(): FeedbackController {
22
+ const controller = useContext(FeedbackContext)
23
+ const { registerLauncher } = useSiteFeatures()
24
+ const owner = useRef(Symbol('feedback launcher'))
25
+ const launcherRef = useCallback<RefCallback<HTMLButtonElement>>(
26
+ (node) => {
27
+ registerLauncher('feedback', owner.current, node)
28
+ },
29
+ [registerLauncher],
30
+ )
31
+ if (!controller) throw new Error('useFeedback requires a SiteFeatures root.')
32
+ return { ...controller, launcherRef }
33
+ }
@@ -0,0 +1,50 @@
1
+ 'use client'
2
+ import { useEffect, useMemo, useState } from 'react'
3
+ import type { SearchHit } from '../contracts'
4
+ import { useSiteFeatures } from './context'
5
+ type Result = {
6
+ results: SearchHit[]
7
+ state: 'idle' | 'loading' | 'ready' | 'error'
8
+ error: string | null
9
+ }
10
+ const idle: Result = { results: [], state: 'idle', error: null }
11
+ export function useSiteSearch(query: string): Result {
12
+ const { search, config } = useSiteFeatures()
13
+ const normalized = query.trim()
14
+ const enabled = config?.enabled.search === true
15
+ const input = useMemo(
16
+ () => ({ query: normalized, enabled, search }),
17
+ [normalized, enabled, search],
18
+ )
19
+ const [value, setValue] = useState<Result & { input: typeof input | null }>({
20
+ ...idle,
21
+ input: null,
22
+ })
23
+ useEffect(() => {
24
+ if (!input.enabled || input.query.length < 2) return
25
+ const controller = new AbortController()
26
+ const timer = setTimeout(() => {
27
+ void input.search(input.query, controller.signal).then(
28
+ (results) => {
29
+ if (!controller.signal.aborted) setValue({ input, results, state: 'ready', error: null })
30
+ },
31
+ () => {
32
+ if (!controller.signal.aborted)
33
+ setValue({
34
+ input,
35
+ results: [],
36
+ state: 'error',
37
+ error: 'Search unavailable. Please retry.',
38
+ })
39
+ },
40
+ )
41
+ }, 200)
42
+ return () => {
43
+ clearTimeout(timer)
44
+ controller.abort()
45
+ }
46
+ }, [input])
47
+ if (!enabled || normalized.length < 2) return idle
48
+ if (value.input !== input) return { results: [], state: 'loading', error: null }
49
+ return { results: value.results, state: value.state, error: value.error }
50
+ }
@@ -0,0 +1,141 @@
1
+ 'use client'
2
+ import { useCallback } from 'react'
3
+ import {
4
+ ProofSchema,
5
+ VerificationConfigSchema,
6
+ type VerificationAction,
7
+ type VerificationConfig,
8
+ } from '../contracts'
9
+ export { VERIFICATION_CSP } from '../contracts'
10
+ type Google = {
11
+ ready(cb: () => void): void
12
+ execute(key: string, options: { action: string }): Promise<string>
13
+ }
14
+ type Turnstile = {
15
+ render(container: HTMLElement, options: Record<string, unknown>): string
16
+ remove(id: string): void
17
+ }
18
+ type Providers = { grecaptcha?: Google & { enterprise?: Google }; turnstile?: Turnstile }
19
+ const scripts = new Map<string, Promise<void>>()
20
+ function wait<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
21
+ return new Promise((resolve, reject) => {
22
+ const abort = () => {
23
+ signal.removeEventListener('abort', abort)
24
+ reject(signal.reason)
25
+ }
26
+ signal.addEventListener('abort', abort, { once: true })
27
+ work.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort))
28
+ if (signal.aborted) abort()
29
+ })
30
+ }
31
+ function load(url: string): Promise<void> {
32
+ const existing = scripts.get(url)
33
+ if (existing) return existing
34
+ const work = new Promise<void>((resolve, reject) => {
35
+ const script = document.createElement('script')
36
+ script.src = url
37
+ script.async = true
38
+ const timeout = setTimeout(() => {
39
+ script.remove()
40
+ reject(Error('Verification script unavailable.'))
41
+ }, 10000)
42
+ script.onload = () => {
43
+ clearTimeout(timeout)
44
+ resolve()
45
+ }
46
+ script.onerror = () => {
47
+ clearTimeout(timeout)
48
+ script.remove()
49
+ reject(Error('Verification script unavailable.'))
50
+ }
51
+ document.head.append(script)
52
+ })
53
+ scripts.set(url, work)
54
+ void work.catch(() => scripts.delete(url))
55
+ return work
56
+ }
57
+ /** Lazy, single-use proof. Nothing loads until the caller submits a protected action. */
58
+ export async function acquireVerificationProof(
59
+ config: VerificationConfig,
60
+ action: VerificationAction,
61
+ options: { signal?: AbortSignal } = {},
62
+ ): Promise<string | null> {
63
+ const parsed = VerificationConfigSchema.parse(config)
64
+ if (parsed.readiness !== 'configured' || (parsed.mode !== 'disabled' && !parsed.siteKey))
65
+ throw Error('Verification unavailable. Please retry or contact the site owner.')
66
+ const signal = options.signal
67
+ ? AbortSignal.any([options.signal, AbortSignal.timeout(120000)])
68
+ : AbortSignal.timeout(120000)
69
+ signal.throwIfAborted()
70
+ if (parsed.mode === 'disabled') return null
71
+ const siteKey = parsed.siteKey!
72
+ const providers = window as unknown as Providers
73
+ if (parsed.provider !== 'turnstile') {
74
+ const enterprise = parsed.provider === 'google-cloud'
75
+ const script =
76
+ 'https://www.google.com/recaptcha/' +
77
+ (enterprise ? 'enterprise.js' : 'api.js') +
78
+ '?render=' +
79
+ encodeURIComponent(siteKey)
80
+ await wait(load(script), signal)
81
+ const api = enterprise ? providers.grecaptcha?.enterprise : providers.grecaptcha
82
+ if (!api) throw Error('Verification unavailable. Please retry.')
83
+ await wait(new Promise<void>((resolve) => api.ready(resolve)), signal)
84
+ return ProofSchema.parse(await wait(api.execute(siteKey, { action }), signal))
85
+ }
86
+ await wait(load('https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'), signal)
87
+ const api = providers.turnstile
88
+ if (!api) throw Error('Verification unavailable. Please retry.')
89
+ const previous = document.activeElement
90
+ const dialog = document.createElement('dialog')
91
+ dialog.className = 'u90-verification-dialog'
92
+ dialog.setAttribute('aria-label', 'Verify your submission')
93
+ const title = document.createElement('p')
94
+ title.textContent = 'Complete this check to send your submission.'
95
+ const container = document.createElement('div')
96
+ container.setAttribute('aria-label', 'Security check')
97
+ container.tabIndex = 0
98
+ const cancel = document.createElement('button')
99
+ cancel.type = 'button'
100
+ cancel.textContent = 'Cancel verification'
101
+ dialog.append(title, container, cancel)
102
+ document.body.append(dialog)
103
+ dialog.showModal()
104
+ container.focus()
105
+ let id: string | undefined
106
+ try {
107
+ return ProofSchema.parse(
108
+ await wait(
109
+ new Promise<string>((resolve, reject) => {
110
+ const cancelled = () => reject(Error('Verification cancelled. Your draft is preserved.'))
111
+ cancel.onclick = cancelled
112
+ dialog.addEventListener('cancel', cancelled, { once: true })
113
+ id = api.render(container, {
114
+ sitekey: siteKey,
115
+ action,
116
+ callback: resolve,
117
+ 'error-callback': () => reject(Error('Verification failed. Please retry.')),
118
+ 'expired-callback': () => reject(Error('Verification expired. Please retry.')),
119
+ })
120
+ }),
121
+ signal,
122
+ ),
123
+ )
124
+ } finally {
125
+ if (id !== undefined) api.remove(id)
126
+ dialog.close()
127
+ dialog.remove()
128
+ if (previous instanceof HTMLElement && previous.isConnected) previous.focus()
129
+ }
130
+ }
131
+ /** Custom forms call this on submit and forward the proof separately from answer values. */
132
+ export function useFormVerification(config: VerificationConfig | undefined) {
133
+ return useCallback(
134
+ (options: { signal?: AbortSignal } = {}) => {
135
+ if (!config)
136
+ return Promise.reject(Error('Verification unavailable. Please contact the site owner.'))
137
+ return acquireVerificationProof(config, 'form_submit', options)
138
+ },
139
+ [config],
140
+ )
141
+ }
@@ -0,0 +1,70 @@
1
+ # Consent setup and lifecycle
2
+
3
+ Install the existing same-origin feature gateway. Mount this standard setup once in the consuming server root layout, independently of whether Consent is currently enabled:
4
+
5
+ ```tsx
6
+ import { SiteFeatures } from '@unitninety/site-kit/features'
7
+ import { ConsentDefaults } from '@unitninety/site-kit/features/server'
8
+ import '@unitninety/site-kit/features/styles.css'
9
+
10
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
11
+ return <html lang="en"><body>
12
+ <ConsentDefaults />
13
+ <SiteFeatures>{children}</SiteFeatures>
14
+ </body></html>
15
+ }
16
+ ```
17
+
18
+ Keep the root around children from their first render, including while choosing custom presentations. Remote accepted configuration controls activation, provider/source, Google mode and integrations. No installation, grant or mode prop is supported. Later administration does not require layout edits. Reuse the native gateway handlers; never add a competing catch-all.
19
+
20
+ `ConsentDefaults` is exported only through the server entry. It renders a stable-ID denied-only `next/script` with `beforeInteractive`, without provider IDs, tag sources, configuration fetches or pageviews. Installed Next 16.3.4 queues App Router scripts before hydration; this does not promise native parser-time execution or control unmanaged async scripts. Remove old CMP, GA and other raw optional installers. The browser driver defensively runs the same initializer before its own first managed Google insertion if its marker is absent; conflicting unmanaged Google ownership aborts insertion. The marker is bookkeeping, never consent authority, and defaults never replay on refresh/navigation/regrant.
21
+
22
+ `useConsent()` exposes exactly `{ state, ready, error, openPreferences }`. A real preferences button calls `openPreferences`; unavailable or throwing providers produce a handled error. The root supplies an accessible status/retry control and a visitor-controlled **Reload to apply consent changes** button when needed. It never reloads automatically on Close or assumes any event confirms a saved choice. Existing preferences controls remain usable during disable/withdrawal where the provider is still available.
23
+
24
+ ## Banner embed URL
25
+
26
+ The Cookies workspace accepts an HTTPS script URL from any banner provider. With an active
27
+ `custom` installation and no `customConsent` registration, Site Kit inserts that script once per
28
+ document. No adapter or consent event is required just to display the provider's banner. The URL
29
+ must point to its external JavaScript, not a provider dashboard, iframe page or pasted HTML. Providers
30
+ that need inline initialization, extra attributes or synchronous parser execution may still need
31
+ website code. The website Content Security Policy must allow the provider’s script origin and
32
+ any additional resources it needs; saving a URL does not relax that policy. Custom script changes retain the existing `code.write` permission requirement.
33
+
34
+ Embedding does not grant U90 consent: `useConsent().ready` stays false, all U90-managed optional
35
+ integrations remain denied, and the provider's own controls handle preferences. Unregistered
36
+ `u90:consent` events are ignored. Successful loading alone is not a consent signal. A missing optional
37
+ registration is not an installation error; actual network failures/timeouts still surface normally.
38
+ Already executed scripts cannot be undone by disabling the feature; reload the document to apply
39
+ URL removal/replacement. Remove duplicate legacy installers before enabling the embed.
40
+
41
+ Recognized direct CookieScript URLs retain their built-in category connection automatically. Other
42
+ URLs use the same provider-independent embed path. Existing saved installations remain compatible.
43
+
44
+ ## Optional custom consent connection
45
+
46
+ The optional `SiteFeatures` prop `customConsent?: CustomConsentRegistration` selects exactly one code-owned registration, only when the accepted remote provider is `custom`:
47
+
48
+ ```ts
49
+ // Stable module identity, or useMemo with stable callback/adapter dependencies.
50
+ const customConsent = { adapter: myConsentAdapter }
51
+ // Alternatively: const customConsent = { openPreferences: reopenMyCMP }
52
+ ```
53
+
54
+ The adapter supplies `read`, `subscribe` (returning cleanup) and `openPreferences`. The event bridge supplies only the reopen callback and publishes a fresh complete validated `ConsentState` in `window` event `u90:consent` during its active subscription. An event before registration cannot qualify. Missing/conflicting registration, invalid/unready snapshots and missing reopening deny optional code. An absent registration still permits the banner embed described above; an explicitly invalid registration remains an error. There is no arbitrary-window-global fallback. Factories are side-effect free; committed ownership subscribes before provider insertion and immediately reads current state.
55
+
56
+ Keep actual adapter identity stable. For a bridge keep both its registration object and reopen callback stable. Replacement after activation requires a document reload and cannot transfer old events/grants. Render-only/suspended updates do not install listeners or replace authority.
57
+
58
+ ## Runtime guarantees and limits
59
+
60
+ A document-owned registry retains execution records across navigation, React replay, root remount, integration removal and endpoint replacement. Equivalent settings revisions do not reinstall scripts or resubscribe. Last accepted config remains authoritative during failed/rejected refresh; real provider failures, committed disable and endpoint/provider replacement still deny immediately. Session/Feedback errors never decide consent. Disabled Consent does not grant categories.
61
+
62
+ The fixed `createConsentLoader(driver)` API exposes `apply(state, integrations): Promise<void>` and `dispose(): void`. `IntegrationDriver` remains `start(integration): Promise<void>` and `stop(id): Promise<'stopped' | 'reload-required'>`. Starts deduplicate; withdrawal invokes stop immediately even during an unresolved start, and late initialization is stopped again. Built-in vetoes are synchronous. Drivers supplied by code must honor that same emission-veto obligation. Ten-second load/stop bounds surface failure and uncertainty. A network load error is retryable; possibly executing timed-out code requires reload.
63
+
64
+ Basic Google mode makes no managed Google tag request before consent. Explicit advanced mode permits only classified Google requests while denied and may produce vendor consent-mode traffic; generic rows remain category-gated. Google defaults/update, `ga-disable`, `send_page_view:false` and current-permission checks guard Site Kit-owned SPA pageviews. Denied queued navigation is discarded; regrant emits only the current page.
65
+
66
+ The shared closed classifier determines new GA4/ContentSquare drivers and approved executable attributes. An already executed GA source retains its proven teardown on category-policy changes, but its events require **both current row-category permission and analytics consent**. That historical capability never changes classification, code.write authorization or advanced eligibility for the edited row. New recategorized rows are generic.
67
+
68
+ ContentSquare uses documented `optout` and requires reload; no web optin or cookie-clearing workaround is supplied. Its persisted opt-out may still affect regrant after reload. Attempted generic/Maps code requires reload regardless of a requested `stop` policy. Removing a script node is not teardown. Any reload requirement blocks every optional restart, including renamed/readded rows, until a real document reload. Previously executed third-party code cannot be rolled back.
69
+
70
+ Tests use deterministic local DOM/provider doubles only. Task3 supplies actual production Next browser tag/beacon proof, both public projections, settings authorization/UI, the standard starter mount and exact KAW fixture transforms. No live-vendor account, regional/legal compliance, ContentSquare delivery/regrant or production rollout is certified.