docugate 0.2.9 → 0.3.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/README.md +200 -57
- package/dist/api.d.ts +44 -0
- package/dist/api.js +11 -5
- package/dist/args.d.ts +29 -0
- package/dist/args.js +121 -0
- package/dist/auth.d.ts +21 -0
- package/dist/banner.d.ts +13 -0
- package/dist/banner.js +50 -0
- package/dist/check.d.ts +22 -0
- package/dist/check.js +4 -3
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +201 -165
- package/dist/commands.d.ts +35 -0
- package/dist/commands.js +293 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.js +43 -3
- package/dist/developers.d.ts +35 -0
- package/dist/developers.js +439 -0
- package/dist/init.d.ts +130 -0
- package/dist/init.js +93 -32
- package/dist/keys/core.d.ts +108 -0
- package/dist/keys/core.js +142 -0
- package/dist/keys/express.d.ts +21 -0
- package/dist/keys/express.js +27 -0
- package/dist/keys/fastify.d.ts +21 -0
- package/dist/keys/fastify.js +21 -0
- package/dist/keys/format.d.ts +11 -0
- package/dist/keys/format.js +46 -0
- package/dist/keys/hono.d.ts +19 -0
- package/dist/keys/hono.js +21 -0
- package/dist/keys/index.d.ts +13 -0
- package/dist/keys/index.js +12 -0
- package/dist/logo-pixels.d.ts +4 -0
- package/dist/logo-pixels.js +35 -0
- package/dist/logo.d.ts +19 -0
- package/dist/logo.js +68 -0
- package/dist/menu.d.ts +29 -0
- package/dist/menu.js +213 -0
- package/dist/openapi.d.ts +38 -0
- package/dist/openapi.js +2 -1
- package/dist/rules.d.ts +69 -0
- package/dist/rules.js +2 -1
- package/dist/select.d.ts +81 -0
- package/dist/select.js +248 -0
- package/dist/session.d.ts +46 -0
- package/dist/session.js +54 -0
- package/dist/spinner.d.ts +12 -0
- package/dist/spinner.js +12 -12
- package/dist/status.d.ts +46 -0
- package/dist/status.js +166 -0
- package/dist/sync/ai.d.ts +44 -0
- package/dist/sync/ai.js +147 -0
- package/dist/sync/apply.d.ts +25 -0
- package/dist/sync/apply.js +104 -0
- package/dist/sync/changes.d.ts +58 -0
- package/dist/sync/changes.js +167 -0
- package/dist/sync/command.d.ts +12 -0
- package/dist/sync/command.js +236 -0
- package/dist/sync/guard.d.ts +15 -0
- package/dist/sync/guard.js +21 -0
- package/dist/sync/hook.d.ts +10 -0
- package/dist/sync/hook.js +62 -0
- package/dist/sync/impact.d.ts +23 -0
- package/dist/sync/impact.js +93 -0
- package/dist/sync/pr.d.ts +37 -0
- package/dist/sync/pr.js +106 -0
- package/dist/sync/run.d.ts +50 -0
- package/dist/sync/run.js +124 -0
- package/dist/sync/state.d.ts +8 -0
- package/dist/sync/state.js +22 -0
- package/dist/sync/workflow.d.ts +20 -0
- package/dist/sync/workflow.js +84 -0
- package/dist/text.d.ts +11 -0
- package/dist/text.js +16 -0
- package/dist/tour-init.d.ts +61 -0
- package/dist/tour-install.d.ts +38 -0
- package/dist/tour-server.d.ts +21 -0
- package/dist/tour.d.ts +60 -0
- package/dist/ui.d.ts +29 -0
- package/dist/ui.js +63 -0
- package/package.json +67 -47
package/dist/init.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
2
|
import { execFileSync, spawn } from 'node:child_process';
|
|
3
|
-
import { existsSync, mkdirSync, readdirSync,
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { basename, dirname, join } from 'node:path';
|
|
5
5
|
import { CONFIG_FILE, loadConfig } from './config.js';
|
|
6
|
+
import { readText } from './text.js';
|
|
6
7
|
import { tourInit, BobMissingError, BobKeyMissingError, readBobKey, useBobKeyForThisRun } from './tour-init.js';
|
|
7
8
|
import { bobKeyCandidates, startSpinner } from './spinner.js';
|
|
9
|
+
import { banner } from './banner.js';
|
|
8
10
|
import { tourInstall, pillSnippetLine, DEFAULT_PORT } from './tour-install.js';
|
|
9
11
|
import { login, loadCredentials } from './auth.js';
|
|
12
|
+
export { asCommand } from './ui.js';
|
|
13
|
+
import { asCommand } from './ui.js';
|
|
10
14
|
/**
|
|
11
15
|
* Sets a repository up for DocuGate: a docugate.json, and a first page if the
|
|
12
16
|
* docs folder has none. Never overwrites — running it twice is harmless, and
|
|
@@ -69,10 +73,49 @@ export function makeDefaultIO() {
|
|
|
69
73
|
});
|
|
70
74
|
},
|
|
71
75
|
print(line) {
|
|
72
|
-
console.log(line);
|
|
76
|
+
console.log(asCommand(line));
|
|
73
77
|
},
|
|
74
78
|
};
|
|
75
79
|
}
|
|
80
|
+
function gh(args, cwd = process.cwd()) {
|
|
81
|
+
try {
|
|
82
|
+
const out = execFileSync('gh', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
83
|
+
return { ok: true, out: out.trim() };
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
const e = err;
|
|
87
|
+
return { ok: false, out: (e.stderr ?? e.message).trim() };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The docs repository through the GitHub CLI, signed in as the person: create
|
|
92
|
+
* it with a README, then write docs/index.md into it through the GitHub API.
|
|
93
|
+
* Null when gh is not installed or not signed in, with the reason printed.
|
|
94
|
+
*/
|
|
95
|
+
function createDocsRepoWithGh(name, isPrivate, title, io) {
|
|
96
|
+
const who = gh(['api', 'user', '--jq', '.login']);
|
|
97
|
+
if (!who.ok || !who.out) {
|
|
98
|
+
io.print(` To create it another way, install the GitHub CLI (https://cli.github.com), run gh auth login, then docugate init again.`);
|
|
99
|
+
io.print(` Or create ${name} on github.com and pick "connect" in docugate init.`);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const stop = startSpinner(['Creating the docs repository with the GitHub CLI', 'Writing its first page']);
|
|
103
|
+
const made = gh(['repo', 'create', name, isPrivate ? '--private' : '--public', '--add-readme',
|
|
104
|
+
'--description', `Documentation for ${title}, published with DocuGate`]);
|
|
105
|
+
if (!made.ok && !/already exists/i.test(made.out)) {
|
|
106
|
+
stop();
|
|
107
|
+
io.print(` warning The GitHub CLI could not create ${name}: ${made.out.split(/\r?\n/).pop()}`);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const full = `${who.out}/${name}`;
|
|
111
|
+
const page = `# ${title}\n\nWelcome. This is the start of the documentation for ${title}.\n\nEvery markdown file in this \`docs/\` folder becomes a page on DocuGate.\n`;
|
|
112
|
+
const seeded = gh(['api', '-X', 'PUT', `repos/${full}/contents/docs/index.md`,
|
|
113
|
+
'-f', 'message=docs: first page, created by DocuGate',
|
|
114
|
+
'-f', `content=${Buffer.from(page).toString('base64')}`]).ok;
|
|
115
|
+
stop();
|
|
116
|
+
io.print(` ${green('✓')} Created https://github.com/${full} with the GitHub CLI`);
|
|
117
|
+
return { repo: full, url: `https://github.com/${full}`, cloneUrl: `https://github.com/${full}.git`, seeded };
|
|
118
|
+
}
|
|
76
119
|
/**
|
|
77
120
|
* A new space and the repository that holds its docs: the repository is
|
|
78
121
|
* created on the person's account with docs/index.md in it, then the space is
|
|
@@ -93,12 +136,36 @@ async function createDocsSpace(io, projectName, baseUrl, options) {
|
|
|
93
136
|
return createRepo(name, priv, { title });
|
|
94
137
|
}))(repoName, visibility === 2, projectName);
|
|
95
138
|
stop();
|
|
96
|
-
io.print(` Created ${repo.url}`);
|
|
139
|
+
io.print(` ${green('✓')} Created ${repo.url}`);
|
|
97
140
|
}
|
|
98
141
|
catch (err) {
|
|
99
142
|
stop();
|
|
100
|
-
|
|
101
|
-
|
|
143
|
+
const { code } = err;
|
|
144
|
+
// DocuGate could not create it (the app's permission, or GitHub's rules
|
|
145
|
+
// for app tokens). The person's own GitHub CLI can: try that before
|
|
146
|
+
// giving up, so the setup still ends with a space.
|
|
147
|
+
if (code === 'app_permission' || code === 'github_error') {
|
|
148
|
+
io.print(` ${dimmed(err.message)}`);
|
|
149
|
+
repo = (options.ghDocsRepoFn ?? createDocsRepoWithGh)(repoName, visibility === 2, projectName, io) ?? undefined;
|
|
150
|
+
}
|
|
151
|
+
else if (code === 'repo_exists') {
|
|
152
|
+
// A second run, or a repository made by hand: use it rather than stop.
|
|
153
|
+
const login = await (options.getSessionFn ?? (async () => (await import('./api.js')).getSession()))()
|
|
154
|
+
.then((s) => s.user?.githubLogin)
|
|
155
|
+
.catch(() => undefined);
|
|
156
|
+
if (login) {
|
|
157
|
+
repo = { repo: `${login}/${repoName}`, url: `https://github.com/${login}/${repoName}`, cloneUrl: '' };
|
|
158
|
+
io.print(` ${repo.repo} already exists: using it for the docs.`);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
io.print(` warning ${err.message}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
io.print(` warning ${err.message}`);
|
|
166
|
+
}
|
|
167
|
+
if (!repo)
|
|
168
|
+
return undefined;
|
|
102
169
|
}
|
|
103
170
|
const createFn = options.createSpaceFn ?? (async (r, name, dir) => {
|
|
104
171
|
const { createSpace } = await import('./api.js');
|
|
@@ -151,17 +218,27 @@ export function setUpAbove(root) {
|
|
|
151
218
|
}
|
|
152
219
|
return undefined;
|
|
153
220
|
}
|
|
154
|
-
/**
|
|
155
|
-
|
|
221
|
+
/**
|
|
222
|
+
* Sets one key in docugate.json, keeping everything else as it was.
|
|
223
|
+
*
|
|
224
|
+
* A file that cannot be read is left alone and reported. It used to be started over with
|
|
225
|
+
* just this key, which threw away a hand-written `title` and `sidebar` over one stray comma.
|
|
226
|
+
*/
|
|
227
|
+
export function saveSetting(root, key, value) {
|
|
156
228
|
const path = join(root, CONFIG_FILE);
|
|
157
229
|
let existing = {};
|
|
158
230
|
if (existsSync(path)) {
|
|
231
|
+
let parsed;
|
|
159
232
|
try {
|
|
160
|
-
|
|
233
|
+
parsed = JSON.parse(readText(path));
|
|
161
234
|
}
|
|
162
235
|
catch {
|
|
163
|
-
|
|
236
|
+
parsed = undefined;
|
|
237
|
+
}
|
|
238
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
239
|
+
throw new Error(`${CONFIG_FILE} is not valid JSON, so it was not changed. Fix it, or delete it, and run this again.`);
|
|
164
240
|
}
|
|
241
|
+
existing = parsed;
|
|
165
242
|
}
|
|
166
243
|
existing[key] = value;
|
|
167
244
|
writeFileSync(path, JSON.stringify(existing, null, 2) + '\n');
|
|
@@ -214,18 +291,7 @@ export const FRESHNESS = [
|
|
|
214
291
|
{ value: 'off', label: 'Never, I will ask for it' },
|
|
215
292
|
];
|
|
216
293
|
function saveRole(root, role) {
|
|
217
|
-
|
|
218
|
-
let existing = {};
|
|
219
|
-
if (existsSync(path)) {
|
|
220
|
-
try {
|
|
221
|
-
existing = JSON.parse(readFileSync(path, 'utf8'));
|
|
222
|
-
}
|
|
223
|
-
catch {
|
|
224
|
-
// leave as empty object; will be overwritten with role added
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
existing.role = role;
|
|
228
|
-
writeFileSync(path, JSON.stringify(existing, null, 2) + '\n');
|
|
294
|
+
saveSetting(root, 'role', role);
|
|
229
295
|
}
|
|
230
296
|
const ROLE_CHOICES = [{ role: 'frontend' }, { role: 'backend' }, { role: 'both' }];
|
|
231
297
|
const FRONTEND_DIRS = ['frontend', 'client', 'web'];
|
|
@@ -258,16 +324,6 @@ const dimmed = paint('2');
|
|
|
258
324
|
const gold = paint('38;2;244;196;63');
|
|
259
325
|
const cream = paint('38;2;245;240;227');
|
|
260
326
|
const green = paint('32');
|
|
261
|
-
/** DocuGate's owl, drawn small: cream eyes, gold beak. */
|
|
262
|
-
export function banner() {
|
|
263
|
-
return [
|
|
264
|
-
'',
|
|
265
|
-
` ${dimmed('╭─────╮')}`,
|
|
266
|
-
` ${dimmed('│')} ${cream('◉ ◉')} ${dimmed('│')} ${bold('DocuGate')}`,
|
|
267
|
-
` ${dimmed('│')} ${gold('▾')} ${dimmed('│')} ${dimmed('Tours and docs for your codebase')}`,
|
|
268
|
-
` ${dimmed('╰─────╯')}`,
|
|
269
|
-
];
|
|
270
|
-
}
|
|
271
327
|
/**
|
|
272
328
|
* A numbered choice: prints the options, and takes only a number. Enter picks
|
|
273
329
|
* the default. Nobody types a word during setup.
|
|
@@ -284,7 +340,7 @@ export async function choose(io, question, options, fallback = 1) {
|
|
|
284
340
|
io.print(` Type a number from 1 to ${options.length}.`);
|
|
285
341
|
}
|
|
286
342
|
}
|
|
287
|
-
function defaultGetRemote(root) {
|
|
343
|
+
export function defaultGetRemote(root) {
|
|
288
344
|
try {
|
|
289
345
|
const remote = execFileSync('git', ['remote', 'get-url', 'origin'], {
|
|
290
346
|
encoding: 'utf8',
|
|
@@ -647,5 +703,10 @@ export async function initFlow(root, options = {}) {
|
|
|
647
703
|
else {
|
|
648
704
|
io.print(` ${green('✓')} ${bold('DocuGate is set up.')} Run docugate tour install to add the Tour button to your app.`);
|
|
649
705
|
}
|
|
706
|
+
// The answer to "how often" only means something once the Action exists.
|
|
707
|
+
if (io.isTTY && final.freshness && final.freshness !== 'off') {
|
|
708
|
+
io.print(``);
|
|
709
|
+
io.print(` ${dimmed('Next:')} ${gold(asCommand('docugate sync init'))} ${dimmed(`adds the GitHub Action that keeps the docs current (${final.freshness})`)}`);
|
|
710
|
+
}
|
|
650
711
|
return { ...base, role, tourResult, tourError, installResult, connectError };
|
|
651
712
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** What DocuGate says about one key. `valid: false` always comes with the reason. */
|
|
2
|
+
export type KeyVerdict = {
|
|
3
|
+
valid: true;
|
|
4
|
+
code: 'VALID';
|
|
5
|
+
keyId: string;
|
|
6
|
+
/** The name the holder gave the key. */
|
|
7
|
+
name: string;
|
|
8
|
+
scopes: string[];
|
|
9
|
+
/** Who generated it. Yours to use or ignore: it is their DocuGate account. */
|
|
10
|
+
user: {
|
|
11
|
+
id: string;
|
|
12
|
+
email: string;
|
|
13
|
+
name: string;
|
|
14
|
+
};
|
|
15
|
+
/** `limit` and `remaining` are null for a key with no allowance. */
|
|
16
|
+
usage: {
|
|
17
|
+
used: number;
|
|
18
|
+
limit: number | null;
|
|
19
|
+
remaining: number | null;
|
|
20
|
+
};
|
|
21
|
+
expiresAt: string | null;
|
|
22
|
+
} | {
|
|
23
|
+
valid: false;
|
|
24
|
+
code: 'MALFORMED' | 'NOT_FOUND' | 'REVOKED' | 'EXPIRED' | 'USAGE_EXCEEDED' | 'MISSING_SCOPE' | 'KEYS_DISABLED' | 'PLAN_LAPSED';
|
|
25
|
+
};
|
|
26
|
+
export type ValidKey = Extract<KeyVerdict, {
|
|
27
|
+
valid: true;
|
|
28
|
+
}>;
|
|
29
|
+
export interface VerifyOptions {
|
|
30
|
+
/** From your space's settings in DocuGate. Starts with `dgs_`. Keep it in an environment variable. */
|
|
31
|
+
secret: string;
|
|
32
|
+
/** Only for a self-hosted DocuGate. Defaults to `DOCUGATE_URL`, then the hosted service. */
|
|
33
|
+
baseUrl?: string;
|
|
34
|
+
/** How long to wait for DocuGate before giving up. Defaults to 5000. */
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
/** For tests, or an environment with its own `fetch`. */
|
|
37
|
+
fetch?: typeof fetch;
|
|
38
|
+
}
|
|
39
|
+
/** DocuGate could not be asked, or refused to answer. Never means a key is bad. */
|
|
40
|
+
export declare class KeyServiceError extends Error {
|
|
41
|
+
constructor(message: string, options?: {
|
|
42
|
+
cause?: unknown;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Ask DocuGate about one key.
|
|
47
|
+
*
|
|
48
|
+
* Resolves with a verdict for every key, valid or not. It rejects, with a
|
|
49
|
+
* `KeyServiceError`, only when it could not get an answer: DocuGate was
|
|
50
|
+
* unreachable, or your secret was refused. Those are not the key's fault, so
|
|
51
|
+
* they are not a verdict.
|
|
52
|
+
*/
|
|
53
|
+
export declare function verifyKey(key: string, options: VerifyOptions & {
|
|
54
|
+
scope?: string;
|
|
55
|
+
cost?: number;
|
|
56
|
+
}): Promise<KeyVerdict>;
|
|
57
|
+
export interface RequireKeyOptions extends VerifyOptions {
|
|
58
|
+
/** A scope the key must hold. Leave out if you do not use scopes. */
|
|
59
|
+
scope?: string;
|
|
60
|
+
/** How many calls from the key's allowance this request uses. Defaults to 1. */
|
|
61
|
+
cost?: number;
|
|
62
|
+
/**
|
|
63
|
+
* Where the key is in the request. `authorization` means `Authorization: Bearer <key>`; any
|
|
64
|
+
* other value is a header name whose whole value is the key. Match what you chose in your
|
|
65
|
+
* space settings.
|
|
66
|
+
*/
|
|
67
|
+
header?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Seconds to remember a *valid* answer, so a busy API is not asking DocuGate on every request.
|
|
70
|
+
* The default, 0, asks every time, which is what makes a revoked key stop on its next call.
|
|
71
|
+
* A request served from this memory does not count against the key's allowance, and a key
|
|
72
|
+
* revoked in the meantime keeps working until the memory expires.
|
|
73
|
+
*/
|
|
74
|
+
cacheSeconds?: number;
|
|
75
|
+
/**
|
|
76
|
+
* What to do when DocuGate cannot be reached. By default the request is refused with a 503,
|
|
77
|
+
* because the alternative is accepting keys nobody checked. Set this if your API would rather
|
|
78
|
+
* stay up than be strict.
|
|
79
|
+
*/
|
|
80
|
+
failOpen?: boolean;
|
|
81
|
+
}
|
|
82
|
+
/** What an adapter should do with one request. */
|
|
83
|
+
export type Outcome = {
|
|
84
|
+
kind: 'allow';
|
|
85
|
+
key: ValidKey | null;
|
|
86
|
+
} | {
|
|
87
|
+
kind: 'deny';
|
|
88
|
+
status: number;
|
|
89
|
+
headers: Record<string, string>;
|
|
90
|
+
body: {
|
|
91
|
+
error: string;
|
|
92
|
+
code?: string;
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
/** The HTTP status a verdict that is not valid should be answered with. */
|
|
96
|
+
export declare function statusFor(code: Extract<KeyVerdict, {
|
|
97
|
+
valid: false;
|
|
98
|
+
}>['code']): number;
|
|
99
|
+
/** The key in a header's value, or nothing. */
|
|
100
|
+
export declare function readKey(value: string | string[] | undefined, header: string): string | null;
|
|
101
|
+
/**
|
|
102
|
+
* One function that takes a request's key header and says what to do. The
|
|
103
|
+
* adapters read the header out of their framework's request and carry out the answer.
|
|
104
|
+
*/
|
|
105
|
+
export declare function createKeyChecker(options: RequireKeyOptions): {
|
|
106
|
+
header: string;
|
|
107
|
+
check: (value: string | string[] | undefined) => Promise<Outcome>;
|
|
108
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { looksLikeKey } from './format.js';
|
|
3
|
+
/**
|
|
4
|
+
* Checking a DocuGate API key from your own backend.
|
|
5
|
+
*
|
|
6
|
+
* A reader of your API docs generates a key on the page. Your backend asks
|
|
7
|
+
* DocuGate whether a key is valid, with a secret only it holds, and DocuGate
|
|
8
|
+
* answers from its own records, so a key you revoke stops working on its next
|
|
9
|
+
* request. This file is that question and what to do with the answer. The
|
|
10
|
+
* adapters for Express, Hono and Fastify are a few lines on top of it.
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULT_BASE_URL = 'https://www.trydocugate.site';
|
|
13
|
+
/** DocuGate could not be asked, or refused to answer. Never means a key is bad. */
|
|
14
|
+
export class KeyServiceError extends Error {
|
|
15
|
+
constructor(message, options) {
|
|
16
|
+
super(message, options);
|
|
17
|
+
this.name = 'KeyServiceError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Ask DocuGate about one key.
|
|
22
|
+
*
|
|
23
|
+
* Resolves with a verdict for every key, valid or not. It rejects, with a
|
|
24
|
+
* `KeyServiceError`, only when it could not get an answer: DocuGate was
|
|
25
|
+
* unreachable, or your secret was refused. Those are not the key's fault, so
|
|
26
|
+
* they are not a verdict.
|
|
27
|
+
*/
|
|
28
|
+
export async function verifyKey(key, options) {
|
|
29
|
+
const base = (options.baseUrl ?? process.env.DOCUGATE_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
30
|
+
const send = options.fetch ?? fetch;
|
|
31
|
+
let res;
|
|
32
|
+
try {
|
|
33
|
+
res = await send(`${base}/api/keys/verify`, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { Authorization: `Bearer ${options.secret}`, 'Content-Type': 'application/json' },
|
|
36
|
+
body: JSON.stringify({ key, scope: options.scope, cost: options.cost }),
|
|
37
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 5000),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (cause) {
|
|
41
|
+
throw new KeyServiceError('DocuGate could not be reached to check an API key.', { cause });
|
|
42
|
+
}
|
|
43
|
+
if (res.status === 401) {
|
|
44
|
+
throw new KeyServiceError('DocuGate refused the verify secret. Check it matches the one in your space settings.');
|
|
45
|
+
}
|
|
46
|
+
if (!res.ok)
|
|
47
|
+
throw new KeyServiceError(`DocuGate answered ${res.status} when checking an API key.`);
|
|
48
|
+
const body = (await res.json().catch(() => null));
|
|
49
|
+
if (!body || typeof body.valid !== 'boolean')
|
|
50
|
+
throw new KeyServiceError('DocuGate sent an answer that could not be read.');
|
|
51
|
+
return body;
|
|
52
|
+
}
|
|
53
|
+
/** The HTTP status a verdict that is not valid should be answered with. */
|
|
54
|
+
export function statusFor(code) {
|
|
55
|
+
if (code === 'MISSING_SCOPE')
|
|
56
|
+
return 403;
|
|
57
|
+
if (code === 'USAGE_EXCEEDED')
|
|
58
|
+
return 429;
|
|
59
|
+
// Not the caller's fault and not fixable by them: the owner's plan or settings.
|
|
60
|
+
if (code === 'PLAN_LAPSED' || code === 'KEYS_DISABLED')
|
|
61
|
+
return 503;
|
|
62
|
+
return 401;
|
|
63
|
+
}
|
|
64
|
+
/** The key in a header's value, or nothing. */
|
|
65
|
+
export function readKey(value, header) {
|
|
66
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
67
|
+
if (!raw)
|
|
68
|
+
return null;
|
|
69
|
+
if (header.toLowerCase() !== 'authorization')
|
|
70
|
+
return raw.trim() || null;
|
|
71
|
+
const match = /^Bearer\s+(\S+)$/i.exec(raw.trim());
|
|
72
|
+
return match ? match[1] : null;
|
|
73
|
+
}
|
|
74
|
+
const unauthorised = (code, error) => ({
|
|
75
|
+
kind: 'deny',
|
|
76
|
+
status: 401,
|
|
77
|
+
headers: { 'WWW-Authenticate': 'Bearer' },
|
|
78
|
+
body: { error, code },
|
|
79
|
+
});
|
|
80
|
+
/**
|
|
81
|
+
* One function that takes a request's key header and says what to do. The
|
|
82
|
+
* adapters read the header out of their framework's request and carry out the answer.
|
|
83
|
+
*/
|
|
84
|
+
export function createKeyChecker(options) {
|
|
85
|
+
if (typeof options.secret !== 'string' || !options.secret.startsWith('dgs_')) {
|
|
86
|
+
throw new Error('requireKey needs the verify secret from your space settings in DocuGate. It starts with dgs_ — ' +
|
|
87
|
+
'pass it from an environment variable.');
|
|
88
|
+
}
|
|
89
|
+
const header = (options.header ?? 'authorization').toLowerCase();
|
|
90
|
+
const cacheMs = Math.max(0, options.cacheSeconds ?? 0) * 1000;
|
|
91
|
+
const remembered = new Map();
|
|
92
|
+
async function check(value) {
|
|
93
|
+
const key = readKey(value, header);
|
|
94
|
+
if (!key)
|
|
95
|
+
return unauthorised('MISSING', 'api_key_required');
|
|
96
|
+
// A string that cannot be a key never costs a network call.
|
|
97
|
+
if (!looksLikeKey(key))
|
|
98
|
+
return unauthorised('MALFORMED', 'invalid_api_key');
|
|
99
|
+
// Remembered by a hash, so the key itself is not kept in memory.
|
|
100
|
+
const id = cacheMs ? createHash('sha256').update(`${options.scope ?? ''}\n${key}`).digest('hex') : '';
|
|
101
|
+
const hit = cacheMs ? remembered.get(id) : undefined;
|
|
102
|
+
if (hit && hit.until > Date.now())
|
|
103
|
+
return { kind: 'allow', key: hit.key };
|
|
104
|
+
let verdict;
|
|
105
|
+
try {
|
|
106
|
+
verdict = await verifyKey(key, options);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (options.failOpen)
|
|
110
|
+
return { kind: 'allow', key: null };
|
|
111
|
+
return {
|
|
112
|
+
kind: 'deny',
|
|
113
|
+
status: 503,
|
|
114
|
+
headers: {},
|
|
115
|
+
body: { error: 'key_service_unavailable', code: error instanceof KeyServiceError ? 'UNAVAILABLE' : undefined },
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (verdict.valid) {
|
|
119
|
+
if (cacheMs) {
|
|
120
|
+
// A busy API must not grow this without bound: drop what has expired first.
|
|
121
|
+
if (remembered.size > 5000) {
|
|
122
|
+
const now = Date.now();
|
|
123
|
+
for (const [k, v] of remembered)
|
|
124
|
+
if (v.until <= now)
|
|
125
|
+
remembered.delete(k);
|
|
126
|
+
if (remembered.size > 5000)
|
|
127
|
+
remembered.clear();
|
|
128
|
+
}
|
|
129
|
+
remembered.set(id, { until: Date.now() + cacheMs, key: verdict });
|
|
130
|
+
}
|
|
131
|
+
return { kind: 'allow', key: verdict };
|
|
132
|
+
}
|
|
133
|
+
const status = statusFor(verdict.code);
|
|
134
|
+
return {
|
|
135
|
+
kind: 'deny',
|
|
136
|
+
status,
|
|
137
|
+
headers: status === 401 ? { 'WWW-Authenticate': 'Bearer' } : {},
|
|
138
|
+
body: { error: status === 401 ? 'invalid_api_key' : status === 403 ? 'forbidden' : status === 429 ? 'usage_exceeded' : 'key_service_unavailable', code: verdict.code },
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return { header, check };
|
|
142
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RequireKeyOptions, ValidKey } from './core.js';
|
|
2
|
+
export type { KeyVerdict, RequireKeyOptions, ValidKey } from './core.js';
|
|
3
|
+
/** The parts of an Express request this uses, so Express itself is not a dependency. */
|
|
4
|
+
interface Req {
|
|
5
|
+
headers: Record<string, string | string[] | undefined>;
|
|
6
|
+
/** Set to the verified key. Absent when `failOpen` let a request through unchecked. */
|
|
7
|
+
docugateKey?: ValidKey | null;
|
|
8
|
+
}
|
|
9
|
+
interface Res {
|
|
10
|
+
status(code: number): Res;
|
|
11
|
+
setHeader(name: string, value: string): unknown;
|
|
12
|
+
json(body: unknown): unknown;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Express middleware that lets a request through only if it carries a valid
|
|
16
|
+
* DocuGate API key. The verified key is on `req.docugateKey`.
|
|
17
|
+
*
|
|
18
|
+
* import { requireKey } from 'docugate/express'
|
|
19
|
+
* app.use('/api', requireKey({ secret: process.env.DOCUGATE_VERIFY_SECRET! }))
|
|
20
|
+
*/
|
|
21
|
+
export declare function requireKey(options: RequireKeyOptions): (req: Req, res: Res, next: (error?: unknown) => void) => Promise<void>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createKeyChecker } from './core.js';
|
|
2
|
+
/**
|
|
3
|
+
* Express middleware that lets a request through only if it carries a valid
|
|
4
|
+
* DocuGate API key. The verified key is on `req.docugateKey`.
|
|
5
|
+
*
|
|
6
|
+
* import { requireKey } from 'docugate/express'
|
|
7
|
+
* app.use('/api', requireKey({ secret: process.env.DOCUGATE_VERIFY_SECRET! }))
|
|
8
|
+
*/
|
|
9
|
+
export function requireKey(options) {
|
|
10
|
+
const { header, check } = createKeyChecker(options);
|
|
11
|
+
return async (req, res, next) => {
|
|
12
|
+
try {
|
|
13
|
+
const outcome = await check(req.headers[header]);
|
|
14
|
+
if (outcome.kind === 'deny') {
|
|
15
|
+
for (const [name, value] of Object.entries(outcome.headers))
|
|
16
|
+
res.setHeader(name, value);
|
|
17
|
+
res.status(outcome.status).json(outcome.body);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
req.docugateKey = outcome.key;
|
|
21
|
+
next();
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
next(error);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RequireKeyOptions, ValidKey } from './core.js';
|
|
2
|
+
export type { KeyVerdict, RequireKeyOptions, ValidKey } from './core.js';
|
|
3
|
+
/** The parts of a Fastify request and reply this uses, so Fastify itself is not a dependency. */
|
|
4
|
+
interface Request {
|
|
5
|
+
headers: Record<string, string | string[] | undefined>;
|
|
6
|
+
/** Set to the verified key. Null when `failOpen` let a request through unchecked. */
|
|
7
|
+
docugateKey?: ValidKey | null;
|
|
8
|
+
}
|
|
9
|
+
interface Reply {
|
|
10
|
+
code(status: number): Reply;
|
|
11
|
+
header(name: string, value: string): Reply;
|
|
12
|
+
send(body: unknown): Reply;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A Fastify hook that lets a request through only if it carries a valid
|
|
16
|
+
* DocuGate API key. The verified key is `request.docugateKey`.
|
|
17
|
+
*
|
|
18
|
+
* import { requireKey } from 'docugate/fastify'
|
|
19
|
+
* app.addHook('preHandler', requireKey({ secret: process.env.DOCUGATE_VERIFY_SECRET! }))
|
|
20
|
+
*/
|
|
21
|
+
export declare function requireKey(options: RequireKeyOptions): (request: Request, reply: Reply) => Promise<Reply | undefined>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createKeyChecker } from './core.js';
|
|
2
|
+
/**
|
|
3
|
+
* A Fastify hook that lets a request through only if it carries a valid
|
|
4
|
+
* DocuGate API key. The verified key is `request.docugateKey`.
|
|
5
|
+
*
|
|
6
|
+
* import { requireKey } from 'docugate/fastify'
|
|
7
|
+
* app.addHook('preHandler', requireKey({ secret: process.env.DOCUGATE_VERIFY_SECRET! }))
|
|
8
|
+
*/
|
|
9
|
+
export function requireKey(options) {
|
|
10
|
+
const { header, check } = createKeyChecker(options);
|
|
11
|
+
return async (request, reply) => {
|
|
12
|
+
const outcome = await check(request.headers[header]);
|
|
13
|
+
if (outcome.kind === 'deny') {
|
|
14
|
+
for (const [name, value] of Object.entries(outcome.headers))
|
|
15
|
+
reply.header(name, value);
|
|
16
|
+
// Returning the reply is how an async Fastify hook says "I have answered".
|
|
17
|
+
return reply.code(outcome.status).send(outcome.body);
|
|
18
|
+
}
|
|
19
|
+
request.docugateKey = outcome.key;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a DocuGate API key looks like, so a request that cannot possibly hold one
|
|
3
|
+
* is refused without a round trip.
|
|
4
|
+
*
|
|
5
|
+
* This is a copy of the server's `isWellFormedKey` (`server/apiKeys.ts`), not an
|
|
6
|
+
* import of it: this package is published on its own. Both test suites check the
|
|
7
|
+
* same two golden keys, so if either side's format moves, a test fails here
|
|
8
|
+
* before any customer's API does.
|
|
9
|
+
*/
|
|
10
|
+
/** Whether a string has a key's shape and a checksum that matches. Not whether the key exists. */
|
|
11
|
+
export declare function looksLikeKey(value: unknown): value is string;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a DocuGate API key looks like, so a request that cannot possibly hold one
|
|
3
|
+
* is refused without a round trip.
|
|
4
|
+
*
|
|
5
|
+
* This is a copy of the server's `isWellFormedKey` (`server/apiKeys.ts`), not an
|
|
6
|
+
* import of it: this package is published on its own. Both test suites check the
|
|
7
|
+
* same two golden keys, so if either side's format moves, a test fails here
|
|
8
|
+
* before any customer's API does.
|
|
9
|
+
*/
|
|
10
|
+
const PREFIX = 'dg_live_';
|
|
11
|
+
const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
|
12
|
+
const BODY_LENGTH = 43;
|
|
13
|
+
const CHECKSUM_LENGTH = 6;
|
|
14
|
+
const CRC_TABLE = (() => {
|
|
15
|
+
const table = new Uint32Array(256);
|
|
16
|
+
for (let i = 0; i < 256; i++) {
|
|
17
|
+
let c = i;
|
|
18
|
+
for (let k = 0; k < 8; k++)
|
|
19
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
20
|
+
table[i] = c >>> 0;
|
|
21
|
+
}
|
|
22
|
+
return table;
|
|
23
|
+
})();
|
|
24
|
+
function crc32(text) {
|
|
25
|
+
let crc = 0xffffffff;
|
|
26
|
+
for (const byte of Buffer.from(text, 'utf8'))
|
|
27
|
+
crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
28
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
29
|
+
}
|
|
30
|
+
function base62(n, length) {
|
|
31
|
+
let out = '';
|
|
32
|
+
let rest = n;
|
|
33
|
+
while (rest > 0) {
|
|
34
|
+
out = ALPHABET[rest % 62] + out;
|
|
35
|
+
rest = Math.floor(rest / 62);
|
|
36
|
+
}
|
|
37
|
+
return out.padStart(length, '0');
|
|
38
|
+
}
|
|
39
|
+
/** Whether a string has a key's shape and a checksum that matches. Not whether the key exists. */
|
|
40
|
+
export function looksLikeKey(value) {
|
|
41
|
+
if (typeof value !== 'string' || value.length !== PREFIX.length + BODY_LENGTH + CHECKSUM_LENGTH)
|
|
42
|
+
return false;
|
|
43
|
+
if (!value.startsWith(PREFIX) || !/^[0-9A-Za-z]+$/.test(value.slice(PREFIX.length)))
|
|
44
|
+
return false;
|
|
45
|
+
return value.slice(-CHECKSUM_LENGTH) === base62(crc32(value.slice(0, -CHECKSUM_LENGTH)), CHECKSUM_LENGTH);
|
|
46
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { RequireKeyOptions } from './core.js';
|
|
2
|
+
export type { KeyVerdict, RequireKeyOptions, ValidKey } from './core.js';
|
|
3
|
+
/** The parts of a Hono context this uses, so Hono itself is not a dependency. */
|
|
4
|
+
interface Context {
|
|
5
|
+
req: {
|
|
6
|
+
header(name: string): string | undefined;
|
|
7
|
+
};
|
|
8
|
+
header(name: string, value: string): void;
|
|
9
|
+
json(body: unknown, status?: number): Response;
|
|
10
|
+
set(key: 'docugateKey', value: unknown): void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Hono middleware that lets a request through only if it carries a valid
|
|
14
|
+
* DocuGate API key. The verified key is `c.get('docugateKey')`.
|
|
15
|
+
*
|
|
16
|
+
* import { requireKey } from 'docugate/hono'
|
|
17
|
+
* app.use('/api/*', requireKey({ secret: process.env.DOCUGATE_VERIFY_SECRET! }))
|
|
18
|
+
*/
|
|
19
|
+
export declare function requireKey(options: RequireKeyOptions): (c: Context, next: () => Promise<void>) => Promise<Response | undefined>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createKeyChecker } from './core.js';
|
|
2
|
+
/**
|
|
3
|
+
* Hono middleware that lets a request through only if it carries a valid
|
|
4
|
+
* DocuGate API key. The verified key is `c.get('docugateKey')`.
|
|
5
|
+
*
|
|
6
|
+
* import { requireKey } from 'docugate/hono'
|
|
7
|
+
* app.use('/api/*', requireKey({ secret: process.env.DOCUGATE_VERIFY_SECRET! }))
|
|
8
|
+
*/
|
|
9
|
+
export function requireKey(options) {
|
|
10
|
+
const { header, check } = createKeyChecker(options);
|
|
11
|
+
return async (c, next) => {
|
|
12
|
+
const outcome = await check(c.req.header(header));
|
|
13
|
+
if (outcome.kind === 'deny') {
|
|
14
|
+
for (const [name, value] of Object.entries(outcome.headers))
|
|
15
|
+
c.header(name, value);
|
|
16
|
+
return c.json(outcome.body, outcome.status);
|
|
17
|
+
}
|
|
18
|
+
c.set('docugateKey', outcome.key);
|
|
19
|
+
await next();
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `docugate/keys`: check a DocuGate API key from any backend, in any framework.
|
|
3
|
+
*
|
|
4
|
+
* import { verifyKey } from 'docugate/keys'
|
|
5
|
+
* const verdict = await verifyKey(key, { secret: process.env.DOCUGATE_VERIFY_SECRET! })
|
|
6
|
+
* if (!verdict.valid) return deny(verdict.code)
|
|
7
|
+
*
|
|
8
|
+
* For Express, Hono or Fastify there is a ready-made middleware: `docugate/express`,
|
|
9
|
+
* `docugate/hono`, `docugate/fastify`.
|
|
10
|
+
*/
|
|
11
|
+
export { createKeyChecker, KeyServiceError, readKey, statusFor, verifyKey } from './core.js';
|
|
12
|
+
export type { KeyVerdict, Outcome, RequireKeyOptions, ValidKey, VerifyOptions } from './core.js';
|
|
13
|
+
export { looksLikeKey } from './format.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `docugate/keys`: check a DocuGate API key from any backend, in any framework.
|
|
3
|
+
*
|
|
4
|
+
* import { verifyKey } from 'docugate/keys'
|
|
5
|
+
* const verdict = await verifyKey(key, { secret: process.env.DOCUGATE_VERIFY_SECRET! })
|
|
6
|
+
* if (!verdict.valid) return deny(verdict.code)
|
|
7
|
+
*
|
|
8
|
+
* For Express, Hono or Fastify there is a ready-made middleware: `docugate/express`,
|
|
9
|
+
* `docugate/hono`, `docugate/fastify`.
|
|
10
|
+
*/
|
|
11
|
+
export { createKeyChecker, KeyServiceError, readKey, statusFor, verifyKey } from './core.js';
|
|
12
|
+
export { looksLikeKey } from './format.js';
|