sh3-server 0.8.1 → 0.9.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 (48) hide show
  1. package/app/assets/index-DKuJNK2S.js +17 -0
  2. package/app/assets/index-DKuJNK2S.js.map +1 -0
  3. package/app/assets/index-DkC3EpjJ.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 +17 -0
  8. package/dist/caller.js +55 -0
  9. package/dist/doc-store/conflicts.d.ts +19 -0
  10. package/dist/doc-store/conflicts.js +79 -0
  11. package/dist/doc-store/index.d.ts +11 -0
  12. package/dist/doc-store/index.js +22 -0
  13. package/dist/doc-store/meta.d.ts +11 -0
  14. package/dist/doc-store/meta.js +37 -0
  15. package/dist/doc-store/policy.d.ts +15 -0
  16. package/dist/doc-store/policy.js +85 -0
  17. package/dist/doc-store/reserved.d.ts +7 -0
  18. package/dist/doc-store/reserved.js +26 -0
  19. package/dist/doc-store/roles.d.ts +12 -0
  20. package/dist/doc-store/roles.js +15 -0
  21. package/dist/doc-store/store.d.ts +71 -0
  22. package/dist/doc-store/store.js +336 -0
  23. package/dist/doc-store/tick.d.ts +13 -0
  24. package/dist/doc-store/tick.js +52 -0
  25. package/dist/fs-backend.d.ts +10 -0
  26. package/dist/fs-backend.js +105 -0
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.js +32 -5
  29. package/dist/keys.d.ts +35 -19
  30. package/dist/keys.js +187 -49
  31. package/dist/migrations/sync-grants.d.ts +7 -0
  32. package/dist/migrations/sync-grants.js +27 -0
  33. package/dist/packages.d.ts +3 -2
  34. package/dist/packages.js +5 -5
  35. package/dist/routes/admin.js +7 -3
  36. package/dist/routes/docs.d.ts +11 -7
  37. package/dist/routes/docs.js +88 -122
  38. package/dist/routes/keys.d.ts +21 -0
  39. package/dist/routes/keys.js +166 -0
  40. package/dist/scope.d.ts +11 -0
  41. package/dist/scope.js +45 -0
  42. package/dist/shard-router.d.ts +10 -4
  43. package/dist/shard-router.js +130 -49
  44. package/dist/shell-shard/index.d.ts +4 -1
  45. package/package.json +1 -1
  46. package/app/assets/index-C3rCTpjL.js +0 -17
  47. package/app/assets/index-C3rCTpjL.js.map +0 -1
  48. package/app/assets/index-GfhVhkjD.css +0 -1
@@ -1,162 +1,128 @@
1
1
  /**
2
2
  * Document backend API routes.
3
3
  *
4
- * Maps the DocumentBackend interface to HTTP endpoints backed by the
5
- * local filesystem. Files are stored at {dataDir}/docs/{tenant}/{shard}/{path}.
4
+ * Delegates every read/write/list/delete to the TenantDocStore, which owns
5
+ * the metadata-sidecar write pipeline (version bump, syncMode cache, tick
6
+ * advance, conflict bucket). This router is intentionally thin: it validates
7
+ * auth and path shape, then calls the store.
6
8
  *
7
- * GET /api/docs/:tenant/_shards → listAllShards
8
- * GET /api/docs/:tenant/_all → listAllDocuments
9
+ * GET /api/docs/:tenant/_shards → listAll (shard ids)
10
+ * GET /api/docs/:tenant/_all → listAll
9
11
  * GET /api/docs/:tenant/:shard → list
10
12
  * GET /api/docs/:tenant/:shard/*path → read
11
13
  * HEAD /api/docs/:tenant/:shard/*path → exists
12
- * PUT /api/docs/:tenant/:shard/*path → write (auth required)
13
- * DELETE /api/docs/:tenant/:shard/*path → delete (auth required)
14
+ * PUT /api/docs/:tenant/:shard/*path → write
15
+ * DELETE /api/docs/:tenant/:shard/*path → delete
14
16
  */
15
17
  import { Hono } from 'hono';
