@supersuit/suggest-a-change 0.1.8 → 0.1.9

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 CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.9 (2026-10-17)
4
+
5
+ - **The door helpers are exported from `./server`.** `json`, `signIn`, `notFound`, `cappedText` and `peek` are what an app's own route wrapper needs to answer as the handlers do and never read a body past a cap. Two apps (cofounders-template, first-years-template) carried byte-identical copies; they now import these. The handlers use the same functions, so there is one copy of the door. No behaviour change.
6
+
3
7
  ## 0.1.8 (2026-10-17)
4
8
 
5
9
  - **The news reaches a person whose entry sits in a menu.** In the Cofounders and Us apps the entry lives in a three-dot menu that stays closed and remounts it on every open, so the 0.1.7 dot was never seen and the toast was drawn inside the closed menu. New in `./client`: `<SuggestNews adapters>`, mounted once at the root, shows the toast with no entry on screen (and keeps the outbox moving); `useSuggestNews(adapters)` returns `{ moved }` for the menu's trigger; `<SuggestDot adapters moved>` draws the entry's dot there (nothing at 0), and the class `sac-dot-host` gives the trigger the `position: relative` it needs. The toast is once per page load and account however many times the entry mounts, whichever of these asks first. `<SuggestEntry>` keeps its own dot and, alone, behaves as in 0.1.7.
package/README.md CHANGED
@@ -212,6 +212,8 @@ A client adapter's `uploadPart` asks `upload-url`, PUTs the bytes straight to st
212
212
 
213
213
  The role stored on a report comes from the signed-in member and the `maintainers` list. A `who` sent by the phone is ignored; a report carrying `timeline`, `status`, `plan` or `decision` is refused. `sniffType(firstBytes)` names a file by its bytes (jpeg, png, webp, heic/heif, gif, mp4/mov, webm, m4a/aac, mp3, wav, ogg/opus; anything else is `null`) and `finalize` refuses a file whose bytes are not the type it was declared as.
214
214
 
215
+ **A wrapper of your own answers in the module's words.** When an app's route has to check a caller or read one field before the handler runs, it uses the same door the handlers use: `json(status, body)` (JSON, never cached), `signIn()` (401) and `notFound()` (404), `cappedText(req, cap)` (the body as text, or a 413 `Response` past `cap` bytes, by the declared length and again by counting what arrives; no body reads as `""`), and `peek(text)` (a JSON object, or `{}` for anything else, for reading one field while the module judges the body). Copying these into an app made a second door that could drift from the first.
216
+
215
217
  **Settle carries a revision.** `agentList` gives each report a `revision`; the Mac sends it back with `settle`, and the store settles only if the report still has exactly that content. A follow-up or file that arrived in between answers 409 `stale`, drops the claim, and the next list offers the report with the addition. Adapters compute the same value with `reportRevision`.
216
218
 
217
219
  **The collector MUST verify.** `agentFile` answers the verified `bytes` and `sha256`; a Mac that downloads the link must check both before it trusts the file.
@@ -10,6 +10,7 @@
10
10
  import { END, FLOW, STATUSES, TOLD, isAcceptedMedia, isReportId, mayMove, meaningFor, validateReport, wordsFor } from '../contract.mjs';
11
11
  import { effectiveLimits } from './rate.js';
12
12
  import { ACCEPTED_TYPES, canonicalType, sameType } from './sniff.js';
13
+ import { json, notFound, signIn, cappedText } from './http.js';
13
14
  /** What a collector saw of a report: its verified files and its follow-ups. A store computes this from its own copy,
14
15
  * inside `settle`, and settles only when it equals what the collector says it collected. */
15
16
  export function reportRevision(r) {
@@ -35,40 +36,17 @@ export const INCOMPLETE_GRACE_MS = 60 * 60_000;
35
36
  const FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
36
37
  const FOLLOW = /^[A-Za-z0-9_-]{1,64}$/;
37
38
  const FORBIDDEN = ['timeline', 'status', 'plan', 'decision', 'files', 'receivedAt', 'followUps', 'claimedBy', 'settledBy'];
38
- const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } });
39
- const notFound = () => json(404, { error: 'not found' });
40
- const signIn = () => json(401, { error: 'sign in first' });
41
39
  const bad = (error) => json(400, { error });
