@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,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.
|