@supersuit/suggest-a-change 0.1.13 → 0.2.1
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 +23 -0
- package/README.md +162 -7
- 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 +2 -1
- 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.js +9 -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 +159 -0
- package/dist/github/compose.js +312 -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 +9 -2
- 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 +2 -1
- 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 +8 -6
- package/src/client/sheet.tsx +78 -4
- package/src/client/styles.css +5 -0
- 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 +362 -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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type Fetch } from './api.ts';
|
|
2
|
+
export interface RelayFile {
|
|
3
|
+
name: string;
|
|
4
|
+
type: string;
|
|
5
|
+
bytes: Uint8Array | ArrayBuffer | string;
|
|
6
|
+
}
|
|
7
|
+
export interface RelayFiling {
|
|
8
|
+
repo: string;
|
|
9
|
+
/** Which of the relay's apps this came from, by its id in the relay's own table (the relay names it and labels it). */
|
|
10
|
+
app?: string;
|
|
11
|
+
/** The report, in the contract's shape. The relay never writes the account id into the issue. */
|
|
12
|
+
report: Record<string, unknown>;
|
|
13
|
+
/** The name the issue says it was filed by. */
|
|
14
|
+
filerName?: string;
|
|
15
|
+
appName?: string;
|
|
16
|
+
appVersion?: string;
|
|
17
|
+
transcript?: string;
|
|
18
|
+
files?: RelayFile[];
|
|
19
|
+
/** True once the filer chose to file without what the privacy catch flagged (they replaced it themselves). */
|
|
20
|
+
redacted?: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface RelayFiler {
|
|
23
|
+
/** Answers the issue; a repeat of the same report answers the same issue. A held report throws status 409 with `{ held, hits }`. */
|
|
24
|
+
file(input: RelayFiling): Promise<{
|
|
25
|
+
number: number;
|
|
26
|
+
url: string;
|
|
27
|
+
}>;
|
|
28
|
+
followUp(input: {
|
|
29
|
+
repo: string;
|
|
30
|
+
number: number;
|
|
31
|
+
reportId: string;
|
|
32
|
+
words?: string;
|
|
33
|
+
transcript?: string;
|
|
34
|
+
files?: RelayFile[];
|
|
35
|
+
redacted?: boolean;
|
|
36
|
+
}): Promise<{
|
|
37
|
+
id: number;
|
|
38
|
+
url: string;
|
|
39
|
+
}>;
|
|
40
|
+
status(items: {
|
|
41
|
+
repo: string;
|
|
42
|
+
number: number;
|
|
43
|
+
}[]): Promise<{
|
|
44
|
+
repo: string;
|
|
45
|
+
number: number;
|
|
46
|
+
status: string;
|
|
47
|
+
reason?: string;
|
|
48
|
+
}[]>;
|
|
49
|
+
/** What the relay can take (asked once, then kept). */
|
|
50
|
+
capabilities(): Promise<RelayCapabilities>;
|
|
51
|
+
}
|
|
52
|
+
/** Files larger than this go to the relay staged (one signed PUT each) rather than inside the request. */
|
|
53
|
+
export declare const STAGE_OVER_BYTES: number;
|
|
54
|
+
export interface RelayCapabilities {
|
|
55
|
+
actions: string[];
|
|
56
|
+
staging: boolean;
|
|
57
|
+
inlineBytes: number;
|
|
58
|
+
maxBytes?: Record<string, number>;
|
|
59
|
+
}
|
|
60
|
+
/** `url` is where the relay is mounted; each action is posted to `<url>/<action>`. */
|
|
61
|
+
export declare function relayFiler({ url, headers, fetch }: {
|
|
62
|
+
url: string;
|
|
63
|
+
headers?: Record<string, string>;
|
|
64
|
+
fetch?: Fetch;
|
|
65
|
+
}): RelayFiler;
|
|
66
|
+
/** True when this token can see the repo, so it can open an issue there itself; false on 401, 403 or 404 (or issues off). */
|
|
67
|
+
export declare function canFileDirectly({ repo, token, fetch, api }: {
|
|
68
|
+
repo: string;
|
|
69
|
+
token: string;
|
|
70
|
+
fetch?: Fetch;
|
|
71
|
+
api?: string;
|
|
72
|
+
}): Promise<boolean>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// THE FILER'S SIDE OF A SUGGESTION RELAY. `relayFiler` talks to any relay running `createSuggestionRelay`, at whatever
|
|
2
|
+
// URL it is mounted on and with whatever header it checks. `canFileDirectly` says whether a filer's own token can see
|
|
3
|
+
// the repo (and so open an issue there); `fileSuggestion` uses it to choose between filing directly and the relay.
|
|
4
|
+
import { github, GitHubError } from './api.js';
|
|
5
|
+
import { toBase64 } from './hosts.js';
|
|
6
|
+
const files64 = (files = []) => files.map((f) => ({ name: f.name, type: f.type, bytes: typeof f.bytes === 'string' ? f.bytes : toBase64(f.bytes) }));
|
|
7
|
+
/** Files larger than this go to the relay staged (one signed PUT each) rather than inside the request. */
|
|
8
|
+
export const STAGE_OVER_BYTES = 2 * 2 ** 20;
|
|
9
|
+
const sizeOf = (b) => (typeof b === 'string' ? Math.floor((b.length * 3) / 4) : b instanceof Uint8Array ? b.length : b.byteLength);
|
|
10
|
+
const raw = (b) => {
|
|
11
|
+
if (b instanceof Uint8Array)
|
|
12
|
+
return b;
|
|
13
|
+
if (b instanceof ArrayBuffer)
|
|
14
|
+
return new Uint8Array(b);
|
|
15
|
+
const bin = atob(b);
|
|
16
|
+
const out = new Uint8Array(bin.length);
|
|
17
|
+
for (let i = 0; i < bin.length; i++)
|
|
18
|
+
out[i] = bin.charCodeAt(i);
|
|
19
|
+
return out;
|
|
20
|
+
};
|
|
21
|
+
/** `url` is where the relay is mounted; each action is posted to `<url>/<action>`. */
|
|
22
|
+
export function relayFiler({ url, headers = {}, fetch }) {
|
|
23
|
+
if (!/^https?:\/\//.test(url))
|
|
24
|
+
throw new Error(`${JSON.stringify(url)} is not a relay URL`);
|
|
25
|
+
const base = url.replace(/\/+$/, '');
|
|
26
|
+
const f = fetch ?? globalThis.fetch;
|
|
27
|
+
const call = async (action, body) => {
|
|
28
|
+
const res = await f(`${base}/${action}`, { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
29
|
+
const text = await res.text();
|
|
30
|
+
let parsed = null;
|
|
31
|
+
try {
|
|
32
|
+
parsed = text ? JSON.parse(text) : null;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
parsed = text;
|
|
36
|
+
}
|
|
37
|
+
if (!res.ok)
|
|
38
|
+
throw new GitHubError(`the suggestion relay answered ${res.status} to ${action}`, res.status, parsed);
|
|
39
|
+
return parsed;
|
|
40
|
+
};
|
|
41
|
+
let caps = null;
|
|
42
|
+
const capabilities = () => (caps ??= (async () => {
|
|
43
|
+
const res = await f(`${base}/capabilities`, { method: 'GET', headers });
|
|
44
|
+
if (!res.ok)
|
|
45
|
+
throw new GitHubError(`the suggestion relay answered ${res.status} to capabilities`, res.status, null);
|
|
46
|
+
return (await res.json());
|
|
47
|
+
})().catch((e) => { caps = null; throw e; }));
|
|
48
|
+
/** Each file inline, or, past STAGE_OVER_BYTES, put to a link from upload-url first and named as staged. A file the
|
|
49
|
+
* relay cannot take at all is refused here, in words, before anything is filed. */
|
|
50
|
+
async function prepare(repo, reportId, files = []) {
|
|
51
|
+
if (!files.some((x) => sizeOf(x.bytes) > STAGE_OVER_BYTES))
|
|
52
|
+
return files64(files);
|
|
53
|
+
const c = await capabilities();
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const x of files) {
|
|
56
|
+
const size = sizeOf(x.bytes);
|
|
57
|
+
if (size <= Math.min(STAGE_OVER_BYTES, c.inlineBytes)) {
|
|
58
|
+
out.push(...files64([x]));
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!c.staging)
|
|
62
|
+
throw new Error(`${x.name} is larger than this relay takes in one request (${Math.round(c.inlineBytes / 2 ** 20)} MB), and it has no staging for larger files`);
|
|
63
|
+
const link = (await call('upload-url', { repo, reportId, name: x.name, type: x.type, bytes: size }));
|
|
64
|
+
const put = await f(link.url, { method: 'PUT', headers: link.headers ?? {}, body: raw(x.bytes) });
|
|
65
|
+
if (!put.ok)
|
|
66
|
+
throw new GitHubError(`the staging upload of ${x.name} answered ${put.status}`, put.status, null);
|
|
67
|
+
out.push({ name: x.name, type: x.type, staged: x.name });
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
capabilities,
|
|
73
|
+
async file(input) { return call('file', { ...input, files: await prepare(input.repo, String(input.report.id), input.files) }); },
|
|
74
|
+
async followUp(input) { return call('follow-up', { ...input, files: await prepare(input.repo, input.reportId, input.files) }); },
|
|
75
|
+
async status(items) { return (await call('status', { items })).items; },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** True when this token can see the repo, so it can open an issue there itself; false on 401, 403 or 404 (or issues off). */
|
|
79
|
+
export async function canFileDirectly({ repo, token, fetch, api }) {
|
|
80
|
+
try {
|
|
81
|
+
const got = await github({ token, fetch, api }, 'GET', `/repos/${repo}`);
|
|
82
|
+
return got.body?.has_issues !== false;
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
if (e instanceof GitHubError && [401, 403, 404].includes(e.status))
|
|
86
|
+
return false;
|
|
87
|
+
throw e;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type Fetch } from './api.ts';
|
|
2
|
+
/** Which issue a file belongs to. The relay reads that issue and requires it to carry `Report <reportId>`. */
|
|
3
|
+
export interface AttachmentTarget {
|
|
4
|
+
repo: string;
|
|
5
|
+
issue: number;
|
|
6
|
+
reportId: string;
|
|
7
|
+
}
|
|
8
|
+
export interface AttachmentFile {
|
|
9
|
+
name: string;
|
|
10
|
+
type: string;
|
|
11
|
+
bytes: Uint8Array | ArrayBuffer;
|
|
12
|
+
}
|
|
13
|
+
export interface AttachmentHost {
|
|
14
|
+
put(target: AttachmentTarget, file: AttachmentFile): Promise<{
|
|
15
|
+
url: string;
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
export declare const ATTACHMENTS_BRANCH = "suggestion-attachments";
|
|
19
|
+
/** Up to this size a file goes through the contents API; past it, through the git data API (blob, tree, commit). */
|
|
20
|
+
export declare const CONTENTS_API_BYTES = 1000000;
|
|
21
|
+
/** Base64 with no dependency and no reliance on btoa's string rules. */
|
|
22
|
+
export declare function toBase64(bytes: Uint8Array | ArrayBuffer): string;
|
|
23
|
+
/** The link form that renders inline in a private issue, for the people who can read the repo. */
|
|
24
|
+
export declare const attachmentUrl: (repo: string, branch: string, path: string) => string;
|
|
25
|
+
/** Commits each file through the contents API with a token that can push (a maintainer's Mac, an app server's own key).
|
|
26
|
+
* The branch is made the first time, as an orphan that shares no history with the code. A path already written is its link. */
|
|
27
|
+
export declare function branchHost({ repo, token, branch, fetch, refuseExisting }: {
|
|
28
|
+
repo: string;
|
|
29
|
+
token: string;
|
|
30
|
+
branch?: string;
|
|
31
|
+
fetch?: Fetch; /** Throw (409) instead of answering the link when the path is already written. */
|
|
32
|
+
refuseExisting?: boolean;
|
|
33
|
+
}): AttachmentHost;
|
|
34
|
+
/** For a filer who cannot push: the file goes to an attachment relay (any URL running `createAttachRelay`, with whatever
|
|
35
|
+
* header proves who is asking), which checks the named issue carries this report, saves the file through its own
|
|
36
|
+
* GitHub App, and answers the link. */
|
|
37
|
+
export declare function relayHost({ url, headers, fetch }: {
|
|
38
|
+
url: string;
|
|
39
|
+
headers?: Record<string, string>;
|
|
40
|
+
fetch?: Fetch;
|
|
41
|
+
}): AttachmentHost;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// WHERE A SCREENSHOT OR RECORDING GOES (design: "Attachment parity for every filer"). GitHub has no API for attaching a
|
|
2
|
+
// file to an issue, so each file is committed to the repo's own `suggestion-attachments` branch and linked: it shows to
|
|
3
|
+
// exactly the people who can read the repo, and to nobody else. Two hosts ship: one for a filer that can push, one
|
|
4
|
+
// through the repo owner's own GitHub App, by way of a suggestion relay, for everyone who cannot.
|
|
5
|
+
import { isRepoName } from '../contract.mjs';
|
|
6
|
+
import { github, GitHubError } from './api.js';
|
|
7
|
+
export const ATTACHMENTS_BRANCH = 'suggestion-attachments';
|
|
8
|
+
/** Up to this size a file goes through the contents API; past it, through the git data API (blob, tree, commit). */
|
|
9
|
+
export const CONTENTS_API_BYTES = 1_000_000;
|
|
10
|
+
const REPORT_ID = /^r-\d{14}-[a-z0-9]{6}$/;
|
|
11
|
+
const FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
12
|
+
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
13
|
+
/** Base64 with no dependency and no reliance on btoa's string rules. */
|
|
14
|
+
export function toBase64(bytes) {
|
|
15
|
+
const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
16
|
+
let out = '';
|
|
17
|
+
for (let i = 0; i < b.length; i += 3) {
|
|
18
|
+
const n = (b[i] << 16) | ((b[i + 1] ?? 0) << 8) | (b[i + 2] ?? 0);
|
|
19
|
+
out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + (i + 1 < b.length ? B64[(n >> 6) & 63] : '=') + (i + 2 < b.length ? B64[n & 63] : '=');
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
/** Refuses anything that would write outside attachments/<report id>/<file>. */
|
|
24
|
+
function pathFor(t, name) {
|
|
25
|
+
if (!isRepoName(t.repo))
|
|
26
|
+
throw new Error(`${JSON.stringify(t.repo)} is not owner/name`);
|
|
27
|
+
if (!REPORT_ID.test(String(t.reportId)))
|
|
28
|
+
throw new Error(`${JSON.stringify(t.reportId)} is not a report id`);
|
|
29
|
+
if (!FILE.test(name))
|
|
30
|
+
throw new Error(`${JSON.stringify(name)} is not a plain file name`);
|
|
31
|
+
return `attachments/${t.reportId}/${name}`;
|
|
32
|
+
}
|
|
33
|
+
/** The link form that renders inline in a private issue, for the people who can read the repo. */
|
|
34
|
+
export const attachmentUrl = (repo, branch, path) => `https://github.com/${repo}/blob/${branch}/${path}?raw=true`;
|
|
35
|
+
/** Commits each file through the contents API with a token that can push (a maintainer's Mac, an app server's own key).
|
|
36
|
+
* The branch is made the first time, as an orphan that shares no history with the code. A path already written is its link. */
|
|
37
|
+
export function branchHost({ repo, token, branch = ATTACHMENTS_BRANCH, fetch, refuseExisting = false }) {
|
|
38
|
+
const cfg = { token, fetch };
|
|
39
|
+
let ready = null;
|
|
40
|
+
const ensure = () => (ready ??= (async () => {
|
|
41
|
+
try {
|
|
42
|
+
await github(cfg, 'GET', `/repos/${repo}/git/ref/heads/${branch}`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
if (!(e instanceof GitHubError) || e.status !== 404)
|
|
47
|
+
throw e;
|
|
48
|
+
}
|
|
49
|
+
const tree = await github(cfg, 'POST', `/repos/${repo}/git/trees`, { tree: [{ path: 'README.md', mode: '100644', type: 'blob', content: 'Screenshots and recordings attached to suggestions filed as issues on this repo, one folder per report. Nothing here is code.\n' }] });
|
|
50
|
+
const commit = await github(cfg, 'POST', `/repos/${repo}/git/commits`, { message: 'Start the suggestion attachments branch', tree: tree.body.sha, parents: [] });
|
|
51
|
+
try {
|
|
52
|
+
await github(cfg, 'POST', `/repos/${repo}/git/refs`, { ref: `refs/heads/${branch}`, sha: commit.body.sha });
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
if (!(e instanceof GitHubError) || e.status !== 422)
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
})().catch((e) => { ready = null; throw e; }));
|
|
59
|
+
/** A file past the contents API's comfortable size, through the git data API: a blob, a tree on top of the branch's
|
|
60
|
+
* own, a commit, and the branch moved to it (once more if another write moved it first). Up to GitHub's 100 MB. */
|
|
61
|
+
async function putLarge(path, file, reportId) {
|
|
62
|
+
try {
|
|
63
|
+
await github(cfg, 'GET', `/repos/${repo}/contents/${path}?ref=${branch}`);
|
|
64
|
+
if (refuseExisting)
|
|
65
|
+
throw new GitHubError(`${path} is already written`, 409, null);
|
|
66
|
+
return; // written once: its link stands
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
if (!(e instanceof GitHubError) || e.status !== 404)
|
|
70
|
+
throw e;
|
|
71
|
+
}
|
|
72
|
+
const blob = (await github(cfg, 'POST', `/repos/${repo}/git/blobs`, { content: toBase64(file.bytes), encoding: 'base64' })).body.sha;
|
|
73
|
+
for (let attempt = 0;; attempt++) {
|
|
74
|
+
const head = (await github(cfg, 'GET', `/repos/${repo}/git/ref/heads/${branch}`)).body.object.sha;
|
|
75
|
+
const base = (await github(cfg, 'GET', `/repos/${repo}/git/commits/${head}`)).body.tree.sha;
|
|
76
|
+
const tree = (await github(cfg, 'POST', `/repos/${repo}/git/trees`, { base_tree: base, tree: [{ path, mode: '100644', type: 'blob', sha: blob }] })).body.sha;
|
|
77
|
+
const commit = (await github(cfg, 'POST', `/repos/${repo}/git/commits`, { message: `Attach ${file.name} to ${reportId}`, tree, parents: [head] })).body.sha;
|
|
78
|
+
try {
|
|
79
|
+
await github(cfg, 'PATCH', `/repos/${repo}/git/refs/heads/${branch}`, { sha: commit });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
if (!(e instanceof GitHubError) || e.status !== 422 || attempt >= 2)
|
|
84
|
+
throw e;
|
|
85
|
+
} // someone else moved the branch: build on theirs
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
async put(target, file) {
|
|
90
|
+
if (target.repo !== repo)
|
|
91
|
+
throw new Error(`this host writes to ${repo}, not ${target.repo}`);
|
|
92
|
+
const path = pathFor(target, file.name);
|
|
93
|
+
await ensure();
|
|
94
|
+
const size = file.bytes instanceof Uint8Array ? file.bytes.length : file.bytes.byteLength;
|
|
95
|
+
if (size > CONTENTS_API_BYTES) {
|
|
96
|
+
await putLarge(path, file, target.reportId);
|
|
97
|
+
return { url: attachmentUrl(repo, branch, path) };
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
await github(cfg, 'PUT', `/repos/${repo}/contents/${path}`, { message: `Attach ${file.name} to ${target.reportId}`, content: toBase64(file.bytes), branch });
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
if (!(e instanceof GitHubError) || e.status !== 422)
|
|
104
|
+
throw e;
|
|
105
|
+
// A 422 means "already there" only if it is there: look, and pass any other refusal on as it came.
|
|
106
|
+
let there = false;
|
|
107
|
+
try {
|
|
108
|
+
await github(cfg, 'GET', `/repos/${repo}/contents/${path}?ref=${branch}`);
|
|
109
|
+
there = true;
|
|
110
|
+
}
|
|
111
|
+
catch (g) {
|
|
112
|
+
if (!(g instanceof GitHubError) || g.status !== 404)
|
|
113
|
+
throw g;
|
|
114
|
+
}
|
|
115
|
+
if (!there)
|
|
116
|
+
throw e;
|
|
117
|
+
if (refuseExisting)
|
|
118
|
+
throw new GitHubError(`${path} is already written`, 409, e.body);
|
|
119
|
+
}
|
|
120
|
+
return { url: attachmentUrl(repo, branch, path) };
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** For a filer who cannot push: the file goes to an attachment relay (any URL running `createAttachRelay`, with whatever
|
|
125
|
+
* header proves who is asking), which checks the named issue carries this report, saves the file through its own
|
|
126
|
+
* GitHub App, and answers the link. */
|
|
127
|
+
export function relayHost({ url, headers = {}, fetch }) {
|
|
128
|
+
const f = fetch ?? globalThis.fetch;
|
|
129
|
+
if (!/^https?:\/\//.test(url))
|
|
130
|
+
throw new Error(`${JSON.stringify(url)} is not a relay URL`);
|
|
131
|
+
return {
|
|
132
|
+
async put(target, file) {
|
|
133
|
+
pathFor(target, file.name);
|
|
134
|
+
const res = await f(url, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: { ...headers, 'content-type': 'application/json' },
|
|
137
|
+
body: JSON.stringify({ repo: target.repo, issue: target.issue, reportId: target.reportId, name: file.name, type: file.type, bytes: toBase64(file.bytes) }),
|
|
138
|
+
});
|
|
139
|
+
const text = await res.text();
|
|
140
|
+
let body = null;
|
|
141
|
+
try {
|
|
142
|
+
body = text ? JSON.parse(text) : null;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
body = text;
|
|
146
|
+
}
|
|
147
|
+
if (!res.ok)
|
|
148
|
+
throw new GitHubError(`the attachment relay answered ${res.status}`, res.status, body);
|
|
149
|
+
if (typeof body?.url !== 'string' || !body.url.startsWith(`https://github.com/${target.repo}/`))
|
|
150
|
+
throw new Error('the attachment relay did not answer a github.com link in this repo');
|
|
151
|
+
return { url: body.url };
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { privacyCatch, redact, mayFileAsIs, normalize } from './privacy.ts';
|
|
2
|
+
export type { Hit, HitKind, Person, CatchOptions } from './privacy.ts';
|
|
3
|
+
export { composeIssue, whatGoes, destinationWords, pieces, BODY_LIMIT, filedByLine, endsWithFiledBy, FILED_BY } from './compose.ts';
|
|
4
|
+
export type { Piece, Destination, Draft, ComposeOptions, ComposedIssue } from './compose.ts';
|
|
5
|
+
export { fileIssue, updateIssue, addFollowUp, issueStates, issueStatus, fileSuggestion, attachPending, github, GitHubError } from './api.ts';
|
|
6
|
+
export type { GitHubConfig, FilingConfig, IssueState, FileSuggestionInput, FiledSuggestion, Fetch } from './api.ts';
|
|
7
|
+
export { branchHost, relayHost, toBase64, attachmentUrl, ATTACHMENTS_BRANCH, CONTENTS_API_BYTES } from './hosts.ts';
|
|
8
|
+
export type { AttachmentHost, AttachmentTarget, AttachmentFile } from './hosts.ts';
|
|
9
|
+
export { createSuggestionRelay, createAttachRelay, memoryUsage, RELAY_LIMITS, RELAY_VOICE_BYTES, FILING_LEASE_MS } from './relay.ts';
|
|
10
|
+
export type { SuggestionRelayOptions, AttachRelayOptions, RelayLimits, RelayUsage, RelayIdentity, Reservation, FiledRecord, StagingAdapter } from './relay.ts';
|
|
11
|
+
export { relayFiler, canFileDirectly, STAGE_OVER_BYTES } from './filer.ts';
|
|
12
|
+
export type { RelayFiler, RelayFiling, RelayFile, RelayCapabilities } from './filer.ts';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// "@supersuit/suggest-a-change/github": a suggestion becomes one ordinary GitHub issue. Zero runtime dependencies,
|
|
2
|
+
// web-standard fetch (injectable). Used straight from an app's server (createReportHandlers' `github` option) or by the
|
|
3
|
+
// owner's Mac with `gh`'s token.
|
|
4
|
+
export { privacyCatch, redact, mayFileAsIs, normalize } from './privacy.js';
|
|
5
|
+
export { composeIssue, whatGoes, destinationWords, pieces, BODY_LIMIT, filedByLine, endsWithFiledBy, FILED_BY } from './compose.js';
|
|
6
|
+
export { fileIssue, updateIssue, addFollowUp, issueStates, issueStatus, fileSuggestion, attachPending, github, GitHubError } from './api.js';
|
|
7
|
+
export { branchHost, relayHost, toBase64, attachmentUrl, ATTACHMENTS_BRANCH, CONTENTS_API_BYTES } from './hosts.js';
|
|
8
|
+
export { createSuggestionRelay, createAttachRelay, memoryUsage, RELAY_LIMITS, RELAY_VOICE_BYTES, FILING_LEASE_MS } from './relay.js';
|
|
9
|
+
export { relayFiler, canFileDirectly, STAGE_OVER_BYTES } from './filer.js';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type HitKind = 'credential' | 'email' | 'phone' | 'person';
|
|
2
|
+
export interface Hit {
|
|
3
|
+
kind: HitKind;
|
|
4
|
+
text: string;
|
|
5
|
+
field: string;
|
|
6
|
+
}
|
|
7
|
+
export interface Person {
|
|
8
|
+
name: string;
|
|
9
|
+
aliases?: string[];
|
|
10
|
+
}
|
|
11
|
+
export interface CatchOptions {
|
|
12
|
+
/** Third parties: anyone in the workspace's people records other than the sender and the owner. */
|
|
13
|
+
people?: Person[];
|
|
14
|
+
/** Secrets the app knows, such as the sender's own account key; one under 12 characters is a name, not a secret, and is not looked for. */
|
|
15
|
+
secrets?: string[];
|
|
16
|
+
}
|
|
17
|
+
/** What the catch reads: report fields, plus a `transcript` the Mac wrote. */
|
|
18
|
+
export interface Readable {
|
|
19
|
+
words?: unknown;
|
|
20
|
+
transcript?: unknown;
|
|
21
|
+
followUps?: unknown;
|
|
22
|
+
[k: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
/** Text as it will read once rendered: compatibility forms folded (NFKC, so fullwidth letters are letters) and invisible
|
|
25
|
+
* format characters removed (zero-width spaces and joiners, soft hyphens, direction marks), so a split token still matches. */
|
|
26
|
+
export declare const normalize: (s: string) => string;
|
|
27
|
+
export declare function privacyCatch(report: Readable, { people, secrets }?: CatchOptions): {
|
|
28
|
+
hits: Hit[];
|
|
29
|
+
};
|
|
30
|
+
/** A copy of the report with every hit replaced by `[removed]` in its own field, wherever it appears there. A field with a
|
|
31
|
+
* hit comes back normalized (the form the catch read), so a hit split by an invisible character is removed too. */
|
|
32
|
+
export declare function redact<T extends Readable>(report: T, hits: Hit[]): T;
|
|
33
|
+
/** No credential among the hits: the owner may file it as it is. A credential only ever files without the flagged part. */
|
|
34
|
+
export declare const mayFileAsIs: (hits: Hit[]) => boolean;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// THE PRIVACY CATCH (suggestions-as-github-issues design, section 5). Runs over everything a person SAID before any of it
|
|
2
|
+
// becomes an issue: the words, the transcript and the follow-ups. Screenshots are not read for text in this version; the
|
|
3
|
+
// sheet's list is what tells the sender they are going. A hit holds the report for the owner; a credential is never
|
|
4
|
+
// filed as it is, whatever the tap (`mayFileAsIs`).
|
|
5
|
+
const CREDENTIALS = [
|
|
6
|
+
/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/g,
|
|
7
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}/g,
|
|
8
|
+
/\bsk-ant-[A-Za-z0-9_-]{20,}/g,
|
|
9
|
+
/\bsk-(?!ant-)[A-Za-z0-9_-]{20,}/g,
|
|
10
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
11
|
+
/\bAIza[0-9A-Za-z_-]{35}/g,
|
|
12
|
+
/\bxox[abposr]-[A-Za-z0-9-]{10,}/g,
|
|
13
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/g,
|
|
14
|
+
];
|
|
15
|
+
// A long random value after a name that says it is secret. The value alone is the hit, so "token=" survives redaction.
|
|
16
|
+
const ASSIGNED = /\b(?:api[_-]?key|key|token|secret|password|passwd)\s*[=:]\s*["']?([A-Za-z0-9+/_=-]{20,})/gi;
|
|
17
|
+
const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
18
|
+
const PHONES = [
|
|
19
|
+
/(?<![\w.])(?:\+?1[\s.-]?)?(?:\(\d{3}\)|\d{3})[\s.-]?\d{3}[\s.-]?\d{4}(?![\w.])/g,
|
|
20
|
+
/(?<![\w.])\+\d{8,15}(?![\w.])/g,
|
|
21
|
+
];
|
|
22
|
+
/** Shannon entropy in bits per character: random keys score high, "aaaa..." and ordinary words low. */
|
|
23
|
+
function entropy(s) {
|
|
24
|
+
const n = new Map();
|
|
25
|
+
for (const ch of s)
|
|
26
|
+
n.set(ch, (n.get(ch) ?? 0) + 1);
|
|
27
|
+
let e = 0;
|
|
28
|
+
for (const c of n.values()) {
|
|
29
|
+
const p = c / s.length;
|
|
30
|
+
e -= p * Math.log2(p);
|
|
31
|
+
}
|
|
32
|
+
return e;
|
|
33
|
+
}
|
|
34
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
35
|
+
/** Text as it will read once rendered: compatibility forms folded (NFKC, so fullwidth letters are letters) and invisible
|
|
36
|
+
* format characters removed (zero-width spaces and joiners, soft hyphens, direction marks), so a split token still matches. */
|
|
37
|
+
export const normalize = (s) => s.normalize('NFKC').replace(/\p{Cf}/gu, '');
|
|
38
|
+
/** EVERY string that reaches the issue, with the path it sits at: the words, the transcript, the follow-ups, where, every
|
|
39
|
+
* diagnostic, the names given (filerName, appName), the contact's handle, and every part of the context (each item's
|
|
40
|
+
* label, value and why, and the routes, taps and errors). Each is normalized before it is read. */
|
|
41
|
+
function fields(r) {
|
|
42
|
+
const out = [];
|
|
43
|
+
const at = (path, v) => { if (typeof v === 'string' && v)
|
|
44
|
+
out.push([path, normalize(v)]); };
|
|
45
|
+
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null);
|
|
46
|
+
at('words', r.words);
|
|
47
|
+
at('transcript', r.transcript);
|
|
48
|
+
if (Array.isArray(r.followUps))
|
|
49
|
+
r.followUps.forEach((f, i) => at(`followUps.${i}.words`, obj(f)?.words));
|
|
50
|
+
const where = obj(r.where);
|
|
51
|
+
at('where.screen', where?.screen);
|
|
52
|
+
at('where.route', where?.route);
|
|
53
|
+
for (const [k, v] of Object.entries(obj(r.diagnostics) ?? {}))
|
|
54
|
+
at(`diagnostics.${k}`, v);
|
|
55
|
+
at('filerName', r.filerName);
|
|
56
|
+
at('appName', r.appName);
|
|
57
|
+
at('who.contact.handle', obj(obj(r.who)?.contact)?.handle);
|
|
58
|
+
// The names of the files, which the issue shows as link text.
|
|
59
|
+
if (Array.isArray(r.files))
|
|
60
|
+
r.files.forEach((f, i) => at(`files.${i}.name`, typeof f === 'string' ? f : obj(f)?.name));
|
|
61
|
+
if (Array.isArray(r.files))
|
|
62
|
+
r.files.forEach((f, i) => at(`files.${i}.name`, typeof f === 'string' ? f : obj(f)?.name));
|
|
63
|
+
const ctx = obj(r.context);
|
|
64
|
+
if (ctx) {
|
|
65
|
+
for (const [list, keys] of [['items', ['label', 'value', 'why']], ['routes', ['route', 'at']], ['taps', ['label', 'at']], ['errors', ['message', 'frame', 'at']]]) {
|
|
66
|
+
const v = ctx[list];
|
|
67
|
+
if (Array.isArray(v))
|
|
68
|
+
v.forEach((x, i) => { for (const k of keys)
|
|
69
|
+
at(`context.${list}.${i}.${k}`, obj(x)?.[k]); });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
const digits = (s) => s.replace(/\D/g, '');
|
|
75
|
+
export function privacyCatch(report, { people = [], secrets = [] } = {}) {
|
|
76
|
+
const hits = [];
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
// The sender's own way to be reached is theirs to share: it is never a hit, while anyone else's still is. An email
|
|
79
|
+
// or phone inside the contact itself (its handle) is the sender's too; anywhere else it is a hit.
|
|
80
|
+
const own = report.who?.contact;
|
|
81
|
+
const ownEmail = typeof own?.email === 'string' ? normalize(own.email).toLowerCase() : null;
|
|
82
|
+
const ownPhone = typeof own?.phone === 'string' && digits(own.phone).length >= 7 ? digits(own.phone) : null;
|
|
83
|
+
const add = (kind, text, field) => {
|
|
84
|
+
if (kind === 'email' && ownEmail && text.toLowerCase() === ownEmail)
|
|
85
|
+
return;
|
|
86
|
+
if (kind === 'phone' && ownPhone && digits(text) === ownPhone)
|
|
87
|
+
return;
|
|
88
|
+
if ((kind === 'email' || kind === 'phone') && field === 'who.contact.handle')
|
|
89
|
+
return;
|
|
90
|
+
const k = `${field}\u0000${text}`;
|
|
91
|
+
if (seen.has(k))
|
|
92
|
+
return;
|
|
93
|
+
// A value already caught as a credential is not caught again as something smaller inside it.
|
|
94
|
+
if (hits.some((h) => h.field === field && h.kind === 'credential' && h.text.includes(text)))
|
|
95
|
+
return;
|
|
96
|
+
seen.add(k);
|
|
97
|
+
hits.push({ kind, text, field });
|
|
98
|
+
};
|
|
99
|
+
const keys = secrets.filter((k) => typeof k === 'string' && k.length >= 12).map(normalize);
|
|
100
|
+
const names = people.flatMap((p) => [p.name, ...(p.aliases ?? [])]).filter((n) => typeof n === 'string' && n.trim().split(/\s+/).length >= 2).map(normalize);
|
|
101
|
+
for (const [field, text] of fields(report)) {
|
|
102
|
+
for (const re of CREDENTIALS)
|
|
103
|
+
for (const m of text.matchAll(re))
|
|
104
|
+
add('credential', m[0], field);
|
|
105
|
+
for (const m of text.matchAll(ASSIGNED))
|
|
106
|
+
if (entropy(m[1]) >= 3)
|
|
107
|
+
add('credential', m[1], field);
|
|
108
|
+
for (const k of keys)
|
|
109
|
+
if (text.includes(k))
|
|
110
|
+
add('credential', k, field);
|
|
111
|
+
for (const m of text.matchAll(EMAIL))
|
|
112
|
+
add('email', m[0], field);
|
|
113
|
+
for (const re of PHONES)
|
|
114
|
+
for (const m of text.matchAll(re))
|
|
115
|
+
add('phone', m[0].trim(), field);
|
|
116
|
+
for (const n of names) {
|
|
117
|
+
const re = new RegExp(`(?<![\\p{L}\\p{N}])${escape(n.trim()).replace(/\s+/g, '\\s+')}(?![\\p{L}\\p{N}])`, 'giu');
|
|
118
|
+
for (const m of text.matchAll(re))
|
|
119
|
+
add('person', m[0], field);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { hits };
|
|
123
|
+
}
|
|
124
|
+
/** A copy of the report with every hit replaced by `[removed]` in its own field, wherever it appears there. A field with a
|
|
125
|
+
* hit comes back normalized (the form the catch read), so a hit split by an invisible character is removed too. */
|
|
126
|
+
export function redact(report, hits) {
|
|
127
|
+
const out = JSON.parse(JSON.stringify(report));
|
|
128
|
+
for (const field of [...new Set(hits.map((h) => h.field))]) {
|
|
129
|
+
const path = field.split('.');
|
|
130
|
+
let at = out;
|
|
131
|
+
for (const p of path.slice(0, -1))
|
|
132
|
+
at = at && typeof at === 'object' ? at[p] : undefined;
|
|
133
|
+
const last = path[path.length - 1];
|
|
134
|
+
if (!at || typeof at !== 'object' || typeof at[last] !== 'string')
|
|
135
|
+
continue;
|
|
136
|
+
const mine = hits.filter((h) => h.field === field).map((h) => h.text).sort((a, b) => b.length - a.length);
|
|
137
|
+
at[last] = mine.reduce((t, h) => t.split(h).join('[removed]'), normalize(at[last]));
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
/** No credential among the hits: the owner may file it as it is. A credential only ever files without the flagged part. */
|
|
142
|
+
export const mayFileAsIs = (hits) => !hits.some((h) => h.kind === 'credential');
|