@supersuit/artifacts 0.58.2 → 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 (64) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +11 -0
  3. package/package.json +5 -2
  4. package/template/hub/README.md +35 -0
  5. package/template/hub/app/[id]/not-found.tsx +8 -0
  6. package/template/hub/app/[id]/page.tsx +15 -0
  7. package/template/hub/app/[id]/share.png/route.tsx +10 -0
  8. package/template/hub/app/[id]/v/[n]/page.tsx +15 -0
  9. package/template/hub/app/api/artifact-pass/route.ts +24 -0
  10. package/template/hub/app/api/artifacts/[id]/access/route.ts +14 -0
  11. package/template/hub/app/api/artifacts/[id]/assets/[name]/route.ts +10 -0
  12. package/template/hub/app/api/artifacts/[id]/forward/route.ts +10 -0
  13. package/template/hub/app/api/artifacts/[id]/personal/route.ts +18 -0
  14. package/template/hub/app/api/artifacts/[id]/reads/route.ts +10 -0
  15. package/template/hub/app/api/artifacts/[id]/recipients/route.ts +14 -0
  16. package/template/hub/app/api/artifacts/[id]/reply/route.ts +10 -0
  17. package/template/hub/app/api/artifacts/[id]/resolve/route.ts +10 -0
  18. package/template/hub/app/api/artifacts/[id]/responses/route.ts +14 -0
  19. package/template/hub/app/api/artifacts/[id]/route.ts +12 -0
  20. package/template/hub/app/api/artifacts/[id]/seen/route.ts +10 -0
  21. package/template/hub/app/api/artifacts/[id]/settings/route.ts +14 -0
  22. package/template/hub/app/api/artifacts/[id]/state/route.ts +14 -0
  23. package/template/hub/app/api/artifacts/[id]/transcribe/route.ts +10 -0
  24. package/template/hub/app/api/artifacts/[id]/uploads/[name]/route.ts +11 -0
  25. package/template/hub/app/api/artifacts/[id]/versions/route.ts +14 -0
  26. package/template/hub/app/api/artifacts/[id]/views/route.ts +10 -0
  27. package/template/hub/app/api/artifacts/route.ts +13 -0
  28. package/template/hub/app/api/comments/route.ts +8 -0
  29. package/template/hub/app/api/health/route.ts +10 -0
  30. package/template/hub/app/api/reader/ack/route.ts +6 -0
  31. package/template/hub/app/api/reader/enter/route.ts +8 -0
  32. package/template/hub/app/api/reader/leave/route.ts +8 -0
  33. package/template/hub/app/api/reader/track/route.ts +6 -0
  34. package/template/hub/app/artifact/sign-in/page.tsx +29 -0
  35. package/template/hub/app/artifact/sign-in/sign-in.tsx +60 -0
  36. package/template/hub/app/brand.json/route.ts +10 -0
  37. package/template/hub/app/frapp/font/route.ts +12 -0
  38. package/template/hub/app/frapp/og/route.ts +17 -0
  39. package/template/hub/app/frapp/route.ts +28 -0
  40. package/template/hub/app/globals.css +7 -0
  41. package/template/hub/app/layout.tsx +32 -0
  42. package/template/hub/app/page.tsx +26 -0
  43. package/template/hub/app/robots.ts +6 -0
  44. package/template/hub/apphosting.yaml +21 -0
  45. package/template/hub/firestore.indexes.json +13 -0
  46. package/template/hub/next.config.ts +27 -0
  47. package/template/hub/package.json +34 -0
  48. package/template/hub/postcss.config.mjs +2 -0
  49. package/template/hub/proxy.ts +24 -0
  50. package/template/hub/src/lib/brand-colors.ts +13 -0
  51. package/template/hub/src/lib/firebase-admin.ts +46 -0
  52. package/template/hub/src/lib/frapp-door-theme.ts +56 -0
  53. package/template/hub/src/lib/frapp-door.d.mts +24 -0
  54. package/template/hub/src/lib/frapp-door.mjs +321 -0
  55. package/template/hub/src/lib/health.ts +23 -0
  56. package/template/hub/src/lib/hub-env-parse.ts +90 -0
  57. package/template/hub/src/lib/hub-env.ts +10 -0
  58. package/template/hub/src/lib/hub-meta.ts +12 -0
  59. package/template/hub/src/lib/local-sign-in.ts +46 -0
  60. package/template/hub/src/lib/routes.ts +44 -0
  61. package/template/hub/src/lib/url-mark.tsx +9 -0
  62. package/template/hub/src/tenant.ts +60 -0
  63. package/template/hub/tailwind.config.ts +8 -0
  64. package/template/hub/tsconfig.json +21 -0
@@ -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;