@supersuit/artifacts 0.33.1 → 0.35.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 +67 -0
- package/README.md +83 -0
- package/lib/artifacts/assets.d.ts +16 -0
- package/lib/artifacts/assets.js +35 -0
- package/lib/artifacts/front-matter.d.ts +4 -0
- package/lib/artifacts/front-matter.js +19 -1
- package/lib/artifacts/index.d.ts +4 -0
- package/lib/artifacts/index.js +5 -0
- package/lib/artifacts/recipients-store.d.ts +75 -0
- package/lib/artifacts/recipients-store.js +160 -0
- package/lib/artifacts/recipients.d.ts +31 -0
- package/lib/artifacts/recipients.js +87 -0
- package/lib/artifacts/render.js +18 -3
- package/lib/artifacts/resolve-digest-store.d.ts +8 -0
- package/lib/artifacts/resolve-digest-store.js +194 -0
- package/lib/artifacts/resolve-digest.d.ts +236 -0
- package/lib/artifacts/resolve-digest.js +282 -0
- package/lib/artifacts/state-store.d.ts +1 -0
- package/lib/artifacts/state-store.js +1 -0
- package/lib/artifacts/store.d.ts +4 -0
- package/lib/artifacts/store.js +9 -0
- package/lib/reader/index.d.ts +1 -0
- package/lib/reader/index.js +1 -0
- package/lib/reader/quiet-view.d.ts +7 -0
- package/lib/reader/quiet-view.js +118 -0
- package/lib/routes/artifacts.d.ts +35 -0
- package/lib/routes/artifacts.js +57 -10
- package/lib/routes/per-reader.d.ts +25 -0
- package/lib/routes/per-reader.js +38 -0
- package/lib/routes/recipients-routes.d.ts +34 -0
- package/lib/routes/recipients-routes.js +182 -0
- package/lib/routes/state-routes.d.ts +10 -0
- package/lib/routes/state-routes.js +52 -15
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,73 @@
|
|
|
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.35.0
|
|
7
|
+
|
|
8
|
+
**Per-reader links, private media, video with sound, quiet views, and expiry.** A page can now go
|
|
9
|
+
to several people at once with one link each, revocable one at a time, with a tailored note per
|
|
10
|
+
reader.
|
|
11
|
+
|
|
12
|
+
- `links: per-reader` in front matter: the page opens only through a link made for one reader
|
|
13
|
+
(`?r=<rid>.<sig>`, signed with the host's recipient secret). Each link can be revoked alone.
|
|
14
|
+
`createRecipientsStore(db, base)` and `memoryRecipientsStore()` hold the readers; the routes are
|
|
15
|
+
`RECIPIENTS` (GET and POST: add, revoke, set a note), `VIEWS` (GET) and `SEEN` (POST).
|
|
16
|
+
- `media: private`: body media is stored under the page's private prefix and served only by signed
|
|
17
|
+
URL to a valid reader. The cover stays public so the share card works.
|
|
18
|
+
- Video with sound: `` renders a clip with controls. `.m4a` voice notes
|
|
19
|
+
are supported.
|
|
20
|
+
- A quiet view beacon. DETECTOR: an unfurl fetch or a background tab used to count as an open.
|
|
21
|
+
REMEDY: a view is the page visible for two seconds with a single-use render token; known unfurl
|
|
22
|
+
agents and the owner are dropped server side. TESTS: `src/routes/seen.test.tsx`,
|
|
23
|
+
`src/reader/quiet-view.test.tsx`.
|
|
24
|
+
- `sweepExpired()` for dated pages: deletes the page, its media, readers, views and comments, and
|
|
25
|
+
returns `{ pages, files, skipped, failed }`. Call it daily from a cron route.
|
|
26
|
+
- Config on `createArtifactRoutes`: `recipients` and `recipientSecret` (a function; at least 32
|
|
27
|
+
characters, shorter is treated as unset).
|
|
28
|
+
|
|
29
|
+
## 0.34.0
|
|
30
|
+
|
|
31
|
+
**Commenters hear when their comments are resolved, in one email per page per burst** (Gary,
|
|
32
|
+
2026-10-12: "Can those notifications around resolved comments batch plz"). 0.33.0 let the owner
|
|
33
|
+
resolve threads, but the reader who asked never found out unless someone texted them.
|
|
34
|
+
|
|
35
|
+
- `createResolveDigests({ store, send, secret, siteUrl, brandName, ownerName?, ownerEmail?, admins?,
|
|
36
|
+
quietMs?, maxWaitMs? })` returns `onResolve` (the route hook), `flush({ now?, dryRun? })` (run it on
|
|
37
|
+
a schedule), `STOP` (the stop link's GET and POST) and `stopUrl`. The package composes and batches;
|
|
38
|
+
the host's `send` delivers.
|
|
39
|
+
- `onResolve` on `createArtifactRoutes` (and the state routes): told after every resolve and reopen
|
|
40
|
+
with the page, the whole thread, the resolution (null on reopen) and who did it (`actor.email` for
|
|
41
|
+
a signed-in owner or admin, `actor.publisher` for the key or a pass). Awaited; a throw is logged and
|
|
42
|
+
the resolve still lands.
|
|
43
|
+
- Who hears: each signed-in writer of a comment in the thread, about their own comments only; never
|
|
44
|
+
an anonymous reader, the person resolving, the owner or the admins. A burst closes after 10 quiet
|
|
45
|
+
minutes for that reader on that page (or 6 hours after its first resolution); a reopened thread
|
|
46
|
+
drops out. Only resolutions recorded through `onResolve` queue anything, so threads resolved before
|
|
47
|
+
a host wires it never produce an email.
|
|
48
|
+
- Sent once: claim and lease are transactions, and every retry of one digest carries the same
|
|
49
|
+
`idempotencyKey` for the provider. Every email has a signed stop link (per reader, per page) and
|
|
50
|
+
`List-Unsubscribe` headers.
|
|
51
|
+
- `createResolveDigestStore(db, base)` (Firestore, beside the state store: `<base>ResolveBursts`,
|
|
52
|
+
`<base>ResolveDigests`, `<base>ResolveStops`; no composite index) and
|
|
53
|
+
`createMemoryResolveDigestStore()`.
|
|
54
|
+
- DETECTOR: `createResolveDigests` is not exported, or `createArtifactRoutes` takes no `onResolve`:
|
|
55
|
+
0.33.1 or earlier. Nothing changes for a host that does not wire it.
|
|
56
|
+
- REMEDY: to turn digests on, build `createResolveDigests` with your store, sender and a stop-link
|
|
57
|
+
secret, pass `onResolve: digests.onResolve`, add `app/api/resolve-digest/stop/route.ts` (GET and
|
|
58
|
+
POST = `digests.STOP`), and call `digests.flush()` from a scheduled job (every 5 minutes is plenty).
|
|
59
|
+
Otherwise update the dependency; nothing else.
|
|
60
|
+
- Tests: `artifacts/resolve-digest.test.ts` (who hears: writer only, replies by others, anonymous,
|
|
61
|
+
owner, admin and the resolver excluded; three threads make one digest; one per reader per page;
|
|
62
|
+
resolving again replaces; the quiet window and its restart; the 6-hour cap; a new burst after a
|
|
63
|
+
claim; reopen drops a thread and an emptied burst sends nothing; double and overlapping flushes send
|
|
64
|
+
once; a failed send retried with the same idempotency key; an expired lease after a crash; no
|
|
65
|
+
backfill; the stop link signed, refused when tampered, honoured for that page only, by GET and POST;
|
|
66
|
+
no secret queues nothing; dry run changes nothing; the email's words, links, escaping and no em
|
|
67
|
+
dash), `routes/resolve-digest.test.tsx` (the route hands the thread and the actor to `onResolve`,
|
|
68
|
+
reopen and the publish key too, a throwing hook never costs the resolve, end to end through RESOLVE
|
|
69
|
+
and flush, nothing for threads resolved before wiring), and `check-packed` (from the tarball: a
|
|
70
|
+
signed-in comment resolved with the key, nothing due inside the window, the dry run, one digest
|
|
71
|
+
sent once, its stop link).
|
|
72
|
+
|
|
6
73
|
## 0.33.1
|
|
7
74
|
|
|
8
75
|
**The player bar: the speed button no longer reflows the bar, 2x is a speed, and the narrator
|
package/README.md
CHANGED
|
@@ -251,6 +251,8 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
|
|
|
251
251
|
| `pack` | A brand pack the host registered by name (`packs` in the routes config). An unknown name renders in the host's default pack; a republish without the line goes back to it |
|
|
252
252
|
| `toc` | The table of contents. Absent, it appears once the page has four `##` sections; `false` never; `true` from one |
|
|
253
253
|
| `definitions` | Terms defined inline (below). A republish without it removes them |
|
|
254
|
+
| `links` | `per-reader`: the page opens only through a link made for one reader (see Per-reader links). Sticky: a republish whose file leaves the line out changes nothing |
|
|
255
|
+
| `media` | `private`: the page's body media is kept private and served through short-lived signed URLs. Needs `links: per-reader`. Sticky like `links` |
|
|
254
256
|
| `change` | One line saying what this version changed (see Version history) |
|
|
255
257
|
| `template` | `document`, the only one so far |
|
|
256
258
|
|
|
@@ -745,6 +747,48 @@ type Ctx = { params: Promise<{ id: string }> }
|
|
|
745
747
|
export function POST(request: NextRequest, ctx: Ctx) { return artifacts.RESOLVE(request, ctx) }
|
|
746
748
|
```
|
|
747
749
|
|
|
750
|
+
### Telling commenters their comments were resolved (`createResolveDigests`)
|
|
751
|
+
|
|
752
|
+
When threads are resolved, each signed-in reader whose comments were in them can get ONE email per
|
|
753
|
+
page per burst of resolving, listing every one of their comments resolved in it: the passage it was
|
|
754
|
+
pinned to (or "a box on the page"), their comment trimmed, the resolution note, a link to
|
|
755
|
+
`<page>#comment-<id>`, and one link to the page. Resolve nineteen threads in a sitting and a reader
|
|
756
|
+
gets one email, not nineteen.
|
|
757
|
+
|
|
758
|
+
- **Who hears:** the signed-in writer of each comment in a resolved thread, about the comments THEY
|
|
759
|
+
wrote (a reply by someone else is not theirs). Never an anonymous reader (no address), never the
|
|
760
|
+
person resolving, never `ownerEmail` or `admins` about their own comments.
|
|
761
|
+
- **The burst:** closes after `quietMs` (default 10 minutes) with no new resolution for that reader
|
|
762
|
+
on that page, or once its first resolution is `maxWaitMs` old (default 6 hours). A thread reopened
|
|
763
|
+
before then drops out; resolving one again replaces its line.
|
|
764
|
+
- **No backfill:** only resolutions that pass through `onResolve` queue anything, so threads resolved
|
|
765
|
+
before a host wires it never produce an email.
|
|
766
|
+
- **Sent once:** a due burst is claimed in one transaction (it becomes a digest record and the burst
|
|
767
|
+
is deleted), and a digest is leased in a transaction before it is sent, so overlapping runs cannot
|
|
768
|
+
both send it. The message carries `idempotencyKey` (the same on every retry of one digest): hand it
|
|
769
|
+
to your provider (Resend's `Idempotency-Key`), and a run that died between sending and recording
|
|
770
|
+
does not deliver twice when the lease expires. A failed send is retried with back-off, at most 5
|
|
771
|
+
times.
|
|
772
|
+
- **Stop:** every email carries a signed link (`<site>/api/resolve-digest/stop?t=...`, and the RFC 8058
|
|
773
|
+
`List-Unsubscribe` headers) that ends these emails for that reader on that page. Without `secret`
|
|
774
|
+
nothing is queued, since no email may go out without one.
|
|
775
|
+
- **Where it waits:** `createResolveDigestStore(db, base)` (Firestore: `<base>ResolveBursts`,
|
|
776
|
+
`<base>ResolveDigests`, `<base>ResolveStops`; single-field queries only, so no index to create), or
|
|
777
|
+
`createMemoryResolveDigestStore()` for tests.
|
|
778
|
+
|
|
779
|
+
```ts
|
|
780
|
+
const digests = createResolveDigests({
|
|
781
|
+
store: createResolveDigestStore(db, 'artifact'),
|
|
782
|
+
send: async (m) => sendWithYourProvider(m), // { to, subject, text, html, headers, idempotencyKey }
|
|
783
|
+
secret: () => process.env.STOP_LINK_SECRET, // signs stop links
|
|
784
|
+
siteUrl: 'https://artifacts.example.com', brandName: 'Example Co', ownerName: 'Ann',
|
|
785
|
+
ownerEmail, admins,
|
|
786
|
+
})
|
|
787
|
+
createArtifactRoutes({ /* ... */, onResolve: digests.onResolve })
|
|
788
|
+
// app/api/resolve-digest/stop/route.ts: GET and POST = digests.STOP
|
|
789
|
+
// a scheduled job: await digests.flush() ({ dryRun: true } reports, sends nothing)
|
|
790
|
+
```
|
|
791
|
+
|
|
748
792
|
### The comments feed
|
|
749
793
|
|
|
750
794
|
So the publisher hears about a shared comment without opening every page, one route lists every
|
|
@@ -788,6 +832,44 @@ composite index: **`slot` ascending, `at` ascending**, collection scope. Without
|
|
|
788
832
|
refuses the query and the error names a link that creates the index. A host with its own
|
|
789
833
|
`StateStore` implements `slotSince(slot, after, limit)`; without it the feed answers 501.
|
|
790
834
|
|
|
835
|
+
## Per-reader links
|
|
836
|
+
|
|
837
|
+
One page, sent to many people, where every person gets their own link. The link names an opaque reader id and a signature over the page and that id, so a link cannot be guessed or carried to another page. The host knows an opaque id, a label you gave it and that link's views and comments, and nothing else about the reader.
|
|
838
|
+
|
|
839
|
+
```markdown
|
|
840
|
+
---
|
|
841
|
+
title: The river
|
|
842
|
+
summary: Eleven minutes on the water.
|
|
843
|
+
links: per-reader
|
|
844
|
+
media: private
|
|
845
|
+
---
|
|
846
|
+

|
|
847
|
+

|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
`links: per-reader` shuts the page to everyone who does not arrive through a valid, unrevoked, unexpired link. It cannot be combined with `password`, `access` or `comments_visible: readers` (readers never see each other's comments). `media: private` keeps body media under the page's private prefix; `.mp4` and `.m4a` (and `.mp3`) are supported, and `sound:` at the start of a video's alt makes it a clip with controls and sound. Both lines are sticky: republishing a file that leaves them out changes nothing.
|
|
851
|
+
|
|
852
|
+
Routes (spread into your route files by `createArtifactRoutes`): `RECIPIENTS`, `VIEWS`, `SEEN`, and `sweepExpired`.
|
|
853
|
+
|
|
854
|
+
| Route | Who | What |
|
|
855
|
+
|---|---|---|
|
|
856
|
+
| `GET /api/artifacts/<id>/recipients` | publish key | Every reader, with the working link for each |
|
|
857
|
+
| `POST /api/artifacts/<id>/recipients` | publish key | Body `{ add?: [{ label, note?, group? }], revoke?: [rid] or '*', expires?: ISO time or null }`. `add` answers each new reader's link; `expires: null` is Keep |
|
|
858
|
+
| `GET /api/artifacts/<id>/views` | publish key | One line per reader: opened, came back, active seconds, plays, devices, comments. A page with no readers yet answers 200 with an empty list; a missing page answers 404 |
|
|
859
|
+
| `POST /api/artifacts/<id>/seen` | the page itself (public) | The reader's own signal, below |
|
|
860
|
+
|
|
861
|
+
Config, both on `createArtifactRoutes`: `recipients` (a store from `createRecipientsStore(db, '<collection path>')`, or `memoryRecipientsStore()` in tests) and `recipientSecret`, a function returning the signing secret, read on every request. A secret shorter than 32 characters is treated as unset: per-reader pages stay shut and the routes answer 404 or 501. Signing with an empty secret throws.
|
|
862
|
+
|
|
863
|
+
The `Page` route must stay `force-dynamic` (the package's `dynamic` export says so; re-export it), because the page carries a fresh render token and answers per link.
|
|
864
|
+
|
|
865
|
+
### What counts as a view
|
|
866
|
+
|
|
867
|
+
A view is the page rendered in a real browser, visible, for at least 2 seconds. The page then sends one signal carrying a single-use render token minted for that render; the token is spent on first use and expires after a day. Known unfurl fetchers (and any request with no user agent) are ignored, and so is a signal for a revoked or expired reader. The owner's own opens record zero. After the first signal the page sends a beat every few seconds while it stays visible (and a final one on hide, by `sendBeacon`), and a play signal when a clip starts. A signed-in owner or admin opens the page with no link and no tracking, so test a reader's link in a signed-out browser, never in the browser you are signed in to.
|
|
868
|
+
|
|
869
|
+
### Expiry
|
|
870
|
+
|
|
871
|
+
`expires` sets the date the page, its media, its readers, their views and their comments go. `sweepExpired()` (call it daily from a cron route) deletes them and returns `{ pages, files, skipped, failed }`: pages removed, media files removed, ids it refused to delete (an expiry that is not a past ISO time, or a page republished without per-reader links), and ids that failed and will be retried by the next run. Your `assets` must provide `deletePage`, or every expired page lands in `failed` and nothing is deleted. A Keep that lands mid-sweep is honored. Deleting a page also removes every prior version of its body.
|
|
872
|
+
|
|
791
873
|
## Brand packs
|
|
792
874
|
|
|
793
875
|
A `BrandPack` is data plus at most two components: colours, type, the kicker line above a title,
|
|
@@ -839,6 +921,7 @@ the page's pack.
|
|
|
839
921
|
| your publish key (named by you, read in `publishKey`) | Authorises publish, delete and access changes |
|
|
840
922
|
| `ARTIFACT_PASS_SECRET` (named by you, read in `readerSecret`) | Verifies reader passes and signs grants; shared with your sign-in side |
|
|
841
923
|
| a publisher-pass secret (named by you, read in `publisherSecret`; may be the same one) | Verifies publisher passes; shared with whatever mints them |
|
|
924
|
+
| a recipient secret (named by you, read in `recipientSecret`; at least 32 characters) | Signs per-reader links and render tokens; shorter is treated as unset |
|
|
842
925
|
| Firebase Admin credentials | The store and readers store |
|
|
843
926
|
|
|
844
927
|
## Contributing
|
|
@@ -25,7 +25,23 @@ export interface ArtifactAssets {
|
|
|
25
25
|
readAudio?(id: string, path: string): Promise<Buffer | null>;
|
|
26
26
|
/** A signed URL to play the recording, valid for about an hour. */
|
|
27
27
|
audioUrl?(id: string, path: string): Promise<string>;
|
|
28
|
+
/** Store bytes privately; return `private:<stored name>`. */
|
|
29
|
+
putPrivate?(id: string, name: string, bytes: Buffer, contentType: string): Promise<string>;
|
|
30
|
+
/** Like signUpload, to the private key; `url` is `private:<stored name>`. */
|
|
31
|
+
signPrivateUpload?(id: string, name: string, digest: string, contentType: string): Promise<{
|
|
32
|
+
uploadUrl: string;
|
|
33
|
+
headers: Record<string, string>;
|
|
34
|
+
url: string;
|
|
35
|
+
}>;
|
|
36
|
+
/** After a signed private upload: `private:<stored name>`, or null if nothing arrived. */
|
|
37
|
+
finishPrivateUpload?(id: string, name: string, digest: string): Promise<string | null>;
|
|
38
|
+
/** A signed read URL for a stored private name, valid one hour. */
|
|
39
|
+
privateUrl?(id: string, stored: string): Promise<string>;
|
|
40
|
+
/** Delete every object under `<prefix>/<id>/`; returns how many. */
|
|
41
|
+
deletePage?(id: string): Promise<number>;
|
|
28
42
|
}
|
|
43
|
+
/** A reference to a private file as it appears in a page: `private:<stored name>`. */
|
|
44
|
+
export declare const PRIVATE_REF: RegExp;
|
|
29
45
|
/** The digest half of a stored name: the first 8 hex of the bytes' sha256. */
|
|
30
46
|
export declare const ASSET_DIGEST: RegExp;
|
|
31
47
|
/** Names are one path segment: letters, digits, dot, dash, underscore. */
|
package/lib/artifacts/assets.js
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
// could do, so every operator gets the same door: PUT bytes with their key, get a URL back.
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
import { MAX_AUDIO_BYTES } from './audio.js';
|
|
7
|
+
import { ARTIFACT_ID_RE } from '../routes/ids.js';
|
|
8
|
+
/** A reference to a private file as it appears in a page: `private:<stored name>`. */
|
|
9
|
+
export const PRIVATE_REF = /^private:([A-Za-z0-9][A-Za-z0-9._-]{0,140})$/;
|
|
7
10
|
/** The digest half of a stored name: the first 8 hex of the bytes' sha256. */
|
|
8
11
|
export const ASSET_DIGEST = /^[0-9a-f]{8}$/;
|
|
9
12
|
const CACHE_CONTROL = 'public, max-age=31536000, immutable';
|
|
@@ -16,6 +19,7 @@ export const ASSET_TYPES = {
|
|
|
16
19
|
jpeg: 'image/jpeg',
|
|
17
20
|
gif: 'image/gif',
|
|
18
21
|
mp3: 'audio/mpeg',
|
|
22
|
+
m4a: 'audio/mp4',
|
|
19
23
|
mp4: 'video/mp4',
|
|
20
24
|
json: 'application/json',
|
|
21
25
|
};
|
|
@@ -131,5 +135,36 @@ export function createArtifactAssets(bucket, prefix) {
|
|
|
131
135
|
const [url] = await bucket.file(`${clean}/${id}/${path}`).getSignedUrl({ version: 'v4', action: 'read', expires: Date.now() + 60 * 60 * 1000 });
|
|
132
136
|
return url;
|
|
133
137
|
},
|
|
138
|
+
async putPrivate(id, name, bytes, contentType) {
|
|
139
|
+
const stored = hashedName(name, bytes);
|
|
140
|
+
await bucket.file(`${clean}/${id}/private/${stored}`).save(bytes, { contentType, resumable: false, metadata: { cacheControl: 'private, max-age=3600' } });
|
|
141
|
+
return `private:${stored}`;
|
|
142
|
+
},
|
|
143
|
+
async signPrivateUpload(id, name, digest, contentType) {
|
|
144
|
+
const stored = storedName(name, digest);
|
|
145
|
+
const headers = { 'content-type': contentType, 'cache-control': 'private, max-age=3600' };
|
|
146
|
+
const [uploadUrl] = await bucket.file(`${clean}/${id}/private/${stored}`).getSignedUrl({ version: 'v4', action: 'write', expires: Date.now() + 15 * 60 * 1000, contentType, extensionHeaders: { 'cache-control': headers['cache-control'] } });
|
|
147
|
+
return { uploadUrl, headers, url: `private:${stored}` };
|
|
148
|
+
},
|
|
149
|
+
async finishPrivateUpload(id, name, digest) {
|
|
150
|
+
const stored = storedName(name, digest);
|
|
151
|
+
const [there] = await bucket.file(`${clean}/${id}/private/${stored}`).exists();
|
|
152
|
+
return there ? `private:${stored}` : null;
|
|
153
|
+
},
|
|
154
|
+
async privateUrl(id, stored) {
|
|
155
|
+
if (!PRIVATE_REF.test(`private:${stored}`))
|
|
156
|
+
throw new Error('not a private file name');
|
|
157
|
+
const [url] = await bucket.file(`${clean}/${id}/private/${stored}`).getSignedUrl({ version: 'v4', action: 'read', expires: Date.now() + 60 * 60 * 1000 });
|
|
158
|
+
return url;
|
|
159
|
+
},
|
|
160
|
+
async deletePage(id) {
|
|
161
|
+
// An empty or path-shaped id would make the prefix wider than one page.
|
|
162
|
+
if (!ARTIFACT_ID_RE.test(id))
|
|
163
|
+
throw new Error('not an artifact id');
|
|
164
|
+
const [files] = await bucket.getFiles({ prefix: `${clean}/${id}/` });
|
|
165
|
+
for (const f of files)
|
|
166
|
+
await f.delete();
|
|
167
|
+
return files.length;
|
|
168
|
+
},
|
|
134
169
|
};
|
|
135
170
|
}
|
|
@@ -31,6 +31,10 @@ export type ArtifactMeta = {
|
|
|
31
31
|
* ABSENT CHANGES NOTHING: the level is host-side state, so a republish that omits the line
|
|
32
32
|
* (or a publisher that strips unknown keys) can never reopen a confidential page. */
|
|
33
33
|
access?: Access | 'public';
|
|
34
|
+
/** `per-reader`: the page opens only through a link made for one reader (recipients.ts). */
|
|
35
|
+
links?: 'per-reader';
|
|
36
|
+
/** `media: private` in the file: body media are stored unlisted and served only to a valid reader. */
|
|
37
|
+
privateMedia?: true;
|
|
34
38
|
/** What readers may put into the page. Content, like the body: a republish without it removes
|
|
35
39
|
* the slots from the page and KEEPS the answers already given. */
|
|
36
40
|
state?: StateConfig;
|
|
@@ -11,7 +11,7 @@ import { parseDefinitions } from './definitions.js';
|
|
|
11
11
|
export const PACK_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
12
12
|
/** The longest `to:` a page may carry: a name or two, never a sentence. */
|
|
13
13
|
export const MAX_TO_CHARS = 60;
|
|
14
|
-
const KNOWN = new Set(['title', 'summary', 'subtitle', 'to', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'pack', 'toc', 'definitions', 'change', 'comments', 'comments_visible']);
|
|
14
|
+
const KNOWN = new Set(['title', 'summary', 'subtitle', 'to', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'pack', 'toc', 'definitions', 'change', 'comments', 'comments_visible', 'links', 'media']);
|
|
15
15
|
export function parseArtifactSource(text) {
|
|
16
16
|
const { data, content } = matter(text);
|
|
17
17
|
const d = data;
|
|
@@ -54,6 +54,18 @@ export function parseArtifactSource(text) {
|
|
|
54
54
|
return { ok: false, error: `access must be one of: public, ${ACCESS_LEVELS.join(', ')}` };
|
|
55
55
|
meta.access = d.access;
|
|
56
56
|
}
|
|
57
|
+
if (d.links !== undefined && d.links !== null) {
|
|
58
|
+
if (d.links !== 'per-reader')
|
|
59
|
+
return { ok: false, error: 'links must be per-reader' };
|
|
60
|
+
if (meta.password || meta.access)
|
|
61
|
+
return { ok: false, error: 'per-reader links stand alone: drop password and access' };
|
|
62
|
+
meta.links = 'per-reader';
|
|
63
|
+
}
|
|
64
|
+
if (d.media !== undefined && d.media !== null) {
|
|
65
|
+
if (d.media !== 'private')
|
|
66
|
+
return { ok: false, error: 'media must be private' };
|
|
67
|
+
meta.privateMedia = true;
|
|
68
|
+
}
|
|
57
69
|
if (d.theme !== undefined) {
|
|
58
70
|
if (!THEME_MODES.includes(d.theme))
|
|
59
71
|
return { ok: false, error: `theme must be one of: ${THEME_MODES.join(', ')}` };
|
|
@@ -94,6 +106,12 @@ export function parseArtifactSource(text) {
|
|
|
94
106
|
return { ok: false, error: 'comments_visible must be owner or readers' };
|
|
95
107
|
meta.commentsVisible = d.comments_visible;
|
|
96
108
|
}
|
|
109
|
+
// Only the per-reader page resolves private media, so on any other page it would ship broken.
|
|
110
|
+
if (meta.privateMedia && meta.links !== 'per-reader')
|
|
111
|
+
return { ok: false, error: 'media: private needs links: per-reader' };
|
|
112
|
+
// People on one broadcast never see each other's comments, so a per-reader page cannot share them.
|
|
113
|
+
if (meta.links === 'per-reader' && meta.commentsVisible === 'readers')
|
|
114
|
+
return { ok: false, error: 'a per-reader page keeps each reader\'s comments to the owner: drop comments_visible: readers' };
|
|
97
115
|
// Widgets declare their own slots, so a notes block (and a checklist with send) is merged into state: here, before the
|
|
98
116
|
// page is stored, and the state API and the republish shape checks see it like any slot.
|
|
99
117
|
// Errors name the line in the FILE: the body's lines are counted after the front matter's.
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -16,9 +16,13 @@ export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, mergeChec
|
|
|
16
16
|
export { parseSimulatorFence, simulatorSrc, parseEmbedFence, embedSrc, EMBED_KINDS, type SimulatorBlock, type EmbedBlock, type EmbedKind } from './simulator.js';
|
|
17
17
|
export { CHECKLIST_SLOT, checklistStorageKey, checkChecklistValue, parseChecklistFence, type ChecklistBlock, type ChecklistItem, type ChecklistValue } from './checklist.js';
|
|
18
18
|
export { textAnchorFrom, findText, blockId, parseAnchor, CONTEXT_CHARS, MAX_QUOTE_CHARS, BLOCK_ID, type Anchor, type TextAnchor, type RegionAnchor } from './anchor.js';
|
|
19
|
+
export { createResolveDigests, noticesFor, composeDigest, resolverName, itemsOf, emailKey, burstKey, stopToken, verifyStopToken, maskEmail, RESOLVE_DIGEST_QUIET_MS, RESOLVE_DIGEST_MAX_WAIT_MS, RESOLVE_DIGEST_LEASE_MS, RESOLVE_DIGEST_MAX_ATTEMPTS, RESOLVE_DIGEST_STOP_PATH, DIGEST_BODY_CHARS, DIGEST_QUOTE_CHARS, type ResolveEvent, type DigestItem, type Burst, type Digest, type DigestStatus, type ResolveDigestStore, type DigestMessage, type DigestSender, type ResolveDigestOptions, type ComposeOptions, type FlushReport } from './resolve-digest.js';
|
|
20
|
+
export { createResolveDigestStore, createMemoryResolveDigestStore } from './resolve-digest-store.js';
|
|
19
21
|
export { COMMENTS_SLOT, COMMENTS_MODES, COMMENT_TRANSCRIPTS, MAX_COMMENT_CHARS, mergeCommentsState, validateComment, type CommentsMode, type CommentsVisible, type CommentValue } from './comments.js';
|
|
20
22
|
export { createPersonalStore, createMemoryPersonalStore, validatePersonal, personalAudioDir, MAX_PERSONAL_PER_READER, type PersonalStore, type PersonalNote, type PersonalNoteValue } from './personal-store.js';
|
|
21
23
|
export { deepgramTranscriber, openaiTranscriber, type Transcriber } from './transcribe.js';
|
|
22
24
|
export { AUDIO_TYPES, audioExtFor, audioTypeFor, baseMime, MAX_AUDIO_BYTES, MAX_MEMO_MS, MEMO_NAME, commentAudioPath, isCommentAudioPath, personalAudioPath, isPersonalAudioPath } from './audio.js';
|
|
23
25
|
export { settleOnPublish, applySettingsPatch, withComments, declaredOf, passwordDigest, newPageKey, type DeclaredSettings, type LiveSettings, type SettingsPatch } from './settings.js';
|
|
24
26
|
export type { PageSettings, SettingsFact, AccessLevel } from '../reader/settings-sheet.js';
|
|
27
|
+
export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN } from './recipients.js';
|
|
28
|
+
export { createRecipientsStore, memoryRecipientsStore, summarizeViews, type RecipientsStore, type Recipient, type RecipientsRecord, type ViewDoc, type RecipientSummary } from './recipients-store.js';
|
package/lib/artifacts/index.js
CHANGED
|
@@ -16,8 +16,13 @@ export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, mergeChec
|
|
|
16
16
|
export { parseSimulatorFence, simulatorSrc, parseEmbedFence, embedSrc, EMBED_KINDS } from './simulator.js';
|
|
17
17
|
export { CHECKLIST_SLOT, checklistStorageKey, checkChecklistValue, parseChecklistFence } from './checklist.js';
|
|
18
18
|
export { textAnchorFrom, findText, blockId, parseAnchor, CONTEXT_CHARS, MAX_QUOTE_CHARS, BLOCK_ID } from './anchor.js';
|
|
19
|
+
export { createResolveDigests, noticesFor, composeDigest, resolverName, itemsOf, emailKey, burstKey, stopToken, verifyStopToken, maskEmail, RESOLVE_DIGEST_QUIET_MS, RESOLVE_DIGEST_MAX_WAIT_MS, RESOLVE_DIGEST_LEASE_MS, RESOLVE_DIGEST_MAX_ATTEMPTS, RESOLVE_DIGEST_STOP_PATH, DIGEST_BODY_CHARS, DIGEST_QUOTE_CHARS } from './resolve-digest.js';
|
|
20
|
+
export { createResolveDigestStore, createMemoryResolveDigestStore } from './resolve-digest-store.js';
|
|
19
21
|
export { COMMENTS_SLOT, COMMENTS_MODES, COMMENT_TRANSCRIPTS, MAX_COMMENT_CHARS, mergeCommentsState, validateComment } from './comments.js';
|
|
20
22
|
export { createPersonalStore, createMemoryPersonalStore, validatePersonal, personalAudioDir, MAX_PERSONAL_PER_READER } from './personal-store.js';
|
|
21
23
|
export { deepgramTranscriber, openaiTranscriber } from './transcribe.js';
|
|
22
24
|
export { AUDIO_TYPES, audioExtFor, audioTypeFor, baseMime, MAX_AUDIO_BYTES, MAX_MEMO_MS, MEMO_NAME, commentAudioPath, isCommentAudioPath, personalAudioPath, isPersonalAudioPath } from './audio.js';
|
|
23
25
|
export { settleOnPublish, applySettingsPatch, withComments, declaredOf, passwordDigest, newPageKey } from './settings.js';
|
|
26
|
+
// Per-reader links: the host-facing surface only. The signing and token primitives stay internal.
|
|
27
|
+
export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN } from './recipients.js';
|
|
28
|
+
export { createRecipientsStore, memoryRecipientsStore, summarizeViews } from './recipients-store.js';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Firestore } from 'firebase-admin/firestore';
|
|
2
|
+
export type Recipient = {
|
|
3
|
+
rid: string;
|
|
4
|
+
label: string;
|
|
5
|
+
note?: string;
|
|
6
|
+
owner?: true;
|
|
7
|
+
group?: true;
|
|
8
|
+
addedAt: string;
|
|
9
|
+
revokedAt?: string;
|
|
10
|
+
};
|
|
11
|
+
export type RecipientsRecord = {
|
|
12
|
+
recipients: Record<string, Recipient>;
|
|
13
|
+
expireAt: string | null;
|
|
14
|
+
keep: boolean;
|
|
15
|
+
revokedAll?: string;
|
|
16
|
+
};
|
|
17
|
+
export type ViewDoc = {
|
|
18
|
+
artifactId: string;
|
|
19
|
+
rid: string;
|
|
20
|
+
session: string;
|
|
21
|
+
device: string;
|
|
22
|
+
firstAt: string;
|
|
23
|
+
lastAt: string;
|
|
24
|
+
activeSeconds: number;
|
|
25
|
+
plays: Record<string, number>;
|
|
26
|
+
};
|
|
27
|
+
export type RecipientSummary = {
|
|
28
|
+
rid: string;
|
|
29
|
+
label: string;
|
|
30
|
+
group: boolean;
|
|
31
|
+
revoked: boolean;
|
|
32
|
+
opened: boolean;
|
|
33
|
+
firstOpenAt: string | null;
|
|
34
|
+
cameBack: boolean;
|
|
35
|
+
activeSeconds: number;
|
|
36
|
+
plays: number;
|
|
37
|
+
devices: number;
|
|
38
|
+
comments: {
|
|
39
|
+
at: string;
|
|
40
|
+
text: string;
|
|
41
|
+
}[];
|
|
42
|
+
};
|
|
43
|
+
export interface RecipientsStore {
|
|
44
|
+
get(artifactId: string): Promise<RecipientsRecord | null>;
|
|
45
|
+
add(artifactId: string, input: {
|
|
46
|
+
label: string;
|
|
47
|
+
note?: string;
|
|
48
|
+
owner?: boolean;
|
|
49
|
+
group?: boolean;
|
|
50
|
+
}[]): Promise<Recipient[]>;
|
|
51
|
+
revoke(artifactId: string, rids: string[] | '*'): Promise<string[]>;
|
|
52
|
+
setExpiry(artifactId: string, expireAt: string | null): Promise<void>;
|
|
53
|
+
useToken(key: string, expiresAt: number): Promise<boolean>;
|
|
54
|
+
recordView(v: {
|
|
55
|
+
artifactId: string;
|
|
56
|
+
rid: string;
|
|
57
|
+
session: string;
|
|
58
|
+
device: string;
|
|
59
|
+
at: string;
|
|
60
|
+
addSeconds: number;
|
|
61
|
+
play?: string;
|
|
62
|
+
}): Promise<void>;
|
|
63
|
+
views(artifactId: string): Promise<ViewDoc[]>;
|
|
64
|
+
expiring(nowIso: string): Promise<string[]>;
|
|
65
|
+
forget(artifactId: string): Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
export declare const CAME_BACK_MS = 3600000;
|
|
68
|
+
export declare function memoryRecipientsStore(): RecipientsStore;
|
|
69
|
+
export declare function createRecipientsStore(db: Firestore, base: string): RecipientsStore;
|
|
70
|
+
/** One line per reader (never the owner), in the order they were added. */
|
|
71
|
+
export declare function summarizeViews(rec: RecipientsRecord, views: ViewDoc[], comments: {
|
|
72
|
+
readerKey: string;
|
|
73
|
+
at: string;
|
|
74
|
+
text: string;
|
|
75
|
+
}[]): RecipientSummary[];
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { newRid } from './recipients.js';
|
|
2
|
+
export const CAME_BACK_MS = 3_600_000;
|
|
3
|
+
const LABEL_MAX = 60;
|
|
4
|
+
const NOTE_MAX = 2000;
|
|
5
|
+
function makeRecipients(input, taken) {
|
|
6
|
+
const at = new Date().toISOString();
|
|
7
|
+
return input.map((i) => {
|
|
8
|
+
const label = String(i.label ?? '').trim().slice(0, LABEL_MAX);
|
|
9
|
+
if (!label)
|
|
10
|
+
throw new Error('a reader needs a label');
|
|
11
|
+
let rid = newRid();
|
|
12
|
+
while (taken.has(rid))
|
|
13
|
+
rid = newRid();
|
|
14
|
+
taken.add(rid);
|
|
15
|
+
const note = typeof i.note === 'string' && i.note.trim() ? i.note.trim().slice(0, NOTE_MAX) : undefined;
|
|
16
|
+
return { rid, label, addedAt: at, ...(note ? { note } : {}), ...(i.owner ? { owner: true } : {}), ...(i.group ? { group: true } : {}) };
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
function revokeIn(rec, rids) {
|
|
20
|
+
const at = new Date().toISOString();
|
|
21
|
+
const hit = [];
|
|
22
|
+
for (const r of Object.values(rec.recipients)) {
|
|
23
|
+
if (r.revokedAt || (rids !== '*' && !rids.includes(r.rid)))
|
|
24
|
+
continue;
|
|
25
|
+
r.revokedAt = at;
|
|
26
|
+
hit.push(r.rid);
|
|
27
|
+
}
|
|
28
|
+
if (rids === '*')
|
|
29
|
+
rec.revokedAll = at;
|
|
30
|
+
return hit;
|
|
31
|
+
}
|
|
32
|
+
function addView(prev, v) {
|
|
33
|
+
const doc = prev ? { ...prev, plays: { ...prev.plays } } : { artifactId: v.artifactId, rid: v.rid, session: v.session, device: v.device, firstAt: v.at, lastAt: v.at, activeSeconds: 0, plays: {} };
|
|
34
|
+
doc.activeSeconds += Math.max(0, Math.min(60, v.addSeconds));
|
|
35
|
+
if (v.at > doc.lastAt)
|
|
36
|
+
doc.lastAt = v.at;
|
|
37
|
+
if (v.play)
|
|
38
|
+
doc.plays[v.play] = (doc.plays[v.play] ?? 0) + 1;
|
|
39
|
+
return doc;
|
|
40
|
+
}
|
|
41
|
+
const viewId = (artifactId, rid, session) => `${artifactId}__${rid}__${session}`;
|
|
42
|
+
export function memoryRecipientsStore() {
|
|
43
|
+
const recs = new Map();
|
|
44
|
+
const views = new Map();
|
|
45
|
+
const tokens = new Set();
|
|
46
|
+
return {
|
|
47
|
+
async get(id) { const r = recs.get(id); return r ? structuredClone(r) : null; },
|
|
48
|
+
async add(id, input) {
|
|
49
|
+
const rec = recs.get(id) ?? { recipients: {}, expireAt: null, keep: false };
|
|
50
|
+
const out = makeRecipients(input, new Set(Object.keys(rec.recipients)));
|
|
51
|
+
for (const r of out)
|
|
52
|
+
rec.recipients[r.rid] = r;
|
|
53
|
+
recs.set(id, rec);
|
|
54
|
+
return structuredClone(out);
|
|
55
|
+
},
|
|
56
|
+
async revoke(id, rids) { const rec = recs.get(id); return rec ? revokeIn(rec, rids) : []; },
|
|
57
|
+
async setExpiry(id, expireAt) {
|
|
58
|
+
const rec = recs.get(id) ?? { recipients: {}, expireAt: null, keep: false };
|
|
59
|
+
rec.expireAt = expireAt;
|
|
60
|
+
rec.keep = expireAt === null;
|
|
61
|
+
recs.set(id, rec);
|
|
62
|
+
},
|
|
63
|
+
async useToken(key) { if (tokens.has(key))
|
|
64
|
+
return false; tokens.add(key); return true; },
|
|
65
|
+
async recordView(v) { const k = viewId(v.artifactId, v.rid, v.session); views.set(k, addView(views.get(k), v)); },
|
|
66
|
+
async views(id) { return [...views.values()].filter((v) => v.artifactId === id).map((v) => structuredClone(v)); },
|
|
67
|
+
async expiring(now) { return [...recs.entries()].filter(([, r]) => r.expireAt && r.expireAt <= now).map(([id]) => id); },
|
|
68
|
+
async forget(id) { recs.delete(id); for (const k of [...views.keys()])
|
|
69
|
+
if (k.startsWith(`${id}__`))
|
|
70
|
+
views.delete(k); },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function createRecipientsStore(db, base) {
|
|
74
|
+
const recsCol = () => db.collection(`${base}Recipients`);
|
|
75
|
+
const viewsCol = () => db.collection(`${base}RecipientViews`);
|
|
76
|
+
const tokensCol = () => db.collection(`${base}RecipientTokens`);
|
|
77
|
+
const read = async (id) => {
|
|
78
|
+
const snap = await recsCol().doc(id).get();
|
|
79
|
+
if (!snap.exists)
|
|
80
|
+
return null;
|
|
81
|
+
const d = snap.data();
|
|
82
|
+
return { recipients: d.recipients ?? {}, expireAt: d.expireAt ?? null, keep: d.keep === true, ...(d.revokedAll ? { revokedAll: d.revokedAll } : {}) };
|
|
83
|
+
};
|
|
84
|
+
return {
|
|
85
|
+
get: read,
|
|
86
|
+
async add(id, input) {
|
|
87
|
+
const rec = (await read(id)) ?? { recipients: {}, expireAt: null, keep: false };
|
|
88
|
+
const out = makeRecipients(input, new Set(Object.keys(rec.recipients)));
|
|
89
|
+
// Write only the readers: a merge deep-merges the map and leaves expireAt/keep alone, so a
|
|
90
|
+
// Keep that landed after the read above is never put back to keep:false.
|
|
91
|
+
const patch = { recipients: Object.fromEntries(out.map((r) => [r.rid, r])) };
|
|
92
|
+
await recsCol().doc(id).set(patch, { merge: true });
|
|
93
|
+
return out;
|
|
94
|
+
},
|
|
95
|
+
async revoke(id, rids) {
|
|
96
|
+
const rec = await read(id);
|
|
97
|
+
if (!rec)
|
|
98
|
+
return [];
|
|
99
|
+
const hit = revokeIn(rec, rids);
|
|
100
|
+
if (!hit.length && rids !== '*')
|
|
101
|
+
return hit;
|
|
102
|
+
const patch = { recipients: Object.fromEntries(hit.map((rid) => [rid, { revokedAt: rec.recipients[rid].revokedAt }])) };
|
|
103
|
+
if (rec.revokedAll)
|
|
104
|
+
patch.revokedAll = rec.revokedAll;
|
|
105
|
+
await recsCol().doc(id).set(patch, { merge: true });
|
|
106
|
+
return hit;
|
|
107
|
+
},
|
|
108
|
+
async setExpiry(id, expireAt) {
|
|
109
|
+
await recsCol().doc(id).set({ expireAt, keep: expireAt === null }, { merge: true });
|
|
110
|
+
},
|
|
111
|
+
async useToken(key, expiresAt) {
|
|
112
|
+
// A Date is a Firestore Timestamp, which is what a TTL policy on `expireAt` reads.
|
|
113
|
+
try {
|
|
114
|
+
await tokensCol().doc(key).create({ expireAt: new Date(expiresAt) });
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
if (e.code === 6)
|
|
119
|
+
return false;
|
|
120
|
+
throw e;
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
async recordView(v) {
|
|
124
|
+
const ref = viewsCol().doc(viewId(v.artifactId, v.rid, v.session));
|
|
125
|
+
const snap = await ref.get();
|
|
126
|
+
await ref.set(addView(snap.exists ? snap.data() : undefined, v));
|
|
127
|
+
},
|
|
128
|
+
async views(id) {
|
|
129
|
+
const snap = await viewsCol().where('artifactId', '==', id).get();
|
|
130
|
+
return snap.docs.map((d) => d.data());
|
|
131
|
+
},
|
|
132
|
+
async expiring(now) {
|
|
133
|
+
const snap = await recsCol().where('expireAt', '<=', now).get();
|
|
134
|
+
return snap.docs.filter((d) => d.data().expireAt).map((d) => d.id);
|
|
135
|
+
},
|
|
136
|
+
async forget(id) {
|
|
137
|
+
await recsCol().doc(id).delete();
|
|
138
|
+
const snap = await viewsCol().where('artifactId', '==', id).get();
|
|
139
|
+
for (const d of snap.docs)
|
|
140
|
+
await d.ref.delete();
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/** One line per reader (never the owner), in the order they were added. */
|
|
145
|
+
export function summarizeViews(rec, views, comments) {
|
|
146
|
+
return Object.values(rec.recipients).filter((r) => !r.owner).sort((a, b) => a.addedAt.localeCompare(b.addedAt)).map((r) => {
|
|
147
|
+
const mine = views.filter((v) => v.rid === r.rid).sort((a, b) => a.firstAt.localeCompare(b.firstAt));
|
|
148
|
+
const firsts = mine.map((v) => Date.parse(v.firstAt));
|
|
149
|
+
return {
|
|
150
|
+
rid: r.rid, label: r.label, group: r.group === true, revoked: !!r.revokedAt,
|
|
151
|
+
opened: mine.length > 0,
|
|
152
|
+
firstOpenAt: mine[0]?.firstAt ?? null,
|
|
153
|
+
cameBack: firsts.length > 1 && firsts[firsts.length - 1] - firsts[0] >= CAME_BACK_MS,
|
|
154
|
+
activeSeconds: mine.reduce((n, v) => n + v.activeSeconds, 0),
|
|
155
|
+
plays: mine.reduce((n, v) => n + Object.values(v.plays).reduce((a, b) => a + b, 0), 0),
|
|
156
|
+
devices: new Set(mine.map((v) => v.device)).size,
|
|
157
|
+
comments: comments.filter((c) => c.readerKey === `r:${r.rid}`).map((c) => ({ at: c.at, text: c.text })),
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export declare const RID_RE: RegExp;
|
|
2
|
+
export declare const RID_COOKIE_MAX_AGE = 7776000;
|
|
3
|
+
export declare const VIEW_AFTER_MS = 2000;
|
|
4
|
+
export declare const TOKEN_MAX_AGE_MS = 86400000;
|
|
5
|
+
/** A signing secret shorter than this is treated as unset by the routes: the page shuts. */
|
|
6
|
+
export declare const RECIPIENT_SECRET_MIN = 32;
|
|
7
|
+
export declare function usableSecret(secret: string | null | undefined): string | undefined;
|
|
8
|
+
export declare function newRid(): string;
|
|
9
|
+
export declare function ridSig(secret: string, id: string, rid: string): string;
|
|
10
|
+
export declare function ridLink(pageUrl: string, id: string, rid: string, secret: string): string;
|
|
11
|
+
export declare function parseRidParam(v: string | null | undefined): {
|
|
12
|
+
rid: string;
|
|
13
|
+
sig: string;
|
|
14
|
+
} | null;
|
|
15
|
+
export declare function verifyRid(secret: string, id: string, rid: string, sig: string): boolean;
|
|
16
|
+
export declare function ridCookieName(id: string): string;
|
|
17
|
+
/** `<time base36>.<nonce 16 hex>.<mac 16 hex>`, minted once per server render. */
|
|
18
|
+
export declare function mintRenderToken(secret: string, id: string, rid: string, now?: number): string;
|
|
19
|
+
export declare function verifyRenderToken(secret: string, id: string, rid: string, token: string, now?: number): {
|
|
20
|
+
ok: true;
|
|
21
|
+
key: string;
|
|
22
|
+
expiresAt: number;
|
|
23
|
+
} | {
|
|
24
|
+
ok: false;
|
|
25
|
+
why: string;
|
|
26
|
+
};
|
|
27
|
+
export declare function beatKey(secret: string, id: string, rid: string, session: string): string;
|
|
28
|
+
/** Messaging apps' and search engines' preview fetchers. Apple's LinkPresentation presents as
|
|
29
|
+
* `facebookexternalhit/1.1 Facebot Twitterbot/1.0`. A request with no user agent is treated as one. */
|
|
30
|
+
export declare const UNFURL_AGENTS: RegExp;
|
|
31
|
+
export declare function isUnfurlAgent(ua: string | null | undefined): boolean;
|