sh3-server 0.7.5 → 0.8.2

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 (46) hide show
  1. package/app/assets/index-Cb-zoqb1.js +17 -0
  2. package/app/assets/index-Cb-zoqb1.js.map +1 -0
  3. package/app/assets/index-DPcN5Lor.css +1 -0
  4. package/app/index.html +2 -2
  5. package/dist/auth.d.ts +10 -3
  6. package/dist/auth.js +14 -22
  7. package/dist/caller.d.ts +16 -0
  8. package/dist/caller.js +54 -0
  9. package/dist/cli.js +9 -7
  10. package/dist/fs-backend.d.ts +10 -0
  11. package/dist/fs-backend.js +105 -0
  12. package/dist/index.js +30 -12
  13. package/dist/keys.d.ts +33 -19
  14. package/dist/keys.js +172 -49
  15. package/dist/packages.d.ts +23 -3
  16. package/dist/packages.js +67 -6
  17. package/dist/routes/admin.js +7 -3
  18. package/dist/routes/docs.d.ts +2 -0
  19. package/dist/routes/docs.js +30 -0
  20. package/dist/routes/keys.d.ts +21 -0
  21. package/dist/routes/keys.js +164 -0
  22. package/dist/scope.d.ts +9 -0
  23. package/dist/scope.js +25 -0
  24. package/dist/settings.d.ts +13 -0
  25. package/dist/settings.js +33 -0
  26. package/dist/shard-router.d.ts +9 -2
  27. package/dist/shard-router.js +58 -29
  28. package/dist/shell-shard/index.d.ts +6 -1
  29. package/dist/shell-shard/index.js +3 -1
  30. package/dist/shell-shard/session-manager.d.ts +2 -1
  31. package/dist/shell-shard/session-manager.js +15 -2
  32. package/dist/shell-shard/ws.js +14 -14
  33. package/dist/tenant-fs/http.d.ts +15 -0
  34. package/dist/tenant-fs/http.js +109 -0
  35. package/dist/tenant-fs/index.d.ts +4 -0
  36. package/dist/tenant-fs/index.js +4 -0
  37. package/dist/tenant-fs/paths.d.ts +23 -0
  38. package/dist/tenant-fs/paths.js +51 -0
  39. package/dist/tenant-fs/resolve.d.ts +16 -0
  40. package/dist/tenant-fs/resolve.js +48 -0
  41. package/dist/tenant-fs/session-required.d.ts +11 -0
  42. package/dist/tenant-fs/session-required.js +19 -0
  43. package/package.json +2 -2
  44. package/app/assets/index-25fXNyG3.js +0 -12
  45. package/app/assets/index-25fXNyG3.js.map +0 -1
  46. package/app/assets/index-BcQ1cruS.css +0 -1
package/dist/keys.js CHANGED
@@ -1,68 +1,191 @@
1
1
  /**
2
- * API key managementgenerate, validate, list, revoke.
2
+ * Unified API key storeadmin, user-tenant, and connector-bound keys.
3
3
  *
4
- * Keys are stored in {dataDir}/keys.json. Each key has an id
5
- * (short identifier for display/revocation), the full key value (used
6
- * for auth), a label, and creation timestamp.
4
+ * Layout on disk:
5
+ * <dataDir>/admin-keys.json — tenantId: null rows only
6
+ * <dataDir>/users/<tenantId>/__system__/keys.json user-tenant rows
7
+ *
8
+ * Legacy migration: a pre-existing <dataDir>/keys.json is treated as admin
9
+ * keys and moved to admin-keys.json on first load, with the original renamed
10
+ * to keys.json.legacy.
7
11
  */
8
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
9
- import { join, dirname } from 'node:path';
12
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, renameSync, } from 'node:fs';
13
+ import { dirname, join } from 'node:path';
10
14
  import { randomBytes } from 'node:crypto';
