@supersuit/artifacts 0.11.1 → 0.12.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.
@@ -10,6 +10,8 @@ export type PersonalRoutesContext = {
10
10
  pageUrl: (id: string) => string;
11
11
  signInOrigin?: string;
12
12
  siteUrl: string;
13
+ /** A signed URL to play one of the reader's own recordings. */
14
+ audioUrl?: (id: string, path: string) => Promise<string>;
13
15
  };
14
16
  type Params = {
15
17
  params: Promise<{
@@ -12,7 +12,8 @@ import { NextResponse } from 'next/server';
12
12
  import { ARTIFACT_ID_RE } from './ids.js';
13
13
  import { GRANT_COOKIE, decide, signInUrl, verifyGrant } from '../artifacts/reader.js';
14
14
  import { isUnlocked, unlockCookieName } from '../artifacts/unlock.js';
15
- import { validatePersonal } from '../artifacts/personal-store.js';
15
+ import { personalAudioDir, validatePersonal } from '../artifacts/personal-store.js';
16
+ import { isPersonalAudioPath } from '../artifacts/audio.js';
16
17
  const MAX_BODY = 16 * 1024;
17
18
  export function createPersonalRoutes(ctx) {
18
19
  const json = (body, status = 200) => NextResponse.json(body, { status, headers: { 'cache-control': 'no-store' } });
@@ -43,7 +44,16 @@ export function createPersonalRoutes(ctx) {
43
44
  return { error: json({ error: 'sign in to keep notes everywhere', device: true, signIn }, 401) };
44
45
  return { id, uid: reader.uid, personal: ctx.personal };
45
46
  }
46
- const list = async (o) => json({ notes: await o.personal.list(o.id, o.uid) });
47
+ // Each note with a recording carries a signed URL to play it, issued to its owner here and nowhere else.
48
+ const list = async (o) => {
49
+ const notes = await o.personal.list(o.id, o.uid);
50
+ const dir = personalAudioDir(o.uid);
51
+ if (ctx.audioUrl)
52
+ for (const n of notes)
53
+ if (isPersonalAudioPath(n.value.audio, dir))
54
+ n.audioUrl = await ctx.audioUrl(o.id, n.value.audio);
55
+ return json({ notes });
56
+ };
47
57
  // A write carries the reader's cookies; a browser always names its Origin on one, so a foreign one is refused.
48
58
  const foreign = (req) => { const origin = req.headers.get('origin'); return origin !== null && origin !== siteOrigin; };
49
59
  async function PERSONAL_GET(req, { params }) {
@@ -75,6 +85,9 @@ export function createPersonalRoutes(ctx) {
75
85
  const v = validatePersonal(b.value);
76
86
  if (!v.ok)
77
87
  return json({ error: v.error }, 400);
88
+ // A note's recording is one this reader uploaded as a personal note: never a comment's, never another reader's.
89
+ if (v.value.audio !== undefined && !isPersonalAudioPath(v.value.audio, personalAudioDir(o.uid)))
90
+ return json({ error: 'a note\'s audio is the path its upload returned' }, 400);
78
91
  if (b.entry !== undefined) {
79
92
  if (typeof b.entry !== 'string' || !(await o.personal.replace(o.id, o.uid, b.entry, v.value)))
80
93
  return json({ error: 'no note of yours with that id' }, 404);
@@ -24,6 +24,9 @@ export type StateRoutesContext = {
24
24
  /** The page owner's sign-in email. Signed in as them, a page's comments show every entry with
25
25
  * full names, whatever `comments_visible:` says. */
26
26
  ownerEmail?: string;
27
+ /** A signed URL to play a comment's recording. Called only for `comments/` paths, and only for
28
+ * comments the asker is already being shown. Without it a recording has no play control. */
29
+ audioUrl?: (id: string, path: string) => Promise<string>;
27
30
  };
28
31
  type Params = {
29
32
  params: Promise<{
@@ -16,6 +16,7 @@ import { GRANT_COOKIE, decide, firstName, signInUrl, verifyGrant } from '../arti
16
16
  import { isUnlocked, unlockCookieName } from '../artifacts/unlock.js';
17
17
  import { ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, checkValue, effectiveWriters, slotVisibility } from '../artifacts/state.js';
18
18
  import { COMMENTS_SLOT, validateComment } from '../artifacts/comments.js';
19
+ import { isCommentAudioPath } from '../artifacts/audio.js';
19
20
  import { readerKeyFor } from '../artifacts/state-store.js';
20
21
  import { NOTES_SLOT, checkNoteValue, hasNotesWidget } from '../artifacts/widgets.js';
21
22
  import { responsesCsv, responsesOf, stateView } from '../artifacts/state-view.js';
@@ -70,6 +71,18 @@ export function createStateRoutes(ctx) {
70
71
  /** Comments are on for this page: the slot exists because `comments:` put it there. */
71
72
  const commentsOn = (a) => !!a.comments && a.comments !== 'off' && Object.hasOwn(a.state.slots, COMMENTS_SLOT);
72
73
  const writerFor = (r) => ({ key: readerKeyFor.signedIn(r.uid), uid: r.uid, email: r.email, name: r.name, anonymous: false });
74
+ /** Beside each comment row with a recording, a signed URL to play it. Only ever a `comments/`
75
+ * path: a personal recording can never be named by a comment, and is never signed here. */
76
+ async function withAudioUrls(id, rows) {
77
+ if (!ctx.audioUrl)
78
+ return rows;
79
+ for (const r of rows) {
80
+ const audio = r.value?.audio;
81
+ if (isCommentAudioPath(audio))
82
+ r.audioUrl = await ctx.audioUrl(id, audio);
83
+ }
84
+ return rows;
85
+ }
73
86
  async function viewBody(a, reader, readerKey) {
74
87
  const allow = reader && ctx.readers && a.access ? await ctx.readers.allowList(a.id) : [];
75
88
  const entries = await ctx.state.entries(a.id);
@@ -81,6 +94,11 @@ export function createStateRoutes(ctx) {
81
94
  .sort((x, y) => x.at.localeCompare(y.at) || x.id.localeCompare(y.id))
82
95
  .map((e) => ({ id: e.id, name: e.writer.name?.trim() || 'a reader', value: e.value, at: e.at, mine: e.readerKey === readerKey }));
83
96
  }
97
+ if (commentsOn(a) && slots[COMMENTS_SLOT]) {
98
+ const v = slots[COMMENTS_SLOT];
99
+ await withAudioUrls(a.id, Array.isArray(v.mine) ? v.mine : []);
100
+ await withAudioUrls(a.id, v.shared ?? []);
101
+ }
84
102
  return {
85
103
  reader: reader ? { firstName: firstName(reader, allow) } : null,
86
104
  canWrite: !!reader || effectiveWriters(a.state, a.access) === 'anyone',
@@ -217,6 +235,10 @@ export function createStateRoutes(ctx) {
217
235
  const c = validateComment(b.value, (cid) => seen.get(cid) ?? null);
218
236
  if (!c.ok)
219
237
  return json({ error: c.error }, 400);
238
+ // A shared comment's recording is one uploaded as a comment, never a path elsewhere under the
239
+ // page: naming a personal recording here would get it signed for the publisher.
240
+ if (c.value.audio !== undefined && !isCommentAudioPath(c.value.audio))
241
+ return json({ error: 'a comment\'s audio is the path its upload returned' }, 400);
220
242
  // An edit keeps the comment where it is in its thread: a reply stays a reply to the same one.
221
243
  if (op === 'replace' && seen.has(entryId) && (seen.get(entryId).parent ?? null) !== (c.value.parent ?? null))
222
244
  return json({ error: 'an edit cannot move a comment into or out of a thread' }, 400);
@@ -269,6 +291,8 @@ export function createStateRoutes(ctx) {
269
291
  const rows = responsesOf(await ctx.state.entries(id));
270
292
  if (req.nextUrl.searchParams.get('format') === 'csv')
271
293
  return new NextResponse(responsesCsv(rows), { headers: { 'content-type': 'text/csv; charset=utf-8', 'cache-control': 'no-store' } });
294
+ // The publisher hears a shared comment's recording from here; the rows are signed in place.
295
+ await withAudioUrls(id, rows.filter((r) => r.slot === COMMENTS_SLOT));
272
296
  return json({ id, title: a.title, responses: rows });
273
297
  }
274
298
  return { STATE_GET, STATE_POST, RESPONSES };
@@ -0,0 +1,35 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import type { StateStore } from '../artifacts/state-store.js';
3
+ import { type PersonalStore } from '../artifacts/personal-store.js';
4
+ import type { ArtifactStore } from '../artifacts/store.js';
5
+ import type { ReadersStore } from '../artifacts/readers-store.js';
6
+ import type { ArtifactAssets } from '../artifacts/assets.js';
7
+ import type { Transcriber } from '../artifacts/transcribe.js';
8
+ /** Host transcriptions per reader per page per hour. */
9
+ export declare const TRANSCRIBE_PER_HOUR = 20;
10
+ export type VoiceRoutesContext = {
11
+ store: ArtifactStore;
12
+ state?: StateStore;
13
+ personal?: PersonalStore;
14
+ readers?: ReadersStore;
15
+ assets?: ArtifactAssets;
16
+ transcribe?: Transcriber;
17
+ readerSecret: () => string | undefined;
18
+ pageUrl: (id: string) => string;
19
+ signInOrigin?: string;
20
+ siteUrl: string;
21
+ clientIp?: (req: NextRequest) => string;
22
+ };
23
+ type Params = {
24
+ params: Promise<{
25
+ id: string;
26
+ }>;
27
+ };
28
+ export declare function createVoiceRoutes(ctx: VoiceRoutesContext): {
29
+ TRANSCRIBE: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
30
+ readerUpload: (req: NextRequest, id: string, name: string, body: {
31
+ audio?: unknown;
32
+ done?: unknown;
33
+ }) => Promise<NextResponse<unknown>>;
34
+ };
35
+ export {};
@@ -0,0 +1,176 @@
1
+ // Readers' voice memos: the upload, and the host's transcription of it.
2
+ //
3
+ // POST /api/artifacts/<id>/uploads/<memo>.<ext> { audio: 'comment'|'personal' [, done: true] }
4
+ // the same UPLOAD route the publisher uses, dispatched here when the body names `audio`:
5
+ // a signed URL to PUT one recording to, then `done` to confirm it arrived
6
+ // POST /api/artifacts/<id>/transcribe { asset: <path the upload returned> }
7
+ //
8
+ // Who may upload or transcribe: for a shared comment, whoever may write the page's comments slot
9
+ // (the same doors the state POST opens: password, access, agreement, `comments:` writers); for a
10
+ // personal note, a signed-in reader, into a directory only their uid derives. A reader who is not
11
+ // signed in keeps a personal memo on their device and never reaches this file.
12
+ //
13
+ // The transcription key lives in `ctx.transcribe`'s closure and is never read here. Every
14
+ // failure of the service answers one fixed line: whatever it threw is dropped, never echoed.
15
+ import { createHash } from 'node:crypto';
16
+ import { NextResponse } from 'next/server';
17
+ import { ARTIFACT_ID_RE } from './ids.js';
18
+ import { GRANT_COOKIE, decide, signInUrl, verifyGrant } from '../artifacts/reader.js';
19
+ import { isUnlocked, unlockCookieName } from '../artifacts/unlock.js';
20
+ import { ANON_WRITES_PER_MINUTE, effectiveWriters } from '../artifacts/state.js';
21
+ import { COMMENTS_SLOT } from '../artifacts/comments.js';
22
+ import { personalAudioDir } from '../artifacts/personal-store.js';
23
+ import { MAX_AUDIO_BYTES, MEMO_NAME, audioTypeFor, commentAudioPath, isCommentAudioPath, isPersonalAudioPath, personalAudioPath, } from '../artifacts/audio.js';
24
+ import { ANON_COOKIE, rateKey } from './state-routes.js';
25
+ /** Host transcriptions per reader per page per hour. */
26
+ export const TRANSCRIBE_PER_HOUR = 20;
27
+ const ANON_ID = /^[A-Za-z0-9]{24}$/;
28
+ export function createVoiceRoutes(ctx) {
29
+ const json = (body, status = 200) => NextResponse.json(body, { status, headers: { 'cache-control': 'no-store' } });
30
+ const siteOrigin = new URL(ctx.siteUrl).origin;
31
+ const foreign = (req) => { const o = req.headers.get('origin'); return o !== null && o !== siteOrigin; };
32
+ const clientIp = (req) => (ctx.clientIp ? ctx.clientIp(req) : (req.headers.get('x-forwarded-for') ?? '').split(',')[0].trim());
33
+ const hash = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16);
34
+ /** The page and the reader, through every door the page has; or the refusal. */
35
+ async function open(req, id) {
36
+ const a = ARTIFACT_ID_RE.test(id) ? await ctx.store.get(id) : null;
37
+ if (!a)
38
+ return { error: json({ error: `no artifact with id ${id}` }, 404) };
39
+ if (a.password && !isUnlocked({ id, password: a.password, cookie: req.cookies.get(unlockCookieName(id))?.value }))
40
+ return { error: json({ error: 'this page is locked' }, 403) };
41
+ const reader = verifyGrant(ctx.readerSecret(), req.cookies.get(GRANT_COOKIE)?.value);
42
+ const signIn = ctx.signInOrigin ? signInUrl(ctx.signInOrigin, ctx.pageUrl(id)) : null;
43
+ if (a.access) {
44
+ if (!ctx.readers)
45
+ return { error: json({ error: 'this page is closed' }, 403) };
46
+ if (!reader)
47
+ return { error: json({ error: 'sign in to read this page', signIn }, 401) };
48
+ if (!decide(a.access, reader, await ctx.readers.allowList(id)).open)
49
+ return { error: json({ error: 'this page is not open to you' }, 403) };
50
+ if (!(await ctx.readers.acknowledged(id, reader.email)))
51
+ return { error: json({ error: 'accept the agreement first' }, 403) };
52
+ }
53
+ return { a, reader, signIn };
54
+ }
55
+ /** May this reader write in `scope` on page `a`? Null when yes, else the refusal. */
56
+ function mayWrite(a, reader, scope, signIn) {
57
+ if (scope === 'personal') {
58
+ if (!ctx.personal)
59
+ return json({ error: 'this host keeps no personal notes', device: true }, 501);
60
+ if (!reader)
61
+ return json({ error: 'sign in to keep recordings everywhere', device: true, signIn }, 401);
62
+ return null;
63
+ }
64
+ const on = !!ctx.state && !!a.state && !!a.comments && a.comments !== 'off' && Object.hasOwn(a.state.slots, COMMENTS_SLOT);
65
+ if (!on)
66
+ return json({ error: 'this page takes no comments' }, 403);
67
+ if (!reader && effectiveWriters(a.state, a.access, COMMENTS_SLOT) !== 'anyone')
68
+ return json({ error: 'sign in to comment', signIn }, 401);
69
+ return null;
70
+ }
71
+ /** Who is asking, for the per-reader counters: the account, else the anonymous cookie, else the address. */
72
+ const askerKey = (req, reader) => {
73
+ if (reader)
74
+ return `u:${reader.uid}`;
75
+ const anon = req.cookies.get(ANON_COOKIE)?.value;
76
+ return anon && ANON_ID.test(anon) ? `a:${anon}` : `ip:${rateKey(clientIp(req), ctx.siteUrl, ctx.readerSecret())}`;
77
+ };
78
+ /** The reader branch of UPLOAD. `body` is already parsed; `name` is `<memo>.<ext>`. */
79
+ async function readerUpload(req, id, name, body) {
80
+ if (foreign(req))
81
+ return json({ error: 'wrong origin' }, 403);
82
+ const assets = ctx.assets;
83
+ if (!assets?.signAudioUpload || !assets.audioSize)
84
+ return json({ error: 'this host does not keep recordings' }, 501);
85
+ const scope = body.audio;
86
+ if (scope !== 'comment' && scope !== 'personal')
87
+ return json({ error: 'audio is comment or personal' }, 400);
88
+ if (!MEMO_NAME.test(name))
89
+ return json({ error: 'a recording is named <id>.<webm|m4a|mp3|ogg>' }, 400);
90
+ const type = audioTypeFor(name);
91
+ const o = await open(req, id);
92
+ if ('error' in o)
93
+ return o.error;
94
+ const refused = mayWrite(o.a, o.reader, scope, o.signIn);
95
+ if (refused)
96
+ return refused;
97
+ const path = scope === 'comment' ? commentAudioPath(name) : personalAudioPath(personalAudioDir(o.reader.uid), name);
98
+ if (body.done === true) {
99
+ const size = await assets.audioSize(id, path);
100
+ if (size === null)
101
+ return json({ error: 'nothing was uploaded to that url' }, 409);
102
+ if (size > MAX_AUDIO_BYTES)
103
+ return json({ error: 'a recording is at most 4 MB' }, 413);
104
+ return json({ path }, 201);
105
+ }
106
+ // An anonymous signer is counted like an anonymous answer, so a public page is not free storage.
107
+ if (!o.reader) {
108
+ const n = ctx.state ? await ctx.state.countAnonWrite(id, `au.${hash(askerKey(req, null))}`, Math.floor(Date.now() / 60000)) : 0;
109
+ if (n > ANON_WRITES_PER_MINUTE)
110
+ return json({ error: 'too many recordings from here; try again in a minute' }, 429);
111
+ }
112
+ return json({ path, ...(await assets.signAudioUpload(id, path, type)) }, 201);
113
+ }
114
+ async function TRANSCRIBE(req, { params }) {
115
+ // No transcriber: the reader's browser is the floor, and a 404 is how it learns that.
116
+ if (!ctx.transcribe)
117
+ return json({ error: 'this host does not transcribe' }, 404);
118
+ if (foreign(req))
119
+ return json({ error: 'wrong origin' }, 403);
120
+ const raw = await req.text();
121
+ if (raw.length > 1024)
122
+ return json({ error: 'request over 1 KB' }, 413);
123
+ let asset;
124
+ try {
125
+ asset = JSON.parse(raw)?.asset;
126
+ }
127
+ catch {
128
+ return json({ error: 'body is { asset }' }, 400);
129
+ }
130
+ const o = await open(req, (await params).id);
131
+ if ('error' in o)
132
+ return o.error;
133
+ const { a, reader, signIn } = o;
134
+ let scope;
135
+ if (isCommentAudioPath(asset))
136
+ scope = 'comment';
137
+ else if (typeof asset === 'string' && asset.startsWith('personal/')) {
138
+ if (!reader)
139
+ return json({ error: 'sign in to transcribe a personal note', signIn }, 401);
140
+ // A personal recording is transcribed only for the reader it belongs to.
141
+ if (!isPersonalAudioPath(asset, personalAudioDir(reader.uid)))
142
+ return json({ error: 'no recording of yours at that path' }, 404);
143
+ scope = 'personal';
144
+ }
145
+ else
146
+ return json({ error: 'asset is the path the upload returned' }, 400);
147
+ const refused = mayWrite(a, reader, scope, signIn);
148
+ if (refused)
149
+ return refused;
150
+ if (!ctx.state)
151
+ return json({ error: 'this host keeps no counters' }, 501);
152
+ if (!ctx.assets?.readAudio)
153
+ return json({ error: 'this host does not keep recordings' }, 501);
154
+ const n = await ctx.state.countAnonWrite(a.id, `tx.${hash(askerKey(req, reader))}`, Math.floor(Date.now() / 3_600_000));
155
+ if (n > TRANSCRIBE_PER_HOUR)
156
+ return json({ error: 'that is enough transcriptions for this hour; type it instead' }, 429);
157
+ const path = asset;
158
+ const bytes = await ctx.assets.readAudio(a.id, path);
159
+ if (!bytes)
160
+ return json({ error: 'no recording at that path' }, 404);
161
+ if (bytes.length > MAX_AUDIO_BYTES)
162
+ return json({ error: 'a recording is at most 4 MB' }, 413);
163
+ const mime = audioTypeFor(path);
164
+ let out = null;
165
+ try {
166
+ out = await ctx.transcribe(new Blob([new Uint8Array(bytes)], { type: mime }), mime);
167
+ }
168
+ catch {
169
+ out = null;
170
+ }
171
+ if (!out || typeof out.text !== 'string')
172
+ return json({ error: 'the transcription service did not answer' }, 502);
173
+ return json({ text: out.text });
174
+ }
175
+ return { TRANSCRIBE, readerUpload };
176
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.11.1",
3
+ "version": "0.12.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",