sh3-server 0.13.0 → 0.13.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.
@@ -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-BPTrm0uN.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-J_irM21j.css">
8
+ <script type="module" crossorigin src="/assets/index-eXElR_9t.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-B1K1agdD.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
- tenantId: string | null;
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
- tenantId: session.userId,
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
- c.set('caller', identityFromSession(session));
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
- tenantId: row.tenantId,
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
- tenantId: null,
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
- /** Build a TenantDocStore with default dependencies wired to a data dir. */
11
- export declare function createTenantDocStore(dataDir: string): TenantDocStore;
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;
@@ -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 TenantDocStore with default dependencies wired to a data dir. */
14
- export function createTenantDocStore(dataDir) {
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 { createTenantDocStore } from './doc-store/index.js';
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 { loadPackages, scanPackages, servePackageBundles, createPackageManagementRoutes } from './packages.js';
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, tenantRequired } from './scope.js';
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 tenantParamMatch and the __-prefix reservation.
122
- // Settings is threaded so tenantParamMatch respects open / no-auth mode.
123
- const docStore = createTenantDocStore(dataDir);
124
- app.route('/api/docs', createDocsRouter(docStore, settings));
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: tenant=${event.tenantId} id=${event.id}`);
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', tenantId: null, ownerUserId: null, mintedByShardId: null, scopes: ['admin:*'] });
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-tenant, and connector-bound keys.
2
+ * Unified API key store — admin, user-scope, and connector-bound keys.
3
3
  *
4
4
  * Layout on disk:
5
- * <dataDir>/admin-keys.json — tenantId: null rows only
6
- * <dataDir>/users/<tenantId>/__system__/keys.json — user-tenant rows
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
- tenantId: string | null;
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
- tenantId: string | null;
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
- listForTenant(tenantId: string): ApiKeyPublic[];
42
- listForShard(tenantId: string, shardId: string): ApiKeyPublic[];
45
+ listForScope(scopeId: string): ApiKeyPublic[];
46
+ listForShard(scopeId: string, shardId: string): ApiKeyPublic[];
43
47
  listAdmin(): ApiKeyPublic[];
44
48
  listAll(): ApiKeyPublic[];
45
- revoke(tenantId: string | null, id: string): ApiKeyPublic | null;
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-tenant, and connector-bound keys.
2
+ * Unified API key store — admin, user-scope, and connector-bound keys.
3
3
  *
4
4
  * Layout on disk:
5
- * <dataDir>/admin-keys.json — tenantId: null rows only
6
- * <dataDir>/users/<tenantId>/__system__/keys.json — user-tenant rows
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
- #byTenant = new Map();
40
+ #byScope = new Map();
23
41
  constructor(dataDir) {
24
42
  this.#dataDir = dataDir;
25
43
  this.#migrateLegacy();
26
44
  this.#loadAdmin();
27
- this.#loadAllTenants();
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
- tenantId: input.tenantId,
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.tenantId === null) {
64
+ if (row.scopeId === null) {
47
65
  this.#admin.push(row);
48
66
  this.#saveAdmin();
49
67
  }
50
68
  else {
51
- const bucket = this.#byTenant.get(row.tenantId) ?? [];
69
+ const bucket = this.#byScope.get(row.scopeId) ?? [];
52
70
  bucket.push(row);
53
- this.#byTenant.set(row.tenantId, bucket);
54
- this.#saveTenant(row.tenantId);
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.#byTenant.values()) {
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
- listForTenant(tenantId) {
78
- return (this.#byTenant.get(tenantId) ?? []).map(strip);
95
+ listForScope(scopeId) {
96
+ return (this.#byScope.get(scopeId) ?? []).map(strip);
79
97
  }
80
- listForShard(tenantId, shardId) {
81
- return this.listForTenant(tenantId).filter((k) => k.mintedByShardId === shardId);
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.#byTenant.values())
106
+ for (const bucket of this.#byScope.values())
89
107
  out.push(...bucket.map(strip));
90
108
  return out;
91
109
  }
92
- revoke(tenantId, id) {
93
- if (tenantId === null) {
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.#byTenant.get(tenantId);
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.#saveTenant(tenantId);
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
- #tenantPath(tenantId) {
119
- return join(this.#dataDir, 'users', tenantId, '__system__', 'keys.json');
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
- const dirty = raw.some((row) => 'connectorId' in row);
128
- const cleaned = raw.map((row) => {
129
- const { connectorId: _drop, ...rest } = row;
130
- return rest;
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
- #loadAllTenants() {
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 tenantId = entry.name;
148
- const file = this.#tenantPath(tenantId);
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
- const dirty = raw.some((row) => 'connectorId' in row);
154
- const cleaned = raw.map((row) => {
155
- const { connectorId: _drop, ...rest } = row;
156
- return rest;
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.#byTenant.set(tenantId, cleaned);
180
+ this.#byScope.set(scopeId, cleaned);
159
181
  if (dirty)
160
- this.#saveTenant(tenantId);
182
+ this.#saveScope(scopeId);
161
183
  }
162
184
  catch {
163
- this.#byTenant.set(tenantId, []);
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
- #saveTenant(tenantId) {
173
- const bucket = this.#byTenant.get(tenantId) ?? [];
174
- const p = this.#tenantPath(tenantId);
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
- tenantId: null,
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
+ }
@@ -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
  /**