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.
- package/app/assets/index-Cb-zoqb1.js +17 -0
- package/app/assets/index-Cb-zoqb1.js.map +1 -0
- package/app/assets/index-DPcN5Lor.css +1 -0
- package/app/index.html +2 -2
- package/dist/auth.d.ts +10 -3
- package/dist/auth.js +14 -22
- package/dist/caller.d.ts +16 -0
- package/dist/caller.js +54 -0
- package/dist/cli.js +9 -7
- package/dist/fs-backend.d.ts +10 -0
- package/dist/fs-backend.js +105 -0
- package/dist/index.js +30 -12
- package/dist/keys.d.ts +33 -19
- package/dist/keys.js +172 -49
- package/dist/packages.d.ts +23 -3
- package/dist/packages.js +67 -6
- package/dist/routes/admin.js +7 -3
- package/dist/routes/docs.d.ts +2 -0
- package/dist/routes/docs.js +30 -0
- package/dist/routes/keys.d.ts +21 -0
- package/dist/routes/keys.js +164 -0
- package/dist/scope.d.ts +9 -0
- package/dist/scope.js +25 -0
- package/dist/settings.d.ts +13 -0
- package/dist/settings.js +33 -0
- package/dist/shard-router.d.ts +9 -2
- package/dist/shard-router.js +58 -29
- package/dist/shell-shard/index.d.ts +6 -1
- package/dist/shell-shard/index.js +3 -1
- package/dist/shell-shard/session-manager.d.ts +2 -1
- package/dist/shell-shard/session-manager.js +15 -2
- package/dist/shell-shard/ws.js +14 -14
- package/dist/tenant-fs/http.d.ts +15 -0
- package/dist/tenant-fs/http.js +109 -0
- package/dist/tenant-fs/index.d.ts +4 -0
- package/dist/tenant-fs/index.js +4 -0
- package/dist/tenant-fs/paths.d.ts +23 -0
- package/dist/tenant-fs/paths.js +51 -0
- package/dist/tenant-fs/resolve.d.ts +16 -0
- package/dist/tenant-fs/resolve.js +48 -0
- package/dist/tenant-fs/session-required.d.ts +11 -0
- package/dist/tenant-fs/session-required.js +19 -0
- package/package.json +2 -2
- package/app/assets/index-25fXNyG3.js +0 -12
- package/app/assets/index-25fXNyG3.js.map +0 -1
- package/app/assets/index-BcQ1cruS.css +0 -1
package/dist/keys.js
CHANGED
|
@@ -1,68 +1,191 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* API key
|
|
2
|
+
* Unified API key store — admin, user-tenant, and connector-bound keys.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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,
|
|
9
|
-
import {
|
|
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
|
-
#
|
|
13
|
-
#
|
|
20
|
+
#dataDir;
|
|
21
|
+
#admin = [];
|
|
22
|
+
#byTenant = new Map();
|
|
14
23
|
constructor(dataDir) {
|
|
15
|
-
this.#
|
|
16
|
-
this.#
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
144
|
+
const raw = JSON.parse(readFileSync(file, 'utf-8'));
|
|
145
|
+
this.#byTenant.set(tenantId, raw);
|
|
22
146
|
}
|
|
23
147
|
catch {
|
|
24
|
-
this.#
|
|
148
|
+
this.#byTenant.set(tenantId, []);
|
|
25
149
|
}
|
|
26
150
|
}
|
|
27
151
|
}
|
|
28
|
-
#
|
|
29
|
-
const
|
|
30
|
-
mkdirSync(
|
|
31
|
-
writeFileSync(
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
}
|
package/dist/packages.d.ts
CHANGED
|
@@ -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':
|
|
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
|
package/dist/routes/admin.js
CHANGED
|
@@ -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.
|
|
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
|
});
|
package/dist/routes/docs.d.ts
CHANGED
|
@@ -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
|
package/dist/routes/docs.js
CHANGED
|
@@ -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;
|