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