@supersuit/artifacts 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/README.md +106 -0
- package/lib/artifacts/front-matter.d.ts +4 -0
- package/lib/artifacts/front-matter.js +8 -1
- package/lib/artifacts/index.d.ts +3 -0
- package/lib/artifacts/index.js +3 -0
- package/lib/artifacts/state-store.d.ts +59 -0
- package/lib/artifacts/state-store.js +151 -0
- package/lib/artifacts/state-view.d.ts +38 -0
- package/lib/artifacts/state-view.js +66 -0
- package/lib/artifacts/state.d.ts +35 -0
- package/lib/artifacts/state.js +83 -0
- package/lib/artifacts/store.d.ts +2 -0
- package/lib/artifacts/store.js +4 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/routes/artifacts.d.ts +25 -2
- package/lib/routes/artifacts.js +41 -4
- package/lib/routes/ids.d.ts +1 -0
- package/lib/routes/ids.js +3 -0
- package/lib/routes/state-routes.d.ts +35 -0
- package/lib/routes/state-routes.js +213 -0
- package/package.json +39 -10
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,43 @@
|
|
|
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.2.0 (2026-09-24)
|
|
7
|
+
|
|
8
|
+
**Reader answers.** A page can now take input from the people reading it. It declares named
|
|
9
|
+
slots under `state:` in its front matter, and readers' answers are kept per reader, beside the
|
|
10
|
+
pages, and handed back to the publisher. Nothing renders an answer UI yet: widgets (poll, form,
|
|
11
|
+
checklist, notes) and HTML pages are the next releases, and they are the first callers.
|
|
12
|
+
|
|
13
|
+
- `state:` in front matter: `writers` (`signed-in`, default, or `anyone`), `visibility`
|
|
14
|
+
(`private`, default, `tally` or `shared`), and `slots` of shape `one` (one value per reader,
|
|
15
|
+
replaced) or `many` (an append-only list per reader). A write to an undeclared slot is refused.
|
|
16
|
+
- Routes: `GET`/`POST /api/artifacts/<id>/state` for the reader, `GET`/`DELETE
|
|
17
|
+
/api/artifacts/<id>/responses` for the publisher (publish key; JSON or `?format=csv`).
|
|
18
|
+
- A reader never receives an email or another reader's key. Shared `one` entries carry an
|
|
19
|
+
opaque id. Tallies count signed-in and anonymous answers apart, because anyone can answer
|
|
20
|
+
again by clearing a cookie. The publisher's CSV escapes cells a spreadsheet would run.
|
|
21
|
+
- Anonymous writers (on `writers: anyone` pages) get an HttpOnly `artifact_anon` cookie, limited
|
|
22
|
+
to 30 writes a minute per page per client; their answers move to them when they sign in, on
|
|
23
|
+
every page. A gated page (`access:`) always requires a signed-in reader the page is open to
|
|
24
|
+
who has accepted the agreement. A password page needs its unlock cookie, which is now also
|
|
25
|
+
set for the page's answer API.
|
|
26
|
+
- Limits: 8 KB per value, 16 KB per request, 200 `many` entries per reader per slot, 2000
|
|
27
|
+
answers per slot per page, the newest 100 shared entries returned. Cross-origin posts refused.
|
|
28
|
+
- Republishing keeps answers; changing a slot's shape is refused (against the previous file and
|
|
29
|
+
against stored answers). Adding and removing slots is allowed.
|
|
30
|
+
- **DETECTOR:** a page with `state:` published to a host without a state store gets
|
|
31
|
+
`warning: this host keeps no answers` in the publish response, and its readers get 501.
|
|
32
|
+
- **REMEDY (hosts):** pass `state: createStateStore(db, '<base>')` to `createArtifactRoutes`,
|
|
33
|
+
add `app/api/artifacts/[id]/state/route.ts` (GET, POST to `STATE_GET`/`STATE_POST`) and
|
|
34
|
+
`app/api/artifacts/[id]/responses/route.ts` (GET, DELETE to `RESPONSES`), set a Firestore TTL
|
|
35
|
+
policy on `expireAt` in `<base>StateRate`, and pass `clientIp` when not on Vercel. README,
|
|
36
|
+
"Reader answers".
|
|
37
|
+
- **Known, fixed next release:** a reader who unlocked a password page before this version holds
|
|
38
|
+
only the page's cookie and must reopen the `?key=` link before answering there.
|
|
39
|
+
- **Tests:** `state.test.ts`, `state-store.test.ts` (memory contract), `state-view.test.ts`,
|
|
40
|
+
`routes/state.test.tsx`, and the packed fixture's anonymous answer round trip (`npm run
|
|
41
|
+
test:packed`). The Firestore store has no emulator test; it is proven on a live host.
|
|
42
|
+
|
|
6
43
|
## 0.1.0 (2026-09-24)
|
|
7
44
|
|
|
8
45
|
**The site shell behind Freedom's `artifacts.<name>` pages, published as a package.** It was a
|
package/README.md
CHANGED
|
@@ -179,6 +179,112 @@ reimplement it. Keep passes short-lived (five minutes is what the tests assume).
|
|
|
179
179
|
Without `signInOrigin`, a confidential page shows its door with no way through. It fails
|
|
180
180
|
closed, never open.
|
|
181
181
|
|
|
182
|
+
## Reader answers (`state:`)
|
|
183
|
+
|
|
184
|
+
A page can take answers from the people reading it: a vote, a reaction, a short response.
|
|
185
|
+
Declare it in front matter:
|
|
186
|
+
|
|
187
|
+
```yaml
|
|
188
|
+
state:
|
|
189
|
+
writers: anyone # or signed-in
|
|
190
|
+
visibility: tally # private | tally | shared
|
|
191
|
+
slots:
|
|
192
|
+
vote:
|
|
193
|
+
shape: one # one value per reader, overwritten by a later `set`
|
|
194
|
+
reactions:
|
|
195
|
+
shape: many # a growing list per reader, built by `append`
|
|
196
|
+
visibility: shared # per-slot override of the page default
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Four routes, each requiring `state: createStateStore(db, '<base>')` in the config or they answer
|
|
200
|
+
501:
|
|
201
|
+
|
|
202
|
+
- `GET /api/artifacts/<id>/state`: the reader's own answers plus what the page's visibility lets
|
|
203
|
+
them see (a tally, or every shared answer).
|
|
204
|
+
- `POST /api/artifacts/<id>/state`: `{ "slot": "vote", "op": "set" | "append" | "remove", "value": ..., "entry": "<id for remove>" }`.
|
|
205
|
+
`set` is for a `one` slot, `append` for a `many` slot; `remove` takes either.
|
|
206
|
+
- `GET /api/artifacts/<id>/responses` (publish key): every answer with who wrote it, whatever the
|
|
207
|
+
page's `visibility` says, including each row's `reader` key. `?format=csv` returns the same rows
|
|
208
|
+
as CSV (a `reader` column after `anonymous`), with formula-injection escaping on any value
|
|
209
|
+
starting `=`, `+`, `-` or `@`.
|
|
210
|
+
- `DELETE /api/artifacts/<id>/responses?reader=<key>` (publish key): erase one reader's answers,
|
|
211
|
+
by the `reader` key from the read above.
|
|
212
|
+
|
|
213
|
+
**Limits**: a value is capped at 8 KB and a request body at 16 KB (Vercel caps bodies at 4.5 MB
|
|
214
|
+
before this runs; on any other host, cap the body size at your proxy too). A `many` slot holds at
|
|
215
|
+
most 200 entries per reader, and a slot holds at most 2,000 entries per page across every reader:
|
|
216
|
+
past that a new answer is refused with 409, though a reader can still replace their own `one`
|
|
217
|
+
answer. A `shared` slot shows readers only its newest 100 entries; tallies count everything. A
|
|
218
|
+
`POST` whose `Origin` header names another site is refused with 403.
|
|
219
|
+
|
|
220
|
+
**The anonymous rate limit**: 30 writes per minute per client address, counted in the
|
|
221
|
+
`<base>StateRate` collection under an HMAC of the address (keyed by your reader secret when set).
|
|
222
|
+
Each counter carries an `expireAt` a day ahead: set a Firestore TTL policy on `expireAt` for that
|
|
223
|
+
collection, or the counters are kept forever. The address is the first hop of `x-forwarded-for`,
|
|
224
|
+
so without one (plain `next start` with no proxy in front) every client shares one bucket: pass
|
|
225
|
+
`clientIp` there. An IPv6 client can rotate addresses within its /64, so treat the limit as a
|
|
226
|
+
brake, not a wall. A `remove` is never counted.
|
|
227
|
+
|
|
228
|
+
**Who can write**: a page with `access:` only ever accepts its signed-in readers, whatever
|
|
229
|
+
`writers:` says, and only after they have accepted the page's agreement (403 until then). A page
|
|
230
|
+
with a password takes answers only from a browser that has unlocked it; opening the page with
|
|
231
|
+
`?key=` sets the unlock cookie for the page and for its state API. `writers: anyone` only takes effect on a page with no `access:`: an anonymous
|
|
232
|
+
writer gets an opaque id in an HttpOnly `artifact_anon` cookie, good for a year. A `shared` slot
|
|
233
|
+
shows a reader everyone else's answer, but an anonymous one only ever by that same opaque id,
|
|
234
|
+
never a name or email; a publisher's `/responses` read and the CSV always show everything.
|
|
235
|
+
|
|
236
|
+
**Signing in after writing anonymously**: the next `GET /api/artifacts/<id>/state` from a reader
|
|
237
|
+
who is now signed in moves that cookie's answers onto their account, once, and clears the cookie.
|
|
238
|
+
|
|
239
|
+
**Republishing refuses only a shape change**: a slot going from `one` to `many` or back is
|
|
240
|
+
refused, checked both against the previous `state:` in front matter and against the shapes already
|
|
241
|
+
sitting in stored answers, so a republish can never misread history. Adding and removing slots is
|
|
242
|
+
allowed and the answers are kept: a removed slot's answers stop showing to readers and still come
|
|
243
|
+
back in `/responses`, and they reappear if the slot does. Drop `state:` entirely to close the page
|
|
244
|
+
to new answers; existing ones stay. Publishing `state:` to a host with no state store succeeds,
|
|
245
|
+
with a `warning` in the response saying the answers have nowhere to go.
|
|
246
|
+
|
|
247
|
+
**Host wiring**:
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
export const artifacts = createArtifactRoutes({
|
|
251
|
+
store,
|
|
252
|
+
state: createStateStore(getFirestore(), 'artifacts'),
|
|
253
|
+
// ...the rest of your config
|
|
254
|
+
})
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
// app/api/artifacts/[id]/state/route.ts
|
|
259
|
+
import type { NextRequest } from 'next/server'
|
|
260
|
+
import { artifacts } from '@/lib/artifacts'
|
|
261
|
+
type Ctx = { params: Promise<{ id: string }> }
|
|
262
|
+
export function GET(request: NextRequest, ctx: Ctx) { return artifacts.STATE_GET(request, ctx) }
|
|
263
|
+
export function POST(request: NextRequest, ctx: Ctx) { return artifacts.STATE_POST(request, ctx) }
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
// app/api/artifacts/[id]/responses/route.ts
|
|
268
|
+
import type { NextRequest } from 'next/server'
|
|
269
|
+
import { artifacts } from '@/lib/artifacts'
|
|
270
|
+
type Ctx = { params: Promise<{ id: string }> }
|
|
271
|
+
export function GET(request: NextRequest, ctx: Ctx) { return artifacts.RESPONSES(request, ctx) }
|
|
272
|
+
export function DELETE(request: NextRequest, ctx: Ctx) { return artifacts.RESPONSES(request, ctx) }
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
**Firestore indexes**: every `append` runs a count over `(artifactId, slot, readerKey)` on
|
|
276
|
+
`<base>State` for `MAX_MANY_PER_READER`, and every new answer runs a count over
|
|
277
|
+
`(artifactId, slot)` for the per-slot total. Firestore may serve both from its single-field
|
|
278
|
+
indexes. If it asks for a composite index instead, the first failure surfaces as a 500 on a
|
|
279
|
+
reader's write, with a create-index link in your host's logs; following it once is the whole step.
|
|
280
|
+
The cross-page move that runs on sign-in queries by `readerKey` alone. Consider a single-field
|
|
281
|
+
index exemption for the `json` field of `<base>State`: it holds whole answers as strings and is
|
|
282
|
+
never queried, so indexing it only costs writes and storage.
|
|
283
|
+
|
|
284
|
+
On a proxy other than Vercel's, pass `clientIp` to `createArtifactRoutes` (used for the anonymous
|
|
285
|
+
rate limit): the default reads the first hop of `x-forwarded-for`, which Vercel overwrites with
|
|
286
|
+
the real client IP but another proxy may only append to.
|
|
287
|
+
|
|
182
288
|
## Brand packs
|
|
183
289
|
|
|
184
290
|
A `BrandPack` is data plus at most two components: colours, type, the kicker line above a title,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Access } from './reader.js';
|
|
2
|
+
import { type StateConfig } from './state.js';
|
|
2
3
|
export type ArtifactMeta = {
|
|
3
4
|
title: string;
|
|
4
5
|
summary: string;
|
|
@@ -23,6 +24,9 @@ export type ArtifactMeta = {
|
|
|
23
24
|
* ABSENT CHANGES NOTHING: the level is host-side state, so a republish that omits the line
|
|
24
25
|
* (or a publisher that strips unknown keys) can never reopen a confidential page. */
|
|
25
26
|
access?: Access | 'public';
|
|
27
|
+
/** What readers may put into the page. Content, like the body: a republish without it removes
|
|
28
|
+
* the slots from the page and KEEPS the answers already given. */
|
|
29
|
+
state?: StateConfig;
|
|
26
30
|
};
|
|
27
31
|
export declare function parseArtifactSource(text: string): {
|
|
28
32
|
ok: true;
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// Documented in README, "Front matter".
|
|
3
3
|
import matter from 'gray-matter';
|
|
4
4
|
import { ACCESS_LEVELS } from './reader.js';
|
|
5
|
-
|
|
5
|
+
import { parseStateConfig } from './state.js';
|
|
6
|
+
const KNOWN = new Set(['title', 'summary', 'subtitle', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state']);
|
|
6
7
|
export function parseArtifactSource(text) {
|
|
7
8
|
const { data, content } = matter(text);
|
|
8
9
|
const d = data;
|
|
@@ -33,5 +34,11 @@ export function parseArtifactSource(text) {
|
|
|
33
34
|
return { ok: false, error: `access must be one of: public, ${ACCESS_LEVELS.join(', ')}` };
|
|
34
35
|
meta.access = d.access;
|
|
35
36
|
}
|
|
37
|
+
if (d.state !== undefined) {
|
|
38
|
+
const s = parseStateConfig(d.state);
|
|
39
|
+
if (!s.ok)
|
|
40
|
+
return { ok: false, error: s.error };
|
|
41
|
+
meta.state = s.state;
|
|
42
|
+
}
|
|
36
43
|
return { ok: true, meta, body: content };
|
|
37
44
|
}
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -7,3 +7,6 @@ export { createArtifactAssets, contentTypeFor, ASSET_NAME, type ArtifactAssets }
|
|
|
7
7
|
export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
|
|
8
8
|
export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS, type Access, type Reader, type AllowEntry, type Decision } from './reader.js';
|
|
9
9
|
export { createReadersStore, summarize, FLAG_KINDS, type ReadersStore, type FlagKind, type SessionDoc, type FlagDoc } from './readers-store.js';
|
|
10
|
+
export { parseStateConfig, effectiveWriters, slotVisibility, checkValue, shapeChanges, SLOT_NAME, MAX_VALUE_BYTES, MAX_MANY_PER_READER, ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, SHARED_LIMIT, type StateConfig, type SlotDef, type Shape, type Visibility, type Writers } from './state.js';
|
|
11
|
+
export { createStateStore, createMemoryStateStore, readerKeyFor, type StateStore, type StateEntry, type Writer } from './state-store.js';
|
|
12
|
+
export { stateView, responsesOf, responsesCsv, type SlotView, type Tally, type SharedEntry, type Response as StateResponse } from './state-view.js';
|
package/lib/artifacts/index.js
CHANGED
|
@@ -7,3 +7,6 @@ export { createArtifactAssets, contentTypeFor, ASSET_NAME } from './assets.js';
|
|
|
7
7
|
export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
|
|
8
8
|
export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS } from './reader.js';
|
|
9
9
|
export { createReadersStore, summarize, FLAG_KINDS } from './readers-store.js';
|
|
10
|
+
export { parseStateConfig, effectiveWriters, slotVisibility, checkValue, shapeChanges, SLOT_NAME, MAX_VALUE_BYTES, MAX_MANY_PER_READER, ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, SHARED_LIMIT } from './state.js';
|
|
11
|
+
export { createStateStore, createMemoryStateStore, readerKeyFor } from './state-store.js';
|
|
12
|
+
export { stateView, responsesOf, responsesCsv } from './state-view.js';
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type Firestore } from 'firebase-admin/firestore';
|
|
2
|
+
import { type Shape } from './state.js';
|
|
3
|
+
export type Writer = {
|
|
4
|
+
key: string;
|
|
5
|
+
uid?: string;
|
|
6
|
+
email?: string;
|
|
7
|
+
name: string | null;
|
|
8
|
+
anonymous: boolean;
|
|
9
|
+
};
|
|
10
|
+
export type StateEntry = {
|
|
11
|
+
id: string;
|
|
12
|
+
artifactId: string;
|
|
13
|
+
slot: string;
|
|
14
|
+
shape: Shape;
|
|
15
|
+
readerKey: string;
|
|
16
|
+
writer: Omit<Writer, 'key'>;
|
|
17
|
+
value: unknown;
|
|
18
|
+
at: string;
|
|
19
|
+
};
|
|
20
|
+
export interface StateStore {
|
|
21
|
+
entries(artifactId: string): Promise<StateEntry[]>;
|
|
22
|
+
set(input: {
|
|
23
|
+
artifactId: string;
|
|
24
|
+
slot: string;
|
|
25
|
+
writer: Writer;
|
|
26
|
+
value: unknown;
|
|
27
|
+
}): Promise<StateEntry>;
|
|
28
|
+
append(input: {
|
|
29
|
+
artifactId: string;
|
|
30
|
+
slot: string;
|
|
31
|
+
writer: Writer;
|
|
32
|
+
value: unknown;
|
|
33
|
+
}): Promise<StateEntry | {
|
|
34
|
+
full: true;
|
|
35
|
+
}>;
|
|
36
|
+
remove(input: {
|
|
37
|
+
artifactId: string;
|
|
38
|
+
slot: string;
|
|
39
|
+
readerKey: string;
|
|
40
|
+
entryId?: string;
|
|
41
|
+
}): Promise<number>;
|
|
42
|
+
removeReader(artifactId: string, readerKey: string): Promise<number>;
|
|
43
|
+
/** Moves every answer on every page from one reader key to another, not just the page the
|
|
44
|
+
* reader signed in from: an anonymous reader who answered several pages before signing in
|
|
45
|
+
* must not have the rest stranded under the cookie that is about to be cleared. */
|
|
46
|
+
moveReader(fromKey: string, to: Writer): Promise<number>;
|
|
47
|
+
/** Records one anonymous write and returns the count for that minute. The Firestore store
|
|
48
|
+
* stamps each counter with `expireAt` a day ahead, for a TTL policy to sweep. */
|
|
49
|
+
countAnonWrite(artifactId: string, ipHash: string, minute: number): Promise<number>;
|
|
50
|
+
/** Every reader's entries in one slot on one page, for MAX_ENTRIES_PER_SLOT. */
|
|
51
|
+
countSlot(artifactId: string, slot: string): Promise<number>;
|
|
52
|
+
}
|
|
53
|
+
export declare const readerKeyFor: {
|
|
54
|
+
signedIn: (uid: string) => string;
|
|
55
|
+
anonymous: (id: string) => string;
|
|
56
|
+
};
|
|
57
|
+
export declare function createStateStore(db: Firestore, base: string): StateStore;
|
|
58
|
+
/** The same contract in memory: for tests, and for hosts' own tests. */
|
|
59
|
+
export declare function createMemoryStateStore(): StateStore;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Where readers' answers live. Beside the pages, per tenant:
|
|
2
|
+
//
|
|
3
|
+
// <base>State/<page>__<slot>__<readerKey> shape one, one document per reader per slot
|
|
4
|
+
// <base>State/<auto> shape many, one document per entry
|
|
5
|
+
// <base>StateRate/<page>__<ipHash>__<min> anonymous write counter, one per minute, with an
|
|
6
|
+
// `expireAt` a day ahead for a Firestore TTL policy
|
|
7
|
+
//
|
|
8
|
+
// One document per answer keeps every page far from Firestore's 1 MiB document cap, which the
|
|
9
|
+
// lightpaper hit on 2026-09-24 when history lived on the page document. Values are stored as a
|
|
10
|
+
// JSON STRING (`json`), because Firestore refuses nested arrays and a form answer can hold one.
|
|
11
|
+
//
|
|
12
|
+
// The Firestore implementation has no emulator test here; it is proven on the live host after
|
|
13
|
+
// each release that changes it. The memory implementation carries the contract tests.
|
|
14
|
+
import { randomUUID } from 'node:crypto';
|
|
15
|
+
import { FieldValue, Timestamp } from 'firebase-admin/firestore';
|
|
16
|
+
import { MAX_MANY_PER_READER } from './state.js';
|
|
17
|
+
export const readerKeyFor = {
|
|
18
|
+
signedIn: (uid) => `u:${uid}`,
|
|
19
|
+
anonymous: (id) => `a:${id}`,
|
|
20
|
+
};
|
|
21
|
+
const writerOf = (w) => ({
|
|
22
|
+
...(w.uid ? { uid: w.uid } : {}), ...(w.email ? { email: w.email } : {}), name: w.name, anonymous: w.anonymous,
|
|
23
|
+
});
|
|
24
|
+
const oneId = (artifactId, slot, readerKey) => `${artifactId}__${slot}__${readerKey}`;
|
|
25
|
+
const toEntry = (id, d) => {
|
|
26
|
+
const { json, ...rest } = d;
|
|
27
|
+
return { id, ...rest, value: JSON.parse(json) };
|
|
28
|
+
};
|
|
29
|
+
const toStored = (e) => {
|
|
30
|
+
const { id: _id, value, ...rest } = e;
|
|
31
|
+
return { ...rest, json: JSON.stringify(value) };
|
|
32
|
+
};
|
|
33
|
+
export function createStateStore(db, base) {
|
|
34
|
+
const col = () => db.collection(`${base}State`);
|
|
35
|
+
const rate = () => db.collection(`${base}StateRate`);
|
|
36
|
+
const load = async (q) => (await q.get()).docs.map((d) => toEntry(d.id, d.data()));
|
|
37
|
+
const store = {
|
|
38
|
+
entries: (artifactId) => load(col().where('artifactId', '==', artifactId)),
|
|
39
|
+
async set({ artifactId, slot, writer, value }) {
|
|
40
|
+
const e = { id: oneId(artifactId, slot, writer.key), artifactId, slot, shape: 'one', readerKey: writer.key, writer: writerOf(writer), value, at: new Date().toISOString() };
|
|
41
|
+
await col().doc(e.id).set(toStored(e));
|
|
42
|
+
return e;
|
|
43
|
+
},
|
|
44
|
+
async append({ artifactId, slot, writer, value }) {
|
|
45
|
+
const n = (await col().where('artifactId', '==', artifactId).where('slot', '==', slot).where('readerKey', '==', writer.key).count().get()).data().count;
|
|
46
|
+
if (n >= MAX_MANY_PER_READER)
|
|
47
|
+
return { full: true };
|
|
48
|
+
const ref = col().doc();
|
|
49
|
+
const e = { id: ref.id, artifactId, slot, shape: 'many', readerKey: writer.key, writer: writerOf(writer), value, at: new Date().toISOString() };
|
|
50
|
+
await ref.set(toStored(e));
|
|
51
|
+
return e;
|
|
52
|
+
},
|
|
53
|
+
async remove({ artifactId, slot, readerKey, entryId }) {
|
|
54
|
+
const mine = (await load(col().where('artifactId', '==', artifactId).where('readerKey', '==', readerKey)))
|
|
55
|
+
.filter((e) => e.slot === slot && (!entryId || e.id === entryId));
|
|
56
|
+
await Promise.all(mine.map((e) => col().doc(e.id).delete()));
|
|
57
|
+
return mine.length;
|
|
58
|
+
},
|
|
59
|
+
async removeReader(artifactId, readerKey) {
|
|
60
|
+
const mine = await load(col().where('artifactId', '==', artifactId).where('readerKey', '==', readerKey));
|
|
61
|
+
await Promise.all(mine.map((e) => col().doc(e.id).delete()));
|
|
62
|
+
return mine.length;
|
|
63
|
+
},
|
|
64
|
+
async moveReader(fromKey, to) {
|
|
65
|
+
const from = await load(col().where('readerKey', '==', fromKey));
|
|
66
|
+
let moved = 0;
|
|
67
|
+
for (const e of from) {
|
|
68
|
+
if (e.shape === 'one') {
|
|
69
|
+
const target = col().doc(oneId(e.artifactId, e.slot, to.key));
|
|
70
|
+
// The signed-in answer wins over one given anonymously on this device.
|
|
71
|
+
if (!(await target.get()).exists) {
|
|
72
|
+
await target.set(toStored({ ...e, id: target.id, readerKey: to.key, writer: writerOf(to) }));
|
|
73
|
+
moved++;
|
|
74
|
+
}
|
|
75
|
+
await col().doc(e.id).delete();
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
await col().doc(e.id).update({ readerKey: to.key, writer: writerOf(to) });
|
|
79
|
+
moved++;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return moved;
|
|
83
|
+
},
|
|
84
|
+
async countAnonWrite(artifactId, ipHash, minute) {
|
|
85
|
+
const ref = rate().doc(`${artifactId}__${ipHash}__${minute}`);
|
|
86
|
+
await ref.set({ count: FieldValue.increment(1), at: new Date().toISOString(), expireAt: Timestamp.fromMillis(Date.now() + 86_400_000) }, { merge: true });
|
|
87
|
+
return (await ref.get()).data()?.count ?? 1;
|
|
88
|
+
},
|
|
89
|
+
async countSlot(artifactId, slot) {
|
|
90
|
+
return (await col().where('artifactId', '==', artifactId).where('slot', '==', slot).count().get()).data().count;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
return store;
|
|
94
|
+
}
|
|
95
|
+
/** The same contract in memory: for tests, and for hosts' own tests. */
|
|
96
|
+
export function createMemoryStateStore() {
|
|
97
|
+
const docs = new Map();
|
|
98
|
+
const counts = new Map();
|
|
99
|
+
const of = (artifactId, readerKey) => [...docs.values()].filter((e) => e.artifactId === artifactId && (!readerKey || e.readerKey === readerKey));
|
|
100
|
+
return {
|
|
101
|
+
entries: async (artifactId) => of(artifactId).map((e) => structuredClone(e)),
|
|
102
|
+
async set({ artifactId, slot, writer, value }) {
|
|
103
|
+
const e = { id: oneId(artifactId, slot, writer.key), artifactId, slot, shape: 'one', readerKey: writer.key, writer: writerOf(writer), value: structuredClone(value), at: new Date().toISOString() };
|
|
104
|
+
docs.set(e.id, e);
|
|
105
|
+
return structuredClone(e);
|
|
106
|
+
},
|
|
107
|
+
async append({ artifactId, slot, writer, value }) {
|
|
108
|
+
if (of(artifactId, writer.key).filter((e) => e.slot === slot).length >= MAX_MANY_PER_READER)
|
|
109
|
+
return { full: true };
|
|
110
|
+
const e = { id: randomUUID(), artifactId, slot, shape: 'many', readerKey: writer.key, writer: writerOf(writer), value: structuredClone(value), at: new Date().toISOString() };
|
|
111
|
+
docs.set(e.id, e);
|
|
112
|
+
return structuredClone(e);
|
|
113
|
+
},
|
|
114
|
+
async remove({ artifactId, slot, readerKey, entryId }) {
|
|
115
|
+
const mine = of(artifactId, readerKey).filter((e) => e.slot === slot && (!entryId || e.id === entryId));
|
|
116
|
+
for (const e of mine)
|
|
117
|
+
docs.delete(e.id);
|
|
118
|
+
return mine.length;
|
|
119
|
+
},
|
|
120
|
+
async removeReader(artifactId, readerKey) {
|
|
121
|
+
const mine = of(artifactId, readerKey);
|
|
122
|
+
for (const e of mine)
|
|
123
|
+
docs.delete(e.id);
|
|
124
|
+
return mine.length;
|
|
125
|
+
},
|
|
126
|
+
async moveReader(fromKey, to) {
|
|
127
|
+
let moved = 0;
|
|
128
|
+
for (const e of [...docs.values()].filter((e) => e.readerKey === fromKey)) {
|
|
129
|
+
docs.delete(e.id);
|
|
130
|
+
if (e.shape === 'one') {
|
|
131
|
+
const id = oneId(e.artifactId, e.slot, to.key);
|
|
132
|
+
if (docs.has(id))
|
|
133
|
+
continue;
|
|
134
|
+
docs.set(id, { ...e, id, readerKey: to.key, writer: writerOf(to) });
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
docs.set(e.id, { ...e, readerKey: to.key, writer: writerOf(to) });
|
|
138
|
+
}
|
|
139
|
+
moved++;
|
|
140
|
+
}
|
|
141
|
+
return moved;
|
|
142
|
+
},
|
|
143
|
+
async countAnonWrite(artifactId, ipHash, minute) {
|
|
144
|
+
const k = `${artifactId}__${ipHash}__${minute}`;
|
|
145
|
+
const n = (counts.get(k) ?? 0) + 1;
|
|
146
|
+
counts.set(k, n);
|
|
147
|
+
return n;
|
|
148
|
+
},
|
|
149
|
+
countSlot: async (artifactId, slot) => of(artifactId).filter((e) => e.slot === slot).length,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type Shape, type StateConfig, type Visibility } from './state.js';
|
|
2
|
+
import type { StateEntry } from './state-store.js';
|
|
3
|
+
export type Tally = {
|
|
4
|
+
signedIn: Record<string, number>;
|
|
5
|
+
anonymous: Record<string, number>;
|
|
6
|
+
};
|
|
7
|
+
export type SharedEntry = {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
value: unknown;
|
|
11
|
+
at: string;
|
|
12
|
+
mine: boolean;
|
|
13
|
+
};
|
|
14
|
+
export type SlotView = {
|
|
15
|
+
shape: Shape;
|
|
16
|
+
visibility: Visibility;
|
|
17
|
+
mine: unknown | {
|
|
18
|
+
id: string;
|
|
19
|
+
value: unknown;
|
|
20
|
+
at: string;
|
|
21
|
+
}[] | null;
|
|
22
|
+
tally?: Tally;
|
|
23
|
+
shared?: SharedEntry[];
|
|
24
|
+
};
|
|
25
|
+
export declare function stateView(state: StateConfig, entries: StateEntry[], readerKey: string | null): Record<string, SlotView>;
|
|
26
|
+
/** `reader` is the key `DELETE /responses?reader=` takes. Publisher-only: it is a credential. */
|
|
27
|
+
export type Response = {
|
|
28
|
+
slot: string;
|
|
29
|
+
id: string;
|
|
30
|
+
value: unknown;
|
|
31
|
+
at: string;
|
|
32
|
+
email: string | null;
|
|
33
|
+
name: string | null;
|
|
34
|
+
anonymous: boolean;
|
|
35
|
+
reader: string;
|
|
36
|
+
};
|
|
37
|
+
export declare function responsesOf(entries: StateEntry[]): Response[];
|
|
38
|
+
export declare function responsesCsv(rows: Response[]): string;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// What each side is shown. A reader gets their own answers, plus tallies or shared entries where
|
|
2
|
+
// the page allows, and NEVER an email. The publisher (publish key) gets everything.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { SHARED_LIMIT, slotVisibility } from './state.js';
|
|
5
|
+
const byAt = (a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id);
|
|
6
|
+
const firstWord = (name) => (name?.trim() ? name.trim().split(/\s+/)[0] : 'a reader');
|
|
7
|
+
// A `one` entry's raw id is `<page>__<slot>__<readerKey>`, and the reader key inside it (`u:<uid>`
|
|
8
|
+
// or `a:<anonId>`) is a credential: whoever reads it can write as that reader by minting the same
|
|
9
|
+
// anonymous cookie. A `shared` slot handed that id straight to every reader, so it is hashed to
|
|
10
|
+
// an opaque, stable value instead. `many` entries keep their random id; it names nothing.
|
|
11
|
+
const opaqueOneId = (id) => createHash('sha256').update(id).digest('hex').slice(0, 16);
|
|
12
|
+
/** Counts per option. A list value (multiple choice) counts each item; objects are not tallied.
|
|
13
|
+
* Anonymous answers are counted apart, because anyone can answer again by clearing a cookie. */
|
|
14
|
+
function tallyOf(entries) {
|
|
15
|
+
const t = { signedIn: {}, anonymous: {} };
|
|
16
|
+
for (const e of entries) {
|
|
17
|
+
const bucket = e.writer.anonymous ? t.anonymous : t.signedIn;
|
|
18
|
+
const items = Array.isArray(e.value) ? e.value : [e.value];
|
|
19
|
+
for (const v of items) {
|
|
20
|
+
if (v === null || typeof v === 'object')
|
|
21
|
+
continue;
|
|
22
|
+
const k = String(v);
|
|
23
|
+
bucket[k] = (bucket[k] ?? 0) + 1;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return t;
|
|
27
|
+
}
|
|
28
|
+
export function stateView(state, entries, readerKey) {
|
|
29
|
+
const out = {};
|
|
30
|
+
for (const [slot, def] of Object.entries(state.slots)) {
|
|
31
|
+
const here = entries.filter((e) => e.slot === slot).sort(byAt);
|
|
32
|
+
const mine = readerKey ? here.filter((e) => e.readerKey === readerKey) : [];
|
|
33
|
+
const visibility = slotVisibility(state, slot);
|
|
34
|
+
const view = {
|
|
35
|
+
shape: def.shape,
|
|
36
|
+
visibility,
|
|
37
|
+
mine: def.shape === 'one' ? (mine[0]?.value ?? null) : mine.map((e) => ({ id: e.id, value: e.value, at: e.at })),
|
|
38
|
+
};
|
|
39
|
+
if (visibility === 'tally')
|
|
40
|
+
view.tally = tallyOf(here);
|
|
41
|
+
if (visibility === 'shared')
|
|
42
|
+
view.shared = here.slice(-SHARED_LIMIT).map((e) => ({
|
|
43
|
+
id: def.shape === 'one' ? opaqueOneId(e.id) : e.id,
|
|
44
|
+
name: firstWord(e.writer.name), value: e.value, at: e.at, mine: e.readerKey === readerKey,
|
|
45
|
+
}));
|
|
46
|
+
out[slot] = view;
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
export function responsesOf(entries) {
|
|
51
|
+
return entries.slice().sort(byAt).map((e) => ({
|
|
52
|
+
slot: e.slot, id: e.id, value: e.value, at: e.at, email: e.writer.email ?? null, name: e.writer.name, anonymous: e.writer.anonymous, reader: e.readerKey,
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
// A cell a spreadsheet reads as a formula (leading =, +, -, @, tab or CR) gets an escaping
|
|
56
|
+
// leading quote first: a reader's answer becomes text a formula, not a command a publisher's
|
|
57
|
+
// spreadsheet app runs the moment the CSV is opened.
|
|
58
|
+
const FORMULA_LEAD = /^[=+\-@\t\r]/;
|
|
59
|
+
const cell = (v) => {
|
|
60
|
+
const safe = FORMULA_LEAD.test(v) ? `'${v}` : v;
|
|
61
|
+
return /[",\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
|
|
62
|
+
};
|
|
63
|
+
export function responsesCsv(rows) {
|
|
64
|
+
const head = 'slot,id,at,email,name,anonymous,reader,value';
|
|
65
|
+
return [head, ...rows.map((r) => [r.slot, r.id, r.at, r.email ?? '', r.name ?? '', String(r.anonymous), r.reader, JSON.stringify(r.value)].map(cell).join(','))].join('\n');
|
|
66
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type Shape = 'one' | 'many';
|
|
2
|
+
export type Visibility = 'private' | 'tally' | 'shared';
|
|
3
|
+
export type Writers = 'signed-in' | 'anyone';
|
|
4
|
+
export type SlotDef = {
|
|
5
|
+
shape: Shape;
|
|
6
|
+
visibility?: Visibility;
|
|
7
|
+
};
|
|
8
|
+
export type StateConfig = {
|
|
9
|
+
writers: Writers;
|
|
10
|
+
visibility: Visibility;
|
|
11
|
+
slots: Record<string, SlotDef>;
|
|
12
|
+
};
|
|
13
|
+
export declare const SLOT_NAME: RegExp;
|
|
14
|
+
export declare const MAX_VALUE_BYTES: number;
|
|
15
|
+
export declare const MAX_MANY_PER_READER = 200;
|
|
16
|
+
export declare const ANON_WRITES_PER_MINUTE = 30;
|
|
17
|
+
/** Every reader's answers together, per page per slot. A new answer past it is refused; a reader
|
|
18
|
+
* replacing their own `one` answer is not, because that adds nothing. */
|
|
19
|
+
export declare const MAX_ENTRIES_PER_SLOT = 2000;
|
|
20
|
+
/** A `shared` slot shows readers only its newest entries, so one busy page cannot make every read
|
|
21
|
+
* ship its whole history. Tallies still count everything. */
|
|
22
|
+
export declare const SHARED_LIMIT = 100;
|
|
23
|
+
export declare function parseStateConfig(raw: unknown): {
|
|
24
|
+
ok: true;
|
|
25
|
+
state: StateConfig;
|
|
26
|
+
} | {
|
|
27
|
+
ok: false;
|
|
28
|
+
error: string;
|
|
29
|
+
};
|
|
30
|
+
/** A gated page's readers are always signed in, so its state is too, whatever the file says. */
|
|
31
|
+
export declare function effectiveWriters(state: StateConfig, access?: string): Writers;
|
|
32
|
+
export declare function slotVisibility(state: StateConfig, slot: string): Visibility;
|
|
33
|
+
export declare function checkValue(value: unknown): string | null;
|
|
34
|
+
/** Answers are kept across a republish, so a slot may never change what shape its data is. */
|
|
35
|
+
export declare function shapeChanges(prev: StateConfig | undefined, next: StateConfig | undefined): string[];
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export const SLOT_NAME = /^[a-z][a-z0-9-]{0,39}$/;
|
|
2
|
+
export const MAX_VALUE_BYTES = 8 * 1024;
|
|
3
|
+
export const MAX_MANY_PER_READER = 200;
|
|
4
|
+
export const ANON_WRITES_PER_MINUTE = 30;
|
|
5
|
+
/** Every reader's answers together, per page per slot. A new answer past it is refused; a reader
|
|
6
|
+
* replacing their own `one` answer is not, because that adds nothing. */
|
|
7
|
+
export const MAX_ENTRIES_PER_SLOT = 2000;
|
|
8
|
+
/** A `shared` slot shows readers only its newest entries, so one busy page cannot make every read
|
|
9
|
+
* ship its whole history. Tallies still count everything. */
|
|
10
|
+
export const SHARED_LIMIT = 100;
|
|
11
|
+
const VISIBILITIES = ['private', 'tally', 'shared'];
|
|
12
|
+
const isMap = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
13
|
+
export function parseStateConfig(raw) {
|
|
14
|
+
if (!isMap(raw))
|
|
15
|
+
return { ok: false, error: 'state must be a map' };
|
|
16
|
+
for (const k of Object.keys(raw))
|
|
17
|
+
if (!['writers', 'visibility', 'slots'].includes(k))
|
|
18
|
+
return { ok: false, error: `state has an unknown key: ${k}` };
|
|
19
|
+
const writers = raw.writers ?? 'signed-in';
|
|
20
|
+
if (writers !== 'signed-in' && writers !== 'anyone')
|
|
21
|
+
return { ok: false, error: 'state.writers must be signed-in or anyone' };
|
|
22
|
+
const visibility = raw.visibility ?? 'private';
|
|
23
|
+
if (!VISIBILITIES.includes(visibility))
|
|
24
|
+
return { ok: false, error: `state.visibility must be one of: ${VISIBILITIES.join(', ')}` };
|
|
25
|
+
const slots = {};
|
|
26
|
+
const rawSlots = raw.slots ?? {};
|
|
27
|
+
if (!isMap(rawSlots))
|
|
28
|
+
return { ok: false, error: 'state.slots must be a map' };
|
|
29
|
+
for (const [name, def] of Object.entries(rawSlots)) {
|
|
30
|
+
if (!SLOT_NAME.test(name))
|
|
31
|
+
return { ok: false, error: `slot name "${name}" must be lowercase letters, digits and dashes, starting with a letter` };
|
|
32
|
+
if (!isMap(def))
|
|
33
|
+
return { ok: false, error: `slot "${name}" needs shape one or many` };
|
|
34
|
+
for (const k of Object.keys(def))
|
|
35
|
+
if (k !== 'shape' && k !== 'visibility')
|
|
36
|
+
return { ok: false, error: `slot "${name}" has an unknown key: ${k}` };
|
|
37
|
+
if (def.shape !== 'one' && def.shape !== 'many')
|
|
38
|
+
return { ok: false, error: `slot "${name}" needs shape one or many` };
|
|
39
|
+
if (def.visibility !== undefined && !VISIBILITIES.includes(def.visibility))
|
|
40
|
+
return { ok: false, error: `slot "${name}" visibility must be one of: ${VISIBILITIES.join(', ')}` };
|
|
41
|
+
slots[name] = { shape: def.shape, ...(def.visibility ? { visibility: def.visibility } : {}) };
|
|
42
|
+
}
|
|
43
|
+
return { ok: true, state: { writers, visibility: visibility, slots } };
|
|
44
|
+
}
|
|
45
|
+
/** A gated page's readers are always signed in, so its state is too, whatever the file says. */
|
|
46
|
+
export function effectiveWriters(state, access) {
|
|
47
|
+
return access ? 'signed-in' : state.writers;
|
|
48
|
+
}
|
|
49
|
+
export function slotVisibility(state, slot) {
|
|
50
|
+
return state.slots[slot]?.visibility ?? state.visibility;
|
|
51
|
+
}
|
|
52
|
+
function isJson(v) {
|
|
53
|
+
if (v === null || typeof v === 'string' || typeof v === 'boolean')
|
|
54
|
+
return true;
|
|
55
|
+
if (typeof v === 'number')
|
|
56
|
+
return Number.isFinite(v);
|
|
57
|
+
if (Array.isArray(v))
|
|
58
|
+
return v.every(isJson);
|
|
59
|
+
if (isMap(v))
|
|
60
|
+
return Object.values(v).every(isJson);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
export function checkValue(value) {
|
|
64
|
+
if (value === undefined)
|
|
65
|
+
return 'value is required';
|
|
66
|
+
if (!isJson(value))
|
|
67
|
+
return 'value must be JSON';
|
|
68
|
+
if (Buffer.byteLength(JSON.stringify(value), 'utf8') > MAX_VALUE_BYTES)
|
|
69
|
+
return 'value is over 8 KB';
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
/** Answers are kept across a republish, so a slot may never change what shape its data is. */
|
|
73
|
+
export function shapeChanges(prev, next) {
|
|
74
|
+
if (!prev || !next)
|
|
75
|
+
return [];
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const [name, def] of Object.entries(next.slots)) {
|
|
78
|
+
const was = prev.slots[name];
|
|
79
|
+
if (was && was.shape !== def.shape)
|
|
80
|
+
out.push(`slot "${name}" changed shape from ${was.shape} to ${def.shape}; rename the slot instead`);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
package/lib/artifacts/store.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Firestore } from 'firebase-admin/firestore';
|
|
2
2
|
import type { ArtifactMeta } from './front-matter.js';
|
|
3
3
|
import type { Access } from './reader.js';
|
|
4
|
+
import type { StateConfig } from './state.js';
|
|
4
5
|
export type ArtifactRecord = {
|
|
5
6
|
id: string;
|
|
6
7
|
title: string;
|
|
@@ -15,6 +16,7 @@ export type ArtifactRecord = {
|
|
|
15
16
|
narrationHash?: string;
|
|
16
17
|
password?: string;
|
|
17
18
|
access?: Access;
|
|
19
|
+
state?: StateConfig;
|
|
18
20
|
markdown: string;
|
|
19
21
|
createdAt: string;
|
|
20
22
|
updatedAt: string;
|
package/lib/artifacts/store.js
CHANGED
|
@@ -56,6 +56,7 @@ async function saveArtifact(col, input) {
|
|
|
56
56
|
...(input.meta.narrationHash ? { narrationHash: input.meta.narrationHash } : {}),
|
|
57
57
|
...(input.meta.password ? { password: input.meta.password } : {}),
|
|
58
58
|
...(input.meta.access && input.meta.access !== 'public' ? { access: input.meta.access } : {}),
|
|
59
|
+
...(input.meta.state ? { state: input.meta.state } : {}),
|
|
59
60
|
};
|
|
60
61
|
if (input.id) {
|
|
61
62
|
const existing = await getArtifact(col, input.id);
|
|
@@ -79,8 +80,10 @@ async function saveArtifact(col, input) {
|
|
|
79
80
|
// Access is the opposite of password on purpose: absent leaves it alone, and only an explicit
|
|
80
81
|
// `access: public` opens the page. Reopening a confidential page must never be a side effect.
|
|
81
82
|
const access = input.meta.access === 'public' ? { access: FieldValue.delete() } : {};
|
|
83
|
+
// Content, like the body: a republish that no longer declares state removes the slots.
|
|
84
|
+
const state = input.meta.state ? {} : { state: FieldValue.delete() };
|
|
82
85
|
await ref.update({
|
|
83
|
-
...fields, ...password, ...subtitle, ...access, markdown: input.markdown, updatedAt: now, version: next,
|
|
86
|
+
...fields, ...password, ...subtitle, ...access, ...state, markdown: input.markdown, updatedAt: now, version: next,
|
|
84
87
|
...(existing.versions ? { versions: FieldValue.delete() } : {}),
|
|
85
88
|
});
|
|
86
89
|
return { id: input.id, version: next, created: false };
|
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
2
|
import { NextRequest, NextResponse } from 'next/server';
|
|
3
3
|
import type { ArtifactStore } from '../artifacts/store.js';
|
|
4
|
+
import type { StateStore } from '../artifacts/state-store.js';
|
|
4
5
|
import { type ArtifactAssets } from '../artifacts/assets.js';
|
|
5
6
|
import type { BrandPack } from '../brand/pack.js';
|
|
6
7
|
import { type Access } from '../artifacts/reader.js';
|
|
7
8
|
import { type ReadersStore } from '../artifacts/readers-store.js';
|
|
8
9
|
export type ArtifactRoutesConfig = {
|
|
9
10
|
store: ArtifactStore;
|
|
11
|
+
/** Where readers' answers live. Without it every state route answers 501. */
|
|
12
|
+
state?: StateStore;
|
|
10
13
|
/** Where uploaded files go. Optional; without it PUT_ASSET answers 501. */
|
|
11
14
|
assets?: ArtifactAssets;
|
|
12
15
|
brand: BrandPack;
|
|
@@ -31,6 +34,10 @@ export type ArtifactRoutesConfig = {
|
|
|
31
34
|
signInOrigin?: string;
|
|
32
35
|
/** Who the banner says grants access, e.g. "Example Co". Default the brand name. */
|
|
33
36
|
owner?: string;
|
|
37
|
+
/** The address the state routes' rate limit counts by. Default reads the first hop of
|
|
38
|
+
* `x-forwarded-for`, which is correct on Vercel (it overwrites XFF with the real client IP)
|
|
39
|
+
* and wrong behind any other proxy that appends rather than replaces; pass this there. */
|
|
40
|
+
clientIp?: (req: NextRequest) => string;
|
|
34
41
|
};
|
|
35
42
|
/** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
|
|
36
43
|
export declare const ARTIFACT_ID: RegExp;
|
|
@@ -45,11 +52,29 @@ type PageProps = Params & {
|
|
|
45
52
|
}>;
|
|
46
53
|
};
|
|
47
54
|
export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
|
|
55
|
+
dynamic: "force-dynamic";
|
|
56
|
+
maxDuration: number;
|
|
57
|
+
STATE_GET: (req: NextRequest, { params }: {
|
|
58
|
+
params: Promise<{
|
|
59
|
+
id: string;
|
|
60
|
+
}>;
|
|
61
|
+
}) => Promise<NextResponse<unknown> | undefined>;
|
|
62
|
+
STATE_POST: (req: NextRequest, { params }: {
|
|
63
|
+
params: Promise<{
|
|
64
|
+
id: string;
|
|
65
|
+
}>;
|
|
66
|
+
}) => Promise<NextResponse<unknown> | undefined>;
|
|
67
|
+
RESPONSES: (req: NextRequest, { params }: {
|
|
68
|
+
params: Promise<{
|
|
69
|
+
id: string;
|
|
70
|
+
}>;
|
|
71
|
+
}) => Promise<NextResponse<unknown>>;
|
|
48
72
|
Page: ({ params, searchParams }: PageProps) => Promise<import("react").JSX.Element>;
|
|
49
73
|
generateMetadata: ({ params }: Params) => Promise<Metadata>;
|
|
50
74
|
POST: (request: NextRequest) => Promise<NextResponse<{
|
|
51
75
|
error: string;
|
|
52
76
|
}> | NextResponse<{
|
|
77
|
+
warning?: string | undefined;
|
|
53
78
|
id: string;
|
|
54
79
|
url: string;
|
|
55
80
|
version: number;
|
|
@@ -109,7 +134,5 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
|
|
|
109
134
|
access: Access | null;
|
|
110
135
|
views: number;
|
|
111
136
|
}>>;
|
|
112
|
-
dynamic: "force-dynamic";
|
|
113
|
-
maxDuration: number;
|
|
114
137
|
};
|
|
115
138
|
export {};
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -8,6 +8,7 @@ import { narrationText } from '../artifacts/narration.js';
|
|
|
8
8
|
import { ArtifactMarkdown } from '../artifacts/render.js';
|
|
9
9
|
import { ArtifactDoor } from '../artifacts/door.js';
|
|
10
10
|
import { isUnlocked, keyHash, unlockCookieName } from '../artifacts/unlock.js';
|
|
11
|
+
import { shapeChanges } from '../artifacts/state.js';
|
|
11
12
|
import { ASSET_NAME, contentTypeFor } from '../artifacts/assets.js';
|
|
12
13
|
import { BrandGround } from '../brand/wrapper.js';
|
|
13
14
|
import { renderShareCard } from '../brand/share-card.js';
|
|
@@ -16,8 +17,10 @@ import { ReaderWatch } from '../reader/reader-watch.js';
|
|
|
16
17
|
import { ACCESS_LEVELS, GRANT_COOKIE, GRANT_TTL_SECONDS, decide, firstName, mintGrant, safeReturnPath, signInUrl, verifyGrant, verifyPass, } from '../artifacts/reader.js';
|
|
17
18
|
import { FLAG_KINDS, summarize } from '../artifacts/readers-store.js';
|
|
18
19
|
import { AckDoor, ConfidentialBanner, NO_PRINT_CSS, NotAllowedDoor, SignInDoor, Watermark, ackText } from '../artifacts/confidential.js';
|
|
20
|
+
import { ARTIFACT_ID_RE } from './ids.js';
|
|
21
|
+
import { createStateRoutes } from './state-routes.js';
|
|
19
22
|
/** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
|
|
20
|
-
export const ARTIFACT_ID =
|
|
23
|
+
export const ARTIFACT_ID = ARTIFACT_ID_RE;
|
|
21
24
|
async function defaultReadCookie(name) {
|
|
22
25
|
const { cookies } = await import('next/headers');
|
|
23
26
|
return (await cookies()).get(name)?.value;
|
|
@@ -34,6 +37,10 @@ export function createArtifactRoutes(config) {
|
|
|
34
37
|
const readerSecret = () => config.readerSecret?.();
|
|
35
38
|
const signOutUrl = (id) => `/api/reader/leave?to=${encodeURIComponent(pagePath(id))}`;
|
|
36
39
|
const shareCardUrl = (id, updatedAt) => `${pageUrl(id)}/share.png?v=${encodeURIComponent(updatedAt)}`;
|
|
40
|
+
const stateRoutes = createStateRoutes({
|
|
41
|
+
store, state: config.state, readers: config.readers, readerSecret, publishKey: config.publishKey, pageUrl, signInOrigin, siteUrl,
|
|
42
|
+
clientIp: config.clientIp,
|
|
43
|
+
});
|
|
37
44
|
async function generateMetadata({ params }) {
|
|
38
45
|
const { id } = await params;
|
|
39
46
|
const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
|
|
@@ -78,8 +85,11 @@ export function createArtifactRoutes(config) {
|
|
|
78
85
|
const open = isUnlocked({ id, password: a.password, key, cookie });
|
|
79
86
|
// Opened by the key in the URL: remember it in a cookie holding the hash, never the
|
|
80
87
|
// password, scoped to this page, so a refresh or a shared device does not ask again.
|
|
88
|
+
// The same cookie again on the page's state API: a cookie scoped to the page path is never
|
|
89
|
+
// sent to /api/artifacts/<id>/state, so without it a password page could not take answers.
|
|
90
|
+
const unlockLine = (path) => `${unlockCookieName(id)}=${keyHash(id, a.password)}; Path=${path}; Max-Age=31536000; SameSite=Lax; Secure`;
|
|
81
91
|
const remember = open && a.password && key !== undefined && cookie !== keyHash(id, a.password)
|
|
82
|
-
? `document.cookie=${JSON.stringify(
|
|
92
|
+
? [pagePath(id), `/api/artifacts/${id}`].map((p) => `document.cookie=${JSON.stringify(unlockLine(p))}`).join(';')
|
|
83
93
|
: null;
|
|
84
94
|
if (!open) {
|
|
85
95
|
return (_jsxs(BrandGround, { pack: brand, children: [_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary })] }), _jsx(ArtifactDoor, { brand: brand, wrongKey: key !== undefined })] }));
|
|
@@ -281,11 +291,38 @@ export function createArtifactRoutes(config) {
|
|
|
281
291
|
if (!parsed.ok)
|
|
282
292
|
return NextResponse.json({ error: parsed.error }, { status: 400 });
|
|
283
293
|
const id = request.nextUrl.searchParams.get('id') ?? parsed.meta.id ?? undefined;
|
|
294
|
+
if (id && parsed.meta.state) {
|
|
295
|
+
const existing = await store.get(id);
|
|
296
|
+
const changed = shapeChanges(existing?.state, parsed.meta.state);
|
|
297
|
+
if (changed.length)
|
|
298
|
+
return NextResponse.json({ error: changed.join('; ') }, { status: 400 });
|
|
299
|
+
// The declared shape can be dodged by republishing once with `state` omitted (which clears
|
|
300
|
+
// it) and then again with the slot's shape flipped: `existing.state` reads as undefined at
|
|
301
|
+
// that final publish, so the check above sees nothing to compare against. The ANSWERS
|
|
302
|
+
// never went anywhere, so the truth is in what is actually stored, not in the file.
|
|
303
|
+
if (config.state) {
|
|
304
|
+
const shapeOf = new Map();
|
|
305
|
+
for (const e of await config.state.entries(id))
|
|
306
|
+
if (!shapeOf.has(e.slot))
|
|
307
|
+
shapeOf.set(e.slot, e.shape);
|
|
308
|
+
const dodged = [];
|
|
309
|
+
for (const [name, def] of Object.entries(parsed.meta.state.slots)) {
|
|
310
|
+
const was = shapeOf.get(name);
|
|
311
|
+
if (was && was !== def.shape)
|
|
312
|
+
dodged.push(`slot "${name}" changed shape from ${was} to ${def.shape}; rename the slot instead`);
|
|
313
|
+
}
|
|
314
|
+
if (dodged.length)
|
|
315
|
+
return NextResponse.json({ error: dodged.join('; ') }, { status: 400 });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
284
318
|
const result = await store.save({ id, meta: parsed.meta, markdown: parsed.body });
|
|
285
319
|
if ('notFound' in result)
|
|
286
320
|
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
287
321
|
revalidatePath(pagePath(result.id));
|
|
288
|
-
return NextResponse.json({
|
|
322
|
+
return NextResponse.json({
|
|
323
|
+
id: result.id, url: pageUrl(result.id), version: result.version,
|
|
324
|
+
...(parsed.meta.state && !config.state ? { warning: 'this host keeps no answers; state: is stored but inert' } : {}),
|
|
325
|
+
}, { status: result.created ? 201 : 200 });
|
|
289
326
|
}
|
|
290
327
|
async function GET(request, { params }) {
|
|
291
328
|
if (!isPublishAuthed(request, config.publishKey()))
|
|
@@ -341,5 +378,5 @@ export function createArtifactRoutes(config) {
|
|
|
341
378
|
revalidatePath(pagePath(id));
|
|
342
379
|
return NextResponse.json({ deleted: true });
|
|
343
380
|
}
|
|
344
|
-
return { Page, generateMetadata, POST, GET, DELETE, PUT_ASSET, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, dynamic: 'force-dynamic', maxDuration: 30 };
|
|
381
|
+
return { Page, generateMetadata, POST, GET, DELETE, PUT_ASSET, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, ...stateRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
|
|
345
382
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const ARTIFACT_ID_RE: RegExp;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { type StateStore } from '../artifacts/state-store.js';
|
|
3
|
+
import type { ArtifactStore } from '../artifacts/store.js';
|
|
4
|
+
import type { ReadersStore } from '../artifacts/readers-store.js';
|
|
5
|
+
export declare const ANON_COOKIE = "artifact_anon";
|
|
6
|
+
/** The id an anonymous writer's rate counter is stored under. An HMAC over the site and the IP,
|
|
7
|
+
* keyed by the reader secret when the host has one (so the stored value cannot be reversed by
|
|
8
|
+
* hashing the IPv4 space), else by the site URL. The site is in the message either way, so two
|
|
9
|
+
* hosts sharing a secret still count one visitor under unrelated ids. */
|
|
10
|
+
export declare function rateKey(ip: string, siteUrl: string, secret?: string): string;
|
|
11
|
+
export type StateRoutesContext = {
|
|
12
|
+
store: ArtifactStore;
|
|
13
|
+
state?: StateStore;
|
|
14
|
+
readers?: ReadersStore;
|
|
15
|
+
readerSecret: () => string | undefined;
|
|
16
|
+
publishKey: () => string | undefined;
|
|
17
|
+
pageUrl: (id: string) => string;
|
|
18
|
+
signInOrigin?: string;
|
|
19
|
+
siteUrl: string;
|
|
20
|
+
/** The address the rate limit counts by. Default reads the first hop of `x-forwarded-for`,
|
|
21
|
+
* which is correct on Vercel (it overwrites XFF with the real client IP) and wrong behind any
|
|
22
|
+
* other proxy that appends rather than replaces; a host behind one of those passes its own. */
|
|
23
|
+
clientIp?: (req: NextRequest) => string;
|
|
24
|
+
};
|
|
25
|
+
type Params = {
|
|
26
|
+
params: Promise<{
|
|
27
|
+
id: string;
|
|
28
|
+
}>;
|
|
29
|
+
};
|
|
30
|
+
export declare function createStateRoutes(ctx: StateRoutesContext): {
|
|
31
|
+
STATE_GET: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown> | undefined>;
|
|
32
|
+
STATE_POST: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown> | undefined>;
|
|
33
|
+
RESPONSES: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
|
|
34
|
+
};
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// The state routes: what readers put into a page, and what the publisher gets back.
|
|
2
|
+
//
|
|
3
|
+
// GET /api/artifacts/<id>/state the reader's own answers + what the page lets them see
|
|
4
|
+
// POST /api/artifacts/<id>/state { slot, op: set|append|remove, value?, entry? }
|
|
5
|
+
// GET /api/artifacts/<id>/responses publish key; every answer with who (?format=csv)
|
|
6
|
+
// DELETE /api/artifacts/<id>/responses?reader=<key> publish key; one reader's answers
|
|
7
|
+
//
|
|
8
|
+
// Who is writing: the grant a gated page already uses, or on a page with `writers: anyone`, an
|
|
9
|
+
// anonymous id in an HttpOnly cookie. When both are present the anonymous answers move to the
|
|
10
|
+
// signed-in reader, once, and the cookie is cleared.
|
|
11
|
+
import { createHmac, randomBytes } from 'node:crypto';
|
|
12
|
+
import { NextResponse } from 'next/server';
|
|
13
|
+
import { isPublishAuthed } from '../artifacts/auth.js';
|
|
14
|
+
import { ARTIFACT_ID_RE } from './ids.js';
|
|
15
|
+
import { GRANT_COOKIE, decide, firstName, signInUrl, verifyGrant } from '../artifacts/reader.js';
|
|
16
|
+
import { isUnlocked, unlockCookieName } from '../artifacts/unlock.js';
|
|
17
|
+
import { ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, checkValue, effectiveWriters } from '../artifacts/state.js';
|
|
18
|
+
import { readerKeyFor } from '../artifacts/state-store.js';
|
|
19
|
+
import { responsesCsv, responsesOf, stateView } from '../artifacts/state-view.js';
|
|
20
|
+
export const ANON_COOKIE = 'artifact_anon';
|
|
21
|
+
const ANON_ID = /^[A-Za-z0-9]{24}$/;
|
|
22
|
+
const MAX_BODY = 16 * 1024;
|
|
23
|
+
/** The id an anonymous writer's rate counter is stored under. An HMAC over the site and the IP,
|
|
24
|
+
* keyed by the reader secret when the host has one (so the stored value cannot be reversed by
|
|
25
|
+
* hashing the IPv4 space), else by the site URL. The site is in the message either way, so two
|
|
26
|
+
* hosts sharing a secret still count one visitor under unrelated ids. */
|
|
27
|
+
export function rateKey(ip, siteUrl, secret) {
|
|
28
|
+
return createHmac('sha256', secret || siteUrl).update(`${siteUrl}|${ip}`).digest('hex').slice(0, 16);
|
|
29
|
+
}
|
|
30
|
+
export function createStateRoutes(ctx) {
|
|
31
|
+
const anonCookie = (v, maxAge) => `${ANON_COOKIE}=${v}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
|
|
32
|
+
const json = (body, status = 200) => NextResponse.json(body, { status, headers: { 'cache-control': 'no-store' } });
|
|
33
|
+
const newAnonId = () => { let s = ''; while (s.length < 24)
|
|
34
|
+
s += randomBytes(24).toString('base64').replace(/[^A-Za-z0-9]/g, ''); return s.slice(0, 24); };
|
|
35
|
+
const defaultClientIp = (req) => (req.headers.get('x-forwarded-for') ?? '').split(',')[0].trim();
|
|
36
|
+
const ipHash = (req) => rateKey((ctx.clientIp ?? defaultClientIp)(req), ctx.siteUrl, ctx.readerSecret());
|
|
37
|
+
const siteOrigin = new URL(ctx.siteUrl).origin;
|
|
38
|
+
/** Resolve the page and who is asking, or the refusal. Shared by GET and POST. */
|
|
39
|
+
async function open(req, id) {
|
|
40
|
+
if (!ctx.state)
|
|
41
|
+
return { error: json({ error: 'this host keeps no answers' }, 501) };
|
|
42
|
+
const a = ARTIFACT_ID_RE.test(id) ? await ctx.store.get(id) : null;
|
|
43
|
+
if (!a)
|
|
44
|
+
return { error: json({ error: `no artifact with id ${id}` }, 404) };
|
|
45
|
+
if (!a.state)
|
|
46
|
+
return { error: json({ error: 'this page takes no answers' }, 404) };
|
|
47
|
+
if (a.password && !isUnlocked({ id, password: a.password, cookie: req.cookies.get(unlockCookieName(id))?.value }))
|
|
48
|
+
return { error: json({ error: 'this page is locked' }, 403) };
|
|
49
|
+
const reader = verifyGrant(ctx.readerSecret(), req.cookies.get(GRANT_COOKIE)?.value);
|
|
50
|
+
const signIn = ctx.signInOrigin ? signInUrl(ctx.signInOrigin, ctx.pageUrl(id)) : null;
|
|
51
|
+
if (a.access) {
|
|
52
|
+
if (!ctx.readers)
|
|
53
|
+
return { error: json({ error: 'this page is closed' }, 403) };
|
|
54
|
+
if (!reader)
|
|
55
|
+
return { error: json({ error: 'sign in to answer', signIn }, 401) };
|
|
56
|
+
if (!decide(a.access, reader, await ctx.readers.allowList(id)).open)
|
|
57
|
+
return { error: json({ error: 'this page is not open to you' }, 403) };
|
|
58
|
+
// The page shows its body only after the agreement, so its answers wait for it too.
|
|
59
|
+
if (!(await ctx.readers.acknowledged(id, reader.email)))
|
|
60
|
+
return { error: json({ error: 'accept the agreement first' }, 403) };
|
|
61
|
+
}
|
|
62
|
+
const rawAnon = req.cookies.get(ANON_COOKIE)?.value;
|
|
63
|
+
const anonId = rawAnon && ANON_ID.test(rawAnon) ? rawAnon : null;
|
|
64
|
+
return { a, reader, anonId, signIn };
|
|
65
|
+
}
|
|
66
|
+
const writerFor = (r) => ({ key: readerKeyFor.signedIn(r.uid), uid: r.uid, email: r.email, name: r.name, anonymous: false });
|
|
67
|
+
async function viewBody(a, reader, readerKey) {
|
|
68
|
+
const allow = reader && ctx.readers && a.access ? await ctx.readers.allowList(a.id) : [];
|
|
69
|
+
return {
|
|
70
|
+
reader: reader ? { firstName: firstName(reader, allow) } : null,
|
|
71
|
+
canWrite: !!reader || effectiveWriters(a.state, a.access) === 'anyone',
|
|
72
|
+
slots: stateView(a.state, await ctx.state.entries(a.id), readerKey),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// A GET that writes, on purpose: when a signed-in reader still carries an anonymous cookie, the
|
|
76
|
+
// answers move here, because this is the first request that sees both. It is safe as a GET:
|
|
77
|
+
// moveReader is idempotent (a second run finds nothing under the cleared key), and the grant
|
|
78
|
+
// cookie is SameSite=Lax, so a cross-site page can trigger it only by top-level navigation,
|
|
79
|
+
// which moves the reader's own answers onto the reader's own account and nothing else.
|
|
80
|
+
async function STATE_GET(req, { params }) {
|
|
81
|
+
const { id } = await params;
|
|
82
|
+
const o = await open(req, id);
|
|
83
|
+
if ('error' in o)
|
|
84
|
+
return o.error;
|
|
85
|
+
const { a, reader, anonId, signIn } = o;
|
|
86
|
+
let clearAnon = false;
|
|
87
|
+
if (reader && anonId) {
|
|
88
|
+
await ctx.state.moveReader(readerKeyFor.anonymous(anonId), writerFor(reader));
|
|
89
|
+
clearAnon = true;
|
|
90
|
+
}
|
|
91
|
+
const key = reader ? readerKeyFor.signedIn(reader.uid) : anonId ? readerKeyFor.anonymous(anonId) : null;
|
|
92
|
+
const res = json({ ...(await viewBody(a, reader, key)), ...(reader ? {} : { signIn }) });
|
|
93
|
+
if (clearAnon)
|
|
94
|
+
res.headers.append('set-cookie', anonCookie('', 0));
|
|
95
|
+
return res;
|
|
96
|
+
}
|
|
97
|
+
async function STATE_POST(req, { params }) {
|
|
98
|
+
const { id } = await params;
|
|
99
|
+
// A cross-site form or fetch carries the reader's cookies (anonymous or Lax grant on a
|
|
100
|
+
// top-level POST); a browser always names its Origin on a POST, so a foreign one is refused.
|
|
101
|
+
const origin = req.headers.get('origin');
|
|
102
|
+
if (origin !== null && origin !== siteOrigin)
|
|
103
|
+
return json({ error: 'wrong origin' }, 403);
|
|
104
|
+
// Refuse on the declared length before reading a byte: a client naming an oversize body
|
|
105
|
+
// does not get the server to buffer it first.
|
|
106
|
+
const declaredLength = Number(req.headers.get('content-length') ?? '');
|
|
107
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY)
|
|
108
|
+
return json({ error: 'request over 16 KB' }, 413);
|
|
109
|
+
const raw = await req.text();
|
|
110
|
+
// Measured in bytes, not JS string length: a string of multi-byte characters can sit under
|
|
111
|
+
// the code-unit count and over the byte cap the limit is actually about.
|
|
112
|
+
if (Buffer.byteLength(raw, 'utf8') > MAX_BODY)
|
|
113
|
+
return json({ error: 'request over 16 KB' }, 413);
|
|
114
|
+
let b;
|
|
115
|
+
try {
|
|
116
|
+
const parsed = JSON.parse(raw);
|
|
117
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
118
|
+
return json({ error: 'body must be JSON' }, 400);
|
|
119
|
+
b = parsed;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return json({ error: 'body must be JSON' }, 400);
|
|
123
|
+
}
|
|
124
|
+
const o = await open(req, id);
|
|
125
|
+
if ('error' in o)
|
|
126
|
+
return o.error;
|
|
127
|
+
const { a, reader, signIn } = o;
|
|
128
|
+
let { anonId } = o;
|
|
129
|
+
const slot = typeof b.slot === 'string' ? b.slot : '';
|
|
130
|
+
// Object.hasOwn, never a bracket read: `slots['__proto__']` or `slots['constructor']`
|
|
131
|
+
// resolves through the prototype chain to a real (truthy) object that names no slot.
|
|
132
|
+
const def = Object.hasOwn(a.state.slots, slot) ? a.state.slots[slot] : undefined;
|
|
133
|
+
if (!def)
|
|
134
|
+
return json({ error: `no slot named ${slot} on this page` }, 400);
|
|
135
|
+
const op = b.op;
|
|
136
|
+
if (op !== 'set' && op !== 'append' && op !== 'remove')
|
|
137
|
+
return json({ error: 'op must be set, append or remove' }, 400);
|
|
138
|
+
if (op === 'set' && def.shape !== 'one')
|
|
139
|
+
return json({ error: `slot ${slot} takes append` }, 400);
|
|
140
|
+
if (op === 'append' && def.shape !== 'many')
|
|
141
|
+
return json({ error: `slot ${slot} takes set` }, 400);
|
|
142
|
+
let setCookie = null;
|
|
143
|
+
let writer;
|
|
144
|
+
if (reader)
|
|
145
|
+
writer = writerFor(reader);
|
|
146
|
+
else {
|
|
147
|
+
if (effectiveWriters(a.state, a.access) !== 'anyone')
|
|
148
|
+
return json({ error: 'sign in to answer', signIn }, 401);
|
|
149
|
+
// A remove adds nothing, so it is never counted and never mints a cookie: with no cookie
|
|
150
|
+
// there is nothing of theirs to remove, and the answer is simply the page as it stands.
|
|
151
|
+
if (op === 'remove') {
|
|
152
|
+
if (!anonId)
|
|
153
|
+
return json(await viewBody(a, null, null));
|
|
154
|
+
const key = readerKeyFor.anonymous(anonId);
|
|
155
|
+
await ctx.state.remove({ artifactId: id, slot, readerKey: key, entryId: typeof b.entry === 'string' ? b.entry : undefined });
|
|
156
|
+
return json(await viewBody(a, null, key));
|
|
157
|
+
}
|
|
158
|
+
const n = await ctx.state.countAnonWrite(id, ipHash(req), Math.floor(Date.now() / 60000));
|
|
159
|
+
if (n > ANON_WRITES_PER_MINUTE)
|
|
160
|
+
return json({ error: 'too many answers from here; try again in a minute' }, 429);
|
|
161
|
+
if (!anonId) {
|
|
162
|
+
anonId = newAnonId();
|
|
163
|
+
setCookie = anonCookie(anonId, 31536000);
|
|
164
|
+
}
|
|
165
|
+
writer = { key: readerKeyFor.anonymous(anonId), name: null, anonymous: true };
|
|
166
|
+
}
|
|
167
|
+
if (op === 'remove') {
|
|
168
|
+
await ctx.state.remove({ artifactId: id, slot, readerKey: writer.key, entryId: typeof b.entry === 'string' ? b.entry : undefined });
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
const bad = checkValue(b.value);
|
|
172
|
+
if (bad)
|
|
173
|
+
return json({ error: bad }, 400);
|
|
174
|
+
// The page-wide cap per slot. Counted before the write and not atomically with it, so a
|
|
175
|
+
// burst can overshoot by the writes in flight; it bounds the slot, it is not a quota.
|
|
176
|
+
if ((await ctx.state.countSlot(id, slot)) >= MAX_ENTRIES_PER_SLOT) {
|
|
177
|
+
const replacing = op === 'set' && (await ctx.state.entries(id)).some((e) => e.slot === slot && e.readerKey === writer.key);
|
|
178
|
+
if (!replacing)
|
|
179
|
+
return json({ error: 'this page is not taking more answers here' }, 409);
|
|
180
|
+
}
|
|
181
|
+
const r = op === 'set'
|
|
182
|
+
? await ctx.state.set({ artifactId: id, slot, writer, value: b.value })
|
|
183
|
+
: await ctx.state.append({ artifactId: id, slot, writer, value: b.value });
|
|
184
|
+
if ('full' in r)
|
|
185
|
+
return json({ error: 'you have left the most answers this page takes here' }, 409);
|
|
186
|
+
}
|
|
187
|
+
const res = json(await viewBody(a, reader, writer.key));
|
|
188
|
+
if (setCookie)
|
|
189
|
+
res.headers.append('set-cookie', setCookie);
|
|
190
|
+
return res;
|
|
191
|
+
}
|
|
192
|
+
async function RESPONSES(req, { params }) {
|
|
193
|
+
if (!isPublishAuthed(req, ctx.publishKey()))
|
|
194
|
+
return json({ error: 'Unauthorized' }, 401);
|
|
195
|
+
if (!ctx.state)
|
|
196
|
+
return json({ error: 'this host keeps no answers' }, 501);
|
|
197
|
+
const { id } = await params;
|
|
198
|
+
const a = ARTIFACT_ID_RE.test(id) ? await ctx.store.get(id) : null;
|
|
199
|
+
if (!a)
|
|
200
|
+
return json({ error: `no artifact with id ${id}` }, 404);
|
|
201
|
+
if (req.method === 'DELETE') {
|
|
202
|
+
const key = req.nextUrl.searchParams.get('reader') ?? '';
|
|
203
|
+
if (!/^[ua]:[A-Za-z0-9_-]{1,128}$/.test(key))
|
|
204
|
+
return json({ error: 'reader must be a reader key like u:<uid> or a:<id>' }, 400);
|
|
205
|
+
return json({ removed: await ctx.state.removeReader(id, key) });
|
|
206
|
+
}
|
|
207
|
+
const rows = responsesOf(await ctx.state.entries(id));
|
|
208
|
+
if (req.nextUrl.searchParams.get('format') === 'csv')
|
|
209
|
+
return new NextResponse(responsesCsv(rows), { headers: { 'content-type': 'text/csv; charset=utf-8', 'cache-control': 'no-store' } });
|
|
210
|
+
return json({ id, title: a.title, responses: rows });
|
|
211
|
+
}
|
|
212
|
+
return { STATE_GET, STATE_POST, RESPONSES };
|
|
213
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|
|
@@ -10,16 +10,41 @@
|
|
|
10
10
|
},
|
|
11
11
|
"homepage": "https://github.com/SupersuitUp/artifacts#readme",
|
|
12
12
|
"bugs": "https://github.com/SupersuitUp/artifacts/issues",
|
|
13
|
-
"keywords": [
|
|
13
|
+
"keywords": [
|
|
14
|
+
"nextjs",
|
|
15
|
+
"markdown",
|
|
16
|
+
"publishing",
|
|
17
|
+
"firestore",
|
|
18
|
+
"read-aloud",
|
|
19
|
+
"og-image"
|
|
20
|
+
],
|
|
14
21
|
"main": "lib/index.js",
|
|
15
22
|
"types": "lib/index.d.ts",
|
|
16
23
|
"exports": {
|
|
17
|
-
".": {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"./
|
|
22
|
-
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./lib/index.d.ts",
|
|
26
|
+
"default": "./lib/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./artifacts": {
|
|
29
|
+
"types": "./lib/artifacts/index.d.ts",
|
|
30
|
+
"default": "./lib/artifacts/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./routes": {
|
|
33
|
+
"types": "./lib/routes/artifacts.d.ts",
|
|
34
|
+
"default": "./lib/routes/artifacts.js"
|
|
35
|
+
},
|
|
36
|
+
"./brand": {
|
|
37
|
+
"types": "./lib/brand/index.d.ts",
|
|
38
|
+
"default": "./lib/brand/index.js"
|
|
39
|
+
},
|
|
40
|
+
"./reader": {
|
|
41
|
+
"types": "./lib/reader/artifact-reader.d.ts",
|
|
42
|
+
"default": "./lib/reader/artifact-reader.js"
|
|
43
|
+
},
|
|
44
|
+
"./gate": {
|
|
45
|
+
"types": "./lib/gate.d.ts",
|
|
46
|
+
"default": "./lib/gate.js"
|
|
47
|
+
},
|
|
23
48
|
"./fonts/*": "./fonts/*",
|
|
24
49
|
"./package.json": "./package.json"
|
|
25
50
|
},
|
|
@@ -46,7 +71,9 @@
|
|
|
46
71
|
"react-dom": ">=19"
|
|
47
72
|
},
|
|
48
73
|
"peerDependenciesMeta": {
|
|
49
|
-
"@google-cloud/storage": {
|
|
74
|
+
"@google-cloud/storage": {
|
|
75
|
+
"optional": true
|
|
76
|
+
}
|
|
50
77
|
},
|
|
51
78
|
"dependencies": {
|
|
52
79
|
"gray-matter": "^4.0.3",
|
|
@@ -70,5 +97,7 @@
|
|
|
70
97
|
"typescript": "~5.9.0",
|
|
71
98
|
"vitest": "^4.0.18"
|
|
72
99
|
},
|
|
73
|
-
"engines": {
|
|
100
|
+
"engines": {
|
|
101
|
+
"node": ">=20"
|
|
102
|
+
}
|
|
74
103
|
}
|