@supersuit/artifacts 0.1.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 (49) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/LICENSE +21 -0
  3. package/README.md +206 -0
  4. package/fonts/Newsreader-600.ttf +0 -0
  5. package/lib/artifacts/assets.d.ts +27 -0
  6. package/lib/artifacts/assets.js +63 -0
  7. package/lib/artifacts/auth.d.ts +1 -0
  8. package/lib/artifacts/auth.js +8 -0
  9. package/lib/artifacts/confidential.d.ts +47 -0
  10. package/lib/artifacts/confidential.js +39 -0
  11. package/lib/artifacts/door.d.ts +5 -0
  12. package/lib/artifacts/door.js +4 -0
  13. package/lib/artifacts/front-matter.d.ts +34 -0
  14. package/lib/artifacts/front-matter.js +37 -0
  15. package/lib/artifacts/index.d.ts +9 -0
  16. package/lib/artifacts/index.js +9 -0
  17. package/lib/artifacts/narration.d.ts +8 -0
  18. package/lib/artifacts/narration.js +85 -0
  19. package/lib/artifacts/reader.d.ts +54 -0
  20. package/lib/artifacts/reader.js +109 -0
  21. package/lib/artifacts/readers-store.d.ts +90 -0
  22. package/lib/artifacts/readers-store.js +141 -0
  23. package/lib/artifacts/render.d.ts +4 -0
  24. package/lib/artifacts/render.js +90 -0
  25. package/lib/artifacts/store.d.ts +52 -0
  26. package/lib/artifacts/store.js +114 -0
  27. package/lib/artifacts/unlock.d.ts +9 -0
  28. package/lib/artifacts/unlock.js +24 -0
  29. package/lib/brand/default-share.d.ts +5 -0
  30. package/lib/brand/default-share.js +37 -0
  31. package/lib/brand/index.d.ts +3 -0
  32. package/lib/brand/index.js +3 -0
  33. package/lib/brand/pack.d.ts +48 -0
  34. package/lib/brand/pack.js +17 -0
  35. package/lib/brand/share-card.d.ts +13 -0
  36. package/lib/brand/share-card.js +50 -0
  37. package/lib/brand/wrapper.d.ts +12 -0
  38. package/lib/brand/wrapper.js +13 -0
  39. package/lib/gate.d.ts +1 -0
  40. package/lib/gate.js +5 -0
  41. package/lib/index.d.ts +4 -0
  42. package/lib/index.js +4 -0
  43. package/lib/reader/artifact-reader.d.ts +14 -0
  44. package/lib/reader/artifact-reader.js +165 -0
  45. package/lib/reader/reader-watch.d.ts +7 -0
  46. package/lib/reader/reader-watch.js +125 -0
  47. package/lib/routes/artifacts.d.ts +115 -0
  48. package/lib/routes/artifacts.js +345 -0
  49. package/package.json +74 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ `@supersuit/artifacts`. One entry per version, newest first. Each entry says what changed, how a
