docugate 0.2.8 → 0.3.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.
Files changed (82) hide show
  1. package/README.md +200 -57
  2. package/dist/api.d.ts +44 -0
  3. package/dist/api.js +11 -5
  4. package/dist/args.d.ts +29 -0
  5. package/dist/args.js +121 -0
  6. package/dist/auth.d.ts +21 -0
  7. package/dist/banner.d.ts +13 -0
  8. package/dist/banner.js +62 -0
  9. package/dist/check.d.ts +22 -0
  10. package/dist/check.js +4 -3
  11. package/dist/cli.d.ts +2 -0
  12. package/dist/cli.js +214 -165
  13. package/dist/commands.d.ts +35 -0
  14. package/dist/commands.js +293 -0
  15. package/dist/config.d.ts +22 -0
  16. package/dist/config.js +43 -3
  17. package/dist/developers.d.ts +35 -0
  18. package/dist/developers.js +439 -0
  19. package/dist/init.d.ts +130 -0
  20. package/dist/init.js +102 -36
  21. package/dist/keys/core.d.ts +108 -0
  22. package/dist/keys/core.js +142 -0
  23. package/dist/keys/express.d.ts +21 -0
  24. package/dist/keys/express.js +27 -0
  25. package/dist/keys/fastify.d.ts +21 -0
  26. package/dist/keys/fastify.js +21 -0
  27. package/dist/keys/format.d.ts +11 -0
  28. package/dist/keys/format.js +46 -0
  29. package/dist/keys/hono.d.ts +19 -0
  30. package/dist/keys/hono.js +21 -0
  31. package/dist/keys/index.d.ts +13 -0
  32. package/dist/keys/index.js +12 -0
  33. package/dist/logo-pixels.d.ts +4 -0
  34. package/dist/logo-pixels.js +35 -0
  35. package/dist/logo.d.ts +19 -0
  36. package/dist/logo.js +68 -0
  37. package/dist/menu.d.ts +16 -0
  38. package/dist/menu.js +157 -0
  39. package/dist/openapi.d.ts +38 -0
  40. package/dist/openapi.js +2 -1
  41. package/dist/rules.d.ts +69 -0
  42. package/dist/rules.js +2 -1
  43. package/dist/select.d.ts +75 -0
  44. package/dist/select.js +215 -0
  45. package/dist/session.d.ts +46 -0
  46. package/dist/session.js +54 -0
  47. package/dist/spinner.d.ts +12 -0
  48. package/dist/spinner.js +12 -12
  49. package/dist/status.d.ts +46 -0
  50. package/dist/status.js +166 -0
  51. package/dist/sync/ai.d.ts +44 -0
  52. package/dist/sync/ai.js +147 -0
  53. package/dist/sync/apply.d.ts +25 -0
  54. package/dist/sync/apply.js +104 -0
  55. package/dist/sync/changes.d.ts +58 -0
  56. package/dist/sync/changes.js +167 -0
  57. package/dist/sync/command.d.ts +12 -0
  58. package/dist/sync/command.js +236 -0
  59. package/dist/sync/guard.d.ts +15 -0
  60. package/dist/sync/guard.js +21 -0
  61. package/dist/sync/hook.d.ts +10 -0
  62. package/dist/sync/hook.js +62 -0
  63. package/dist/sync/impact.d.ts +23 -0
  64. package/dist/sync/impact.js +93 -0
  65. package/dist/sync/pr.d.ts +37 -0
  66. package/dist/sync/pr.js +106 -0
  67. package/dist/sync/run.d.ts +50 -0
  68. package/dist/sync/run.js +124 -0
  69. package/dist/sync/state.d.ts +8 -0
  70. package/dist/sync/state.js +22 -0
  71. package/dist/sync/workflow.d.ts +20 -0
  72. package/dist/sync/workflow.js +84 -0
  73. package/dist/text.d.ts +11 -0
  74. package/dist/text.js +16 -0
  75. package/dist/tour-init.d.ts +61 -0
  76. package/dist/tour-install.d.ts +38 -0
  77. package/dist/tour-install.js +58 -0
  78. package/dist/tour-server.d.ts +21 -0
  79. package/dist/tour.d.ts +60 -0
  80. package/dist/ui.d.ts +29 -0
  81. package/dist/ui.js +63 -0
  82. 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, readFileSync, writeFileSync } from 'node:fs';
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
- io.print(` warning ${err.message}`);
101
- return undefined;
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
- /** Sets one key in docugate.json, keeping everything else as it was. */
155
- function saveSetting(root, key, value) {
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
- existing = JSON.parse(readFileSync(path, 'utf8'));
233
+ parsed = JSON.parse(readText(path));
161
234
  }
