@supersuit/artifacts 0.61.0 → 0.63.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,48 @@
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.63.0 (2026-11-08)
7
+
8
+ **Ask for access.** A signed-in visitor turned away at a private page's door (a `links: per-reader`
9
+ page without their link, or an `access:` page that was not shared with their account) can now ask
10
+ the publisher for it, with an optional note of up to 500 characters (`ASK_NOTE_MAX`).
11
+
12
+ - New route `POST /api/reader/ask` (`ASK`), a plain form post from the door. It records an `asked`
13
+ flag (the note as `detail`) and sends an `asked` reader event (`note` on `ReaderEvent`), once per
14
+ visitor per page. The owner, an admin, anyone the page already opens to, and a page that is not
15
+ private record nothing. It always lands back on the page.
16
+ - After asking, the door says the request was passed on instead of showing the form again.
17
+ - `READ_ALERT_KINDS` gains `asked` (`readAlertsDue({ askedAttempt: true })`, once per reader per page).
18
+ - `summarize()` returns `asked`: who asked, their latest note and when. `FLAG_KINDS` gains `asked`.
19
+ - The form shows only on a host with a reader store (`readers`), since a request needs somewhere to go.
20
+ - DETECTOR: a signed-in visitor at a private page's door has no way to ask for it.
21
+ - REMEDY: update, and mount `app/api/reader/ask/route.ts` (`POST` = `ASK`), as the hub template
22
+ now does. A host whose read-alert text switches on the alert kind adds a line for `asked`.
23
+ - TESTS: `src/routes/ask-access.test.tsx` (the form on both doors, none signed out or without a
24
+ reader store, the asked state, the route's recording, note cap and refusals, the alert and the
25
+ summary), `src/artifacts/read-alerts.test.ts`, `test/hub-template.test.mjs` (the route ships).
26
+
27
+ ## 0.62.0 (2026-11-08)
28
+
29
+ **The no-link door on a per-reader page says what it is and what to do.** A visitor opening a
30
+ `links: per-reader` page without their link met one line, "This page opens from the link it was sent
31
+ with.", which read as an error and offered nothing to a signed-in reader who was not sent it.
32
+
33
+ - The door now says the page was shared with specific people, that each has their own link, and to
34
+ open it from the message it came in or ask the sender for theirs (`NO_LINK` is now the headline,
35
+ "This page was shared with specific people.").
36
+ - Signed out (with `signInOrigin`): "Think you should have access? Sign in." as a button.
37
+ - Signed in but not sent it: names the account, offers "Use a different account" (the same switch a
38
+ gated page uses), and records the attempt as a `refused` flag with a `refused` reader event, like a
39
+ gated page's refusal, so the publisher's read alerts can see it. The door says "This attempt was
40
+ recorded" only when the record was written. An unfurl fetcher records nothing.
41
+ - A revoked or expired link adds "ask the person who sent it for a new link."
42
+ - Nothing of the page (title, summary, body) is shown before the gate, as before.
43
+ - DETECTOR: a per-reader page's bare address shows only "This page opens from the link it was sent with."
44
+ - REMEDY: update. Nothing to configure. A host that matched the old `NO_LINK` text should read the constant.
45
+ - TESTS: `src/routes/per-reader.test.tsx` (the door's words, the signed-in account and switch link,
46
+ the refusal recorded and claimed only when a reader store wrote it, the revoked-link line).
47
+
6
48
  ## 0.61.0 (2026-11-08)
7
49
 
