@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
package/src/site/wire.ts
ADDED
|
@@ -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
|