@supersuit/artifacts 0.58.1 → 0.59.0

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 (70) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +11 -0
  3. package/lib/artifacts/letter-breaks.d.ts +2 -0
  4. package/lib/artifacts/letter-breaks.js +68 -0
  5. package/lib/artifacts/render.js +2 -1
  6. package/lib/routes/artifacts.js +2 -1
  7. package/lib/routes/backend-auth.d.ts +9 -0
  8. package/lib/routes/backend-auth.js +42 -0
  9. package/package.json +5 -2
  10. package/template/hub/README.md +35 -0
  11. package/template/hub/app/[id]/not-found.tsx +8 -0
  12. package/template/hub/app/[id]/page.tsx +15 -0
  13. package/template/hub/app/[id]/share.png/route.tsx +10 -0
  14. package/template/hub/app/[id]/v/[n]/page.tsx +15 -0
  15. package/template/hub/app/api/artifact-pass/route.ts +24 -0
  16. package/template/hub/app/api/artifacts/[id]/access/route.ts +14 -0
  17. package/template/hub/app/api/artifacts/[id]/assets/[name]/route.ts +10 -0
  18. package/template/hub/app/api/artifacts/[id]/forward/route.ts +10 -0
  19. package/template/hub/app/api/artifacts/[id]/personal/route.ts +18 -0
  20. package/template/hub/app/api/artifacts/[id]/reads/route.ts +10 -0
  21. package/template/hub/app/api/artifacts/[id]/recipients/route.ts +14 -0
  22. package/template/hub/app/api/artifacts/[id]/reply/route.ts +10 -0
  23. package/template/hub/app/api/artifacts/[id]/resolve/route.ts +10 -0
  24. package/template/hub/app/api/artifacts/[id]/responses/route.ts +14 -0
  25. package/template/hub/app/api/artifacts/[id]/route.ts +12 -0
  26. package/template/hub/app/api/artifacts/[id]/seen/route.ts +10 -0
  27. package/template/hub/app/api/artifacts/[id]/settings/route.ts +14 -0
  28. package/template/hub/app/api/artifacts/[id]/state/route.ts +14 -0
  29. package/template/hub/app/api/artifacts/[id]/transcribe/route.ts +10 -0
  30. package/template/hub/app/api/artifacts/[id]/uploads/[name]/route.ts +11 -0
  31. package/template/hub/app/api/artifacts/[id]/versions/route.ts +14 -0
  32. package/template/hub/app/api/artifacts/[id]/views/route.ts +10 -0
  33. package/template/hub/app/api/artifacts/route.ts +13 -0
  34. package/template/hub/app/api/comments/route.ts +8 -0
  35. package/template/hub/app/api/health/route.ts +10 -0
  36. package/template/hub/app/api/reader/ack/route.ts +6 -0
  37. package/template/hub/app/api/reader/enter/route.ts +8 -0
  38. package/template/hub/app/api/reader/leave/route.ts +8 -0
  39. package/template/hub/app/api/reader/track/route.ts +6 -0
  40. package/template/hub/app/artifact/sign-in/page.tsx +29 -0
  41. package/template/hub/app/artifact/sign-in/sign-in.tsx +60 -0
  42. package/template/hub/app/brand.json/route.ts +10 -0
  43. package/template/hub/app/frapp/font/route.ts +12 -0
  44. package/template/hub/app/frapp/og/route.ts +17 -0
  45. package/template/hub/app/frapp/route.ts +28 -0
  46. package/template/hub/app/globals.css +7 -0
  47. package/template/hub/app/layout.tsx +32 -0
  48. package/template/hub/app/page.tsx +26 -0
  49. package/template/hub/app/robots.ts +6 -0
  50. package/template/hub/apphosting.yaml +21 -0
  51. package/template/hub/firestore.indexes.json +13 -0
  52. package/template/hub/next.config.ts +27 -0
  53. package/template/hub/package.json +34 -0
  54. package/template/hub/postcss.config.mjs +2 -0
  55. package/template/hub/proxy.ts +24 -0
  56. package/template/hub/src/lib/brand-colors.ts +13 -0
  57. package/template/hub/src/lib/firebase-admin.ts +46 -0
  58. package/template/hub/src/lib/frapp-door-theme.ts +56 -0
  59. package/template/hub/src/lib/frapp-door.d.mts +24 -0
  60. package/template/hub/src/lib/frapp-door.mjs +321 -0
  61. package/template/hub/src/lib/health.ts +23 -0
  62. package/template/hub/src/lib/hub-env-parse.ts +90 -0
  63. package/template/hub/src/lib/hub-env.ts +10 -0
  64. package/template/hub/src/lib/hub-meta.ts +12 -0
  65. package/template/hub/src/lib/local-sign-in.ts +46 -0
  66. package/template/hub/src/lib/routes.ts +44 -0
  67. package/template/hub/src/lib/url-mark.tsx +9 -0
  68. package/template/hub/src/tenant.ts +60 -0
  69. package/template/hub/tailwind.config.ts +8 -0
  70. package/template/hub/tsconfig.json +21 -0
