@supersuit/artifacts 0.1.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 +37 -0
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/fonts/Newsreader-600.ttf +0 -0
- package/lib/artifacts/assets.d.ts +27 -0
- package/lib/artifacts/assets.js +63 -0
- package/lib/artifacts/auth.d.ts +1 -0
- package/lib/artifacts/auth.js +8 -0
- package/lib/artifacts/confidential.d.ts +47 -0
- package/lib/artifacts/confidential.js +39 -0
- package/lib/artifacts/door.d.ts +5 -0
- package/lib/artifacts/door.js +4 -0
- package/lib/artifacts/front-matter.d.ts +34 -0
- package/lib/artifacts/front-matter.js +37 -0
- package/lib/artifacts/index.d.ts +9 -0
- package/lib/artifacts/index.js +9 -0
- package/lib/artifacts/narration.d.ts +8 -0
- package/lib/artifacts/narration.js +85 -0
- package/lib/artifacts/reader.d.ts +54 -0
- package/lib/artifacts/reader.js +109 -0
- package/lib/artifacts/readers-store.d.ts +90 -0
- package/lib/artifacts/readers-store.js +141 -0
- package/lib/artifacts/render.d.ts +4 -0
- package/lib/artifacts/render.js +90 -0
- package/lib/artifacts/store.d.ts +52 -0
- package/lib/artifacts/store.js +114 -0
- package/lib/artifacts/unlock.d.ts +9 -0
- package/lib/artifacts/unlock.js +24 -0
- package/lib/brand/default-share.d.ts +5 -0
- package/lib/brand/default-share.js +37 -0
- package/lib/brand/index.d.ts +3 -0
- package/lib/brand/index.js +3 -0
- package/lib/brand/pack.d.ts +48 -0
- package/lib/brand/pack.js +17 -0
- package/lib/brand/share-card.d.ts +13 -0
- package/lib/brand/share-card.js +50 -0
- package/lib/brand/wrapper.d.ts +12 -0
- package/lib/brand/wrapper.js +13 -0
- package/lib/gate.d.ts +1 -0
- package/lib/gate.js +5 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.js +4 -0
- package/lib/reader/artifact-reader.d.ts +14 -0
- package/lib/reader/artifact-reader.js +165 -0
- package/lib/reader/reader-watch.d.ts +7 -0
- package/lib/reader/reader-watch.js +125 -0
- package/lib/routes/artifacts.d.ts +115 -0
- package/lib/routes/artifacts.js +345 -0
- package/package.json +74 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The text a narrator reads for an artifact, derived from the markdown TREE with the
|
|
2
|
+
// same skip rules the renderer marks in the markup (data-nospeak, link cards, code,
|
|
3
|
+
// footnotes), so the words on screen are exactly the words spoken. The client-side
|
|
4
|
+
// highlighter skips the same nodes, which is what keeps the two in step.
|
|
5
|
+
//
|
|
6
|
+
// Built from the tree rather than the rendered HTML because Next refuses
|
|
7
|
+
// react-dom/server inside app code. Any block the renderer adds must be mirrored here.
|
|
8
|
+
import { unified } from 'unified';
|
|
9
|
+
import remarkParse from 'remark-parse';
|
|
10
|
+
import remarkGfm from 'remark-gfm';
|
|
11
|
+
const CALLOUT = /^\[!(note|warning)\]\s*/i;
|
|
12
|
+
function inline(nodes) {
|
|
13
|
+
let out = '';
|
|
14
|
+
for (const n of nodes) {
|
|
15
|
+
switch (n.type) {
|
|
16
|
+
case 'text':
|
|
17
|
+
case 'inlineCode':
|
|
18
|
+
out += n.value;
|
|
19
|
+
break;
|
|
20
|
+
case 'emphasis':
|
|
21
|
+
case 'strong':
|
|
22
|
+
case 'delete':
|
|
23
|
+
case 'link':
|
|
24
|
+
case 'linkReference':
|
|
25
|
+
out += inline(n.children);
|
|
26
|
+
break;
|
|
27
|
+
case 'break':
|
|
28
|
+
out += ' ';
|
|
29
|
+
break;
|
|
30
|
+
// footnoteReference, image, imageReference, html: not spoken
|
|
31
|
+
default:
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
function blocks(nodes, out) {
|
|
38
|
+
for (const n of nodes) {
|
|
39
|
+
switch (n.type) {
|
|
40
|
+
case 'heading':
|
|
41
|
+
case 'paragraph':
|
|
42
|
+
out.push(inline(n.children));
|
|
43
|
+
break;
|
|
44
|
+
case 'blockquote': {
|
|
45
|
+
const inner = [];
|
|
46
|
+
blocks(n.children, inner);
|
|
47
|
+
if (inner.length)
|
|
48
|
+
inner[0] = inner[0].replace(CALLOUT, '');
|
|
49
|
+
out.push(...inner);
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
case 'list':
|
|
53
|
+
for (const item of n.children)
|
|
54
|
+
blocks(item.children, out);
|
|
55
|
+
break;
|
|
56
|
+
case 'table':
|
|
57
|
+
for (const row of n.children) {
|
|
58
|
+
out.push(row.children.map((c) => inline(c.children)).join(', '));
|
|
59
|
+
}
|
|
60
|
+
break;
|
|
61
|
+
// code (including ```links), image, footnoteDefinition, thematicBreak, html: not spoken
|
|
62
|
+
default:
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function clean(lines) {
|
|
68
|
+
return lines
|
|
69
|
+
.map((l) => l.replace(/\s+/g, ' ').trim())
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.join('\n');
|
|
72
|
+
}
|
|
73
|
+
export function narrationTextFromMarkdown(markdown) {
|
|
74
|
+
const tree = unified().use(remarkParse).use(remarkGfm).parse(markdown);
|
|
75
|
+
const out = [];
|
|
76
|
+
blocks(tree.children, out);
|
|
77
|
+
return clean(out);
|
|
78
|
+
}
|
|
79
|
+
export function narrationText(input) {
|
|
80
|
+
return [input.title.trim(), input.summary.trim(), narrationTextFromMarkdown(input.markdown)].filter(Boolean).join('\n');
|
|
81
|
+
}
|
|
82
|
+
/** Tokens the highlighter and the timings agree on: whitespace-split, lowercased, letters and digits only. */
|
|
83
|
+
export function normalizeWord(w) {
|
|
84
|
+
return w.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
|
|
85
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type Access = 'invite' | 'freedom';
|
|
2
|
+
export declare const ACCESS_LEVELS: readonly Access[];
|
|
3
|
+
/** The person a pass or a grant names. `member` is an active Freedom account at sign-in time. */
|
|
4
|
+
export type Reader = {
|
|
5
|
+
uid: string;
|
|
6
|
+
email: string;
|
|
7
|
+
name: string | null;
|
|
8
|
+
member: boolean;
|
|
9
|
+
};
|
|
10
|
+
export declare const PASS_TTL_SECONDS = 300;
|
|
11
|
+
/** A week, so a revoked Freedom account loses `freedom` pages within seven days. Allowlist
|
|
12
|
+
* changes need no wait: the list is read on every request. */
|
|
13
|
+
export declare const GRANT_TTL_SECONDS: number;
|
|
14
|
+
export declare const GRANT_COOKIE = "artifact_reader";
|
|
15
|
+
export declare function mintPass(secret: string, reader: Reader, exp: number): string;
|
|
16
|
+
export declare function verifyPass(secret: string | undefined, pass: string | null | undefined, now?: number): Reader | null;
|
|
17
|
+
export declare function mintGrant(secret: string, reader: Reader, now?: number): string;
|
|
18
|
+
export declare function verifyGrant(secret: string | undefined, grant: string | null | undefined, now?: number): Reader | null;
|
|
19
|
+
/** One person let in by name. Stored on the host, never in the published file. */
|
|
20
|
+
export type AllowEntry = {
|
|
21
|
+
email: string;
|
|
22
|
+
name?: string;
|
|
23
|
+
reason?: string;
|
|
24
|
+
addedAt?: string;
|
|
25
|
+
};
|
|
26
|
+
export type Decision = {
|
|
27
|
+
open: true;
|
|
28
|
+
why: 'listed';
|
|
29
|
+
reason: string;
|
|
30
|
+
} | {
|
|
31
|
+
open: true;
|
|
32
|
+
why: 'member';
|
|
33
|
+
reason: string;
|
|
34
|
+
} | {
|
|
35
|
+
open: false;
|
|
36
|
+
why: 'signed-out';
|
|
37
|
+
} | {
|
|
38
|
+
open: false;
|
|
39
|
+
why: 'not-allowed';
|
|
40
|
+
reader: Reader;
|
|
41
|
+
};
|
|
42
|
+
export declare const MEMBER_REASON = "you are a Freedom user";
|
|
43
|
+
export declare const LISTED_REASON = "you were given access to it by name";
|
|
44
|
+
/** Whether this reader may open a page with this access level. The allowlist opens both
|
|
45
|
+
* levels; `freedom` also opens to any active Freedom account. A listed person's own reason
|
|
46
|
+
* wins over the membership one, because it is the truer account of why they are reading. */
|
|
47
|
+
export declare function decide(access: Access, reader: Reader | null, allow: AllowEntry[]): Decision;
|
|
48
|
+
/** The name the banner addresses: the allowlist's name, then the account's, first word only. */
|
|
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;
|
|
52
|
+
/** A path on this host that `enter` may send a reader to after it sets the grant: one page id,
|
|
53
|
+
* nothing else, so the redirect cannot be pointed off the host. */
|
|
54
|
+
export declare function safeReturnPath(to: string | null | undefined, prefix?: string): string | null;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Who is reading a gated page, and whether they may.
|
|
2
|
+
//
|
|
3
|
+
// A page with `access:` in its front matter never renders its body to an anonymous request.
|
|
4
|
+
// The reader signs in at the host's sign-in authority (`signInOrigin`, the service that knows
|
|
5
|
+
// who holds a Freedom account), which bounces them back with a five-minute PASS naming them. The host swaps the pass for its
|
|
6
|
+
// own GRANT, an HttpOnly cookie on this host, and from then on every gated page on the host
|
|
7
|
+
// knows who is reading without asking again.
|
|
8
|
+
//
|
|
9
|
+
// THE PASS SHAPE IS A CONTRACT WITH THE SIGN-IN AUTHORITY, byte for byte (README, "The reader
|
|
10
|
+
// pass"): `a1.<payload>.<sig>`, payload the base64url of a small JSON, sig the first 32 hex of
|
|
11
|
+
// HMAC-SHA256(secret, `artifact-pass:a1.<payload>`). reader.test.ts pins the vector, so a
|
|
12
|
+
// change here is named by a failing test before it can reach a deploy and break every sign-in.
|
|
13
|
+
//
|
|
14
|
+
// WHY A PERSON AND NOT A LINK. Nothing in the URL opens the page, so a forwarded link opens a
|
|
15
|
+
// sign-in door and nothing else. When the person it was forwarded to signs in and is not on
|
|
16
|
+
// the list, their refusal is recorded under their own address, which is how a forward shows up.
|
|
17
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
18
|
+
export const ACCESS_LEVELS = ['invite', 'freedom'];
|
|
19
|
+
export const PASS_TTL_SECONDS = 300;
|
|
20
|
+
/** A week, so a revoked Freedom account loses `freedom` pages within seven days. Allowlist
|
|
21
|
+
* changes need no wait: the list is read on every request. */
|
|
22
|
+
export const GRANT_TTL_SECONDS = 7 * 24 * 60 * 60;
|
|
23
|
+
export const GRANT_COOKIE = 'artifact_reader';
|
|
24
|
+
const b64url = (s) => Buffer.from(s, 'utf8').toString('base64url');
|
|
25
|
+
const unb64url = (s) => Buffer.from(s, 'base64url').toString('utf8');
|
|
26
|
+
const sig = (secret, domain, body) => createHmac('sha256', secret).update(`${domain}:${body}`).digest('hex').slice(0, 32);
|
|
27
|
+
function safeEqual(a, b) {
|
|
28
|
+
if (a.length !== b.length)
|
|
29
|
+
return false;
|
|
30
|
+
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
31
|
+
}
|
|
32
|
+
function encode(secret, domain, version, reader, exp) {
|
|
33
|
+
// Short keys: this rides in a URL and a cookie.
|
|
34
|
+
const payload = b64url(JSON.stringify({ u: reader.uid, e: reader.email, n: reader.name, m: reader.member, x: exp }));
|
|
35
|
+
const body = `${version}.${payload}`;
|
|
36
|
+
return `${body}.${sig(secret, domain, body)}`;
|
|
37
|
+
}
|
|
38
|
+
function decode(secret, domain, version, token, now) {
|
|
39
|
+
if (!secret || typeof token !== 'string')
|
|
40
|
+
return null;
|
|
41
|
+
const parts = token.split('.');
|
|
42
|
+
if (parts.length !== 3 || parts[0] !== version)
|
|
43
|
+
return null;
|
|
44
|
+
const body = `${parts[0]}.${parts[1]}`;
|
|
45
|
+
if (!/^[0-9a-f]{32}$/.test(parts[2]) || !safeEqual(parts[2], sig(secret, domain, body)))
|
|
46
|
+
return null;
|
|
47
|
+
let d;
|
|
48
|
+
try {
|
|
49
|
+
d = JSON.parse(unb64url(parts[1]));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
if (typeof d.x !== 'number' || d.x * 1000 < now)
|
|
55
|
+
return null;
|
|
56
|
+
if (typeof d.u !== 'string' || !d.u || typeof d.e !== 'string' || !d.e.includes('@'))
|
|
57
|
+
return null;
|
|
58
|
+
return {
|
|
59
|
+
uid: d.u,
|
|
60
|
+
email: d.e.trim().toLowerCase(),
|
|
61
|
+
name: typeof d.n === 'string' && d.n.trim() ? d.n.trim() : null,
|
|
62
|
+
member: d.m === true,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export function mintPass(secret, reader, exp) {
|
|
66
|
+
return encode(secret, 'artifact-pass', 'a1', reader, exp);
|
|
67
|
+
}
|
|
68
|
+
export function verifyPass(secret, pass, now = Date.now()) {
|
|
69
|
+
return decode(secret, 'artifact-pass', 'a1', pass, now);
|
|
70
|
+
}
|
|
71
|
+
export function mintGrant(secret, reader, now = Date.now()) {
|
|
72
|
+
return encode(secret, 'artifact-grant', 'g1', reader, Math.floor(now / 1000) + GRANT_TTL_SECONDS);
|
|
73
|
+
}
|
|
74
|
+
export function verifyGrant(secret, grant, now = Date.now()) {
|
|
75
|
+
return decode(secret, 'artifact-grant', 'g1', grant, now);
|
|
76
|
+
}
|
|
77
|
+
export const MEMBER_REASON = 'you are a Freedom user';
|
|
78
|
+
export const LISTED_REASON = 'you were given access to it by name';
|
|
79
|
+
/** Whether this reader may open a page with this access level. The allowlist opens both
|
|
80
|
+
* levels; `freedom` also opens to any active Freedom account. A listed person's own reason
|
|
81
|
+
* wins over the membership one, because it is the truer account of why they are reading. */
|
|
82
|
+
export function decide(access, reader, allow) {
|
|
83
|
+
if (!reader)
|
|
84
|
+
return { open: false, why: 'signed-out' };
|
|
85
|
+
const listed = allow.find((a) => a.email.trim().toLowerCase() === reader.email);
|
|
86
|
+
if (listed)
|
|
87
|
+
return { open: true, why: 'listed', reason: listed.reason?.trim() || LISTED_REASON };
|
|
88
|
+
if (access === 'freedom' && reader.member)
|
|
89
|
+
return { open: true, why: 'member', reason: MEMBER_REASON };
|
|
90
|
+
return { open: false, why: 'not-allowed', reader };
|
|
91
|
+
}
|
|
92
|
+
/** The name the banner addresses: the allowlist's name, then the account's, first word only. */
|
|
93
|
+
export function firstName(reader, allow) {
|
|
94
|
+
const listed = allow.find((a) => a.email.trim().toLowerCase() === reader.email);
|
|
95
|
+
const full = listed?.name?.trim() || reader.name;
|
|
96
|
+
return full ? full.split(/\s+/)[0] : null;
|
|
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)}`;
|
|
101
|
+
}
|
|
102
|
+
/** A path on this host that `enter` may send a reader to after it sets the grant: one page id,
|
|
103
|
+
* nothing else, so the redirect cannot be pointed off the host. */
|
|
104
|
+
export function safeReturnPath(to, prefix = '/') {
|
|
105
|
+
if (typeof to !== 'string')
|
|
106
|
+
return null;
|
|
107
|
+
const m = new RegExp(`^${prefix.replace(/\//g, '\\/')}([abcdefghjkmnpqrstuvwxyz23456789]{8})$`).exec(to);
|
|
108
|
+
return m ? `${prefix}${m[1]}` : null;
|
|
109
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { type Firestore } from 'firebase-admin/firestore';
|
|
2
|
+
import type { AllowEntry, Reader } from './reader.js';
|
|
3
|
+
export declare const FLAG_KINDS: readonly ["save", "print", "copy", "image", "devtools", "refused"];
|
|
4
|
+
export type FlagKind = (typeof FLAG_KINDS)[number];
|
|
5
|
+
export type SessionDoc = {
|
|
6
|
+
artifactId: string;
|
|
7
|
+
session: string;
|
|
8
|
+
email: string;
|
|
9
|
+
name: string | null;
|
|
10
|
+
member: boolean;
|
|
11
|
+
startedAt: string;
|
|
12
|
+
lastAt: string;
|
|
13
|
+
activeSeconds: number;
|
|
14
|
+
maxScroll: number;
|
|
15
|
+
device?: string;
|
|
16
|
+
country?: string;
|
|
17
|
+
};
|
|
18
|
+
export type FlagDoc = {
|
|
19
|
+
artifactId: string;
|
|
20
|
+
email: string;
|
|
21
|
+
name: string | null;
|
|
22
|
+
kind: FlagKind;
|
|
23
|
+
detail?: string;
|
|
24
|
+
at: string;
|
|
25
|
+
country?: string;
|
|
26
|
+
};
|
|
27
|
+
export type AckDoc = {
|
|
28
|
+
artifactId: string;
|
|
29
|
+
email: string;
|
|
30
|
+
name: string | null;
|
|
31
|
+
text: string;
|
|
32
|
+
at: string;
|
|
33
|
+
country?: string;
|
|
34
|
+
};
|
|
35
|
+
export interface ReadersStore {
|
|
36
|
+
acknowledged(artifactId: string, email: string): Promise<boolean>;
|
|
37
|
+
acknowledge(input: {
|
|
38
|
+
artifactId: string;
|
|
39
|
+
reader: Reader;
|
|
40
|
+
text: string;
|
|
41
|
+
country?: string;
|
|
42
|
+
}): Promise<void>;
|
|
43
|
+
acks(artifactId: string): Promise<AckDoc[]>;
|
|
44
|
+
allowList(artifactId: string): Promise<AllowEntry[]>;
|
|
45
|
+
allow(artifactId: string, add: AllowEntry[], remove: string[]): Promise<AllowEntry[]>;
|
|
46
|
+
touchSession(input: {
|
|
47
|
+
artifactId: string;
|
|
48
|
+
session: string;
|
|
49
|
+
reader: Reader;
|
|
50
|
+
addSeconds: number;
|
|
51
|
+
scroll: number;
|
|
52
|
+
device?: string;
|
|
53
|
+
country?: string;
|
|
54
|
+
}): Promise<void>;
|
|
55
|
+
flag(input: {
|
|
56
|
+
artifactId: string;
|
|
57
|
+
reader: Reader;
|
|
58
|
+
kind: FlagKind;
|
|
59
|
+
detail?: string;
|
|
60
|
+
country?: string;
|
|
61
|
+
}): Promise<void>;
|
|
62
|
+
sessions(artifactId: string): Promise<SessionDoc[]>;
|
|
63
|
+
flags(artifactId: string): Promise<FlagDoc[]>;
|
|
64
|
+
}
|
|
65
|
+
export declare function createReadersStore(db: Firestore, base: string): ReadersStore;
|
|
66
|
+
export type ReaderSummary = {
|
|
67
|
+
email: string;
|
|
68
|
+
name: string | null;
|
|
69
|
+
member: boolean;
|
|
70
|
+
sessions: number;
|
|
71
|
+
activeSeconds: number;
|
|
72
|
+
maxScroll: number;
|
|
73
|
+
firstSeen: string;
|
|
74
|
+
lastSeen: string;
|
|
75
|
+
flags: {
|
|
76
|
+
kind: FlagKind;
|
|
77
|
+
at: string;
|
|
78
|
+
detail?: string;
|
|
79
|
+
}[];
|
|
80
|
+
};
|
|
81
|
+
/** Per-reader totals for one page, most recently seen first, plus everyone refused at the door. */
|
|
82
|
+
export declare function summarize(sessionDocs: SessionDoc[], flagDocs: FlagDoc[]): {
|
|
83
|
+
readers: ReaderSummary[];
|
|
84
|
+
refused: {
|
|
85
|
+
email: string;
|
|
86
|
+
name: string | null;
|
|
87
|
+
attempts: number;
|
|
88
|
+
lastAt: string;
|
|
89
|
+
}[];
|
|
90
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// The host-side record for gated pages: who may read each one, and what each reader did.
|
|
2
|
+
//
|
|
3
|
+
// Lives in Firestore beside the pages, never in a published file, because a person's email
|
|
4
|
+
// never goes into file content, and so that changing who may read needs no republish.
|
|
5
|
+
//
|
|
6
|
+
// <base>Access/<artifactId> { readers: { <email>: AllowEntry } }
|
|
7
|
+
// <base>Sessions/<sessionId> one visit: who, when, how long actively reading, how far
|
|
8
|
+
// <base>Flags/<auto> one attempt to save, print, copy, or open a page refused
|
|
9
|
+
// <base>Acks/<page>__<email> the reader's agreement to keep the page confidential, with the
|
|
10
|
+
// exact wording they agreed to, before the body was shown
|
|
11
|
+
//
|
|
12
|
+
// `base` is the artifacts collection path (`tenants/<id>/artifacts`), so each tenant's record
|
|
13
|
+
// sits next to its own pages.
|
|
14
|
+
import { FieldValue } from 'firebase-admin/firestore';
|
|
15
|
+
export const FLAG_KINDS = ['save', 'print', 'copy', 'image', 'devtools', 'refused'];
|
|
16
|
+
/** An email as a map key: lowercased, and with the dots Firestore reads as a path escaped. */
|
|
17
|
+
const keyOf = (email) => email.trim().toLowerCase().replace(/\./g, ',');
|
|
18
|
+
export function createReadersStore(db, base) {
|
|
19
|
+
const access = () => db.collection(`${base}Access`);
|
|
20
|
+
const sessions = () => db.collection(`${base}Sessions`);
|
|
21
|
+
const flags = () => db.collection(`${base}Flags`);
|
|
22
|
+
const acks = () => db.collection(`${base}Acks`);
|
|
23
|
+
const ackId = (artifactId, email) => `${artifactId}__${keyOf(email)}`;
|
|
24
|
+
const listOf = (data) => Object.values((data?.readers ?? {})).sort((a, b) => a.email.localeCompare(b.email));
|
|
25
|
+
return {
|
|
26
|
+
async acknowledged(artifactId, email) {
|
|
27
|
+
return (await acks().doc(ackId(artifactId, email)).get()).exists;
|
|
28
|
+
},
|
|
29
|
+
async acknowledge({ artifactId, reader, text, country }) {
|
|
30
|
+
const doc = { artifactId, email: reader.email, name: reader.name, text, at: new Date().toISOString(), ...(country ? { country } : {}) };
|
|
31
|
+
await acks().doc(ackId(artifactId, reader.email)).set(doc);
|
|
32
|
+
},
|
|
33
|
+
async acks(artifactId) {
|
|
34
|
+
const snap = await acks().where('artifactId', '==', artifactId).get();
|
|
35
|
+
return snap.docs.map((d) => d.data());
|
|
36
|
+
},
|
|
37
|
+
async allowList(artifactId) {
|
|
38
|
+
const snap = await access().doc(artifactId).get();
|
|
39
|
+
return snap.exists ? listOf(snap.data()) : [];
|
|
40
|
+
},
|
|
41
|
+
async allow(artifactId, add, remove) {
|
|
42
|
+
const now = new Date().toISOString();
|
|
43
|
+
const update = {};
|
|
44
|
+
for (const e of add) {
|
|
45
|
+
const email = e.email.trim().toLowerCase();
|
|
46
|
+
update[`readers.${keyOf(email)}`] = {
|
|
47
|
+
email,
|
|
48
|
+
...(e.name?.trim() ? { name: e.name.trim() } : {}),
|
|
49
|
+
...(e.reason?.trim() ? { reason: e.reason.trim() } : {}),
|
|
50
|
+
addedAt: now,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
for (const email of remove)
|
|
54
|
+
update[`readers.${keyOf(email)}`] = FieldValue.delete();
|
|
55
|
+
const ref = access().doc(artifactId);
|
|
56
|
+
if (Object.keys(update).length) {
|
|
57
|
+
// Create the document only when it is missing. `set({ readers: {} }, { merge: true })`
|
|
58
|
+
// looks harmless and is not: Firestore writes an EMPTY map in a merge as a value, so it
|
|
59
|
+
// replaced the whole list and every `allow` erased everyone added before it. Seen live
|
|
60
|
+
// on 2026-09-24, when adding one reader to the lightpaper removed the one before.
|
|
61
|
+
if (!(await ref.get()).exists)
|
|
62
|
+
await ref.set({ readers: {} });
|
|
63
|
+
await ref.update(update);
|
|
64
|
+
}
|
|
65
|
+
return listOf((await ref.get()).data());
|
|
66
|
+
},
|
|
67
|
+
async touchSession({ artifactId, session, reader, addSeconds, scroll, device, country }) {
|
|
68
|
+
const ref = sessions().doc(session);
|
|
69
|
+
const now = new Date().toISOString();
|
|
70
|
+
await db.runTransaction(async (tx) => {
|
|
71
|
+
const snap = await tx.get(ref);
|
|
72
|
+
if (!snap.exists) {
|
|
73
|
+
const doc = {
|
|
74
|
+
artifactId, session, email: reader.email, name: reader.name, member: reader.member,
|
|
75
|
+
startedAt: now, lastAt: now, activeSeconds: addSeconds, maxScroll: scroll,
|
|
76
|
+
...(device ? { device } : {}), ...(country ? { country } : {}),
|
|
77
|
+
};
|
|
78
|
+
tx.set(ref, doc);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const cur = snap.data();
|
|
82
|
+
// A session id is the browser's; it can never be moved onto another reader or page.
|
|
83
|
+
if (cur.email !== reader.email || cur.artifactId !== artifactId)
|
|
84
|
+
return;
|
|
85
|
+
tx.update(ref, {
|
|
86
|
+
lastAt: now,
|
|
87
|
+
activeSeconds: FieldValue.increment(addSeconds),
|
|
88
|
+
maxScroll: Math.max(cur.maxScroll ?? 0, scroll),
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
async flag({ artifactId, reader, kind, detail, country }) {
|
|
93
|
+
const doc = {
|
|
94
|
+
artifactId, email: reader.email, name: reader.name, kind, at: new Date().toISOString(),
|
|
95
|
+
...(detail ? { detail } : {}), ...(country ? { country } : {}),
|
|
96
|
+
};
|
|
97
|
+
await flags().add(doc);
|
|
98
|
+
},
|
|
99
|
+
async sessions(artifactId) {
|
|
100
|
+
const snap = await sessions().where('artifactId', '==', artifactId).get();
|
|
101
|
+
return snap.docs.map((d) => d.data());
|
|
102
|
+
},
|
|
103
|
+
async flags(artifactId) {
|
|
104
|
+
const snap = await flags().where('artifactId', '==', artifactId).get();
|
|
105
|
+
return snap.docs.map((d) => d.data());
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** Per-reader totals for one page, most recently seen first, plus everyone refused at the door. */
|
|
110
|
+
export function summarize(sessionDocs, flagDocs) {
|
|
111
|
+
const by = new Map();
|
|
112
|
+
for (const s of sessionDocs) {
|
|
113
|
+
const r = by.get(s.email) ?? {
|
|
114
|
+
email: s.email, name: s.name, member: s.member, sessions: 0, activeSeconds: 0, maxScroll: 0,
|
|
115
|
+
firstSeen: s.startedAt, lastSeen: s.lastAt, flags: [],
|
|
116
|
+
};
|
|
117
|
+
r.sessions += 1;
|
|
118
|
+
r.activeSeconds += s.activeSeconds ?? 0;
|
|
119
|
+
r.maxScroll = Math.max(r.maxScroll, s.maxScroll ?? 0);
|
|
120
|
+
if (s.startedAt < r.firstSeen)
|
|
121
|
+
r.firstSeen = s.startedAt;
|
|
122
|
+
if (s.lastAt > r.lastSeen)
|
|
123
|
+
r.lastSeen = s.lastAt;
|
|
124
|
+
by.set(s.email, r);
|
|
125
|
+
}
|
|
126
|
+
const refused = new Map();
|
|
127
|
+
for (const f of flagDocs.slice().sort((a, b) => a.at.localeCompare(b.at))) {
|
|
128
|
+
if (f.kind === 'refused') {
|
|
129
|
+
const r = refused.get(f.email) ?? { email: f.email, name: f.name, attempts: 0, lastAt: f.at };
|
|
130
|
+
r.attempts += 1;
|
|
131
|
+
r.lastAt = f.at;
|
|
132
|
+
refused.set(f.email, r);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
by.get(f.email)?.flags.push({ kind: f.kind, at: f.at, ...(f.detail ? { detail: f.detail } : {}) });
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
readers: [...by.values()].sort((a, b) => b.lastSeen.localeCompare(a.lastSeen)),
|
|
139
|
+
refused: [...refused.values()].sort((a, b) => b.lastAt.localeCompare(a.lastAt)),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import ReactMarkdown from 'react-markdown';
|
|
3
|
+
import remarkGfm from 'remark-gfm';
|
|
4
|
+
const GOLD = '#C2A15C';
|
|
5
|
+
function hostOf(url) {
|
|
6
|
+
try {
|
|
7
|
+
return new URL(url).host.replace(/^www\./, '');
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return '';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function textOf(node) {
|
|
14
|
+
if (node == null || typeof node === 'boolean')
|
|
15
|
+
return '';
|
|
16
|
+
if (typeof node === 'string' || typeof node === 'number')
|
|
17
|
+
return String(node);
|
|
18
|
+
if (Array.isArray(node))
|
|
19
|
+
return node.map(textOf).join('');
|
|
20
|
+
if (typeof node === 'object' && 'props' in node) {
|
|
21
|
+
return textOf(node.props.children);
|
|
22
|
+
}
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
function LinkCards({ source }) {
|
|
26
|
+
const rows = source
|
|
27
|
+
.split('\n')
|
|
28
|
+
.map((l) => l.trim())
|
|
29
|
+
.filter(Boolean)
|
|
30
|
+
.map((l) => {
|
|
31
|
+
const [url, ...rest] = l.split('|');
|
|
32
|
+
return { url: url.trim(), note: rest.join('|').trim() };
|
|
33
|
+
});
|
|
34
|
+
return (_jsx("ul", { "data-artifact-links": true, className: "my-6 grid list-none gap-3 p-0", children: rows.map((r) => (_jsx("li", { className: "m-0 p-0", children: _jsxs("a", { href: r.url, target: "_blank", rel: "noopener noreferrer", className: "block rounded-lg border border-white/10 bg-white/[0.03] px-4 py-3 no-underline transition-colors hover:border-white/25", children: [_jsx("span", { className: "block text-[11px] uppercase tracking-[0.2em]", style: { color: GOLD }, children: hostOf(r.url) }), _jsx("span", { className: "block text-zinc-100", children: r.note || r.url })] }) }, r.url))) }));
|
|
35
|
+
}
|
|
36
|
+
const CALLOUT = /^\[!(note|warning)\]\s*/i;
|
|
37
|
+
// A link the reader can actually follow: absolute, a scheme like mailto:, a
|
|
38
|
+
// root path on this host (uploaded assets), or an in-page fragment. Anything
|
|
39
|
+
// else is a path on the author's disk (../meeting-transcripts/x.md), which the
|
|
40
|
+
// publisher never uploads, so the anchor would 404. Those render as their text.
|
|
41
|
+
const REACHABLE = /^(?:[a-z][a-z0-9+.-]*:|\/|#)/i;
|
|
42
|
+
export function isReachableHref(href) {
|
|
43
|
+
return typeof href === 'string' && REACHABLE.test(href);
|
|
44
|
+
}
|
|
45
|
+
const components = {
|
|
46
|
+
h1: ({ children }) => _jsx("h1", { className: "mt-10 mb-4 font-serif text-3xl text-zinc-50", children: children }),
|
|
47
|
+
h2: ({ children }) => _jsx("h2", { className: "mt-10 mb-3 font-serif text-2xl text-zinc-50", children: children }),
|
|
48
|
+
h3: ({ children }) => _jsx("h3", { className: "mt-8 mb-2 text-lg font-semibold text-zinc-100", children: children }),
|
|
49
|
+
p: ({ children }) => _jsx("p", { className: "my-4 leading-relaxed", children: children }),
|
|
50
|
+
ul: ({ children }) => _jsx("ul", { className: "my-4 list-disc space-y-1 pl-6", children: children }),
|
|
51
|
+
ol: ({ children }) => _jsx("ol", { className: "my-4 list-decimal space-y-1 pl-6", children: children }),
|
|
52
|
+
hr: () => _jsx("hr", { className: "my-10 border-white/10" }),
|
|
53
|
+
table: ({ children }) => (_jsx("div", { className: "my-6 overflow-x-auto", children: _jsx("table", { className: "w-full border-collapse text-sm", children: children }) })),
|
|
54
|
+
th: ({ children }) => (_jsx("th", { className: "border-b border-white/20 px-3 py-2 text-left font-semibold text-zinc-100", children: children })),
|
|
55
|
+
td: ({ children }) => _jsx("td", { className: "border-b border-white/10 px-3 py-2 align-top", children: children }),
|
|
56
|
+
img: ({ src, alt }) => (
|
|
57
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
58
|
+
_jsx("img", { src: typeof src === 'string' ? src : undefined, alt: alt ?? '', className: "my-6 w-full rounded-lg border border-white/10" })),
|
|
59
|
+
a: ({ href, children }) => !isReachableHref(href) ? (_jsx("span", { children: children })) : (_jsxs("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: "text-zinc-50 underline decoration-white/30 underline-offset-4 hover:decoration-white", children: [children, href && hostOf(href) ? (_jsxs("span", { "data-nospeak": true, className: "ml-1 text-xs text-zinc-500", children: ["(", hostOf(href), ")"] })) : null] })),
|
|
60
|
+
code: ({ className, children }) => {
|
|
61
|
+
const lang = /language-(\w+)/.exec(className ?? '')?.[1];
|
|
62
|
+
if (lang === 'links')
|
|
63
|
+
return _jsx(LinkCards, { source: textOf(children) });
|
|
64
|
+
if (!className) {
|
|
65
|
+
return _jsx("code", { className: "rounded bg-white/10 px-1.5 py-0.5 text-[0.9em] text-zinc-100", children: children });
|
|
66
|
+
}
|
|
67
|
+
return _jsx("code", { className: className, children: children });
|
|
68
|
+
},
|
|
69
|
+
pre: ({ children }) => {
|
|
70
|
+
// A links fence renders its own block; do not wrap it in <pre>.
|
|
71
|
+
const inner = Array.isArray(children) ? children[0] : children;
|
|
72
|
+
const cls = inner?.props?.className ?? '';
|
|
73
|
+
if (/language-links/.test(cls))
|
|
74
|
+
return _jsx(_Fragment, { children: children });
|
|
75
|
+
return (_jsx("pre", { "data-artifact-code": true, className: "my-6 overflow-x-auto rounded-lg border border-white/10 bg-black/40 p-4 text-sm text-zinc-100", children: children }));
|
|
76
|
+
},
|
|
77
|
+
blockquote: ({ children }) => {
|
|
78
|
+
const t = textOf(children).trim();
|
|
79
|
+
const m = CALLOUT.exec(t);
|
|
80
|
+
if (!m) {
|
|
81
|
+
return (_jsx("blockquote", { className: "my-6 border-l-2 pl-4 italic text-zinc-300", style: { borderColor: GOLD }, children: children }));
|
|
82
|
+
}
|
|
83
|
+
const kind = m[1].toLowerCase();
|
|
84
|
+
const body = t.replace(CALLOUT, '');
|
|
85
|
+
return (_jsxs("aside", { "data-callout": kind, className: "my-6 rounded-lg border px-4 py-3", style: { borderColor: kind === 'warning' ? '#d97706' : GOLD, background: 'rgba(255,255,255,0.03)' }, children: [_jsx("span", { "data-nospeak": true, className: "block text-[11px] uppercase tracking-[0.2em]", style: { color: GOLD }, children: kind }), _jsx("p", { className: "mt-1 text-zinc-100", children: body })] }));
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
export function ArtifactMarkdown({ markdown }) {
|
|
89
|
+
return (_jsx("div", { className: "text-[17px] text-zinc-200", children: _jsx(ReactMarkdown, { remarkPlugins: [remarkGfm], components: components, children: markdown }) }));
|
|
90
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type Firestore } from 'firebase-admin/firestore';
|
|
2
|
+
import type { ArtifactMeta } from './front-matter.js';
|
|
3
|
+
import type { Access } from './reader.js';
|
|
4
|
+
export type ArtifactRecord = {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
summary: string;
|
|
8
|
+
template: 'document';
|
|
9
|
+
subtitle?: string;
|
|
10
|
+
audience?: string;
|
|
11
|
+
cover?: string;
|
|
12
|
+
voice?: string;
|
|
13
|
+
narration?: string;
|
|
14
|
+
timings?: string;
|
|
15
|
+
narrationHash?: string;
|
|
16
|
+
password?: string;
|
|
17
|
+
access?: Access;
|
|
18
|
+
markdown: string;
|
|
19
|
+
createdAt: string;
|
|
20
|
+
updatedAt: string;
|
|
21
|
+
/** The current version number. Absent on pages saved before history moved out. */
|
|
22
|
+
version?: number;
|
|
23
|
+
/** LEGACY: history as an array on the document. Moved to the subcollection on next save. */
|
|
24
|
+
versions?: {
|
|
25
|
+
markdown: string;
|
|
26
|
+
at: string;
|
|
27
|
+
}[];
|
|
28
|
+
views: number;
|
|
29
|
+
};
|
|
30
|
+
export declare function newArtifactId(): string;
|
|
31
|
+
export type SaveResult = {
|
|
32
|
+
id: string;
|
|
33
|
+
version: number;
|
|
34
|
+
created: boolean;
|
|
35
|
+
} | {
|
|
36
|
+
notFound: true;
|
|
37
|
+
};
|
|
38
|
+
export interface ArtifactStore {
|
|
39
|
+
get(id: string): Promise<ArtifactRecord | null>;
|
|
40
|
+
save(input: {
|
|
41
|
+
id?: string;
|
|
42
|
+
meta: ArtifactMeta;
|
|
43
|
+
markdown: string;
|
|
44
|
+
}): Promise<SaveResult>;
|
|
45
|
+
delete(id: string): Promise<boolean>;
|
|
46
|
+
bumpViews(id: string): Promise<void>;
|
|
47
|
+
/** Set who may read a page without republishing it; 'public' opens it. False if no such page. */
|
|
48
|
+
setAccess?(id: string, access: Access | 'public'): Promise<boolean>;
|
|
49
|
+
}
|
|
50
|
+
/** The Firestore-backed store. The instance hands in its own Firestore, so the shell
|
|
51
|
+
* never knows which project it is writing to; a hosted tenant passes a namespaced one. */
|
|
52
|
+
export declare function createArtifactStore(db: Firestore, collection?: string): ArtifactStore;
|