42
40
  const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
43
41
  /** Reads a JSON object body, refusing past `BODY_CAP` by the header and again by counting what arrives. */
44
42
  async function readBody(req, cap = BODY_CAP, optional = false) {
45
- const declared = Number(req.headers.get('content-length'));
46
- if (Number.isFinite(declared) && declared > cap)
47
- return { res: json(413, { error: 'too large' }) };
48
- if (!req.body)
43
+ if (!req.body && !(Number(req.headers.get('content-length')) > cap))
49
44
  return optional ? { body: {} } : { res: bad('a JSON body is needed') };
50
- const reader = req.body.getReader();
51
- const chunks = [];
52
- let n = 0;
53
- for (;;) {
54
- const { done, value } = await reader.read();
55
- if (done)
56
- break;
57
- n += value.byteLength;
58
- if (n > cap) {
59
- await reader.cancel().catch(() => { });
60
- return { res: json(413, { error: 'too large' }) };
61
- }
62
- chunks.push(value);
63
- }
64
- const all = new Uint8Array(n);
65
- let o = 0;
66
- for (const c of chunks) {
67
- all.set(c, o);
68
- o += c.byteLength;
69
- }
45
+ const text = await cappedText(req, cap);
46
+ if (typeof text !== 'string')
47
+ return { res: text };
70
48
  try {
71
- const v = JSON.parse(new TextDecoder().decode(all));
49
+ const v = JSON.parse(text);
72
50
  return isObj(v) ? { body: v } : optional ? { body: {} } : { res: bad('a JSON object is needed') };
73
51
  }
74
52
  catch {
@@ -0,0 +1,10 @@
1
+ /** A JSON response that is never cached. */
2
+ export declare const json: (status: number, body: unknown) => Response;
3
+ /** 401, the module's answer to a caller it does not know. */
4
+ export declare const signIn: () => Response;
5
+ /** 404, the module's answer to a report that is gone or not the caller's (the two are never told apart). */
6
+ export declare const notFound: () => Response;
7
+ /** The body as text, or a 413 once it passes `cap` bytes: by its declared length, and again by counting what arrives. A request with no body reads as ''. */
8
+ export declare function cappedText(req: Request, cap: number): Promise<string | Response>;
9
+ /** A JSON object out of text, or an empty one: the module judges the body itself; a wrapper only peeks at a field. */
10
+ export declare function peek(text: string): Record<string, unknown>;
@@ -0,0 +1,49 @@
1
+ // The door, in the module's own words: the answers the handlers give for "who are you" and "no such report", a body
2
+ // read that stops at a cap, and a peek at one field. Exported so an app's own route wrapper (one that has to check a
3
+ // caller, or read a field, before the module sees the request) answers exactly as the module does and never reads a
4
+ // body past the cap. Web-standard APIs only.
5
+ /** A JSON response that is never cached. */
6
+ export const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } });
7
+ /** 401, the module's answer to a caller it does not know. */
8
+ export const signIn = () => json(401, { error: 'sign in first' });
9
+ /** 404, the module's answer to a report that is gone or not the caller's (the two are never told apart). */
10
+ export const notFound = () => json(404, { error: 'not found' });
11
+ /** The body as text, or a 413 once it passes `cap` bytes: by its declared length, and again by counting what arrives. A request with no body reads as ''. */
12
+ export async function cappedText(req, cap) {
13
+ const declared = Number(req.headers.get('content-length'));
14
+ if (Number.isFinite(declared) && declared > cap)
15
+ return json(413, { error: 'too large' });
16
+ if (!req.body)
17
+ return '';
18
+ const reader = req.body.getReader();
19
+ const chunks = [];
20
+ let n = 0;
21
+ for (;;) {
22
+ const { done, value } = await reader.read();
23
+ if (done)
24
+ break;
25
+ n += value.byteLength;
26
+ if (n > cap) {
27
+ await reader.cancel().catch(() => { });
28
+ return json(413, { error: 'too large' });
29
+ }
30
+ chunks.push(value);
31
+ }
32
+ const all = new Uint8Array(n);
33
+ let o = 0;
34
+ for (const c of chunks) {
35
+ all.set(c, o);
36
+ o += c.byteLength;
37
+ }
38
+ return new TextDecoder().decode(all);
39
+ }
40
+ /** A JSON object out of text, or an empty one: the module judges the body itself; a wrapper only peeks at a field. */
41
+ export function peek(text) {
42
+ try {
43
+ const v = JSON.parse(text);
44
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
45
+ }
46
+ catch {
47
+ return {};
48
+ }
49
+ }
@@ -1,5 +1,6 @@
1
1
  export { createReportHandlers, reportRevision, TOLD, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, HELD_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.ts';
2
2
  export type { HostedStore, StoredReport, TimelineEntry, FileState, FollowUp, TellEvent, SubmitterNews, ReportHandlerOptions } from './handlers.ts';
3
+ export { json, signIn, notFound, cappedText, peek } from './http.ts';
3
4
  export { sniffType, sameType, canonicalType, ACCEPTED_TYPES } from './sniff.ts';
4
5
  export { effectiveLimits } from './rate.ts';
5
6
  export type { Limits } from './rate.ts';
@@ -1,5 +1,6 @@
1
1
  // "@supersuit/suggest-a-change/server": web-standard APIs only, no runtime dependency, nothing from react or the client.
2
2
  export { createReportHandlers, reportRevision, TOLD, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, HELD_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.js';
3
+ export { json, signIn, notFound, cappedText, peek } from './http.js';
3
4
  export { sniffType, sameType, canonicalType, ACCEPTED_TYPES } from './sniff.js';
4
5
  export { effectiveLimits } from './rate.js';
5
6
  export { runStoreConformance, checks as conformanceChecks } from './conformance.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/suggest-a-change",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "The report format and flow behind a Suggest a change button: what a report is, who acts at each step, the screens, and the check every app runs. The contract has zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  import { END, FLOW, STATUSES, TOLD, isAcceptedMedia, isReportId, mayMove, meaningFor, validateReport, wordsFor } from '../contract.mjs'
11
11
  import { effectiveLimits, type Limits } from './rate.ts'
12
12
  import { ACCEPTED_TYPES, canonicalType, sameType } from './sniff.ts'
13
+ import { json, notFound, signIn, cappedText } from './http.ts'
13
14
 
14
15
  /** `by` is the actor (contract FLOW). `holder` is the maintainer whose Mac started or ended the work, written on a being-made
15
16
  * or end entry: the approval on such a report never lapses, and it is how the list and the claim name who has it. */
@@ -129,34 +130,17 @@ const FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/
129
130
  const FOLLOW = /^[A-Za-z0-9_-]{1,64}$/
130
131
  const FORBIDDEN = ['timeline', 'status', 'plan', 'decision', 'files', 'receivedAt', 'followUps', 'claimedBy', 'settledBy']
131
132
 
132
- const json = (status: number, body: unknown): Response =>
133
- new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } })
134
- const notFound = () => json(404, { error: 'not found' })
135
- const signIn = () => json(401, { error: 'sign in first' })
136
133
  const bad = (error: string) => json(400, { error })