4
+ host can tell whether it is affected (DETECTOR), what a host does about it (REMEDY), and the tests.
5
+
6
+ ## 0.1.0 (2026-09-24)
7
+
8
+ **The site shell behind Freedom's `artifacts.<name>` pages, published as a package.** It was a
9
+ private workspace package (`@freedom/site-shell`) inside the one deployment that serves every
10
+ operator's pages; it now ships from its own public repository so a fix is one release that every
11
+ host picks up, and anyone can file one.
12
+
13
+ - Everything the host had: the Firestore artifact store with version history, the route factory
14
+ (`createArtifactRoutes`), the read-along reader, brand packs with `freedomDefault`, password
15
+ pages, confidential pages (`access: freedom | invite`) with the readers store, the agreement,
16
+ the watermark and the read record, and the share card.
17
+ - **Compiled ESM with declarations** (`lib/`), so a host no longer needs `transpilePackages`.
18
+ Relative imports carry `.js`; `next/*` imports deliberately do NOT. `next/navigation.js` skips
19
+ the alias Next applies per layer, and every route handler then fails to build on a missing
20
+ app-router context module. Plain Node ESM cannot resolve the extensionless form, so a host's
21
+ Vitest inlines the package (README).
22
+ - **No sign-in service is assumed.** `signInOrigin` used to default to one company's sign-in
23
+ host. It now has no default, and a confidential page on a host that sets none shows its door
24
+ with no way through: it fails closed rather than sending readers somewhere the host never named.
25
+ - **The share-card font ships in the tarball** at `fonts/Newsreader-600.ttf` and is found from a
26
+ host's `node_modules`, where it used to be read from the host's own source tree. Next's tracer
27
+ does not follow it there, so a host names it in `outputFileTracingIncludes` under a key that is
28
+ a glob (`'/*/share.png'`; `'/[id]/share.png'` is a character class and matches nothing).
29
+ - **DETECTOR:** a host importing `@freedom/site-shell` is on the pre-package copy.
30
+ - **REMEDY:** depend on `@supersuit/artifacts`, rename the imports, drop the package from
31
+ `transpilePackages`, point Tailwind `content` and any `outputFileTracingIncludes` for the font
32
+ at `node_modules/@supersuit/artifacts/`, inline the package in Vitest, and pass `signInOrigin`
33
+ explicitly if the host serves confidential pages.
34
+ - 92 tests, including one for the fail-closed door, plus `npm run test:packed`, which packs the
35
+ tarball into a small Next.js app (`test/fixture`), builds it, checks the share route's trace
36
+ carries the font, then serves it and fetches a page, a share card and the publish route. It was
37
+ verified to fail on a `next/navigation.js` import and on a tarball without `fonts/`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Supersuit Up
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,206 @@
1
+ # @supersuit/artifacts
2
+
3
+ Publish a markdown file, get a branded page at a short link. `@supersuit/artifacts` is the whole
4
+ site shell behind that: the store, the route handlers, the read-along reader, brand packs,
5
+ password pages, confidential pages with a signed-in reader list, and the share card a link
6
+ unfurls as. You mount it in a Next.js App Router app with a handful of one-line route files.
7
+
8
+ It is the shell that serves every Freedom operator's `artifacts.<theirname>` page, published
9
+ openly because none of it is secret and all of it is better with more eyes on it.
10
+
11
+ ```bash
12
+ npm install @supersuit/artifacts
13
+ ```
14
+
15
+ Peers: `next >= 16`, `react >= 19`, `react-dom >= 19`, `firebase-admin >= 13` (the store is
16
+ Firestore), and optionally `@google-cloud/storage >= 7` for uploaded files.
17
+
18
+ ## What you get
19
+
20
+ | Export | What it is |
21
+ |---|---|
22
+ | `createArtifactRoutes(config)` | Every route handler: the page, its metadata, the publish API, the share card, the reader sign-in exchange, access control |
23
+ | `createArtifactStore(db, path)` | Firestore store for pages and their version history, under any collection path |
24
+ | `createReadersStore(db, path)` | The record for confidential pages: allowlist, agreements, reading sessions, flags |
25
+ | `createArtifactAssets(bucket, prefix)` | Uploaded images for a page, in a Cloud Storage bucket |
26
+ | `freedomDefault`, `BrandPack` | The default look and the type any other look implements |
27
+ | `BrandGround`, `BrandMark` | The pack's page backdrop and mark, for your own pages (a home, a 404) |
28
+ | `parseArtifactSource` | The front-matter contract, as a parser you can call before publishing |
29
+ | `mintPass`, `verifyPass` | The reader pass, for the sign-in side (see below) |
30
+ | `ARTIFACT_PUBLIC_PREFIXES` (`/gate`) | Paths to leave open if you mount artifacts inside a gated site |
31
+
32
+ Subpath imports: `@supersuit/artifacts/artifacts`, `/routes`, `/brand`, `/reader`, `/gate`.
33
+ The package ships compiled ESM with type declarations; no `transpilePackages` needed.
34
+
35
+ ## Mount it in a Next.js app
36
+
37
+ One module builds the routes; every route file is a one-line delegation.
38
+
39
+ ```ts
40
+ // src/lib/artifacts.ts
41
+ import { createArtifactRoutes, createArtifactStore, createArtifactAssets, createReadersStore, freedomDefault } from '@supersuit/artifacts'
42
+ import { getFirestore } from 'firebase-admin/firestore'
43
+ import { getStorage } from 'firebase-admin/storage'
44
+
45
+ export const artifacts = createArtifactRoutes({
46
+ store: createArtifactStore(getFirestore(), 'artifacts'),
47
+ assets: createArtifactAssets(getStorage().bucket(), 'artifacts'), // optional
48
+ brand: { ...freedomDefault, kicker: 'A page from Sam Rivera' },
49
+ siteUrl: 'https://artifacts.example.com',
50
+ publishKey: () => process.env.ARTIFACTS_PUBLISH_KEY,
51
+ // Only for confidential pages (access:). Leave all three out and such a page stays shut.
52
+ readers: createReadersStore(getFirestore(), 'artifact'),
53
+ readerSecret: () => process.env.ARTIFACT_PASS_SECRET,
54
+ signInOrigin: 'https://accounts.example.com',
55
+ })
56
+ ```
57
+
58
+ ```tsx
59
+ // app/[id]/page.tsx
60
+ import { artifacts } from '@/lib/artifacts'
61
+ export const dynamic = 'force-dynamic'
62
+ export const generateMetadata = artifacts.generateMetadata
63
+ export default artifacts.Page
64
+ ```
65
+
66
+ The rest, each exporting the named handler(s) from `artifacts`:
67
+
68
+ | File | Handlers |
69
+ |---|---|
70
+ | `app/[id]/share.png/route.tsx` | `GET` = `SHARE_IMAGE` |
71
+ | `app/api/artifacts/route.ts` | `POST` (publish) |
72
+ | `app/api/artifacts/[id]/route.ts` | `GET`, `DELETE` |
73
+ | `app/api/artifacts/[id]/assets/[name]/route.ts` | `PUT` = `PUT_ASSET` |
74
+ | `app/api/artifacts/[id]/access/route.ts` | `GET`, `POST` = `ACCESS` |
75
+ | `app/api/artifacts/[id]/reads/route.ts` | `GET` = `READS` |
76
+ | `app/api/reader/enter/route.ts` | `GET` = `ENTER` |
77
+ | `app/api/reader/leave/route.ts` | `GET` = `LEAVE` |
78
+ | `app/api/reader/ack/route.ts` | `POST` = `ACK` |
79
+ | `app/api/reader/track/route.ts` | `POST` = `TRACK` |
80
+
81
+ Pages live at `/<id>` by default. Mounting inside a larger site, pass `pagePrefix: '/a/'` and put
82
+ the page at `app/a/[id]/`. One deployment can serve many sites: build one set of routes per
83
+ hostname and pick by the `host` header.
84
+
85
+ ### Three things your build config must know
86
+
87
+ - **Tailwind**: the shell's markup uses Tailwind classes, so add the package to `content`:
88
+ `'./node_modules/@supersuit/artifacts/lib/**/*.js'`.
89
+ - **The share-card font** ships at `node_modules/@supersuit/artifacts/fonts/` and is read from
90
+ `process.cwd()` at request time. Next's file tracer does not follow that path into
91
+ `node_modules`, so tell it, or the default pack's share card has no font in production:
92
+
93
+ ```ts
94
+ // next.config.ts
95
+ outputFileTracingIncludes: { '/*/share.png': ['./node_modules/@supersuit/artifacts/fonts/**'] },
96
+ ```
97
+
98
+ The key is a glob. `'/[id]/share.png'` reads as a character class and matches nothing, silently.
99
+ - **Vitest** (or anything running the shell under plain Node ESM): the shell imports
100
+ `next/navigation`, `next/server` and friends without a file extension, because that is the
101
+ form Next's bundler aliases per layer (with `.js` every route handler fails to build). Plain
102
+ Node cannot resolve that form, so let Vite resolve the package instead:
103
+ `test: { server: { deps: { inline: ['@supersuit/artifacts'] } } }`.
104
+
105
+ ## Publishing a page
106
+
107
+ `POST /api/artifacts` with `Authorization: Bearer <publish key>` and the markdown file as the
108
+ body. The response carries the page's id and URL. Posting a file whose front matter carries
109
+ that `id:` republishes it in place, keeping every earlier version.
110
+
111
+ ## Front matter
112
+
113
+ ```markdown
114
+ ---
115
+ title: The Plan for Q4
116
+ subtitle: What we are doing and why
117
+ summary: Three bets, what each costs, and what we stop doing to afford them.
118
+ ---
119
+ ```
120
+
121
+ | Key | Meaning |
122
+ |---|---|
123
+ | `title` (required) | The large line, and the unfurl title |
124
+ | `summary` (required) | The italic teaser under it, and the unfurl description |
125
+ | `subtitle` | A line between the two. A republish without it removes it |
126
+ | `id` | Republish in place instead of minting a new page |
127
+ | `cover` | An image URL (absolute or site-relative) used as the unfurl image instead of the share card |
128
+ | `audience` | Free text stored with the page |
129
+ | `voice`, `narration`, `timings`, `narrationHash` | Read-aloud: the audio and word timings a publisher generated. Absent, no player |
130
+ | `password` | Shuts the body behind a door (below) |
131
+ | `access` | `freedom`, `invite` or `public`: a confidential page (below) |
132
+ | `template` | `document`, the only one so far |
133
+
134
+ An unknown key is refused, so a typo fails loudly instead of being ignored.
135
+
136
+ ## A page with a password
137
+
138
+ `password: <string>` shuts the body behind a door. The title, summary and unfurl stay, so the
139
+ link still reads as itself in a thread. It opens on `?key=<password>` in the URL, or on the
140
+ cookie that first open sets, which holds a hash bound to the page id: it names no password and
141
+ opens no other page. Republishing without the line takes the door down.
142
+
143
+ ## A confidential page (`access:`)
144
+
145
+ `access: freedom` opens the page only to a signed-in reader with an active Freedom account or
146
+ on the page's list; `access: invite` opens it only to the list. Everyone else gets the title,
147
+ the summary and a sign-in door; the body never reaches them.
148
+
149
+ **The level is host-side state.** A republish that omits `access:` leaves it as it was; only
150
+ `access: public` (or `POST /api/artifacts/<id>/access` with `{"access":"public"}`) opens the page
151
+ again, so a publisher that strips keys can never reopen a confidential page by accident.
152
+
153
+ The list lives in the readers store, never in the file, so addresses stay out of content and
154
+ changing it needs no republish: `POST /api/artifacts/<id>/access` with
155
+ `{"add":[{"email":"...","name":"...","reason":"..."}],"remove":["..."]}` and the publish key.
156
+ `GET /api/artifacts/<id>/reads` returns who read it, for how long, and what they tried.
157
+
158
+ An allowed reader agrees to a short confidentiality statement before the body renders, once per
159
+ page; the wording is stored with their address and the time. The page then greets them by first
160
+ name, says why they can read it, tiles their address as a faint watermark, and records reading
161
+ time, scroll depth, and attempts to print, copy at length, or save. Refused sign-ins are
162
+ recorded too, which is how a forwarded link shows up.
163
+
164
+ ### The reader pass (the contract with your sign-in side)
165
+
166
+ This package does not sign anyone in. It sends readers to your sign-in authority and trusts a
167
+ signed pass coming back:
168
+
169
+ 1. A signed-out reader is sent to `<signInOrigin>/artifact/sign-in?to=<page URL>`.
170
+ 2. Your side authenticates them and redirects to `<page origin>/api/reader/enter?pass=<pass>&to=/<id>`.
171
+ 3. `ENTER` verifies the pass, sets a week-long HttpOnly grant cookie, and lands them on the page.
172
+
173
+ The pass is `a1.<payload>.<sig>`: `payload` is the base64url of
174
+ `{"u": uid, "e": email, "n": name|null, "m": isMember, "x": expiry-unix-seconds}`, and `sig` is
175
+ the first 32 hex characters of `HMAC-SHA256(ARTIFACT_PASS_SECRET, "artifact-pass:a1.<payload>")`.
176
+ `mintPass(secret, reader, exp)` builds one, so a Node sign-in side can import it rather than
177
+ reimplement it. Keep passes short-lived (five minutes is what the tests assume).
178
+
179
+ Without `signInOrigin`, a confidential page shows its door with no way through. It fails
180
+ closed, never open.
181
+
182
+ ## Brand packs
183
+
184
+ A `BrandPack` is data plus at most two components: colours, type, the kicker line above a title,
185
+ the narrator label, an optional full-page `Wrapper` and `Mark`, and an optional `share` block
186
+ (a font loader and a backdrop) that draws the unfurl card. `freedomDefault` is the default look.
187
+ Keep a pack carrying your own trademark in your own app, not in a pull request here.
188
+
189
+ ## Environment
190
+
191
+ | Variable | Used for |
192
+ |---|---|
193
+ | your publish key (named by you, read in `publishKey`) | Authorises publish, delete and access changes |
194
+ | `ARTIFACT_PASS_SECRET` (named by you, read in `readerSecret`) | Verifies reader passes and signs grants; shared with your sign-in side |
195
+ | Firebase Admin credentials | The store and readers store |
196
+
197
+ ## Contributing
198
+
199
+ Fixes and improvements are welcome: open an issue or a pull request at
200
+ [SupersuitUp/artifacts](https://github.com/SupersuitUp/artifacts). `npm test` runs the suite,
201
+ `npm run build` compiles, and `npm run test:packed` builds and serves a small Next.js app from
202
+ the packed tarball. A behaviour change comes with its test in the same pull request.
203
+
204
+ ## License
205
+
206
+ MIT
Binary file
@@ -0,0 +1,27 @@
1
+ import type { Bucket } from '@google-cloud/storage';
2
+ export interface ArtifactAssets {
3
+ /** Store `bytes` for artifact `id` under `name`; return the public URL. */
4
+ put(id: string, name: string, bytes: Buffer, contentType: string): Promise<string>;
5
+ }
6
+ /** Names are one path segment: letters, digits, dot, dash, underscore. */
7
+ export declare const ASSET_NAME: RegExp;
8
+ export declare const ASSET_TYPES: Record<string, string>;
9
+ export declare function contentTypeFor(name: string): string | undefined;
10
+ /**
11
+ * The stored name carries a hash of the bytes: `21-the-fork.9f3c1a22.png`.
12
+ *
13
+ * WITHOUT THIS, `immutable` IS A LIE AND THE READER PAYS FOR IT. Assets are served
14
+ * `public, max-age=31536000, immutable`, which instructs every browser and cache never to
15
+ * revalidate for a year, and republishing an artifact overwrote the object under the SAME
16
+ * filename. So a reader who opened a page once kept the first version they ever loaded,
17
+ * permanently, while the publisher's own `curl` fetched fresh bytes and reported everything fine.
18
+ * Measured 2026-09-23: an operator kept seeing a retired diagram on his phone after four
19
+ * republishes, and every check from this machine said the host was correct. It was. His copy was
20
+ * the one nobody could see.
21
+ *
22
+ * Hashing the bytes into the name is what `immutable` is designed for: changed bytes get a new
23
+ * URL, so nothing has to be invalidated and nothing can go stale. Unchanged bytes keep the same
24
+ * URL and stay cached, which is the whole benefit.
25
+ */
26
+ export declare function hashedName(name: string, bytes: Buffer): string;
27
+ export declare function createArtifactAssets(bucket: Bucket, prefix: string): ArtifactAssets;
@@ -0,0 +1,63 @@
1
+ // Where an artifact's files live (cover, images, narration audio and timings). The shell
2
+ // only knows the interface; the instance hands in a bucket. Before this existed the
3
+ // publisher shelled out to gsutil against the maintainer's bucket, which no other operator
4
+ // could do, so every operator gets the same door: PUT bytes with their key, get a URL back.
5
+ import { createHash } from 'node:crypto';
6
+ /** Names are one path segment: letters, digits, dot, dash, underscore. */
7
+ export const ASSET_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/;
8
+ export const ASSET_TYPES = {
9
+ webp: 'image/webp',
10
+ png: 'image/png',
11
+ jpg: 'image/jpeg',
12
+ jpeg: 'image/jpeg',
13
+ gif: 'image/gif',
14
+ mp3: 'audio/mpeg',
15
+ json: 'application/json',
16
+ };
17
+ export function contentTypeFor(name) {
18
+ const ext = name.split('.').pop()?.toLowerCase() ?? '';
19
+ return ASSET_TYPES[ext];
20
+ }
21
+ /**
22
+ * The stored name carries a hash of the bytes: `21-the-fork.9f3c1a22.png`.
23
+ *
24
+ * WITHOUT THIS, `immutable` IS A LIE AND THE READER PAYS FOR IT. Assets are served
25
+ * `public, max-age=31536000, immutable`, which instructs every browser and cache never to
26
+ * revalidate for a year, and republishing an artifact overwrote the object under the SAME
27
+ * filename. So a reader who opened a page once kept the first version they ever loaded,
28
+ * permanently, while the publisher's own `curl` fetched fresh bytes and reported everything fine.
29
+ * Measured 2026-09-23: an operator kept seeing a retired diagram on his phone after four
30
+ * republishes, and every check from this machine said the host was correct. It was. His copy was
31
+ * the one nobody could see.
32
+ *
33
+ * Hashing the bytes into the name is what `immutable` is designed for: changed bytes get a new
34
+ * URL, so nothing has to be invalidated and nothing can go stale. Unchanged bytes keep the same
35
+ * URL and stay cached, which is the whole benefit.
36
+ */
37
+ export function hashedName(name, bytes) {
38
+ const digest = createHash('sha256').update(bytes).digest('hex').slice(0, 8);
39
+ const dot = name.lastIndexOf('.');
40
+ // A name with no extension is legal here, so append rather than assume there is a dot.
41
+ return dot <= 0 ? `${name}.${digest}` : `${name.slice(0, dot)}.${digest}${name.slice(dot)}`;
42
+ }
43
+ export function createArtifactAssets(bucket, prefix) {
44
+ const clean = prefix.replace(/^\/+|\/+$/g, '');
45
+ return {
46
+ async put(id, name, bytes, contentType) {
47
+ const key = `${clean}/${id}/${hashedName(name, bytes)}`;
48
+ const file = bucket.file(key);
49
+ await file.save(bytes, {
50
+ contentType,
51
+ resumable: false,
52
+ metadata: { cacheControl: 'public, max-age=31536000, immutable' },
53
+ });
54
+ try {
55
+ await file.makePublic();
56
+ }
57
+ catch {
58
+ // A bucket with uniform public access has nothing to make public; the URL below still serves.
59
+ }
60
+ return `https://storage.googleapis.com/${bucket.name}/${key}`;
61
+ },
62
+ };
63
+ }
@@ -0,0 +1 @@
1
+ export declare function isPublishAuthed(request: Request, key: string | undefined): boolean;
@@ -0,0 +1,8 @@
1
+ // One publish key per instance. The site gate lets the artifacts API through precisely
2
+ // because this header is the whole authorization.
3
+ export function isPublishAuthed(request, key) {
4
+ if (!key)
5
+ return false;
6
+ const h = request.headers.get('authorization') ?? '';
7
+ return h === `Bearer ${key}`;
8
+ }
@@ -0,0 +1,47 @@
1
+ import type { BrandPack } from '../brand/pack.js';
2
+ import type { Reader } from './reader.js';
3
+ export declare function bannerLines({ name, email, reason, owner }: {
4
+ name: string | null;
5
+ email: string;
6
+ reason: string;
7
+ owner: string;
8
+ }): {
9
+ lead: string;
10
+ why: string;
11
+ rule: string;
12
+ record: string;
13
+ };
14
+ export declare function ConfidentialBanner(props: {
15
+ name: string | null;
16
+ email: string;
17
+ reason: string;
18
+ owner: string;
19
+ brand: BrandPack;
20
+ signOutUrl: string;
21
+ }): import("react").JSX.Element;
22
+ /** A tiled, rotated, near-invisible line of the reader's address over the whole page. */
23
+ export declare function Watermark({ email }: {
24
+ email: string;
25
+ }): import("react").JSX.Element;
26
+ /** Printing shows this and nothing else, whichever way the print dialog was reached. */
27
+ export declare const NO_PRINT_CSS = "@media print { body * { visibility: hidden !important; } body::after { content: \"This page is confidential and cannot be printed.\"; visibility: visible; position: fixed; top: 40%; left: 0; right: 0; text-align: center; font: 16px sans-serif; color: #000; } }";
28
+ export declare function SignInDoor({ brand, href }: {
29
+ brand: BrandPack;
30
+ href?: string;
31
+ }): import("react").JSX.Element;
32
+ export declare function NotAllowedDoor({ brand, reader, signOutUrl }: {
33
+ brand: BrandPack;
34
+ reader: Reader;
35
+ signOutUrl: string;
36
+ }): import("react").JSX.Element;
37
+ /** The agreement a reader makes before a confidential page opens. The exact string is stored
38
+ * with their acknowledgement, so the record says what they agreed to, not what it says today. */
39
+ export declare function ackText(owner: string): string;
40
+ export declare function AckDoor({ brand, name, email, owner, pageId, signOutUrl }: {
41
+ brand: BrandPack;
42
+ name: string | null;
43
+ email: string;
44
+ owner: string;
45
+ pageId: string;
46
+ signOutUrl: string;
47
+ }): import("react").JSX.Element;
@@ -0,0 +1,39 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ export function bannerLines({ name, email, reason, owner }) {
3
+ return {
4
+ lead: `${name ? `${name}, this` : 'This'} is confidential.`,
5
+ why: `You are reading it because ${reason}.`,
6
+ rule: `It is for you alone. Do not share, forward, copy, screenshot, print, or download it, or discuss its contents with anyone ${owner} has not given access.`,
7
+ record: `Your reading is recorded under ${email}.`,
8
+ };
9
+ }
10
+ export function ConfidentialBanner(props) {
11
+ const l = bannerLines(props);
12
+ return (_jsxs("aside", { "data-nospeak": true, className: "mx-auto max-w-2xl rounded-xl border px-5 py-4 text-sm leading-relaxed", style: { borderColor: props.brand.accent, color: props.brand.ink }, children: [_jsx("p", { className: "font-semibold", style: { color: props.brand.accent }, children: l.lead }), _jsxs("p", { className: "mt-1", children: [l.why, " ", l.rule] }), _jsxs("p", { className: "mt-2 text-xs opacity-60", children: [l.record, " Not you? ", _jsx("a", { href: props.signOutUrl, className: "underline", children: "Sign out" }), "."] })] }));
13
+ }
14
+ /** A tiled, rotated, near-invisible line of the reader's address over the whole page. */
15
+ export function Watermark({ email }) {
16
+ const text = `${email} · confidential`.replace(/[<>&"']/g, '');
17
+ const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='420' height='220'><text x='10' y='120' transform='rotate(-24 210 110)' font-family='sans-serif' font-size='14' fill='rgba(128,128,128,0.10)'>${text}</text></svg>`;
18
+ return (_jsx("div", { "aria-hidden": true, "data-nospeak": true, className: "pointer-events-none fixed inset-0 z-40", style: { backgroundImage: `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")` } }));
19
+ }
20
+ /** Printing shows this and nothing else, whichever way the print dialog was reached. */
21
+ export const NO_PRINT_CSS = `@media print { body * { visibility: hidden !important; } body::after { content: "This page is confidential and cannot be printed."; visibility: visible; position: fixed; top: 40%; left: 0; right: 0; text-align: center; font: 16px sans-serif; color: #000; } }`;
22
+ export function SignInDoor({ brand, href }) {
23
+ // No href means the host configured no sign-in authority: the door stays, with no way through.
24
+ if (!href) {
25
+ return (_jsx("div", { "data-nospeak": true, className: "mx-auto max-w-sm px-6 pb-24 text-center", children: _jsx("p", { className: "text-sm opacity-70", children: "This page is confidential, and this site cannot sign readers in yet." }) }));
26
+ }
27
+ return (_jsxs("div", { "data-nospeak": true, className: "mx-auto max-w-sm px-6 pb-24 text-center", children: [_jsx("p", { className: "text-sm opacity-70", children: "This page is confidential. Sign in with the Google account your Freedom account uses, or the address it was shared with." }), _jsx("a", { href: href, className: "mt-5 inline-block rounded-lg px-5 py-2 text-sm font-medium", style: { background: brand.accent, color: brand.ground }, children: "Sign in to read" })] }));
28
+ }
29
+ export function NotAllowedDoor({ brand, reader, signOutUrl }) {
30
+ return (_jsxs("div", { "data-nospeak": true, className: "mx-auto max-w-sm px-6 pb-24 text-center text-sm", children: [_jsxs("p", { className: "opacity-80", children: ["You are signed in as ", reader.email, ", and this page has not been shared with that address. If it was sent to a different address of yours, sign out and use that one."] }), _jsx("p", { className: "mt-3 opacity-60", children: "This attempt was recorded." }), _jsx("a", { href: signOutUrl, className: "mt-5 inline-block underline", style: { color: brand.accent }, children: "Sign out" })] }));
31
+ }
32
+ /** The agreement a reader makes before a confidential page opens. The exact string is stored
33
+ * with their acknowledgement, so the record says what they agreed to, not what it says today. */
34
+ export function ackText(owner) {
35
+ return `I understand this document is confidential. I will not share, forward, copy, screenshot, print, or download it, or discuss its contents with anyone ${owner} has not given access. I understand that my reading is recorded.`;
36
+ }
37
+ export function AckDoor({ brand, name, email, owner, pageId, signOutUrl }) {
38
+ return (_jsxs("form", { method: "post", action: "/api/reader/ack", "data-nospeak": true, className: "mx-auto max-w-md px-6 pb-24 text-left text-sm", children: [_jsx("input", { type: "hidden", name: "id", value: pageId }), _jsxs("div", { className: "rounded-xl border px-5 py-5", style: { borderColor: brand.accent }, children: [_jsx("p", { className: "font-semibold", style: { color: brand.accent }, children: name ? `${name}, before you read` : 'Before you read' }), _jsx("p", { className: "mt-3 leading-relaxed", children: ackText(owner) }), _jsxs("label", { className: "mt-4 flex items-start gap-3", children: [_jsx("input", { type: "checkbox", name: "agree", value: "yes", required: true, className: "mt-1" }), _jsx("span", { children: "I agree to keep it confidential." })] }), _jsx("button", { type: "submit", className: "mt-5 w-full rounded-lg px-5 py-2 text-sm font-medium", style: { background: brand.accent, color: brand.ground }, children: "Agree and open" }), _jsxs("p", { className: "mt-3 text-xs opacity-60", children: ["Signed in as ", email, ". Your agreement is recorded with the time. Not you? ", _jsx("a", { href: signOutUrl, className: "underline", children: "Sign out" }), "."] })] })] }));
39
+ }
@@ -0,0 +1,5 @@
1
+ import type { BrandPack } from '../brand/pack.js';
2
+ export declare function ArtifactDoor({ brand, wrongKey }: {
3
+ brand: BrandPack;
4
+ wrongKey: boolean;
5
+ }): import("react").JSX.Element;
@@ -0,0 +1,4 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ export function ArtifactDoor({ brand, wrongKey }) {
3
+ return (_jsxs("form", { method: "get", "data-nospeak": true, className: "mx-auto max-w-sm px-6 pb-24 text-center", children: [_jsx("label", { htmlFor: "artifact-key", className: "block text-sm opacity-70", children: "This page is for the people it was written for. Enter the key you were given." }), _jsx("input", { id: "artifact-key", name: "key", type: "password", autoComplete: "off", autoFocus: true, className: "mt-4 w-full rounded-lg border px-4 py-3 text-center text-base outline-none", style: { borderColor: brand.accent, background: 'transparent', color: brand.ink } }), wrongKey ? (_jsx("p", { className: "mt-3 text-sm", style: { color: brand.accent }, children: "That key did not open it." })) : null, _jsx("button", { type: "submit", className: "mt-4 rounded-lg px-5 py-2 text-sm font-medium", style: { background: brand.accent, color: brand.ground }, children: "Open" })] }));
4
+ }
@@ -0,0 +1,34 @@
1
+ import { type Access } from './reader.js';
2
+ export type ArtifactMeta = {
3
+ title: string;
4
+ summary: string;
5
+ template: 'document';
6
+ /** A line under the title. Title, subtitle and summary are three separate fields; the summary
7
+ * is the teaser, and the unfurl carries the title and the summary. */
8
+ subtitle?: string;
9
+ audience?: string;
10
+ cover?: string;
11
+ id?: string;
12
+ /** Narrator to generate with (a voice id the host's publisher knows). The publisher acts on it; the API stores it. */
13
+ voice?: string;
14
+ /** Bucket URLs written by the publisher after narration; absent means no player. */
15
+ narration?: string;
16
+ timings?: string;
17
+ /** Hash of the narrated text, so a changed body is re-narrated and an unchanged one is not. */
18
+ narrationHash?: string;
19
+ /** Shut the page behind this until `?key=<password>` or the cookie it sets. Never rendered. */
20
+ password?: string;
21
+ /** Who may read it, checked against a signed-in reader: `invite` is the host-side allowlist
22
+ * only; `freedom` is any active Freedom account plus that list; `public` opens it again.
23
+ * ABSENT CHANGES NOTHING: the level is host-side state, so a republish that omits the line
24
+ * (or a publisher that strips unknown keys) can never reopen a confidential page. */
25
+ access?: Access | 'public';
26
+ };
27
+ export declare function parseArtifactSource(text: string): {
28
+ ok: true;
29
+ meta: ArtifactMeta;
30
+ body: string;
31
+ } | {
32
+ ok: false;
33
+ error: string;
34
+ };
@@ -0,0 +1,37 @@
1
+ // The artifact contract: markdown plus a small front matter.
2
+ // Documented in README, "Front matter".
3
+ import matter from 'gray-matter';
4
+ import { ACCESS_LEVELS } from './reader.js';
5
+ const KNOWN = new Set(['title', 'summary', 'subtitle', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access']);
6
+ export function parseArtifactSource(text) {
7
+ const { data, content } = matter(text);
8
+ const d = data;
9
+ for (const k of Object.keys(d)) {
10
+ if (!KNOWN.has(k))
11
+ return { ok: false, error: `front matter has an unknown key: ${k}` };
12
+ }
13
+ for (const k of ['title', 'summary']) {
14
+ const v = d[k];
15
+ if (typeof v !== 'string' || !v.trim())
16
+ return { ok: false, error: `front matter is missing ${k}` };
17
+ }
18
+ const template = d.template ?? 'document';
19
+ if (template !== 'document')
20
+ return { ok: false, error: 'template must be document' };
21
+ const meta = {
22
+ title: String(d.title).trim(),
23
+ summary: String(d.summary).trim(),
24
+ template: 'document',
25
+ };
26
+ for (const k of ['subtitle', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password']) {
27
+ const v = d[k];
28
+ if (typeof v === 'string' && v)
29
+ meta[k] = v;
30
+ }
31
+ if (d.access !== undefined) {
32
+ if (d.access !== 'public' && !ACCESS_LEVELS.includes(d.access))
33
+ return { ok: false, error: `access must be one of: public, ${ACCESS_LEVELS.join(', ')}` };
34
+ meta.access = d.access;
35
+ }
36
+ return { ok: true, meta, body: content };
37
+ }
@@ -0,0 +1,9 @@
1
+ export { parseArtifactSource, type ArtifactMeta } from './front-matter.js';
2
+ export { ArtifactMarkdown } from './render.js';
3
+ export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
4
+ export { createArtifactStore, newArtifactId, type ArtifactStore, type ArtifactRecord, type SaveResult } from './store.js';
5
+ export { isPublishAuthed } from './auth.js';
6
+ export { createArtifactAssets, contentTypeFor, ASSET_NAME, type ArtifactAssets } from './assets.js';
7
+ export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
8
+ export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS, type Access, type Reader, type AllowEntry, type Decision } from './reader.js';
9
+ export { createReadersStore, summarize, FLAG_KINDS, type ReadersStore, type FlagKind, type SessionDoc, type FlagDoc } from './readers-store.js';
@@ -0,0 +1,9 @@
1
+ export { parseArtifactSource } from './front-matter.js';
2
+ export { ArtifactMarkdown } from './render.js';
3
+ export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
4
+ export { createArtifactStore, newArtifactId } from './store.js';
5
+ export { isPublishAuthed } from './auth.js';
6
+ export { createArtifactAssets, contentTypeFor, ASSET_NAME } from './assets.js';
7
+ export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
8
+ export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS } from './reader.js';
9
+ export { createReadersStore, summarize, FLAG_KINDS } from './readers-store.js';
@@ -0,0 +1,8 @@
1
+ export declare function narrationTextFromMarkdown(markdown: string): string;
2
+ export declare function narrationText(input: {
3
+ title: string;
4
+ summary: string;
5
+ markdown: string;
6
+ }): string;
7
+ /** Tokens the highlighter and the timings agree on: whitespace-split, lowercased, letters and digits only. */
8
+ export declare function normalizeWord(w: string): string;