@supersuit/artifacts 0.62.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,27 @@
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
+
6
27
  ## 0.62.0 (2026-11-08)
7
28
 
8
29
  **The no-link door on a per-reader page says what it is and what to do.** A visitor opening a
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;
@@ -23,7 +23,7 @@ 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
25
  import { perReaderGate, signInToAct, withPrivateUrls, UNAVAILABLE } from './per-reader.js';
26
- import { PerReaderDoor } from './per-reader-door.js';
26
+ import { PerReaderDoor, AskForAccess, ASK_NOTE_MAX } from './per-reader-door.js';
27
27
  import { ridCookieName, mintRenderToken, usableSecret, isUnfurlAgent, RID_COOKIE_MAX_AGE } from '../artifacts/recipients.js';
28
28
  import { PageMenu } from '../reader/page-menu.js';
29
29
  import { COMMENTS_MODES } from '../artifacts/comments.js';
@@ -358,12 +358,13 @@ export function createArtifactRoutes(config) {
358
358
  // A signed-in visitor at the no-link door is a refusal like a gated page's: it goes on the
359
359
  // page's record (and to the publisher's read alerts), and the door says so only when it did.
360
360
  let recorded = false;
361
- if (g.why === 'no-link' && me && config.readers && !bot) {
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) {
362
363
  recorded = await config.readers.flag({ artifactId: id, reader: me, kind: 'refused' }).then(() => true, () => false);
363
364
  if (recorded)
364
365
  await emitReaderEvent({ kind: 'refused', reader: { email: me.email, name: me.name }, summary: null, entry: null }, a);
365
366
  }
366
- return (_jsx(BrandGround, { pack: pack, mode: a.theme, children: _jsx(PerReaderDoor, { brand: brand, why: g.why, reader: me, recorded: recorded, signInHref: signInOrigin ? signInUrl(signInOrigin, pageUrl(id)) : null, switchHref: switchUrl(id) }) }));
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) }) }));
367
368
  }
368
369
  // A fetcher is not the reader: it sets no cookie, carries no render token and sees no note.
369
370
  const remember = g.fromLink && !bot
@@ -434,7 +435,7 @@ export function createArtifactRoutes(config) {
434
435
  void flagged;
435
436
  }
436
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'
437
- ? _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) }) })
438
439
  : _jsx(SignInDoor, { brand: brand, href: signInOrigin ? signInUrl(signInOrigin, here) : undefined, note: config.signInNote })] }));
439
440
  }
440
441
  const r = reader;
@@ -583,6 +584,51 @@ export function createArtifactRoutes(config) {
583
584
  res.headers.append('set-cookie', cookieLine('', 0));
584
585
  return res;
585
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
+ }
586
632
  /** POST /api/reader/ack: the reader agrees to keep the page confidential. A plain form post
587
633
  * from AckDoor; records the exact wording and lands them on the page. Only a reader the page
588
634
  * is open to can agree, and only with the box ticked. */
@@ -1134,5 +1180,5 @@ export function createArtifactRoutes(config) {
1134
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>`;
1135
1181
  return new Response(html, { status: 410, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'x-robots-tag': 'noindex, nofollow' } });
1136
1182
  }
1137
- 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 });
1138
1184
  }
@@ -1,6 +1,16 @@
1
1
  import type { BrandPack } from '../brand/pack.js';
2
2
  import type { Reader } from '../artifacts/reader.js';
3
- export declare function PerReaderDoor({ brand, why, reader, signInHref, switchHref, recorded }: {
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 }: {
4
14
  brand: BrandPack;
5
15
  why: 'no-link' | 'revoked' | 'expired' | 'closed';
6
16
  /** The signed-in visitor, when there is one (never the owner or an admin: they get through). */
@@ -11,4 +21,9 @@ export declare function PerReaderDoor({ brand, why, reader, signInHref, switchHr
11
21
  switchHref: string;
12
22
  /** The refused attempt was written to the page's record, so the publisher can see it. */
13
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;
14
29
  }): import("react").JSX.Element;
@@ -1,9 +1,20 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { NO_LINK, UNAVAILABLE } from './per-reader.js';
3
- export function PerReaderDoor({ brand, why, reader, signInHref, switchHref, recorded }) {
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 }) {
4
15
  const wrap = 'mx-auto max-w-md px-6 pt-32 pb-24 text-center';
5
16
  if (why !== 'no-link') {
6
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." })] }));
7
18
  }
8
- 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("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] }));
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] }));
9
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.62.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.62.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",