16
- import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, statSync, readdirSync, } from 'node:fs';
17
- import { join, dirname, relative } from 'node:path';
18
- export function createDocsRouter(dataDir) {
18
+ import { tenantParamMatch } from '../scope.js';
19
+ export function createDocsRouter(store, settings) {
19
20
  const router = new Hono();
20
- const docsDir = join(dataDir, 'docs');
21
- function resolvePath(tenant, shard, filePath) {
22
- // Prevent path traversal
23
- const resolved = join(docsDir, tenant, shard, filePath);
24
- if (!resolved.startsWith(join(docsDir, tenant, shard))) {
25
- throw new Error('Path traversal detected');
26
- }
27
- return resolved;
21
+ router.use('/:tenant/*', tenantParamMatch('tenant', settings));
22
+ router.use('/:tenant', tenantParamMatch('tenant', settings));
23
+ function isReservedShardId(shard) {
24
+ return shard.startsWith('__');
28
25
  }
29
- function collectFiles(dir, base) {
30
- const results = [];
31
- if (!existsSync(dir))
32
- return results;
33
- const entries = readdirSync(dir, { withFileTypes: true });
34
- for (const entry of entries) {
35
- const full = join(dir, entry.name);
36
- if (entry.isDirectory()) {
37
- results.push(...collectFiles(full, base));
38
- }
39
- else {
40
- const stat = statSync(full);
41
- results.push({
42
- path: relative(base, full).replace(/\\/g, '/'),
43
- size: stat.size,
44
- lastModified: stat.mtimeMs,
45
- });
46
- }
47
- }
48
- return results;
49
- }
50
- // List all shard ids that have content for a tenant.
51
- router.get('/:tenant/_shards', (c) => {
26
+ // Shards list
27
+ router.get('/:tenant/_shards', async (c) => {
52
28
  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);
29
+ const all = await store.listAll(tenant);
30
+ const seen = new Set();
31
+ for (const e of all)
32
+ seen.add(e.shardId);
33
+ return c.json([...seen].filter((id) => !isReservedShardId(id)));
59
34
  });
60
- // Tenant-wide document list with shardId attached on each entry.
61
- router.get('/:tenant/_all', (c) => {
35
+ // All docs
36
+ router.get('/:tenant/_all', async (c) => {
62
37
  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);
38
+ return c.json(await store.listAll(tenant));
77
39
  });
78
- // List documents for a tenant/shard
79
- router.get('/:tenant/:shard', (c) => {
40
+ // Per-shard list
41
+ router.get('/:tenant/:shard', async (c) => {
80
42
  const { tenant, shard } = c.req.param();
81
- const dir = join(docsDir, tenant, shard);
82
- const files = collectFiles(dir, dir);
83
- return c.json(files);
43
+ if (isReservedShardId(shard))
44
+ return c.notFound();
45
+ return c.json(await store.list(tenant, shard));
84
46
  });
85
- // Read a document
86
- router.get('/:tenant/:shard/*', (c) => {
47
+ // Read
48
+ router.get('/:tenant/:shard/*', async (c) => {
87
49
  const { tenant, shard } = c.req.param();
50
+ if (isReservedShardId(shard))
51
+ return c.notFound();
88
52
  const filePath = c.req.path.replace(`/api/docs/${tenant}/${shard}/`, '');
89
53
  if (!filePath)
90
54
  return c.json({ error: 'Missing file path' }, 400);
91
- let resolved;
92
- try {
93
- resolved = resolvePath(tenant, shard, filePath);
94
- }
95
- catch {
96
- return c.json({ error: 'Invalid path' }, 400);
55
+ if (c.req.query('meta') === '1') {
56
+ const meta = await store.readMeta(tenant, shard, filePath);
57
+ if (!meta)
58
+ return c.json({ exists: false });
59
+ const payload = { exists: true, ...meta };
60
+ if (meta.syncState === 'conflict') {
61
+ const cf = await store.readConflict(tenant, shard, filePath);
62
+ if (cf) {
63
+ payload.branches = cf.branches.map((b) => ({
64
+ origin: b.origin,
65
+ version: b.version,
66
+ at: b.at,
67
+ }));
68
+ }
69
+ }
70
+ return c.json(payload);
97
71
  }
98
- if (!existsSync(resolved)) {
72
+ const content = await store.read(tenant, shard, filePath);
73
+ if (content === null)
99
74
  return c.notFound();
100
- }
101
- const content = readFileSync(resolved);
102
- // Detect binary vs text heuristically: if the file can be decoded as
103
- // UTF-8 without replacement characters, treat as text.
104
- const text = new TextDecoder('utf-8', { fatal: true });
105
- try {
106
- const str = text.decode(content);
107
- return c.text(str);
108
- }
109
- catch {
110
- return new Response(content, {
111
- headers: { 'Content-Type': 'application/octet-stream' },
112
- });
113
- }
75
+ return c.text(content);
114
76
  });
115
- // Check existence via HEAD request
116
- router.on('HEAD', '/:tenant/:shard/*', (c) => {
77
+ // Exists
78
+ router.on('HEAD', '/:tenant/:shard/*', async (c) => {
117
79
  const { tenant, shard } = c.req.param();
80
+ if (isReservedShardId(shard))
81
+ return c.notFound();
118
82
  const filePath = c.req.path.replace(`/api/docs/${tenant}/${shard}/`, '');
119
- let resolved;
120
- try {
121
- resolved = resolvePath(tenant, shard, filePath);
83
+ return new Response(null, {
84
+ status: (await store.exists(tenant, shard, filePath)) ? 200 : 404,
85
+ });
86
+ });
87
+ // Conflict resolve — must match before the generic PUT so the `/resolve`
88
+ // suffix isn't captured as part of the file path.
89
+ router.post('/:tenant/:shard/*', async (c) => {
90
+ const { tenant, shard } = c.req.param();
91
+ if (isReservedShardId(shard))
92
+ return c.json({ error: 'Reserved shard id' }, 400);
93
+ const rawPath = c.req.path.replace(`/api/docs/${tenant}/${shard}/`, '');
94
+ if (!rawPath.endsWith('/resolve')) {
95
+ return c.json({ error: 'Unknown docs POST endpoint' }, 404);
122
96
  }
123
- catch {
124
- return new Response(null, { status: 400 });
97
+ const filePath = rawPath.replace(/\/resolve$/, '');
98
+ if (!filePath)
99
+ return c.json({ error: 'Missing file path' }, 400);
100
+ const body = await c.req.json().catch(() => null);
101
+ if (!body || typeof body.choice === 'undefined') {
102
+ return c.json({ error: 'Body must include { choice }' }, 400);
125
103
  }
126
- return new Response(null, { status: existsSync(resolved) ? 200 : 404 });
104
+ await store.resolveConflict(tenant, shard, filePath, body.choice);
105
+ return c.json({ ok: true });
127
106
  });
128
- // Write a document (auth required via middleware)
107
+ // Write
129
108
  router.put('/:tenant/:shard/*', async (c) => {
130
109
  const { tenant, shard } = c.req.param();
110
+ if (isReservedShardId(shard))
111
+ return c.json({ error: 'Reserved shard id' }, 400);
131
112
  const filePath = c.req.path.replace(`/api/docs/${tenant}/${shard}/`, '');
132
113
  if (!filePath)
133
114
  return c.json({ error: 'Missing file path' }, 400);
134
- let resolved;
135
- try {
136
- resolved = resolvePath(tenant, shard, filePath);
137
- }
138
- catch {
139
- return c.json({ error: 'Invalid path' }, 400);
140
- }
141
- mkdirSync(dirname(resolved), { recursive: true });
142
- const body = await c.req.arrayBuffer();
143
- writeFileSync(resolved, Buffer.from(body));
144
- return c.json({ ok: true });
115
+ const body = await c.req.text();
116
+ const result = await store.write(tenant, shard, filePath, body);
117
+ return c.json({ ok: true, ...result });
145
118
  });
146
- // Delete a document (auth required via middleware)
147
- router.delete('/:tenant/:shard/*', (c) => {
119
+ // Delete
120
+ router.delete('/:tenant/:shard/*', async (c) => {
148
121
  const { tenant, shard } = c.req.param();
122
+ if (isReservedShardId(shard))
123
+ return c.json({ error: 'Reserved shard id' }, 400);
149
124
  const filePath = c.req.path.replace(`/api/docs/${tenant}/${shard}/`, '');
150
- let resolved;
151
- try {
152
- resolved = resolvePath(tenant, shard, filePath);
153
- }
154
- catch {
155
- return c.json({ error: 'Invalid path' }, 400);
156
- }
157
- if (existsSync(resolved)) {
158
- unlinkSync(resolved);
159
- }
125
+ await store.delete(tenant, shard, filePath);
160
126
  return c.json({ ok: true });
161
127
  });
162
128
  return router;
@@ -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;
@@ -0,0 +1,166 @@
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 { randomBytes } from 'node:crypto';
15
+ import { tenantRequired } from '../scope.js';
16
+ const TICKET_TTL_MS = 60_000;
17
+ function sweepExpired(tickets) {
18
+ const now = Date.now();
19
+ for (const [token, entry] of tickets) {
20
+ if (now - entry.issuedAt > TICKET_TTL_MS)
21
+ tickets.delete(token);
22
+ }
23
+ }
24
+ export function createKeysRouter(keys, onRevoke) {
25
+ const router = new Hono();
26
+ const tickets = new Map();
27
+ // SSE subscribers — one entry per connected browser tab.
28
+ const sseSubscribers = new Set();
29
+ /** Push a revocation event to all connected SSE listeners. */
30
+ function pushToBus(ev) {
31
+ for (const fn of sseSubscribers)
32
+ fn(ev);
33
+ }
34
+ router.use('*', tenantRequired);
35
+ router.get('/', (c) => {
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ const caller = c.get('caller');
38
+ return c.json(keys.listForTenant(caller.tenantId));
39
+ });
40
+ /**
41
+ * POST /consent — shell-only endpoint that issues a single-use consent ticket.
42
+ *
43
+ * Only session-authenticated callers may call this — bearer-token clients
44
+ * (background shards) must not be able to self-issue consent proofs.
45
+ */
46
+ router.post('/consent', async (c) => {
47
+ sweepExpired(tickets);
48
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
49
+ const caller = c.get('caller');
50
+ if (caller.source !== 'session' || !caller.userId) {
51
+ return c.json({ error: 'Consent requires a logged-in session.' }, 403);
52
+ }
53
+ const body = (await c.req.json());
54
+ if (!body.shardId || !body.label || !Array.isArray(body.scopes)) {
55
+ return c.json({ error: 'Missing shardId/label/scopes' }, 400);
56
+ }
57
+ if (!body.scopes.every((s) => typeof s === 'string')) {
58
+ return c.json({ error: 'scopes must be an array of strings' }, 400);
59
+ }
60
+ const ticket = `tk_${randomBytes(16).toString('hex')}`;
61
+ tickets.set(ticket, {
62
+ shardId: body.shardId,
63
+ label: body.label,
64
+ scopes: body.scopes,
65
+ peerRole: body.peerRole,
66
+ peerId: body.peerId,
67
+ expiresIn: body.expiresIn,
68
+ tenantId: caller.tenantId,
69
+ userId: caller.userId,
70
+ issuedAt: Date.now(),
71
+ });
72
+ return c.json({ ticket });
73
+ });
74
+ /**
75
+ * POST / — mint a key using a valid consent ticket.
76
+ *
77
+ * Ticket is consumed on first use (single-use). Expired or unknown tickets
78
+ * return 400. The full key value is returned exactly once.
79
+ */
80
+ router.post('/', async (c) => {
81
+ sweepExpired(tickets);
82
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
83
+ const caller = c.get('caller');
84
+ const body = (await c.req.json());
85
+ if (!body.ticket)
86
+ return c.json({ error: 'Missing ticket' }, 400);
87
+ const entry = tickets.get(body.ticket);
88
+ tickets.delete(body.ticket); // consume immediately (single-use)
89
+ if (!entry)
90
+ return c.json({ error: 'Ticket invalid or already used' }, 400);
91
+ if (Date.now() - entry.issuedAt > TICKET_TTL_MS) {
92
+ return c.json({ error: 'Ticket invalid or already used' }, 400);
93
+ }
94
+ if (entry.tenantId !== caller.tenantId) {
95
+ return c.json({ error: 'Ticket invalid or already used' }, 400);
96
+ }
97
+ const expiresAt = entry.expiresIn
98
+ ? new Date(Date.now() + entry.expiresIn).toISOString()
99
+ : undefined;
100
+ const row = keys.generate({
101
+ label: entry.label,
102
+ tenantId: entry.tenantId,
103
+ ownerUserId: entry.userId,
104
+ mintedByShardId: entry.shardId,
105
+ scopes: entry.scopes,
106
+ peerRole: entry.peerRole,
107
+ peerId: entry.peerId,
108
+ expiresAt,
109
+ });
110
+ return c.json({ id: row.id, key: row.key });
111
+ });
112
+ /**
113
+ * GET /events — server-sent events stream.
114
+ *
115
+ * Delivers `{ id, shardId }` messages whenever a key belonging to this
116
+ * tenant is revoked from any source. The client-side revocation bus
117
+ * consumes this stream and dispatches `onKeyRevoked` on the owning shard.
118
+ *
119
+ * Auth: tenantRequired (already applied via the `*` middleware above).
120
+ * Each connected tab gets its own stream. Connections are cleaned up
121
+ * automatically when the client disconnects (abort signal).
122
+ */
123
+ router.get('/events', (c) => {
124
+ return new Response(new ReadableStream({
125
+ start(controller) {
126
+ const enc = new TextEncoder();
127
+ const send = (data) => {
128
+ controller.enqueue(enc.encode(`data: ${JSON.stringify(data)}\n\n`));
129
+ };
130
+ const fn = (ev) => send(ev);
131
+ sseSubscribers.add(fn);
132
+ // Send a keepalive comment every 20 s so proxies don't kill idle connections.
133
+ const heartbeat = setInterval(() => {
134
+ try {
135
+ controller.enqueue(enc.encode(': ping\n\n'));
136
+ }
137
+ catch { /* stream closed */ }
138
+ }, 20_000);
139
+ c.req.raw.signal?.addEventListener('abort', () => {
140
+ clearInterval(heartbeat);
141
+ sseSubscribers.delete(fn);
142
+ controller.close();
143
+ });
144
+ },
145
+ }), {
146
+ headers: {
147
+ 'content-type': 'text/event-stream',
148
+ 'cache-control': 'no-cache',
149
+ connection: 'keep-alive',
150
+ },
151
+ });
152
+ });
153
+ router.delete('/:id', async (c) => {
154
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
155
+ const caller = c.get('caller');
156
+ const id = c.req.param('id');
157
+ const removed = keys.revoke(caller.tenantId, id);
158
+ if (!removed)
159
+ return c.json({ error: 'Key not found' }, 404);
160
+ await onRevoke({ tenantId: caller.tenantId, id, row: removed });
161
+ // Broadcast to SSE subscribers so other browser tabs learn of the revocation.
162
+ pushToBus({ id, shardId: removed.mintedByShardId ?? null });
163
+ return c.json({ ok: true });
164
+ });
165
+ return router;
166
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Scope-based route guards. Operate on the `caller` set by resolveCaller.
3
+ *
4
+ * scopeRequired(scope) — 403 unless caller has that scope or admin:*.
5
+ * tenantRequired — 401 unless caller.tenantId is non-null.
6
+ */
7
+ import type { MiddlewareHandler } from 'hono';
8
+ import type { SettingsStore } from './settings.js';
9
+ export declare function scopeRequired(scope: string): MiddlewareHandler;
10
+ export declare const tenantRequired: MiddlewareHandler;
11
+ export declare function tenantParamMatch(paramName: string, settings?: SettingsStore): MiddlewareHandler;
package/dist/scope.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Scope-based route guards. Operate on the `caller` set by resolveCaller.
3
+ *
4
+ * scopeRequired(scope) — 403 unless caller has that scope or admin:*.
5
+ * tenantRequired — 401 unless caller.tenantId is non-null.
6
+ */
7
+ export function scopeRequired(scope) {
8
+ return async (c, next) => {
9
+ const caller = c.get('caller');
10
+ if (!caller)
11
+ return c.json({ error: 'Caller not resolved' }, 500);
12
+ if (caller.scopes.includes('admin:*') || caller.scopes.includes(scope)) {
13
+ return next();
14
+ }
15
+ return c.json({ error: `Missing required scope: ${scope}` }, 403);
16
+ };
17
+ }
18
+ export const tenantRequired = async (c, next) => {
19
+ const caller = c.get('caller');
20
+ if (!caller)
21
+ return c.json({ error: 'Caller not resolved' }, 500);
22
+ if (!caller.tenantId)
23
+ return c.json({ error: 'Tenant-scoped credentials required' }, 401);
24
+ return next();
25
+ };
26
+ export function tenantParamMatch(paramName, settings) {
27
+ return async (c, next) => {
28
+ // Open / no-auth mode: admin has explicitly disabled tenant isolation.
29
+ // Mirrors sessionAuth's open-mode bypass; required for Tauri sidecar mode.
30
+ if (settings && !settings.get().auth.required)
31
+ return next();
32
+ const caller = c.get('caller');
33
+ if (!caller)
34
+ return c.json({ error: 'Caller not resolved' }, 500);
35
+ if (caller.scopes.includes('admin:*'))
36
+ return next();
37
+ if (!caller.tenantId)
38
+ return c.json({ error: 'Tenant-scoped credentials required' }, 401);
39
+ const requested = c.req.param(paramName);
40
+ if (caller.tenantId !== requested) {
41
+ return c.json({ error: `Caller tenant does not match :${paramName}` }, 403);
42
+ }
43
+ return next();
44
+ };
45
+ }
@@ -2,10 +2,12 @@ import { Hono } from 'hono';
2
2
  import type { MiddlewareHandler } from 'hono';
3
3
  import type { KeyStore } from './keys.js';
4
4
  import type { SettingsStore } from './settings.js';
5
+ import type { TenantDocStore } from './doc-store/index.js';
5
6
  export interface MountContext {
6
7
  pkgDir: string;
7
8
  keys: KeyStore;
8
9
  settings: SettingsStore;
10
+ docStore: TenantDocStore;
9
11
  /**
10
12
  * Register a WebSocket upgrade handler on a path under this shard's
11
13
  * route prefix. The returned value is a Hono middleware handler that
@@ -45,13 +47,14 @@ export interface MountContext {
45
47
  */
46
48
  wsRegister(onConnect: (ws: any, c: any) => void): any;
47
49
  }
48
- /** Middleware that requires admin session OR valid API key. */
49
- export declare function adminOnly(keys: KeyStore, settings: SettingsStore): MiddlewareHandler;
50
+ /** Middleware requiring the caller's scope set to include admin:*. */
51
+ export declare function adminOnly(_keys: KeyStore, settings: SettingsStore): MiddlewareHandler;
50
52
  /**
51
53
  * Dynamic shard route manager. Holds a Map of shard Hono sub-apps
52
54
  * and delegates requests from a single wildcard route.
53
55
  */
54
56
  export declare class ShardRouter {
57
+ #private;
55
58
  private shards;
56
59
  /**
57
60
  * Import a server.js bundle and mount its routes.
@@ -66,9 +69,12 @@ export declare class ShardRouter {
66
69
  mountStatic(shardId: string, mod: {
67
70
  id: string;
68
71
  routes: (router: Hono, ctx: any) => void | Promise<void>;
72
+ teardown?: () => void | Promise<void>;
69
73
  }, ctx: MountContext): Promise<void>;
70
- /** Remove a shard's routes. */
71
- unmount(shardId: string): boolean;
74
+ /** Remove a shard's routes. Calls `teardown()` if the shard defined one. */
75
+ unmount(shardId: string): Promise<boolean>;
76
+ /** Call teardown on every mounted shard without clearing the registry. */
77
+ unmountAll(): Promise<void>;
72
78
  /**
73
79
  * Hono handler for the wildcard route.
74
80
  * Looks up the shard sub-app and delegates with path stripping.