@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 +22 -0
- package/lib/artifacts/confidential.js +3 -3
- package/lib/artifacts/reader.d.ts +6 -2
- package/lib/artifacts/reader.js +5 -3
- package/lib/routes/artifacts.js +12 -6
- package/package.json +1 -1
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: "
|
|
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,
|
|
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: "
|
|
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
|
-
|
|
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;
|
package/lib/artifacts/reader.js
CHANGED
|
@@ -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
|
-
|
|
100
|
-
|
|
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. */
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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.
|
|
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",
|