@boxxy/server 0.1.4 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boxxy/server",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "The boxxy server: the API behind its three doors, and the runner loop",
5
5
  "homepage": "https://github.com/zgeoff/boxxy/tree/main/apps/server#readme",
6
6
  "bugs": "https://github.com/zgeoff/boxxy/issues",
@@ -32,19 +32,19 @@
32
32
  "typecheck": "tsrs --noEmit"
33
33
  },
34
34
  "dependencies": {
35
- "@boxxy/caller-proof-imp": "0.1.4",
36
- "@boxxy/closeout": "0.1.4",
37
- "@boxxy/content-hash": "0.1.4",
38
- "@boxxy/contract": "0.1.4",
39
- "@boxxy/dispatch": "0.1.4",
40
- "@boxxy/forge-github": "0.1.4",
41
- "@boxxy/model": "0.1.4",
42
- "@boxxy/notifier-atc": "0.1.4",
43
- "@boxxy/pepys": "0.1.4",
44
- "@boxxy/planning": "0.1.4",
45
- "@boxxy/rules": "0.1.4",
46
- "@boxxy/store-sqlite": "0.1.4",
47
- "@boxxy/worker-atc": "0.1.4",
35
+ "@boxxy/caller-proof-imp": "0.1.5",
36
+ "@boxxy/closeout": "0.1.5",
37
+ "@boxxy/content-hash": "0.1.5",
38
+ "@boxxy/contract": "0.1.5",
39
+ "@boxxy/dispatch": "0.1.5",
40
+ "@boxxy/forge-github": "0.1.5",
41
+ "@boxxy/model": "0.1.5",
42
+ "@boxxy/notifier-atc": "0.1.5",
43
+ "@boxxy/pepys": "0.1.5",
44
+ "@boxxy/planning": "0.1.5",
45
+ "@boxxy/rules": "0.1.5",
46
+ "@boxxy/store-sqlite": "0.1.5",
47
+ "@boxxy/worker-atc": "0.1.5",
48
48
  "@modelcontextprotocol/server": "2.4.0",
49
49
  "@orpc/client": "2.0.0-beta.50",
50
50
  "@orpc/mcp": "2.0.0-beta.50",
@@ -1,25 +1,36 @@
1
- import { readdir } from 'node:fs/promises';
1
+ import { readdir, stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
4
  export type IssuersAnswer =
5
5
  | { readonly kind: 'read'; readonly issuers: readonly unknown[] }
6
6
  | { readonly kind: 'invalid'; readonly message: string };
7
7
 
8
- // The trust documents, one per file of each folder, as parsed JSON in name order. The adapter
9
- // checks their shape; this reads them only.
8
+ // The trust documents, one per regular file of each folder, as parsed JSON in name order. The
9
+ // adapter checks their shape; this reads them only. Folders that hold no document refuse, so a
10
+ // configured but empty mount never starts a server that refuses every session in silence.
10
11
  export async function readTrustedIssuers(folders: readonly string[]): Promise<IssuersAnswer> {
11
12
  const issuers: unknown[] = [];
12
13
 
13
14
  for (const folder of folders) {
14
- const files = await readFileNames(folder);
15
+ const names = await readEntryNames(folder);
15
16
 
16
- if (files === null) {
17
+ if (names === null) {
17
18
  return { kind: 'invalid', message: `no trusted issuers folder at ${folder}` };
18
19
  }
19
20
 
20
- for (const name of files) {
21
+ for (const name of names) {
21
22
  const file = path.join(folder, name);
22
23
 
24
+ const entry = await readEntryKind(file);
25
+
26
+ if (entry === 'missing') {
27
+ return { kind: 'invalid', message: `the trust document at ${file} points nowhere` };
28
+ }
29
+
30
+ if (entry === 'other') {
31
+ continue;
32
+ }
33
+
23
34
  const text = await Bun.file(file).text();
24
35
 
25
36
  const json = parseJSON(text);
@@ -32,23 +43,38 @@ export async function readTrustedIssuers(folders: readonly string[]): Promise<Is
32
43
  }
33
44
  }
34
45
 
46
+ if (issuers.length === 0) {
47
+ return { kind: 'invalid', message: `no trust document in ${folders.join(', ')}` };
48
+ }
49
+
35
50
  return { kind: 'read', issuers };
36
51
  }
37
52
 
38
- // null: the folder does not exist
39
- async function readFileNames(folder: string): Promise<string[] | null> {
53
+ // null: the folder does not exist. A Kubernetes volume keeps its real files under `..data` and
54
+ // `..<timestamp>` and shows each key as a symlink beside them, so `..` names are skipped.
55
+ async function readEntryNames(folder: string): Promise<string[] | null> {
40
56
  try {
41
- const entries = await readdir(folder, { withFileTypes: true });
57
+ const names = await readdir(folder);
42
58
 
43
- return entries
44
- .filter((entry) => entry.isFile())
45
- .map((entry) => entry.name)
59
+ return names
60
+ .filter((name) => !name.startsWith('..'))
46
61
  .toSorted((left, right) => left.localeCompare(right));
47
62
  } catch {
48
63
  return null;
49
64
  }
50
65
  }
51
66
 
67
+ // `stat` follows a symlink to its target; a symlink that points nowhere is `missing`
68
+ async function readEntryKind(file: string): Promise<'file' | 'other' | 'missing'> {
69
+ try {
70
+ const stats = await stat(file);
71
+
72
+ return stats.isFile() ? 'file' : 'other';
73
+ } catch {
74
+ return 'missing';
75
+ }
76
+ }
77
+
52
78
  function parseJSON(text: string): unknown {
53
79
  try {
54
80
  const value: unknown = JSON.parse(text);