@supersuit/artifacts 0.61.0 → 0.62.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.62.0 (2026-11-08)
7
+
8
+ **The no-link door on a per-reader page says what it is and what to do.** A visitor opening a
9
+ `links: per-reader` page without their link met one line, "This page opens from the link it was sent
10
+ with.", which read as an error and offered nothing to a signed-in reader who was not sent it.
11
+
12
+ - The door now says the page was shared with specific people, that each has their own link, and to
13
+ open it from the message it came in or ask the sender for theirs (`NO_LINK` is now the headline,
14
+ "This page was shared with specific people.").
15
+ - Signed out (with `signInOrigin`): "Think you should have access? Sign in." as a button.
16
+ - Signed in but not sent it: names the account, offers "Use a different account" (the same switch a
17
+ gated page uses), and records the attempt as a `refused` flag with a `refused` reader event, like a
18
+ gated page's refusal, so the publisher's read alerts can see it. The door says "This attempt was
19
+ recorded" only when the record was written. An unfurl fetcher records nothing.
20
+ - A revoked or expired link adds "ask the person who sent it for a new link."
21
+ - Nothing of the page (title, summary, body) is shown before the gate, as before.
22
+ - DETECTOR: a per-reader page's bare address shows only "This page opens from the link it was sent with."
23
+ - REMEDY: update. Nothing to configure. A host that matched the old `NO_LINK` text should read the constant.
24
+ - TESTS: `src/routes/per-reader.test.tsx` (the door's words, the signed-in account and switch link,
25
+ the refusal recorded and claimed only when a reader store wrote it, the revoked-link line).
26
+
6
27
  ## 0.61.0 (2026-11-08)
7
28
 
8
29
  **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.
@@ -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 } 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,15 @@ 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
+ if (g.why === 'no-link' && me && config.readers && !bot) {
362
+ recorded = await config.readers.flag({ artifactId: id, reader: me, kind: 'refused' }).then(() => true, () => false);
363
+ if (recorded)
364
+ await emitReaderEvent({ kind: 'refused', reader: { email: me.email, name: me.name }, summary: null, entry: null }, a);
365
+ }
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) }) }));
360
367
  }
361
368
  // A fetcher is not the reader: it sets no cookie, carries no render token and sees no note.
362
369
  const remember = g.fromLink && !bot
@@ -0,0 +1,14 @@
1
+ import type { BrandPack } from '../brand/pack.js';
2
+ import type { Reader } from '../artifacts/reader.js';
3
+ export declare function PerReaderDoor({ brand, why, reader, signInHref, switchHref, recorded }: {
4
+ brand: BrandPack;
5
+ why: 'no-link' | 'revoked' | 'expired' | 'closed';
6
+ /** The signed-in visitor, when there is one (never the owner or an admin: they get through). */
7
+ reader: Reader | null;
8
+ /** Where "Sign in" goes; null when this host cannot sign readers in. */
9
+ signInHref: string | null;
10
+ /** Signs out and sends them to choose another account. */
11
+ switchHref: string;
12
+ /** The refused attempt was written to the page's record, so the publisher can see it. */
13
+ recorded: boolean;
14
+ }): import("react").JSX.Element;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { NO_LINK, UNAVAILABLE } from './per-reader.js';
3
+ export function PerReaderDoor({ brand, why, reader, signInHref, switchHref, recorded }) {
4
+ const wrap = 'mx-auto max-w-md px-6 pt-32 pb-24 text-center';
5
+ if (why !== 'no-link') {
6
+ 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
+ }
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] }));
9
+ }
@@ -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.62.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",
@@ -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.62.0",
16
16
  "firebase": "^12.19.0",
17
17
  "firebase-admin": "^13.7.0",
18
18
  "next": "^16.0.1",