sh3-server 0.13.1 → 0.13.3
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-BRJE2HIy.css +1 -0
- package/app/assets/index-COeOnrKc.js +22 -0
- package/app/assets/index-COeOnrKc.js.map +1 -0
- package/app/assets/workspace-rekey-DRgvbNY-.js +2 -0
- package/app/assets/workspace-rekey-DRgvbNY-.js.map +1 -0
- package/app/index.html +2 -2
- package/dist/caller.d.ts +9 -2
- package/dist/caller.js +16 -6
- package/dist/doc-store/index.d.ts +11 -2
- package/dist/doc-store/index.js +4 -2
- package/dist/index.js +20 -11
- package/dist/keys.d.ts +14 -8
- package/dist/keys.js +66 -40
- package/dist/middleware/project-allowlist.d.ts +30 -0
- package/dist/middleware/project-allowlist.js +49 -0
- package/dist/packages.d.ts +13 -0
- package/dist/packages.js +28 -0
- package/dist/projects.d.ts +39 -0
- package/dist/projects.js +128 -0
- package/dist/routes/admin.js +1 -1
- package/dist/routes/docs.d.ts +23 -9
- package/dist/routes/docs.js +57 -48
- package/dist/routes/keys.d.ts +4 -4
- package/dist/routes/keys.js +12 -12
- package/dist/routes/projects.d.ts +13 -0
- package/dist/routes/projects.js +101 -0
- package/dist/scope.d.ts +9 -4
- package/dist/scope.js +17 -15
- package/dist/shard-router.js +2 -2
- package/package.json +1 -1
- package/app/assets/index-AzWjqwzj.css +0 -1
- package/app/assets/index-DgeU5PrI.js +0 -19
- package/app/assets/index-DgeU5PrI.js.map +0 -1
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
const n="sh3:workspace:__app__:",a=":scope:";function r(l){if(typeof localStorage>"u")return;const t=[];for(let o=0;o<localStorage.length;o++){const e=localStorage.key(o);!e||!e.startsWith(n)||e.includes(a)||t.push([e,`${e}${a}${l}`])}for(const[o,e]of t){const c=localStorage.getItem(o);c!==null&&(localStorage.setItem(e,c),localStorage.removeItem(o))}}export{r as migrateLegacyWorkspaceKeys};
|
|
2
|
+
//# sourceMappingURL=workspace-rekey-DRgvbNY-.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workspace-rekey-DRgvbNY-.js","sources":["../../../sh3-core/src/apps/workspace-rekey.ts"],"sourcesContent":["/*\n * Workspace state-zone key migration.\n *\n * Per ADR-002 amendment (2026-05-04), the workspace zone is keyed by\n * `(scopeId, appId)`. Pre-existing localStorage entries written under\n * the old `sh3:workspace:__app__:<appId>` prefix are rewritten to\n * `sh3:workspace:__app__:<appId>:scope:<personalScopeId>` on first\n * boot after upgrade. Idempotent — re-running on already-migrated\n * entries is a no-op.\n *\n * Only entries whose shardId starts with the framework `__app__:`\n * marker are migrated; bare shard keys are left alone.\n */\n\nconst APP_PREFIX = 'sh3:workspace:__app__:';\nconst SCOPE_MARKER = ':scope:';\n\nexport function migrateLegacyWorkspaceKeys(personalScopeId: string): void {\n if (typeof localStorage === 'undefined') return;\n const toMove: Array<[string, string]> = [];\n for (let i = 0; i < localStorage.length; i++) {\n const key = localStorage.key(i);\n if (!key || !key.startsWith(APP_PREFIX)) continue;\n if (key.includes(SCOPE_MARKER)) continue;\n toMove.push([key, `${key}${SCOPE_MARKER}${personalScopeId}`]);\n }\n for (const [oldKey, newKey] of toMove) {\n const value = localStorage.getItem(oldKey);\n if (value !== null) {\n localStorage.setItem(newKey, value);\n localStorage.removeItem(oldKey);\n }\n }\n}\n"],"names":["APP_PREFIX","SCOPE_MARKER","migrateLegacyWorkspaceKeys","personalScopeId","toMove","i","key","oldKey","newKey","value"],"mappings":"AAcA,MAAMA,EAAa,yBACbC,EAAe,UAEd,SAASC,EAA2BC,EAA+B,CACxE,GAAI,OAAO,aAAiB,IAAa,OACzC,MAAMC,EAAkC,CAAA,EACxC,QAASC,EAAI,EAAGA,EAAI,aAAa,OAAQA,IAAK,CAC5C,MAAMC,EAAM,aAAa,IAAID,CAAC,EAC1B,CAACC,GAAO,CAACA,EAAI,WAAWN,CAAU,GAClCM,EAAI,SAASL,CAAY,GAC7BG,EAAO,KAAK,CAACE,EAAK,GAAGA,CAAG,GAAGL,CAAY,GAAGE,CAAe,EAAE,CAAC,CAC9D,CACA,SAAW,CAACI,EAAQC,CAAM,IAAKJ,EAAQ,CACrC,MAAMK,EAAQ,aAAa,QAAQF,CAAM,EACrCE,IAAU,OACZ,aAAa,QAAQD,EAAQC,CAAK,EAClC,aAAa,WAAWF,CAAM,EAElC,CACF"}
|
package/app/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<title>SH3</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-COeOnrKc.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BRJE2HIy.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="app"></div>
|
package/dist/caller.d.ts
CHANGED
|
@@ -3,15 +3,22 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Must be mounted after sessionAuth. Session wins over bearer when both
|
|
5
5
|
* are present; bearer tokens are ignored on session-authenticated calls.
|
|
6
|
+
*
|
|
7
|
+
* accessibleScopes lists every scope the caller may read or write. For
|
|
8
|
+
* sessions, that's the user's personal scope plus every project they are
|
|
9
|
+
* a member of. For bearer keys, that's the key's bound scope (or empty
|
|
10
|
+
* for admin keys, which are management-only).
|
|
6
11
|
*/
|
|
7
12
|
import type { MiddlewareHandler } from 'hono';
|
|
8
13
|
import type { KeyStore } from './keys.js';
|
|
14
|
+
import type { ProjectStore } from './projects.js';
|
|
9
15
|
export interface CallerIdentity {
|
|
10
|
-
|
|
16
|
+
scopeId: string | null;
|
|
11
17
|
userId: string | null;
|
|
12
18
|
scopes: string[];
|
|
19
|
+
accessibleScopes: string[];
|
|
13
20
|
peerRole?: 'primary' | 'replica';
|
|
14
21
|
peerId?: string;
|
|
15
22
|
source: 'session' | 'key' | 'none';
|
|
16
23
|
}
|
|
17
|
-
export declare function resolveCaller(keys: KeyStore): MiddlewareHandler;
|
|
24
|
+
export declare function resolveCaller(keys: KeyStore, projects?: ProjectStore): MiddlewareHandler;
|
package/dist/caller.js
CHANGED
|
@@ -3,15 +3,21 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Must be mounted after sessionAuth. Session wins over bearer when both
|
|
5
5
|
* are present; bearer tokens are ignored on session-authenticated calls.
|
|
6
|
+
*
|
|
7
|
+
* accessibleScopes lists every scope the caller may read or write. For
|
|
8
|
+
* sessions, that's the user's personal scope plus every project they are
|
|
9
|
+
* a member of. For bearer keys, that's the key's bound scope (or empty
|
|
10
|
+
* for admin keys, which are management-only).
|
|
6
11
|
*/
|
|
7
|
-
function identityFromSession(session) {
|
|
12
|
+
function identityFromSession(session, projectIds) {
|
|
8
13
|
const scopes = ['session:user'];
|
|
9
14
|
if (session.role === 'admin')
|
|
10
15
|
scopes.unshift('admin:*');
|
|
11
16
|
return {
|
|
12
|
-
|
|
17
|
+
scopeId: session.userId,
|
|
13
18
|
userId: session.userId,
|
|
14
19
|
scopes,
|
|
20
|
+
accessibleScopes: [session.userId, ...projectIds],
|
|
15
21
|
source: 'session',
|
|
16
22
|
};
|
|
17
23
|
}
|
|
@@ -22,21 +28,24 @@ function extractBearerKey(authorization) {
|
|
|
22
28
|
return null;
|
|
23
29
|
return authorization.slice('Bearer '.length);
|
|
24
30
|
}
|
|
25
|
-
export function resolveCaller(keys) {
|
|
31
|
+
export function resolveCaller(keys, projects) {
|
|
26
32
|
return async (c, next) => {
|
|
27
33
|
const session = c.get('session');
|
|
28
34
|
if (session) {
|
|
29
|
-
|
|
35
|
+
const projectIds = projects?.listForUser(session.userId).map((p) => p.id) ?? [];
|
|
36
|
+
c.set('caller', identityFromSession(session, projectIds));
|
|
30
37
|
return next();
|
|
31
38
|
}
|
|
32
39
|
const token = extractBearerKey(c.req.header('Authorization'));
|
|
33
40
|
if (token) {
|
|
34
41
|
const row = keys.resolve(token);
|
|
35
42
|
if (row) {
|
|
43
|
+
const isAdminKey = row.scopes.includes('admin:*');
|
|
36
44
|
c.set('caller', {
|
|
37
|
-
|
|
45
|
+
scopeId: row.scopeId,
|
|
38
46
|
userId: row.ownerUserId,
|
|
39
47
|
scopes: [...row.scopes],
|
|
48
|
+
accessibleScopes: isAdminKey ? [] : (row.scopeId ? [row.scopeId] : []),
|
|
40
49
|
peerRole: row.peerRole,
|
|
41
50
|
peerId: row.peerId,
|
|
42
51
|
source: 'key',
|
|
@@ -45,9 +54,10 @@ export function resolveCaller(keys) {
|
|
|
45
54
|
}
|
|
46
55
|
}
|
|
47
56
|
c.set('caller', {
|
|
48
|
-
|
|
57
|
+
scopeId: null,
|
|
49
58
|
userId: null,
|
|
50
59
|
scopes: [],
|
|
60
|
+
accessibleScopes: [],
|
|
51
61
|
source: 'none',
|
|
52
62
|
});
|
|
53
63
|
return next();
|
|
@@ -7,5 +7,14 @@ export { ConflictBucket, type ConflictRef } from './conflicts.js';
|
|
|
7
7
|
export { filterReservedMeta, RESERVED_META_KEYS } from './reserved.js';
|
|
8
8
|
export { readMeta, writeMeta, deleteMeta, type DocMetadata } from './meta.js';
|
|
9
9
|
import { TenantDocStore } from './store.js';
|
|
10
|
-
/**
|
|
11
|
-
|
|
10
|
+
/**
|
|
11
|
+
* ScopedDocStore is the canonical name under the unified scope model
|
|
12
|
+
* (ADR-023). The class is still spelled TenantDocStore internally for
|
|
13
|
+
* change-budget reasons; callers should prefer the ScopedDocStore alias
|
|
14
|
+
* so the source vocabulary already reflects the unified naming.
|
|
15
|
+
*/
|
|
16
|
+
export type ScopedDocStore = TenantDocStore;
|
|
17
|
+
/** Build a ScopedDocStore with default dependencies wired to a data dir. */
|
|
18
|
+
export declare function createScopedDocStore(dataDir: string): ScopedDocStore;
|
|
19
|
+
/** @deprecated use createScopedDocStore — kept while internal callers migrate. */
|
|
20
|
+
export declare const createTenantDocStore: typeof createScopedDocStore;
|
package/dist/doc-store/index.js
CHANGED
|
@@ -10,8 +10,8 @@ import { PolicyCache } from './policy.js';
|
|
|
10
10
|
import { TickCounter } from './tick.js';
|
|
11
11
|
import { PeerRoles } from './roles.js';
|
|
12
12
|
import { ConflictBucket } from './conflicts.js';
|
|
13
|
-
/** Build a
|
|
14
|
-
export function
|
|
13
|
+
/** Build a ScopedDocStore with default dependencies wired to a data dir. */
|
|
14
|
+
export function createScopedDocStore(dataDir) {
|
|
15
15
|
return new TenantDocStore({
|
|
16
16
|
dataDir,
|
|
17
17
|
policy: new PolicyCache(dataDir),
|
|
@@ -20,3 +20,5 @@ export function createTenantDocStore(dataDir) {
|
|
|
20
20
|
conflicts: new ConflictBucket(dataDir),
|
|
21
21
|
});
|
|
22
22
|
}
|
|
23
|
+
/** @deprecated use createScopedDocStore — kept while internal callers migrate. */
|
|
24
|
+
export const createTenantDocStore = createScopedDocStore;
|
package/dist/index.js
CHANGED
|
@@ -18,19 +18,21 @@ import { KeyStore } from './keys.js';
|
|
|
18
18
|
import { UserStore } from './users.js';
|
|
19
19
|
import { SessionStore } from './sessions.js';
|
|
20
20
|
import { SettingsStore } from './settings.js';
|
|
21
|
+
import { ProjectStore } from './projects.js';
|
|
21
22
|
import { sessionAuth, adminAuth } from './auth.js';
|
|
22
23
|
import { resolveCaller } from './caller.js';
|
|
23
24
|
import { createAuthRouter } from './routes/auth.js';
|
|
24
25
|
import { createBootRouter } from './routes/boot.js';
|
|
25
26
|
import { createAdminRouter } from './routes/admin.js';
|
|
26
27
|
import { createDocsRouter } from './routes/docs.js';
|
|
27
|
-
import {
|
|
28
|
+
import { createScopedDocStore } from './doc-store/index.js';
|
|
28
29
|
import { createEnvStateRouter } from './routes/env-state.js';
|
|
29
30
|
import { createKeysRouter } from './routes/keys.js';
|
|
30
|
-
import {
|
|
31
|
+
import { createProjectsRouter } from './routes/projects.js';
|
|
32
|
+
import { loadPackages, scanPackages, servePackageBundles, createPackageManagementRoutes, getServerAppRegistry } from './packages.js';
|
|
31
33
|
import { removeLegacyGrants } from './migrations/sync-grants.js';
|
|
32
34
|
import { ShardRouter, adminOnly } from './shard-router.js';
|
|
33
|
-
import { scopeRequired,
|
|
35
|
+
import { scopeRequired, requireCallerScope } from './scope.js';
|
|
34
36
|
import { registerTenantFsRoutes } from './tenant-fs/index.js';
|
|
35
37
|
import shellShardServer from './shell-shard/index.js';
|
|
36
38
|
export async function createServer(options = {}) {
|
|
@@ -50,6 +52,7 @@ export async function createServer(options = {}) {
|
|
|
50
52
|
const keys = new KeyStore(dataDir);
|
|
51
53
|
const users = new UserStore(dataDir);
|
|
52
54
|
const settings = new SettingsStore(dataDir);
|
|
55
|
+
const projects = new ProjectStore(dataDir);
|
|
53
56
|
// --no-auth: disable auth enforcement (Tauri sidecar / local-owner mode)
|
|
54
57
|
if (options.noAuth) {
|
|
55
58
|
settings.update({ auth: { required: false, guestAllowed: true } });
|
|
@@ -116,18 +119,24 @@ export async function createServer(options = {}) {
|
|
|
116
119
|
app.route('/api/auth', createAuthRouter(keys, users, sessions, settings));
|
|
117
120
|
// --- Session-gated routes ---
|
|
118
121
|
app.use('/api/*', sessionAuth(sessions, settings, keys));
|
|
119
|
-
app.use('/api/*', resolveCaller(keys));
|
|
122
|
+
app.use('/api/*', resolveCaller(keys, projects));
|
|
120
123
|
// Document backend API — gated by sessionAuth + resolveCaller (mounted above).
|
|
121
|
-
// The router itself enforces
|
|
122
|
-
// Settings is threaded so
|
|
123
|
-
const docStore =
|
|
124
|
-
app.route('/api/docs', createDocsRouter(docStore,
|
|
124
|
+
// The router itself enforces scopeAccessMatch and the __-prefix reservation.
|
|
125
|
+
// Settings is threaded so scopeAccessMatch respects open / no-auth mode.
|
|
126
|
+
const docStore = createScopedDocStore(dataDir);
|
|
127
|
+
app.route('/api/docs', createDocsRouter(docStore, {
|
|
128
|
+
settings,
|
|
129
|
+
projectStore: projects,
|
|
130
|
+
appRegistry: getServerAppRegistry(dataDir),
|
|
131
|
+
}));
|
|
132
|
+
// Projects CRUD (member listing + admin management).
|
|
133
|
+
app.route('/api/projects', createProjectsRouter(projects, dataDir));
|
|
125
134
|
// Environment state API (per-shard server-backed config)
|
|
126
135
|
app.route('/api/env-state', createEnvStateRouter(dataDir));
|
|
127
136
|
// User-tenant key management (list/revoke). Mint lives at /api/shards-keys.
|
|
128
137
|
app.route('/api/keys', createKeysRouter(keys, async (event) => {
|
|
129
138
|
// Broadcast will be wired in Task 10 once the shell has a consumer.
|
|
130
|
-
console.log(`[sh3] key revoked:
|
|
139
|
+
console.log(`[sh3] key revoked: scope=${event.scopeId} id=${event.id}`);
|
|
131
140
|
}));
|
|
132
141
|
// Package listing (public read, writes need admin — handled by admin middleware on management routes)
|
|
133
142
|
app.get('/api/packages', (c) => {
|
|
@@ -207,7 +216,7 @@ export async function createServer(options = {}) {
|
|
|
207
216
|
permissions: [],
|
|
208
217
|
adminOnly: adminOnly(keys, settings),
|
|
209
218
|
scopeRequired,
|
|
210
|
-
tenantRequired,
|
|
219
|
+
tenantRequired: requireCallerScope,
|
|
211
220
|
wsRegister,
|
|
212
221
|
});
|
|
213
222
|
app.route('/api/shell', shellSubApp);
|
|
@@ -237,7 +246,7 @@ export async function createServer(options = {}) {
|
|
|
237
246
|
removeLegacyGrants(dataDir);
|
|
238
247
|
// First-boot: generate admin key + admin user
|
|
239
248
|
if (keys.isEmpty()) {
|
|
240
|
-
const initial = keys.generate({ label: 'Initial admin key',
|
|
249
|
+
const initial = keys.generate({ label: 'Initial admin key', scopeId: null, ownerUserId: null, mintedByShardId: null, scopes: ['admin:*'] });
|
|
241
250
|
const tempPassword = UserStore.generatePassword();
|
|
242
251
|
await users.create({
|
|
243
252
|
username: 'admin',
|
package/dist/keys.d.ts
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Unified API key store — admin, user-
|
|
2
|
+
* Unified API key store — admin, user-scope, and connector-bound keys.
|
|
3
3
|
*
|
|
4
4
|
* Layout on disk:
|
|
5
|
-
* <dataDir>/admin-keys.json —
|
|
6
|
-
* <dataDir>/users/<
|
|
5
|
+
* <dataDir>/admin-keys.json — scopeId: null rows only
|
|
6
|
+
* <dataDir>/users/<scopeId>/__system__/keys.json — user-scope rows
|
|
7
7
|
*
|
|
8
8
|
* Legacy migration: a pre-existing <dataDir>/keys.json is treated as admin
|
|
9
9
|
* keys and moved to admin-keys.json on first load, with the original renamed
|
|
10
10
|
* to keys.json.legacy.
|
|
11
|
+
*
|
|
12
|
+
* Field migration: rows with the legacy `tenantId` field are rewritten to
|
|
13
|
+
* `scopeId` on load (idempotent). The on-disk JSON is rewritten when any
|
|
14
|
+
* row is migrated.
|
|
11
15
|
*/
|
|
12
16
|
export interface ApiKey {
|
|
13
17
|
id: string;
|
|
14
18
|
key: string;
|
|
15
19
|
label: string;
|
|
16
|
-
|
|
20
|
+
scopeId: string | null;
|
|
17
21
|
ownerUserId: string | null;
|
|
18
22
|
mintedByShardId: string | null;
|
|
19
23
|
scopes: string[];
|
|
@@ -25,7 +29,7 @@ export interface ApiKey {
|
|
|
25
29
|
export type ApiKeyPublic = Omit<ApiKey, 'key'>;
|
|
26
30
|
export interface GenerateInput {
|
|
27
31
|
label: string;
|
|
28
|
-
|
|
32
|
+
scopeId: string | null;
|
|
29
33
|
ownerUserId: string | null;
|
|
30
34
|
mintedByShardId: string | null;
|
|
31
35
|
scopes: string[];
|
|
@@ -38,12 +42,14 @@ export declare class KeyStore {
|
|
|
38
42
|
constructor(dataDir: string);
|
|
39
43
|
generate(input: GenerateInput): ApiKey;
|
|
40
44
|
resolve(token: string): ApiKey | null;
|
|
41
|
-
|
|
42
|
-
listForShard(
|
|
45
|
+
listForScope(scopeId: string): ApiKeyPublic[];
|
|
46
|
+
listForShard(scopeId: string, shardId: string): ApiKeyPublic[];
|
|
43
47
|
listAdmin(): ApiKeyPublic[];
|
|
44
48
|
listAll(): ApiKeyPublic[];
|
|
45
|
-
revoke(
|
|
49
|
+
revoke(scopeId: string | null, id: string): ApiKeyPublic | null;
|
|
46
50
|
isEmpty(): boolean;
|
|
47
51
|
/** @deprecated use resolve() — kept until all callers migrate. */
|
|
48
52
|
validate(token: string): boolean;
|
|
53
|
+
/** @deprecated use listForScope() — kept for one minor while callers migrate. */
|
|
54
|
+
listForTenant(scopeId: string): ApiKeyPublic[];
|
|
49
55
|
}
|
package/dist/keys.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Unified API key store — admin, user-
|
|
2
|
+
* Unified API key store — admin, user-scope, and connector-bound keys.
|
|
3
3
|
*
|
|
4
4
|
* Layout on disk:
|
|
5
|
-
* <dataDir>/admin-keys.json —
|
|
6
|
-
* <dataDir>/users/<
|
|
5
|
+
* <dataDir>/admin-keys.json — scopeId: null rows only
|
|
6
|
+
* <dataDir>/users/<scopeId>/__system__/keys.json — user-scope rows
|
|
7
7
|
*
|
|
8
8
|
* Legacy migration: a pre-existing <dataDir>/keys.json is treated as admin
|
|
9
9
|
* keys and moved to admin-keys.json on first load, with the original renamed
|
|
10
10
|
* to keys.json.legacy.
|
|
11
|
+
*
|
|
12
|
+
* Field migration: rows with the legacy `tenantId` field are rewritten to
|
|
13
|
+
* `scopeId` on load (idempotent). The on-disk JSON is rewritten when any
|
|
14
|
+
* row is migrated.
|
|
11
15
|
*/
|
|
12
16
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, renameSync, } from 'node:fs';
|
|
13
17
|
import { dirname, join } from 'node:path';
|
|
@@ -16,15 +20,29 @@ function strip(k) {
|
|
|
16
20
|
const { key: _drop, ...rest } = k;
|
|
17
21
|
return rest;
|
|
18
22
|
}
|
|
23
|
+
function migrateRow(input) {
|
|
24
|
+
const row = input;
|
|
25
|
+
let migrated = false;
|
|
26
|
+
if ('tenantId' in row && !('scopeId' in row)) {
|
|
27
|
+
row.scopeId = row.tenantId ?? null;
|
|
28
|
+
delete row.tenantId;
|
|
29
|
+
migrated = true;
|
|
30
|
+
}
|
|
31
|
+
if ('connectorId' in row) {
|
|
32
|
+
delete row.connectorId;
|
|
33
|
+
migrated = true;
|
|
34
|
+
}
|
|
35
|
+
return { row: row, migrated };
|
|
36
|
+
}
|
|
19
37
|
export class KeyStore {
|
|
20
38
|
#dataDir;
|
|
21
39
|
#admin = [];
|
|
22
|
-
#
|
|
40
|
+
#byScope = new Map();
|
|
23
41
|
constructor(dataDir) {
|
|
24
42
|
this.#dataDir = dataDir;
|
|
25
43
|
this.#migrateLegacy();
|
|
26
44
|
this.#loadAdmin();
|
|
27
|
-
this.#
|
|
45
|
+
this.#loadAllScopes();
|
|
28
46
|
}
|
|
29
47
|
// ---------- public API ----------
|
|
30
48
|
generate(input) {
|
|
@@ -34,7 +52,7 @@ export class KeyStore {
|
|
|
34
52
|
id,
|
|
35
53
|
key,
|
|
36
54
|
label: input.label,
|
|
37
|
-
|
|
55
|
+
scopeId: input.scopeId,
|
|
38
56
|
ownerUserId: input.ownerUserId,
|
|
39
57
|
mintedByShardId: input.mintedByShardId,
|
|
40
58
|
scopes: [...input.scopes],
|
|
@@ -43,15 +61,15 @@ export class KeyStore {
|
|
|
43
61
|
createdAt: new Date().toISOString(),
|
|
44
62
|
expiresAt: input.expiresAt,
|
|
45
63
|
};
|
|
46
|
-
if (row.
|
|
64
|
+
if (row.scopeId === null) {
|
|
47
65
|
this.#admin.push(row);
|
|
48
66
|
this.#saveAdmin();
|
|
49
67
|
}
|
|
50
68
|
else {
|
|
51
|
-
const bucket = this.#
|
|
69
|
+
const bucket = this.#byScope.get(row.scopeId) ?? [];
|
|
52
70
|
bucket.push(row);
|
|
53
|
-
this.#
|
|
54
|
-
this.#
|
|
71
|
+
this.#byScope.set(row.scopeId, bucket);
|
|
72
|
+
this.#saveScope(row.scopeId);
|
|
55
73
|
}
|
|
56
74
|
return { ...row, scopes: [...row.scopes] };
|
|
57
75
|
}
|
|
@@ -67,30 +85,30 @@ export class KeyStore {
|
|
|
67
85
|
const adminHit = this.#admin.find(match);
|
|
68
86
|
if (adminHit)
|
|
69
87
|
return adminHit;
|
|
70
|
-
for (const bucket of this.#
|
|
88
|
+
for (const bucket of this.#byScope.values()) {
|
|
71
89
|
const hit = bucket.find(match);
|
|
72
90
|
if (hit)
|
|
73
91
|
return hit;
|
|
74
92
|
}
|
|
75
93
|
return null;
|
|
76
94
|
}
|
|
77
|
-
|
|
78
|
-
return (this.#
|
|
95
|
+
listForScope(scopeId) {
|
|
96
|
+
return (this.#byScope.get(scopeId) ?? []).map(strip);
|
|
79
97
|
}
|
|
80
|
-
listForShard(
|
|
81
|
-
return this.
|
|
98
|
+
listForShard(scopeId, shardId) {
|
|
99
|
+
return this.listForScope(scopeId).filter((k) => k.mintedByShardId === shardId);
|
|
82
100
|
}
|
|
83
101
|
listAdmin() {
|
|
84
102
|
return this.#admin.map(strip);
|
|
85
103
|
}
|
|
86
104
|
listAll() {
|
|
87
105
|
const out = this.#admin.map(strip);
|
|
88
|
-
for (const bucket of this.#
|
|
106
|
+
for (const bucket of this.#byScope.values())
|
|
89
107
|
out.push(...bucket.map(strip));
|
|
90
108
|
return out;
|
|
91
109
|
}
|
|
92
|
-
revoke(
|
|
93
|
-
if (
|
|
110
|
+
revoke(scopeId, id) {
|
|
111
|
+
if (scopeId === null) {
|
|
94
112
|
const idx = this.#admin.findIndex((k) => k.id === id);
|
|
95
113
|
if (idx < 0)
|
|
96
114
|
return null;
|
|
@@ -98,14 +116,14 @@ export class KeyStore {
|
|
|
98
116
|
this.#saveAdmin();
|
|
99
117
|
return strip(removed);
|
|
100
118
|
}
|
|
101
|
-
const bucket = this.#
|
|
119
|
+
const bucket = this.#byScope.get(scopeId);
|
|
102
120
|
if (!bucket)
|
|
103
121
|
return null;
|
|
104
122
|
const idx = bucket.findIndex((k) => k.id === id);
|
|
105
123
|
if (idx < 0)
|
|
106
124
|
return null;
|
|
107
125
|
const [removed] = bucket.splice(idx, 1);
|
|
108
|
-
this.#
|
|
126
|
+
this.#saveScope(scopeId);
|
|
109
127
|
return strip(removed);
|
|
110
128
|
}
|
|
111
129
|
isEmpty() {
|
|
@@ -115,8 +133,8 @@ export class KeyStore {
|
|
|
115
133
|
#adminPath() {
|
|
116
134
|
return join(this.#dataDir, 'admin-keys.json');
|
|
117
135
|
}
|
|
118
|
-
#
|
|
119
|
-
return join(this.#dataDir, 'users',
|
|
136
|
+
#scopePath(scopeId) {
|
|
137
|
+
return join(this.#dataDir, 'users', scopeId, '__system__', 'keys.json');
|
|
120
138
|
}
|
|
121
139
|
#loadAdmin() {
|
|
122
140
|
const p = this.#adminPath();
|
|
@@ -124,10 +142,12 @@ export class KeyStore {
|
|
|
124
142
|
return;
|
|
125
143
|
try {
|
|
126
144
|
const raw = JSON.parse(readFileSync(p, 'utf-8'));
|
|
127
|
-
|
|
128
|
-
const cleaned = raw.map((
|
|
129
|
-
const {
|
|
130
|
-
|
|
145
|
+
let dirty = false;
|
|
146
|
+
const cleaned = raw.map((r) => {
|
|
147
|
+
const { row, migrated } = migrateRow(r);
|
|
148
|
+
if (migrated)
|
|
149
|
+
dirty = true;
|
|
150
|
+
return row;
|
|
131
151
|
});
|
|
132
152
|
this.#admin.push(...cleaned);
|
|
133
153
|
if (dirty)
|
|
@@ -137,30 +157,32 @@ export class KeyStore {
|
|
|
137
157
|
// Corrupt admin file — leave empty; the next generate() overwrites.
|
|
138
158
|
}
|
|
139
159
|
}
|
|
140
|
-
#
|
|
160
|
+
#loadAllScopes() {
|
|
141
161
|
const usersDir = join(this.#dataDir, 'users');
|
|
142
162
|
if (!existsSync(usersDir))
|
|
143
163
|
return;
|
|
144
164
|
for (const entry of readdirSync(usersDir, { withFileTypes: true })) {
|
|
145
165
|
if (!entry.isDirectory())
|
|
146
166
|
continue;
|
|
147
|
-
const
|
|
148
|
-
const file = this.#
|
|
167
|
+
const scopeId = entry.name;
|
|
168
|
+
const file = this.#scopePath(scopeId);
|
|
149
169
|
if (!existsSync(file))
|
|
150
170
|
continue;
|
|
151
171
|
try {
|
|
152
172
|
const raw = JSON.parse(readFileSync(file, 'utf-8'));
|
|
153
|
-
|
|
154
|
-
const cleaned = raw.map((
|
|
155
|
-
const {
|
|
156
|
-
|
|
173
|
+
let dirty = false;
|
|
174
|
+
const cleaned = raw.map((r) => {
|
|
175
|
+
const { row, migrated } = migrateRow(r);
|
|
176
|
+
if (migrated)
|
|
177
|
+
dirty = true;
|
|
178
|
+
return row;
|
|
157
179
|
});
|
|
158
|
-
this.#
|
|
180
|
+
this.#byScope.set(scopeId, cleaned);
|
|
159
181
|
if (dirty)
|
|
160
|
-
this.#
|
|
182
|
+
this.#saveScope(scopeId);
|
|
161
183
|
}
|
|
162
184
|
catch {
|
|
163
|
-
this.#
|
|
185
|
+
this.#byScope.set(scopeId, []);
|
|
164
186
|
}
|
|
165
187
|
}
|
|
166
188
|
}
|
|
@@ -169,9 +191,9 @@ export class KeyStore {
|
|
|
169
191
|
mkdirSync(dirname(p), { recursive: true });
|
|
170
192
|
writeFileSync(p, JSON.stringify(this.#admin, null, 2));
|
|
171
193
|
}
|
|
172
|
-
#
|
|
173
|
-
const bucket = this.#
|
|
174
|
-
const p = this.#
|
|
194
|
+
#saveScope(scopeId) {
|
|
195
|
+
const bucket = this.#byScope.get(scopeId) ?? [];
|
|
196
|
+
const p = this.#scopePath(scopeId);
|
|
175
197
|
mkdirSync(dirname(p), { recursive: true });
|
|
176
198
|
writeFileSync(p, JSON.stringify(bucket, null, 2));
|
|
177
199
|
}
|
|
@@ -184,7 +206,7 @@ export class KeyStore {
|
|
|
184
206
|
const legacy = JSON.parse(readFileSync(legacyPath, 'utf-8'));
|
|
185
207
|
const migrated = legacy.map((row) => ({
|
|
186
208
|
...row,
|
|
187
|
-
|
|
209
|
+
scopeId: null,
|
|
188
210
|
ownerUserId: null,
|
|
189
211
|
mintedByShardId: null,
|
|
190
212
|
scopes: ['admin:*'],
|
|
@@ -203,4 +225,8 @@ export class KeyStore {
|
|
|
203
225
|
const hit = this.resolve(token);
|
|
204
226
|
return !!hit && hit.scopes.includes('admin:*');
|
|
205
227
|
}
|
|
228
|
+
/** @deprecated use listForScope() — kept for one minor while callers migrate. */
|
|
229
|
+
listForTenant(scopeId) {
|
|
230
|
+
return this.listForScope(scopeId);
|
|
231
|
+
}
|
|
206
232
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* projectAppAllowlist — write-only enforcement of the project's app allowlist.
|
|
3
|
+
*
|
|
4
|
+
* For a project scope (URL :scope matches a ProjectStore entry), writes can
|
|
5
|
+
* only target shards owned by allowlisted apps, plus the framework shards
|
|
6
|
+
* listed in FRAMEWORK_SHARDS. Personal scopes are not subject to allowlists.
|
|
7
|
+
*
|
|
8
|
+
* Reads (GET / HEAD) are unrestricted; the membership check in
|
|
9
|
+
* scopeAccessMatch already gates access to project data. This middleware
|
|
10
|
+
* is layered on top to scope-down what apps can persist into the
|
|
11
|
+
* project's namespace.
|
|
12
|
+
*
|
|
13
|
+
* No admin bypass — admins authoring a write inside a project still hit
|
|
14
|
+
* the allowlist.
|
|
15
|
+
*/
|
|
16
|
+
import type { MiddlewareHandler } from 'hono';
|
|
17
|
+
import type { ProjectStore } from '../projects.js';
|
|
18
|
+
export declare const FRAMEWORK_SHARDS: readonly string[];
|
|
19
|
+
interface AppRegistry {
|
|
20
|
+
get(id: string): {
|
|
21
|
+
manifest: {
|
|
22
|
+
requiredShards: string[];
|
|
23
|
+
};
|
|
24
|
+
} | null;
|
|
25
|
+
}
|
|
26
|
+
export declare function projectAppAllowlist(opts: {
|
|
27
|
+
projectStore: ProjectStore;
|
|
28
|
+
appRegistry: AppRegistry;
|
|
29
|
+
}): MiddlewareHandler;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* projectAppAllowlist — write-only enforcement of the project's app allowlist.
|
|
3
|
+
*
|
|
4
|
+
* For a project scope (URL :scope matches a ProjectStore entry), writes can
|
|
5
|
+
* only target shards owned by allowlisted apps, plus the framework shards
|
|
6
|
+
* listed in FRAMEWORK_SHARDS. Personal scopes are not subject to allowlists.
|
|
7
|
+
*
|
|
8
|
+
* Reads (GET / HEAD) are unrestricted; the membership check in
|
|
9
|
+
* scopeAccessMatch already gates access to project data. This middleware
|
|
10
|
+
* is layered on top to scope-down what apps can persist into the
|
|
11
|
+
* project's namespace.
|
|
12
|
+
*
|
|
13
|
+
* No admin bypass — admins authoring a write inside a project still hit
|
|
14
|
+
* the allowlist.
|
|
15
|
+
*/
|
|
16
|
+
export const FRAMEWORK_SHARDS = [
|
|
17
|
+
'__sh3core__',
|
|
18
|
+
'__projects__',
|
|
19
|
+
];
|
|
20
|
+
const WRITE_METHODS = new Set(['PUT', 'POST', 'DELETE']);
|
|
21
|
+
export function projectAppAllowlist(opts) {
|
|
22
|
+
return async (c, next) => {
|
|
23
|
+
if (!WRITE_METHODS.has(c.req.method))
|
|
24
|
+
return next();
|
|
25
|
+
const scope = c.req.param('scope');
|
|
26
|
+
const shard = c.req.param('shard');
|
|
27
|
+
if (!scope || !shard)
|
|
28
|
+
return next(); // bad request — let the route handle it
|
|
29
|
+
const project = opts.projectStore.get(scope);
|
|
30
|
+
if (!project)
|
|
31
|
+
return next(); // personal scope — no allowlist applies
|
|
32
|
+
if (FRAMEWORK_SHARDS.includes(shard))
|
|
33
|
+
return next();
|
|
34
|
+
const allowed = new Set(FRAMEWORK_SHARDS);
|
|
35
|
+
for (const appId of project.appAllowlist) {
|
|
36
|
+
const app = opts.appRegistry.get(appId);
|
|
37
|
+
if (!app)
|
|
38
|
+
continue;
|
|
39
|
+
for (const s of app.manifest.requiredShards)
|
|
40
|
+
allowed.add(s);
|
|
41
|
+
}
|
|
42
|
+
if (allowed.has(shard))
|
|
43
|
+
return next();
|
|
44
|
+
return c.json({
|
|
45
|
+
error: `Shard "${shard}" is not in the project's app allowlist`,
|
|
46
|
+
project: project.id,
|
|
47
|
+
}, 403);
|
|
48
|
+
};
|
|
49
|
+
}
|
package/dist/packages.d.ts
CHANGED
|
@@ -27,6 +27,19 @@ export declare function loadPackages(shardRouter: ShardRouter, dataDir: string,
|
|
|
27
27
|
* manifests so the listing endpoint always reflects what is on disk.
|
|
28
28
|
*/
|
|
29
29
|
export declare function scanPackages(dataDir: string): DiscoveredPackage[];
|
|
30
|
+
/**
|
|
31
|
+
* Minimal lookup over installed app manifests, used by the project
|
|
32
|
+
* allowlist middleware to expand allowlisted app ids into their
|
|
33
|
+
* requiredShards lists. Re-reads `<dataDir>/packages/` on each call;
|
|
34
|
+
* the working set is small enough that per-request scanning is fine.
|
|
35
|
+
*/
|
|
36
|
+
export declare function getServerAppRegistry(dataDir: string): {
|
|
37
|
+
get(id: string): {
|
|
38
|
+
manifest: {
|
|
39
|
+
requiredShards: string[];
|
|
40
|
+
};
|
|
41
|
+
} | null;
|
|
42
|
+
};
|
|
30
43
|
/**
|
|
31
44
|
* Register `GET /packages/:id/client.js` to serve client bundles from disk.
|
|
32
45
|
* The `Cache-Control` header is read from settings on every request:
|
package/dist/packages.js
CHANGED
|
@@ -134,6 +134,34 @@ export function scanPackages(dataDir) {
|
|
|
134
134
|
return result;
|
|
135
135
|
}
|
|
136
136
|
// ---------------------------------------------------------------------------
|
|
137
|
+
// Server-side app registry (for project allowlist enforcement)
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
/**
|
|
140
|
+
* Minimal lookup over installed app manifests, used by the project
|
|
141
|
+
* allowlist middleware to expand allowlisted app ids into their
|
|
142
|
+
* requiredShards lists. Re-reads `<dataDir>/packages/` on each call;
|
|
143
|
+
* the working set is small enough that per-request scanning is fine.
|
|
144
|
+
*/
|
|
145
|
+
export function getServerAppRegistry(dataDir) {
|
|
146
|
+
return {
|
|
147
|
+
get(id) {
|
|
148
|
+
const manifestPath = join(dataDir, 'packages', id, 'manifest.json');
|
|
149
|
+
if (!existsSync(manifestPath))
|
|
150
|
+
return null;
|
|
151
|
+
try {
|
|
152
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
153
|
+
if (manifest.type !== 'app')
|
|
154
|
+
return null;
|
|
155
|
+
const requiredShards = Array.isArray(manifest.requiredShards) ? manifest.requiredShards : [];
|
|
156
|
+
return { manifest: { requiredShards } };
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
137
165
|
// Client bundle serving
|
|
138
166
|
// ---------------------------------------------------------------------------
|
|
139
167
|
/**
|