@supersuit/artifacts 0.34.0 → 0.36.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 +53 -0
- package/README.md +50 -1
- package/lib/artifacts/assets.d.ts +29 -1
- package/lib/artifacts/assets.js +54 -2
- package/lib/artifacts/front-matter.d.ts +4 -0
- package/lib/artifacts/front-matter.js +19 -1
- package/lib/artifacts/index.d.ts +3 -1
- package/lib/artifacts/index.js +4 -1
- 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/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 +29 -0
- package/lib/routes/artifacts.js +65 -9
- 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 +5 -0
- package/lib/routes/state-routes.js +37 -15
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,59 @@
|
|
|
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.36.0
|
|
7
|
+
|
|
8
|
+
**Private media moves to its own private bucket, and a host without one refuses it.** 0.35.0 kept
|
|
9
|
+
`media: private` files under `<prefix>/<id>/private/` in the same bucket as public assets and
|
|
10
|
+
never called `makePublic` on them. That is no protection on a bucket that carries a bucket-wide
|
|
11
|
+
public read grant: every "private" object was readable by anyone holding its URL.
|
|
12
|
+
|
|
13
|
+
- `createArtifactAssets(bucket, prefix, { privateBucket })`. With `privateBucket`, `putPrivate`,
|
|
14
|
+
`signPrivateUpload`, `finishPrivateUpload` and `privateUrl` use it (same `<prefix>/<id>/private/`
|
|
15
|
+
keys); `put`, `signUpload`, `finishUpload`, covers and narration stay on `bucket`. `deletePage`
|
|
16
|
+
clears the page from both. Passing the public bucket again as the private one throws.
|
|
17
|
+
- Without `privateBucket` the four private methods are not defined, so the host fails closed:
|
|
18
|
+
`POST /api/artifacts` refuses a page with `media: private` (or a republish of a page that already
|
|
19
|
+
keeps private media) with 501 and saves nothing, and `?private=1` uploads answer 501, all with
|
|
20
|
+
the same words naming `privateBucket`.
|
|
21
|
+
- `PRIVATE_READ_TTL_MS` (exported, `60 * 60 * 1000`): the life of a signed V4 read URL. One is
|
|
22
|
+
minted per private file on every render that passed the per-reader gate, and none for a revoked
|
|
23
|
+
or expired link.
|
|
24
|
+
- DETECTOR: your host passes `media: private` pages and calls `createArtifactAssets(bucket, prefix)`
|
|
25
|
+
with two arguments; or `gcloud storage buckets get-iam-policy` on your bucket lists `allUsers`
|
|
26
|
+
and you have objects under `*/private/`.
|
|
27
|
+
- REMEDY: create a separate bucket (uniform access, public access prevention enforced, object
|
|
28
|
+
access for the host's service account only), pass it as `privateBucket`, give it CORS for `PUT`
|
|
29
|
+
from your origin if browsers upload to it directly, and republish any page whose private media
|
|
30
|
+
sits in the public bucket (then delete those old objects). A host that publishes no private
|
|
31
|
+
media needs nothing; until it adds a private bucket, `media: private` is refused.
|
|
32
|
+
- TESTS: `src/artifacts/assets.test.ts` (private media), `src/routes/private-media.test.tsx`
|
|
33
|
+
(publishing media: private), `src/routes/per-reader.test.tsx` (mints a fresh signed URL on every
|
|
34
|
+
passing render), `src/artifacts/index.test.ts`.
|
|
35
|
+
|
|
36
|
+
## 0.35.0
|
|
37
|
+
|
|
38
|
+
**Per-reader links, private media, video with sound, quiet views, and expiry.** A page can now go
|
|
39
|
+
to several people at once with one link each, revocable one at a time, with a tailored note per
|
|
40
|
+
reader.
|
|
41
|
+
|
|
42
|
+
- `links: per-reader` in front matter: the page opens only through a link made for one reader
|
|
43
|
+
(`?r=<rid>.<sig>`, signed with the host's recipient secret). Each link can be revoked alone.
|
|
44
|
+
`createRecipientsStore(db, base)` and `memoryRecipientsStore()` hold the readers; the routes are
|
|
45
|
+
`RECIPIENTS` (GET and POST: add, revoke, set a note), `VIEWS` (GET) and `SEEN` (POST).
|
|
46
|
+
- `media: private`: body media is stored under the page's private prefix and served only by signed
|
|
47
|
+
URL to a valid reader. The cover stays public so the share card works.
|
|
48
|
+
- Video with sound: `` renders a clip with controls. `.m4a` voice notes
|
|
49
|
+
are supported.
|
|
50
|
+
- A quiet view beacon. DETECTOR: an unfurl fetch or a background tab used to count as an open.
|
|
51
|
+
REMEDY: a view is the page visible for two seconds with a single-use render token; known unfurl
|
|
52
|
+
agents and the owner are dropped server side. TESTS: `src/routes/seen.test.tsx`,
|
|
53
|
+
`src/reader/quiet-view.test.tsx`.
|
|
54
|
+
- `sweepExpired()` for dated pages: deletes the page, its media, readers, views and comments, and
|
|
55
|
+
returns `{ pages, files, skipped, failed }`. Call it daily from a cron route.
|
|
56
|
+
- Config on `createArtifactRoutes`: `recipients` and `recipientSecret` (a function; at least 32
|
|
57
|
+
characters, shorter is treated as unset).
|
|
58
|
+
|
|
6
59
|
## 0.34.0
|
|
7
60
|
|
|
8
61
|
**Commenters hear when their comments are resolved, in one email per page per burst** (Gary,
|
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ Firestore), and optionally `@google-cloud/storage >= 7` for uploaded files.
|
|
|
22
22
|
| `createArtifactRoutes(config)` | Every route handler: the page, its metadata, the publish API, the share card, the reader sign-in exchange, access control |
|
|
23
23
|
| `createArtifactStore(db, path)` | Firestore store for pages and their version history, under any collection path |
|
|
24
24
|
| `createReadersStore(db, path)` | The record for confidential pages: allowlist, agreements, reading sessions, flags |
|
|
25
|
-
| `createArtifactAssets(bucket, prefix)` | Uploaded images for a page, in a Cloud Storage bucket |
|
|
25
|
+
| `createArtifactAssets(bucket, prefix, { privateBucket? })` | Uploaded images for a page, in a Cloud Storage bucket; private media in a separate private bucket |
|
|
26
26
|
| `freedomDefault`, `BrandPack` | The default look and the type any other look implements |
|
|
27
27
|
| `BrandGround`, `BrandMark` | The pack's page backdrop and mark, for your own pages (a home, a 404) |
|
|
28
28
|
| `parseArtifactSource` | The front-matter contract, as a parser you can call before publishing (widget fences included) |
|
|
@@ -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
|
|
|
@@ -830,6 +832,52 @@ composite index: **`slot` ascending, `at` ascending**, collection scope. Without
|
|
|
830
832
|
refuses the query and the error names a link that creates the index. A host with its own
|
|
831
833
|
`StateStore` implements `slotSince(slot, after, limit)`; without it the feed answers 501.
|
|
832
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
|
+
### Private media lives in its own bucket
|
|
853
|
+
|
|
854
|
+
`media: private` needs a private store: `createArtifactAssets(bucket, prefix, { privateBucket })`. Private files go to `privateBucket` under `<prefix>/<id>/private/`; covers, images and narration stay on the public `bucket`. Without `privateBucket` the private methods are not defined, and the host refuses private media with 501 at every door: publishing a page with `media: private` (or republishing a page that already keeps it), and `?private=1` uploads. Nothing is saved. This is on purpose: a public bucket often carries a bucket-wide public read grant, so an object written there is readable by anyone who has its URL, whatever the object's own settings say.
|
|
855
|
+
|
|
856
|
+
The private bucket must be a different bucket from the public one (passing the same bucket twice throws), with uniform bucket-level access, public access prevention enforced, and object access granted only to the host's own service account. The page signs a V4 read URL per private file on every render that passed the per-reader gate, valid `PRIVATE_READ_TTL_MS` (one hour); a revoked or expired link renders nothing and mints nothing. A reader revoked after a render can keep loading a URL already handed to them for at most that hour. Expiry's `deletePage` clears the page from both buckets.
|
|
857
|
+
|
|
858
|
+
If the publisher uploads straight to the private bucket from a browser (`?private=1` on `UPLOAD`), give that bucket a CORS rule allowing `PUT` from your origin with the `content-type` and `cache-control` headers; a reader's browser playing a signed URL needs `GET` (and `HEAD`) from your origin only if your player fetches it with CORS.
|
|
859
|
+
|
|
860
|
+
Routes (spread into your route files by `createArtifactRoutes`): `RECIPIENTS`, `VIEWS`, `SEEN`, and `sweepExpired`.
|
|
861
|
+
|
|
862
|
+
| Route | Who | What |
|
|
863
|
+
|---|---|---|
|
|
864
|
+
| `GET /api/artifacts/<id>/recipients` | publish key | Every reader, with the working link for each |
|
|
865
|
+
| `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 |
|
|
866
|
+
| `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 |
|
|
867
|
+
| `POST /api/artifacts/<id>/seen` | the page itself (public) | The reader's own signal, below |
|
|
868
|
+
|
|
869
|
+
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.
|
|
870
|
+
|
|
871
|
+
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.
|
|
872
|
+
|
|
873
|
+
### What counts as a view
|
|
874
|
+
|
|
875
|
+
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.
|
|
876
|
+
|
|
877
|
+
### Expiry
|
|
878
|
+
|
|
879
|
+
`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. With a private bucket configured it deletes from both buckets. A Keep that lands mid-sweep is honored. Deleting a page also removes every prior version of its body.
|
|
880
|
+
|
|
833
881
|
## Brand packs
|
|
834
882
|
|
|
835
883
|
A `BrandPack` is data plus at most two components: colours, type, the kicker line above a title,
|
|
@@ -881,6 +929,7 @@ the page's pack.
|
|
|
881
929
|
| your publish key (named by you, read in `publishKey`) | Authorises publish, delete and access changes |
|
|
882
930
|
| `ARTIFACT_PASS_SECRET` (named by you, read in `readerSecret`) | Verifies reader passes and signs grants; shared with your sign-in side |
|
|
883
931
|
| a publisher-pass secret (named by you, read in `publisherSecret`; may be the same one) | Verifies publisher passes; shared with whatever mints them |
|
|
932
|
+
| 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 |
|
|
884
933
|
| Firebase Admin credentials | The store and readers store |
|
|
885
934
|
|
|
886
935
|
## Contributing
|
|
@@ -25,9 +25,37 @@ 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 V4 signed read URL for a stored private name, valid PRIVATE_READ_TTL_MS (one hour). */
|
|
39
|
+
privateUrl?(id: string, stored: string): Promise<string>;
|
|
40
|
+
/** Delete every object under `<prefix>/<id>/`, in the public bucket and the private one; 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;
|
|
47
|
+
/** How long a signed read URL for private media stays valid: one hour. A reader revoked after a
|
|
48
|
+
* render can keep loading a URL already handed to them for at most this long. */
|
|
49
|
+
export declare const PRIVATE_READ_TTL_MS: number;
|
|
50
|
+
export interface ArtifactAssetsOptions {
|
|
51
|
+
/**
|
|
52
|
+
* The bucket private media lives in. It must be a different bucket from the public one, with
|
|
53
|
+
* uniform access, public access prevention enforced, and object access granted only to the
|
|
54
|
+
* host's own service account. Without it the private methods are not defined at all, so a
|
|
55
|
+
* `media: private` page is refused instead of being stored where anyone could read it by URL.
|
|
56
|
+
*/
|
|
57
|
+
privateBucket?: Bucket;
|
|
58
|
+
}
|
|
31
59
|
/** Names are one path segment: letters, digits, dot, dash, underscore. */
|
|
32
60
|
export declare const ASSET_NAME: RegExp;
|
|
33
61
|
export declare const ASSET_TYPES: Record<string, string>;
|
|
@@ -51,4 +79,4 @@ export declare function contentTypeFor(name: string): string | undefined;
|
|
|
51
79
|
export declare function hashedName(name: string, bytes: Buffer): string;
|
|
52
80
|
/** `name` with `digest` spliced in before the extension, the way hashedName stores it. */
|
|
53
81
|
export declare function storedName(name: string, digest: string): string;
|
|
54
|
-
export declare function createArtifactAssets(bucket: Bucket, prefix: string): ArtifactAssets;
|
|
82
|
+
export declare function createArtifactAssets(bucket: Bucket, prefix: string, options?: ArtifactAssetsOptions): ArtifactAssets;
|
package/lib/artifacts/assets.js
CHANGED
|
@@ -4,9 +4,15 @@
|
|
|
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';
|
|
13
|
+
/** How long a signed read URL for private media stays valid: one hour. A reader revoked after a
|
|
14
|
+
* render can keep loading a URL already handed to them for at most this long. */
|
|
15
|
+
export const PRIVATE_READ_TTL_MS = 60 * 60 * 1000;
|
|
10
16
|
/** Names are one path segment: letters, digits, dot, dash, underscore. */
|
|
11
17
|
export const ASSET_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/;
|
|
12
18
|
export const ASSET_TYPES = {
|
|
@@ -16,6 +22,7 @@ export const ASSET_TYPES = {
|
|
|
16
22
|
jpeg: 'image/jpeg',
|
|
17
23
|
gif: 'image/gif',
|
|
18
24
|
mp3: 'audio/mpeg',
|
|
25
|
+
m4a: 'audio/mp4',
|
|
19
26
|
mp4: 'video/mp4',
|
|
20
27
|
json: 'application/json',
|
|
21
28
|
};
|
|
@@ -48,9 +55,13 @@ export function storedName(name, digest) {
|
|
|
48
55
|
// A name with no extension is legal here, so append rather than assume there is a dot.
|
|
49
56
|
return dot <= 0 ? `${name}.${digest}` : `${name.slice(0, dot)}.${digest}${name.slice(dot)}`;
|
|
50
57
|
}
|
|
51
|
-
export function createArtifactAssets(bucket, prefix) {
|
|
58
|
+
export function createArtifactAssets(bucket, prefix, options = {}) {
|
|
52
59
|
const clean = prefix.replace(/^\/+|\/+$/g, '');
|
|
53
|
-
|
|
60
|
+
const priv = options.privateBucket;
|
|
61
|
+
// The same bucket twice would put "private" media back where a public read grant reaches it.
|
|
62
|
+
if (priv && priv.name === bucket.name)
|
|
63
|
+
throw new Error('the private bucket must be a different bucket from the public one');
|
|
64
|
+
const assets = {
|
|
54
65
|
async put(id, name, bytes, contentType) {
|
|
55
66
|
const key = `${clean}/${id}/${hashedName(name, bytes)}`;
|
|
56
67
|
const file = bucket.file(key);
|
|
@@ -131,5 +142,46 @@ export function createArtifactAssets(bucket, prefix) {
|
|
|
131
142
|
const [url] = await bucket.file(`${clean}/${id}/${path}`).getSignedUrl({ version: 'v4', action: 'read', expires: Date.now() + 60 * 60 * 1000 });
|
|
132
143
|
return url;
|
|
133
144
|
},
|
|
145
|
+
async deletePage(id) {
|
|
146
|
+
// An empty or path-shaped id would make the prefix wider than one page.
|
|
147
|
+
if (!ARTIFACT_ID_RE.test(id))
|
|
148
|
+
throw new Error('not an artifact id');
|
|
149
|
+
let n = 0;
|
|
150
|
+
for (const b of priv ? [bucket, priv] : [bucket]) {
|
|
151
|
+
const [files] = await b.getFiles({ prefix: `${clean}/${id}/` });
|
|
152
|
+
for (const f of files)
|
|
153
|
+
await f.delete();
|
|
154
|
+
n += files.length;
|
|
155
|
+
}
|
|
156
|
+
return n;
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
if (!priv)
|
|
160
|
+
return assets;
|
|
161
|
+
const privateFile = (id, stored) => priv.file(`${clean}/${id}/private/${stored}`);
|
|
162
|
+
return {
|
|
163
|
+
...assets,
|
|
164
|
+
async putPrivate(id, name, bytes, contentType) {
|
|
165
|
+
const stored = hashedName(name, bytes);
|
|
166
|
+
await privateFile(id, stored).save(bytes, { contentType, resumable: false, metadata: { cacheControl: 'private, max-age=3600' } });
|
|
167
|
+
return `private:${stored}`;
|
|
168
|
+
},
|
|
169
|
+
async signPrivateUpload(id, name, digest, contentType) {
|
|
170
|
+
const stored = storedName(name, digest);
|
|
171
|
+
const headers = { 'content-type': contentType, 'cache-control': 'private, max-age=3600' };
|
|
172
|
+
const [uploadUrl] = await privateFile(id, stored).getSignedUrl({ version: 'v4', action: 'write', expires: Date.now() + 15 * 60 * 1000, contentType, extensionHeaders: { 'cache-control': headers['cache-control'] } });
|
|
173
|
+
return { uploadUrl, headers, url: `private:${stored}` };
|
|
174
|
+
},
|
|
175
|
+
async finishPrivateUpload(id, name, digest) {
|
|
176
|
+
const stored = storedName(name, digest);
|
|
177
|
+
const [there] = await privateFile(id, stored).exists();
|
|
178
|
+
return there ? `private:${stored}` : null;
|
|
179
|
+
},
|
|
180
|
+
async privateUrl(id, stored) {
|
|
181
|
+
if (!PRIVATE_REF.test(`private:${stored}`))
|
|
182
|
+
throw new Error('not a private file name');
|
|
183
|
+
const [url] = await privateFile(id, stored).getSignedUrl({ version: 'v4', action: 'read', expires: Date.now() + PRIVATE_READ_TTL_MS });
|
|
184
|
+
return url;
|
|
185
|
+
},
|
|
134
186
|
};
|
|
135
187
|
}
|
|
@@ -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
|
@@ -4,7 +4,7 @@ export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narra
|
|
|
4
4
|
export { createArtifactStore, newArtifactId, cleanNote, MAX_CHANGE_NOTE_CHARS, type ArtifactStore, type ArtifactRecord, type SaveResult, type SaveInput, type VersionEntry, type VersionRecord } from './store.js';
|
|
5
5
|
export { isPublishAuthed, isPublisherAuthed, type PublishAuth } from './auth.js';
|
|
6
6
|
export { mintPublisherPass, verifyPublisherPass, PUBLISHER_PASS_TTL_SECONDS, type Publisher } from './publisher.js';
|
|
7
|
-
export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST, type ArtifactAssets } from './assets.js';
|
|
7
|
+
export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST, PRIVATE_READ_TTL_MS, type ArtifactAssets, type ArtifactAssetsOptions } from './assets.js';
|
|
8
8
|
export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
|
|
9
9
|
export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS, type Access, type Reader, type AllowEntry, type Decision } from './reader.js';
|
|
10
10
|
export { createReadersStore, summarize, FLAG_KINDS, type ReadersStore, type FlagKind, type SessionDoc, type FlagDoc, type ReaderSummary } from './readers-store.js';
|
|
@@ -24,3 +24,5 @@ export { deepgramTranscriber, openaiTranscriber, type Transcriber } from './tran
|
|
|
24
24
|
export { AUDIO_TYPES, audioExtFor, audioTypeFor, baseMime, MAX_AUDIO_BYTES, MAX_MEMO_MS, MEMO_NAME, commentAudioPath, isCommentAudioPath, personalAudioPath, isPersonalAudioPath } from './audio.js';
|
|
25
25
|
export { settleOnPublish, applySettingsPatch, withComments, declaredOf, passwordDigest, newPageKey, type DeclaredSettings, type LiveSettings, type SettingsPatch } from './settings.js';
|
|
26
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
|
@@ -4,7 +4,7 @@ export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narra
|
|
|
4
4
|
export { createArtifactStore, newArtifactId, cleanNote, MAX_CHANGE_NOTE_CHARS } from './store.js';
|
|
5
5
|
export { isPublishAuthed, isPublisherAuthed } from './auth.js';
|
|
6
6
|
export { mintPublisherPass, verifyPublisherPass, PUBLISHER_PASS_TTL_SECONDS } from './publisher.js';
|
|
7
|
-
export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST } from './assets.js';
|
|
7
|
+
export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST, PRIVATE_READ_TTL_MS } from './assets.js';
|
|
8
8
|
export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
|
|
9
9
|
export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS } from './reader.js';
|
|
10
10
|
export { createReadersStore, summarize, FLAG_KINDS } from './readers-store.js';
|
|
@@ -23,3 +23,6 @@ export { createPersonalStore, createMemoryPersonalStore, validatePersonal, perso
|
|
|
23
23
|
export { deepgramTranscriber, openaiTranscriber } from './transcribe.js';
|
|
24
24
|
export { AUDIO_TYPES, audioExtFor, audioTypeFor, baseMime, MAX_AUDIO_BYTES, MAX_MEMO_MS, MEMO_NAME, commentAudioPath, isCommentAudioPath, personalAudioPath, isPersonalAudioPath } from './audio.js';
|
|
25
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;
|