@supersuit/artifacts 0.52.0 → 0.54.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 +39 -0
- package/README.md +16 -2
- package/lib/artifacts/index.d.ts +1 -1
- package/lib/artifacts/recipients-store.d.ts +15 -0
- package/lib/artifacts/recipients-store.js +2 -1
- package/lib/reader/page-analytics.js +17 -4
- package/lib/routes/recipients-routes.d.ts +10 -1
- package/lib/routes/recipients-routes.js +47 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,45 @@
|
|
|
3
3
|
`@supersuit/artifacts`. One entry per version, newest first. Each entry says what changed, how a
|
|
4
4
|
host can tell whether it is affected (DETECTOR), what a host does about it (REMEDY), and the tests.
|
|
5
5
|
|
|
6
|
+
## 0.54.0 (2026-10-25)
|
|
7
|
+
|
|
8
|
+
**A page no longer records under a reader left on the browser by another page.** posthog-js keeps
|
|
9
|
+
the last identity it was given on an origin, and `PageAnalytics` never cleared it, so a browser that
|
|
10
|
+
opened one per-reader link sent every password or public page it opened afterward under that
|
|
11
|
+
reader's recipient id and label, and a per-reader page's init-time `$pageview` went out under the
|
|
12
|
+
previous reader before `identify()` switched it.
|
|
13
|
+
|
|
14
|
+
- `PageAnalytics` calls `posthog.reset()` when the browser is identified as anyone other than the
|
|
15
|
+
page's own reader, before `register` (reset clears registered properties). An anonymous browser
|
|
16
|
+
is not reset, so its first visit still joins the reader it becomes; the device id survives.
|
|
17
|
+
- `capture_pageview` is now `false`; the page captures `$pageview` itself once the reader is settled.
|
|
18
|
+
- DETECTOR: in PostHog, a `password` or `public` `page_kind` event whose `distinct_id` starts with
|
|
19
|
+
`recipient:`, or whose person has a `name`, came from a host on an older version.
|
|
20
|
+
- REMEDY: update the host. Events already recorded keep the leftover identity; a dashboard should
|
|
21
|
+
name a reader only by an id of the page's own kind (a recipient id on `per-reader`, an email on
|
|
22
|
+
`gated`) and count everything else by device.
|
|
23
|
+
- Tests: `page-analytics-identity.test.tsx` mounts the component against a stand-in posthog-js
|
|
24
|
+
holding a leftover identity and checks the call order on every page kind.
|
|
25
|
+
|
|
26
|
+
## 0.53.0 (2026-10-25)
|
|
27
|
+
|
|
28
|
+
**Every per-reader link can record who minted it, on whose authority, when, and the funnel step it
|
|
29
|
+
was sent for.** Superlinks: when an operator's agent sends a page, the link it makes names the
|
|
30
|
+
person it is for, and the founders asked that it also name who made it ("<person>'s Pluma, on
|
|
31
|
+
<person>'s explicit request, <time>"), so that a funnel move citing the link's opens has
|
|
32
|
+
provenance rather than a typed claim.
|
|
33
|
+
|
|
34
|
+
- `POST /recipients` `add` entries take `provenance: { actor, authority, at?, link?, funnel?, stage? }`.
|
|
35
|
+
`at` defaults to now; a missing actor or authority, a malformed time, a non-https link, a funnel
|
|
36
|
+
that is not a slug, or a stage without its funnel is refused (400) and nothing is added.
|
|
37
|
+
- `add`'s `added`, `GET /recipients` and `GET /views` carry `provenance` per reader (`null` when
|
|
38
|
+
none). Stored on the reader's row by both stores; `Provenance` is exported with the store types.
|
|
39
|
+
- DETECTOR: a sender that posts `provenance` and reads `added[i].provenance` back as missing or
|
|
40
|
+
`undefined` is talking to a host on 0.52.x or older, which drops the field.
|
|
41
|
+
- REMEDY: bump. Links made before this carry `provenance: null`; nothing is migrated.
|
|
42
|
+
- Tests: recipients-api.test.tsx (provenance kept and answered by add, the listing and views;
|
|
43
|
+
stamped now when no time is given; each malformed shape refused with 400).
|
|
44
|
+
|
|
6
45
|
## 0.52.0 (2026-10-25)
|
|
7
46
|
|
|
8
47
|
**A render before the send is never counted as the reader opening the page.** A per-reader page
|
package/README.md
CHANGED
|
@@ -1037,8 +1037,8 @@ Routes (spread into your route files by `createArtifactRoutes`): `RECIPIENTS`, `
|
|
|
1037
1037
|
| Route | Who | What |
|
|
1038
1038
|
|---|---|---|
|
|
1039
1039
|
| `GET /api/artifacts/<id>/recipients` | publish key | Every reader, with the working link for each |
|
|
1040
|
-
| `POST /api/artifacts/<id>/recipients` | publish key | Body `{ add?: [{ label, note?, group?, closes? }], revoke?: [rid] or '*', expires?: ISO time or null, closes?: { rids: [rid] or '*', at: ISO time or null }, sent?: { rids: [rid] or '*', at?: ISO time } }`. `add` answers each new reader's link; `expires: null` is Keep; `closes` sets, moves or clears a reader's feedback window; `sent` records when links reached their readers (see A render before the send) |
|
|
1041
|
-
| `GET /api/artifacts/<id>/views` | publish key | One line per reader: opened, came back, active seconds, plays, devices, comments, `sentAt` and `
|
|
1040
|
+
| `POST /api/artifacts/<id>/recipients` | publish key | Body `{ add?: [{ label, note?, group?, closes?, provenance? }], revoke?: [rid] or '*', expires?: ISO time or null, closes?: { rids: [rid] or '*', at: ISO time or null }, sent?: { rids: [rid] or '*', at?: ISO time } }`. `add` answers each new reader's link and its `provenance` (see Who minted a link); `expires: null` is Keep; `closes` sets, moves or clears a reader's feedback window; `sent` records when links reached their readers (see A render before the send) |
|
|
1041
|
+
| `GET /api/artifacts/<id>/views` | publish key | One line per reader: opened, came back, active seconds, plays, devices, comments, `sentAt`, `beforeSend` and `provenance`. Each comment is `{ id, parent, at, text }` (see Comment ids in /views). A page with no readers yet answers 200 with an empty list; a missing page answers 404 |
|
|
1042
1042
|
| `POST /api/artifacts/<id>/seen` | the page itself (public) | The reader's own signal, below |
|
|
1043
1043
|
|
|
1044
1044
|
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.
|
|
@@ -1070,6 +1070,20 @@ fact reclassifies what is already there. A link with no `sentAt` is read exactly
|
|
|
1070
1070
|
cost, stated: a reader who opens the link within the seconds between delivery and the sender's
|
|
1071
1071
|
mark is counted as before-send; senders mark as soon as delivery is confirmed.
|
|
1072
1072
|
|
|
1073
|
+
### Who minted a link (0.53.0)
|
|
1074
|
+
|
|
1075
|
+
Every link can say who made it, on whose authority, and when: `add: [{ label, provenance: { actor,
|
|
1076
|
+
authority, at?, link?, funnel?, stage? } }]`. `actor` is a person acting by hand or "<person>'s
|
|
1077
|
+
Pluma"; `authority` names what they acted under ("approved by <person> on the send card", "on
|
|
1078
|
+
<person>'s explicit request", "under the recurrent <name>"), with `link` to the session or message
|
|
1079
|
+
where one exists (https only); `at` defaults to now. `funnel` (a slug) and `stage` (an id, only with
|
|
1080
|
+
its funnel) name the funnel step the link was sent for, so a funnel move that cites the link's opens
|
|
1081
|
+
has provenance. A provenance with no actor or no authority, or a malformed time, link, funnel or
|
|
1082
|
+
stage, is refused (400) and nothing is added. It is kept on the reader's row and answered back by
|
|
1083
|
+
`add`, `GET /recipients` and `GET /views` (`null` for a link made without one). A host on 0.52.x
|
|
1084
|
+
ignores the field, so a sender that needs it reads it back from `added` and treats `null` as an old
|
|
1085
|
+
host.
|
|
1086
|
+
|
|
1073
1087
|
### Expiry
|
|
1074
1088
|
|
|
1075
1089
|
`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.
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -26,6 +26,6 @@ export { AUDIO_TYPES, audioExtFor, audioTypeFor, baseMime, MAX_AUDIO_BYTES, MAX_
|
|
|
26
26
|
export { settleOnPublish, applySettingsPatch, withComments, declaredOf, passwordDigest, newPageKey, type DeclaredSettings, type LiveSettings, type SettingsPatch } from './settings.js';
|
|
27
27
|
export type { PageSettings, SettingsFact, AccessLevel } from '../reader/settings-sheet.js';
|
|
28
28
|
export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN, ridOfReaderKey } from './recipients.js';
|
|
29
|
-
export { createRecipientsStore, memoryRecipientsStore, summarizeViews, type RecipientsStore, type Recipient, type RecipientsRecord, type ViewDoc, type RecipientSummary } from './recipients-store.js';
|
|
29
|
+
export { createRecipientsStore, memoryRecipientsStore, summarizeViews, type RecipientsStore, type Recipient, type RecipientsRecord, type ViewDoc, type RecipientSummary, type Provenance } from './recipients-store.js';
|
|
30
30
|
export { migrateMemosToPrivate, MEMO_KEY, type MemoMigration } from './migrate-memos.js';
|
|
31
31
|
export { parseTranscriptFence, scanVoiceNotes, transcriptFenceFromWords, wordsFromWhisperJson, isAudioSrc, TRANSCRIPT_LANG, MAX_TRANSCRIPT_WORDS, type TranscriptParagraphs, type TimedWord, type VoiceNoteBlock } from './voice-note.js';
|
|
@@ -11,6 +11,19 @@ export type Recipient = {
|
|
|
11
11
|
revokedAt?: string;
|
|
12
12
|
closesAt?: string | null;
|
|
13
13
|
sentAt?: string;
|
|
14
|
+
provenance?: Provenance;
|
|
15
|
+
};
|
|
16
|
+
/** Who minted a link, on whose authority, and when (a superlink, 0.53.0): `actor` is a person by
|
|
17
|
+
* hand or "<person>'s Pluma"; `authority` is the request, recurrent or approval it acted under, with
|
|
18
|
+
* `link` to the session or message where one exists; `funnel` and `stage` name the funnel step the
|
|
19
|
+
* link was sent to move someone from, so a funnel move citing its opens has provenance. */
|
|
20
|
+
export type Provenance = {
|
|
21
|
+
actor: string;
|
|
22
|
+
authority: string;
|
|
23
|
+
at: string;
|
|
24
|
+
link?: string;
|
|
25
|
+
funnel?: string;
|
|
26
|
+
stage?: string;
|
|
14
27
|
};
|
|
15
28
|
export type RecipientsRecord = {
|
|
16
29
|
recipients: Record<string, Recipient>;
|
|
@@ -43,6 +56,7 @@ export type RecipientSummary = {
|
|
|
43
56
|
revoked: boolean;
|
|
44
57
|
closesAt: string | null;
|
|
45
58
|
sentAt: string | null;
|
|
59
|
+
provenance: Provenance | null;
|
|
46
60
|
beforeSend: BeforeSend;
|
|
47
61
|
opened: boolean;
|
|
48
62
|
firstOpenAt: string | null;
|
|
@@ -63,6 +77,7 @@ type NewRecipient = {
|
|
|
63
77
|
owner?: boolean;
|
|
64
78
|
group?: boolean;
|
|
65
79
|
closesAt?: string;
|
|
80
|
+
provenance?: Provenance;
|
|
66
81
|
};
|
|
67
82
|
export interface RecipientsStore {
|
|
68
83
|
get(artifactId: string): Promise<RecipientsRecord | null>;
|
|
@@ -15,7 +15,7 @@ function makeRecipients(input, taken) {
|
|
|
15
15
|
const note = typeof i.note === 'string' && i.note.trim() ? i.note.trim().slice(0, NOTE_MAX) : undefined;
|
|
16
16
|
// The owner never carries a window: setCloses skips them and the page hides the closed state
|
|
17
17
|
// from them, so one stored here would offer Share on their link and then refuse it.
|
|
18
|
-
return { rid, label, addedAt: at, ...(note ? { note } : {}), ...(i.owner ? { owner: true } : {}), ...(i.group ? { group: true } : {}), ...(typeof i.closesAt === 'string' && !i.owner ? { closesAt: i.closesAt } : {}) };
|
|
18
|
+
return { rid, label, addedAt: at, ...(note ? { note } : {}), ...(i.owner ? { owner: true } : {}), ...(i.group ? { group: true } : {}), ...(typeof i.closesAt === 'string' && !i.owner ? { closesAt: i.closesAt } : {}), ...(i.provenance ? { provenance: { ...i.provenance } } : {}) };
|
|
19
19
|
});
|
|
20
20
|
}
|
|
21
21
|
function revokeIn(rec, rids) {
|
|
@@ -212,6 +212,7 @@ export function summarizeViews(rec, views, comments) {
|
|
|
212
212
|
return {
|
|
213
213
|
rid: r.rid, label: r.label, group: r.group === true, revoked: !!r.revokedAt, closesAt: r.closesAt ?? null,
|
|
214
214
|
sentAt: r.sentAt ?? null,
|
|
215
|
+
provenance: r.provenance ?? null,
|
|
215
216
|
beforeSend: Number.isFinite(sentMs) ? { sessions: early.length, devices: preDevices.size, activeSeconds: early.reduce((n, v) => n + v.activeSeconds, 0), firstAt: early[0]?.firstAt ?? null } : null,
|
|
216
217
|
opened: mine.length > 0,
|
|
217
218
|
firstOpenAt: mine[0]?.firstAt ?? null,
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
// than a gated one: the replay still shows where the reader went, how far and how fast, without the
|
|
12
12
|
// words. Autocapture is told not to collect element text for the same reason. There is no opt-out:
|
|
13
13
|
// the options come from posthogInitOptions, which a test holds to RECORDING_MASK for every kind.
|
|
14
|
+
//
|
|
15
|
+
// posthog-js keeps the last identity it was given on this origin, and a page that names nobody does
|
|
16
|
+
// not clear it. So a browser that opened one reader's per-reader link reported every page it opened
|
|
17
|
+
// after under that reader's id and label, and a per-reader page's init-time view went out under the
|
|
18
|
+
// previous reader before identify switched it. The page now drops an identity it was not given
|
|
19
|
+
// before it records anything, and captures its own page view once it has settled who the reader is.
|
|
14
20
|
import { useEffect } from 'react';
|
|
15
21
|
import { currentSection } from '../artifacts/toc-spy.js';
|
|
16
22
|
/** Sections read for less than this are passed over, not read. */
|
|
@@ -65,7 +71,8 @@ export function posthogInitOptions(plan) {
|
|
|
65
71
|
return {
|
|
66
72
|
api_host: plan.host,
|
|
67
73
|
person_profiles: 'identified_only',
|
|
68
|
-
|
|
74
|
+
// Captured by the page once it has settled who the reader is (see PageAnalytics).
|
|
75
|
+
capture_pageview: false,
|
|
69
76
|
capture_pageleave: true,
|
|
70
77
|
autocapture: true,
|
|
71
78
|
// Autocapture records a clicked element's text; on these pages that text is the page.
|
|
@@ -85,11 +92,17 @@ export function PageAnalytics({ plan, rootId = 'artifact-narration-root' }) {
|
|
|
85
92
|
if (cancelled)
|
|
86
93
|
return;
|
|
87
94
|
posthog.init(key, posthogInitOptions({ host }));
|
|
95
|
+
const who = identity ? JSON.parse(identity) : null;
|
|
96
|
+
// A reader left on this browser by another page is dropped first: reset also clears the
|
|
97
|
+
// registered properties, so it comes before register. An anonymous browser is left alone, so
|
|
98
|
+
// its first visit still joins the reader it becomes.
|
|
99
|
+
const left = posthog.get_property('$user_state') === 'identified' ? posthog.get_distinct_id() : null;
|
|
100
|
+
if (left && left !== who?.distinctId)
|
|
101
|
+
posthog.reset();
|
|
88
102
|
posthog.register({ artifact_id: pageId, page_kind: kind, ...(tenant ? { tenant } : {}) });
|
|
89
|
-
if (
|
|
90
|
-
const who = JSON.parse(identity);
|
|
103
|
+
if (who)
|
|
91
104
|
posthog.identify(who.distinctId, { ...(who.email ? { email: who.email } : {}), ...(who.name ? { name: who.name } : {}) });
|
|
92
|
-
|
|
105
|
+
posthog.capture('$pageview');
|
|
93
106
|
const capture = (e, p) => posthog.capture(e, p);
|
|
94
107
|
const root = document.getElementById(rootId);
|
|
95
108
|
const ids = root ? Array.from(root.querySelectorAll('h2[id], h3[id]'), (h) => h.id) : [];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
|
-
import { type RecipientsStore } from '../artifacts/recipients-store.js';
|
|
2
|
+
import { type Provenance, type RecipientsStore } from '../artifacts/recipients-store.js';
|
|
3
3
|
import type { ArtifactStore } from '../artifacts/store.js';
|
|
4
4
|
import type { StateStore } from '../artifacts/state-store.js';
|
|
5
5
|
import type { ArtifactAssets } from '../artifacts/assets.js';
|
|
@@ -20,6 +20,15 @@ export type RecipientsRoutesCtx = {
|
|
|
20
20
|
/** Where the sweep reports a page it skipped or could not finish. Defaults to console.warn. */
|
|
21
21
|
log?: (message: string) => void;
|
|
22
22
|
};
|
|
23
|
+
/** A reader's provenance as sent, checked: who minted the link, on whose authority, when (default
|
|
24
|
+
* now), and the funnel step it serves. -> the clean record, or the reason it is refused. */
|
|
25
|
+
export declare function readProvenance(v: unknown, now?: Date): {
|
|
26
|
+
ok: true;
|
|
27
|
+
value: Provenance;
|
|
28
|
+
} | {
|
|
29
|
+
ok: false;
|
|
30
|
+
why: string;
|
|
31
|
+
};
|
|
23
32
|
export declare function createRecipientsRoutes(given: RecipientsRoutesCtx): {
|
|
24
33
|
SEEN: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
|
|
25
34
|
RECIPIENTS: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
|
|
@@ -8,6 +8,38 @@ import { ARTIFACT_ID_RE } from './ids.js';
|
|
|
8
8
|
const SESSION = /^[a-z0-9]{1,32}$/;
|
|
9
9
|
const MEDIA = /^[A-Za-z0-9][A-Za-z0-9._-]{0,140}$/;
|
|
10
10
|
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
11
|
+
const SLUG = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
12
|
+
/** A reader's provenance as sent, checked: who minted the link, on whose authority, when (default
|
|
13
|
+
* now), and the funnel step it serves. -> the clean record, or the reason it is refused. */
|
|
14
|
+
export function readProvenance(v, now = new Date()) {
|
|
15
|
+
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
16
|
+
return { ok: false, why: 'provenance is { actor, authority, at?, link?, funnel?, stage? }' };
|
|
17
|
+
const p = v;
|
|
18
|
+
const text = (x, max) => (typeof x === 'string' && x.trim() ? x.trim().slice(0, max) : '');
|
|
19
|
+
const actor = text(p.actor, 120), authority = text(p.authority, 300);
|
|
20
|
+
if (!actor || !authority)
|
|
21
|
+
return { ok: false, why: 'provenance needs who minted the link (actor) and on whose authority (authority)' };
|
|
22
|
+
if (p.at !== undefined && (typeof p.at !== 'string' || !ISO.test(p.at)))
|
|
23
|
+
return { ok: false, why: 'provenance.at must be an ISO time' };
|
|
24
|
+
let link;
|
|
25
|
+
if (p.link !== undefined) {
|
|
26
|
+
try {
|
|
27
|
+
const u = new URL(String(p.link));
|
|
28
|
+
if (u.protocol !== 'https:')
|
|
29
|
+
throw new Error('not https');
|
|
30
|
+
link = u.toString();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return { ok: false, why: 'provenance.link must be an https address' };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (p.funnel !== undefined && (typeof p.funnel !== 'string' || !SLUG.test(p.funnel)))
|
|
37
|
+
return { ok: false, why: 'provenance.funnel must be a funnel slug' };
|
|
38
|
+
if (p.stage !== undefined && (typeof p.stage !== 'string' || !SLUG.test(p.stage) || p.funnel === undefined))
|
|
39
|
+
return { ok: false, why: 'provenance.stage must be a stage id, with its funnel' };
|
|
40
|
+
const at = typeof p.at === 'string' ? new Date(p.at).toISOString() : now.toISOString();
|
|
41
|
+
return { ok: true, value: { actor, authority, at, ...(link ? { link } : {}), ...(p.funnel ? { funnel: p.funnel } : {}), ...(p.stage ? { stage: p.stage } : {}) } };
|
|
42
|
+
}
|
|
11
43
|
const none = () => new NextResponse(null, { status: 204, headers: { 'cache-control': 'no-store' } });
|
|
12
44
|
const json = (b, status = 200) => NextResponse.json(b, { status, headers: { 'cache-control': 'no-store' } });
|
|
13
45
|
export function createRecipientsRoutes(given) {
|
|
@@ -65,7 +97,7 @@ export function createRecipientsRoutes(given) {
|
|
|
65
97
|
const rec = (await ctx.recipients.get(id)) ?? { recipients: {}, expireAt: null, keep: false };
|
|
66
98
|
return {
|
|
67
99
|
id, expireAt: rec.expireAt, keep: rec.keep,
|
|
68
|
-
recipients: Object.values(rec.recipients).map((r) => ({ rid: r.rid, label: r.label, group: r.group === true, owner: r.owner === true, revoked: !!r.revokedAt, closesAt: r.closesAt ?? null, sentAt: r.sentAt ?? null, url: ridLink(ctx.pageUrl(id), id, r.rid, secret) })),
|
|
100
|
+
recipients: Object.values(rec.recipients).map((r) => ({ rid: r.rid, label: r.label, group: r.group === true, owner: r.owner === true, revoked: !!r.revokedAt, closesAt: r.closesAt ?? null, sentAt: r.sentAt ?? null, provenance: r.provenance ?? null, url: ridLink(ctx.pageUrl(id), id, r.rid, secret) })),
|
|
69
101
|
};
|
|
70
102
|
}
|
|
71
103
|
/** GET and POST /api/artifacts/<id>/recipients: who a per-reader page was sent to (publish key). */
|
|
@@ -96,6 +128,17 @@ export function createRecipientsRoutes(given) {
|
|
|
96
128
|
const adds = Array.isArray(b.add) ? b.add : [];
|
|
97
129
|
if (adds.some((x) => x.closes !== undefined && (typeof x.closes !== 'string' || Number.isNaN(Date.parse(x.closes)))))
|
|
98
130
|
return json({ error: 'closes must be an ISO time' }, 400);
|
|
131
|
+
const provenances = [];
|
|
132
|
+
for (const x of adds) {
|
|
133
|
+
if (x.provenance === undefined) {
|
|
134
|
+
provenances.push(undefined);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const p = readProvenance(x.provenance);
|
|
138
|
+
if (!p.ok)
|
|
139
|
+
return json({ error: p.why }, 400);
|
|
140
|
+
provenances.push(p.value);
|
|
141
|
+
}
|
|
99
142
|
const win = b.closes;
|
|
100
143
|
if (win !== undefined) {
|
|
101
144
|
const ridsOk = !!win && (win.rids === '*' || (Array.isArray(win.rids) && win.rids.every((x) => typeof x === 'string' && RID_RE.test(x))));
|
|
@@ -122,13 +165,14 @@ export function createRecipientsRoutes(given) {
|
|
|
122
165
|
sentAt = new Date(atMs).toISOString();
|
|
123
166
|
}
|
|
124
167
|
if (adds.length) {
|
|
125
|
-
const made = await ctx.recipients.add(id, adds.map((x) => ({
|
|
168
|
+
const made = await ctx.recipients.add(id, adds.map((x, i) => ({
|
|
126
169
|
label: x.label,
|
|
127
170
|
...(typeof x.note === 'string' ? { note: x.note } : {}), ...(x.owner === true ? { owner: true } : {}), ...(x.group === true ? { group: true } : {}),
|
|
128
171
|
// Never for the owner, whatever store the host brought: their link has no window.
|
|
129
172
|
...(typeof x.closes === 'string' && x.owner !== true ? { closesAt: new Date(x.closes).toISOString() } : {}),
|
|
173
|
+
...(provenances[i] ? { provenance: provenances[i] } : {}),
|
|
130
174
|
})));
|
|
131
|
-
added = made.map((r) => ({ rid: r.rid, label: r.label, url: ridLink(ctx.pageUrl(id), id, r.rid, secret) }));
|
|
175
|
+
added = made.map((r) => ({ rid: r.rid, label: r.label, url: ridLink(ctx.pageUrl(id), id, r.rid, secret), provenance: r.provenance ?? null }));
|
|
132
176
|
}
|
|
133
177
|
const revoked = b.revoke === '*' || Array.isArray(b.revoke)
|
|
134
178
|
? await ctx.recipients.revoke(id, b.revoke === '*' ? '*' : b.revoke.filter((x) => typeof x === 'string' && RID_RE.test(x)))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.0",
|
|
4
4
|
"description": "Publish markdown as branded, read-aloud pages from a Next.js app: the artifacts store, the route factory, the read-along reader, brand packs, password and confidential pages, and share cards.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|