137
134
  const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
138
135
 
139
136
  type Body = { body: Record<string, unknown> } | { res: Response }
140
137
  /** Reads a JSON object body, refusing past `BODY_CAP` by the header and again by counting what arrives. */
141
138
  async function readBody(req: Request, cap = BODY_CAP, optional = false): Promise<Body> {
142
- const declared = Number(req.headers.get('content-length'))
143
- if (Number.isFinite(declared) && declared > cap) return { res: json(413, { error: 'too large' }) }
144
- if (!req.body) return optional ? { body: {} } : { res: bad('a JSON body is needed') }
145
- const reader = req.body.getReader()
146
- const chunks: Uint8Array[] = []
147
- let n = 0
148
- for (;;) {
149
- const { done, value } = await reader.read()
150
- if (done) break
151
- n += value.byteLength
152
- if (n > cap) { await reader.cancel().catch(() => {}); return { res: json(413, { error: 'too large' }) } }
153
- chunks.push(value)
154
- }
155
- const all = new Uint8Array(n)
156
- let o = 0
157
- for (const c of chunks) { all.set(c, o); o += c.byteLength }
139
+ if (!req.body && !(Number(req.headers.get('content-length')) > cap)) return optional ? { body: {} } : { res: bad('a JSON body is needed') }
140
+ const text = await cappedText(req, cap)
141
+ if (typeof text !== 'string') return { res: text }
158
142
  try {
159
- const v = JSON.parse(new TextDecoder().decode(all))
143
+ const v = JSON.parse(text)
160
144
  return isObj(v) ? { body: v } : optional ? { body: {} } : { res: bad('a JSON object is needed') }
161
145
  } catch { return optional ? { body: {} } : { res: bad('the body is not JSON') } }
162
146
  }
