@supersuit/artifacts 0.14.0 → 0.15.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,28 @@
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.15.0 (2026-09-29)
7
+
8
+ **A reader signed in with the wrong Google account can change it.** Before, "Sign out" only
9
+ cleared this host's grant; the sign-in authority still held the Google session and vouched for
10
+ the same account again, so the reader looped back as the wrong person. Now:
11
+
12
+ - `GET /api/reader/leave?to=/<id>&switch=1` clears the grant and redirects to
13
+ `<signInOrigin>/artifact/sign-in?to=<page>&switch=1`. Without `switch` it still lands on the page.
14
+ - `signInUrl(origin, page, { switchAccount: true })` builds that URL.
15
+ - The refusal door, the agreement and the confidential banner say "Use a different account" and
16
+ link to the switch.
17
+ - An open page now shows a signed-in reader "Signed in as <email> · Use a different account ·
18
+ Sign out" under the version line (not spoken). Before, it showed nothing at all.
19
+
20
+ - DETECTOR: a reader on a page of yours reports they cannot switch Google accounts.
21
+ - REMEDY: bump, AND make the host's `/artifact/sign-in` honor `switch=1` by signing its Firebase
22
+ user out before vouching (and ask Google with `prompt: 'select_account'`). A host whose sign-in
23
+ ignores `switch` still loops.
24
+ - Tests: the switch URL, LEAVE with and without switch (grant cleared, redirect, off-host `to`
25
+ refused), the three gated surfaces linking the switch, the open-page reader line present when
26
+ signed in and absent when not.
27
+
6
28
  ## 0.14.0 (2026-09-28)
7
29
 
8
30
  **A page can say who it is for.** `to: Isaiah` in front matter reads "For Isaiah" in three
@@ -9,7 +9,7 @@ export function bannerLines({ name, email, reason, owner }) {
9
9
  }
10
10
  export function ConfidentialBanner(props) {
11
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" }), "."] })] }));
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: "Use a different account" }), "."] })] }));
13
13
  }
14
14
  /** A tiled, rotated, near-invisible line of the reader's address over the whole page. */
