@supersuit/artifacts 0.58.0 → 0.58.2
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 +39 -0
- package/lib/artifacts/letter-breaks.d.ts +2 -0
- package/lib/artifacts/letter-breaks.js +68 -0
- package/lib/artifacts/render.js +2 -1
- package/lib/routes/artifacts.js +5 -2
- package/lib/routes/backend-auth.d.ts +9 -0
- package/lib/routes/backend-auth.js +42 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,45 @@
|
|
|
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.58.2 (2026-11-02)
|
|
7
|
+
|
|
8
|
+
**A letter's greeting and sign-off keep their own lines.** Markdown joins a soft line break with a
|
|
9
|
+
space, so a letter ending "Your friend in Christ," then the name on a plain newline drew as one
|
|
10
|
+
line. `remarkLetterBreaks` (in `ArtifactMarkdown`'s plugin list) turns a paragraph that opens on a
|
|
11
|
+
short line (40 characters or fewer) ending in a comma into real breaks after each short line in it.
|
|
12
|
+
Hard-wrapped prose still joins.
|
|
13
|
+
|
|
14
|
+
- DETECTOR: a page whose sign-off reads "Love, Gary" on one line.
|
|
15
|
+
- REMEDY: update; nothing to change in the page.
|
|
16
|
+
- Tests: `src/artifacts/letter-breaks.test.tsx`.
|
|
17
|
+
|
|
18
|
+
**A host that cannot sign in to its own database says so, instead of a bare 500.** When a host's
|
|
19
|
+
cloud credential is refused (a workload identity provider trusting another Vercel team's OIDC
|
|
20
|
+
issuer, or a revoked service account key), every Firestore call threw and every route answered an
|
|
21
|
+
empty 500. Publishers saw "500, no message", and the Supersuit comments client guessed "a missing
|
|
22
|
+
index" (2026-10-28, after a hub's Vercel project moved teams). Every route handler in
|
|
23
|
+
`createArtifactRoutes` now answers that one cause with `503 { error: "backend-auth", message }`. The
|
|
24
|
+
cause itself is logged on the server and kept out of the body. Any other error is unchanged.
|
|
25
|
+
|
|
26
|
+
- DETECTOR: the server log shows `invalid_grant`, `Getting metadata from plugin failed`,
|
|
27
|
+
`UNAUTHENTICATED` or `Could not load the default credentials`; clients now get a typed 503.
|
|
28
|
+
- REMEDY: fix the host's credential. For Vercel workload identity, point the provider's issuer at
|
|
29
|
+
`https://oidc.vercel.com/<team>` and its audience at `https://vercel.com/<team>`.
|
|
30
|
+
- Tests: `src/routes/backend-auth.test.tsx`.
|
|
31
|
+
|
|
32
|
+
## 0.58.1 (2026-10-27)
|
|
33
|
+
|
|
34
|
+
**The no-link door on a per-reader page offers sign-in.** A signed-in owner or hub admin already got
|
|
35
|
+
through a `links: per-reader` page without a link, but a signed-out one met "This page opens from the
|
|
36
|
+
link it was sent with." and nothing else, so a co-founder opening a company page's bare address had
|
|
37
|
+
no way in (2026-10-27).
|
|
38
|
+
|
|
39
|
+
- DETECTOR: open a per-reader page's bare address signed out; the door has no sign-in link.
|
|
40
|
+
- REMEDY: none for the host beyond taking this version. The door now carries "Sign in" (only with
|
|
41
|
+
`signInOrigin` set, only to a visitor not signed in); a signed-in reader who is not owner or admin
|
|
42
|
+
still sees the plain door, and nothing of the page is shown before the gate.
|
|
43
|
+
- Tests: `src/routes/per-reader.test.tsx` (sign-in offered when signed out, not offered when signed in).
|
|
44
|
+
|
|
6
45
|
## 0.58.0 (2026-10-27)
|
|
7
46
|
|
|
8
47
|
**A link's provenance can name its reader by an opaque person id.** Superlinks step 6: a funnel
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const SHORT = 40;
|
|
2
|
+
const isParent = (n) => !!n && Array.isArray(n.children);
|
|
3
|
+
/** The paragraph's text as the writer typed it, soft breaks included. */
|
|
4
|
+
function plain(nodes) {
|
|
5
|
+
return nodes.map((n) => {
|
|
6
|
+
const x = n;
|
|
7
|
+
if (x.type === 'text' || x.type === 'inlineCode')
|
|
8
|
+
return x.value ?? '';
|
|
9
|
+
if (x.type === 'break')
|
|
10
|
+
return '\n';
|
|
11
|
+
return x.children ? plain(x.children) : '';
|
|
12
|
+
}).join('');
|
|
13
|
+
}
|
|
14
|
+
function breakLetter(p) {
|
|
15
|
+
const lines = plain(p.children).split('\n');
|
|
16
|
+
if (lines.length < 2)
|
|
17
|
+
return;
|
|
18
|
+
const short = (l) => l.trim().length <= SHORT;
|
|
19
|
+
if (!short(lines[0]) || !/,$/.test(lines[0].trim()))
|
|
20
|
+
return;
|
|
21
|
+
let line = 0;
|
|
22
|
+
const walk = (parent) => {
|
|
23
|
+
const out = [];
|
|
24
|
+
for (const child of parent.children) {
|
|
25
|
+
const c = child;
|
|
26
|
+
if (c.type === 'text') {
|
|
27
|
+
const parts = c.value.split('\n');
|
|
28
|
+
parts.forEach((part, k) => {
|
|
29
|
+
if (k > 0) {
|
|
30
|
+
const keep = short(lines[line]);
|
|
31
|
+
line++;
|
|
32
|
+
if (keep) {
|
|
33
|
+
out.push({ type: 'break' });
|
|
34
|
+
part = part.replace(/^\s+/, '');
|
|
35
|
+
}
|
|
36
|
+
else
|
|
37
|
+
part = ' ' + part.replace(/^\s+/, '');
|
|
38
|
+
}
|
|
39
|
+
if (part)
|
|
40
|
+
out.push({ type: 'text', value: part });
|
|
41
|
+
});
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (c.type === 'break')
|
|
45
|
+
line++;
|
|
46
|
+
else if (isParent(c))
|
|
47
|
+
walk(c);
|
|
48
|
+
out.push(c);
|
|
49
|
+
}
|
|
50
|
+
parent.children = out;
|
|
51
|
+
};
|
|
52
|
+
walk(p);
|
|
53
|
+
}
|
|
54
|
+
export function remarkLetterBreaks() {
|
|
55
|
+
return (tree) => {
|
|
56
|
+
const visit = (n) => {
|
|
57
|
+
if (!isParent(n))
|
|
58
|
+
return;
|
|
59
|
+
for (const c of n.children) {
|
|
60
|
+
if (c.type === 'paragraph')
|
|
61
|
+
breakLetter(c);
|
|
62
|
+
else
|
|
63
|
+
visit(c);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
visit(tree);
|
|
67
|
+
};
|
|
68
|
+
}
|
package/lib/artifacts/render.js
CHANGED
|
@@ -23,6 +23,7 @@ import { VideoAutoplay } from '../reader/video-autoplay.js';
|
|
|
23
23
|
import { CodeCopy } from '../reader/code-copy.js';
|
|
24
24
|
import { DefinitionLayer } from '../reader/definition-layer.js';
|
|
25
25
|
import { remarkDefinitions } from './definitions.js';
|
|
26
|
+
import { remarkLetterBreaks } from './letter-breaks.js';
|
|
26
27
|
import { AUDIO_SRC, remarkVoiceNotes, TRANSCRIPT_LANG } from './voice-note.js';
|
|
27
28
|
import { VoiceNote } from '../widgets/voice-note.js';
|
|
28
29
|
// Every colour is a theme variable (brand/theme.ts), so a page follows its light, dark or system mode.
|
|
@@ -371,7 +372,7 @@ export function ArtifactMarkdown({ markdown, notes, checklist, definitions, Sect
|
|
|
371
372
|
// Voice notes first, so a section groups the note node the pair became.
|
|
372
373
|
const voiceNotes = [];
|
|
373
374
|
const voiced = /^ {0,3}(`{3,}|~{3,})\s*transcript\b/m.test(markdown);
|
|
374
|
-
const plugins = [remarkGfm, ...(voiced ? [remarkVoiceNotes(voiceNotes)] : []), ...(defined ? [remarkDefinitions(defined)] : []), ...(outline.length ? [remarkSections(outline[0].depth)] : [])];
|
|
375
|
+
const plugins = [remarkGfm, remarkLetterBreaks, ...(voiced ? [remarkVoiceNotes(voiceNotes)] : []), ...(defined ? [remarkDefinitions(defined)] : []), ...(outline.length ? [remarkSections(outline[0].depth)] : [])];
|
|
375
376
|
const lists = /^ {0,3}(`{3,}|~{3,})\s*checklist\b/m.test(markdown) ? checklistsOf(markdown) : null;
|
|
376
377
|
const checklists = lists
|
|
377
378
|
// `send` from the host means it keeps answers and the page declared the `checklist` slot; on a
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -40,6 +40,7 @@ import { PageAnalytics } from '../reader/page-analytics.js';
|
|
|
40
40
|
import { analyticsPlan } from '../reader/analytics-plan.js';
|
|
41
41
|
import { createPersonalRoutes } from './personal-routes.js';
|
|
42
42
|
import { createVoiceRoutes } from './voice-routes.js';
|
|
43
|
+
import { guardRouteTable } from './backend-auth.js';
|
|
43
44
|
/** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
|
|
44
45
|
export const ARTIFACT_ID = ARTIFACT_ID_RE;
|
|
45
46
|
/** A version number as it appears in a URL: no zero, no leading zero, so each version has one address. */
|
|
@@ -353,7 +354,9 @@ export function createArtifactRoutes(config) {
|
|
|
353
354
|
const secret = usableSecret(config.recipientSecret?.());
|
|
354
355
|
const g = await perReaderGate({ id, param, cookie: await readCookie(ridCookieName(id)).catch(() => undefined), secret, recipients: config.recipients });
|
|
355
356
|
if (g.state === 'shut') {
|
|
356
|
-
return (
|
|
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] }));
|
|
357
360
|
}
|
|
358
361
|
// A fetcher is not the reader: it sets no cookie, carries no render token and sees no note.
|
|
359
362
|
const remember = g.fromLink && !bot
|
|
@@ -1122,5 +1125,5 @@ export function createArtifactRoutes(config) {
|
|
|
1122
1125
|
+ `<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>`;
|
|
1123
1126
|
return new Response(html, { status: 410, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'x-robots-tag': 'noindex, nofollow' } });
|
|
1124
1127
|
}
|
|
1125
|
-
return { 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 };
|
|
1128
|
+
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 });
|
|
1126
1129
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
/** True when an error is the host failing to authenticate to its own backend. */
|
|
3
|
+
export declare function isBackendAuthError(e: unknown): boolean;
|
|
4
|
+
export declare const BACKEND_AUTH_MESSAGE = "This host could not sign in to its database, so nothing was read or saved. Its owner should check the host's cloud credentials (the workload identity provider's issuer and audience, or the service account key).";
|
|
5
|
+
export declare function backendAuthResponse(): NextResponse;
|
|
6
|
+
/** Wrap a route handler so a backend credential failure answers the typed 503. */
|
|
7
|
+
export declare function guardBackendAuth<A extends unknown[], R>(handler: (...args: A) => R | Promise<R>): (...args: A) => Promise<R | NextResponse>;
|
|
8
|
+
/** Every route handler in a route table (its UPPER_CASE functions) gets the guard. */
|
|
9
|
+
export declare function guardRouteTable<T extends Record<string, unknown>>(table: T): T;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// A host whose cloud credential is refused (a workload identity provider trusting another
|
|
2
|
+
// issuer, a revoked or missing service account key) fails every Firestore call. Unhandled, each
|
|
3
|
+
// route then answers a bare 500 that a publisher reads as "something broke" and a client hint
|
|
4
|
+
// can only guess at (on 2026-10-28 it guessed "a missing index"). This turns that one cause into
|
|
5
|
+
// a typed 503 the publisher can name, and leaves every other error exactly as it was.
|
|
6
|
+
import { NextResponse } from 'next/server';
|
|
7
|
+
const AUTH_MESSAGE = /invalid_grant|Getting metadata from plugin failed|Could not load the default credentials|UNAUTHENTICATED|Request had invalid authentication credentials/i;
|
|
8
|
+
/** True when an error is the host failing to authenticate to its own backend. */
|
|
9
|
+
export function isBackendAuthError(e) {
|
|
10
|
+
if (!(e instanceof Error))
|
|
11
|
+
return false;
|
|
12
|
+
if (e.code === 16)
|
|
13
|
+
return true; // gRPC UNAUTHENTICATED
|
|
14
|
+
return AUTH_MESSAGE.test(e.message);
|
|
15
|
+
}
|
|
16
|
+
export const BACKEND_AUTH_MESSAGE = "This host could not sign in to its database, so nothing was read or saved. Its owner should check the host's cloud credentials (the workload identity provider's issuer and audience, or the service account key).";
|
|
17
|
+
export function backendAuthResponse() {
|
|
18
|
+
return NextResponse.json({ error: 'backend-auth', message: BACKEND_AUTH_MESSAGE }, { status: 503, headers: { 'cache-control': 'no-store' } });
|
|
19
|
+
}
|
|
20
|
+
/** Wrap a route handler so a backend credential failure answers the typed 503. */
|
|
21
|
+
export function guardBackendAuth(handler) {
|
|
22
|
+
return async (...args) => {
|
|
23
|
+
try {
|
|
24
|
+
return await handler(...args);
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
if (!isBackendAuthError(e))
|
|
28
|
+
throw e;
|
|
29
|
+
console.error('[artifacts] backend credential refused:', e);
|
|
30
|
+
return backendAuthResponse();
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Every route handler in a route table (its UPPER_CASE functions) gets the guard. */
|
|
35
|
+
export function guardRouteTable(table) {
|
|
36
|
+
const out = { ...table };
|
|
37
|
+
for (const [k, v] of Object.entries(table)) {
|
|
38
|
+
if (typeof v === 'function' && /^[A-Z][A-Z0-9_]*$/.test(k))
|
|
39
|
+
out[k] = guardBackendAuth(v);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.58.
|
|
3
|
+
"version": "0.58.2",
|
|
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",
|