162
235
  catch {
163
- // unreadable: start again with this key
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');
@@ -188,7 +265,9 @@ async function askForBobKey(io, openUrl) {
188
265
  return false;
189
266
  }
190
267
  useBobKeyForThisRun(key);
191
- io.print(' Using the key for this run only. To keep it for next time, set it in your environment:');
268
+ // Say it arrived, without showing it: the last four characters and the length.
269
+ io.print(` ${green('✓')} Key received (…${key.slice(-4)}, ${key.length} characters). Using it for this run only.`);
270
+ io.print(' To keep it for next time, set it in your environment:');
192
271
  io.print(process.platform === 'win32'
193
272
  ? ' [Environment]::SetEnvironmentVariable("BOB_API_KEY", "<key>", "User") (then open a new terminal)'
194
273
  : ' export BOB_API_KEY=<key> (add it to your shell profile)');
@@ -212,18 +291,7 @@ export const FRESHNESS = [
212
291
  { value: 'off', label: 'Never, I will ask for it' },
213
292
  ];
214
293
  function saveRole(root, role) {
215
- const path = join(root, CONFIG_FILE);
216
- let existing = {};
217
- if (existsSync(path)) {
218
- try {
219
- existing = JSON.parse(readFileSync(path, 'utf8'));
220
- }
221
- catch {
222
- // leave as empty object; will be overwritten with role added
223
- }
224
- }
225
- existing.role = role;
226
- writeFileSync(path, JSON.stringify(existing, null, 2) + '\n');
294
+ saveSetting(root, 'role', role);
227
295
  }
228
296
  const ROLE_CHOICES = [{ role: 'frontend' }, { role: 'backend' }, { role: 'both' }];
229
297
  const FRONTEND_DIRS = ['frontend', 'client', 'web'];
@@ -256,16 +324,6 @@ const dimmed = paint('2');
256
324
  const gold = paint('38;2;244;196;63');
257
325
  const cream = paint('38;2;245;240;227');
258
326
  const green = paint('32');
259
- /** DocuGate's owl, drawn small: cream eyes, gold beak. */
260
- export function banner() {
261
- return [
262
- '',
263
- ` ${dimmed('╭─────╮')}`,
264
- ` ${dimmed('│')} ${cream('◉ ◉')} ${dimmed('│')} ${bold('DocuGate')}`,
265
- ` ${dimmed('│')} ${gold('▾')} ${dimmed('│')} ${dimmed('Tours and docs for your codebase')}`,
266
- ` ${dimmed('╰─────╯')}`,
267
- ];
268
- }
269
327
  /**
270
328
  * A numbered choice: prints the options, and takes only a number. Enter picks
271
329
  * the default. Nobody types a word during setup.
@@ -282,7 +340,7 @@ export async function choose(io, question, options, fallback = 1) {
282
340
  io.print(` Type a number from 1 to ${options.length}.`);
283
341
  }
284
342
  }
285
- function defaultGetRemote(root) {
343
+ export function defaultGetRemote(root) {
286
344
  try {
287
345
  const remote = execFileSync('git', ['remote', 'get-url', 'origin'], {
288
346
  encoding: 'utf8',
@@ -549,14 +607,14 @@ export async function initFlow(root, options = {}) {
549
607
  try {
550
608
  tourResult = tourInit(root, { runBob: options.runBob });
551
609
  stop();
552
- io.print(` IBM Bob wrote the tour.`);
610
+ io.print(` ${green('✓')} IBM Bob wrote the tour (${tourResult.added.length} screen${tourResult.added.length === 1 ? '' : 's'}).`);
553
611
  }
554
612
  catch (error) {
555
613
  stop();
556
614
  // Bob missing or failed — describe what went wrong, then continue.
557
615
  if (error instanceof BobKeyMissingError) {
558
616
  tourError =
559
- `IBM Bob did not accept the key. Check BOB_API_KEY holds an Inference key from bob.ibm.com, then run docugate tour init.`;
617
+ `✗ IBM Bob did not accept this key. Check it is an Inference key from bob.ibm.com, then run docugate tour init.`;
560
618
  }
561
619
  else if (error instanceof BobMissingError) {
562
620
  tourError =
@@ -623,6 +681,8 @@ export async function initFlow(root, options = {}) {
623
681
  if (role !== 'backend') {
624
682
  rows.push(['Tour', tourResult ? `IBM Bob wrote ${tourResult.added.length} screen${tourResult.added.length === 1 ? '' : 's'}` : 'add steps in the browser']);
625
683
  rows.push(['Tour button', hasPill && installResult?.target ? installResult.target : 'not added']);
684
+ if (installResult?.hook)
685
+ rows.push(['Starts with', `npm run ${installResult.hook.replace(/^pre/, '')}`]);
626
686
  }
627
687
  io.print(``);
628
688
  io.print(` ${dimmed('╭')} ${bold('DocuGate setup')}`);
@@ -636,11 +696,17 @@ export async function initFlow(root, options = {}) {
636
696
  }
637
697
  else if (hasPill) {
638
698
  io.print(` ${green('✓')} ${bold('DocuGate is set up.')}`);
639
- io.print(` Start your project as usual (for example ${gold('npm run dev')}) and open it:`);
699
+ const run = installResult?.hook ? `npm run ${installResult.hook.replace(/^pre/, '')}` : 'npm run dev';
700
+ io.print(` Start your project as usual (${gold(run)}) and open it:`);
640
701
  io.print(` the ${gold('Tour')} button is in the bottom-right corner.`);
641
702
  }
642
703
  else {
643
704
  io.print(` ${green('✓')} ${bold('DocuGate is set up.')} Run docugate tour install to add the Tour button to your app.`);
644
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
+ }
645
711
  return { ...base, role, tourResult, tourError, installResult, connectError };
646
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>;