@supersuit/suggest-a-change 0.1.12 → 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 +22 -1
- package/README.md +163 -8
- package/dist/client/adapters.d.ts +27 -0
- package/dist/client/context.d.ts +61 -0
- package/dist/client/context.js +279 -0
- package/dist/client/detail.d.ts +2 -2
- package/dist/client/detail.js +12 -8
- package/dist/client/entry.js +10 -5
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +1 -0
- package/dist/client/mine.js +4 -2
- package/dist/client/runtime.js +3 -0
- package/dist/client/seen.d.ts +12 -0
- package/dist/client/seen.js +30 -7
- package/dist/client/sheet.js +64 -4
- package/dist/client/words.d.ts +10 -1
- package/dist/client/words.js +11 -2
- package/dist/contract.d.mts +32 -22
- package/dist/contract.mjs +127 -37
- package/dist/github/api.d.ts +100 -0
- package/dist/github/api.js +164 -0
- package/dist/github/compose.d.ts +152 -0
- package/dist/github/compose.js +309 -0
- package/dist/github/filer.d.ts +72 -0
- package/dist/github/filer.js +89 -0
- package/dist/github/hosts.d.ts +41 -0
- package/dist/github/hosts.js +154 -0
- package/dist/github/index.d.ts +12 -0
- package/dist/github/index.js +9 -0
- package/dist/github/privacy.d.ts +34 -0
- package/dist/github/privacy.js +142 -0
- package/dist/github/relay.d.ts +159 -0
- package/dist/github/relay.js +734 -0
- package/dist/server/geo.d.ts +17 -0
- package/dist/server/geo.js +35 -0
- package/dist/server/handlers.d.ts +54 -3
- package/dist/server/handlers.js +143 -41
- package/dist/server/index.d.ts +3 -1
- package/dist/server/index.js +1 -0
- package/package.json +5 -1
- package/src/client/adapters.ts +18 -1
- package/src/client/context.ts +243 -0
- package/src/client/detail.tsx +13 -8
- package/src/client/entry.tsx +9 -5
- package/src/client/index.ts +2 -0
- package/src/client/mine.tsx +7 -3
- package/src/client/runtime.ts +3 -0
- package/src/client/seen.ts +27 -6
- package/src/client/sheet.tsx +78 -4
- package/src/client/styles.css +11 -2
- package/src/client/words.ts +15 -2
- package/src/contract.mjs +110 -37
- package/src/github/api.ts +169 -0
- package/src/github/compose.ts +354 -0
- package/src/github/filer.ts +96 -0
- package/src/github/hosts.ts +117 -0
- package/src/github/index.ts +15 -0
- package/src/github/privacy.ts +131 -0
- package/src/github/relay.ts +637 -0
- package/src/server/geo.ts +32 -0
- package/src/server/handlers.ts +158 -45
- package/src/server/index.ts +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.2.0 (2026-10-26)
|
|
4
|
+
|
|
5
|
+
A suggestion now becomes one ordinary GitHub issue, with everything it was sent with, in the repo that owns the app. A minor version because the statuses changed: apps on `^0.1` stay on 0.1 until they take this one.
|
|
6
|
+
|
|
7
|
+
- **The statuses are GitHub's.** `STATUSES` is `on-phone`, `received`, `held`, `open`, `done`, `not-planned`; `END` is `done` and `not-planned` (closed); `TOLD` is `open`, `done`, `not-planned`. `FLOW`: on-phone to received, held or open; received to held, open or not-planned; held to open or not-planned; open to done or not-planned; a closed issue goes back to open when it is reopened. The words: Waiting on <owner> (held), Open with "Filed as issue #N", Done, Not planned. `meaningFor(status, { owner, issue })` takes the issue for the number.
|
|
8
|
+
- **The old statuses stay readable.** `LEGACY` folds each into what it now means (with-freeda, planned, waiting-on-owner and being-made to received, because the Mac files them; live, sent-to-template and filed to done; not-now to not-planned) and `currentStatus(s)` applies it. `wordsFor`, `meaningFor`, `mayMove`, the screens, the seen record and the server all read through it, so a stored 0.1 report reads in the new words and an app upgrading its server announces nothing. `agentStatus` writes only the new statuses.
|
|
9
|
+
- **A report may carry `issue: { repo, number, url }`**, checked by `validIssue` (owner/name, a positive whole number, a https://github.com/ link). The phone may not send one (refused like `status`). `agentStatus` takes `issue` on the move to open (required unless the report already has one), stores it on the timeline entry, and the member's view and list carry it.
|
|
10
|
+
- **New export `./github`**, zero dependencies, web-standard fetch (injectable): `privacyCatch`, `redact`, `mayFileAsIs`, `composeIssue` (with a `pending` mode for the first body), `whatGoes` and `destinationWords` (the sheet's list, built from the same pieces as the body, with a parity test), `fileIssue`, `updateIssue`, `addFollowUp`, `issueStates` (one GraphQL call), `issueStatus`, `fileSuggestion` and `attachPending` (the issue first, then each file, then the edit; a failed file keeps saying it is coming and can be retried), the `AttachmentHost` interface with `branchHost` and `relayHost`.
|
|
11
|
+
- **Filing for people with no access to the repo, not even read.** `createSuggestionRelay({ app, identity, repos?, privacy?, limits?, usage? })` is one handler you run, acting as your own GitHub App (contents and issues, read and write), with four actions chosen by the last path segment: `file` (the relay runs the privacy catch, saves the files, then opens the issue as the App, filed by the person's name and never their account id; idempotent by report id), `follow-up` and `status` (only for the identity that filed it), and `attach` (for a filer who opened the issue themselves; `createAttachRelay` is that alone). Daily caps per identity on reports and on bytes; `RelayUsage` is the store behind them, with `memoryUsage()` for a single server instance only. On the filer's side, `relayFiler({ url, headers })`, `canFileDirectly({ repo, token })`, and `fileSuggestion({ repo, token?, relay? }, ...)`, which files directly when the token can see the repo and through the relay otherwise (`via` says which).
|
|
12
|
+
- **Every piece of context that helps fix it, shared by default, each with why.** The module collects, with no app code: the person's name in the app (never the account id), local time with time zone and UTC offset, languages, device, system and browser in plain words (Client Hints, else the user agent), screen, window and pixel ratio, colour scheme and reduced motion, connection, how long the page was open, and a trail kept from first mount in memory only: the last 10 pages, the last 20 taps by name (never an input's value) and the last 10 errors and unhandled rejections (no query strings anywhere). New adapters: `context()` for the app's own items (each with a `why`; `{ items, precise: true }` also asks for a precise location) and `contact()` for how to reach the sender. The sheet lists every item with its value and why, each a tick box; what is unticked is dropped before the report leaves the phone. `who.contact` (email, phone, handle) goes by default to a private repo and is off with the reason for a public one. The contract allows `context` and `who.contact` with their shapes checked, and still refuses an email anywhere else. The issue gets a context table of what, value and why, the trail in `<details>`, and "Reach them" with `mailto:`/`tel:` links; the trail is cut first to fit. `geo(req)` on the handlers and the relay adds roughly where they were from the request (Vercel and Cloudflare headers documented). The privacy catch now reads the context too, and never flags the sender's own email or phone.
|
|
13
|
+
- **Files past a serverless body limit.** A relay takes a file inside a request only up to `limits.inlineBytes` (3 MB). With a `staging` adapter (`uploadUrl`, `read`, `discard`; the GCS and S3 signed PUT shapes are in the README) a new `upload-url` action answers a one-time signed PUT for `staging/<account>/<report>/<name>`, identity-bound, with the day's bytes held up front, and `file` and `attach` take `{ staged: name }`: the relay reads the object, sniffs it, writes it through the git data API (blob, tree, commit) when it is over 1 MB, and discards it. `GET .../capabilities` says what the relay takes; `relayFiler` stages anything over 2 MB itself when it can and refuses before filing when it cannot. Without staging a file over the inline cap is refused in words before anything is filed, and a sheet destination with `maxInlineBytes` names such a file before Send.
|
|
14
|
+
- **The app is the host's, per app.** `createSuggestionRelay({ apps: { <id>: { name, label } } })`: a request's `app` id must be in the table (else 400 "unknown app"), and the issue's app name and `app:<label>` come from it; with no table there is no app label and the issue says "in an app". `identity()` no longer names the app. The server half takes `app.label` beside `app.name`. `composeIssue` takes `appLabel` (null for none).
|
|
15
|
+
- **A second review.** A request listing a file twice is refused, and no request (follow-ups included) carries more of a kind than a report may. A staged read is bounded by the bytes `upload-url` held for it, counted as it streams, and a key it never issued is not read. Every time a report carries is an ISO time of at most 40 characters (`isIsoTime`), the privacy catch reads them, and the composer escapes one that is not a time like any other line. `attach` to an issue the relay did not file needs the opener's GitHub login (`RelayIdentity.githubLogin`), and binds only after that proof. A follow-up is composed and measured before any file is written, and holds to the per-report caps. `RelayUsage.begin` takes a lease that a later try takes over; `stage`, `staged` and `unstage` are new. A request's own app or filer name never reaches the issue. The composer's last line is written and recognized by one pair (`filedByLine`, `endsWithFiledBy`), so attach accepts both "in the <app>" and "in an app". A contents API 422 counts as "already written" only after a re-check finds the file. A filing that made an issue is never abandoned, and the store is asked again if recording it fails. File names go through the privacy catch. Report ids draw on `crypto.getRandomValues`.
|
|
16
|
+
- **Hardened after an independent review.** A relay binds each report to the first identity that files or attaches for it (anyone else is 403), and `attach` accepts only an issue that ENDS with the composer's own `## Filed by` line for that report. With `redacted: true` the relay redacts what it found itself; raw text is never filed on the filer's word. The privacy catch reads every string that reaches the issue (where, every diagnostic, the whole context, the trail, the names, the contact handle), after NFKC folding and removing invisible format characters (`normalize` is exported). The daily caps are reserved atomically up front (`RelayUsage.reserve`, `commit`, `release`, `bind`), and request bodies are capped at what each action needs and never above the day's byte budget. The relay takes the name, role, app and contact from `identity()` (`RelayIdentity`), never the request; the server half takes the contact from `contactOf(member)`, and the phone's `who.contact` only says whether to share it. Every report-borne string in the issue is inert: the words and transcript in a fence longer than any backtick run inside, single-line strings with HTML and markdown escaped and `@`/`#123` broken by a zero-width joiner; only host-produced https attachment links become images. `app:<slug>` comes only from a host-supplied app name, and an issue carries at most ten labels. The trail never takes text from an editable region or a text, search or combo box, turns id-like route segments into `:id`, shares one state across bundled copies, and a reset restores only wrappers that are still its own. A repo name with a `.` or `..` segment is refused everywhere, and a follow-up goes through the host's `repos()` like a filing.
|
|
17
|
+
- **The sheet says exactly what files, and where.** A new optional adapter `destination()` answering `{ repo, public, account, attaches, name? }` replaces the one-line note above Send with the list (`whatGoes`), the repo, whether it is private or public (public in `--sac-danger`), and whose GitHub account files it. Without it the sheet keeps the 0.1 line.
|
|
18
|
+
- **My suggestions and the detail view** show the new words and, when a report has an issue, "Issue #N" as a link (only ever a github.com link).
|
|
19
|
+
- **The server.** `createReportHandlers` takes an optional `github: { repo, token, attachments?, people?, nameOf?, fetch? }` and files each complete report itself: at submit when it has no files, else when its last file is verified, one caller at a time. A privacy hit holds it (`held`, with the kinds found on the entry and never shown to the member); GitHub failing leaves it received for a Mac. Settling no longer moves a report to with-freeda: collecting is not filing. The approval now guards held, filing and closing, and an open issue stays with the Mac that filed it; only that Mac (or anyone, when nobody is named) reopens a closed one. `tellSubmitter` is told on `TOLD` and its news carries `issue`. `runStoreConformance` is unchanged.
|
|
20
|
+
|
|
21
|
+
## 0.1.13 (2026-10-21)
|
|
22
|
+
|
|
23
|
+
- **Old news is no longer toasted on every open.** The page load's one toast went to the newest move the person had not opened, however long ago it happened, so opening the app a day later replayed the same news each time. The phone now keeps what the last page load already had as news (`#view:noticed`, beside the seen record and cleared with it): only a move that is new since that load is toasted, and an older one keeps the dot and the New mark until it is opened. The first load on this release behaves as before. New in `seen.ts`: `fresh(store, id, moves)`.
|
|
24
|
+
- **A toast is a small pill at the bottom, not a bar over the top of the page.** `.sac-toasts` anchored to the top safe area and each `.sac-toast` spanned the width, so the suggestion news shown when an app opened sat over the host's own header and first row. Toasts now sit above the bottom safe area, as wide as their words (up to 24rem), in a smaller type, with a drawn × so they read as dismissable; a tap still dismisses. Styling only: no API or `Words` change. Test: package.test.mjs, red against the old stylesheet.
|
|
4
25
|
|
|
5
26
|
## 0.1.12 (2026-10-19)
|
|
6
27
|
|
package/README.md
CHANGED
|
@@ -12,9 +12,11 @@ npm install @supersuit/suggest-a-change
|
|
|
12
12
|
import { KINDS, STATUSES, FLOW, CEILINGS } from "@supersuit/suggest-a-change";
|
|
13
13
|
|
|
14
14
|
console.log(KINDS); // ["change", "broken", "feedback"]
|
|
15
|
-
console.log(FLOW["
|
|
15
|
+
console.log(FLOW["received"]); // { actor: "app", next: ["held", "open", "not-planned"] }
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
+
A suggestion becomes one ordinary GitHub issue. The statuses a person sees are GitHub's: `on-phone` (not sent yet), `received` (the app has it), `held` (waiting on the owner: the privacy catch stopped it, or the account cannot file yet), `open` (the issue exists), `done` and `not-planned` (closed; a reopened issue goes back to `open`). The 0.1 statuses stay readable: `LEGACY` maps each to the one it now means and `currentStatus(s)` folds it, and every function and screen here reads through it.
|
|
19
|
+
|
|
18
20
|
The package root is the report contract: the kinds, roles, ceilings, the status list and the flow map (who performs each step and which moves are allowed), plus the functions that validate a report against them. It has no imports, so it can also be copied whole into a place that has no install step. If you copy it, keep a hash of the copy and fail a test when the bytes change; fix the contract here and copy it again.
|
|
19
21
|
|
|
20
22
|
The client core is TypeScript, built to `dist/` with declarations. `"./client"` is one entry point that includes the screens, so it needs React 19 installed in the app (the contract at the package root and `"./check"` do not):
|
|
@@ -42,6 +44,7 @@ const adapters: Adapters = {
|
|
|
42
44
|
snapshot: async () => screenshotBlob(), // optional: one tap to attach a picture of this screen
|
|
43
45
|
about: { app: "notes", version: "1.2.3" }, // stamped into every report
|
|
44
46
|
lands: "app", // "mac" on a Helm that posts straight to the owner's Mac
|
|
47
|
+
destination: async () => ({ repo: "acme/notes", public: false, account: "alex-gh", attaches: true, name: "Alex Kim" }), // optional
|
|
45
48
|
};
|
|
46
49
|
// On every screen below home: <SuggestEntry adapters={adapters} onOpen={() => setOpen(true)} />
|
|
47
50
|
// then: {open && <SuggestSheet adapters={adapters} where={{ screen: "Photos", route: location.pathname }} onClose={() => setOpen(false)} />}
|
|
@@ -71,11 +74,36 @@ Styling: structure only, class names prefixed `sac-`. `@supersuit/suggest-a-chan
|
|
|
71
74
|
|
|
72
75
|
The four diagnostics are the app's version, the time, the screen size and the phone type. All four go with every report, with the screen's name and address, and the sheet says so in one small line above Send rather than a section of switches.
|
|
73
76
|
|
|
77
|
+
**Every piece of context that helps fix it, shared by default, each saying why.** With no app code, the module collects at send time, and the sheet lists every item with its value and a one-line why, each with a tick box so the person can leave it out (what they added themselves, the app's version and their name always go). Anything unticked is dropped before the report leaves the phone, diagnostics included.
|
|
78
|
+
|
|
79
|
+
| Item | Value, for example | Why it is shared (the sheet's words) |
|
|
80
|
+
| --- | --- | --- |
|
|
81
|
+
| Your name in the app | `identity().name` or `.username`; never the account id | so whoever fixes it knows who to thank, and can look at your account if the problem is in it |
|
|
82
|
+
| Your local time | `2026-10-26 12:04 (America/Chicago, UTC-05:00)` | so the fixer can line it up with what the app logged at that moment |
|
|
83
|
+
| Your languages | `en-US, es` | some problems only happen in one language or date format |
|
|
84
|
+
| Your device | `iPhone, iOS 18.1, Safari 18.1` (User-Agent Client Hints when the browser has them, else the user agent, in plain words) | so the fixer can try it on the same kind of device and browser |
|
|
85
|
+
| Screen and window | `390x844 screen, 390x664 window, 3x pixels` | how things are laid out depends on the size of the screen and window |
|
|
86
|
+
| Appearance | `dark mode, reduced motion` | some problems only show in dark mode or with animations turned down |
|
|
87
|
+
| Connection | `online, wifi, 4g speed` | a slow or dropped connection explains many problems |
|
|
88
|
+
| How long the page was open | `12 minutes` | some problems only start after a page has been open a while |
|
|
89
|
+
| Pages you visited before this | the last 10, with times, never a query string, and a segment that looks like an id, token or address (a uuid, a long number, long hex or base64, over 24 characters, anything with an @) becomes `:id` | so the fixer can retrace how you got here |
|
|
90
|
+
| Your last taps | the last 20, by what the button or link is called; never what was typed | so the fixer can repeat what you did |
|
|
91
|
+
| Errors the page hit | the last 10 errors and unhandled rejections, message and first stack frame, no query strings | the error the app hit is often the fastest way to the cause |
|
|
92
|
+
|
|
93
|
+
The trail (pages, taps, errors) is kept from the moment any of the module's screens first mounts (the entry, `<KeepSending>`, `<SuggestNews>`), in memory only, in small ring buffers. Never captured: an input's value, the text of anything someone types into (an editable region, a text, search or combo box; it is "a text field"), cookies, storage, query strings. A host that bundles two copies of the module still gets one trail and one set of listeners (the state sits on the global object under a registered symbol), and a reset never undoes a wrapper the host put on `history` after ours. `collectContext()` and `installContextListeners()` are exported from `"./client"` for a host that wants them elsewhere.
|
|
94
|
+
|
|
95
|
+
- **The app's own context.** `context: async () => [{ label: "Plan", value: "Pro, yearly", why: "some features only exist on Pro" }]` adds anything app-specific; an item without a `why` is dropped. Or `context: { items, precise: true }` to also ask the browser for a precise location (its permission prompt); without that, location is only the time zone, plus roughly where from the receiver (below).
|
|
96
|
+
- **A way to reach them.** `contact: async () => ({ email, phone, handle })` gives what the app already knows, so whoever fixes it can say thanks or ask a question. It goes by default to a private repo and is off, with the reason ("this repo is public, so anyone could read it"), for a public one. It travels as `who.contact`, the one place a report may carry an email. The receiving side treats it as the person saying "share it" and takes the address itself from the host (the server half's `contactOf(member)`, a relay's `identity()`), so a phone can never put someone else's address on an issue.
|
|
97
|
+
|
|
98
|
+
The issue carries it all in a "Context (shared so this can be fixed)" table of what, value and why (in place of the About line), the trail in collapsible blocks, and a "Reach them" section with `mailto:` and `tel:` links "To thank them or ask a follow-up question". When the body would pass GitHub's limit, the trail is cut first (oldest taps, then pages, then errors), then the transcript.
|
|
99
|
+
|
|
100
|
+
**Where it files.** With the optional `destination()` adapter, answering `{ repo, public, account, attaches, name? }`, that line becomes the list of exactly what the issue will carry ("your words · your voice note and its transcript · 2 screenshots, saved to the suggestion-attachments branch of acme/notes · this screen (Photos, /photos) · notes 1.2.3 · iPhone, 390x844 · the time · your name, Alex Kim"), the repo it files on, who can read it there (a public repo says so in `--sac-danger`), and whose GitHub account files it. The list follows the draft as things are added or removed, and it is built by `whatGoes` from `./github`, the same pieces `composeIssue` writes the body from, so the sheet and the issue cannot drift. `attaches: false` says files wait until the repo can take them. Sending is the consent. Without the adapter (or when it answers null) the sheet keeps the one line. My suggestions and the detail view link the issue a report became ("Issue #7"); a member who cannot read the repo still sees the number and the state.
|
|
101
|
+
|
|
74
102
|
**Identity.** Every screen asks `adapters.identity()` when it mounts, whenever it is handed a different `adapters` object, and whenever the host calls `refreshIdentity()` (exported from `"./client"`; call it after a sign-in, sign-out or account switch). When the account differs, the old account's rows leave the screen and its waiting reports stay on the phone, to be sent only while that account is signed in; every send re-confirms the account first. Keep `adapters` stable where you can; a parent that rebuilds it every render is safe, only slower (one `identity()` call per render).
|
|
75
103
|
|
|
76
|
-
**Telling the person in the app, with no setup.** The entry asks `mine()` once when it mounts and compares each report's status with the one this account last opened (kept on the phone beside the cached views, per app and account, and cleared on sign-out). A report that has since been
|
|
104
|
+
**Telling the person in the app, with no setup.** The entry asks `mine()` once when it mounts and compares each report's status with the one this account last opened (kept on the phone beside the cached views, per app and account, and cleared on sign-out). A report that has since been filed or closed (`TOLD`) shows a dot on the entry, says New on its row in My suggestions, and the newest such move gets one toast per page load in the module's words (`words.text.movedToast`, given the status's own word and, on `not-planned`, the reason: "Your suggestion: Done", "Your suggestion: Not planned. too big for now"). Opening the report clears it. A report this phone sees for the first time is recorded silently, so an app taking this release does not announce every old report. The list's `not-planned` rows carry their one-line `note` for this. Out of the app, the server's `tellSubmitter` does the telling (below).
|
|
77
105
|
|
|
78
|
-
**When the entry sits in a menu.** A host that shows the entry inside a menu that stays closed (a three-dot menu, often remounting its rows on every open) would never show the dot, and the toast would be drawn inside the closed menu. Mount `<SuggestNews>` once at the app's root, always mounted: it asks `mine()` when it mounts, shows the page load's one toast with no entry on screen, and keeps the outbox moving. Put the dot on the menu's own trigger with `useSuggestNews(adapters)`, which returns `{ moved }`, and `<SuggestDot>`, which draws the entry's dot (nothing when `moved` is 0). The trigger needs `position: relative`; the class `sac-dot-host` does it. The toast is once per page load and account however many times the entry mounts, whichever of these asks first. The entry in the menu keeps its own dot.
|
|
106
|
+
**When the entry sits in a menu.** A host that shows the entry inside a menu that stays closed (a three-dot menu, often remounting its rows on every open) would never show the dot, and the toast would be drawn inside the closed menu. Mount `<SuggestNews>` once at the app's root, always mounted: it asks `mine()` when it mounts, shows the page load's one toast with no entry on screen, and keeps the outbox moving. Put the dot on the menu's own trigger with `useSuggestNews(adapters)`, which returns `{ moved }`, and `<SuggestDot>`, which draws the entry's dot (nothing when `moved` is 0). The trigger needs `position: relative`; the class `sac-dot-host` does it. The toast is once per page load and account however many times the entry mounts, whichever of these asks first, and only for a move the previous page load had not already seen: older news keeps the dot and the New mark without a toast. The entry in the menu keeps its own dot.
|
|
79
107
|
|
|
80
108
|
```tsx
|
|
81
109
|
import { SuggestDot, SuggestEntry, SuggestNews, useSuggestNews } from "@supersuit/suggest-a-change/client";
|
|
@@ -189,10 +217,24 @@ const h = createReportHandlers({
|
|
|
189
217
|
maintainersSince: { mo: "2026-10-01" }, // optional: reports from before this are not offered to that maintainer's Mac
|
|
190
218
|
tell: (e) => notify(e), // ids only, never words; a failure never fails the request
|
|
191
219
|
tellSubmitter: (e) => push(e.member, e.words, e.note), // REQUIRED: tell the person who suggested it (or { none: "<reason>" })
|
|
220
|
+
github: { repo: "acme/notes", token, attachments }, // optional: file each complete report as an issue from here (below)
|
|
192
221
|
});
|
|
193
222
|
```
|
|
194
223
|
|
|
195
|
-
**Telling the person who suggested it is required.** `tellSubmitter` is called once when a report
|
|
224
|
+
**Telling the person who suggested it is required.** `tellSubmitter` is called once when a report becomes an issue (`open`) and once each time it closes or reopens (`done`, `not-planned`, `open`; the list is exported as `TOLD`), with `{ member, reportId, status, words, meaning, ended, note?, issue? }`. `words` and `meaning` are the module's own words for the status (`wordsFor`/`meaningFor`, with `app.owner` as the owner's name when given), never the report's text, so a lock screen never shows what someone wrote; `note` is the one-line reason and comes with `not-planned` only; `issue` is `{ repo, number, url }` once there is one. Being held tells nobody, a repeat of a status tells nobody, a move the store refused tells nobody, and a maintainer is not told that the issue they just filed from their own report exists (its close is still told).
|
|
225
|
+
|
|
226
|
+
**Roughly where they were, with no prompt.** `geo: (req) => ({ city, region, country })` adds one context item ("Roughly where they were") from the request, on the server half and on a suggestion relay alike; a place the phone claims is dropped. The host already puts it in headers:
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
// Vercel
|
|
230
|
+
geo: (req) => ({ city: decodeURIComponent(req.headers.get("x-vercel-ip-city") ?? ""), region: req.headers.get("x-vercel-ip-country-region") ?? "", country: req.headers.get("x-vercel-ip-country") ?? "" }),
|
|
231
|
+
// Cloudflare (cf-ipcity and cf-region need the "Add visitor location headers" managed transform)
|
|
232
|
+
geo: (req) => ({ city: req.headers.get("cf-ipcity") ?? "", region: req.headers.get("cf-region") ?? "", country: req.headers.get("cf-ipcountry") ?? "" }),
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The handlers keep a report's `context` (the contract checks its shape) and show it back to the member who sent it. **How to reach them comes from the host:** `contactOf(member)` answers what the app knows; the phone only says whether to share it, by sending `who.contact` while the person leaves it ticked, and what it sent there is ignored. Without `contactOf` no report carries a contact, and one of the wrong shape is dropped.
|
|
236
|
+
|
|
237
|
+
**Filing from the app's server.** With `github: { repo, token, attachments?, people?, nameOf?, fetch? }` the handlers file each report as an issue the moment it is complete: at submit when it has no files, else when its last file is verified. One caller files it (the approval is taken under a one-time name and the report read again under it). The privacy catch runs first; a hit moves the report to `held` with the kinds found (`flags`, never the words, never in the member's view) for the owner to decide. The files are read back through the store's own read links and saved with `attachments` (a `branchHost` with a token that can push, usually); the issue is opened first, then the files saved, then their links edited in. GitHub failing leaves the report `received` and the approval free, so a maintainer's Mac can file it. `app.name` and `app.label` (the host's own) name the app and its `app:<label>`; `nameOf(member)` gives the name the issue says; the member key never appears in an issue. Send it on the app's own channel (push, email, an activity row) to that member only. A failure never fails the move. `createReportHandlers` throws at startup when `tellSubmitter` is missing; an app that genuinely has nobody to tell passes `tellSubmitter: { none: "only the maintainer uses this app" }`, and an empty reason throws too. The `tell` event for a status move now also carries `member` and `status`.
|
|
196
238
|
|
|
197
239
|
| Route | Handler | Answers |
|
|
198
240
|
| --- | --- | --- |
|
|
@@ -206,11 +248,11 @@ const h = createReportHandlers({
|
|
|
206
248
|
| `GET /api/agent/reports/:id/files/:file` | `agentFile(req, id, file)` | 200 `{ url, expiresAt, type, bytes, sha256 }` (the link lives five minutes), 404 |
|
|
207
249
|
| `POST /api/agent/reports/:id/settle` | `agentSettle` | body `{ revision }` (the one the list gave this report): 200, 400 (no revision), 409 (not claimed, or `stale`: something was added since the list, so the report is offered again) |
|
|
208
250
|
| `POST /api/agent/reports/:id/claim-approval` | `agentClaimApproval` | 200, or 409 `{ claimedBy }`, or on a closed report 409 `{ closed: true, status, claimedBy? }` |
|
|
209
|
-
| `POST /api/agent/reports/:id/status` | `agentStatus` | 200, 400, 409 |
|
|
251
|
+
| `POST /api/agent/reports/:id/status` | `agentStatus` | body `{ status, step?, note?, issue? }`: 200, 400 (not a status, an old status, a bad issue, or open with no issue), 409 |
|
|
210
252
|
|
|
211
253
|
A client adapter's `uploadPart` asks `upload-url`, PUTs the bytes straight to storage, then calls `finalize`. Nothing is uploaded through the app's server and no handler reads a body over about 400 KB (sized from the field limits at four bytes a character; far under 4 MB).
|
|
212
254
|
|
|
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 `
|
|
255
|
+
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`, `decision` or `issue` is refused. Settling (`agentSettle`) moves the files under `collected/` and leaves the status alone: collecting is not filing. `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
256
|
|
|
215
257
|
**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
258
|
|
|
@@ -218,7 +260,7 @@ The role stored on a report comes from the signed-in member and the `maintainers
|
|
|
218
260
|
|
|
219
261
|
**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.
|
|
220
262
|
|
|
221
|
-
**The approval.**
|
|
263
|
+
**The approval.** Holding a report (`held`) needs no claim. Leaving `held`, filing (`open`) and closing need the Mac that holds the approval (taken atomically if nobody holds it, otherwise 409 with who does). Before it is filed the holder can release it, and an unrenewed approval lapses after seven days. Once it is filed or closed it does not lapse: that move names the maintainer on its timeline entry (`holder`, never shown to the member), the approval is taken for `HELD_MS`, and another Mac asking is answered 409 with that holder. A claim on a closed report is 409 `{ closed: true, status, claimedBy }` for everyone and writes nothing; only a move back to `open` (GitHub reopened it) is taken, by the Mac that filed it or by anyone when nobody is named. The owner may decline (`not-planned`) from `received` or `held`; that entry's actor is the owner, not GitHub.
|
|
222
264
|
|
|
223
265
|
**What a storage adapter implements** (`HostedStore`). Every method that changes a report is narrow and atomic: `put` is create-only (`created`, `exists`, or `limit` when the member's day is full, counted in the same transaction), and `setFile` (never replaces a verified entry; an older pending never replaces a newer one), `addFollowUp` (the caps, a reused file name and an ended report are checked inside the write), `setStatus(expectFrom)`, `claimApproval`, `claim`, `settle` and `reopen` each change one thing. A real adapter implements these with a transaction or a precondition, never "read the report, change a copy, write it back": that rolls back a status or a claim written in between. Upload links must be create-only and one-time with the size and type fixed in their conditions (a Firebase or S3 signed PUT with `if-generation-match: 0` or `If-None-Match: *`), `discard` must delete a refused object, and `readUrl` links must expire after `ttlMs`.
|
|
224
266
|
|
|
@@ -230,6 +272,118 @@ await runStoreConformance(() => ({ store, now, advance, putViaLink, getViaLink,
|
|
|
230
272
|
// putViaLink / getViaLink do what your storage service does with a PUT on an upload link and a GET on a read link.
|
|
231
273
|
```
|
|
232
274
|
|
|
275
|
+
## Suggestions as GitHub issues (`./github`)
|
|
276
|
+
|
|
277
|
+
Zero runtime dependencies, web-standard `fetch` (pass your own as `fetch` anywhere). It turns a report into one plain GitHub issue that can be triaged like any other bug or feature request, from an app's server or from the owner's own machine with `gh`'s token.
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
import { privacyCatch, redact, mayFileAsIs, composeIssue, whatGoes, fileSuggestion, attachPending, branchHost, relayHost, issueStates, issueStatus, addFollowUp } from "@supersuit/suggest-a-change/github";
|
|
281
|
+
|
|
282
|
+
// 1. Nothing is filed before the privacy catch has run over the words, the transcript and the follow-ups.
|
|
283
|
+
const { hits } = privacyCatch({ ...report, transcript }, { people, secrets: [senderKey] });
|
|
284
|
+
// hits: [{ kind: "credential" | "email" | "phone" | "person", text, field }]. Any hit: hold it for the owner.
|
|
285
|
+
// mayFileAsIs(hits) is false when a credential is among them: that one only ever files as redact(report, hits).
|
|
286
|
+
|
|
287
|
+
// 2. The issue first (saying what is being added), then each file against it, then the links edited in.
|
|
288
|
+
const filed = await fileSuggestion({ repo: "acme/notes", token }, {
|
|
289
|
+
report, // redacted, if the owner chose that
|
|
290
|
+
compose: { appName: "Notes", appVersion: "1.2.3", filerName: "Alex Kim", filerRole: "member", transcript },
|
|
291
|
+
files: [{ name: "image-1.png", type: "image/png", bytes }], // the report's own file names
|
|
292
|
+
attachments: branchHost({ repo: "acme/notes", token }), // or relayHost(...) for a filer who cannot push
|
|
293
|
+
});
|
|
294
|
+
// { number, url, attached: { "image-1.png": url }, pending: [] }
|
|
295
|
+
// A file that failed stays in `pending`, and the issue keeps saying it is being added. Retry it later:
|
|
296
|
+
await attachPending({ repo: "acme/notes", token }, { number: filed.number, report, compose, files, attachments, attached: filed.attached });
|
|
297
|
+
|
|
298
|
+
// 3. Follow-ups are comments; the person's status is the issue's own.
|
|
299
|
+
await addFollowUp({ repo: "acme/notes", token }, filed.number, "one more thing");
|
|
300
|
+
const states = await issueStates({ token }, [{ repo: "acme/notes", number: filed.number }]); // one GraphQL call
|
|
301
|
+
issueStatus(states[0]); // "open" | "done" | "not-planned"; a closed issue's `reason` is its last comment's first line
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
- `composeIssue(report, { appName, appVersion, filerName, filerRole, attachmentUrls, transcript, pending })` answers `{ title, body, labels, carries }`. The title is the first sentence of the words (else the transcript), at most 70 characters, else "Suggestion from <app>". Labels are GitHub's own (`enhancement`, `bug`, `feedback`) plus `app:<slug>`. The body's sections are What they said, Voice note, Screenshots (pictures inline, videos and recordings linked), Where, About and Filed by, which ends `Report <id>.`, exactly: a relay requires that as the last line, under `## Filed by`, before it saves a file. **Nothing a report brings can render as markup:** the words, the transcript and follow-ups sit in a fence longer than any backtick run inside them, and every single-line string (where, the context, the trail, names, the handle) has its HTML and markdown escaped, `@` mentions and `#123` references broken with a zero-width joiner, so it pings nobody and links nothing. The only images are attachment links the host produced, and only plain `https` ones. The body never carries the sender's key or an email outside Reach them, and stays under GitHub's limit (`BODY_LIMIT`) by cutting the transcript, with a note. Any file without a link yet is written as "being added"; `pending: true` writes every file that way.
|
|
305
|
+
- `whatGoes(draft, destination)` is the sheet's list, one item per piece of `carries`, and `destinationWords(destination)` its headline, privacy line and account line.
|
|
306
|
+
- `fileIssue`, `updateIssue`, `addFollowUp` are the REST calls; `fileIssue` creates missing labels first and skips one it may not create (a read-only filer; GitHub then keeps only the labels they may set).
|
|
307
|
+
- `AttachmentHost` is `{ put({ repo, issue, reportId }, { name, type, bytes }) -> { url } }`. Every file goes to `attachments/<report id>/<file>` on the repo's own `suggestion-attachments` branch (made the first time, as an orphan sharing no history with the code), linked as `https://github.com/<repo>/blob/suggestion-attachments/<path>?raw=true`, which renders for exactly the people who can read the repo. `branchHost({ repo, token })` writes with a token that can push. `relayHost({ url, headers })` sends the file to an attachment relay (below) with whatever header your relay checks, for the many filers who can open an issue but cannot push.
|
|
308
|
+
|
|
309
|
+
### Run your own suggestion relay
|
|
310
|
+
|
|
311
|
+
A relay is one route on a server you run. It acts as a GitHub App you own, and your repo trusts nobody else's server. It does four things, chosen by the **last segment of the path** it is mounted on: `.../file`, `.../follow-up`, `.../status` and `.../attach`, each a POST with a JSON body. Any other last segment is 404.
|
|
312
|
+
|
|
313
|
+
1. **Create the GitHub App** (Settings, Developer settings, GitHub Apps, New GitHub App). Repository permissions: **Contents: Read and write** and **Issues: Read and write**, nothing else. No webhook is needed. Generate a private key (GitHub gives you a PKCS#1 `.pem`; the relay takes that or PKCS#8) and note the App ID.
|
|
314
|
+
2. **Install it** on each repo suggestions file to. Uninstalling it stops every write at once; the issues and files already made stay in the repo.
|
|
315
|
+
3. **Mount the handler.** In a Next.js app (app router), one dynamic route serves all four actions, `app/api/suggestions/[action]/route.ts`:
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
import { createSuggestionRelay } from "@supersuit/suggest-a-change/github";
|
|
319
|
+
|
|
320
|
+
const relay = createSuggestionRelay({
|
|
321
|
+
app: { id: process.env.SUGGESTIONS_APP_ID!, privateKey: process.env.SUGGESTIONS_APP_KEY! },
|
|
322
|
+
identity: async (req) => {
|
|
323
|
+
// Whoever your app already knows: a session cookie, an API key in the authorization header. Null refuses.
|
|
324
|
+
const user = await userFromRequest(req);
|
|
325
|
+
// The issue says it was filed by `name`, with `role`, and shares `contact` if the person ticked it.
|
|
326
|
+
// None of these is ever taken from the request.
|
|
327
|
+
// githubLogin: the login this person files under, when you know it; attaching to an issue they opened themselves needs it.
|
|
328
|
+
return user ? { account: user.id, name: user.displayName, role: "member", githubLogin: user.github, contact: { email: user.email } } : null;
|
|
329
|
+
},
|
|
330
|
+
repos: async (account) => ["acme/notes"], // optional: where this identity may file; null means any repo the App is on
|
|
331
|
+
apps: { notes: { name: "Notes", label: "notes" } }, // the apps it files for: a request names one by id, and the issue's name and app:<label> come from here
|
|
332
|
+
staging, // optional: a bucket for files past a request body limit (below)
|
|
333
|
+
privacy: { people: await thirdParties() }, // optional: names the privacy catch looks for, and any secrets you know
|
|
334
|
+
// limits: { perPersonPerDay: 10 }, // may lower any limit, never raise one
|
|
335
|
+
usage: myDatabaseUsage, // see below
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
export const POST = relay;
|
|
339
|
+
export const GET = relay; // GET .../capabilities: what this relay can take, so a filer knows before it sends
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
4. **Point the filers at it:** `relayFiler({ url: "https://your.app/api/suggestions", headers: { authorization: `Bearer ${apiKey}` } })` for filing, following up and reading state, or `relayHost({ url: "https://your.app/api/suggestions/attach", headers })` for attaching to an issue the filer opened themselves.
|
|
343
|
+
|
|
344
|
+
**The usage store.** `usage` remembers the daily counts, which identity filed which issue, and each report's filing (so a repeat answers the same issue). The default, `memoryUsage()`, lives in memory and is right for **one server instance only**: on a host that runs several instances, or restarts, the caps, the idempotency and who-filed-what are lost or split between instances. Implement `RelayUsage` on your database for anything else. Each method must be ATOMIC, because the check and the change it guards are one step: `reserve(account, day, reportId, bytes, limits)` holds a day's report count and bytes for one request before any work starts (two requests at once can never both pass a cap of one), `commit` keeps it, `release` gives it back when the request fails; `bind(repo, reportId, account)` makes a report belong to the first identity that files or attaches for it; `begin(repo, reportId, account, leaseMs)`, `finish` and `abandon` make filing once per report, under a lease another try takes over once it runs out (its process died); `stage`, `staged` and `unstage` remember what `upload-url` gave a link for, so a staged read never goes past it; `filer` answers who filed an issue.
|
|
345
|
+
|
|
346
|
+
### Files past a request body limit: staging
|
|
347
|
+
|
|
348
|
+
Serverless hosts cap a request body (Vercel at about 4.5 MB), so a relay takes a file inside a request only up to `limits.inlineBytes` (3 MB by default). For anything larger, give it a `staging` adapter, a bucket it controls:
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
const staging = {
|
|
352
|
+
// A one-time signed PUT that can only create `key` once, with this type and at most these bytes.
|
|
353
|
+
// Google Cloud Storage: a V4 signed URL for PUT with x-goog-if-generation-match: 0 and
|
|
354
|
+
// x-goog-content-length-range: 0,<bytes> signed. S3: a presigned PUT with If-None-Match: * and the
|
|
355
|
+
// content type and length signed. Answer the headers the PUT must send.
|
|
356
|
+
uploadUrl: async (key, { type, bytes }) => ({ url, headers: { "content-type": type, "x-goog-if-generation-match": "0" } }),
|
|
357
|
+
read: async (key) => objectStreamOrNull(key), // a ReadableStream, Uint8Array or ArrayBuffer, or null when nothing arrived
|
|
358
|
+
discard: async (key) => deleteObject(key), // once the relay has written it to GitHub
|
|
359
|
+
};
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
The filer asks `upload-url` `{ repo, reportId, name, type, bytes }` for each large file (bound to that identity and report like a filing, with the day's bytes held there and then), PUTs the bytes to the link it gets for `staging/<account>/<report id>/<name>`, and names the file in `file` or `attach` as `{ name, type, staged: name }`. The relay reads the staged object, sniffs its bytes, writes it to the branch through the git data API (a blob, a tree, a commit; the contents API is only used under 1 MB), then discards it. `relayFiler` does all of this itself for any file over 2 MB when the relay's `GET .../capabilities` says it stages. Without staging, a file over `inlineBytes` is refused in words before anything is filed (`relayFiler` refuses it before it sends), and a sheet whose `destination()` answers `maxInlineBytes` names such a file in its list before Send: "it does not reach the issue".
|
|
363
|
+
|
|
364
|
+
### Filing for people with no access
|
|
365
|
+
|
|
366
|
+
Someone who cannot read the repo can still suggest a change, with every screenshot and recording. Their machine sends the whole report to the relay's `file` action, and the relay files it:
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
import { relayFiler, fileSuggestion } from "@supersuit/suggest-a-change/github";
|
|
370
|
+
|
|
371
|
+
const relay = relayFiler({ url: "https://your.app/api/suggestions", headers: { authorization: `Bearer ${apiKey}` } });
|
|
372
|
+
// With no token, or one that cannot see the repo (canFileDirectly is false), fileSuggestion files through the relay;
|
|
373
|
+
// with one that can, it files directly as before. `via` says which.
|
|
374
|
+
const got = await fileSuggestion({ repo: "acme/notes", token: maybeToken, relay, app: "notes" }, { report, compose: { appName: "Notes", appVersion: "1.2.3", filerName: "Alex Kim", transcript }, files });
|
|
375
|
+
// { number, url, via: "relay" | "direct", attached, pending }
|
|
376
|
+
await relay.followUp({ repo: "acme/notes", number: got.number, reportId: report.id, words: "one more thing" });
|
|
377
|
+
await relay.status([{ repo: "acme/notes", number: got.number }]); // [{ repo, number, status: "open" | "done" | "not-planned", reason? }]
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
- **`file`** `{ repo, app?, report, transcript?, files: [{ name, type, bytes (base64) } | { name, type, staged: name }], redacted? }` answers `{ number, url }`. The relay checks the report against the contract and each file against what the report declares, runs the privacy catch itself, saves the files to `suggestion-attachments`, then opens the issue **as the App**, filed by the name `identity()` gives (never the account id, and never a name in the request), with the role and the contact from `identity()` too and the app's name and `app:<label>` from the relay's `apps` table by the request's `app` id (an id not in the table is 400 "unknown app"; with no table no request names an app, and the issue says "in an app" with no app label); the contact only when the report carries `who.contact` (the person left it ticked). At most ten labels. A credential refuses with 422 naming the kind and the field, never the text, whatever `redacted` says. Any other hit refuses with 409 `{ held: true, hits: [{ kind, field }] }` until the filer sends again with `redacted: true`, saying they chose to file without the flagged part; the relay then redacts what it found itself, so raw text is never filed on the filer's word. The catch reads every string that would reach the issue (where, every diagnostic, the context, the trail, the names, the handle), after folding look-alike letters and removing invisible characters. A repeat of the same report answers the same issue; another identity using that report id is 409.
|
|
381
|
+
- **`follow-up`** `{ repo, number, reportId, words, transcript?, files?, redacted? }` adds a comment, only for the identity that filed that report through this relay (anyone else, 403). The same privacy catch runs.
|
|
382
|
+
- **`status`** `{ items: [{ repo, number }] }` answers `{ items: [{ repo, number, status, reason? }] }`, only for issues this identity filed (anything else, 403).
|
|
383
|
+
- **`attach`** is for a filer who opened the issue themselves but cannot push. Reading the issue is the proof they could file there: it must exist, be an issue, be open or closed within a week, and END with the composer's own `## Filed by` line for that report ("Report <id>" anywhere else proves nothing). And a report belongs to the first identity that files or attaches for it: anyone else attaching to it is 403. An issue the relay filed takes files only from the identity that filed it; an issue someone opened themselves takes them only when its author's login is the `githubLogin` that `identity()` vouches for, and nothing is claimed until that proof holds. A request lists each file once and no more of each kind than a report may carry; a follow-up is composed and measured before any file is written, and holds to the report's caps like `attach`.
|
|
384
|
+
|
|
385
|
+
On every call the relay refuses, and each refusal has a test: anything but POST (405); an action it does not have (404); a caller `identity` does not know (401); a repo `repos` does not allow for that identity (403, "this account may not file on <repo>"); a malformed repo, number, report id, file name, base64 body or report, or a file the report does not declare (400); bytes that are not an accepted picture, video or recording, or not the type declared, judged by the bytes (422); a file over the size caps (413); a repo the App is not installed on (403, "the app is not installed on <repo>"); a credential (422) or another privacy hit (409, held); a follow-up or status request for an issue another identity filed (403); for `attach`, an issue that does not exist (404), is a pull request, does not carry the report, or closed more than a week ago (403), a path already written (409), or a report holding all the files it may (422); and more reports, or more bytes, in a day than one identity's cap (429). It writes only `attachments/<report id>/<file>` on `suggestion-attachments`, with an installation token scoped to the one repo. A host that limits request bodies (several serverless platforms stop at a few MB) also limits what the relay can take: a report with a long video needs a route that accepts a body that large.
|
|
386
|
+
|
|
233
387
|
## What is here, and what is coming
|
|
234
388
|
|
|
235
389
|
| Export | State |
|
|
@@ -240,12 +394,13 @@ await runStoreConformance(() => ({ store, now, advance, putViaLink, getViaLink,
|
|
|
240
394
|
| `"./styles.css"` optional defaults | here |
|
|
241
395
|
| `"./check"` and the `suggest-a-change-check` and `suggest-a-change init` commands | here |
|
|
242
396
|
| `"./server"` the receiving side: handlers, store conformance suite, byte sniffing | here |
|
|
397
|
+
| `"./github"` suggestions as issues: privacy catch, issue body, filing, attachment hosts, your own suggestion relay | here |
|
|
243
398
|
|
|
244
399
|
Each export is added to `package.json` in the same change as its code, so nothing here points at a file that does not exist.
|
|
245
400
|
|
|
246
401
|
## Status
|
|
247
402
|
|
|
248
|
-
Version 0.1.0 is the first release and is published once by its owner (the first publish of a new package name cannot be a tag push). Every release after that is a pushed `v<version>` tag (GitHub Actions, npm trusted publishing). Below 1.0, additions are patch versions, so apps
|
|
403
|
+
Version 0.1.0 is the first release and is published once by its owner (the first publish of a new package name cannot be a tag push). Every release after that is a pushed `v<version>` tag (GitHub Actions, npm trusted publishing). Below 1.0, additions are patch versions, so apps take them without a change; a change to the statuses is a minor version (0.2.0 moved them onto GitHub's), which an app on `^0.1` takes on purpose.
|
|
249
404
|
|
|
250
405
|
## License
|
|
251
406
|
|
|
@@ -4,11 +4,22 @@ import type { Store } from './idb.ts';
|
|
|
4
4
|
import type { StoreAdapter } from './reports.ts';
|
|
5
5
|
import type { startTake } from './recorder.ts';
|
|
6
6
|
import type { Words } from './words.ts';
|
|
7
|
+
import type { Destination } from '../github/compose.ts';
|
|
8
|
+
import type { AppContextItem } from './context.ts';
|
|
7
9
|
export type Role = 'operator' | 'member' | 'maintainer';
|
|
8
10
|
export type Kind = 'change' | 'broken' | 'feedback';
|
|
11
|
+
/** `name` (or `username`) is how the person is called in the app; it goes in the context, and the account never does. */
|
|
9
12
|
export type Identity = {
|
|
10
13
|
account: string;
|
|
11
14
|
role: Role;
|
|
15
|
+
name?: string;
|
|
16
|
+
username?: string;
|
|
17
|
+
};
|
|
18
|
+
/** How whoever fixes it can reach the sender: what the app already knows. */
|
|
19
|
+
export type Contact = {
|
|
20
|
+
email?: string;
|
|
21
|
+
phone?: string;
|
|
22
|
+
handle?: string;
|
|
12
23
|
};
|
|
13
24
|
export type HeardUpload = (key: string, blob: Blob, ms: number) => Promise<{
|
|
14
25
|
audio: string;
|
|
@@ -98,4 +109,20 @@ export interface Adapters {
|
|
|
98
109
|
startTake?: typeof startTake;
|
|
99
110
|
/** False when the receiving side cannot take a voice note added to a report afterwards (the detail view then says so). */
|
|
100
111
|
appendVoice?: boolean;
|
|
112
|
+
/** Where a sent report files as a GitHub issue. With it, the sheet lists exactly what goes and where above Send
|
|
113
|
+
* (the same list the issue is written from); null or absent keeps the one-line note. */
|
|
114
|
+
destination?(): Promise<SheetDestination | null>;
|
|
115
|
+
/** Anything app-specific that would help fix it, each with why it is shared (an item without a why is dropped). Or
|
|
116
|
+
* `{ items?, precise? }`, where `precise: true` also asks the browser for the person's location (its permission prompt). */
|
|
117
|
+
context?: (() => Promise<AppContextItem[]>) | {
|
|
118
|
+
items?(): Promise<AppContextItem[]>;
|
|
119
|
+
precise?: boolean;
|
|
120
|
+
};
|
|
121
|
+
/** How to reach the sender, from what the app already knows. Shared by default to a private repo, off for a public one. */
|
|
122
|
+
contact?(): Promise<Contact | null>;
|
|
123
|
+
}
|
|
124
|
+
/** The issue's destination as the sheet shows it: the repo, whether it is public, the GitHub login it files from,
|
|
125
|
+
* whether files can reach it yet, and (optionally) the sender's name as the issue will say it. */
|
|
126
|
+
export interface SheetDestination extends Destination {
|
|
127
|
+
name?: string;
|
|
101
128
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Identity } from './adapters.ts';
|
|
2
|
+
export interface ContextItem {
|
|
3
|
+
key: string;
|
|
4
|
+
label: string;
|
|
5
|
+
value: string;
|
|
6
|
+
why: string;
|
|
7
|
+
}
|
|
8
|
+
export interface Trail {
|
|
9
|
+
routes: {
|
|
10
|
+
route: string;
|
|
11
|
+
at: string;
|
|
12
|
+
}[];
|
|
13
|
+
taps: {
|
|
14
|
+
label: string;
|
|
15
|
+
at: string;
|
|
16
|
+
}[];
|
|
17
|
+
errors: {
|
|
18
|
+
message: string;
|
|
19
|
+
frame?: string;
|
|
20
|
+
at: string;
|
|
21
|
+
}[];
|
|
22
|
+
}
|
|
23
|
+
export interface ReportContext extends Trail {
|
|
24
|
+
items: ContextItem[];
|
|
25
|
+
}
|
|
26
|
+
/** What an app adds through the `context` adapter: anything app-specific, each with why it helps. */
|
|
27
|
+
export interface AppContextItem {
|
|
28
|
+
label: string;
|
|
29
|
+
value: string;
|
|
30
|
+
why: string;
|
|
31
|
+
}
|
|
32
|
+
/** A URL or path with no query and no hash, wherever it appears in a piece of text. */
|
|
33
|
+
export declare const withoutQueries: (s: string) => string;
|
|
34
|
+
/** Starts keeping the trail. Idempotent: every screen of the module calls it on mount, and it installs once. */
|
|
35
|
+
export declare function installContextListeners(): void;
|
|
36
|
+
/** Stops keeping the trail and forgets it (tests, and a host that signs someone out). */
|
|
37
|
+
export declare function resetContext(): void;
|
|
38
|
+
/** A copy of the trail as it stands. */
|
|
39
|
+
export declare const contextTrail: () => Trail;
|
|
40
|
+
/** A user agent in plain words: "iPhone, iOS 18.1, Safari 18.1". */
|
|
41
|
+
export declare function parseUserAgent(ua: string): string;
|
|
42
|
+
export interface CollectOptions {
|
|
43
|
+
identity?: Identity | null;
|
|
44
|
+
where?: {
|
|
45
|
+
screen?: string;
|
|
46
|
+
route?: string;
|
|
47
|
+
};
|
|
48
|
+
about?: {
|
|
49
|
+
app?: string;
|
|
50
|
+
version?: string;
|
|
51
|
+
};
|
|
52
|
+
/** The app's own items (the `context` adapter). */
|
|
53
|
+
extra?: () => Promise<AppContextItem[]>;
|
|
54
|
+
/** Ask the browser for a precise location (shows its permission prompt). Off unless the app opts in. */
|
|
55
|
+
precise?: boolean;
|
|
56
|
+
/** A user agent to read instead of the browser's (tests). */
|
|
57
|
+
userAgent?: string;
|
|
58
|
+
now?: () => number;
|
|
59
|
+
}
|
|
60
|
+
/** Everything the module knows that would help fix it, each item with why it is shared, and the trail. */
|
|
61
|
+
export declare function collectContext(o?: CollectOptions): Promise<ReportContext>;
|