sh3-server 0.8.1 → 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.
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
@@ -52,5 +54,5 @@ export declare function validateRequiredShards(manifest: Record<string, unknown>
52
54
  * Returns a Hono router with POST /install and POST /uninstall.
53
55
  * Protected by the blanket `/api/*` auth middleware already applied upstream.
54
56
  */
55
- export declare function createPackageManagementRoutes(shardRouter: ShardRouter, dataDir: string, keys: KeyStore, settings: SettingsStore, wsRegister: WsRegister, frameworkShardIds?: string[]): Hono;
57
+ export declare function createPackageManagementRoutes(shardRouter: ShardRouter, dataDir: string, keys: KeyStore, settings: SettingsStore, wsRegister: WsRegister, documentBackend: DocumentBackend, frameworkShardIds?: string[]): Hono;
56
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);
@@ -193,7 +193,7 @@ export function validateRequiredShards(manifest, knownShards) {
193
193
  * Returns a Hono router with POST /install and POST /uninstall.
194
194
  * Protected by the blanket `/api/*` auth middleware already applied upstream.
195
195
  */
196
- export function createPackageManagementRoutes(shardRouter, dataDir, keys, settings, wsRegister, frameworkShardIds = []) {
196
+ export function createPackageManagementRoutes(shardRouter, dataDir, keys, settings, wsRegister, documentBackend, frameworkShardIds = []) {
197
197
  const router = new Hono();
198
198
  router.post('/install', async (c) => {
199
199
  const form = await c.req.formData();
@@ -267,7 +267,7 @@ export function createPackageManagementRoutes(shardRouter, dataDir, keys, settin
267
267
  writeFileSync(join(pkgDir, 'server.js'), buf);
268
268
  // Hot-mount server routes
269
269
  try {
270
- 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 });
271
271
  }
272
272
  catch (err) {
273
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
  });
@@ -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,164 @@
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
+ connectorId: body.connectorId,
66
+ expiresIn: body.expiresIn,
67
+ tenantId: caller.tenantId,
68
+ userId: caller.userId,
69
+ issuedAt: Date.now(),
70
+ });
71
+ return c.json({ ticket });
72
+ });
73
+ /**
74
+ * POST / — mint a key using a valid consent ticket.
75
+ *
76
+ * Ticket is consumed on first use (single-use). Expired or unknown tickets
77
+ * return 400. The full key value is returned exactly once.
78
+ */
79
+ router.post('/', async (c) => {
80
+ sweepExpired(tickets);
81
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
82
+ const caller = c.get('caller');
83
+ const body = (await c.req.json());
84
+ if (!body.ticket)
85
+ return c.json({ error: 'Missing ticket' }, 400);
86
+ const entry = tickets.get(body.ticket);
87
+ tickets.delete(body.ticket); // consume immediately (single-use)
88
+ if (!entry)
89
+ return c.json({ error: 'Ticket invalid or already used' }, 400);
90
+ if (Date.now() - entry.issuedAt > TICKET_TTL_MS) {
91
+ return c.json({ error: 'Ticket invalid or already used' }, 400);
92
+ }
93
+ if (entry.tenantId !== caller.tenantId) {
94
+ return c.json({ error: 'Ticket invalid or already used' }, 400);
95
+ }
96
+ const expiresAt = entry.expiresIn
97
+ ? new Date(Date.now() + entry.expiresIn).toISOString()
98
+ : undefined;
99
+ const row = keys.generate({
100
+ label: entry.label,
101
+ tenantId: entry.tenantId,
102
+ ownerUserId: entry.userId,
103
+ mintedByShardId: entry.shardId,
104
+ scopes: entry.scopes,
105
+ connectorId: entry.connectorId,
106
+ expiresAt,
107
+ });
108
+ return c.json({ id: row.id, key: row.key });
109
+ });
110
+ /**
111
+ * GET /events — server-sent events stream.
112
+ *
113
+ * Delivers `{ id, shardId }` messages whenever a key belonging to this
114
+ * tenant is revoked from any source. The client-side revocation bus
115
+ * consumes this stream and dispatches `onKeyRevoked` on the owning shard.
116
+ *
117
+ * Auth: tenantRequired (already applied via the `*` middleware above).
118
+ * Each connected tab gets its own stream. Connections are cleaned up
119
+ * automatically when the client disconnects (abort signal).
120
+ */
121
+ router.get('/events', (c) => {
122
+ return new Response(new ReadableStream({
123
+ start(controller) {
124
+ const enc = new TextEncoder();
125
+ const send = (data) => {
126
+ controller.enqueue(enc.encode(`data: ${JSON.stringify(data)}\n\n`));
127
+ };
128
+ const fn = (ev) => send(ev);
129
+ sseSubscribers.add(fn);
130
+ // Send a keepalive comment every 20 s so proxies don't kill idle connections.
131
+ const heartbeat = setInterval(() => {
132
+ try {
133
+ controller.enqueue(enc.encode(': ping\n\n'));
134
+ }
135
+ catch { /* stream closed */ }
136
+ }, 20_000);
137
+ c.req.raw.signal?.addEventListener('abort', () => {
138
+ clearInterval(heartbeat);
139
+ sseSubscribers.delete(fn);
140
+ controller.close();
141
+ });
142
+ },
143
+ }), {
144
+ headers: {
145
+ 'content-type': 'text/event-stream',
146
+ 'cache-control': 'no-cache',
147
+ connection: 'keep-alive',
148
+ },
149
+ });
150
+ });
151
+ router.delete('/:id', async (c) => {
152
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
153
+ const caller = c.get('caller');
154
+ const id = c.req.param('id');
155
+ const removed = keys.revoke(caller.tenantId, id);
156
+ if (!removed)
157
+ return c.json({ error: 'Key not found' }, 404);
158
+ await onRevoke({ tenantId: caller.tenantId, id, row: removed });
159
+ // Broadcast to SSE subscribers so other browser tabs learn of the revocation.
160
+ pushToBus({ id, shardId: removed.mintedByShardId ?? null });
161
+ return c.json({ ok: true });
162
+ });
163
+ return router;
164
+ }
@@ -0,0 +1,9 @@
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
+ export declare function scopeRequired(scope: string): MiddlewareHandler;
9
+ export declare const tenantRequired: MiddlewareHandler;
package/dist/scope.js ADDED
@@ -0,0 +1,25 @@
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
+ };
@@ -6,6 +6,8 @@ export interface MountContext {
6
6
  pkgDir: string;
7
7
  keys: KeyStore;
8
8
  settings: SettingsStore;
9
+ /** The server's document backend, for sync handle construction. */
10
+ documentBackend: import('sh3-core/server-sync').DocumentBackend;
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,8 +47,13 @@ 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;
52
+ /**
53
+ * Build a ServerShardContext object for the given shard, wiring
54
+ * sync/syncRegistry based on the declared permissions.
55
+ */
56
+ export declare function buildShardCtx(shardId: string, dataDir: string, permissions: string[], keys: KeyStore, settings: SettingsStore, wsRegister: MountContext['wsRegister'], documentBackend: MountContext['documentBackend']): Record<string, unknown>;
50
57
  /**
51
58
  * Dynamic shard route manager. Holds a Map of shard Hono sub-apps
52
59
  * and delegates requests from a single wildcard route.