@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,249 @@
1
+ import { z } from 'zod'
2
+ import type { FieldDecl, ZoneDecl } from '../../elements'
3
+ import type { SiteManifest } from '../../site/types'
4
+ import { KnowledgeTitleSchema } from './knowledge'
5
+
6
+ export type TextSingleProjection = { kind: 'text' }
7
+ export type FieldProjection =
8
+ | { kind: 'text'; tokens?: Record<string, TextSingleProjection> }
9
+ | { kind: 'group'; fields: Record<string, FieldProjection> }
10
+ | { kind: 'list'; item: FieldProjection }
11
+ | { kind: 'zone'; blocks: ZoneProjection }
12
+ | { kind: 'single'; key: string; value: FieldProjection }
13
+ | { kind: 'entries'; collection: string; fields: Record<string, FieldProjection> }
14
+ export type ZoneProjection = Record<
15
+ string,
16
+ { fields: Record<string, FieldProjection>; entryFields?: Record<string, FieldProjection> }
17
+ >
18
+ export type PageProjection = {
19
+ title: { from: 'pageTitle' } | { from: 'literal'; value: string }
20
+ blocks: ZoneProjection
21
+ layouts?: Record<string, ZoneProjection>
22
+ }
23
+ export type EntryProjection = {
24
+ title: { from: 'field'; field: string } | { from: 'literal'; value: string }
25
+ blocks: ZoneProjection
26
+ layouts?: Record<string, ZoneProjection>
27
+ }
28
+ export type KnowledgePolicyV1 = {
29
+ version: 1
30
+ pages: Record<string, PageProjection>
31
+ pageTypes: Record<string, PageProjection>
32
+ collections: Record<string, EntryProjection>
33
+ }
34
+ export const KNOWLEDGE_POLICY_LIMITS = {
35
+ bytes: 256 * 1024,
36
+ depth: 12,
37
+ selectors: 2000,
38
+ hops: 3,
39
+ references: 100,
40
+ } as const
41
+ const name = z
42
+ .string()
43
+ .regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/)
44
+ .refine((v) => !['__proto__', 'constructor', 'prototype'].includes(v))
45
+ const fields = (): z.ZodType<Record<string, FieldProjection>> => z.record(name, field)
46
+ const field: z.ZodType<FieldProjection> = z.lazy(() =>
47
+ z.discriminatedUnion('kind', [
48
+ z.strictObject({
49
+ kind: z.literal('text'),
50
+ tokens: z.record(name, z.strictObject({ kind: z.literal('text') })).optional(),
51
+ }),
52
+ z.strictObject({ kind: z.literal('group'), fields: fields() }),
53
+ z.strictObject({ kind: z.literal('list'), item: field }),
54
+ z.strictObject({ kind: z.literal('zone'), blocks: zone }),
55
+ z.strictObject({ kind: z.literal('single'), key: name, value: field }),
56
+ z.strictObject({ kind: z.literal('entries'), collection: name, fields: fields() }),
57
+ ]),
58
+ )
59
+ const zone: z.ZodType<ZoneProjection> = z.record(
60
+ name,
61
+ z.strictObject({ fields: fields(), entryFields: fields().optional() }),
62
+ )
63
+ const literal = z.strictObject({ from: z.literal('literal'), value: KnowledgeTitleSchema })
64
+ const page = z.strictObject({
65
+ title: z.union([z.strictObject({ from: z.literal('pageTitle') }), literal]),
66
+ blocks: zone,
67
+ layouts: z.record(name, zone).optional(),
68
+ })
69
+ const entry = z.strictObject({
70
+ title: z.union([z.strictObject({ from: z.literal('field'), field: name }), literal]),
71
+ blocks: zone,
72
+ layouts: z.record(name, zone).optional(),
73
+ })
74
+ // Bound traversal before recursive Zod parsing, including cyclic non-JSON callers.
75
+ const bounded = (v: unknown): boolean => {
76
+ let selectors = 0
77
+ const seen = new Set<object>()
78
+ const walk = (x: unknown, depth: number): boolean => {
79
+ if (depth > 12) return false
80
+ if (!x || typeof x !== 'object') return true
81
+ if (seen.has(x)) return false
82
+ seen.add(x)
83
+ if ('kind' in x && ++selectors > 2000) return false
84
+ const ok = Object.values(x).every((y) => walk(y, depth + 1))
85
+ seen.delete(x)
86
+ return ok
87
+ }
88
+ try {
89
+ return (
90
+ walk(v, 0) &&
91
+ new TextEncoder().encode(JSON.stringify(v)).byteLength <= KNOWLEDGE_POLICY_LIMITS.bytes
92
+ )
93
+ } catch {
94
+ return false
95
+ }
96
+ }
97
+ export const KnowledgePolicySchema: z.ZodType<KnowledgePolicyV1> = z
98
+ .unknown()
99
+ .refine(bounded, 'Knowledge policy exceeds its bounds.')
100
+ .pipe(
101
+ z.strictObject({
102
+ version: z.literal(1),
103
+ pages: z.record(name, page),
104
+ pageTypes: z.record(name, page),
105
+ collections: z.record(name, entry),
106
+ }),
107
+ )
108
+ const textLike = (d: FieldDecl | undefined) =>
109
+ !!d &&
110
+ 'element' in d &&
111
+ (d.element === 'u90/rich-text' || (d.element === 'u90/text' && d.options?.format !== 'code'))
112
+ /** Independent manifest validation. Pairing itself deliberately does not invoke this gate. */
113
+ export function validateKnowledgePolicy(
114
+ manifest: SiteManifest,
115
+ input: unknown = manifest.knowledge,
116
+ ): { ready: true; policy: KnowledgePolicyV1 } | { ready: false; reason: string } {
117
+ const parsed = KnowledgePolicySchema.safeParse(input)
118
+ if (!parsed.success)
119
+ return {
120
+ ready: false,
121
+ reason: input === undefined ? 'Knowledge policy missing.' : 'Invalid knowledge policy.',
122
+ }
123
+ const m = manifest
124
+ function assert(ok: unknown, reason: string): asserts ok {
125
+ if (!ok) throw new Error(reason)
126
+ }
127
+ const own = <T>(record: Record<string, T>, key: string): T | undefined =>
128
+ Object.hasOwn(record, key) ? record[key] : undefined
129
+ const selectFields = (
130
+ selectors: Record<string, FieldProjection>,
131
+ declarations: Record<string, FieldDecl>,
132
+ collection?: string,
133
+ hops = 0,
134
+ budget = 3,
135
+ ) => {
136
+ for (const [key, selector] of Object.entries(selectors))
137
+ select(selector, own(declarations, key), collection, hops, budget)
138
+ }
139
+ const select = (
140
+ p: FieldProjection,
141
+ d: FieldDecl | undefined,
142
+ collection?: string,
143
+ hops = 0,
144
+ budget = 3,
145
+ ): void => {
146
+ assert(d, 'Unknown selected field.')
147
+ assert(hops <= 3, 'Reference hop ceiling exceeded.')
148
+ if (p.kind === 'text') {
149
+ assert(textLike(d), 'Selected field is not non-code text.')
150
+ for (const key of Object.keys(p.tokens ?? {}))
151
+ assert(textLike(own(m.schema.singles, key)?.field), 'Token must name a text Single.')
152
+ } else if (p.kind === 'group') {
153
+ assert('container' in d && d.container === 'u90/group', 'Expected group.')
154
+ selectFields(p.fields, d.fields, collection, hops, budget)
155
+ } else if (p.kind === 'list') {
156
+ assert('container' in d && d.container === 'u90/list', 'Expected list.')
157
+ select(p.item, d.item, collection, hops, budget)
158
+ } else if (p.kind === 'zone') {
159
+ assert('container' in d && d.container === 'u90/zone', 'Expected zone.')
160
+ selectZone(p.blocks, d, collection, hops, budget)
161
+ } else if (p.kind === 'single') {
162
+ assert(
163
+ 'element' in d && d.element === 'u90/single' && d.options?.key === p.key,
164
+ 'Single target mismatch.',
165
+ )
166
+ select(p.value, own(m.schema.singles, p.key)?.field, collection, hops + 1, 3)
167
+ } else {
168
+ assert(
169
+ 'element' in d &&
170
+ ['u90/relation', 'u90/selection', 'u90/query'].includes(d.element) &&
171
+ d.options?.to === p.collection,
172
+ 'Entry target mismatch.',
173
+ )
174
+ const target = own(m.schema.collections, p.collection)
175
+ assert(target, 'Unknown collection.')
176
+ const renderedDepth = Math.min(
177
+ budget,
178
+ d.element === 'u90/query' || d.options?.inverseOf ? 1 : Number(d.options?.depth ?? 1),
179
+ )
180
+ assert(renderedDepth > 0, 'Selected entry fields exceed public render depth.')
181
+ selectFields(p.fields, target.fields, p.collection, hops + 1, renderedDepth - 1)
182
+ }
183
+ }
184
+ const selectZone = (
185
+ p: ZoneProjection,
186
+ d: ZoneDecl,
187
+ collection?: string,
188
+ hops = 0,
189
+ budget = 3,
190
+ ): void => {
191
+ for (const [key, b] of Object.entries(p)) {
192
+ const block = own(m.schema.blocks, key)
193
+ assert(block && d.allow.includes(key), 'Block is not allowed at this placement.')
194
+ selectFields(b.fields, block.fields, collection, hops, budget)
195
+ if (b.entryFields) {
196
+ assert(collection && block.entry === collection, 'Invalid entryFields placement.')
197
+ selectFields(
198
+ b.entryFields,
199
+ m.schema.collections[collection].fields,
200
+ collection,
201
+ hops,
202
+ budget,
203
+ )
204
+ }
205
+ }
206
+ }
207
+ const layouts = (p: PageProjection | EntryProjection, start: string | undefined) => {
208
+ const chain = new Set<string>()
209
+ let key = start ?? Object.keys(m.layouts).find((k) => m.layouts[k].default)
210
+ while (key && !chain.has(key)) {
211
+ chain.add(key)
212
+ key = m.layouts[key]?.parent
213
+ }
214
+ for (const [layout, selected] of Object.entries(p.layouts ?? {})) {
215
+ assert(chain.has(layout), 'Layout is not attached.')
216
+ selectZone(selected, {
217
+ container: 'u90/zone',
218
+ allow: m.layouts[layout].blocks.filter((b) => b !== 'SLOT'),
219
+ })
220
+ }
221
+ }
222
+ try {
223
+ for (const category of ['pages', 'pageTypes'] as const)
224
+ for (const [key, p] of Object.entries(parsed.data[category])) {
225
+ const root = own(m[category], key)
226
+ assert(root, 'Unknown page root.')
227
+ selectZone(p.blocks, root.blocks)
228
+ layouts(p, root.layout)
229
+ }
230
+ for (const [key, p] of Object.entries(parsed.data.collections)) {
231
+ const root = own(m.collections, key)
232
+ const declaration = own(m.schema.collections, key)
233
+ assert(root?.route && declaration, 'Collection root must be routable.')
234
+ if (p.title.from === 'field')
235
+ assert(
236
+ textLike(own(declaration.fields, p.title.field)),
237
+ 'Title field must be non-code text.',
238
+ )
239
+ selectZone(p.blocks, declaration.template ?? { container: 'u90/zone', allow: [] }, key)
240
+ layouts(p, root.layout)
241
+ }
242
+ return { ready: true, policy: parsed.data }
243
+ } catch (error) {
244
+ return {
245
+ ready: false,
246
+ reason: error instanceof Error ? error.message : 'Invalid policy binding.',
247
+ }
248
+ }
249
+ }
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod'
2
+ import { KnowledgeKeySchema, KnowledgePathSchema, KnowledgeTitleSchema } from './knowledge'
3
+ export const SearchHitSchema = z.strictObject({
4
+ id: KnowledgeKeySchema,
5
+ title: KnowledgeTitleSchema,
6
+ path: KnowledgePathSchema,
7
+ excerpt: z.string().max(1000),
8
+ kind: z.enum(['page', 'entry', 'faq']),
9
+ })
10
+ export type SearchHit = z.infer<typeof SearchHitSchema>
11
+
12
+ export const SearchQuerySchema = z.strictObject({ q: z.string().trim().min(2).max(200) })
13
+ export const SearchResponseSchema = z.strictObject({
14
+ version: z.literal(1),
15
+ results: z.array(SearchHitSchema).max(8),
16
+ })
17
+ // Eight hits: ASCII key128 + canonical ASCII path2048 + worst-case JSON title/excerpt
18
+ // escaping (200+1000)*6 + bounded field syntax < 76 KiB. Reserve 128 KiB for this response only.
19
+ export const SEARCH_RESPONSE_BYTES = 128 * 1024
20
+ export function parseSearchQuery(params: URLSearchParams) {
21
+ if (params.size !== 1 || !params.has('q')) return null
22
+ const parsed = SearchQuerySchema.safeParse({ q: params.get('q') })
23
+ return parsed.success ? parsed.data : null
24
+ }
@@ -0,0 +1,52 @@
1
+ import { z } from 'zod'
2
+ import { FeedbackInputSchema } from './feedback'
3
+ export * from './verificationConfig'
4
+ export type VerificationAction = 'form_submit' | 'feedback_submit' | 'ask_ai_session'
5
+ export const ProofSchema = z
6
+ .string()
7
+ .min(1)
8
+ .refine((v) => new TextEncoder().encode(v).length <= 4096 && !/[\s\x00-\x1f\x7f]/.test(v))
9
+ /** null requests the saved no-provider policy; only the platform can authorize it. */
10
+ export const ActionProofSchema = ProofSchema.nullable()
11
+ export const GrantSchema = z
12
+ .string()
13
+ .min(1)
14
+ .max(4096)
15
+ .regex(/^[A-Za-z0-9_.-]+$/)
16
+ export const FeedbackVerificationSchema = z.strictObject({
17
+ body: FeedbackInputSchema,
18
+ proof: ActionProofSchema,
19
+ })
20
+ export const FeedbackSubmissionSchema = z.strictObject({
21
+ body: FeedbackInputSchema,
22
+ grant: GrantSchema,
23
+ })
24
+ export const FormVerificationSchema = z.strictObject({
25
+ requestId: z.uuid(),
26
+ mode: z.enum(['verify', 'submit']),
27
+ proof: ActionProofSchema.optional(),
28
+ grant: GrantSchema.optional(),
29
+ })
30
+ export const FormEnvelopeSchema = z.strictObject({
31
+ values: z.record(z.string(), z.unknown()),
32
+ verification: FormVerificationSchema,
33
+ })
34
+
35
+ // Both approved providers belong in the site's initial CSP, so a saved provider switch needs no code change.
36
+ export const VERIFICATION_CSP = {
37
+ scriptSrc: [
38
+ 'https://www.google.com',
39
+ 'https://www.gstatic.com',
40
+ 'https://challenges.cloudflare.com',
41
+ ],
42
+ connectSrc: [
43
+ 'https://www.google.com',
44
+ 'https://www.gstatic.com',
45
+ 'https://challenges.cloudflare.com',
46
+ ],
47
+ frameSrc: [
48
+ 'https://www.google.com',
49
+ 'https://recaptcha.google.com',
50
+ 'https://challenges.cloudflare.com',
51
+ ],
52
+ } as const
@@ -0,0 +1,9 @@
1
+ import { z } from 'zod'
2
+ export const VerificationConfigSchema = z.strictObject({
3
+ mode: z.enum(['managed', 'byo', 'disabled']),
4
+ provider: z.enum(['google-cloud', 'google-v3', 'turnstile']),
5
+ siteKey: z.string().nullable(),
6
+ readiness: z.enum(['configured', 'setup_required', 'unavailable']),
7
+ revision: z.int().nonnegative(),
8
+ })
9
+ export type VerificationConfig = z.infer<typeof VerificationConfigSchema>
@@ -0,0 +1,15 @@
1
+ import Script from 'next/script'
2
+ import { CONSENT_DEFAULTS_ID, CONSENT_DEFAULTS_SCRIPT } from '../consent/defaults'
3
+ /** Mount once in the consuming server root layout, before SiteFeatures. */
4
+ export function ConsentDefaults() {
5
+ return (
6
+ // App Router consumers mount this wrapper in their root layout. This rule
7
+ // uses the Pages Router _document filename heuristic for reusable packages.
8
+ // eslint-disable-next-line @next/next/no-before-interactive-script-outside-document
9
+ <Script
10
+ id={CONSENT_DEFAULTS_ID}
11
+ strategy="beforeInteractive"
12
+ dangerouslySetInnerHTML={{ __html: CONSENT_DEFAULTS_SCRIPT }}
13
+ />
14
+ )
15
+ }
@@ -0,0 +1,185 @@
1
+ # Site Features server integration (contract 1)
2
+
3
+ ```ts
4
+ // app/api/u90/features/v1/[...feature]/route.ts
5
+ import { createSiteFeatures, createRecaptchaVerifier } from '@unitninety/site-kit/features/server'
6
+
7
+ export const { GET, POST } = createSiteFeatures({
8
+ env: {
9
+ PLATFORM_URL: process.env.PLATFORM_URL!,
10
+ PAYLOAD_API_KEY: process.env.PAYLOAD_API_KEY!,
11
+ SITE_URL: process.env.SITE_URL!,
12
+ },
13
+ verifyVisitor: createRecaptchaVerifier({
14
+ secret: process.env.RECAPTCHA_SECRET!,
15
+ hostname: new URL(process.env.SITE_URL!).hostname,
16
+ action: 'site_session',
17
+ minimumScore: 0.5,
18
+ }),
19
+ // Optional deployment-specific resolver; see identity policy below.
20
+ // resolveClientIdentity: async (request) => trustedConnectionIdentity(request),
21
+ })
22
+ ```
23
+
24
+ GET `config`, POST `session`, and POST `feedback` are implemented. Unknown paths, query parameters,
25
+ methods and future operations fail closed. Native `createU90(site, { features: {
26
+ verifyVisitor, resolveClientIdentity }, env, fetch })` composes this exact gateway. Existing
27
+ sites may omit feature environment values; their legacy handlers continue working.
28
+
29
+ `SITE_URL` and the authenticated tenant's saved `siteUrl` must have the same exact origin.
30
+ Local tests must save the owned local origin on their disposable tenant. Request Host,
31
+ Origin and forwarding headers cannot add preview domains to the audience. `PLATFORM_URL`
32
+ and the service key stay on the site server. The platform alone stores
33
+ `SITE_FEATURE_TOKEN_KEY` (at least 32 random characters; generate 32 random bytes). Rotation
34
+ invalidates existing sessions. No site receives that signing key.
35
+
36
+ ## Verification and identity policy
37
+
38
+ The platform's saved connection chooses Default, Custom Google reCAPTCHA, Custom Cloudflare
39
+ Turnstile, or Disabled. Default is used when no connection is saved; missing credentials and
40
+ provider failures remain closed. Only a manager-saved Disabled connection skips the external
41
+ assessment. Tenant authentication, the exact website audience, signed visitor identity and
42
+ transactional action grants remain mandatory in every mode. AI spending/token budgets and
43
+ Default monthly funded verification allowances remain active.
44
+
45
+ `VerifyVisitor(request)` is an optional supplemental server check. It returns `{ ok: true }`
46
+ or `{ ok: false, retryable: boolean }` and receives only a bounded `{ proof }` string. The
47
+ platform still assesses the proof centrally; a successful callback cannot authorize an action
48
+ or bypass a central refusal. All custom adapters have a ten-second deadline. Omitting the
49
+ callback does not opt out of the saved provider. An explicit `proof: null` skips supplemental
50
+ callbacks and is forwarded to the platform, which accepts it only for saved Disabled mode.
51
+
52
+ A valid signed session reuses its verification authority for 15 minutes. The session owner
53
+ survives renewal, while connection changes invalidate outstanding verification authority.
54
+ Search is cookieless and does not request a provider check. A CAPTCHA result never creates
55
+ an authenticated identity from arbitrary browser claims or forwarding headers.
56
+
57
+ `resolveClientIdentity?: (request: Request) => Promise<string | undefined>` is separate from
58
+ verification. It must use identity established by the deployment's authenticated connection
59
+ metadata. Never return a browser-generated UUID, visitor session ID, proof, arbitrary
60
+ `x-forwarded-for`, `x-real-ip`, or another browser-supplied header. The optional value is bounded
61
+ to 512 UTF-8 bytes and sent only over the private authenticated exchange. Request and identity
62
+ rate caps are no longer enforced by Protection.
63
+
64
+ ## Task 4 browser session protocol
65
+
66
+ The only public body is strict JSON `{ requestId: UUID, proof?: string | null }`, capped at 16 KiB.
67
+ A string `proof` must be nonempty and at most 4 KiB UTF-8. Explicit `null` requests saved
68
+ Disabled mode; an omitted proof only probes existing session authority. The gateway consumes a private platform token and returns only `{ version: 1,
69
+ ok: true }`; browser JavaScript never sees the credential.
70
+
71
+ 1. Coalesce concurrent `ensureSession` calls at the feature root. On a user action, generate
72
+ one logical-attempt UUID and POST `{requestId}` with same-origin credentials and an abort
73
+ signal. Do not acquire a proof on config load, hidden/disabled widget mount or Search.
74
+ 2. The site forwards only its named cookie in a private resume request. The platform verifies
75
+ signature, tenant, stored audience and expiry. Success preserves the same visitor ID and
76
+ returns acknowledgment without another provider assessment.
77
+ 3. If current session authority is missing, the site returns 403 `verification_required`.
78
+ Refresh public verification configuration. Acquire proof once for the saved provider, or
79
+ use `null` for saved Disabled mode, and POST the same UUID with that value. Rejected/empty proof, cancellation, missing client adapter,
80
+ verification refusal or outage becomes a visible retry state. No automatic challenge loop.
81
+ 4. On issue success the site sets `u90_feature_session`, host-only, HttpOnly, SameSite=Lax,
82
+ Path `/api/u90/features/v1`, Secure on HTTPS. It is a browser-session cookie without
83
+ Max-Age/Expires; the signed identity expires after 24 hours and verification authority after 15 minutes.
84
+ 5. On an uncertain network outcome, probe again without proof using the same UUID and cookie.
85
+ A received cookie resumes normally. If no cookie arrived, an already-claimed UUID cannot
86
+ recover its credential: the platform returns 409 `conflict` after an authorized issuance
87
+ attempt. Never replay a private token by request ID. An explicit user retry may begin one
88
+ fresh UUID attempt and obtain fresh single-use proof; do not spin or automatically repeat
89
+ paid work. In particular, don't automatically resend a consumed reCAPTCHA proof.
90
+ 6. A later feature authorization failure may run `ensureSession` once. That is distinct from
91
+ retrying the underlying operation and never authorizes automatic retry of uncertain paid
92
+ work. Session acknowledgment is not permission to call disabled features.
93
+
94
+ Private exchange fields (`mode`, audience/session/client/verification headers) are constructed
95
+ by the gateway. Browser versions of those headers and body fields are discarded or rejected.
96
+ The platform authenticates the site service credential and independently enforces the saved connection.
97
+ Never call the private platform exchange directly from browser code.
98
+
99
+ ## Platform storage extension contract
100
+
101
+ `createFeatureRequestStore({ payload, req?, now? })` exposes:
102
+
103
+ - `beginFeatureRequest({ tenantId, operation, requestId, payloadHash, ownerId, audience })`:
104
+ `{ status: 'new', attemptId }`, `{ status: 'running' }`,
105
+ `{ status: 'conflict' }`, or `{ status: 'replay', result }`.
106
+ - `completeFeatureRequest({ tenantId, operation, requestId, payloadHash, ownerId, audience,
107
+ attemptId, result })`: a boolean indicating whether the matching live pending attempt
108
+ completed. Always retain the returned attempt ID; late completions cannot overwrite a
109
+ replacement claim. Results are JSON object receipts/status capped at 16 KiB, never raw
110
+ provider streams or credentials.
111
+
112
+ Derive owner/tenant/audience from authenticated claims; hash schema-normalized payload with
113
+ `featurePayloadHash({ ownerId, audience, payload })`. Session issuance uses a fresh owner,
114
+ so an uncredentialed repeated UUID conflicts even if its body is identical. Proof is excluded
115
+ from hashes. Search can generate a server request ID for its one-shot durable reservation;
116
+ no visitor cookie is needed. The supported operation list is currently session/feedback/search;
117
+ AI must add its tighter metering and uncertain-charge reconciliation before enabling its route.
118
+
119
+ Claims, tenant+client/session counters, and uniqueness run under the same tenant lock in an
120
+ actual Payload transaction. Supplied `req` joins the caller's transaction: the caller owns
121
+ commit/rollback and must roll back on failure. Never do provider/network/stream work inside
122
+ these locks. Pending claims expire after 24 hours, completed receipts seven days after
123
+ completion. Expired IDs can be reused with a new fenced attempt. Expiry never implies an
124
+ uncertain paid provider operation is safe to retry; the AI plan must add settlement rules.
125
+
126
+ The authenticated daily job-retention cron (04:30 UTC) drains expired runtime data through
127
+ short transactions of up to 1,000 requests and 1,000 counters per batch, using a fixed expiry
128
+ cutoff. Each cleanup statement has a five-second database deadline, and no new batch starts
129
+ unless its worst-case time fits within the 40-second run budget (including Payload loading).
130
+ The separately registered minutely `feature-retention` cron continues from expired rows still
131
+ in PostgreSQL after a budget exit, interruption or locked-row skip; no in-memory continuation
132
+ or unconsumed queue is required. Each run reports deleted counts and whether expired rows
133
+ remain (conservatively true if no batch fit). Expiry indexes bound each pass. Tenant purge and
134
+ copied-DB sanitization explicitly remove both runtime collections.
135
+
136
+ ## Feedback submission
137
+
138
+ POST `feedback` carries canonical `FeedbackInput` alongside its verification envelope. It requires an exact site Origin,
139
+ enabled Feedback, and a central action verification grant. The
140
+ platform returns the public `{ submissionId, receivedAt }` receipt with 201 for a new row
141
+ or 200 for an identical retry. A changed body or different grant owner reusing an ID returns 409.
142
+ Verification metadata is separate from canonical answers; Feedback does not need an Ask AI session.
143
+
144
+ The platform saves grant consumption, request claim, immutable canonical field snapshot, answers and receipt
145
+ in one transaction. Notification claims happen after commit and permit one automatic attempt;
146
+ a timeout is terminal even if delivery may have succeeded. A retry returns the committed receipt.
147
+ Explicit KAW adoption replaces an empty provisioned canonical form while retaining the exact
148
+ `pageFeedback` form ID, historical answers/snapshots, handling settings and read flags. Existing
149
+ canonical history requires an explicit merge; this service never moves submitted history.
150
+
151
+ ## Generic forms and central grants
152
+
153
+ `client.submitForm` and `client.submitEnquiry` require a verification envelope in their server-side
154
+ options. Older two-argument integrations fail closed. The browser calls
155
+ `acquireVerificationProof(config, 'form_submit')` or `useFormVerification(config)` at submit time.
156
+ It returns a fresh provider proof or `null` when Disabled is explicitly saved. Forward that result
157
+ to the server helper separately from form values:
158
+
159
+ ```ts
160
+ await client.submitForm('contact', answers, {
161
+ protection: {
162
+ honeypot: honeypotValue,
163
+ verification: {
164
+ requestId,
165
+ proof: visitorProof, // string, or null only for saved Disabled mode
166
+ onGrant: (grant, requestId) => retainForIdenticalRetry(grant, requestId),
167
+ },
168
+ },
169
+ })
170
+ ```
171
+
172
+ The SDK exchanges the envelope for a central signed action grant, then submits normalized
173
+ answers with that grant. The platform binds authority to the tenant, website, operation,
174
+ request ID and payload, and consumes it inside the mutation transaction. Retain the returned
175
+ grant/request ID for an unchanged uncertain retry; committed receipt recovery does not perform
176
+ a second mutation. A supplied `verifyVisitor` callback is supplemental only and is skipped for
177
+ `proof: null`. The platform still decides whether null is permitted by its saved mode.
178
+
179
+ The optional honeypot must be empty and at most 256 bytes. The deprecated `clientIdentity` option
180
+ remains bounded to 256 UTF-8 bytes without control characters; it does not establish a signed
181
+ visitor identity or grant authorization. Oversized or malformed proofs fail before submission.
182
+ Keep API keys and server verifiers private, and never put proof or grants in answer fields.
183
+
184
+ Request, identity, notification and concurrency allowances have been removed from Protection.
185
+ AI spending/token budgets and Default monthly funded verification allowances remain in force.