15
+ function strip(k) {
16
+ const { key: _drop, ...rest } = k;
17
+ return rest;
18
+ }
11
19
  export class KeyStore {
12
- #path;
13
- #keys = [];
20
+ #dataDir;
21
+ #admin = [];
22
+ #byTenant = new Map();
14
23
  constructor(dataDir) {
15
- this.#path = join(dataDir, 'keys.json');
16
- this.#load();
24
+ this.#dataDir = dataDir;
25
+ this.#migrateLegacy();
26
+ this.#loadAdmin();
27
+ this.#loadAllTenants();
28
+ }
29
+ // ---------- public API ----------
30
+ generate(input) {
31
+ const id = randomBytes(4).toString('hex');
32
+ const key = `sh3_${randomBytes(32).toString('hex')}`;
33
+ const row = {
34
+ id,
35
+ key,
36
+ label: input.label,
37
+ tenantId: input.tenantId,
38
+ ownerUserId: input.ownerUserId,
39
+ mintedByShardId: input.mintedByShardId,
40
+ scopes: [...input.scopes],
41
+ connectorId: input.connectorId,
42
+ createdAt: new Date().toISOString(),
43
+ expiresAt: input.expiresAt,
44
+ };
45
+ if (row.tenantId === null) {
46
+ this.#admin.push(row);
47
+ this.#saveAdmin();
48
+ }
49
+ else {
50
+ const bucket = this.#byTenant.get(row.tenantId) ?? [];
51
+ bucket.push(row);
52
+ this.#byTenant.set(row.tenantId, bucket);
53
+ this.#saveTenant(row.tenantId);
54
+ }
55
+ return { ...row, scopes: [...row.scopes] };
56
+ }
57
+ resolve(token) {
58
+ const now = Date.now();
59
+ const match = (row) => {
60
+ if (row.key !== token)
61
+ return false;
62
+ if (row.expiresAt && Date.parse(row.expiresAt) <= now)
63
+ return false;
64
+ return true;
65
+ };
66
+ const adminHit = this.#admin.find(match);
67
+ if (adminHit)
68
+ return adminHit;
69
+ for (const bucket of this.#byTenant.values()) {
70
+ const hit = bucket.find(match);
71
+ if (hit)
72
+ return hit;
73
+ }
74
+ return null;
75
+ }
76
+ listForTenant(tenantId) {
77
+ return (this.#byTenant.get(tenantId) ?? []).map(strip);
78
+ }
79
+ listForShard(tenantId, shardId) {
80
+ return this.listForTenant(tenantId).filter((k) => k.mintedByShardId === shardId);
81
+ }
82
+ listAdmin() {
83
+ return this.#admin.map(strip);
84
+ }
85
+ listAll() {
86
+ const out = this.#admin.map(strip);
87
+ for (const bucket of this.#byTenant.values())
88
+ out.push(...bucket.map(strip));
89
+ return out;
90
+ }
91
+ revoke(tenantId, id) {
92
+ if (tenantId === null) {
93
+ const idx = this.#admin.findIndex((k) => k.id === id);
94
+ if (idx < 0)
95
+ return null;
96
+ const [removed] = this.#admin.splice(idx, 1);
97
+ this.#saveAdmin();
98
+ return strip(removed);
99
+ }
100
+ const bucket = this.#byTenant.get(tenantId);
101
+ if (!bucket)
102
+ return null;
103
+ const idx = bucket.findIndex((k) => k.id === id);
104
+ if (idx < 0)
105
+ return null;
106
+ const [removed] = bucket.splice(idx, 1);
107
+ this.#saveTenant(tenantId);
108
+ return strip(removed);
17
109
  }
18
- #load() {
19
- if (existsSync(this.#path)) {
110
+ isEmpty() {
111
+ return this.#admin.length === 0;
112
+ }
113
+ // ---------- persistence ----------
114
+ #adminPath() {
115
+ return join(this.#dataDir, 'admin-keys.json');
116
+ }
117
+ #tenantPath(tenantId) {
118
+ return join(this.#dataDir, 'users', tenantId, '__system__', 'keys.json');
119
+ }
120
+ #loadAdmin() {
121
+ const p = this.#adminPath();
122
+ if (!existsSync(p))
123
+ return;
124
+ try {
125
+ const raw = JSON.parse(readFileSync(p, 'utf-8'));
126
+ this.#admin.push(...raw);
127
+ }
128
+ catch {
129
+ // Corrupt admin file — leave empty; the next generate() overwrites.
130
+ }
131
+ }
132
+ #loadAllTenants() {
133
+ const usersDir = join(this.#dataDir, 'users');
134
+ if (!existsSync(usersDir))
135
+ return;
136
+ for (const entry of readdirSync(usersDir, { withFileTypes: true })) {
137
+ if (!entry.isDirectory())
138
+ continue;
139
+ const tenantId = entry.name;
140
+ const file = this.#tenantPath(tenantId);
141
+ if (!existsSync(file))
142
+ continue;
20
143
  try {
21
- this.#keys = JSON.parse(readFileSync(this.#path, 'utf-8'));
144
+ const raw = JSON.parse(readFileSync(file, 'utf-8'));
145
+ this.#byTenant.set(tenantId, raw);
22
146
  }
23
147
  catch {
24
- this.#keys = [];
148
+ this.#byTenant.set(tenantId, []);
25
149
  }
26
150
  }
27
151
  }
28
- #save() {
29
- const dir = dirname(this.#path);
30
- mkdirSync(dir, { recursive: true });
31
- writeFileSync(this.#path, JSON.stringify(this.#keys, null, 2));
32
- }
33
- /** Validate a bearer token. Returns true if the key exists. */
34
- validate(token) {
35
- return this.#keys.some((k) => k.key === token);
36
- }
37
- /** List all keys (returns id, label, createdAt — never the full key). */
38
- list() {
39
- return this.#keys.map(({ id, label, createdAt }) => ({ id, label, createdAt }));
152
+ #saveAdmin() {
153
+ const p = this.#adminPath();
154
+ mkdirSync(dirname(p), { recursive: true });
155
+ writeFileSync(p, JSON.stringify(this.#admin, null, 2));
40
156
  }
41
- /** List all keys including full key values (admin only). */
42
- listFull() {
43
- return this.#keys.map((k) => ({ ...k }));
157
+ #saveTenant(tenantId) {
158
+ const bucket = this.#byTenant.get(tenantId) ?? [];
159
+ const p = this.#tenantPath(tenantId);
160
+ mkdirSync(dirname(p), { recursive: true });
161
+ writeFileSync(p, JSON.stringify(bucket, null, 2));
44
162
  }
45
- /** Generate a new API key. Returns the full key (only shown once). */
46
- generate(label) {
47
- const id = randomBytes(4).toString('hex');
48
- const key = `sh3_${randomBytes(32).toString('hex')}`;
49
- const entry = { id, key, label, createdAt: new Date().toISOString() };
50
- this.#keys.push(entry);
51
- this.#save();
52
- return entry;
53
- }
54
- /** Revoke a key by id. Returns true if found and removed. */
55
- revoke(id) {
56
- const before = this.#keys.length;
57
- this.#keys = this.#keys.filter((k) => k.id !== id);
58
- if (this.#keys.length < before) {
59
- this.#save();
60
- return true;
163
+ #migrateLegacy() {
164
+ const legacyPath = join(this.#dataDir, 'keys.json');
165
+ const adminPath = this.#adminPath();
166
+ if (!existsSync(legacyPath) || existsSync(adminPath))
167
+ return;
168
+ try {
169
+ const legacy = JSON.parse(readFileSync(legacyPath, 'utf-8'));
170
+ const migrated = legacy.map((row) => ({
171
+ ...row,
172
+ tenantId: null,
173
+ ownerUserId: null,
174
+ mintedByShardId: null,
175
+ scopes: ['admin:*'],
176
+ }));
177
+ mkdirSync(dirname(adminPath), { recursive: true });
178
+ writeFileSync(adminPath, JSON.stringify(migrated, null, 2));
179
+ renameSync(legacyPath, legacyPath + '.legacy');
180
+ }
181
+ catch {
182
+ // If migration fails, leave everything alone; admin can investigate.
61
183
  }
62
- return false;
63
184
  }
64
- /** True if no keys exist (first boot). */
65
- isEmpty() {
66
- return this.#keys.length === 0;
185
+ // ---------- legacy compat ----------
186
+ /** @deprecated use resolve() — kept until all callers migrate. */
187
+ validate(token) {
188
+ const hit = this.resolve(token);
189
+ return !!hit && hit.scopes.includes('admin:*');
67
190
  }
68
191
  }
@@ -5,6 +5,8 @@ import { ShardRouter } from './shard-router.js';
5
5
  import type { MountContext } from './shard-router.js';
6
6
  /** Type of the `wsRegister` factory field from MountContext. */
7
7
  type WsRegister = MountContext['wsRegister'];
8
+ /** Type of the `documentBackend` field from MountContext. */
9
+ type DocumentBackend = MountContext['documentBackend'];
8
10
  export interface DiscoveredPackage {
9
11
  id: string;
10
12
  type: string;
@@ -19,7 +21,7 @@ export interface DiscoveredPackage {
19
21
  * For each valid package, mount server routes (if server.js exists) and
20
22
  * return the full list of discovered packages.
21
23
  */
22
- export declare function loadPackages(shardRouter: ShardRouter, dataDir: string, keys: KeyStore, settings: SettingsStore, wsRegister: WsRegister): Promise<DiscoveredPackage[]>;
24
+ export declare function loadPackages(shardRouter: ShardRouter, dataDir: string, keys: KeyStore, settings: SettingsStore, wsRegister: WsRegister, documentBackend: DocumentBackend): Promise<DiscoveredPackage[]>;
23
25
  /**
24
26
  * Re-scan `<dataDir>/packages/` and return metadata for all valid packages.
25
27
  * Unlike `loadPackages`, this does NOT mount server routes — it only reads
@@ -28,11 +30,29 @@ export declare function loadPackages(shardRouter: ShardRouter, dataDir: string,
28
30
  export declare function scanPackages(dataDir: string): DiscoveredPackage[];
29
31
  /**
30
32
  * Register `GET /packages/:id/client.js` to serve client bundles from disk.
33
+ * The `Cache-Control` header is read from settings on every request:
34
+ * - cacheMaxAge === 0 → `no-store`
35
+ * - otherwise → `public, max-age=<n>` (never `immutable`)
31
36
  */
32
- export declare function servePackageBundles(app: Hono, dataDir: string): void;
37
+ export declare function servePackageBundles(app: Hono, dataDir: string, settings: SettingsStore): void;
38
+ export interface MissingShardsResult {
39
+ missing: Array<{
40
+ id: string;
41
+ }>;
42
+ }
43
+ /**
44
+ * Check an uploaded manifest's `requiredShards` against the set of shard ids
45
+ * already known to the server (framework shards + installed shard/combo
46
+ * packages + shards present in the current upload for combo bundles).
47
+ *
48
+ * Returns `{ missing: [] }` for shard-only packages or when all requirements
49
+ * are satisfied. The shape of `missing` entries is deliberately extensible
50
+ * (future version-mismatch entries can add a `versionMismatch: true` flag).
51
+ */
52
+ export declare function validateRequiredShards(manifest: Record<string, unknown>, knownShards: Set<string>): MissingShardsResult;
33
53
  /**
34
54
  * Returns a Hono router with POST /install and POST /uninstall.
35
55
  * Protected by the blanket `/api/*` auth middleware already applied upstream.
36
56
  */
37
- export declare function createPackageManagementRoutes(shardRouter: ShardRouter, dataDir: string, keys: KeyStore, settings: SettingsStore, wsRegister: WsRegister): Hono;
57
+ export declare function createPackageManagementRoutes(shardRouter: ShardRouter, dataDir: string, keys: KeyStore, settings: SettingsStore, wsRegister: WsRegister, documentBackend: DocumentBackend, frameworkShardIds?: string[]): Hono;
38
58
  export {};
package/dist/packages.js CHANGED
@@ -18,7 +18,7 @@ function isValidId(id) {
18
18
  * For each valid package, mount server routes (if server.js exists) and
19
19
  * return the full list of discovered packages.
20
20
  */
21
- export async function loadPackages(shardRouter, dataDir, keys, settings, wsRegister) {
21
+ export async function loadPackages(shardRouter, dataDir, keys, settings, wsRegister, documentBackend) {
22
22
  const packagesDir = join(dataDir, 'packages');
23
23
  if (!existsSync(packagesDir)) {
24
24
  mkdirSync(packagesDir, { recursive: true });
@@ -68,7 +68,7 @@ export async function loadPackages(shardRouter, dataDir, keys, settings, wsRegis
68
68
  };
69
69
  if (hasServer) {
70
70
  try {
71
- await shardRouter.mount(manifest.id, serverJs, { pkgDir, keys, settings, wsRegister });
71
+ await shardRouter.mount(manifest.id, serverJs, { pkgDir, keys, settings, wsRegister, documentBackend });
72
72
  }
73
73
  catch (err) {
74
74
  console.warn(`[sh3] ${manifest.id}/server.js failed to load:`, err);
@@ -138,8 +138,11 @@ export function scanPackages(dataDir) {
138
138
  // ---------------------------------------------------------------------------
139
139
  /**
140
140
  * Register `GET /packages/:id/client.js` to serve client bundles from disk.
141
+ * The `Cache-Control` header is read from settings on every request:
142
+ * - cacheMaxAge === 0 → `no-store`
143
+ * - otherwise → `public, max-age=<n>` (never `immutable`)
141
144
  */
142
- export function servePackageBundles(app, dataDir) {
145
+ export function servePackageBundles(app, dataDir, settings) {
143
146
  app.get('/packages/:id/client.js', (c) => {
144
147
  const id = c.req.param('id');
145
148
  if (!isValidId(id)) {
@@ -149,13 +152,40 @@ export function servePackageBundles(app, dataDir) {
149
152
  if (!existsSync(filePath)) {
150
153
  return c.json({ error: 'Client bundle not found' }, 404);
151
154
  }
155
+ const maxAge = settings.get().packages.cacheMaxAge;
156
+ const cacheControl = maxAge === 0 ? 'no-store' : `public, max-age=${maxAge}`;
152
157
  const content = readFileSync(filePath, 'utf-8');
153
158
  return c.body(content, 200, {
154
159
  'Content-Type': 'application/javascript',
155
- 'Cache-Control': 'public, max-age=31536000, immutable',
160
+ 'Cache-Control': cacheControl,
156
161
  });
157
162
  });
158
163
  }
164
+ /**
165
+ * Check an uploaded manifest's `requiredShards` against the set of shard ids
166
+ * already known to the server (framework shards + installed shard/combo
167
+ * packages + shards present in the current upload for combo bundles).
168
+ *
169
+ * Returns `{ missing: [] }` for shard-only packages or when all requirements
170
+ * are satisfied. The shape of `missing` entries is deliberately extensible
171
+ * (future version-mismatch entries can add a `versionMismatch: true` flag).
172
+ */
173
+ export function validateRequiredShards(manifest, knownShards) {
174
+ const type = manifest.type;
175
+ if (type !== 'app' && type !== 'combo')
176
+ return { missing: [] };
177
+ const required = manifest.requiredShards;
178
+ if (!Array.isArray(required))
179
+ return { missing: [] };
180
+ const missing = [];
181
+ for (const id of required) {
182
+ if (typeof id !== 'string')
183
+ continue;
184
+ if (!knownShards.has(id))
185
+ missing.push({ id });
186
+ }
187
+ return { missing };
188
+ }
159
189
  // ---------------------------------------------------------------------------
160
190
  // Package management routes (install / uninstall)
161
191
  // ---------------------------------------------------------------------------
@@ -163,7 +193,7 @@ export function servePackageBundles(app, dataDir) {
163
193
  * Returns a Hono router with POST /install and POST /uninstall.
164
194
  * Protected by the blanket `/api/*` auth middleware already applied upstream.
165
195
  */
166
- export function createPackageManagementRoutes(shardRouter, dataDir, keys, settings, wsRegister) {
196
+ export function createPackageManagementRoutes(shardRouter, dataDir, keys, settings, wsRegister, documentBackend, frameworkShardIds = []) {
167
197
  const router = new Hono();
168
198
  router.post('/install', async (c) => {
169
199
  const form = await c.req.formData();
@@ -189,6 +219,37 @@ export function createPackageManagementRoutes(shardRouter, dataDir, keys, settin
189
219
  if (typeof manifest.version !== 'string' || !manifest.version) {
190
220
  return c.json({ error: 'Missing "version" in manifest' }, 400);
191
221
  }
222
+ // requiredShards guard — reject apps whose shard deps are not on the server.
223
+ const installedShardIds = [];
224
+ const pkgsDir = join(dataDir, 'packages');
225
+ if (existsSync(pkgsDir)) {
226
+ for (const entry of readdirSync(pkgsDir, { withFileTypes: true })) {
227
+ if (!entry.isDirectory())
228
+ continue;
229
+ const otherManifestPath = join(pkgsDir, entry.name, 'manifest.json');
230
+ if (!existsSync(otherManifestPath))
231
+ continue;
232
+ try {
233
+ const other = JSON.parse(readFileSync(otherManifestPath, 'utf-8'));
234
+ if ((other.type === 'shard' || other.type === 'combo') && typeof other.id === 'string') {
235
+ installedShardIds.push(other.id);
236
+ }
237
+ }
238
+ catch { /* skip malformed manifest */ }
239
+ }
240
+ }
241
+ const knownShards = new Set([
242
+ ...frameworkShardIds,
243
+ ...installedShardIds,
244
+ ]);
245
+ // Combo uploads contribute their own shard to the known set (the shard id equals the combo id).
246
+ if (manifest.type === 'combo' && typeof manifest.id === 'string') {
247
+ knownShards.add(manifest.id);
248
+ }
249
+ const { missing } = validateRequiredShards(manifest, knownShards);
250
+ if (missing.length > 0) {
251
+ return c.json({ code: 'missing-shards', missing }, 409);
252
+ }
192
253
  const pkgDir = join(dataDir, 'packages', id);
193
254
  mkdirSync(pkgDir, { recursive: true });
194
255
  // Write manifest
@@ -206,7 +267,7 @@ export function createPackageManagementRoutes(shardRouter, dataDir, keys, settin
206
267
  writeFileSync(join(pkgDir, 'server.js'), buf);
207
268
  // Hot-mount server routes
208
269
  try {
209
- await shardRouter.mount(id, join(pkgDir, 'server.js'), { pkgDir, keys, settings, wsRegister });
270
+ await shardRouter.mount(id, join(pkgDir, 'server.js'), { pkgDir, keys, settings, wsRegister, documentBackend });
210
271
  }
211
272
  catch (err) {
212
273
  // Roll back entire install — broken server bundle must not be half-installed
@@ -99,7 +99,11 @@ export function createAdminRouter(users, settings, sessions, keys, dataDir) {
99
99
  });
100
100
  // --- API Keys ---
101
101
  router.get('/keys', (c) => {
102
- return c.json(keys.listFull());
102
+ return c.json(keys.listAdmin());
103
+ });
104
+ /** Read-only audit endpoint — every key across all tenants and admin. */
105
+ router.get('/keys/all', (c) => {
106
+ return c.json(keys.listAll());
103
107
  });
104
108
  router.post('/keys', async (c) => {
105
109
  let body;
@@ -113,12 +117,12 @@ export function createAdminRouter(users, settings, sessions, keys, dataDir) {
113
117
  if (!label) {
114
118
  return c.json({ error: 'Label required' }, 400);
115
119
  }
116
- const key = keys.generate(label);
120
+ const key = keys.generate({ label, tenantId: null, ownerUserId: null, mintedByShardId: null, scopes: ['admin:*'] });
117
121
  return c.json(key, 201);
118
122
  });
119
123
  router.delete('/keys/:id', (c) => {
120
124
  const id = c.req.param('id');
121
- if (!keys.revoke(id))
125
+ if (!keys.revoke(null, id))
122
126
  return c.json({ error: 'Key not found' }, 404);
123
127
  return c.body(null, 204);
124
128
  });
@@ -4,6 +4,8 @@
4
4
  * Maps the DocumentBackend interface to HTTP endpoints backed by the
5
5
  * local filesystem. Files are stored at {dataDir}/docs/{tenant}/{shard}/{path}.
6
6
  *
7
+ * GET /api/docs/:tenant/_shards → listAllShards
8
+ * GET /api/docs/:tenant/_all → listAllDocuments
7
9
  * GET /api/docs/:tenant/:shard → list
8
10
  * GET /api/docs/:tenant/:shard/*path → read
9
11
  * HEAD /api/docs/:tenant/:shard/*path → exists
@@ -4,6 +4,8 @@
4
4
  * Maps the DocumentBackend interface to HTTP endpoints backed by the
5
5
  * local filesystem. Files are stored at {dataDir}/docs/{tenant}/{shard}/{path}.
6
6
  *
7
+ * GET /api/docs/:tenant/_shards → listAllShards
8
+ * GET /api/docs/:tenant/_all → listAllDocuments
7
9
  * GET /api/docs/:tenant/:shard → list
8
10
  * GET /api/docs/:tenant/:shard/*path → read
9
11
  * HEAD /api/docs/:tenant/:shard/*path → exists
@@ -45,6 +47,34 @@ export function createDocsRouter(dataDir) {
45
47
  }
46
48
  return results;
47
49
  }
50
+ // List all shard ids that have content for a tenant.
51
+ router.get('/:tenant/_shards', (c) => {
52
+ const { tenant } = c.req.param();
53
+ const tenantDir = join(docsDir, tenant);
54
+ if (!existsSync(tenantDir))
55
+ return c.json([]);
56
+ const entries = readdirSync(tenantDir, { withFileTypes: true });
57
+ const shards = entries.filter((e) => e.isDirectory()).map((e) => e.name);
58
+ return c.json(shards);
59
+ });
60
+ // Tenant-wide document list with shardId attached on each entry.
61
+ router.get('/:tenant/_all', (c) => {
62
+ const { tenant } = c.req.param();
63
+ const tenantDir = join(docsDir, tenant);
64
+ if (!existsSync(tenantDir))
65
+ return c.json([]);
66
+ const entries = readdirSync(tenantDir, { withFileTypes: true });
67
+ const out = [];
68
+ for (const entry of entries) {
69
+ if (!entry.isDirectory())
70
+ continue;
71
+ const shardDir = join(tenantDir, entry.name);
72
+ for (const f of collectFiles(shardDir, shardDir)) {
73
+ out.push({ ...f, shardId: entry.name });
74
+ }
75
+ }
76
+ return c.json(out);
77
+ });
48
78
  // List documents for a tenant/shard
49
79
  router.get('/:tenant/:shard', (c) => {
50
80
  const { tenant, shard } = c.req.param();
@@ -0,0 +1,21 @@
1
+ /**
2
+ * User-tenant key management endpoints.
3
+ *
4
+ * Auth: uses `caller.tenantId` set by resolveCaller. Each caller can only
5
+ * see and revoke keys in their own tenant.
6
+ *
7
+ * Mint flow:
8
+ * 1. Shell calls POST /consent (session-only) → receives a short-lived ticket.
9
+ * 2. Shard calls POST / with the ticket → key is generated and returned once.
10
+ *
11
+ * Tickets are single-use and expire after TICKET_TTL_MS (60 s).
12
+ */
13
+ import { Hono } from 'hono';
14
+ import type { KeyStore, ApiKeyPublic } from '../keys.js';
15
+ export interface RevocationEvent {
16
+ tenantId: string;
17
+ id: string;
18
+ row: ApiKeyPublic;
19
+ }
20
+ export type RevocationHook = (event: RevocationEvent) => Promise<void> | void;
21
+ export declare function createKeysRouter(keys: KeyStore, onRevoke: RevocationHook): Hono;