@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,321 @@
1
+ // THE TILE APP DOOR, on the operator's own artifacts hub: <their hub>/frapp?k=<kind>&t=<title>#to=<tailnet url>
2
+ //
3
+ // Supersuit sends every tile app link through the operator's own hub when their workspace names
4
+ // one (doorLink() points at <artifacts.origin>/frapp), so a personal link never routes through
5
+ // anyone else's domain, and the door wears the hub's own brand pack (doorHtml's `theme`, built by
6
+ // src/lib/frapp-door-theme.ts).
7
+ //
8
+ // Every tile app link Supersuit texts the operator is a tailnet URL (https://<mac>.<tailnet>.ts.net/…).
9
+ // When the phone's Tailscale is off that hostname does not resolve, so no server can answer and
10
+ // Safari shows its bare "can't find the server" screen. So the texted link points HERE first. This
11
+ // page tries the tailnet from the phone, forwards the instant it answers, and otherwise says to
12
+ // turn Tailscale on and retries until it can.
13
+ //
14
+ // THREE PROPERTIES ARE THE DESIGN:
15
+ //
16
+ // 1. THE KEY NEVER REACHES THIS SERVER. The target rides in the FRAGMENT (#to=…), which a
17
+ // browser never sends in a request, so no request log, no edge log and no analytics call
18
+ // ever holds the frapp's one-time key. The route serves the same bytes to every visitor of
19
+ // one host. That is also why validation happens in the page: the server never sees the target.
20
+ // 2. IT IS NOT AN OPEN REDIRECT. Only an https URL on a *.ts.net or <handle>.helm.supersuit.co host, on the default port, with
21
+ // no userinfo, is ever followed. Anything else gets a refusal screen and no navigation.
22
+ // 3. IT NEVER PROXIES. The page only probes (a no-cors fetch, whose response is opaque and
23
+ // unread) and navigates. Nothing from the tailnet passes through this host.
24
+ //
25
+ // The validator below is the function the page runs: its source is injected with toString(),
26
+ // so the tested function and the shipped one are the same bytes.
27
+
28
+ /**
29
+ * Which door this hub serves, reported on GET /api/health as `door` so Supersuit can tell an
30
+ * updated hub from an old one before it texts a link through it.
31
+ * 1 follows *.ts.net only (no `door` field on the health answer reads as this)
32
+ * 2 also follows <handle>.helm.supersuit.co (771552a)
33
+ * Bump it whenever doorTarget starts following a new kind of link.
34
+ */
35
+ export const DOOR_VERSION = 2;
36
+
37
+ /**
38
+ * The tailnet URL a door link carries, or null when it is not one we will follow.
39
+ * @param {string} raw the decoded `to` value from the fragment
40
+ */
41
+ export function doorTarget(raw) {
42
+ if (typeof raw !== "string" || raw.length > 4096) return null;
43
+ let u;
44
+ try { u = new URL(raw); } catch { return null; }
45
+ if (u.protocol !== "https:") return null;
46
+ if (u.username || u.password) return null;
47
+ if (u.port && u.port !== "443") return null;
48
+ // <machine>.<tailnet>.ts.net: at least two labels in front of ts.net, each a DNS label.
49
+ const tailnet = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+\.ts\.net$/.test(u.hostname);
50
+ // <handle>.helm.supersuit.co: the hosted Helm, a public name for the Mac's tailnet address, so it
51
+ // strands the phone the same way when Tailscale cannot reach the Mac. One
52
+ // DNS label in front, under the Helm's own domain, so the door stays no open redirect.
53
+ const helm = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.helm\.supersuit\.co$/.test(u.hostname);
54
+ if (!tailnet && !helm) return null;
55
+ return u.href;
56
+ }
57
+
58
+ /** Read `to` out of a location.hash, or null. Shipped to the page with doorTarget. */
59
+ export function doorFragment(hash) {
60
+ if (typeof hash !== "string") return null;
61
+ try { return new URLSearchParams(hash.replace(/^#/, "")).get("to"); } catch { return null; }
62
+ }
63
+
64
+ // WHAT THE LINK IS. The share card is the first thing the operator sees of a link, in the
65
+ // Messages bubble, before they tap, so a door link may carry `?k=<kind>&t=<title>` in the QUERY,
66
+ // which the server does see, and the card shows both.
67
+ //
68
+ // The kind is from a closed list; an unknown one is the generic card. The title is the page's own
69
+ // title, chosen to pass through this server. It is never the key: that stays in the fragment. It
70
+ // is cleaned to one short line and escaped before it goes anywhere near markup.
71
+ export const DOOR_KINDS = {
72
+ review: { title: "A draft for you to review", line: "Read it on your phone and leave your notes." },
73
+ compare: { title: "A choice for you to make", line: "Look them over and tap the one you want." },
74
+ approve: { title: "Waiting on your yes", line: "Approve it, or say what to change." },
75
+ note: { title: "A question for you", line: "Answer in your own words." },
76
+ listen: { title: "Something to listen to", line: "Read aloud to you, on your phone." },
77
+ portal: { title: "Your Supersuit portal", line: "Where your projects stand, and what is next." },
78
+ store: { title: "Your frapps", line: "Every page Pluma keeps for you." },
79
+ room: { title: "Your room", line: "Opens on your own Mac. Nothing is stored here." },
80
+ };
81
+ const GENERIC = { title: "A page from your Supersuit", line: "Private to your own devices. Opens over Tailscale." };
82
+ export const TITLE_MAX = 120;
83
+
84
+ /** A title fit for a card: one line, no control characters, at most TITLE_MAX characters, or null. */
85
+ export function doorTitle(t) {
86
+ if (typeof t !== "string") return null;
87
+ const one = t.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ").replace(/\s+/g, " ").trim();
88
+ if (!one) return null;
89
+ return one.length > TITLE_MAX ? one.slice(0, TITLE_MAX - 1).trimEnd() + "\u2026" : one;
90
+ }
91
+
92
+ /**
93
+ * The card for a door link: its kind (null when unknown), the kind's own words, and the page
94
+ * title when one was given. `heading` is what the card leads with: the page title, else the kind.
95
+ */
96
+ export function doorCard(k, t) {
97
+ const kind = typeof k === "string" && Object.hasOwn(DOOR_KINDS, k) ? k : null;
98
+ const base = kind ? DOOR_KINDS[kind] : GENERIC;
99
+ const title = doorTitle(t);
100
+ return { kind, kindTitle: base.title, line: base.line, title, heading: title || base.title };
101
+ }
102
+
103
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
104
+
105
+ // The page's own behaviour. Kept as a function so its source is what ships, and so a test can
106
+ // read its constants. Runs in the browser; nothing here may reference Node.
107
+ function doorMain(doorTarget, doorFragment) {
108
+ var PROBE_MS = 4000, RETRY_MS = 3000, SLOW_RETRY_MS = 10000, SLOW_AFTER_MS = 120000;
109
+ var $ = function (id) { return document.getElementById(id); };
110
+ var show = function (id) {
111
+ ["checking", "help", "refused"].forEach(function (s) { $(s).hidden = s !== id; });
112
+ };
113
+ var target = doorTarget(doorFragment(location.hash) || "");
114
+ if (!target) { show("refused"); return; }
115
+ $("anyway").href = target;
116
+ var started = Date.now(), timer = null, inflight = false, gone = false;
117
+
118
+ function probe() {
119
+ // An opaque answer is enough: ANY HTTP response (the store's 403 to a keyless request
120
+ // included) proves the name resolved and the route exists. A phone off the tailnet cannot
121
+ // resolve the name at all, so the fetch rejects, fast. A dead route times out.
122
+ var ctl = typeof AbortController === "function" ? new AbortController() : null;
123
+ var t = setTimeout(function () { if (ctl) ctl.abort(); }, PROBE_MS);
124
+ var origin = new URL(target).origin;
125
+ return fetch(origin + "/?door-probe=" + Date.now(), {
126
+ mode: "no-cors", cache: "no-store", credentials: "omit", referrerPolicy: "no-referrer",
127
+ signal: ctl ? ctl.signal : undefined,
128
+ }).then(function () { clearTimeout(t); return true; }, function () { clearTimeout(t); return false; });
129
+ }
130
+
131
+ function attempt() {
132
+ if (inflight || gone) return;
133
+ inflight = true;
134
+ $("status").textContent = "Checking…";
135
+ probe().then(function (ok) {
136
+ inflight = false;
137
+ if (ok) { gone = true; location.replace(target); return; }
138
+ // THE PROBE CAN FAIL WHERE A NAVIGATION WOULD NOT. On an iPhone with
139
+ // Tailscale on and connected, the no-cors fetch never reached the Mac, while typing the same
140
+ // URL into Safari loaded it at once: WebKit refuses a public page's fetch to a tailnet
141
+ // address that it lets a top-level navigation make. So the first failed probe is not taken
142
+ // as "Tailscale is off": the door goes to the Mac directly, ONCE. If Tailscale really is
143
+ // off, Safari shows its own error and Back returns here, where the mark below sends the
144
+ // page straight to the guide instead of navigating again. assign, never replace, so that
145
+ // Back exists. The mark lives in this entry's history state, which survives Back whether
146
+ // or not the browser kept the page in memory, and is scoped to the target so a new link
147
+ // gets its own one try.
148
+ if (!directTried()) { gone = true; markDirect(); location.assign(target); return; }
149
+ show("help");
150
+ schedule();
151
+ });
152
+ }
153
+
154
+ function directTried() {
155
+ try { return !!(history.state && history.state.doorDirect === target); } catch (e) { return false; }
156
+ }
157
+ function markDirect() {
158
+ try { history.replaceState({ doorDirect: target }, "", location.href); } catch (e) { /* no history API: navigate anyway */ }
159
+ }
160
+
161
+ function schedule() {
162
+ clearTimeout(timer);
163
+ if (document.hidden) { $("status").textContent = ""; return; }
164
+ var wait = Date.now() - started > SLOW_AFTER_MS ? SLOW_RETRY_MS : RETRY_MS;
165
+ $("status").textContent = "Trying again every " + Math.round(wait / 1000) + " seconds.";
166
+ timer = setTimeout(attempt, wait);
167
+ }
168
+
169
+ // A second door link opened in this tab changes only the fragment, which is a same-document
170
+ // navigation: without this the page kept probing the FIRST target forever. A reload starts
171
+ // clean against the new one.
172
+ window.addEventListener("hashchange", function () { gone = true; clearTimeout(timer); location.reload(); });
173
+ // Coming back from the Tailscale app is the moment it most likely works: try at once.
174
+ document.addEventListener("visibilitychange", function () { if (!document.hidden) attempt(); else clearTimeout(timer); });
175
+ window.addEventListener("online", attempt);
176
+ window.addEventListener("pageshow", function (e) { if (e.persisted) { gone = false; attempt(); } });
177
+ $("retry").addEventListener("click", function () { clearTimeout(timer); attempt(); });
178
+ // Only after a beat does "checking" appear, so a phone already on the tailnet never sees it.
179
+ setTimeout(function () { if (!gone && $("help").hidden) show("checking"); }, 700);
180
+ // CHROME GATES THE PROBE, SO CHROME SKIPS IT. Chromium's Local Network Access refuses a
181
+ // public page's request to a tailnet address (100.64/10) unless the person has granted
182
+ // "local network" to this site, and the refusal is the same TypeError a phone off the tailnet
183
+ // produces, and the door would say "turn on Tailscale" to a Mac that was on it. So where that permission exists
184
+ // and is not granted, the probe cannot tell us anything, and the door behaves as the plain
185
+ // link it replaced: it forwards at once. WebKit (every iPhone browser) has no such permission,
186
+ // the query throws, and the probe runs as designed. Granted means the probe works: run it.
187
+ lnaGated().then(function (gated) { if (gated) { gone = true; location.replace(target); } else attempt(); });
188
+ }
189
+
190
+ /** True when the browser gates local-network requests and this site has not been granted. */
191
+ export function lnaGated() {
192
+ try {
193
+ if (!navigator.permissions || !navigator.permissions.query) return Promise.resolve(false);
194
+ return navigator.permissions.query({ name: "local-network-access" })
195
+ .then(function (s) { return s.state !== "granted"; }, function () { return false; });
196
+ } catch (e) { return Promise.resolve(false); }
197
+ }
198
+
199
+ export const DOOR_SCRIPT = `(function () { ${lnaGated.toString()}
200
+ (${doorMain.toString()})(${doorTarget.toString()}, ${doorFragment.toString()}); })();`;
201
+
202
+ /** The look of one host's door: the tenant's brand pack, reduced to what plain HTML can wear. */
203
+ const DEFAULT_THEME = {
204
+ name: "Supersuit",
205
+ kicker: "A page from Supersuit",
206
+ scheme: "dark",
207
+ dark: { ground: "#0e0f13", ink: "#ece7dc", accent: "#c9a96e" },
208
+ light: null,
209
+ display: 'Georgia, "Iowan Old Style", "Times New Roman", serif',
210
+ font: false,
211
+ mark: null,
212
+ backdrop: null,
213
+ };
214
+
215
+ // A theme value goes into a <style> block; nothing in one may close it or open markup.
216
+ const cssSafe = (s) => String(s).replace(/[<>{}]/g, "");
217
+
218
+ function paletteVars(p) {
219
+ return `--ground:${cssSafe(p.ground)};--ink:${cssSafe(p.ink)};--accent:${cssSafe(p.accent)};`;
220
+ }
221
+
222
+ /** The page. Same bytes for every visitor of one host: it holds no target and no key. */
223
+ export function doorHtml({ origin = "", kind = null, title = null, theme = DEFAULT_THEME } = {}) {
224
+ const th = { ...DEFAULT_THEME, ...(theme || {}) };
225
+ const card = doorCard(kind, title);
226
+ const q = new URLSearchParams();
227
+ if (card.kind) q.set("k", card.kind);
228
+ if (card.title) q.set("t", card.title);
229
+ const og = `${origin}/frapp/og${q.size ? `?${q}` : ""}`;
230
+ const desc = card.title ? `${card.kindTitle}. ${card.line}` : card.line;
231
+ const base = th.scheme === "light" && th.light ? th.light : th.dark;
232
+ const other = th.scheme === "system" && th.light ? th.light : null;
233
+ const display = (th.font ? `"DoorDisplay", ` : "") + cssSafe(th.display);
234
+ const mark = th.mark
235
+ ? `<img class="mark" src="${esc(th.mark.src)}" width="${Number(th.mark.width) || 64}" height="${Number(th.mark.height) || 64}" alt="">`
236
+ : "";
237
+ const backdrop = th.backdrop ? `<div class="sky" aria-hidden="true"></div>` : "";
238
+ return `<!doctype html>
239
+ <html lang="en"><head>
240
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
241
+ <meta name="robots" content="noindex,nofollow">
242
+ <meta name="referrer" content="no-referrer">
243
+ <meta name="theme-color" content="${esc(base.ground)}">
244
+ <title>Opening your frapp · ${esc(th.name)}</title>
245
+ <meta property="og:title" content="${esc(card.heading)}">
246
+ <meta property="og:description" content="${esc(desc)}">
247
+ <meta property="og:image" content="${esc(og)}">
248
+ <meta property="og:image:width" content="1200">
249
+ <meta property="og:image:height" content="630">
250
+ <meta property="og:site_name" content="${esc(th.name)}">
251
+ <meta property="og:type" content="website">
252
+ <meta name="twitter:card" content="summary_large_image">
253
+ <style>
254
+ ${th.font ? `@font-face{font-family:"DoorDisplay";src:url(/frapp/font) format("truetype");font-weight:600;font-display:swap}` : ""}
255
+ :root{color-scheme:${th.scheme === "light" ? "light" : th.scheme === "system" ? "light dark" : "dark"};${paletteVars(base)}--tap:44px;--pill:980px;
256
+ --mute:color-mix(in srgb,var(--ink) 62%,transparent);--card:color-mix(in srgb,var(--ink) 6%,transparent);--line:color-mix(in srgb,var(--ink) 16%,transparent)}
257
+ ${other ? `@media(prefers-color-scheme:light){:root{${paletteVars(other)}}}` : ""}
258
+ *{box-sizing:border-box}
259
+ [hidden]{display:none!important}
260
+ html{background:var(--ground)}
261
+ body{margin:0;min-height:100svh;display:grid;place-items:center;background:var(--ground);color:var(--ink);position:relative;isolation:isolate;overflow-x:hidden;
262
+ font:1.0625rem/1.4706 -apple-system,BlinkMacSystemFont,"SF Pro Text","Helvetica Neue",Helvetica,Arial,sans-serif;
263
+ letter-spacing:-.011em;-webkit-font-smoothing:antialiased;
264
+ padding:calc(env(safe-area-inset-top) + 28px) 24px calc(env(safe-area-inset-bottom) + 28px)}
265
+ .sky{position:absolute;inset:0 0 auto 0;height:900px;z-index:-1;pointer-events:none;opacity:.6;
266
+ background:url(${th.backdrop ? cssSafe(th.backdrop) : "none"}) top/cover no-repeat;
267
+ -webkit-mask-image:linear-gradient(to bottom,#000 30%,transparent 100%);mask-image:linear-gradient(to bottom,#000 30%,transparent 100%)}
268
+ main{width:100%;max-width:24rem;text-align:center}
269
+ section{animation:rise .6s cubic-bezier(.2,.7,.2,1) both}
270
+ @keyframes rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
271
+ .mark{display:block;width:auto;height:72px;margin:0 auto 1.1rem;filter:drop-shadow(0 0 28px color-mix(in srgb,var(--accent) 35%,transparent))}
272
+ .kicker{font-size:.6875rem;letter-spacing:.3em;text-transform:uppercase;color:var(--accent);margin:0 0 2rem}
273
+ h1{font:600 1.875rem/1.12 ${display};letter-spacing:-.01em;margin:0 0 .9rem;color:var(--ink)}
274
+ p{margin:0 0 1rem}
275
+ .lede{font-size:1.1875rem;line-height:1.4211}
276
+ .opening{font-family:${display};font-size:1.375rem}
277
+ .card{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:1rem 1.1rem;margin:1.4rem 0;text-align:left}
278
+ .card ol{margin:0;padding-left:1.25rem}
279
+ .card li{margin:.35rem 0}
280
+ button{width:100%;min-height:var(--tap);padding:.8rem 1.4rem;background:var(--accent);color:var(--ground);border:1px solid transparent;
281
+ border-radius:var(--pill);font:inherit;font-weight:600;cursor:pointer;transition:transform .08s,opacity .12s}
282
+ button:active{transform:scale(.985)}
283
+ button:focus-visible,a:focus-visible{outline:2.5px solid var(--accent);outline-offset:3px}
284
+ .status{color:var(--mute);font-size:.875rem;min-height:1.3em;margin:.8rem 0 0}
285
+ .small{color:var(--mute);font-size:.875rem;margin-top:1.6rem}
286
+ a{color:var(--accent);text-decoration:none}
287
+ .dots{display:inline-flex;gap:6px;margin-top:.4rem}
288
+ .dots i{width:7px;height:7px;border-radius:50%;background:var(--accent);animation:pulse 1.2s infinite ease-in-out}
289
+ .dots i:nth-child(2){animation-delay:.15s}.dots i:nth-child(3){animation-delay:.3s}
290
+ @keyframes pulse{0%,80%,100%{opacity:.25}40%{opacity:1}}
291
+ @media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
292
+ </style></head>
293
+ <body>${backdrop}<main>
294
+ ${mark}
295
+ <p class="kicker">${esc(th.kicker)}</p>
296
+ <section id="checking" hidden>
297
+ <p class="lede opening">Opening your frapp</p>
298
+ <div class="dots" aria-label="Opening"><i></i><i></i><i></i></div>
299
+ </section>
300
+ <section id="help" hidden>
301
+ <h1>Turn on Tailscale</h1>
302
+ <p class="lede">Your phone isn't on your private network. Turn on Tailscale, then tap Retry.</p>
303
+ <div class="card"><ol>
304
+ <li>Open the Tailscale app and switch it on.</li>
305
+ <li>Or swipe down to Control Center and turn on VPN.</li>
306
+ <li>Come back here. It opens by itself once you're connected.</li>
307
+ </ol></div>
308
+ <button id="retry" type="button">Retry</button>
309
+ <p class="status" id="status" aria-live="polite"></p>
310
+ <p class="small">Tailscale already on? Your Mac may be asleep or offline. <a id="anyway" href="#" rel="noreferrer">Open it anyway</a></p>
311
+ </section>
312
+ <section id="refused" hidden>
313
+ <h1>This isn't a frapp link</h1>
314
+ <p class="lede">Supersuit only opens pages on your own private network, and this link doesn't point at one.</p>
315
+ <p class="small">Ask Pluma to send it again.</p>
316
+ </section>
317
+ <noscript><p class="lede">Turn on Tailscale, then open the link again.</p></noscript>
318
+ </main>
319
+ <script>${DOOR_SCRIPT}</script>
320
+ </body></html>`;
321
+ }
@@ -0,0 +1,23 @@
1
+ // GET /api/health: the shell's HEALTH, which runs the comments feed's query and names a missing
2
+ // Firestore index. Every answer also carries `door`, the tile app door version this deploy serves
3
+ // (DOOR_VERSION): Supersuit sends a hosted Helm link through the door only when the hub says it
4
+ // follows one.
5
+ import { NextResponse, type NextRequest } from 'next/server'
6
+ import { DOOR_VERSION } from './frapp-door.mjs'
7
+
8
+ type Handler = (req: NextRequest) => Promise<Response> | Response
9
+
10
+ async function withDoor(res: Response): Promise<Response> {
11
+ let body: unknown
12
+ try { body = await res.clone().json() } catch { return res }
13
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return res
14
+ const headers = new Headers(res.headers)
15
+ headers.delete('content-length')
16
+ return NextResponse.json({ ...(body as object), door: DOOR_VERSION }, { status: res.status, headers })
17
+ }
18
+
19
+ export async function healthOf(routes: object, req: NextRequest): Promise<Response> {
20
+ const HEALTH = (routes as { HEALTH?: Handler }).HEALTH
21
+ if (typeof HEALTH === 'function') return withDoor(await HEALTH(req))
22
+ return NextResponse.json({ ok: false, door: DOOR_VERSION, error: "this hub's @supersuit/artifacts has no HEALTH" }, { status: 501, headers: { 'cache-control': 'no-store' } })
23
+ }
@@ -0,0 +1,90 @@
1
+ // The HUB_TENANT parser, with no imports at all, so next.config.ts can read the same value the
2
+ // app reads without pulling React into Next's config loader. HUB_TENANT is what makes this app one
3
+ // operator's hub, deployed into their own Firebase project; create-or-update-my-artifact-hub sets it.
4
+
5
+ const HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
6
+
7
+ /** A hub's own brand look, validated. Required: ground, text, accent. Everything else optional. */
8
+ export type HubPack = {
9
+ name?: string
10
+ kicker?: string
11
+ ground: string
12
+ text: string
13
+ accent: string
14
+ display?: string
15
+ body?: string
16
+ fontsUrl?: string
17
+ light?: { ground: string; text: string; accent: string }
18
+ mark?: { src: string; width: number; height: number }
19
+ favicon?: string
20
+ }
21
+
22
+ function hex(v: unknown, field: string): string {
23
+ if (typeof v !== 'string' || !HEX.test(v)) throw new Error(`HUB_TENANT ${field} must be a hex color like #rrggbb`)
24
+ return v
25
+ }
26
+ function str(v: unknown, field: string): string | undefined {
27
+ if (v === undefined) return undefined
28
+ if (typeof v !== 'string' || !v.trim()) throw new Error(`HUB_TENANT ${field} must be a non-empty string`)
29
+ return v.trim()
30
+ }
31
+ function https(v: unknown, field: string): string | undefined {
32
+ if (v === undefined) return undefined
33
+ let ok = false
34
+ try { ok = typeof v === 'string' && new URL(v).protocol === 'https:' } catch { /* not a url */ }
35
+ if (!ok) throw new Error(`HUB_TENANT ${field} must be an https: url`)
36
+ return v as string
37
+ }
38
+ function num(v: unknown, field: string): number {
39
+ if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) throw new Error(`HUB_TENANT ${field} must be a positive number`)
40
+ return v
41
+ }
42
+
43
+ function parsePack(p: any): HubPack {
44
+ if (!p || typeof p !== 'object' || Array.isArray(p)) throw new Error('HUB_TENANT pack must be an object')
45
+ const out: HubPack = { ground: hex(p.ground, 'pack.ground'), text: hex(p.text, 'pack.text'), accent: hex(p.accent, 'pack.accent') }
46
+ const name = str(p.name, 'pack.name'); if (name) out.name = name
47
+ const kicker = str(p.kicker, 'pack.kicker'); if (kicker) out.kicker = kicker
48
+ const display = str(p.display, 'pack.display'); if (display) out.display = display
49
+ const body = str(p.body, 'pack.body'); if (body) out.body = body
50
+ const fontsUrl = https(p.fontsUrl, 'pack.fontsUrl'); if (fontsUrl) out.fontsUrl = fontsUrl
51
+ const favicon = https(p.favicon, 'pack.favicon'); if (favicon) out.favicon = favicon
52
+ if (p.light !== undefined) {
53
+ out.light = { ground: hex(p.light?.ground, 'pack.light.ground'), text: hex(p.light?.text, 'pack.light.text'), accent: hex(p.light?.accent, 'pack.light.accent') }
54
+ }
55
+ if (p.mark !== undefined) {
56
+ out.mark = { src: https(p.mark?.src, 'pack.mark.src') ?? (() => { throw new Error('HUB_TENANT pack.mark.src must be an https: url') })(), width: num(p.mark?.width, 'pack.mark.width'), height: num(p.mark?.height, 'pack.mark.height') }
57
+ }
58
+ return out
59
+ }
60
+
61
+ export type HubEnvConfig = {
62
+ host: string
63
+ owner: string
64
+ firebase: { apiKey: string; appId: string; authDomain: string; projectId: string }
65
+ /** A tenant id of another host's table, served in place. This template carries no such table, so
66
+ * a hub that names one refuses to start (src/tenant.ts) rather than serve a blank hub. */
67
+ adopt?: string
68
+ /** The hub's own brand look. Not with `adopt`: an adopted row has its own look. */
69
+ pack?: HubPack
70
+ }
71
+
72
+ export function parseHubEnv(raw: string | undefined): HubEnvConfig | null {
73
+ if (!raw?.trim()) return null
74
+ const c = JSON.parse(raw)
75
+ if (!c.firebase?.projectId) throw new Error('HUB_TENANT has no firebase block; a sovereign hub must sign its own readers in')
76
+ const host = typeof c.host === 'string' ? c.host.trim().toLowerCase() : ''
77
+ const owner = typeof c.owner === 'string' ? c.owner.trim() : ''
78
+ if (!host) throw new Error('HUB_TENANT has no host')
79
+ if (!owner) throw new Error('HUB_TENANT has no owner')
80
+ if (String(c.firebase.authDomain ?? '').trim().toLowerCase() !== host) {
81
+ throw new Error('HUB_TENANT firebase.authDomain must equal host: the same-origin /__/auth rewrite depends on it')
82
+ }
83
+ if (c.adopt !== undefined && (typeof c.adopt !== 'string' || !c.adopt.trim())) {
84
+ throw new Error('HUB_TENANT adopt must be a non-empty tenant id')
85
+ }
86
+ const adopt = typeof c.adopt === 'string' ? c.adopt.trim() : undefined
87
+ if (adopt && c.pack !== undefined) throw new Error('HUB_TENANT cannot carry both adopt and pack: an adopted row has its own look')
88
+ const pack = c.pack !== undefined ? parsePack(c.pack) : undefined
89
+ return { host, owner, firebase: c.firebase, ...(adopt ? { adopt } : {}), ...(pack ? { pack } : {}) }
90
+ }
@@ -0,0 +1,10 @@
1
+ // The hub's own variables, all named _HUB: TENANT_PUBLISH_KEY_HUB, TENANT_OWNER_EMAIL_HUB,
2
+ // TENANT_ADMINS_HUB. A person's email lives here, in the environment, never in a file.
3
+ const read = (name: string, env: Record<string, string | undefined> = process.env) => env[name]?.trim() || undefined
4
+ const list = (v: string | undefined) => (v ?? '').split(',').map((s) => s.trim()).filter(Boolean)
5
+
6
+ export const publishKey = (env?: Record<string, string | undefined>) => read('TENANT_PUBLISH_KEY_HUB', env)
7
+ export const ownerEmail = (env?: Record<string, string | undefined>) => read('TENANT_OWNER_EMAIL_HUB', env)
8
+ /** Who sees a page's version history besides the owner: an earlier draft can hold what the
9
+ * current one was edited to remove. */
10
+ export const admins = (env?: Record<string, string | undefined>) => list(read('TENANT_ADMINS_HUB', env))
@@ -0,0 +1,12 @@
1
+ // The root layout's metadata for a hub, as a pure function. Imports only types.
2
+ import type { Metadata } from 'next'
3
+ import type { HubEnvConfig } from './hub-env-parse'
4
+
5
+ export function hubMetadata(hub: HubEnvConfig | null): Metadata {
6
+ const pack = hub?.pack
7
+ return {
8
+ title: pack ? (pack.name ?? hub!.owner) : hub ? hub.owner : 'Artifacts',
9
+ robots: { index: false, follow: false },
10
+ ...(pack?.favicon && { icons: { icon: pack.favicon } }),
11
+ }
12
+ }
@@ -0,0 +1,46 @@
1
+ // Sign-in on the hub's OWN host. The reader signs in with Google on `<siteUrl>/artifact/sign-in`
2
+ // against the hub's own Firebase project, the page posts the ID token to /api/artifact-pass, and
3
+ // this mints the five-minute PASS signed with ARTIFACT_PASS_SECRET, which the package's
4
+ // `/api/reader/enter` swaps for a grant.
5
+ //
6
+ // THE COST: this side knows nobody's Supersuit membership, so every pass says `member: false`.
7
+ // An `access: freedom` page opens only to people on its list here. `access: invite` and
8
+ // `password:` pages are unaffected.
9
+ import { mintPass } from '@supersuit/artifacts'
10
+ import type { Tenant } from '@/tenant'
11
+
12
+ /** The package's pass lifetime (reader.ts), which its index does not export. */
13
+ const PASS_TTL_SECONDS = 300
14
+ const PAGE = /^\/[abcdefghjkmnpqrstuvwxyz23456789]{8}$/
15
+
16
+ export type VerifiedUser = { uid: string; email?: string; emailVerified: boolean; name?: string }
17
+
18
+ export type PassResult =
19
+ | { ok: true; url: string }
20
+ | { ok: false; status: number; error: string }
21
+
22
+ /** The enter URL for a signed-in reader, or the reason there is none. */
23
+ export async function localPass(opts: {
24
+ tenant: Tenant
25
+ to: string | null
26
+ authorization: string | null
27
+ verify: (idToken: string) => Promise<VerifiedUser | null>
28
+ secret: string | undefined
29
+ now?: number
30
+ }): Promise<PassResult> {
31
+ const { tenant, to, authorization, verify, secret } = opts
32
+ if (!tenant.localSignIn) return { ok: false, status: 404, error: 'no-local-sign-in' }
33
+ if (!secret) return { ok: false, status: 503, error: 'gate-not-configured' }
34
+ if (typeof to !== 'string' || !PAGE.test(to)) return { ok: false, status: 400, error: 'not-an-artifact' }
35
+ const token = /^Bearer (.+)$/.exec(authorization ?? '')?.[1]
36
+ if (!token) return { ok: false, status: 401, error: 'not signed in' }
37
+ const user = await verify(token).catch(() => null)
38
+ if (!user) return { ok: false, status: 401, error: 'not signed in' }
39
+ if (!user.email || !user.emailVerified) return { ok: false, status: 403, error: 'email-not-verified' }
40
+ const exp = Math.floor((opts.now ?? Date.now()) / 1000) + PASS_TTL_SECONDS
41
+ const pass = mintPass(secret, { uid: user.uid, email: user.email.toLowerCase(), name: user.name ?? null, member: false }, exp)
42
+ const u = new URL('/api/reader/enter', tenant.siteUrl)
43
+ u.searchParams.set('to', to)
44
+ u.searchParams.set('pass', pass)
45
+ return { ok: true, url: u.toString() }
46
+ }
@@ -0,0 +1,44 @@
1
+ // The hub's shell routes, built once. Storage lives in the hub's own Firestore under tenants/hub/.
2
+ // Every app/ route file is a thin call into what this returns.
3
+ import { createArtifactAssets, createArtifactRoutes, createArtifactStore, createPersonalStore, createReadersStore, createRecipientsStore, createStateStore } from '@supersuit/artifacts'
4
+ import { getAdminBucket, getAdminDb, getAdminPrivateBucket } from '@/lib/firebase-admin'
5
+ import { admins, ownerEmail, publishKey } from '@/lib/hub-env'
6
+ import { hubTenant } from '@/tenant'
7
+
8
+ /** The line a private page's door shows: readers sign in here, with Google. */
9
+ export const LOCAL_SIGN_IN_NOTE = 'This page is private. Sign in with the Google account it was shared with.'
10
+
11
+ let routes: ReturnType<typeof createArtifactRoutes> | null = null
12
+
13
+ export function hubRoutes() {
14
+ if (routes) return routes
15
+ const tenant = hubTenant()
16
+ const base = `tenants/${tenant.id}/artifact`
17
+ routes = createArtifactRoutes({
18
+ store: createArtifactStore(getAdminDb(), `tenants/${tenant.id}/artifacts`),
19
+ // Private media goes to its own bucket (ARTIFACT_PRIVATE_BUCKET); with none, the package
20
+ // refuses `media: private` rather than storing it where the public read grant reaches it.
21
+ assets: createArtifactAssets(getAdminBucket(), `tenants/${tenant.id}/artifacts`, { privateBucket: getAdminPrivateBucket() }),
22
+ brand: tenant.pack,
23
+ siteUrl: tenant.siteUrl,
24
+ publishKey: () => publishKey(),
25
+ // Gated pages (`access:` in front matter): readers sign in on this host and get a pass.
26
+ readers: createReadersStore(getAdminDb(), base),
27
+ readerSecret: () => process.env.ARTIFACT_PASS_SECRET,
28
+ signInOrigin: tenant.siteUrl,
29
+ signInNote: LOCAL_SIGN_IN_NOTE,
30
+ // Per-reader links (`links: per-reader`): who each link was made for, signed with one secret.
31
+ recipients: createRecipientsStore(getAdminDb(), base),
32
+ recipientSecret: () => process.env.ARTIFACT_RECIPIENT_SECRET?.trim() || undefined,
33
+ // Readers' answers on pages that declare `state:`. firestore.indexes.json carries the composite
34
+ // its comments feed needs (artifactState: slot, at).
35
+ state: createStateStore(getAdminDb(), base),
36
+ // Readers' personal notes: a store no publisher route reads, so "only you will see this" is true.
37
+ personal: createPersonalStore(getAdminDb(), base),
38
+ owner: tenant.owner,
39
+ ownerEmail: ownerEmail(),
40
+ admins: admins(),
41
+ history: 'admins',
42
+ })
43
+ return routes
44
+ }
@@ -0,0 +1,9 @@
1
+ // A brand mark served from a static https URL (an SVG in the hub's bucket), as the pack's Mark.
2
+ import type { ComponentType } from 'react'
3
+
4
+ export function markFromUrl(src: string, width: number, height: number): ComponentType<{ className?: string }> {
5
+ return function UrlMark({ className }) {
6
+ // eslint-disable-next-line @next/next/no-img-element
7
+ return <img src={src} width={width} height={height} alt="" className={className} />
8
+ }
9
+ }
@@ -0,0 +1,60 @@
1
+ // The one tenant this hub serves, built from HUB_TENANT (src/lib/hub-env-parse.ts). Pages live in
2
+ // Firestore under tenants/hub/..., so a hub's data paths never depend on its domain.
3
+ import { freedomDefault, type BrandPack } from '@supersuit/artifacts/brand'
4
+ import { markFromUrl } from '@/lib/url-mark'
5
+ import { parseHubEnv, type HubEnvConfig } from '@/lib/hub-env-parse'
6
+
7
+ /** A Firebase web app's public config. `authDomain` is the hub's own host: its `/__/auth` is
8
+ * rewritten to Firebase in next.config.ts, so the Google handler is same-origin. */
9
+ export type FirebaseWebConfig = { apiKey: string; appId: string; authDomain: string; projectId: string }
10
+
11
+ export type Tenant = {
12
+ id: string
13
+ /** The canonical https origin, used in returned URLs and share metadata. */
14
+ siteUrl: string
15
+ pack: BrandPack
16
+ /** Who a confidential page's banner says grants access. */
17
+ owner?: string
18
+ /** Readers sign in on this host, with Google, against the hub's own Firebase project. */
19
+ localSignIn?: { firebase: FirebaseWebConfig }
20
+ /** False until HUB_TENANT is set: the app builds, and every page says so. */
21
+ configured: boolean
22
+ }
23
+
24
+ /** The default look with the operator's own name on the kicker. */
25
+ export function defaultPackFor(name: string): BrandPack {
26
+ return { ...freedomDefault, id: `freedom-default:${name}`, kicker: `A page from ${name}` }
27
+ }
28
+
29
+ export function tenantFromConfig(c: HubEnvConfig | null): Tenant {
30
+ if (!c) return { id: 'hub', siteUrl: 'http://localhost:3000', pack: defaultPackFor('this hub'), configured: false }
31
+ // Fail closed: this template has no other host's table to adopt from, and serving a blank hub
32
+ // over someone's real pages is worse than not starting.
33
+ if (c.adopt) throw new Error(`HUB_TENANT adopt (${c.adopt}) needs the host that holds that tenant; this hub serves its own pages only`)
34
+ const base = defaultPackFor(c.owner)
35
+ const p = c.pack
36
+ const { light: _inherited, ...baseNoLight } = base
37
+ const pack: BrandPack = {
38
+ // A hub with its own pack gets its own id and no inherited light palette, so the package
39
+ // derives light mode from the pack's accent instead of showing the default colors.
40
+ ...(p ? { ...baseNoLight, id: `hub:${c.owner}` } : base),
41
+ ...(p && {
42
+ name: p.name ?? c.owner,
43
+ kicker: p.kicker ?? `A page from ${c.owner}`,
44
+ ground: p.ground,
45
+ ink: p.text,
46
+ accent: p.accent,
47
+ type: { display: p.display ?? base.type.display, body: p.body ?? base.type.body },
48
+ ...(p.light && { light: { ground: p.light.ground, ink: p.light.text, accent: p.light.accent } }),
49
+ ...(p.mark && { Mark: markFromUrl(p.mark.src, p.mark.width, p.mark.height) }),
50
+ }),
51
+ }
52
+ return { id: 'hub', siteUrl: `https://${c.host}`, pack, owner: c.owner, localSignIn: { firebase: c.firebase }, configured: true }
53
+ }
54
+
55
+ let cached: Tenant | null = null
56
+
57
+ /** The hub's tenant, read from HUB_TENANT once. */
58
+ export function hubTenant(): Tenant {
59
+ return (cached ??= tenantFromConfig(parseHubEnv(process.env.HUB_TENANT)))
60
+ }
@@ -0,0 +1,8 @@
1
+ import type { Config } from 'tailwindcss'
2
+
3
+ export default {
4
+ // The shell's classes live in @supersuit/artifacts (compiled JS); scan it or they vanish at build.
5
+ content: ['./app/**/*.{ts,tsx}', './src/**/*.{ts,tsx}', './node_modules/@supersuit/artifacts/lib/**/*.js'],
6
+ theme: { extend: {} },
7
+ plugins: [],
8
+ } satisfies Config