@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,125 @@
1
+ import type { EntryRecord, FieldDecl, Id, MediaRecord, PageRecord } from '../elements'
2
+ import type { SiteManifest } from './types'
3
+
4
+ /** A page's or an entry's SEO settings as the platform stores them. `ogImage` is a media id whose record is in the reply's `media`. */
5
+ export type ReplySeo = { title: string | null; description: string | null; ogTitle: string | null; ogDescription: string | null; ogImage: Id | null; canonical: string | null; noindex: boolean }
6
+ /** The document a reply is for, as stored, with the columns the site needs to place it. */
7
+ export type ReplyDocument = {
8
+ kind: 'page' | 'entry'
9
+ id: string
10
+ slug: string
11
+ key?: string
12
+ pageType?: string
13
+ collection?: string
14
+ route?: string
15
+ /** A page's title, as editors name it. Absent for an entry. */
16
+ title?: string
17
+ seo: ReplySeo | null
18
+ /** The layouts the document renders inside, outermost first. */
19
+ layoutChain: string[]
20
+ stored: unknown
21
+ }
22
+ /** A Single as stored, with its declaration when the platform's manifest does not declare it: a Single made on the platform. */
23
+ export type StoredSingle = { stored: unknown; declaration?: FieldDecl }
24
+ export type DocumentReply = {
25
+ status: 'ok'
26
+ /** `manifestHash` of the manifest the platform holds. */
27
+ manifest: string
28
+ document: ReplyDocument
29
+ layouts: Record<string, unknown>
30
+ singles: Record<string, StoredSingle>
31
+ media: Record<string, MediaRecord>
32
+ pages: Record<string, PageRecord>
33
+ /** By `entryKey(collection, id)`. */
34
+ entries: Record<string, EntryRecord>
35
+ /** By `queryKey(collection, query)`. */
36
+ queries: Record<string, { ids: Id[]; total: number }>
37
+ /** By `inverseKey(collection, field, id)`. */
38
+ inverses: Record<string, Id[]>
39
+ tags: string[]
40
+ /** The language answered, when the tenant has languages. A tenant with none answers without it. */
41
+ locale?: string
42
+ /** A draft read in a translated language only: by document key, the overlay keys that fell back to another language or to the original. */
43
+ fallbacks?: Record<string, string[]>
44
+ /** A tenant with languages only: the active languages, other than the original and the default, whose own overlay of this document holds copy (ruling R2). */
45
+ translated?: string[]
46
+ }
47
+ export type MissingReply = { status: 'missing'; manifest: string; tags: string[] }
48
+ export type Reply = DocumentReply | MissingReply
49
+ /** A media reference the platform hydrated on egress: an absolute url, never a bare id. */
50
+ export type SeoMedia = { alt: string; url: null | string }
51
+
52
+ /** The seven days `openingHoursSpecification` addresses, spelled the way schema.org wants them. */
53
+ export type OpeningHoursDay = 'friday' | 'monday' | 'saturday' | 'sunday' | 'thursday' | 'tuesday' | 'wednesday'
54
+
55
+ /**
56
+ * One day's hours, as stored. `opens`/`closes` are 24-hour `HH:MM`, the format schema.org's own
57
+ * `opens`/`closes` properties expect, so the emitter never converts. Both are null while `closed`
58
+ * is true, or while the row is not yet filled in.
59
+ */
60
+ export type OpeningHoursRow = {
61
+ closed: boolean
62
+ closes: null | string
63
+ day: OpeningHoursDay
64
+ opens: null | string
65
+ }
66
+
67
+ /** The organisation half of the site-wide SEO singleton, for JSON-LD and contact metadata. */
68
+ export type SeoOrganisation = {
69
+ address: null | string
70
+ email: null | string
71
+ /**
72
+ * Free-text hours typed before the structured editor existed ("Mo-Fr 07:00-17:00", "7am-5pm
73
+ * daily", anything). Never parsed and not shown in the dashboard's editor. It is read only as a
74
+ * fallback: the organisation JSON-LD emits it verbatim when `openingHours` is empty.
75
+ */
76
+ legacyOpeningHours: null | string
77
+ legalName: null | string
78
+ logo: null | SeoMedia
79
+ /** Empty means "not set", as for every other absent field here, not "closed every day". */
80
+ openingHours: OpeningHoursRow[]
81
+ phone: null | string
82
+ sameAs: string[]
83
+ }
84
+
85
+ /** The site-wide SEO settings, as `/site` answers them. */
86
+ export type SiteReply = {
87
+ allowIndexing: boolean
88
+ bingVerification: null | string
89
+ description: null | string
90
+ googleVerification: null | string
91
+ locale: null | string
92
+ ogImage: null | SeoMedia
93
+ organisation: null | SeoOrganisation
94
+ siteName: null | string
95
+ titleTemplate: null | string
96
+ twitterCard: 'summary' | 'summary_large_image'
97
+ twitterHandle: null | string
98
+ /**
99
+ * The languages a tenant has configured: the original, the default for a visitor no rule places, the active languages and the
100
+ * countries mapped to an active language. Absent from a reply when the tenant has no language settings.
101
+ */
102
+ languages?: {
103
+ original: string
104
+ default: string
105
+ active: { id: string; prefix: string; name: string }[]
106
+ /** The prefixes of languages a visitor cannot reach, those inactive or preparing: the proxy sends their addresses to the default's. Absent from a platform before plan 4. */
107
+ inactive?: { id: string; prefix: string }[]
108
+ countries: Record<string, string>
109
+ } | null
110
+ }
111
+ export type SitemapReply = { entries: { path: string; lastModified: string | null; /** A tenant with languages only: the active languages to list this entry in. */ languages?: string[] }[] }
112
+
113
+ /** JSON with every object's keys sorted, so two equal manifests serialise identically wherever they were built. */
114
+ export const canonicalJson = (value: unknown): string =>
115
+ JSON.stringify(value, (_key, v: unknown) =>
116
+ v && typeof v === 'object' && !Array.isArray(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, (v as Record<string, unknown>)[k]])) : v)
117
+ /**
118
+ * `sha256:<hex>` of a manifest's canonical JSON. The platform sends the hash of the manifest it holds
119
+ * on every reply, and a site whose own hash differs knows the platform has not yet caught up with a
120
+ * deploy. Web Crypto, so it runs in Node, the edge and a browser alike.
121
+ */
122
+ export async function manifestHash(manifest: SiteManifest): Promise<string> {
123
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonicalJson(manifest)))
124
+ return 'sha256:' + [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
125
+ }
@@ -0,0 +1,42 @@
1
+ 'use client'
2
+
3
+ import { createContext, createElement, useContext, type ReactNode } from 'react'
4
+ import { LANGUAGE_COOKIE, type LanguageLinks } from '../languages/routing'
5
+
6
+ const LanguageContext = createContext<LanguageLinks | null>(null)
7
+
8
+ /** Wraps a page rendered in a language site (`render(read, links)`): the page's language and its address in each active one. */
9
+ export function LanguageProvider({ value, children }: { value: LanguageLinks; children?: ReactNode }) {
10
+ return createElement(LanguageContext.Provider, { value }, children)
11
+ }
12
+
13
+ /** The page's language and its addresses, or null outside a language site's page. */
14
+ export const useLanguage = (): LanguageLinks | null => useContext(LanguageContext)
15
+
16
+ /** Remembers a visitor's choice for a year, on the whole site: the proxy reads it on `/` (`u90_lang`). */
17
+ export function rememberLanguage(id: string, doc: Document = document): void {
18
+ doc.cookie = `${LANGUAGE_COOKIE}=${encodeURIComponent(id)}; Path=/; Max-Age=31536000; SameSite=Lax`
19
+ }
20
+
21
+ /**
22
+ * The site's language switcher: the same page in every active language, each by its own name, the one shown marked current.
23
+ * A plain link, so the next page is a full load and its `<html lang>` is its own. Nothing outside a language site's page, or
24
+ * with one language. In draft mode its links carry the language in `u90-locale` rather than a prefix (`pageBody` builds them).
25
+ */
26
+ export function LanguageSwitcher({ label = 'Language', className }: { label?: string; className?: string }) {
27
+ const value = useLanguage()
28
+ if (!value || value.links.length < 2) return null
29
+ return (
30
+ <nav aria-label={label} className={className}>
31
+ <ul>
32
+ {value.links.map((link) => (
33
+ <li key={link.id}>
34
+ <a href={link.href} hrefLang={link.id} lang={link.id} aria-current={link.id === value.current ? 'true' : undefined} onClick={() => rememberLanguage(link.id)}>
35
+ {link.name}
36
+ </a>
37
+ </li>
38
+ ))}
39
+ </ul>
40
+ </nav>
41
+ )
42
+ }
@@ -0,0 +1,90 @@
1
+ # Existing-deployment test previews
2
+
3
+ This capability uses a site's current renderer with signed branch content and test feature actions. Install it once on the site; opening a workspace never builds or deploys the site.
4
+
5
+ ## Site installation
6
+
7
+ Keep the site's normal production `PLATFORM_URL`, `PAYLOAD_API_KEY`, and `SITE_URL`. A trustworthy identity must change on every deployed build, including same-commit redeployments: prefer Vercel's `VERCEL_DEPLOYMENT_ID`, then its generated `VERCEL_URL`; other hosts must inject a unique `U90_TEST_PREVIEW_BUILD_ID` (or pass the same `buildId` option to createU90 and the guard). A commit SHA or a constant environment label is insufficient. Without an identity, capabilities report unsupported and entry refuses. The production service key authenticates only the fixed source trust URL and the private Proxy/build-cookie attestations. It is never sent to a session's branch URL.
8
+
9
+ Mount the existing `u90.handlers.GET` and `POST` at `app/api/u90/[...u90]/route.ts`. This includes:
10
+
11
+ - `GET /api/u90/test-preview?token=<signed-site-session>&path=<same-site-path>`: enables draft and stores the signed session in `u90-preview-token`, the existing HTTP-only Secure preview cookie. Test-prefixed cookies are retained for the browser session without a maxAge, so signed expiry leaves an explicit refusal marker until exit, re-entry, or browser-session end. Ordinary draft cookies retain their existing twelve-hour maxAge. An ancillary `u90-test-preview-binding` HTTP-only Secure cookie authenticates the actual Next bypass value, the session hash, canonical site origin and deployed build identity; it expires with the signed session. No revalidation secret is accepted or required. Redirects with `u90test=1` to detect failed cookie setup.
12
+ - `GET /api/u90/test-preview-capabilities`: requires authenticated evidence that the guard ran for this request.
13
+ - `GET /api/u90/exit-preview`: removes the preview cookie, ancillary binding cookie and Next draft bypass through the existing cleanup adapter, including when the signed session is expired. The validated same-site exit destination loses only the reserved `u90test` landing marker; other query values and the fragment remain intact.
14
+
15
+ Install the guard **first** in `proxy.ts`, with coverage for **all paths**, including every API and server action. An excluded path is outside this guard. If there are no later Proxy steps:
16
+
17
+ ```ts
18
+ import { testPreviewProxy } from '@unitninety/site-kit/test-preview/server'
19
+ export const proxy = testPreviewProxy()
20
+ export const config = { matcher: '/:path*' }
21
+ ```
22
+
23
+ To compose existing Proxy steps:
24
+
25
+ ```ts
26
+ import { NextResponse } from 'next/server'
27
+ import { testPreviewProxy } from '@unitninety/site-kit/test-preview/server'
28
+
29
+ export const proxy = testPreviewProxy({
30
+ env: {
31
+ PLATFORM_URL: process.env.PLATFORM_URL,
32
+ PAYLOAD_API_KEY: process.env.PAYLOAD_API_KEY,
33
+ SITE_URL: process.env.SITE_URL,
34
+ VERCEL_DEPLOYMENT_ID: process.env.VERCEL_DEPLOYMENT_ID,
35
+ VERCEL_URL: process.env.VERCEL_URL,
36
+ U90_TEST_PREVIEW_BUILD_ID: process.env.U90_TEST_PREVIEW_BUILD_ID,
37
+ },
38
+ next: async (request, testContext) => {
39
+ // Test content reads its languages and links from the branch. Skip production
40
+ // redirect/language snapshots and any custom Proxy side effects in this mode.
41
+ if (testContext) return NextResponse.next({ request: { headers: request.headers } })
42
+ // Run existing steps with this sanitized request, preserving its forwarded headers.
43
+ return NextResponse.next({ request: { headers: request.headers } })
44
+ },
45
+ })
46
+ export const config = { matcher: '/:path*' }
47
+ ```
48
+
49
+ Always preserve the sanitized forwarded request headers on next/rewrite responses. Native Next may expose an internal localhost origin behind a reverse proxy. Attestations therefore bind the configured trusted `SITE_URL` origin plus the exact pathname/query, method, cookie header and expiry. Forwarded host and user Origin never select this trust boundary. The guard strips incoming attestations, signs a short-lived request-bound attestation using the server credential, and never emits it as an ordinary response header. A capability/entry route reached without the guard refuses even if the caller sends a guessed marker.
50
+
51
+ Connect the bridge to the same configured runtime:
52
+
53
+ ```tsx
54
+ <U90Bridge testPreview={u90.testPreview}>{children}</U90Bridge>
55
+ ```
56
+
57
+ Server integrations can call `await u90.testPreview()` to decide whether to show a disabled test placeholder. The returned context includes a private signed token: do not serialize it. Client components can read `useEditing().testPreview` from `@unitninety/site-kit/bridge`; it is only a boolean and can safely disable a custom QuoteForm or third-party integration.
58
+
59
+ ## Wire contract
60
+
61
+ The source must implement `GET {configured PLATFORM_URL}/api/u90/test-preview/trust`, authenticated by its existing site service key, returning exactly:
62
+
63
+ ```ts
64
+ { version: 1, issuerPublicKey: string, tenantPublicId: string,
65
+ siteOrigin: string, mediaKey: string }
66
+ ```
67
+
68
+ Trust must bind the authenticated tenant and canonical site origin. The runtime fetch has a total five-second deadline, a 16 KiB body limit, no redirects, one in-flight fetch and one cached trust record per runtime connection, expiring after five minutes. Every session verification uses current epoch seconds and Task 1's Ed25519 protocol. Invalid/expired test prefixes refuse; they never become ordinary draft tokens.
69
+
70
+ Capabilities return:
71
+
72
+ ```ts
73
+ { version: 1, protocol: 'u90tp1', guard: true, supported: boolean,
74
+ reason?: 'build_identity_unavailable' | 'draft_binding_unavailable' | 'draft_mode_check_unavailable',
75
+ actions: readonly string[], manifestHash: string, deploymentId: string | null }
76
+ ```
77
+
78
+ `actions` are supported POST paths: `/api/u90/features/v1/session`, `/verify`, `/feedback`, `/ask-ai/messages`, and `/ask-ai/messages/:id/rating` (the latter four relative to the same feature prefix). Reads allow config, search and validated conversation recovery. `deploymentId` is the selected unique deployed-build identity, otherwise null. Unsupported capabilities return `supported: false`, a setup reason, and an empty action list. The controller must require `supported: true`; guard presence alone is insufficient. Commit SHA is never used for the safety binding. [Vercel documents its unique deployment identifier as available at build and runtime](https://vercel.com/docs/environment-variables/system-environment-variables). Compare `manifestHash` with the branch's copied declaration before allowing editing.
79
+
80
+ `createTestPreviewRuntime({ env, fetch?, now?, trustTimeoutMs? }).resolve(token)` returns `TestPreviewContext | null`; null means an ordinary credential, and invalid test credentials throw `TestPreviewRefused`. `now` is integer epoch seconds. `testPreviewProxy` accepts the same options plus `next(request, context)`; defaults use the three site environment variables. `createU90` shares its runtime between reads/handlers and accepts `testPreview: {now?, trustTimeoutMs?, buildId?}` for controlled adapters.
81
+
82
+ Custom test-aware draft adapters must provide `draft()` and `draftEnabled()`, plus entry-only `draftBinding()` (returns the actual bypass value after enabling draft) and `enableDraftBinding(binding, maxAge)`. `disableDraft()` must clear both preview cookies and the Next bypass. These added adapter methods are optional in the public type for compatibility; the shipped native adapter implements them. `draftEnabled()` is a cookie-free lightweight boolean check that runs inside a Next `use cache` scope. An injected custom `draft` adapter without `draftEnabled` is unsupported: authenticated capabilities report `reason: "draft_mode_check_unavailable"`, no editable actions, and entry refuses before enabling draft. All public reads, including site/languages/page/metadata, and feature handlers refuse test-prefixed credentials even when no test options or binding overrides were supplied. Ordinary legacy credentials still work. Only the legacy `html()` root-attribute helper retains its published-only, no-draft behavior via a private language reader; it cannot establish an editable test. The native production path retains its cache-scoped check and never calls `cookies()`.
83
+
84
+ ## Limits and browser behavior
85
+
86
+ Test mode blocks unadapted API routes, server actions, non-read methods outside the named feature actions, and standard analytics routes. Existing downstream CSP policies remain in force alongside the preview policy. Media paths additionally carry the shared sandbox/script prohibition in the Proxy itself, including default Next continuations, so the media response cannot inherit page script permissions. Its page CSP limits network connections to this site, frames and form submissions to none, and its parent frame to the verified branch. Framework scripts/styles/assets and HTTPS images remain available. Verified branch read replies rewrite only their own branch media namespace to the source media key before page/entry/site/SEO images are emitted. The resulting credential-free source path supports Next image optimizer follow-up requests that carry no browser cookie. In active test mode, the media gateway permits only this source key or the session's own branch key; it forwards no site credentials and never follows redirects.
87
+
88
+ The guard does not sandbox arbitrary custom server code, custom GET routes, side effects hidden in rendering, or routes omitted from its matcher. Audit those integrations and use disabled placeholders. The next callback must skip custom side effects for test context.
89
+
90
+ Embedded Canvas previews require Next's `__prerender_bypass`, `u90-preview-token`, and `u90-test-preview-binding` cookies. Verified test visitor-session issuance and rotation also use host-only HttpOnly/Secure/SameSite=None cookies; production visitor cookies retain SameSite=Lax. Next creates its bypass cookie without a partitioned flag. Browsers that block third-party cookies may refuse iframe setup; use a top-level test tab or an explicit browser cookie allowance. Entry's `u90test=1` landing marker refuses when the signed cookie is missing, and a signed cookie without a valid bypass/build/session binding also refuses. On a live redeploy, an old binding refuses even if an obsolete bypass cookie is nonempty; enter a new test session against the updated renderer. No cross-browser iframe guarantee is made without browser testing. Expired sessions require a new entry; branch data and edits remain intact.
@@ -0,0 +1,54 @@
1
+ import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
2
+ import type { Env } from '../server/read'
3
+ import type { TestPreviewOptions } from './runtime'
4
+
5
+ export const PREVIEW_BINDING_COOKIE = 'u90-test-preview-binding'
6
+ /** Host identity must change on every deployed build, including redeploys of the same commit. */
7
+ export function previewBuildId(
8
+ options: Pick<TestPreviewOptions, 'env' | 'buildId'>,
9
+ ): string | null {
10
+ const value =
11
+ options.env.VERCEL_DEPLOYMENT_ID ??
12
+ options.env.VERCEL_URL ??
13
+ options.buildId ??
14
+ options.env.U90_TEST_PREVIEW_BUILD_ID
15
+ return typeof value === 'string' && value.trim().length > 0 && value.length <= 512
16
+ ? value.trim()
17
+ : null
18
+ }
19
+ export function createDraftBinding(
20
+ env: Env,
21
+ buildId: string,
22
+ token: string,
23
+ bypass: string,
24
+ ): string {
25
+ if (!env.PAYLOAD_API_KEY || !env.SITE_URL || !buildId || !bypass)
26
+ throw new Error('Test preview build binding unavailable')
27
+ const tokenHash = createHash('sha256').update(token).digest('base64url')
28
+ const message = JSON.stringify([
29
+ 'u90-test-preview-binding-v1',
30
+ new URL(env.SITE_URL).origin,
31
+ buildId,
32
+ bypass,
33
+ tokenHash,
34
+ ])
35
+ return 'u90tb1.' + createHmac('sha256', env.PAYLOAD_API_KEY).update(message).digest('base64url')
36
+ }
37
+ export function verifyDraftBinding(
38
+ env: Env,
39
+ buildId: string | null,
40
+ token: string,
41
+ bypass: string | null,
42
+ binding: string | null,
43
+ ): boolean {
44
+ try {
45
+ if (!buildId || !bypass || !binding || !/^u90tb1\.[A-Za-z0-9_-]{43}$/.test(binding))
46
+ return false
47
+ const expected = createDraftBinding(env, buildId, token, bypass)
48
+ const a = Buffer.from(binding),
49
+ b = Buffer.from(expected)
50
+ return a.length === b.length && timingSafeEqual(a, b)
51
+ } catch {
52
+ return false
53
+ }
54
+ }
@@ -0,0 +1,16 @@
1
+ import type { NextAdapters } from '../server/adapters'
2
+ import type { TestPreviewContext } from './runtime'
3
+ /** Server-only adapter; returns just safe client props, never the signed session. */
4
+ export async function resolveBridgePreview(options: {
5
+ platformOrigin: string
6
+ draft: NextAdapters['draft']
7
+ resolve: (token: string | null) => Promise<TestPreviewContext | null>
8
+ }) {
9
+ const draft = await options.draft()
10
+ const context = await options.resolve(draft.token)
11
+ return {
12
+ editing: context ? true : draft.enabled && options.platformOrigin !== '',
13
+ platformOrigin: context?.platformOrigin ?? options.platformOrigin,
14
+ testPreview: !!context,
15
+ }
16
+ }
@@ -0,0 +1,186 @@
1
+ import { MEDIA_SANDBOX } from './media'
2
+ import { createHmac, timingSafeEqual } from 'node:crypto'
3
+ import { PREVIEW_BINDING_COOKIE, previewBuildId, verifyDraftBinding } from './binding'
4
+ import { NextResponse } from 'next/server'
5
+ import { matchAskAiRoute } from '../features/server/http'
6
+ import type { Env } from '../server/read'
7
+ import {
8
+ createTestPreviewRuntime,
9
+ EXPECTED_PREVIEW_PARAM,
10
+ previewResponseHeaders,
11
+ requestPreviewToken,
12
+ requestCookie,
13
+ type TestPreviewOptions,
14
+ type TestPreviewContext,
15
+ } from './runtime'
16
+ export const GUARD_HEADER = 'x-u90-test-preview-guard'
17
+ export const TEST_PREVIEW_ACTIONS = [
18
+ '/api/u90/features/v1/session',
19
+ '/api/u90/features/v1/verify',
20
+ '/api/u90/features/v1/feedback',
21
+ '/api/u90/features/v1/ask-ai/messages',
22
+ '/api/u90/features/v1/ask-ai/messages/:id/rating',
23
+ ] as const
24
+ export type TestPreviewCapabilities = {
25
+ version: 1
26
+ protocol: 'u90tp1'
27
+ guard: true
28
+ supported: boolean
29
+ reason?:
30
+ 'build_identity_unavailable' | 'draft_binding_unavailable' | 'draft_mode_check_unavailable'
31
+ actions: readonly string[]
32
+ manifestHash: string
33
+ deploymentId: string | null
34
+ }
35
+ // Native Next may expose an internal localhost origin behind a reverse proxy.
36
+ // Bind the configured trusted public origin and exact route/query, never forwarded host.
37
+ const attestationUrl = (request: Request, env: Env) => {
38
+ const url = new URL(request.url)
39
+ return new URL(env.SITE_URL!).origin + url.pathname + url.search
40
+ }
41
+ const hmac = (request: Request, env: Env, expires: number) =>
42
+ createHmac('sha256', env.PAYLOAD_API_KEY!)
43
+ .update(
44
+ JSON.stringify([
45
+ request.method,
46
+ attestationUrl(request, env),
47
+ request.headers.get('cookie'),
48
+ expires,
49
+ ]),
50
+ )
51
+ .digest('base64url')
52
+ export function verifyGuardAttestation(
53
+ request: Request,
54
+ env: Env,
55
+ now = Math.floor(Date.now() / 1000),
56
+ ): boolean {
57
+ try {
58
+ if (!env.PAYLOAD_API_KEY || !env.SITE_URL) return false
59
+ const value = request.headers.get(GUARD_HEADER) ?? ''
60
+ const [time, signature, extra] = value.split('.')
61
+ const expires = Number(time)
62
+ if (
63
+ extra !== undefined ||
64
+ !Number.isSafeInteger(expires) ||
65
+ expires <= now ||
66
+ expires > now + 60 ||
67
+ !/^[A-Za-z0-9_-]{43}$/.test(signature ?? '')
68
+ )
69
+ return false
70
+ const a = Buffer.from(signature),
71
+ b = Buffer.from(hmac(request, env, expires))
72
+ return a.length === b.length && timingSafeEqual(a, b)
73
+ } catch {
74
+ return false
75
+ }
76
+ }
77
+ export function previewCsp(parent: string): string {
78
+ return `default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data: blob:; font-src 'self' data:; media-src 'self'; connect-src 'self'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'; frame-ancestors ${parent}`
79
+ }
80
+ /** Install first, with a matcher covering every path. `next` composes later Proxy steps using sanitized request headers. */
81
+ export function testPreviewProxy(
82
+ options: TestPreviewOptions & {
83
+ next?: (request: Request, context: TestPreviewContext | null) => Response | Promise<Response>
84
+ } = {
85
+ env: {
86
+ PLATFORM_URL: process.env.PLATFORM_URL,
87
+ PAYLOAD_API_KEY: process.env.PAYLOAD_API_KEY,
88
+ SITE_URL: process.env.SITE_URL,
89
+ VERCEL_DEPLOYMENT_ID: process.env.VERCEL_DEPLOYMENT_ID,
90
+ VERCEL_URL: process.env.VERCEL_URL,
91
+ U90_TEST_PREVIEW_BUILD_ID: process.env.U90_TEST_PREVIEW_BUILD_ID,
92
+ },
93
+ },
94
+ ) {
95
+ const runtime = createTestPreviewRuntime(options)
96
+ const now = options.now ?? (() => Math.floor(Date.now() / 1000))
97
+ return async (incoming: Request): Promise<Response> => {
98
+ const headers = new Headers(incoming.headers)
99
+ headers.delete(GUARD_HEADER)
100
+ const request = new Request(incoming, { headers })
101
+ const url = new URL(request.url)
102
+ const path = url.pathname
103
+ const setup = [
104
+ '/api/u90/test-preview',
105
+ '/api/u90/test-preview-capabilities',
106
+ '/api/u90/exit-preview',
107
+ ].includes(path)
108
+ const refused = () =>
109
+ new Response(
110
+ 'Test preview refused. Open a new test session; allow preview cookies or use a top-level test tab.',
111
+ { status: 403, headers: previewResponseHeaders },
112
+ )
113
+ let delegated = false
114
+ try {
115
+ // Recovery and capability checks remain reachable with expired credentials.
116
+ const context = setup ? null : await runtime.resolve(requestPreviewToken(request))
117
+ if (!setup && url.searchParams.get(EXPECTED_PREVIEW_PARAM) === '1' && !context)
118
+ return refused()
119
+ if (context) {
120
+ // A nonempty bypass is insufficient: Next rotates its opaque value on every build.
121
+ // Entry binds the actual bypass to the host-unique deployment and signed session.
122
+ if (
123
+ !verifyDraftBinding(
124
+ options.env,
125
+ previewBuildId(options),
126
+ context.token,
127
+ requestCookie(request, '__prerender_bypass'),
128
+ requestCookie(request, PREVIEW_BINDING_COOKIE),
129
+ )
130
+ )
131
+ return refused()
132
+ let decoded = path
133
+ for (let i = 0; i < 3 && decoded.includes('%'); i++) decoded = decodeURIComponent(decoded)
134
+ if (decoded.includes('%')) return refused()
135
+ const normalized = new URL(decoded.replaceAll('\\', '/'), url.origin).pathname.toLowerCase()
136
+ const read = request.method === 'GET' || request.method === 'HEAD'
137
+ const featureRead =
138
+ ['/api/u90/features/v1/config', '/api/u90/features/v1/search'].includes(path) ||
139
+ matchAskAiRoute(request.method, url)?.operation === 'conversation'
140
+ const featureWrite =
141
+ request.method === 'POST' &&
142
+ ([
143
+ '/api/u90/features/v1/session',
144
+ '/api/u90/features/v1/verify',
145
+ '/api/u90/features/v1/feedback',
146
+ ].includes(path) ||
147
+ !!matchAskAiRoute(request.method, url))
148
+ const media = /^\/api\/u90\/media\//.test(path)
149
+ if (
150
+ request.headers.has('next-action') ||
151
+ /(?:^|\/)(?:analytics|_vercel|_next\/analytics)(?:\/|$)/i.test(normalized) ||
152
+ (/^\/api(?:\/|$)/.test(normalized) &&
153
+ !((read && (featureRead || media)) || featureWrite)) ||
154
+ (!read && !featureWrite)
155
+ )
156
+ return refused()
157
+ }
158
+ if (setup && request.method !== 'GET') return refused()
159
+ if (options.env.PAYLOAD_API_KEY && (setup || context)) {
160
+ const expires = now() + 60
161
+ headers.set(GUARD_HEADER, `${expires}.${hmac(request, options.env, expires)}`)
162
+ }
163
+ const forwarded = new Request(request, { headers })
164
+ delegated = true
165
+ const response = await (options.next
166
+ ? options.next(forwarded, context)
167
+ : NextResponse.next({ request: { headers } }))
168
+ // Attestation is an internal request header, never a response header.
169
+ response.headers.delete(GUARD_HEADER)
170
+ if (context || setup)
171
+ for (const [key, value] of Object.entries(previewResponseHeaders))
172
+ response.headers.set(key, value)
173
+ if (context) {
174
+ // Multiple CSP policies intersect. Preserve downstream restrictions, and carry the
175
+ // media sandbox even when NextResponse.next() has not seen the eventual media response.
176
+ if (/^\/api\/u90\/media\//.test(path))
177
+ response.headers.append('content-security-policy', MEDIA_SANDBOX)
178
+ response.headers.append('content-security-policy', previewCsp(context.platformOrigin))
179
+ }
180
+ return response
181
+ } catch (error) {
182
+ if (delegated) throw error
183
+ return refused()
184
+ }
185
+ }
186
+ }
@@ -0,0 +1,23 @@
1
+ import type { TestPreviewContext } from './runtime'
2
+
3
+ /** Restrictive policy for source media, shared by the gateway and Proxy continuation. */
4
+ export const MEDIA_SANDBOX = "sandbox; default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
5
+
6
+ /** Translate only this verified branch namespace in JSON replies. URLs contain no session credential. */
7
+ export function sourceMediaPaths<T>(value: T, context: TestPreviewContext): T {
8
+ const branchPrefix = `/api/u90/media/${context.claims.branchMediaKey}/`
9
+ const sourcePrefix = `/api/u90/media/${context.trust.mediaKey}/`
10
+ const visit = (input: unknown): unknown => {
11
+ if (typeof input === 'string') {
12
+ for (const prefix of [branchPrefix, context.platformOrigin + branchPrefix]) {
13
+ if (input.startsWith(prefix)) return sourcePrefix + input.slice(prefix.length)
14
+ }
15
+ return input
16
+ }
17
+ if (Array.isArray(input)) return input.map(visit)
18
+ if (input !== null && typeof input === 'object')
19
+ return Object.fromEntries(Object.entries(input).map(([key, child]) => [key, visit(child)]))
20
+ return input
21
+ }
22
+ return visit(value) as T
23
+ }
@@ -0,0 +1,67 @@
1
+ /** Canonical Ed25519 DER keys encoded as unpadded base64url. */
2
+ export interface PreviewKeyPair {
3
+ privateKey: string
4
+ publicKey: string
5
+ }
6
+
7
+ export interface EnvironmentCertificate {
8
+ version: 1
9
+ environmentId: string
10
+ platformOrigin: string
11
+ sourceOrigin: string
12
+ publicKey: string
13
+ /** Integer epoch seconds. */
14
+ issuedAt: number
15
+ /** Integer epoch seconds; at most 604800 seconds after issuance. */
16
+ expiresAt: number
17
+ }
18
+
19
+ interface BasePreviewClaims {
20
+ version: 1
21
+ tenantPublicId: string
22
+ nonce: string
23
+ /** Integer epoch seconds. */
24
+ issuedAt: number
25
+ /** Integer epoch seconds; at most 900 seconds after issuance. */
26
+ expiresAt: number
27
+ }
28
+
29
+ export interface ReferencePreviewClaims extends BasePreviewClaims {
30
+ purpose: 'reference'
31
+ siteOrigin?: never
32
+ branchMediaKey?: never
33
+ }
34
+
35
+ export interface SitePreviewClaims extends BasePreviewClaims {
36
+ purpose: 'site'
37
+ siteOrigin: string
38
+ branchMediaKey: string
39
+ }
40
+
41
+ export type PreviewClaims = ReferencePreviewClaims | SitePreviewClaims
42
+
43
+ export interface VerifyPreviewSessionOptions {
44
+ issuerPublicKey: string
45
+ purpose: PreviewClaims['purpose']
46
+ sourceOrigin?: string
47
+ platformOrigin?: string
48
+ siteOrigin?: string
49
+ tenantPublicId?: string
50
+ /** Override current time using integer epoch seconds. */
51
+ now?: number
52
+ }
53
+
54
+ export function generatePreviewKeyPair(): PreviewKeyPair
55
+ export function signEnvironmentCertificate(claims: EnvironmentCertificate, privateKey: string): string
56
+ export function verifyEnvironmentCertificate(
57
+ certificate: string,
58
+ publicKey: string,
59
+ options?: { now?: number },
60
+ ): EnvironmentCertificate
61
+ export function signPreviewSession(claims: PreviewClaims, certificate: string, privateKey: string): string
62
+ export function verifyPreviewSession(
63
+ token: string,
64
+ options: VerifyPreviewSessionOptions,
65
+ ): { certificate: EnvironmentCertificate; claims: PreviewClaims }
66
+ /** Includes malformed test-prefixed credentials that must fail closed. */
67
+ export function isTestPreviewToken(value: unknown): boolean