@@ -0,0 +1,38 @@
1
+ // The door, in the module's own words: the answers the handlers give for "who are you" and "no such report", a body
2
+ // read that stops at a cap, and a peek at one field. Exported so an app's own route wrapper (one that has to check a
3
+ // caller, or read a field, before the module sees the request) answers exactly as the module does and never reads a
4
+ // body past the cap. Web-standard APIs only.
5
+
6
+ /** A JSON response that is never cached. */
7
+ export const json = (status: number, body: unknown): Response =>
8
+ new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } })
9
+ /** 401, the module's answer to a caller it does not know. */
10
+ export const signIn = (): Response => json(401, { error: 'sign in first' })
11
+ /** 404, the module's answer to a report that is gone or not the caller's (the two are never told apart). */
12
+ export const notFound = (): Response => json(404, { error: 'not found' })
13
+
14
+ /** The body as text, or a 413 once it passes `cap` bytes: by its declared length, and again by counting what arrives. A request with no body reads as ''. */
15
+ export async function cappedText(req: Request, cap: number): Promise<string | Response> {
16
+ const declared = Number(req.headers.get('content-length'))
17
+ if (Number.isFinite(declared) && declared > cap) return json(413, { error: 'too large' })
18
+ if (!req.body) return ''
19
+ const reader = req.body.getReader()
20
+ const chunks: Uint8Array[] = []
21
+ let n = 0
22
+ for (;;) {
23
+ const { done, value } = await reader.read()
24
+ if (done) break
25
+ n += value.byteLength
26
+ if (n > cap) { await reader.cancel().catch(() => {}); return json(413, { error: 'too large' }) }
27
+ chunks.push(value)
28
+ }
29
+ const all = new Uint8Array(n)
30
+ let o = 0
31
+ for (const c of chunks) { all.set(c, o); o += c.byteLength }
32
+ return new TextDecoder().decode(all)
33
+ }
34
+
35
+ /** A JSON object out of text, or an empty one: the module judges the body itself; a wrapper only peeks at a field. */
36
+ export function peek(text: string): Record<string, unknown> {
37
+ try { const v = JSON.parse(text) as unknown; return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {} } catch { return {} }
38
+ }
@@ -1,6 +1,7 @@
1
1
  // "@supersuit/suggest-a-change/server": web-standard APIs only, no runtime dependency, nothing from react or the client.
2
2
  export { createReportHandlers, reportRevision, TOLD, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, HELD_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.ts'
3
3
  export type { HostedStore, StoredReport, TimelineEntry, FileState, FollowUp, TellEvent, SubmitterNews, ReportHandlerOptions } from './handlers.ts'
4
+ export { json, signIn, notFound, cappedText, peek } from './http.ts'
4
5
  export { sniffType, sameType, canonicalType, ACCEPTED_TYPES } from './sniff.ts'
5
6
  export { effectiveLimits } from './rate.ts'
6
7
  export type { Limits } from './rate.ts'