15
15
  export function Watermark({ email }) {
@@ -30,7 +30,7 @@ export function SignInDoor({ brand, href, note = DEFAULT_SIGN_IN_NOTE }) {
30
30
  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" })] }));
31
31
  }
32
32
  export function NotAllowedDoor({ brand, reader, signOutUrl }) {
33
- 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" })] }));
33
+ 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" })] }));
34
34
  }
35
35
  /** The agreement a reader makes before a confidential page opens. The exact string is stored
36
36
  * with their acknowledgement, so the record says what they agreed to, not what it says today. */
@@ -38,5 +38,5 @@ export function ackText(owner) {
38
38
  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.`;
39
39
  }
40
40
  export function AckDoor({ brand, name, email, owner, pageId, signOutUrl }) {
41
- 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" }), "."] })] })] }));
41
+ 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: "Use a different account" }), "."] })] })] }));
42
42
  }
@@ -47,8 +47,12 @@ export declare const LISTED_REASON = "you were given access to it by name";
47
47
  export declare function decide(access: Access, reader: Reader | null, allow: AllowEntry[]): Decision;
48
48
  /** The name the banner addresses: the allowlist's name, then the account's, first word only. */
49
49
  export declare function firstName(reader: Reader, allow: AllowEntry[]): string | null;
50
- /** Where a reader goes to sign in, carrying the page they asked for. */
51
- export declare function signInUrl(signInOrigin: string, pageUrl: string): string;
50
+ /** Where a reader goes to sign in, carrying the page they asked for. `switchAccount` tells the
51
+ * authority to forget the Google account it already holds and ask which one, because otherwise
52
+ * it vouches for the same account again and the reader can never change it. */
53
+ export declare function signInUrl(signInOrigin: string, pageUrl: string, opts?: {
54
+ switchAccount?: boolean;
55
+ }): string;
52
56
  /** A path on this host that `enter` may send a reader to after it sets the grant: one page id,
53
57
  * nothing else, so the redirect cannot be pointed off the host. */
54
58
  export declare function safeReturnPath(to: string | null | undefined, prefix?: string): string | null;
@@ -95,9 +95,11 @@ export function firstName(reader, allow) {
95
95
  const full = listed?.name?.trim() || reader.name;
96
96
  return full ? full.split(/\s+/)[0] : null;
97
97
  }
98
- /** Where a reader goes to sign in, carrying the page they asked for. */
99
- export function signInUrl(signInOrigin, pageUrl) {
100
- return `${signInOrigin}/artifact/sign-in?to=${encodeURIComponent(pageUrl)}`;
98
+ /** Where a reader goes to sign in, carrying the page they asked for. `switchAccount` tells the
99
+ * authority to forget the Google account it already holds and ask which one, because otherwise
100
+ * it vouches for the same account again and the reader can never change it. */
101
+ export function signInUrl(signInOrigin, pageUrl, opts = {}) {
102
+ return `${signInOrigin}/artifact/sign-in?to=${encodeURIComponent(pageUrl)}${opts.switchAccount ? '&switch=1' : ''}`;
101
103
  }
102
104
  /** A path on this host that `enter` may send a reader to after it sets the grant: one page id,
103
105
  * nothing else, so the redirect cannot be pointed off the host. */
@@ -53,6 +53,9 @@ export function createArtifactRoutes(config) {
53
53
  const ownerEmail = config.ownerEmail?.trim().toLowerCase();
54
54
  const readerSecret = () => config.readerSecret?.();
55
55
  const signOutUrl = (id) => `/api/reader/leave?to=${encodeURIComponent(pagePath(id))}`;
56
+ // Signing out alone cannot change the account: the authority still holds the Google session
57
+ // and vouches for it again on the next sign-in. This leaves AND sends them to choose one.
58
+ const switchUrl = (id) => (signInOrigin ? `${signOutUrl(id)}&switch=1` : signOutUrl(id));
56
59
  const shareCardUrl = (id, updatedAt) => `${pageUrl(id)}/share.png?v=${encodeURIComponent(updatedAt)}`;
57
60
  // Readers' recordings are played through short-lived signed URLs, issued only beside the comment
58
61
  // or note they belong to; a host whose assets cannot sign them shows no play control.
@@ -176,18 +179,18 @@ export function createArtifactRoutes(config) {
176
179
  if (d.why === 'not-allowed' && config.readers)
177
180
  void config.readers.flag({ artifactId: id, reader: d.reader, kind: 'refused' }).catch(() => { });
178
181
  return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [header, d.why === 'not-allowed'
179
- ? _jsx(NotAllowedDoor, { brand: brand, reader: d.reader, signOutUrl: signOutUrl(id) })
182
+ ? _jsx(NotAllowedDoor, { brand: brand, reader: d.reader, signOutUrl: switchUrl(id) })
180
183
  : _jsx(SignInDoor, { brand: brand, href: signInOrigin ? signInUrl(signInOrigin, here) : undefined, note: config.signInNote })] }));
181
184
  }
182
185
  const r = reader;
183
186
  // The agreement comes before the body, every reader, once per page. No agreement, no body.
184
187
  if (config.readers && !(await config.readers.acknowledged(id, r.email))) {
185
- return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [header, _jsx(AckDoor, { brand: brand, name: firstName(r, allow), email: r.email, owner: owner, pageId: id, signOutUrl: signOutUrl(id) })] }));
188
+ return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [header, _jsx(AckDoor, { brand: brand, name: firstName(r, allow), email: r.email, owner: owner, pageId: id, signOutUrl: switchUrl(id) })] }));
186
189
  }
187
190
  const page = await render(false, r);
188
191
  if (n === null)
189
192
  void store.bumpViews(id);
190
- return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsx("style", { dangerouslySetInnerHTML: { __html: NO_PRINT_CSS } }), _jsx(Watermark, { email: r.email }), _jsx("div", { className: "px-6 pt-20 sm:pt-24", children: _jsx(ConfidentialBanner, { name: firstName(r, allow), email: r.email, reason: d.reason, owner: owner, brand: brand, signOutUrl: signOutUrl(id) }) }), page, _jsx(ReaderWatch, { artifactId: id, endpoint: "/api/reader/track", accent: brand.accent })] }));
193
+ return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsx("style", { dangerouslySetInnerHTML: { __html: NO_PRINT_CSS } }), _jsx(Watermark, { email: r.email }), _jsx("div", { className: "px-6 pt-20 sm:pt-24", children: _jsx(ConfidentialBanner, { name: firstName(r, allow), email: r.email, reason: d.reason, owner: owner, brand: brand, signOutUrl: switchUrl(id) }) }), page, _jsx(ReaderWatch, { artifactId: id, endpoint: "/api/reader/track", accent: brand.accent })] }));
191
194
  }
192
195
  /** Every version of a page, newest first, or null when the store keeps none. A failed read hides
193
196
  * the History control rather than failing the page. */
@@ -230,7 +233,7 @@ export function createArtifactRoutes(config) {
230
233
  const toc = showToc(a.markdown, a.toc) ? tocOf(a.markdown) : [];
231
234
  // Shared comments need the page to take them AND a host that keeps answers; personal notes need neither.
232
235
  const mode = config.state && a.state && a.comments && a.comments !== 'off' && a.state.slots[COMMENTS_SLOT] ? a.comments : 'off';
233
- return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsxs("div", { id: "artifact-narration-root", children: [_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx(Kicker, { to: a.to }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary }), _jsx(VersionLine, { a: a, n: a.version ?? (a.versions?.length ?? 0) + 1, at: a.updatedAt, history: await historyOf(a.id) })] }), a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null, _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc.length ? _jsx(TocInline, { items: toc }) : null, _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined })] })] }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: !!reader && !!ownerEmail && reader.email === ownerEmail, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : (
236
+ return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsxs("div", { id: "artifact-narration-root", children: [_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx(Kicker, { to: a.to }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary }), _jsx(VersionLine, { a: a, n: a.version ?? (a.versions?.length ?? 0) + 1, at: a.updatedAt, history: await historyOf(a.id) }), top && reader ? (_jsxs("p", { "data-nospeak": true, "data-reader-line": true, className: "mt-2 text-xs opacity-60", children: [`Signed in as ${reader.email} · `, _jsx("a", { href: switchUrl(a.id), className: "underline", children: "Use a different account" }), ' · ', _jsx("a", { href: signOutUrl(a.id), className: "underline", children: "Sign out" })] })) : null] }), a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null, _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc.length ? _jsx(TocInline, { items: toc }) : null, _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined })] })] }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: !!reader && !!ownerEmail && reader.email === ownerEmail, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : (
234
237
  // No recorded narration, or its timings would not load: the browser reads the page.
235
238
  _jsx(BrowserReader, { rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground }))] }));
236
239
  }
@@ -250,10 +253,13 @@ export function createArtifactRoutes(config) {
250
253
  res.headers.append('set-cookie', cookieLine(mintGrant(secret, reader), GRANT_TTL_SECONDS));
251
254
  return res;
252
255
  }
253
- /** GET /api/reader/leave?to=/<id>: sign out of this host's gated pages. */
256
+ /** GET /api/reader/leave?to=/<id>[&switch=1]: sign out of this host's pages. With switch=1 it
257
+ * goes on to the authority's account chooser, which is the only way the account changes. */
254
258
  async function LEAVE(request) {
255
259
  const to = safeReturnPath(request.nextUrl.searchParams.get('to'), prefix) ?? '/';
256
- const res = NextResponse.redirect(`${siteUrl}${to}`, 303);
260
+ const switching = request.nextUrl.searchParams.get('switch') === '1' && !!signInOrigin && to !== '/';
261
+ const res = NextResponse.redirect(switching ? signInUrl(signInOrigin, `${siteUrl}${to}`, { switchAccount: true }) : `${siteUrl}${to}`, 303);
262
+ res.headers.set('cache-control', 'no-store');
257
263
  res.headers.append('set-cookie', cookieLine('', 0));
258
264
  return res;
259
265
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.14.0",
3
+ "version": "0.15.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",