@@ -0,0 +1,6 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { hubRoutes } from '@/lib/routes'
3
+
4
+ export function POST(request: NextRequest) {
5
+ return hubRoutes().ACK(request)
6
+ }
@@ -0,0 +1,8 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { hubRoutes } from '@/lib/routes'
3
+
4
+ export const dynamic = 'force-dynamic'
5
+
6
+ export function GET(request: NextRequest) {
7
+ return hubRoutes().ENTER(request)
8
+ }
@@ -0,0 +1,8 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { hubRoutes } from '@/lib/routes'
3
+
4
+ export const dynamic = 'force-dynamic'
5
+
6
+ export function GET(request: NextRequest) {
7
+ return hubRoutes().LEAVE(request)
8
+ }
@@ -0,0 +1,6 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { hubRoutes } from '@/lib/routes'
3
+
4
+ export function POST(request: NextRequest) {
5
+ return hubRoutes().TRACK(request)
6
+ }
@@ -0,0 +1,29 @@
1
+ import { notFound } from 'next/navigation'
2
+ import { hubTenant } from '@/tenant'
3
+ import { SignIn } from './sign-in'
4
+
5
+ export const dynamic = 'force-dynamic'
6
+ export const metadata = { title: 'Sign in', robots: { index: false, follow: false } }
7
+
8
+ // The sign-in a private page's door links to: Google, against the hub's own Firebase project.
9
+ export default async function Page({ searchParams }: { searchParams: Promise<{ to?: string; switch?: string }> }) {
10
+ const tenant = hubTenant()
11
+ if (!tenant.localSignIn) notFound()
12
+ const { to, switch: sw } = await searchParams
13
+ // Only a page on this host: `to` arrives as a full URL from the door.
14
+ let path: string | null = null
15
+ try {
16
+ const u = new URL(to ?? '')
17
+ if (u.origin === tenant.siteUrl && /^\/[a-z0-9]{8}$/.test(u.pathname)) path = u.pathname
18
+ } catch {}
19
+ const { pack } = tenant
20
+ return (
21
+ <main style={{ background: pack.ground, color: pack.ink, minHeight: '100vh', fontFamily: pack.type.body }}>
22
+ <div style={{ maxWidth: '26rem', margin: '0 auto', padding: '18vh 1.5rem 4rem', textAlign: 'center' }}>
23
+ <p style={{ fontSize: '.75rem', letterSpacing: '.14em', textTransform: 'uppercase', opacity: 0.6 }}>{pack.kicker}</p>
24
+ <h1 style={{ fontFamily: pack.type.display, fontSize: '1.9rem', margin: '.6rem 0 1rem' }}>Open a private page</h1>
25
+ <SignIn firebase={tenant.localSignIn.firebase} to={path} accent={pack.accent} ground={pack.ground} switchAccount={sw === '1'} />
26
+ </div>
27
+ </main>
28
+ )
29
+ }
@@ -0,0 +1,60 @@
1
+ 'use client'
2
+ import { useEffect, useState } from 'react'
3
+ import { initializeApp, getApps, getApp } from 'firebase/app'
4
+ import { getAuth, GoogleAuthProvider, onAuthStateChanged, signInWithPopup, signOut, type User } from 'firebase/auth'
5
+ import type { FirebaseWebConfig } from '@/tenant'
6
+
7
+ const NAME = 'artifacts'
8
+
9
+ export function SignIn({ firebase, to, accent, ground, switchAccount = false }: { firebase: FirebaseWebConfig; to: string | null; accent: string; ground: string; switchAccount?: boolean }) {
10
+ const [user, setUser] = useState<User | null | undefined>(undefined)
11
+ const [state, setState] = useState<'idle' | 'working' | 'unverified' | 'error'>('idle')
12
+ const auth = () => getAuth(getApps().some(a => a.name === NAME) ? getApp(NAME) : initializeApp(firebase, NAME))
13
+
14
+ // `switch=1` from the page's "Use a different account": forget the account this host holds
15
+ // BEFORE watching it, or it would vouch for the same one again and the reader could never change.
16
+ useEffect(() => {
17
+ let off = () => {}
18
+ let live = true
19
+ ;(switchAccount ? signOut(auth()).catch(() => {}) : Promise.resolve()).then(() => {
20
+ if (live) off = onAuthStateChanged(auth(), setUser)
21
+ })
22
+ return () => { live = false; off() }
23
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
24
+
25
+ useEffect(() => {
26
+ if (!user || !to || state !== 'idle') return
27
+ let live = true
28
+ setState('working')
29
+ ;(async () => {
30
+ try {
31
+ const res = await fetch(`/api/artifact-pass?to=${encodeURIComponent(to)}`, { headers: { authorization: `Bearer ${await user.getIdToken()}` } })
32
+ const d = await res.json().catch(() => ({}))
33
+ if (!live) return
34
+ if (res.ok && typeof d.url === 'string') { window.location.replace(d.url); return }
35
+ setState(res.status === 403 ? 'unverified' : 'error')
36
+ } catch { if (live) setState('error') }
37
+ })()
38
+ return () => { live = false }
39
+ // `state` stays OUT of the deps: setting it to 'working' would re-run this effect, whose
40
+ // cleanup marks the in-flight request dead, and the page would sit on "Opening…" forever.
41
+ // eslint-disable-next-line react-hooks/exhaustive-deps
42
+ }, [user, to])
43
+
44
+ const button = { background: accent, color: ground, border: 0, borderRadius: '.6rem', padding: '.65rem 1.3rem', fontSize: '.95rem', cursor: 'pointer' }
45
+ if (!to) return <p style={{ opacity: 0.7 }}>Nothing to open. Tap the link you were sent, then its sign-in button.</p>
46
+ if (user === undefined) return <p style={{ opacity: 0.6 }}>…</p>
47
+ if (!user) return (
48
+ <>
49
+ <p style={{ opacity: 0.75, marginBottom: '1.4rem' }}>Sign in with the Google account this page was shared with.</p>
50
+ <button style={button} onClick={() => {
51
+ const p = new GoogleAuthProvider(); p.setCustomParameters({ prompt: 'select_account' })
52
+ signInWithPopup(auth(), p).catch(() => setState('error'))
53
+ }}>Sign in with Google</button>
54
+ {state === 'error' && <p style={{ opacity: 0.7, marginTop: '1rem' }}>That did not finish. Try again.</p>}
55
+ </>
56
+ )
57
+ if (state === 'unverified') return <p style={{ opacity: 0.75 }}>That Google account has no verified email. Sign in with one that does.</p>
58
+ if (state === 'error') return <p style={{ opacity: 0.75 }}>Signed in as {user.email}, but the page did not open. Reload to try again.</p>
59
+ return <p style={{ opacity: 0.6 }}>Opening…</p>
60
+ }
@@ -0,0 +1,10 @@
1
+ // GET /brand.json: the colours this hub renders its everyday pages in (src/lib/brand-colors.ts).
2
+ // Public on purpose: it is three colours any page on the hub already shows.
3
+ import { brandColors } from '@/lib/brand-colors'
4
+ import { hubTenant } from '@/tenant'
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export function GET() {
9
+ return Response.json(brandColors(hubTenant().pack), { headers: { 'cache-control': 'public, max-age=300' } })
10
+ }
@@ -0,0 +1,12 @@
1
+ // GET /frapp/font: the pack's display face, the same bytes its share card is set in, so the door
2
+ // (plain HTML, no next/font) can wear it. Same-origin only by the door's CSP (font-src 'self').
3
+ import { hubTenant } from '@/tenant'
4
+
5
+ export const dynamic = 'force-dynamic'
6
+
7
+ export async function GET() {
8
+ const pack = hubTenant().pack
9
+ if (!pack.share?.font) return new Response(null, { status: 404 })
10
+ const f = await pack.share.font()
11
+ return new Response(f.data, { headers: { 'content-type': 'font/ttf', 'cache-control': 'public, max-age=86400' } })
12
+ }
@@ -0,0 +1,17 @@
1
+ // GET /frapp/og?k=<kind>&t=<title>: the share card a texted door link unfurls into, drawn by the
2
+ // shell's own share card in the hub's pack. With a title, the kind is the kicker above it.
3
+ import { renderShareCard } from '@supersuit/artifacts/brand'
4
+ import { doorCard } from '@/lib/frapp-door.mjs'
5
+ import { cardPack } from '@/lib/frapp-door-theme'
6
+ import { hubTenant } from '@/tenant'
7
+
8
+ export const dynamic = 'force-dynamic'
9
+
10
+ export async function GET(req: Request) {
11
+ const tenant = hubTenant()
12
+ const url = new URL(req.url)
13
+ const card = doorCard(url.searchParams.get('k'), url.searchParams.get('t'))
14
+ if (!tenant.pack.share) return new Response(null, { status: 404 })
15
+ const kicker = card.title ? card.kindTitle : tenant.pack.kicker
16
+ return renderShareCard(cardPack(tenant.id, tenant.pack, kicker), card.heading)
17
+ }
@@ -0,0 +1,28 @@
1
+ // GET /frapp: the door every texted tile app link opens first, on the operator's own hub and in its
2
+ // own brand pack. See src/lib/frapp-door.mjs.
3
+ //
4
+ // The tailnet target rides in the URL FRAGMENT, which the browser never sends, so this handler
5
+ // cannot see, log or store a key even by accident. It reads only `?k=` (a kind from a closed
6
+ // list) and `?t=` (the page title), so the share card can say what is being sent.
7
+ import { doorHtml } from '@/lib/frapp-door.mjs'
8
+ import { DOOR_CSP, doorTheme } from '@/lib/frapp-door-theme'
9
+ import { hubTenant } from '@/tenant'
10
+
11
+ export const dynamic = 'force-dynamic'
12
+
13
+ export function GET(req: Request) {
14
+ const tenant = hubTenant()
15
+ const url = new URL(req.url)
16
+ const html = doorHtml({ origin: tenant.siteUrl, kind: url.searchParams.get('k'), title: url.searchParams.get('t'), theme: doorTheme(tenant) })
17
+ return new Response(html, {
18
+ status: 200,
19
+ headers: {
20
+ 'content-type': 'text/html; charset=utf-8',
21
+ 'content-security-policy': DOOR_CSP,
22
+ 'referrer-policy': 'no-referrer',
23
+ 'x-content-type-options': 'nosniff',
24
+ 'x-robots-tag': 'noindex, nofollow',
25
+ 'cache-control': 'public, max-age=300',
26
+ },
27
+ })
28
+ }
@@ -0,0 +1,7 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ body {
6
+ font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
7
+ }
@@ -0,0 +1,32 @@
1
+ import type { Metadata } from 'next'
2
+ import { parseHubEnv } from '@/lib/hub-env-parse'
3
+ import { hubMetadata } from '@/lib/hub-meta'
4
+ // The default pack's faces (DM Serif Display over DM Sans), self-hosted.
5
+ import '@fontsource/dm-serif-display/400.css'
6
+ import '@fontsource/dm-sans/400.css'
7
+ import '@fontsource/dm-sans/500.css'
8
+ import '@fontsource/dm-sans/600.css'
9
+ import './globals.css'
10
+
11
+ // A hub may carry its own look, with its own web fonts.
12
+ const hub = parseHubEnv(process.env.HUB_TENANT)
13
+ const hubPack = hub?.pack
14
+
15
+ export function generateMetadata(): Metadata {
16
+ return hubMetadata(hub)
17
+ }
18
+
19
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
20
+ return (
21
+ <html lang="en">
22
+ {hubPack?.fontsUrl && (
23
+ <head>
24
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
25
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
26
+ <link rel="stylesheet" href={hubPack.fontsUrl} />
27
+ </head>
28
+ )}
29
+ <body className="antialiased">{children}</body>
30
+ </html>
31
+ )
32
+ }
@@ -0,0 +1,26 @@
1
+ // The hub's home. Pages are shared by link, so there is nothing to list here; this says whose
2
+ // pages these are and stops.
3
+ import { BrandGround, BrandMark } from '@supersuit/artifacts/brand'
4
+ import { hubTenant } from '@/tenant'
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export default function Home() {
9
+ const t = hubTenant()
10
+ return (
11
+ <BrandGround pack={t.pack}>
12
+ <div className="mx-auto max-w-2xl px-6 pt-32 text-center">
13
+ <BrandMark pack={t.pack} className="mx-auto mb-6 block h-14 w-auto text-5xl" />
14
+ <p className="text-[11px] uppercase tracking-[0.3em]" style={{ color: t.pack.accent }}>
15
+ {t.pack.kicker}
16
+ </p>
17
+ <h1 className="mt-4 text-3xl" style={{ fontFamily: t.pack.type.display }}>
18
+ {t.configured ? `Pages ${t.owner ?? t.pack.name} shares by link` : 'This hub is not configured yet'}
19
+ </h1>
20
+ <p className="mt-4 opacity-70">
21
+ {t.configured ? 'If you were sent one, the link takes you straight to it.' : 'Its setup sets HUB_TENANT when it deploys it.'}
22
+ </p>
23
+ </div>
24
+ </BrandGround>
25
+ )
26
+ }
@@ -0,0 +1,6 @@
1
+ import type { MetadataRoute } from 'next'
2
+
3
+ // Every page here is shared by link and none is meant to be found.
4
+ export default function robots(): MetadataRoute.Robots {
5
+ return { rules: { userAgent: '*', disallow: '/' } }
6
+ }
@@ -0,0 +1,21 @@
1
+ # The shape of a hub's settings. Every "__SET_BY_SETUP__" value is replaced at deploy time by the
2
+ # apphosting.hub.yaml override that create-or-update-my-artifact-hub writes; the secrets are created
3
+ # in the hub's own Secret Manager by the same setup.
4
+ runConfig:
5
+ minInstances: 0
6
+ maxInstances: 2
7
+ env:
8
+ - variable: HUB_TENANT
9
+ value: "__SET_BY_SETUP__"
10
+ - variable: FIREBASE_STORAGE_BUCKET
11
+ value: "__SET_BY_SETUP__"
12
+ - variable: ARTIFACT_PRIVATE_BUCKET
13
+ value: "__SET_BY_SETUP__"
14
+ - variable: TENANT_PUBLISH_KEY_HUB
15
+ secret: hub-publish-key
16
+ - variable: ARTIFACT_PASS_SECRET
17
+ secret: artifact-pass-secret
18
+ # Signs per-reader links (`links: per-reader`, and every shared review). Without it the hub
19
+ # answers 501 "this host keeps no per-reader links".
20
+ - variable: ARTIFACT_RECIPIENT_SECRET
21
+ secret: artifact-recipient-secret
@@ -0,0 +1,13 @@
1
+ {
2
+ "indexes": [
3
+ {
4
+ "collectionGroup": "artifactState",
5
+ "queryScope": "COLLECTION",
6
+ "fields": [
7
+ { "fieldPath": "slot", "order": "ASCENDING" },
8
+ { "fieldPath": "at", "order": "ASCENDING" }
9
+ ]
10
+ }
11
+ ],
12
+ "fieldOverrides": []
13
+ }
@@ -0,0 +1,27 @@
1
+ import type { NextConfig } from 'next'
2
+ // Relative and import-free on purpose: Next's config loader does not resolve the `@/` alias.
3
+ import { parseHubEnv } from './src/lib/hub-env-parse'
4
+
5
+ const nextConfig: NextConfig = {
6
+ poweredByHeader: false,
7
+ images: { formats: ['image/avif', 'image/webp'] },
8
+ // Loaded at runtime from node_modules rather than bundled, like firebase-admin.
9
+ serverExternalPackages: ['@google-cloud/storage'],
10
+ // Firebase's Google sign-in handler, served same-origin so Safari keeps the sign-in. No host
11
+ // condition: behind App Hosting the request's Host header is not the public host, so a
12
+ // `has: host` match would never fire and Google sign-in would 404.
13
+ async rewrites() {
14
+ const hub = parseHubEnv(process.env.HUB_TENANT)
15
+ return hub ? [{ source: '/__/auth/:path*', destination: `https://${hub.firebase.projectId}.firebaseapp.com/__/auth/:path*` }] : []
16
+ },
17
+ // The share card and the door's card read the package's font from disk at request time. Next's
18
+ // tracer does not follow the package's own literal into node_modules, so these are load-bearing.
19
+ // The key is a glob: '[id]' would be a character class and match nothing.
20
+ outputFileTracingIncludes: {
21
+ '/*/share.png': ['./node_modules/@supersuit/artifacts/fonts/**'],
22
+ '/frapp/og': ['./node_modules/@supersuit/artifacts/fonts/**'],
23
+ '/frapp/font': ['./node_modules/@supersuit/artifacts/fonts/**'],
24
+ },
25
+ }
26
+
27
+ export default nextConfig
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "supersuit-artifact-hub",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "description": "One operator's own artifact hub: the @supersuit/artifacts shell as a deployable Next.js app for Firebase App Hosting, configured entirely by HUB_TENANT.",
6
+ "scripts": {
7
+ "dev": "next dev",
8
+ "build": "next build",
9
+ "start": "next start"
10
+ },
11
+ "dependencies": {
12
+ "@fontsource/dm-sans": "^5.3.0",
13
+ "@fontsource/dm-serif-display": "^5.3.0",
14
+ "@google-cloud/storage": "^7.22.0",
15
+ "@supersuit/artifacts": "0.59.0",
16
+ "firebase": "^12.19.0",
17
+ "firebase-admin": "^13.7.0",
18
+ "next": "^16.0.1",
19
+ "react": "^19.0.0",
20
+ "react-dom": "^19.0.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^22",
24
+ "@types/react": "^19.0.7",
25
+ "@types/react-dom": "^19.0.3",
26
+ "autoprefixer": "^10",
27
+ "postcss": "^8",
28
+ "tailwindcss": "^3.4.1",
29
+ "typescript": "^5"
30
+ },
31
+ "engines": {
32
+ "node": ">=20"
33
+ }
34
+ }
@@ -0,0 +1,2 @@
1
+ const config = { plugins: { tailwindcss: {}, autoprefixer: {} } }
2
+ export default config
@@ -0,0 +1,24 @@
1
+ // Ahead of a page: a per-reader link that was revoked, or whose broadcast passed its date, answers
2
+ // 410 Gone with the same quiet words the page shows, so preview services and crawlers drop it.
3
+ //
4
+ // Only a request carrying a reader's link (`?r=`) or its cookie (`artifact_r_<id>`) can be shut, so
5
+ // that cheap check runs first and loads nothing. A store that fails to answer never costs a reader
6
+ // the page: the page itself refuses a shut link.
7
+ import { NextResponse, type NextRequest } from 'next/server'
8
+
9
+ const RID_COOKIE = 'artifact_r_'
10
+
11
+ export async function proxy(request: NextRequest) {
12
+ const carriesLink = request.nextUrl.searchParams.has('r') || request.cookies.getAll().some((c) => c.name.startsWith(RID_COOKIE))
13
+ if (!carriesLink) return NextResponse.next()
14
+ try {
15
+ const { hubRoutes } = await import('@/lib/routes')
16
+ const shut = await hubRoutes().shutPageResponse(request)
17
+ if (shut) return shut
18
+ } catch {
19
+ // Fall through to the page, which applies the same gate.
20
+ }
21
+ return NextResponse.next()
22
+ }
23
+
24
+ export const config = { matcher: ['/:id([a-z0-9]{8})'] }
@@ -0,0 +1,13 @@
1
+ // What the hub's pages actually render in, reduced to three colours, for /brand.json. Supersuit's
2
+ // publish script compares this with the brand universe the operator's workspace loads, so a pack
3
+ // that has fallen behind is named on the next publish instead of being noticed by eye. The pack's opening mode decides which
4
+ // palette counts: a light-mode pack renders its `light` palette, every other its dark one.
5
+ import type { BrandPack } from '@supersuit/artifacts/brand'
6
+
7
+ export type BrandColors = { ground: string; text: string; accent: string; mode: string; pack: string }
8
+
9
+ export function brandColors(pack: BrandPack): BrandColors {
10
+ const mode = pack.mode ?? 'dark'
11
+ const p = mode === 'light' && pack.light ? pack.light : { ground: pack.ground, ink: pack.ink, accent: pack.accent }
12
+ return { ground: p.ground, text: p.ink, accent: p.accent, mode, pack: pack.id }
13
+ }
@@ -0,0 +1,46 @@
1
+ // Firestore, Storage and Auth for the hub, through Application Default Credentials: on App Hosting
2
+ // that is the backend's own runtime service account, so no key is stored anywhere. For a local run,
3
+ // FIREBASE_SERVICE_ACCOUNT_KEY (one line of service account JSON) is used instead when it is set.
4
+ import { initializeApp, getApps, cert, applicationDefault, type ServiceAccount } from 'firebase-admin/app'
5
+ import { getFirestore, type Firestore } from 'firebase-admin/firestore'
6
+ import { getStorage } from 'firebase-admin/storage'
7
+ import { getAuth, type Auth } from 'firebase-admin/auth'
8
+ import type { Bucket } from '@google-cloud/storage'
9
+
10
+ let _db: Firestore | null = null
11
+ let _bucket: Bucket | null = null
12
+ let _privateBucket: Bucket | null = null
13
+
14
+ function getAdminApp() {
15
+ if (getApps().length > 0) return getApps()[0]
16
+ const key = process.env.FIREBASE_SERVICE_ACCOUNT_KEY?.trim()
17
+ const credential = key ? cert(JSON.parse(key) as ServiceAccount) : applicationDefault()
18
+ const storageBucket = process.env.FIREBASE_STORAGE_BUCKET?.trim() || undefined
19
+ return initializeApp({ credential, ...(storageBucket ? { storageBucket } : {}) })
20
+ }
21
+
22
+ export function getAdminDb(): Firestore {
23
+ return (_db ??= getFirestore(getAdminApp()))
24
+ }
25
+
26
+ /** The project's default bucket (FIREBASE_STORAGE_BUCKET): public page media. */
27
+ export function getAdminBucket(): Bucket {
28
+ return (_bucket ??= getStorage(getAdminApp()).bucket() as unknown as Bucket)
29
+ }
30
+
31
+ /**
32
+ * The bucket private media (`media: private`) lives in, named by ARTIFACT_PRIVATE_BUCKET. It must
33
+ * not be the public bucket: that one carries a bucket-wide public read grant. Unset or blank means
34
+ * no private store, and the package then refuses private media instead of making it public.
35
+ */
36
+ export function getAdminPrivateBucket(): Bucket | undefined {
37
+ const name = process.env.ARTIFACT_PRIVATE_BUCKET?.trim()
38
+ if (!name || name === '__SET_BY_SETUP__') return undefined
39
+ if (_privateBucket?.name !== name) _privateBucket = getStorage(getAdminApp()).bucket(name) as unknown as Bucket
40
+ return _privateBucket
41
+ }
42
+
43
+ /** ID-token verification for readers signing in on this host (src/lib/local-sign-in.ts). */
44
+ export function getAdminAuth(): Auth {
45
+ return getAuth(getAdminApp())
46
+ }
@@ -0,0 +1,56 @@
1
+ // The door wears the hub it is served on (src/lib/frapp-door.mjs). The door is plain HTML under a
2
+ // strict CSP, so a pack's React parts (Wrapper, Mark) cannot run there; this reduces a pack to what
3
+ // plain HTML can wear: its palettes, its kicker and its display face (served by /frapp/font from
4
+ // the pack's own share-card font).
5
+ import { createHash } from 'node:crypto'
6
+ import type { BrandPack } from '@supersuit/artifacts/brand'
7
+ import { DOOR_SCRIPT, type DoorTheme } from './frapp-door.mjs'
8
+ import type { Tenant } from '@/tenant'
9
+
10
+ /** A CSS font stack with its `var(--…)` entries dropped: the door loads no next/font variables. */
11
+ export function plainStack(stack: string): string {
12
+ const parts = stack.split(',').map((s) => s.trim()).filter((s) => s && !s.startsWith('var('))
13
+ return parts.length ? parts.join(', ') : 'Georgia, serif'
14
+ }
15
+
16
+ export function doorTheme(tenant: Tenant, pack: BrandPack = tenant.pack): DoorTheme {
17
+ return {
18
+ name: pack.name,
19
+ kicker: pack.kicker,
20
+ scheme: pack.mode ?? 'dark',
21
+ dark: { ground: pack.ground, ink: pack.ink, accent: pack.accent },
22
+ light: pack.light ?? null,
23
+ display: plainStack(pack.type.display),
24
+ font: Boolean(pack.share?.font),
25
+ mark: null,
26
+ backdrop: null,
27
+ }
28
+ }
29
+
30
+ const SCRIPT_HASH = createHash('sha256').update(DOOR_SCRIPT).digest('base64')
31
+
32
+ /** The door's CSP. Nothing loads but its own inline script (by hash) and style, its own mark,
33
+ * backdrop and face, and the one probe the page makes, which may only go to a tailnet host. */
34
+ export const DOOR_CSP = [
35
+ "default-src 'none'",
36
+ `script-src 'sha256-${SCRIPT_HASH}'`,
37
+ "style-src 'unsafe-inline'",
38
+ "img-src 'self'",
39
+ "font-src 'self'",
40
+ 'connect-src https://*.ts.net',
41
+ "base-uri 'none'",
42
+ "form-action 'none'",
43
+ "frame-ancestors 'none'",
44
+ ].join('; ')
45
+
46
+ // renderShareCard caches a pack's font and backdrop by pack object, so one derived pack per
47
+ // tenant and kicker keeps that cache warm.
48
+ const cardPacks = new Map<string, BrandPack>()
49
+
50
+ /** The tenant's pack with the door card's kicker on it, for the shell's share card. */
51
+ export function cardPack(tenantId: string, pack: BrandPack, kicker: string): BrandPack {
52
+ const key = `${tenantId}\u0000${kicker}`
53
+ let p = cardPacks.get(key)
54
+ if (!p) cardPacks.set(key, (p = { ...pack, kicker }))
55
+ return p
56
+ }
@@ -0,0 +1,24 @@
1
+ export type DoorPalette = { ground: string; ink: string; accent: string };
2
+ export type DoorTheme = {
3
+ name: string;
4
+ kicker: string;
5
+ scheme: 'dark' | 'light' | 'system';
6
+ dark: DoorPalette;
7
+ light: DoorPalette | null;
8
+ display: string;
9
+ /** The tenant's display face is served at /frapp/font. */
10
+ font: boolean;
11
+ mark: { src: string; width: number; height: number } | null;
12
+ backdrop: string | null;
13
+ };
14
+ export function doorTarget(raw: unknown): string | null;
15
+ export function doorFragment(hash: unknown): string | null;
16
+ export function lnaGated(): Promise<boolean>;
17
+ export const DOOR_SCRIPT: string;
18
+ export const DOOR_KINDS: Record<string, { title: string; line: string }>;
19
+ export const TITLE_MAX: number;
20
+ export function doorTitle(t: unknown): string | null;
21
+ export function doorCard(k: unknown, t?: unknown): { kind: string | null; kindTitle: string; line: string; title: string | null; heading: string };
22
+ export function doorHtml(opts?: { origin?: string; kind?: string | null; title?: string | null; theme?: Partial<DoorTheme> }): string;
23
+ /** The frapp door version this deploy serves, reported on /api/health (supersuit-dev#1182). */
24
+ export const DOOR_VERSION: number;