@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
package/CHANGELOG.md CHANGED
@@ -3,6 +3,25 @@
3
3
  `@supersuit/artifacts`. One entry per version, newest first. Each entry says what changed, how a
4
4
  host can tell whether it is affected (DETECTOR), what a host does about it (REMEDY), and the tests.
5
5
 
6
+ ## 0.59.0 (2026-11-02)
7
+
8
+ **The deployable hub host ships in the package** (supersuit-dev#1630). `template/hub/` is a complete
9
+ Next.js app for one operator's own artifact hub on Firebase App Hosting: every shell route as a
10
+ one-line file, Google sign-in on the hub's own domain (the same-origin `/__/auth` rewrite and
11
+ `/api/artifact-pass`), `/brand.json`, `/api/health` with the tile app door's version, the door
12
+ itself at `/frapp`, the 410 proxy for shut per-reader links, `apphosting.yaml` with every setting the
13
+ hub setup writes, and the comments feed's Firestore index. It is configured only by `HUB_TENANT`
14
+ and the variables in its README, so it needs nothing from any private repository. Until now the
15
+ only deployable host was private, and an operator's hub setup stopped at its first step with no
16
+ source they could reach (supersuit-dev#1606). `npm version` pins the template to the package's own
17
+ version (`scripts/sync-template-version.mjs`).
18
+
19
+ - DETECTOR: `ls node_modules/@supersuit/artifacts/template/hub` is empty or missing.
20
+ - REMEDY: update. Supersuit's hub setup uses this host when `--hub-source` is not given.
21
+ - Tests: `test/hub-template.test.mjs`; `npm run test:template` (`scripts/check-template.mjs`)
22
+ installs the template from the packed tarball outside the repo and runs `next build`, and the
23
+ publish workflow runs it before publishing.
24
+
6
25
  ## 0.58.2 (2026-11-02)
7
26
 
8
27
  **A letter's greeting and sign-off keep their own lines.** Markdown joins a soft line break with a
package/README.md CHANGED
@@ -15,6 +15,17 @@ npm install @supersuit/artifacts
15
15
  Peers: `next >= 16`, `react >= 19`, `react-dom >= 19`, `firebase-admin >= 13` (the store is
16
16
  Firestore), and optionally `@google-cloud/storage >= 7` for uploaded files.
17
17
 
18
+ ## A deployable hub host, in the package
19
+
20
+ `template/hub/` is a complete Next.js app that serves one operator's pages from a Google Cloud
21
+ project they own (Firebase App Hosting, Firestore, Cloud Storage, Google sign-in on their own
22
+ domain), configured entirely by environment variables. It ships in the tarball so anyone can
23
+ deploy a hub without access to any private repository. Supersuit's `create-or-update-my-artifact-hub`
24
+ copies it out of the package, runs `npm install` and deploys it; its own
25
+ [README](./template/hub/README.md) lists every variable. The template pins this package's own
26
+ version (`npm version` keeps it in step), and `npm run test:template` packs the package, installs
27
+ the template from the tarball outside this repo and runs `next build` on it.
28
+
18
29
  ## What you get
19
30
 
20
31
  | Export | What it is |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.58.2",
3
+ "version": "0.59.0",
4
4
  "description": "Publish markdown as branded, read-aloud pages from a Next.js app: the artifacts store, the route factory, the read-along reader, brand packs, password and confidential pages, and share cards.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -54,6 +54,7 @@
54
54
  "files": [
55
55
  "lib",
56
56
  "fonts",
57
+ "template",
57
58
  "README.md",
58
59
  "CHANGELOG.md",
59
60
  "LICENSE"
@@ -64,7 +65,9 @@
64
65
  "typecheck": "tsc -p tsconfig.json --noEmit",
65
66
  "test": "vitest run",
66
67
  "test:packed": "node scripts/check-packed.mjs",
67
- "prepack": "npm run build"
68
+ "prepack": "npm run build",
69
+ "test:template": "node scripts/check-template.mjs",
70
+ "version": "node scripts/sync-template-version.mjs"
68
71
  },
69
72
  "peerDependencies": {
70
73
  "@google-cloud/storage": ">=7",
@@ -0,0 +1,35 @@
1
+ # The artifact hub host
2
+
3
+ A deployable Next.js app that serves one operator's published pages on their own domain, from a
4
+ Google Cloud project they own. It is the `@supersuit/artifacts` shell wired to Firestore, Cloud
5
+ Storage and Firebase sign-in, and nothing else. It ships inside the `@supersuit/artifacts` npm
6
+ package at `template/hub/`.
7
+
8
+ **You do not deploy this by hand.** Supersuit's `create-or-update-my-artifact-hub` skill copies this
9
+ folder out of the installed package, runs `npm install`, and deploys it to Firebase App Hosting with
10
+ the settings it writes for that hub. It reads `apphosting.yaml` for the shape of the settings.
11
+
12
+ ## What configures it
13
+
14
+ Everything comes from the environment. `apphosting.yaml` lists every variable; the setup writes the
15
+ real values into an `apphosting.hub.yaml` override at deploy time.
16
+
17
+ | Variable | What it is |
18
+ | --- | --- |
19
+ | `HUB_TENANT` | JSON: `{ host, owner, firebase: { apiKey, appId, authDomain, projectId }, pack? }`. `authDomain` must equal `host`. `pack` is the hub's own colors, type and mark. |
20
+ | `FIREBASE_STORAGE_BUCKET` | The project's default bucket: public page media. |
21
+ | `ARTIFACT_PRIVATE_BUCKET` | A bucket with no public grant, for `media: private`. Unset, private media is refused. |
22
+ | `TENANT_PUBLISH_KEY_HUB` | The publish key (a Secret Manager secret). |
23
+ | `ARTIFACT_PASS_SECRET` | Signs the five-minute reader pass (a secret). |
24
+ | `ARTIFACT_RECIPIENT_SECRET` | Signs per-reader links (a secret). |
25
+ | `TENANT_OWNER_EMAIL_HUB` | The address the owner signs in with. Optional. |
26
+ | `TENANT_ADMINS_HUB` | Comma-separated admin sign-in addresses. Optional. |
27
+
28
+ With `HUB_TENANT` unset the app still builds, and every page says the hub is not configured yet.
29
+
30
+ ## What it serves
31
+
32
+ Pages at `/<id>`, past versions at `/<id>/v/<n>`, share cards at `/<id>/share.png`, the publish and
33
+ reader APIs under `/api/`, reader sign-in at `/artifact/sign-in` (Google, against the hub's own
34
+ Firebase project, same-origin through the `/__/auth` rewrite), `/brand.json`, `/api/health`, and the
35
+ tile app door at `/frapp`.
@@ -0,0 +1,8 @@
1
+ export default function ArtifactNotFound() {
2
+ return (
3
+ <div className="mx-auto max-w-2xl px-6 pt-32 text-center">
4
+ <h1 className="text-3xl">Nothing here</h1>
5
+ <p className="mt-4 opacity-70">This page was unpublished or the link is off by a character.</p>
6
+ </div>
7
+ )
8
+ }
@@ -0,0 +1,15 @@
1
+ // Published pages: /<id>. Thin file; the behaviour is the shell's, the wiring is src/lib/routes.ts.
2
+ import type { Metadata } from 'next'
3
+ import { hubRoutes } from '@/lib/routes'
4
+
5
+ export const dynamic = 'force-dynamic'
6
+
7
+ type Props = { params: Promise<{ id: string }>; searchParams?: Promise<{ key?: string | string[] }> }
8
+
9
+ export function generateMetadata(props: Props): Promise<Metadata> {
10
+ return hubRoutes().generateMetadata(props)
11
+ }
12
+
13
+ export default function ArtifactPage(props: Props) {
14
+ return hubRoutes().Page(props)
15
+ }
@@ -0,0 +1,10 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { hubRoutes } from '@/lib/routes'
3
+
4
+ type Ctx = { params: Promise<{ id: string }> }
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export function GET(request: NextRequest, ctx: Ctx) {
9
+ return hubRoutes().SHARE_IMAGE(request, ctx)
10
+ }
@@ -0,0 +1,15 @@
1
+ // One past version of a page: /<id>/v/<n>, read-only, behind the page's own door.
2
+ import type { Metadata } from 'next'
3
+ import { hubRoutes } from '@/lib/routes'
4
+
5
+ export const dynamic = 'force-dynamic'
6
+
7
+ type Props = { params: Promise<{ id: string; n: string }>; searchParams?: Promise<{ key?: string | string[] }> }
8
+
9
+ export function generateMetadata(props: Props): Promise<Metadata> {
10
+ return hubRoutes().generateVersionMetadata(props)
11
+ }
12
+
13
+ export default function ArtifactVersionPage(props: Props) {
14
+ return hubRoutes().VersionPage(props)
15
+ }
@@ -0,0 +1,24 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { getAdminAuth } from '@/lib/firebase-admin'
3
+ import { localPass } from '@/lib/local-sign-in'
4
+ import { hubTenant } from '@/tenant'
5
+
6
+ export const dynamic = 'force-dynamic'
7
+ export const runtime = 'nodejs'
8
+
9
+ // The pass for a reader who signed in on this host (app/artifact/sign-in).
10
+ export async function GET(request: NextRequest) {
11
+ const r = await localPass({
12
+ tenant: hubTenant(),
13
+ to: request.nextUrl.searchParams.get('to'),
14
+ authorization: request.headers.get('authorization'),
15
+ verify: async (t) => {
16
+ const d = await getAdminAuth().verifyIdToken(t)
17
+ return { uid: d.uid, email: d.email, emailVerified: d.email_verified === true, name: typeof d.name === 'string' ? d.name : undefined }
18
+ },
19
+ secret: process.env.ARTIFACT_PASS_SECRET,
20
+ })
21
+ return r.ok
22
+ ? Response.json({ url: r.url }, { headers: { 'cache-control': 'no-store' } })
23
+ : Response.json({ error: r.error }, { status: r.status, headers: { 'cache-control': 'no-store' } })
24
+ }
@@ -0,0 +1,14 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { hubRoutes } from '@/lib/routes'
3
+
4
+ type Ctx = { params: Promise<{ id: string }> }
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export function GET(request: NextRequest, ctx: Ctx) {
9
+ return hubRoutes().ACCESS(request, ctx)
10
+ }
11
+
12
+ export function POST(request: NextRequest, ctx: Ctx) {
13
+ return hubRoutes().ACCESS(request, ctx)
14
+ }
@@ -0,0 +1,10 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { hubRoutes } from '@/lib/routes'
3
+
4
+ type Ctx = { params: Promise<{ id: string; name: string }> }
5
+
6
+ export const maxDuration = 60
7
+
8
+ export function PUT(request: NextRequest, ctx: Ctx) {
9
+ return hubRoutes().PUT_ASSET(request, ctx)
10
+ }
@@ -0,0 +1,10 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { hubRoutes } from '@/lib/routes'
3
+
4
+ type Ctx = { params: Promise<{ id: string }> }
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export function POST(request: NextRequest, ctx: Ctx) {
9
+ return hubRoutes().FORWARD(request, ctx)
10
+ }
@@ -0,0 +1,18 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { hubRoutes } from '@/lib/routes'
3
+
4
+ type Ctx = { params: Promise<{ id: string }> }
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export function GET(request: NextRequest, ctx: Ctx) {
9
+ return hubRoutes().PERSONAL_GET(request, ctx)
10
+ }
11
+
12
+ export function POST(request: NextRequest, ctx: Ctx) {
13
+ return hubRoutes().PERSONAL_POST(request, ctx)
14
+ }
15
+
16
+ export function DELETE(request: NextRequest, ctx: Ctx) {
17
+ return hubRoutes().PERSONAL_DELETE(request, ctx)
18
+ }
@@ -0,0 +1,10 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { hubRoutes } from '@/lib/routes'
3
+
4
+ type Ctx = { params: Promise<{ id: string }> }
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export function GET(request: NextRequest, ctx: Ctx) {
9
+ return hubRoutes().READS(request, ctx)
10
+ }
@@ -0,0 +1,14 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { hubRoutes } from '@/lib/routes'
3
+
4
+ type Ctx = { params: Promise<{ id: string }> }
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export function GET(request: NextRequest, ctx: Ctx) {
9
+ return hubRoutes().RECIPIENTS(request, ctx)
10
+ }
11
+
12
+ export function POST(request: NextRequest, ctx: Ctx) {
13
+ return hubRoutes().RECIPIENTS(request, ctx)
14
+ }
@@ -0,0 +1,10 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { hubRoutes } from '@/lib/routes'
3
+
4
+ type Ctx = { params: Promise<{ id: string }> }
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export function POST(request: NextRequest, ctx: Ctx) {
9
+ return hubRoutes().REPLY(request, ctx)
10
+ }
@@ -0,0 +1,10 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { hubRoutes } from '@/lib/routes'
3
+
4
+ type Ctx = { params: Promise<{ id: string }> }
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export function POST(request: NextRequest, ctx: Ctx) {
9
+ return hubRoutes().RESOLVE(request, ctx)
10
+ }
@@ -0,0 +1,14 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { hubRoutes } from '@/lib/routes'
3
+
4
+ type Ctx = { params: Promise<{ id: string }> }
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export function GET(request: NextRequest, ctx: Ctx) {
9
+ return hubRoutes().RESPONSES(request, ctx)
10
+ }
11
+
12
+ export function DELETE(request: NextRequest, ctx: Ctx) {
13
+ return hubRoutes().RESPONSES(request, ctx)
14
+ }
@@ -0,0 +1,12 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { hubRoutes } from '@/lib/routes'
3
+
4
+ type Ctx = { params: Promise<{ id: string }> }
5
+
6
+ export function GET(request: NextRequest, ctx: Ctx) {
7
+ return hubRoutes().GET(request, ctx)
8
+ }
9
+
10
+ export function DELETE(request: NextRequest, ctx: Ctx) {
11
+ return hubRoutes().DELETE(request, ctx)
12
+ }
@@ -0,0 +1,10 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { hubRoutes } from '@/lib/routes'
3
+
4
+ type Ctx = { params: Promise<{ id: string }> }
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export function POST(request: NextRequest, ctx: Ctx) {
9
+ return hubRoutes().SEEN(request, ctx)
10
+ }
@@ -0,0 +1,14 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { hubRoutes } from '@/lib/routes'
3
+
4
+ type Ctx = { params: Promise<{ id: string }> }
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export function GET(request: NextRequest, ctx: Ctx) {
9
+ return hubRoutes().SETTINGS(request, ctx)
10
+ }
11
+
12
+ export function POST(request: NextRequest, ctx: Ctx) {
13
+ return hubRoutes().SETTINGS(request, ctx)
14
+ }
@@ -0,0 +1,14 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { hubRoutes } from '@/lib/routes'
3
+
4
+ type Ctx = { params: Promise<{ id: string }> }
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export function GET(request: NextRequest, ctx: Ctx) {
9
+ return hubRoutes().STATE_GET(request, ctx)
10
+ }
11
+
12
+ export function POST(request: NextRequest, ctx: Ctx) {
13
+ return hubRoutes().STATE_POST(request, ctx)
14
+ }
@@ -0,0 +1,10 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { hubRoutes } from '@/lib/routes'
3
+
4
+ type Ctx = { params: Promise<{ id: string }> }
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export function POST(request: NextRequest, ctx: Ctx) {
9
+ return hubRoutes().TRANSCRIBE(request, ctx)
10
+ }
@@ -0,0 +1,11 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { hubRoutes } from '@/lib/routes'
3
+
4
+ type Ctx = { params: Promise<{ id: string; name: string }> }
5
+
6
+ // Big files (narration) skip the function: this signs a URL the publisher PUTs to directly.
7
+ export const maxDuration = 30
8
+
9
+ export function POST(request: NextRequest, ctx: Ctx) {
10
+ return hubRoutes().UPLOAD(request, ctx)
11
+ }
@@ -0,0 +1,14 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { hubRoutes } from '@/lib/routes'
3
+
4
+ type Ctx = { params: Promise<{ id: string }> }
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export function GET(request: NextRequest, ctx: Ctx) {
9
+ return hubRoutes().VERSIONS(request, ctx)
10
+ }
11
+
12
+ export function POST(request: NextRequest, ctx: Ctx) {
13
+ return hubRoutes().VERSIONS(request, ctx)
14
+ }
@@ -0,0 +1,10 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { hubRoutes } from '@/lib/routes'
3
+
4
+ type Ctx = { params: Promise<{ id: string }> }
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export function GET(request: NextRequest, ctx: Ctx) {
9
+ return hubRoutes().VIEWS(request, ctx)
10
+ }
@@ -0,0 +1,13 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { hubRoutes } from '@/lib/routes'
3
+
4
+ export const dynamic = 'force-dynamic'
5
+ export const maxDuration = 30
6
+
7
+ export function POST(request: NextRequest) {
8
+ return hubRoutes().POST(request)
9
+ }
10
+
11
+ export function GET(request: NextRequest) {
12
+ return hubRoutes().LIST(request)
13
+ }
@@ -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().COMMENTS_FEED(request)
8
+ }
@@ -0,0 +1,10 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { hubRoutes } from '@/lib/routes'
3
+ import { healthOf } from '@/lib/health'
4
+
5
+ export const dynamic = 'force-dynamic'
6
+
7
+ // Whether this hub's queries will be served, the comments feed's Firestore index first. No key.
8
+ export function GET(request: NextRequest) {
9
+ return healthOf(hubRoutes(), request)
10
+ }
@@ -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
+ }