8
50
  **Send outcomes: one success indicator for any send** (issue #12, item 1, and outcomes). A page declares a `goal` (`read` by default, `respond`, `ack`, or `{ click: <cta id> }`); a link titled `cta:<id>` is a CTA whose press the page records through `POST /seen` (`event: click`, under the beat, refused for an id the page does not carry); and every reader's line in `GET /views` and `summarizeViews` gains `lastOpenAt`, `sessions`, `opens`, `clicks`, `goal`, `outcome` (`not-opened`, `opened`, `read`, `returned`, `acted`), `succeeded` and `daysSinceSent`, all under the before-send rule. Thresholds are exported (`OUTCOME_READ_SECONDS` 30, `OUTCOME_RETURN_GAP_MS` one hour) and a host overrides them with `outcomeThresholds` on the routes, `summarizeViews`'s fourth argument, or `?readSeconds=` on `/views`. `ack` is new: the only acknowledgement before was the confidential page's agreement, keyed to a signed-in email, which a per-reader page cannot carry, so `ack` is a press of the CTA named `ack`. `GET /api/artifacts/<id>` answers `goal`. Existing fields are unchanged; a page with no goal reads as `read`; a view stored before clicks reads as none.
package/README.md CHANGED
@@ -93,6 +93,7 @@ The rest, each exporting the named handler(s) from `artifacts`:
93
93
  | `app/api/reader/enter/route.ts` | `GET` = `ENTER` |
94
94
  | `app/api/reader/leave/route.ts` | `GET` = `LEAVE` |
95
95
  | `app/api/reader/ack/route.ts` | `POST` = `ACK` |
96
+ | `app/api/reader/ask/route.ts` | `POST` = `ASK` (a visitor turned away asks for access; 0.63.0) |
96
97
  | `app/api/reader/track/route.ts` | `POST` = `TRACK` |
97
98
  | `app/api/artifacts/[id]/resolve/route.ts` | `POST` = `RESOLVE` (see [resolving a thread](#resolving-a-thread)) |
98
99
  | `app/api/comments/route.ts` | `GET` = `COMMENTS_FEED` (see [the comments feed](#the-comments-feed)) |
@@ -34,10 +34,11 @@ export declare function SignInDoor({ brand, href, note }: {
34
34
  href?: string;
35
35
  note?: string;
36
36
  }): import("react").JSX.Element;
37
- export declare function NotAllowedDoor({ brand, reader, signOutUrl }: {
37
+ export declare function NotAllowedDoor({ brand, reader, signOutUrl, ask }: {
38
38
  brand: BrandPack;
39
39
  reader: Reader;
40
40
  signOutUrl: string;
41
+ ask?: React.ReactNode;
41
42
  }): import("react").JSX.Element;
42
43
  /** The agreement a reader makes before a confidential page opens. The exact string is stored
43
44
  * with their acknowledgement, so the record says what they agreed to, not what it says today. */
@@ -30,8 +30,8 @@ export function SignInDoor({ brand, href, note = DEFAULT_SIGN_IN_NOTE }) {
30
30
  }
31
31
  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: note }), _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" })] }));
32
32
  }
33
- export function NotAllowedDoor({ brand, reader, signOutUrl }) {
34
- 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, switch to that account."] }), _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: "Use a different account" })] }));
33
+ export function NotAllowedDoor({ brand, reader, signOutUrl, ask }) {
34
+ 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, switch to that account."] }), _jsx("p", { className: "mt-3 opacity-60", children: "This attempt was recorded." }), ask, _jsx("a", { href: signOutUrl, className: "mt-5 inline-block underline", style: { color: brand.accent }, children: "Use a different account" })] }));
35
35
  }
36
36
  /** The agreement a reader makes before a confidential page opens. The exact string is stored
37
37
  * with their acknowledgement, so the record says what they agreed to, not what it says today. */
@@ -1,5 +1,5 @@
1
1
  import type { ReaderSummary } from './readers-store.js';
2
- export declare const READ_ALERT_KINDS: readonly ["first-open", "refused", "finished", "came-back", "quiet"];
2
+ export declare const READ_ALERT_KINDS: readonly ["first-open", "refused", "asked", "finished", "came-back", "quiet"];
3
3
  export type ReadAlertKind = (typeof READ_ALERT_KINDS)[number];
4
4
  /** A normal adult reading pace, used to turn a page's word count into its reading time. */
5
5
  export declare const WORDS_PER_MINUTE = 230;
@@ -33,6 +33,8 @@ export type ReadAlertInput = {
33
33
  } | null;
34
34
  /** True when this evaluation is for a refusal at the door that just happened. */
35
35
  refusedAttempt?: boolean;
36
+ /** True when this evaluation is for a request for access that just happened. */
37
+ askedAttempt?: boolean;
36
38
  /** The page's reading time in seconds (`readSecondsFor`). Zero or absent: reaching the end is enough. */
37
39
  readSeconds?: number;
38
40
  now: string | Date;
@@ -59,10 +61,13 @@ export declare function readAlertsDue(input: ReadAlertInput): ReadAlert[];
59
61
  *
60
62
  * - `visit`: a reading heartbeat was recorded (`summary` is their record so far).
61
63
  * - `refused`: a signed-in person was turned away at the door (`reader` is the account tried).
64
+ * - `asked`: a signed-in person turned away asked for access (`note` is what they wrote, or null).
62
65
  * - `sweep`: a listed reader, visited by `sweepReaders()` (a daily job) whether or not anything
63
66
  * changed, so a host can notice what did NOT happen (quiet). */
64
67
  export type ReaderEvent = {
65
- kind: 'visit' | 'refused' | 'sweep';
68
+ kind: 'visit' | 'refused' | 'asked' | 'sweep';
69
+ /** `asked` only: the visitor's note to the publisher, trimmed, at most ASK_NOTE_MAX characters. */
70
+ note?: string | null;
66
71
  artifactId: string;
67
72
  title: string;
68
73
  /** The page's address, e.g. https://artifacts.example.com/abc23456. */
@@ -1,4 +1,4 @@
1
- export const READ_ALERT_KINDS = ['first-open', 'refused', 'finished', 'came-back', 'quiet'];
1
+ export const READ_ALERT_KINDS = ['first-open', 'refused', 'asked', 'finished', 'came-back', 'quiet'];
2
2
  /** A normal adult reading pace, used to turn a page's word count into its reading time. */
3
3
  export const WORDS_PER_MINUTE = 230;
4
4
  /** How far down counts as reaching the end, in percent of the page. */
@@ -47,6 +47,11 @@ export function readAlertsDue(input) {
47
47
  const base = { email, sessions: s?.sessions ?? 0, activeSeconds: s?.activeSeconds ?? 0, maxScroll: s?.maxScroll ?? 0 };
48
48
  const fresh = (at) => !!at && now - new Date(at).getTime() <= FRESH_HOURS * HOUR_MS;
49
49
  const out = [];
50
+ if (input.askedAttempt) {
51
+ if (!sent.has('asked'))
52
+ out.push({ kind: 'asked', ...base });
53
+ return out;
54
+ }
50
55
  if (input.refusedAttempt) {
51
56
  if (!sent.has('refused'))
52
57
  out.push({ kind: 'refused', ...base });
@@ -1,6 +1,7 @@
1
1
  import { type Firestore } from 'firebase-admin/firestore';
2
2
  import type { AllowEntry, Reader } from './reader.js';
3
- export declare const FLAG_KINDS: readonly ["save", "print", "copy", "image", "devtools", "refused"];
3
+ /** `refused`: turned away at the door. `asked`: asked the publisher for access, `detail` the note. */
4
+ export declare const FLAG_KINDS: readonly ["save", "print", "copy", "image", "devtools", "refused", "asked"];
4
5
  export type FlagKind = (typeof FLAG_KINDS)[number];
5
6
  export type SessionDoc = {
6
7
  artifactId: string;
@@ -95,4 +96,10 @@ export declare function summarize(sessionDocs: SessionDoc[], flagDocs: FlagDoc[]
95
96
  attempts: number;
96
97
  lastAt: string;
97
98
  }[];
99
+ asked: {
100
+ email: string;
101
+ name: string | null;
102
+ note: string | null;
103
+ at: string;
104
+ }[];
98
105
  };
@@ -12,7 +12,8 @@
12
12
  // `base` is the artifacts collection path (`tenants/<id>/artifacts`), so each tenant's record
13
13
  // sits next to its own pages.
14
14
  import { FieldValue } from 'firebase-admin/firestore';
15
- export const FLAG_KINDS = ['save', 'print', 'copy', 'image', 'devtools', 'refused'];
15
+ /** `refused`: turned away at the door. `asked`: asked the publisher for access, `detail` the note. */
16
+ export const FLAG_KINDS = ['save', 'print', 'copy', 'image', 'devtools', 'refused', 'asked'];
16
17
  /** An email as a map key: lowercased, and with the dots Firestore reads as a path escaped. */
17
18
  const keyOf = (email) => email.trim().toLowerCase().replace(/\./g, ',');
18
19
  export function createReadersStore(db, base) {
@@ -130,7 +131,13 @@ export function summarize(sessionDocs, flagDocs) {
130
131
  by.set(s.email, r);
131
132
  }
132
133
  const refused = new Map();
134
+ // Who asked the publisher for access, the latest request each, newest first.
135
+ const asked = new Map();
133
136
  for (const f of flagDocs.slice().sort((a, b) => a.at.localeCompare(b.at))) {
137
+ if (f.kind === 'asked') {
138
+ asked.set(f.email, { email: f.email, name: f.name, note: f.detail ?? null, at: f.at });
139
+ continue;
140
+ }
134
141
  if (f.kind === 'refused') {
135
142
  const r = refused.get(f.email) ?? { email: f.email, name: f.name, attempts: 0, lastAt: f.at };
136
143
  r.attempts += 1;
@@ -143,5 +150,6 @@ export function summarize(sessionDocs, flagDocs) {
143
150
  return {
144
151
  readers: [...by.values()].sort((a, b) => b.lastSeen.localeCompare(a.lastSeen)),
145
152
  refused: [...refused.values()].sort((a, b) => b.lastAt.localeCompare(a.lastAt)),
153
+ asked: [...asked.values()].sort((a, b) => b.at.localeCompare(a.at)),
146
154
  };
147
155
  }
@@ -333,6 +333,7 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
333
333
  LEAVE: (request: NextRequest) => Promise<NextResponse<unknown>>;
334
334
  TRACK: (request: NextRequest) => Promise<NextResponse<unknown>>;
335
335
  ACK: (request: NextRequest) => Promise<NextResponse<unknown>>;
336
+ ASK: (request: NextRequest) => Promise<NextResponse<unknown>>;
336
337
  ACCESS: (request: NextRequest, { params }: Params) => Promise<NextResponse<{
337
338
  error: string;
338
339
  }> | NextResponse<{
@@ -358,6 +359,12 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
358
359
  attempts: number;
359
360
  lastAt: string;
360
361
  }[];
362
+ asked: {
363
+ email: string;
364
+ name: string | null;
365
+ note: string | null;
366
+ at: string;
367
+ }[];
361
368
  id: string;
362
369
  title: string;
363
370
  access: Access | null;
@@ -22,7 +22,8 @@ import { CommentLayer } from '../reader/comment-layer.js';
22
22
  import { ReaderWatch } from '../reader/reader-watch.js';
23
23
  import { UpdatedTime } from '../reader/updated-time.js';
24
24
  import { FeedbackWindow } from '../reader/feedback-window.js';
25
- import { perReaderGate, signInToAct, withPrivateUrls, NO_LINK, UNAVAILABLE } from './per-reader.js';
25
+ import { perReaderGate, signInToAct, withPrivateUrls, UNAVAILABLE } from './per-reader.js';
26
+ import { PerReaderDoor, AskForAccess, ASK_NOTE_MAX } from './per-reader-door.js';
26
27
  import { ridCookieName, mintRenderToken, usableSecret, isUnfurlAgent, RID_COOKIE_MAX_AGE } from '../artifacts/recipients.js';
27
28
  import { PageMenu } from '../reader/page-menu.js';
28
29
  import { COMMENTS_MODES } from '../artifacts/comments.js';
@@ -354,9 +355,16 @@ export function createArtifactRoutes(config) {
354
355
  const secret = usableSecret(config.recipientSecret?.());
355
356
  const g = await perReaderGate({ id, param, cookie: await readCookie(ridCookieName(id)).catch(() => undefined), secret, recipients: config.recipients });
356
357
  if (g.state === 'shut') {
357
- return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsx("p", { "data-per-reader-shut": true, className: "mx-auto max-w-xl px-6 pt-32 text-center text-lg opacity-80", children: g.why === 'no-link' ? NO_LINK : UNAVAILABLE }), g.why === 'no-link' && !me && signInOrigin
358
- ? _jsxs("p", { "data-per-reader-sign-in": true, className: "mx-auto max-w-xl px-6 pt-4 text-center text-base opacity-70", children: ["Published this page or help run this site? ", _jsx("a", { className: "underline", href: signInUrl(signInOrigin, pageUrl(id)), children: "Sign in" })] })
359
- : null] }));
358
+ // A signed-in visitor at the no-link door is a refusal like a gated page's: it goes on the
359
+ // page's record (and to the publisher's read alerts), and the door says so only when it did.
360
+ let recorded = false;
361
+ const asked = g.why === 'no-link' && me && config.readers ? await hasAsked(id, me.email) : false;
362
+ if (g.why === 'no-link' && me && config.readers && !bot && !asked) {
363
+ recorded = await config.readers.flag({ artifactId: id, reader: me, kind: 'refused' }).then(() => true, () => false);
364
+ if (recorded)
365
+ await emitReaderEvent({ kind: 'refused', reader: { email: me.email, name: me.name }, summary: null, entry: null }, a);
366
+ }
367
+ return (_jsx(BrandGround, { pack: pack, mode: a.theme, children: _jsx(PerReaderDoor, { brand: brand, why: g.why, reader: me, recorded: recorded, pageId: id, canAsk: !!config.readers, asked: asked, signInHref: signInOrigin ? signInUrl(signInOrigin, pageUrl(id)) : null, switchHref: switchUrl(id) }) }));
360
368
  }
361
369
  // A fetcher is not the reader: it sets no cookie, carries no render token and sees no note.
362
370
  const remember = g.fromLink && !bot
@@ -427,7 +435,7 @@ export function createArtifactRoutes(config) {
427
435
  void flagged;
428
436
  }
429
437
  return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsx(Menu, { a: a, reader: reader, shutOut: d.why === 'not-allowed', settings: await settingsFor(a, reader) }), header, d.why === 'not-allowed'
430
- ? _jsx(NotAllowedDoor, { brand: brand, reader: d.reader, signOutUrl: switchUrl(id) })
438
+ ? _jsx(NotAllowedDoor, { brand: brand, reader: d.reader, signOutUrl: switchUrl(id), ask: _jsx(AskForAccess, { brand: brand, pageId: id, canAsk: true, asked: await hasAsked(id, d.reader.email) }) })
431
439
  : _jsx(SignInDoor, { brand: brand, href: signInOrigin ? signInUrl(signInOrigin, here) : undefined, note: config.signInNote })] }));
432
440
  }
433
441
  const r = reader;
@@ -576,6 +584,51 @@ export function createArtifactRoutes(config) {
576
584
  res.headers.append('set-cookie', cookieLine('', 0));
577
585
  return res;
578
586
  }
587
+ /** Whether this visitor already asked for access to this page. A failed read reads as not asked. */
588
+ async function hasAsked(id, email) {
589
+ if (!config.readers)
590
+ return false;
591
+ try {
592
+ return (await config.readers.flags(id)).some((f) => f.kind === 'asked' && same(f.email, email));
593
+ }
594
+ catch {
595
+ return false;
596
+ }
597
+ }
598
+ /** POST /api/reader/ask: a signed-in visitor turned away at a private page's door asks the
599
+ * publisher for access, with an optional note. A plain form post from AskForAccess. Recorded as
600
+ * an `asked` flag and told to the host as an `asked` reader event (its read alerts), once per
601
+ * visitor per page. Anyone the page already opens to, or a page that is not private, records
602
+ * nothing. Always lands back on the page, whose door then says the request was passed on. */
603
+ async function ASK(request) {
604
+ const form = await request.formData().catch(() => null);
605
+ const id = String(form?.get('id') ?? '');
606
+ if (!ARTIFACT_ID.test(id))
607
+ return NextResponse.json({ error: 'no page' }, { status: 400 });
608
+ const back = NextResponse.redirect(`${siteUrl}${pagePath(id)}`, 303);
609
+ back.headers.set('cache-control', 'no-store');
610
+ if (!config.readers)
611
+ return back;
612
+ const reader = verifyGrant(readerSecret(), request.cookies.get(GRANT_COOKIE)?.value);
613
+ if (!reader || isAdmin(reader))
614
+ return back;
615
+ const a = await store.get(id);
616
+ if (!a)
617
+ return back;
618
+ if (a.access) {
619
+ if (decide(a.access, reader, await config.readers.allowList(id), false).open)
620
+ return back;
621
+ }
622
+ else if (a.links !== 'per-reader')
623
+ return back;
624
+ if (await hasAsked(id, reader.email))
625
+ return back;
626
+ const note = String(form?.get('note') ?? '').trim().slice(0, ASK_NOTE_MAX) || null;
627
+ const ok = await config.readers.flag({ artifactId: id, reader, kind: 'asked', ...(note ? { detail: note } : {}), country: request.headers.get('x-vercel-ip-country') ?? undefined }).then(() => true, () => false);
628
+ if (ok)
629
+ await emitReaderEvent({ kind: 'asked', note, reader: { email: reader.email, name: reader.name }, summary: null, entry: null }, a);
630
+ return back;
631
+ }
579
632
  /** POST /api/reader/ack: the reader agrees to keep the page confidential. A plain form post
580
633
  * from AckDoor; records the exact wording and lands them on the page. Only a reader the page
581
634
  * is open to can agree, and only with the box ticked. */
@@ -1127,5 +1180,5 @@ export function createArtifactRoutes(config) {
1127
1180
  + `<p data-per-reader-shut style="max-width:36rem;padding:0 1.5rem;text-align:center;font-size:1.125rem;opacity:.8">${UNAVAILABLE}</p></body></html>`;
1128
1181
  return new Response(html, { status: 410, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'x-robots-tag': 'noindex, nofollow' } });
1129
1182
  }
1130
- return guardRouteTable({ Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, LIST, shutPageResponse, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, SETTINGS, READS, sweepReaders, TRANSCRIBE: voiceRoutes.TRANSCRIBE, ...stateRoutes, ...personalRoutes, ...recipientsRoutes, dynamic: 'force-dynamic', maxDuration: 30 });
1183
+ return guardRouteTable({ Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, LIST, shutPageResponse, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ASK, ACCESS, SETTINGS, READS, sweepReaders, TRANSCRIBE: voiceRoutes.TRANSCRIBE, ...stateRoutes, ...personalRoutes, ...recipientsRoutes, dynamic: 'force-dynamic', maxDuration: 30 });
1131
1184
  }
@@ -0,0 +1,29 @@
1
+ import type { BrandPack } from '../brand/pack.js';
2
+ import type { Reader } from '../artifacts/reader.js';
3
+ /** The longest note a visitor can send with a request for access. */
4
+ export declare const ASK_NOTE_MAX = 500;
5
+ /** What a signed-in visitor turned away can do about it: ask the publisher, once, with a note.
6
+ * `asked` is their request already on the record; `canAsk` false means there is nowhere to keep one. */
7
+ export declare function AskForAccess({ brand, pageId, canAsk, asked }: {
8
+ brand: BrandPack;
9
+ pageId: string;
10
+ canAsk: boolean;
11
+ asked: boolean;
12
+ }): import("react").JSX.Element | null;
13
+ export declare function PerReaderDoor({ brand, why, reader, signInHref, switchHref, recorded, pageId, canAsk, asked }: {
14
+ brand: BrandPack;
15
+ why: 'no-link' | 'revoked' | 'expired' | 'closed';
16
+ /** The signed-in visitor, when there is one (never the owner or an admin: they get through). */
17
+ reader: Reader | null;
18
+ /** Where "Sign in" goes; null when this host cannot sign readers in. */
19
+ signInHref: string | null;
20
+ /** Signs out and sends them to choose another account. */
21
+ switchHref: string;
22
+ /** The refused attempt was written to the page's record, so the publisher can see it. */
23
+ recorded: boolean;
24
+ pageId: string;
25
+ /** The host keeps a reader record, so a request for access has somewhere to go. */
26
+ canAsk?: boolean;
27
+ /** This visitor already asked. */
28
+ asked?: boolean;
29
+ }): import("react").JSX.Element;
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { NO_LINK, UNAVAILABLE } from './per-reader.js';
3
+ /** The longest note a visitor can send with a request for access. */
4
+ export const ASK_NOTE_MAX = 500;
5
+ /** What a signed-in visitor turned away can do about it: ask the publisher, once, with a note.
6
+ * `asked` is their request already on the record; `canAsk` false means there is nowhere to keep one. */
7
+ export function AskForAccess({ brand, pageId, canAsk, asked }) {
8
+ if (asked)
9
+ return _jsx("p", { "data-per-reader-asked": true, className: "mt-4 opacity-80", children: "You asked for access. The person who published it has your request, and can send you a link or add this account." });
10
+ if (!canAsk)
11
+ return null;
12
+ return (_jsxs("form", { method: "post", action: "/api/reader/ask", "data-per-reader-ask": true, className: "mx-auto mt-5 max-w-sm text-left", children: [_jsx("input", { type: "hidden", name: "id", value: pageId }), _jsx("label", { className: "block text-sm opacity-80", htmlFor: "ask-note", children: "Add a note for the person who published it (optional)" }), _jsx("textarea", { id: "ask-note", name: "note", maxLength: ASK_NOTE_MAX, rows: 3, className: "mt-2 w-full rounded-lg border bg-transparent px-3 py-2 text-sm", style: { borderColor: brand.accent } }), _jsx("button", { type: "submit", className: "mt-3 w-full rounded-lg px-5 py-2 text-sm font-medium", style: { background: brand.accent, color: brand.ground }, children: "Ask for access" })] }));
13
+ }
14
+ export function PerReaderDoor({ brand, why, reader, signInHref, switchHref, recorded, pageId, canAsk = false, asked = false }) {
15
+ const wrap = 'mx-auto max-w-md px-6 pt-32 pb-24 text-center';
16
+ if (why !== 'no-link') {
17
+ return (_jsxs("div", { "data-nospeak": true, className: wrap, children: [_jsx("p", { "data-per-reader-shut": true, className: "text-lg opacity-80", children: UNAVAILABLE }), _jsx("p", { className: "mt-4 text-sm opacity-70", children: "The link you used was closed. If you still need this page, ask the person who sent it for a new link." })] }));
18
+ }
19
+ return (_jsxs("div", { "data-nospeak": true, className: wrap, children: [_jsx("p", { "data-per-reader-shut": true, className: "text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: NO_LINK }), _jsx("p", { className: "mt-4 text-base opacity-80", children: "Everyone it was sent to has their own link. Open it from the message it came in, or ask the person who sent it for yours." }), reader ? (_jsxs("div", { "data-per-reader-not-shared": true, className: "mt-8 text-sm", children: [_jsxs("p", { className: "opacity-80", children: ["You are signed in as ", reader.email, ", and this page was not shared with that account."] }), recorded ? _jsx("p", { className: "mt-2 opacity-60", children: "This attempt was recorded, so the person who published it can see you tried." }) : null, _jsx(AskForAccess, { brand: brand, pageId: pageId, canAsk: canAsk, asked: asked }), _jsx("a", { href: switchHref, className: "mt-4 inline-block underline", style: { color: brand.accent }, children: "Use a different account" })] })) : signInHref ? (_jsxs("div", { "data-per-reader-sign-in": true, className: "mt-8 text-sm", children: [_jsx("p", { className: "opacity-80", children: "Think you should have access? Sign in. The person who published it, and anyone who helps run this site, can open it once signed in." }), _jsx("a", { href: signInHref, className: "mt-4 inline-block rounded-lg px-5 py-2 text-sm font-medium", style: { background: brand.accent, color: brand.ground }, children: "Sign in" })] })) : null] }));
20
+ }
@@ -27,7 +27,9 @@ export declare function signInToAct(page: {
27
27
  forwarded?: boolean;
28
28
  }): boolean;
29
29
  export declare const UNAVAILABLE = "This page is no longer available.";
30
- export declare const NO_LINK = "This page opens from the link it was sent with.";
30
+ /** The no-link door's headline. Until 0.62.0 it was the whole door ("This page opens from the
31
+ * link it was sent with."), which told a visitor neither what kind of page this was nor what to do. */
32
+ export declare const NO_LINK = "This page was shared with specific people.";
31
33
  export declare function perReaderGate(o: {
32
34
  id: string;
33
35
  param: string | undefined;
@@ -10,7 +10,9 @@ export function signInToAct(page, link) {
10
10
  return link.group && page.commentName !== 'required' && (!!link.forwarded || (!!page.forward && page.forward !== 'off'));
11
11
  }
12
12
  export const UNAVAILABLE = 'This page is no longer available.';
13
- export const NO_LINK = 'This page opens from the link it was sent with.';
13
+ /** The no-link door's headline. Until 0.62.0 it was the whole door ("This page opens from the
14
+ * link it was sent with."), which told a visitor neither what kind of page this was nor what to do. */
15
+ export const NO_LINK = 'This page was shared with specific people.';
14
16
  export async function perReaderGate(o) {
15
17
  // An unset or empty secret verifies nothing: fail closed before any signature is compared.
16
18
  if (!o.secret || !o.recipients)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.61.0",
3
+ "version": "0.63.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",
@@ -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().ASK(request)
6
+ }
@@ -12,7 +12,7 @@
12
12
  "@fontsource/dm-sans": "^5.3.0",
13
13
  "@fontsource/dm-serif-display": "^5.3.0",
14
14
  "@google-cloud/storage": "^7.22.0",
15
- "@supersuit/artifacts": "0.61.0",
15
+ "@supersuit/artifacts": "0.63.0",
16
16
  "firebase": "^12.19.0",
17
17
  "firebase-admin": "^13.7.0",
18
18
  "next": "^16.0.1",