@descix/cli 1.0.18 → 1.0.20

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.
@@ -89,7 +89,6 @@ execute_remote_command({
89
89
  This returns:
90
90
  - `communities`: Communities the user belongs to (with roles)
91
91
  - `apps`: Apps the user owns or can develop
92
- - `service_slots`: Available service deployment slots
93
92
 
94
93
  ### Step 2: Present Entitlements to User
95
94
 
@@ -10,7 +10,7 @@ This document covers how AI agents interact with the DeSciX platform via MCP (Mo
10
10
 
11
11
  ## 1. Architecture Overview
12
12
 
13
- DeSciX operates as a **Federated MCP Service Mesh**. The DeSciX backend acts as a broker that aggregates tools from all registered microservices into a unified catalog.
13
+ DeSciX operates as a **Federated MCP Service Mesh**. The DeSciX backend acts as a broker: it registers tools from all registered microservices alongside its own native commands, dispatches calls to whichever service owns the command, and advertises a small handshake floor of that set at `tools/list` (see §2.2).
14
14
 
15
15
  **Key Innovation:** AI agents discover and invoke capabilities across the entire mesh without knowing where individual services are hosted.
16
16
 
@@ -46,14 +46,29 @@ MCP provides the communication protocol between AI agents and the DeSciX platfor
46
46
  - `ask_question_to_app` - RAG chat with an app's knowledge base (synthesized answer, cited)
47
47
  - `query_knowledge_base` - Raw vector search, returns chunks
48
48
 
49
- ### 2.2 Cursor/AI Agent Integration
49
+ ### 2.2 The Two-Hop: Reaching Commands That Are Not In Your Tool List
50
50
 
51
- When an AI agent (e.g., Cursor) is configured with the DeSciX MCP server:
51
+ **Your MCP tool list is a small floor, not the platform's capability surface.** The platform
52
+ registers many more commands than it advertises at the handshake, so that a session pays ~1k
53
+ tokens of catalog instead of ~8.7k. Everything outside the advertised floor stays registered and
54
+ callable — you reach it with two hops:
52
55
 
53
- 1. Agent receives user question
54
- 2. Agent uses `tell_me_how` to find relevant tools
55
- 3. Agent calls `execute_remote_command` with discovered tool
56
- 4. Result returned to user
56
+ 1. `tell_me_how({ question })` — names the command and its parameters
57
+ 2. `execute_remote_command({ command, params })` — invokes it
58
+
59
+ **Rule for an agent: if a capability is not a tool in your list, do not conclude the platform
60
+ lacks it, and do not guess a command name — ask `tell_me_how`, then `execute_remote_command`
61
+ what it names.** This is the standing integration pattern for every caller, on every transport.
62
+
63
+ The advertised floor is a **floor, not a ceiling**: `tools/list` serves it UNION the calling
64
+ identity's granted command names, so a caller may see more, never less. The floor's membership
65
+ and its contract are owned by `describeDiscoveryCoreFence()` /
66
+ `DISCOVERY_CORE_TOOL_NAMES` in `@descix/platform-api`
67
+ (`src/mcp-tools/nativeTools.js`) — read your live `tools/list` rather than a copy of it.
68
+
69
+ The CLI's stdio MCP server additionally concatenates its CLI-local diagnostics (`descix_doctor`,
70
+ `platform_health`) onto whatever the backend advertises; those are local CLI operations, never
71
+ `/apifront` commands, so `execute_remote_command` does not reach them.
57
72
 
58
73
  ### 2.3 The `_descix` Context Object
59
74
 
@@ -155,21 +170,23 @@ After `descix quickstart` completes:
155
170
 
156
171
  ### 4.1 Federated MCP Broker
157
172
 
158
- The DeSciX backend aggregates tools from all registered microservices:
173
+ The DeSciX backend registers tools from all registered microservices and makes them callable and
174
+ semantically discoverable — the handshake advertises only the floor (§2.2):
159
175
 
160
176
  ```
161
177
  Microservice A
162
178
  └── manifest.json (tools: [tool1, tool2])
163
179
  │
164
180
  ▼
165
- Registers with Core
166
- │
181
+ Registers with Core ──► callable via execute_remote_command
182
+ │ + vectorized for tell_me_how
167
183
  ▼
168
184
  DeSciX Backend (Broker)
169
- └── Aggregated tools/list
185
+ └── tools/list ──► handshake floor + this caller's grants
170
186
  │
171
187
  ▼
172
- AI Agent sees: [tool1, tool2, core_tools...]
188
+ AI Agent's tool list: the floor. tool1/tool2 are reached
189
+ with tell_me_how -> execute_remote_command.
173
190
  ```
174
191
 
175
192
  ### 4.2 SERVICE_README Vectorization
package/bin/descix.js CHANGED
@@ -11,7 +11,7 @@ import { Command } from 'commander';
11
11
  import chalk from 'chalk';
12
12
  import { DeSciXApiClient } from '../lib/api-client.js';
13
13
  import { requireAuth } from '../lib/auth-guard.js';
14
- import { WorkspaceConfig, unmappedAppMessage, resolveWorkspacePath, TOP_LEVEL_API_URL_REMEDY } from '../lib/workspace-config.js';
14
+ import { WorkspaceConfig, unmappedAppMessage, resolveWorkspacePath } from '../lib/workspace-config.js';
15
15
  import { CLI_VERSION } from '../lib/cli-version.js';
16
16
  import { recordInvocationOrigin } from '../lib/origin.js';
17
17
  // Chat session pointer + the ONE rule for when a dead pointer may be self-healed.
@@ -28,10 +28,12 @@ import * as configCommands from '../lib/commands/config.js';
28
28
  import * as buyCommands from '../lib/commands/buy.js';
29
29
  import * as creditsCommands from '../lib/commands/credits.js';
30
30
  import * as airdropCommands from '../lib/commands/airdrop.js';
31
+ import { MICROSERVICE_AUTH_MODEL } from '../lib/commands/microservice-auth-model.js';
31
32
  import { runInit } from '../lib/commands/init.js';
32
33
  // "May I prompt?" has ONE OWNER. No command in this file derives it.
33
34
  import { createPromptSession } from '../lib/interactive.js';
34
- import { registerAllRetiredKbSync, CANONICAL_KB_SYNC } from '../lib/commands/retired-kb-sync.js';
35
+ import { CANONICAL_KB_SYNC } from '../lib/commands/kb-sync-surface.js';
36
+ import { registerAllRetiredVerbs } from '../lib/commands/retired-verbs.js';
35
37
  import { runStatus } from '../lib/commands/status.js';
36
38
  import { refreshCommunityIdentity, printIdentityReceipt } from '../lib/commands/communityIdentity.js';
37
39
  import { runAppInit } from '../lib/commands/appInit.js';
@@ -455,9 +457,9 @@ airdropCommand
455
457
 
456
458
  // ============ Sync Commands: REMOVED ============
457
459
  // `sync assets`, `sync site` and `sync kb` are all gone, so the `sync` GROUP is gone with
458
- // them: a registered verb with no working children is dead weight. Every retired surface is
459
- // registered in ONE place below (search registerAllRetiredKbSync), driven off the owner list
460
- // in lib/commands/retired-kb-sync.js -- this file names no retired verb itself.
460
+ // them: a registered verb with no working children is dead weight. Every retired verb is
461
+ // registered in ONE place below (search registerAllRetiredVerbs), driven off the owner table
462
+ // in lib/commands/retired-verbs.js -- this file names no retired verb itself.
461
463
 
462
464
  // ============ Community/App Commands ============
463
465
 
@@ -1639,35 +1641,41 @@ appCommand
1639
1641
 
1640
1642
  // ============ App: set-site (WS-SSGPOD — site.static workspace gap) ============
1641
1643
  //
1642
- // Canonical write path for an app's site config in workspace.json — specifically site.static,
1643
- // the relative path the dev gateway's staticSitePlugin serves at /p/{appId}/ (resolved against
1644
- // the app's localPath; "." means the localPath itself). Optionally sets site.port for
1645
- // dev-server sites. Parallel to `descix app set-port` (microservice.port); closes the
1646
- // site.static gap so workspace.json never needs hand-editing (CEO-D-2026-06-02-SSGPOD-SITE-PREPROD).
1647
- // Backed by WorkspaceConfig.setStaticSite (parallel to setMicroservicePort).
1648
- // It writes ONLY the local workspace.json site.{} slot.
1644
+ // Canonical write path for an app's site.{} slot in workspace.json. TWO MODES, one active
1645
+ // per write:
1646
+ // static mode (--static): site.static — the relative dir (under the app's localPath;
1647
+ // "." = the localPath itself) the gateway's staticSitePlugin
1648
+ // serves at /p/{appId}/.
1649
+ // dev-server mode (--port): site.port [+ site.protocol] — the local dev server the
1650
+ // gateway proxies /p/{appId} to (vite-style hotserving).
1651
+ // Setting one mode CLEARS the other's fields; passing BOTH flags in one invocation is the
1652
+ // explicit combined write and keeps both (the gateway then serves the DEV SERVER — site.port
1653
+ // wins — and says so on the serve banner). Parallel to `descix app set-port`
1654
+ // (microservice.port); no hand-editing of workspace.json (CEO-D-2026-06-02-SSGPOD-SITE-PREPROD).
1655
+ // Backed by WorkspaceConfig.setStaticSite. It writes ONLY the local workspace.json site.{} slot.
1649
1656
  appCommand
1650
1657
  .command('set-site')
1651
- .description("Set a mapped app static-site config (writes env.products[<app>].site.static, the relative dir served at /p/<app>/). Optionally --port; --unset clears site.{}.")
1658
+ .description("Set a mapped app's site config (env.products[<app>].site). One active mode per write: --static <dir> (built site served at /p/<app>/) or --port <n> [--protocol http|https] (dev server proxied at /p/<app>); setting one clears the other. Pass both flags together to keep both — the gateway then serves the dev server (site.port wins). --unset clears site.{} entirely.")
1652
1659
  .requiredOption('-a, --app <app_id>', 'App ID (must be mapped in workspace.json)')
1653
- .option('--static <path>', 'Relative static-site path under the app localPath (e.g. "site"; "." = the localPath itself)')
1654
- .option('--port <port>', 'Site dev-server port (1-65535) for framework dev sites; mutually optional with --static')
1655
- .option('--unset', 'Remove site.static (and site.port) for the app, clearing the site.{} slot')
1660
+ .option('--static <path>', 'Static mode: relative dir under the app localPath (e.g. "site"; "." = the localPath itself). Alone, it clears site.port and site.protocol (one active mode per write).')
1661
+ .option('--port <port>', 'Dev-server mode: proxy /p/<app> to your local dev server on this port (1-65535). Alone, it clears site.static. With --static in the same call, both are kept and the gateway serves the dev server.')
1662
+ .option('--protocol <scheme>', 'Dev-server upstream scheme: "http" or "https" (default https when unset). Dev-server mode only — requires site.port. A wrong scheme fails loud at the gateway (502); there is no auto-detection.')
1663
+ .option('--unset', 'Remove site.static, site.port and site.protocol for the app, clearing the site.{} slot')
1656
1664
  .action(async (options) => {
1657
1665
  try {
1658
1666
  const appId = options.app;
1659
1667
  const workspaceConfig = await WorkspaceConfig.load();
1660
1668
 
1661
- // --unset: remove both site.static and site.port (setStaticSite cleans up empty site.{})
1669
+ // --unset: remove all site.{} fields (setStaticSite cleans up the empty slot)
1662
1670
  if (options.unset) {
1663
- await workspaceConfig.setStaticSite(appId, { static: null, port: null });
1664
- console.log(chalk.green(`\n✓ site config cleared for ${appId}\n`));
1671
+ await workspaceConfig.setStaticSite(appId, { static: null, port: null, protocol: null });
1672
+ console.log(chalk.green(`\n✓ site config cleared for ${appId} (site.static, site.port, site.protocol removed)\n`));
1665
1673
  return;
1666
1674
  }
1667
1675
 
1668
1676
  // Require at least one field to set — do not silently no-op.
1669
- if (options.static === undefined && options.port === undefined) {
1670
- console.error(chalk.red('\n❌ Nothing to set. Provide --static <path> and/or --port <n>, or --unset to clear.\n'));
1677
+ if (options.static === undefined && options.port === undefined && options.protocol === undefined) {
1678
+ console.error(chalk.red('\n❌ Nothing to set. Provide --static <path> (static mode), --port <n> [--protocol http|https] (dev-server mode), or --unset to clear.\n'));
1671
1679
  process.exit(1);
1672
1680
  }
1673
1681
 
@@ -1690,15 +1698,34 @@ appCommand
1690
1698
  fields.port = portNum;
1691
1699
  }
1692
1700
 
1693
- // setStaticSite mutates the live env entry and saves — and hard-fails (canonical
1694
- // "not mapped in workspace.json" error) for an unmapped app, surfaced in the catch below.
1701
+ if (options.protocol !== undefined) {
1702
+ if (options.protocol !== 'http' && options.protocol !== 'https') {
1703
+ console.error(chalk.red(`\n❌ Invalid --protocol ${JSON.stringify(options.protocol)}. Only "http" or "https" are valid (it names the scheme of your app's own dev server behind /p/${appId}).\n`));
1704
+ process.exit(1);
1705
+ }
1706
+ fields.protocol = options.protocol;
1707
+ }
1708
+
1709
+ // setStaticSite applies ONE-ACTIVE-MODE-PER-WRITE and saves — it hard-fails (canonical
1710
+ // "not mapped in workspace.json" error, or protocol-without-port) in the catch below.
1695
1711
  await workspaceConfig.setStaticSite(appId, fields);
1696
1712
 
1697
1713
  console.log(chalk.green(`\n✓ site config set for ${appId}`));
1698
- if (fields.static !== undefined) console.log(chalk.cyan(` site.static: ${fields.static}`));
1699
- if (fields.port !== undefined) console.log(chalk.cyan(` site.port: ${fields.port}`));
1700
- console.log(chalk.gray(` Written to env.products[${appId}].site in workspace.json.`));
1701
- console.log(chalk.gray(` The gateway serves site.static at /p/${appId}/ (staticSitePlugin); \`descix serve\` picks it up.\n`));
1714
+ // Print the RESULTING slot, not just the fields passed: a dev-server write keeps a
1715
+ // previously declared site.protocol (same mode, new port), and a retained field the
1716
+ // output does not show is invisible state — the whole defect class this command fixes.
1717
+ const entryNow = workspaceConfig.env?.platform?.appId === appId
1718
+ ? workspaceConfig.env.platform
1719
+ : (workspaceConfig.env?.products || []).find(p => p.appId === appId);
1720
+ console.log(chalk.cyan(` site is now: ${JSON.stringify(entryNow?.site)}`));
1721
+ if (fields.static !== undefined && fields.port === undefined) {
1722
+ console.log(chalk.gray(` site.port and site.protocol cleared (one active mode per write) — /p/${appId}/ now serves the static dir.`));
1723
+ } else if (fields.port !== undefined && fields.static === undefined) {
1724
+ console.log(chalk.gray(` site.static cleared (one active mode per write) — /p/${appId} now proxies to your dev server.`));
1725
+ } else if (fields.static !== undefined && fields.port !== undefined) {
1726
+ console.log(chalk.yellow(` Combined write: BOTH modes kept. The gateway serves the DEV SERVER (site.port wins); site.static is used only after you clear site.port (--static alone, or --unset).`));
1727
+ }
1728
+ console.log(chalk.gray(` Written to env.products[${appId}].site in workspace.json; \`descix serve\` picks it up.\n`));
1702
1729
  } catch (error) {
1703
1730
  console.error(chalk.red(error.message));
1704
1731
  process.exit(1);
@@ -1951,12 +1978,8 @@ kbCommand
1951
1978
  });
1952
1979
 
1953
1980
  // `kb chunk` and `kb sync` (low-level Git-mode steps) are REMOVED. Their implementations
1954
- // runKbChunk/runKbSync are deleted from lib/commands/kb.js. Both names exit non-zero naming
1955
- // `descix kb corpus sync`.
1956
- // EVERY retired kb-sync surface is registered here, by ITERATING the owner list -- no verb
1957
- // name is typed in this file. Adding a surface to RETIRED_KB_SYNC_SURFACES registers it;
1958
- // nothing can be registered without being in that list.
1959
- registerAllRetiredKbSync({ program, kb: kbCommand }, chalk.red);
1981
+ // runKbChunk/runKbSync are deleted from lib/commands/kb.js. Both names are rows in
1982
+ // lib/commands/retired-verbs.js, registered with the rest of that table below.
1960
1983
 
1961
1984
 
1962
1985
  // app records — APP DATA PLANE structured record store (CEO-D-2026-06-02-APP-DATA-PLANE)
@@ -2929,6 +2952,7 @@ microserviceCommand
2929
2952
  microserviceCommand
2930
2953
  .command('register')
2931
2954
  .description('Register microservice with gateway (-r <local SERVICE_README>; git-mode only — no Drive)')
2955
+ .addHelpText('after', `\nAuthentication:\n ${MICROSERVICE_AUTH_MODEL}\n`)
2932
2956
  .option('-m, --manifest <path>', 'Path to manifest.json', './manifest.json')
2933
2957
  .option('-r, --readme <path>', 'Path to local SERVICE_README file')
2934
2958
  .option('-c, --community <id>', 'Community ID (auto-detects from context)')
@@ -3289,128 +3313,6 @@ microserviceCommand
3289
3313
  }
3290
3314
  });
3291
3315
 
3292
- // microservice register-delegate - Register delegate key
3293
- microserviceCommand
3294
- .command('register-delegate')
3295
- .description('Provision the service delegate key (SERVICE_KEY) that authenticates mesh/loopback calls — run this if your service gets HTTP 401 calling /apifront or another service')
3296
- .option('-c, --community <id>', 'Community ID (auto-detects from context)')
3297
- .option('-a, --app <id>', 'App ID (auto-detects from context)')
3298
- .option('-s, --slot <id>', 'Service slot ID (uses first available if not provided)')
3299
- .action(async (options) => {
3300
- try {
3301
- const apiClient = new DeSciXApiClient();
3302
- await requireAuth(apiClient);
3303
-
3304
- // Auto-detect context
3305
- const workspaceConfig = await WorkspaceConfig.load();
3306
- const ctx = workspaceConfig.resolveContextWithOptions(options);
3307
-
3308
- let communityId = ctx.communityId;
3309
- let appId = ctx.appId;
3310
-
3311
- // Try manifest.json if not detected
3312
- if (!communityId || !appId) {
3313
- try {
3314
- const manifestContent = await fs.readFile('./manifest.json', 'utf-8');
3315
- const manifest = JSON.parse(manifestContent);
3316
- if (!communityId) communityId = manifest.service?.community_id;
3317
- if (!appId) appId = manifest.service?.app_id;
3318
- } catch {
3319
- // Not found
3320
- }
3321
- }
3322
-
3323
- if (!communityId || !appId) {
3324
- console.error(chalk.red('\n❌ Community and App ID required.'));
3325
- console.log(chalk.gray(' Either provide -c and -a flags, cd into an app directory, or have manifest.json present.\n'));
3326
- process.exit(1);
3327
- }
3328
-
3329
- console.log(chalk.cyan('\n🔑 Registering Service Delegate Key\n'));
3330
-
3331
- // Fetch entitlements
3332
- const entitlementsResponse = await apiClient.invoke('fetch_my_purchases', {});
3333
- const entitlements = entitlementsResponse.message || {};
3334
- const allSlots = entitlements.service_slots || [];
3335
-
3336
- // Service slots come ONLY from subscriptions right now. NFT-based slots are FUTURE
3337
- // functionality (app/NFT association is not wired yet) — never select them here.
3338
- // CEO-D-2026-06-01-MESH-AUTH-DRIVE-REMOVAL (Fix C).
3339
- const serviceSlots = allSlots.filter(slot => slot.type === 'subscription');
3340
-
3341
- if (serviceSlots.length === 0) {
3342
- console.error(chalk.red('❌ No subscription service slot available.'));
3343
- console.log(chalk.white(' Service slots are provided by a subscription. A subscription is required to'));
3344
- console.log(chalk.white(' provision a delegate. (NFT-based slots are future functionality and are'));
3345
- console.log(chalk.white(' not selectable yet.)'));
3346
- process.exit(1);
3347
- }
3348
-
3349
- // Select slot (subscription slots only)
3350
- let selectedSlot = serviceSlots[0];
3351
- if (options.slot) {
3352
- selectedSlot = serviceSlots.find(s => (s.id || s.nft_id) === options.slot);
3353
- if (!selectedSlot) {
3354
- console.error(chalk.red(`❌ Subscription slot ${options.slot} not found in your entitlements.`));
3355
- console.log(chalk.gray(' Only subscription slots are selectable. Run without -s to use the first available.'));
3356
- process.exit(1);
3357
- }
3358
- }
3359
-
3360
- console.log(chalk.gray(` Selected slot: ${selectedSlot.name} (${selectedSlot.type})\n`));
3361
-
3362
- // Generate key pair
3363
- const crypto = await import('crypto');
3364
- const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', {
3365
- namedCurve: 'secp256k1',
3366
- publicKeyEncoding: { type: 'spki', format: 'pem' },
3367
- privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
3368
- });
3369
-
3370
- console.log(chalk.gray(' Generating key pair...'));
3371
-
3372
- // Register with Virtual Registry
3373
- const registerResponse = await apiClient.invoke('register_delegate', {
3374
- slot_id: selectedSlot.nft_id || selectedSlot.id,
3375
- slot_type: selectedSlot.type,
3376
- public_key: publicKey,
3377
- app_id: appId,
3378
- community_id: communityId
3379
- });
3380
-
3381
- console.log(chalk.green(' ✓ Delegate registered with Core\n'));
3382
-
3383
- // Save to dev-overrides.json
3384
- const overridesPath = path.resolve(process.cwd(), 'dev-overrides.json');
3385
- let overrides = {};
3386
-
3387
- try {
3388
- const content = await fs.readFile(overridesPath, 'utf-8');
3389
- overrides = JSON.parse(content);
3390
- } catch (e) {
3391
- // File doesn't exist, start fresh
3392
- }
3393
-
3394
- overrides.SERVICE_KEY = {
3395
- privateKey,
3396
- publicKey,
3397
- slotId: selectedSlot.nft_id || selectedSlot.id,
3398
- slotType: selectedSlot.type,
3399
- appId: appId,
3400
- communityId: communityId,
3401
- createdAt: new Date().toISOString()
3402
- };
3403
-
3404
- await fs.writeFile(overridesPath, JSON.stringify(overrides, null, 2));
3405
- console.log(chalk.green(`✅ Key saved to dev-overrides.json\n`));
3406
- console.log(chalk.gray(` Path: ${overridesPath}\n`));
3407
-
3408
- } catch (error) {
3409
- console.error(chalk.red(`\n❌ Error: ${error.message}\n`));
3410
- process.exit(1);
3411
- }
3412
- });
3413
-
3414
3316
  // microservice restart - Kill + relaunch a local microservice (DEV only)
3415
3317
  microserviceCommand
3416
3318
  .command('restart <name>')
@@ -3863,24 +3765,6 @@ configCommand
3863
3765
  }
3864
3766
  });
3865
3767
 
3866
- // `config set-url` is RETIRED, and it is retired LOUDLY rather than deleted outright: a deleted
3867
- // subcommand only earns commander's "unknown command", which names no replacement. It used to
3868
- // assign a top-level `apiUrl` that save() never serialized, then print a success banner over a
3869
- // value that landed nowhere — the file was re-stamped and the origin never moved. Hidden from
3870
- // --help; any invocation exits non-zero naming the three surfaces that actually set the origin.
3871
- configCommand
3872
- .command('set-url', { hidden: true })
3873
- .description('retired — see error text')
3874
- .argument('[url]')
3875
- .allowUnknownOption()
3876
- .action(() => {
3877
- fail(new Error(
3878
- '"descix config set-url" is retired: it never wrote the origin it reported (a retired top-level\n' +
3879
- 'key that nothing reads). Set the API origin where it is read, env.apiUrl, with one of:\n' +
3880
- ` ${TOP_LEVEL_API_URL_REMEDY}`
3881
- ));
3882
- });
3883
-
3884
3768
  configCommand
3885
3769
  .command('init')
3886
3770
  .description('Pin this workspace to an environment (requires the global --env dev|demo|prod)')
@@ -4593,6 +4477,11 @@ program
4593
4477
  }
4594
4478
  });
4595
4479
 
4480
+ // ============ Retired verbs ============
4481
+ // Every retired verb is a row in lib/commands/retired-verbs.js, registered here in one call once
4482
+ // every parent exists. No retired verb name is typed in this file.
4483
+ registerAllRetiredVerbs({ program, kb: kbCommand, config: configCommand, microservice: microserviceCommand });
4484
+
4596
4485
  // ============ Public-vs-admin help listing (CEO ruling, see lib/command-visibility.js) ============
4597
4486
  //
4598
4487
  // The class comes from the server (`get_command_surface`, guest-allowed) at the moment help is
@@ -135,7 +135,7 @@ export function applyVisibility(program, surfaceCommands) {
135
135
  const hasChildren = Array.isArray(cmd.commands) && cmd.commands.length > 0;
136
136
  // ADDITIVE ONLY. Some commands are registered `{ hidden: true }` by the CLI itself for
137
137
  // reasons that have nothing to do with server admin-gating — see the retired-surface
138
- // registrations in lib/commands/retired-kb-sync.js, each a deliberate loud-refusal stub kept
138
+ // registrations in lib/commands/retired-verbs.js, each a deliberate loud-refusal stub kept
139
139
  // out of the default listing so it doesn't clutter it, while still working and refusing
140
140
  // clearly when typed. This layer may HIDE a visible command; it must never REVEAL a command
141
141
  // the CLI already chose to hide. MEASURED: an early version unconditionally overwrote
@@ -205,12 +205,17 @@ export async function executeQueue(options) {
205
205
  if (result.token_contract_address) console.log(` token_contract: ${result.token_contract_address}`);
206
206
  if (result.abi_source) console.log(` abi_source: ${result.abi_source.doc_id} (v${result.abi_source.version || '?'})`);
207
207
  console.log(` transfers_total: ${chalk.yellow(result.transfers_total)}`);
208
+ console.log(` batches_total: ${result.batches_total}`);
208
209
  console.log(` debit_total: ${result.debit_total}`);
209
210
  console.log(` credit_total: ${result.credit_total}`);
210
- console.log(` bonus_total: ${result.bonus_total}`);
211
211
  const nz = result.net_zero_assertion || {};
212
212
  const nzLabel = nz.passes ? chalk.green('PASS') : chalk.red('FAIL');
213
213
  console.log(` net_zero_assertion: ${nzLabel} (Σ=${nz.sum})`);
214
+ const pbc = result.pre_batch_checks || {};
215
+ console.log(` pre_batch_checks: ${pbc.passes ? chalk.green('PASS') : chalk.red('FAIL — --apply would refuse')}`);
216
+ for (const r of (pbc.refusals || [])) {
217
+ console.log(chalk.red(` ${r.kind} ${r.wallet} (${r.community_id}) holds ${r.held}, needs ${r.required} base units — rows ${(r.pending_ids || []).join(', ')}`));
218
+ }
214
219
  console.log(` unique_source_wallets: ${result.unique_source_wallets}`);
215
220
  console.log(` unique_master_wallets: ${result.unique_master_wallets}`);
216
221
  console.log(` prospective_batch_id: ${result.prospective_batch_id}`);
@@ -11,10 +11,10 @@ import { fileURLToPath } from 'url';
11
11
  import { dirname, join } from 'path';
12
12
  // clone.js is imported dynamically inside the invite flow to avoid circular deps
13
13
  import { WorkspaceConfig } from '../workspace-config.js';
14
- // The canonical KB-sync surface is owned by retired-kb-sync.js. Consume the constant: a literal
14
+ // The canonical KB-sync surface is owned by kb-sync-surface.js. Consume the constant: a literal
15
15
  // spelled here is a second derivation of the same fact, and the removed verb this replaced
16
16
  // reached a developer who had done everything right.
17
- import { CANONICAL_KB_SYNC } from './retired-kb-sync.js';
17
+ import { CANONICAL_KB_SYNC } from './kb-sync-surface.js';
18
18
  // "May I prompt?" has ONE OWNER. init does not derive it, and holds no TTY check of its own.
19
19
  import { createLazyPromptSession } from '../interactive.js';
20
20
 
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The canonical KB surfaces, named once. Every refusal, hint and next-step that points a
3
+ * developer at KB sync or KB creation consumes these constants rather than retyping them.
4
+ */
5
+
6
+ /** The single canonical KB sync surface. */
7
+ export const CANONICAL_KB_SYNC = 'descix kb corpus sync';
8
+
9
+ /** The verb that creates a KB — `kb corpus sync`'s dependency. */
10
+ export const CANONICAL_KB_CREATE = 'descix app init --kb <kb_name>';
@@ -32,7 +32,7 @@ import * as driveADC from '../google-storage-adc.js';
32
32
  // The one canonical KB-sync surface, from its owner. Never spell it as a literal here: these
33
33
  // commands' next steps are read by a developer as instructions, so they must name the live verb
34
34
  // and nothing else.
35
- import { CANONICAL_KB_SYNC } from './retired-kb-sync.js';
35
+ import { CANONICAL_KB_SYNC } from './kb-sync-surface.js';
36
36
  import { loadSyncState, syncStatePath } from '../core/syncState.js';
37
37
 
38
38
  // ============ Pull Command ============
@@ -0,0 +1,19 @@
1
+ /**
2
+ * ONE OWNER for how an app microservice authenticates, as the CLI states it.
3
+ *
4
+ * CEO-D-2026-06-02-APP-DATA-PLANE: an app microservice writes on the INJECTED CALLER auth
5
+ * (`params._descix`) with per-app_id isolation, never a per-service delegate key.
6
+ * `microservice register --help` prints this text, and the retired delegate-key verb's refusal
7
+ * (lib/commands/retired-verbs.js) prints it too.
8
+ */
9
+
10
+ export const MICROSERVICE_AUTH_RULING = 'CEO-D-2026-06-02-APP-DATA-PLANE';
11
+
12
+ /** The auth model, printed after `microservice register --help` and in the refusal. */
13
+ export const MICROSERVICE_AUTH_MODEL =
14
+ `A microservice is issued no key of its own (${MICROSERVICE_AUTH_RULING}).\n` +
15
+ ` Inbound: /apifront forwards each call with the caller's identity injected as params._descix,\n` +
16
+ ` isolated per app_id. Your handler acts on that caller.\n` +
17
+ ` Outbound: calls from your service to /apifront authenticate as the developer through\n` +
18
+ ` @descix/cli createServiceApiClient (DEVELOPER_WALLET_ADDRESS + DEVELOPER_SIGNATURE).\n` +
19
+ ` HTTP 401 on an outbound call: those two developer-credential values are missing or wrong.`;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * ONE OWNER for every retired CLI verb: a table and one refusal.
3
+ *
4
+ * A retired verb's implementation is deleted; its NAME stays registered, hidden, so typing it
5
+ * exits non-zero naming the replacement instead of earning commander's "unknown command". That
6
+ * is not a compat fence: nothing here works under an old name.
7
+ *
8
+ * bin/descix.js calls registerAllRetiredVerbs() once and names no retired verb itself, and no
9
+ * other module registers a hidden command (tests/retired-verbs-one-path.test.js). Retiring a verb
10
+ * is one row in RETIRED_VERBS.
11
+ */
12
+
13
+ import chalk from 'chalk';
14
+ import { TOP_LEVEL_API_URL_REMEDY } from '../workspace-config.js';
15
+ import { MICROSERVICE_AUTH_MODEL } from './microservice-auth-model.js';
16
+ import { CANONICAL_KB_SYNC, CANONICAL_KB_CREATE } from './kb-sync-surface.js';
17
+
18
+ const kbSyncRefusal = (invocation) =>
19
+ `❌ \`${invocation}\` has been REMOVED. There is no replacement flag and no fallback.\n\n` +
20
+ `Use the one canonical KB sync surface:\n` +
21
+ ` ${CANONICAL_KB_SYNC} -a <app_id> [-k <kb_name>]\n\n` +
22
+ `It syncs from a git-tracked corpus manifest (.descix/manifests/), which the removed ` +
23
+ `path could not do. If the KB does not exist yet, create it first with ` +
24
+ `\`${CANONICAL_KB_CREATE}\`.`;
25
+ const KB_SYNC_DESCRIPTION = `REMOVED — use \`${CANONICAL_KB_SYNC}\``;
26
+
27
+ /**
28
+ * Every retired verb. `invocation` is what a user types; `parent` is the id of the commander
29
+ * parent it hangs off (a root passed to registerAllRetiredVerbs, or an earlier row); `refusal`
30
+ * renders the full text printed on refusal.
31
+ */
32
+ export const RETIRED_VERBS = Object.freeze([
33
+ // The KB sync surfaces superseded by `descix kb corpus sync`.
34
+ { id: 'sync', parent: 'program', name: 'sync', invocation: 'descix sync',
35
+ description: KB_SYNC_DESCRIPTION, refusal: kbSyncRefusal },
36
+ { id: 'sync.kb', parent: 'sync', name: 'kb', invocation: 'descix sync kb',
37
+ description: KB_SYNC_DESCRIPTION, refusal: kbSyncRefusal },
38
+ { id: 'kb.chunk', parent: 'kb', name: 'chunk', invocation: 'descix kb chunk',
39
+ description: KB_SYNC_DESCRIPTION, refusal: kbSyncRefusal },
40
+ { id: 'kb.sync', parent: 'kb', name: 'sync', invocation: 'descix kb sync',
41
+ description: KB_SYNC_DESCRIPTION, refusal: kbSyncRefusal },
42
+ // It assigned a top-level `apiUrl` that save() never serialized and printed success over it.
43
+ { id: 'config.set-url', parent: 'config', name: 'set-url', invocation: 'descix config set-url',
44
+ description: 'retired — see error text',
45
+ refusal: () =>
46
+ '"descix config set-url" is retired: it never wrote the origin it reported (a retired top-level\n' +
47
+ 'key that nothing reads). Set the API origin where it is read, env.apiUrl, with one of:\n' +
48
+ ` ${TOP_LEVEL_API_URL_REMEDY}` },
49
+ // There is no per-app microservice delegate key (CEO-D-2026-06-02-APP-DATA-PLANE).
50
+ { id: 'microservice.register-delegate', parent: 'microservice', name: 'register-delegate',
51
+ invocation: 'descix microservice register-delegate', description: 'removed — see error text',
52
+ refusal: () =>
53
+ '"descix microservice register-delegate" has been removed: there is no per-app microservice ' +
54
+ 'delegate key.\n' + MICROSERVICE_AUTH_MODEL },
55
+ ]);
56
+
57
+ /** Implementations deleted with the KB sync surfaces. No exported symbol survives without a caller. */
58
+ export const DELETED_KB_SYNC_SYMBOLS = Object.freeze([
59
+ 'runKbChunk',
60
+ 'runKbSync',
61
+ 'runKbBuild',
62
+ 'runKbStatus',
63
+ 'runKbCompare',
64
+ 'updateKB',
65
+ ]);
66
+
67
+ /** The exact text a retired verb prints. */
68
+ export function retiredVerbRefusal(verb) {
69
+ return verb.refusal(verb.invocation);
70
+ }
71
+
72
+ /** THE refusal: print the verb's text and exit non-zero. Never warns, never falls back. */
73
+ export function refuseRetiredVerb(verb) {
74
+ console.error(chalk.red(`\n${retiredVerbRefusal(verb)}\n`));
75
+ process.exit(1);
76
+ }
77
+
78
+ /**
79
+ * Register every row of RETIRED_VERBS, hidden, under its parent. Each accepts and ignores the old
80
+ * arguments and options so an old script fails with the refusal rather than a commander parse
81
+ * error. helpOption(false) is load-bearing: commander answers `--help` BEFORE the action and exits
82
+ * 0, so without it the most natural way to probe a dead verb is the one way it appears to work.
83
+ *
84
+ * @param {Record<string, import('commander').Command>} roots - seed parents by id, e.g. { program, kb, config, microservice }
85
+ * @returns {Record<string, import('commander').Command>} roots plus each registered verb by id
86
+ */
87
+ export function registerAllRetiredVerbs(roots) {
88
+ const byId = { ...roots };
89
+ for (const verb of RETIRED_VERBS) {
90
+ const parent = byId[verb.parent];
91
+ if (!parent) {
92
+ throw new Error(
93
+ `retired-verbs: no parent '${verb.parent}' available for '${verb.invocation}'. ` +
94
+ 'Seed it in the roots map or order the table so the parent is registered first.'
95
+ );
96
+ }
97
+ byId[verb.id] = parent
98
+ .command(verb.name, { hidden: true })
99
+ .description(verb.description)
100
+ .allowUnknownOption(true)
101
+ .helpOption(false)
102
+ .argument('[args...]', 'ignored')
103
+ .action(() => refuseRetiredVerb(verb));
104
+ }
105
+ return byId;
106
+ }
@@ -13,8 +13,8 @@
13
13
  *
14
14
  * Difference from the CLI: a Cloud Run service has no `.descix/wallet.json` and no TTY. So:
15
15
  * - the developer credential is supplied EXPLICITLY (read from the service's config — the
16
- * GITIGNORED dev-overrides.json in dev / a SECRET in prod, NEVER checked-in
17
- * defaults-config.json, because the signature is a credential), and
16
+ * app's defaults-config, the developer's choice, or Secret Manager; a defaults-config
17
+ * credential ships inside the image and must never be committed to a public repo), and
18
18
  * - on a session-refresh failure it HARD-FAILS (serviceMode) — it never tries interactive
19
19
  * device login.
20
20
  *
@@ -50,15 +50,16 @@ export function createServiceApiClient({ baseUrl, walletAddress, signature, user
50
50
  }
51
51
  if (!walletAddress || typeof walletAddress !== 'string') {
52
52
  throw new Error(
53
- 'createServiceApiClient: walletAddress is required — the developer credential. Put it in the ' +
54
- 'GITIGNORED dev-overrides.json (dev) / a secret (prod), never defaults-config.json. No fallback.'
53
+ 'createServiceApiClient: walletAddress is required — the developer credential. ' +
54
+ 'It may live in the app defaults-config (your choice, e.g. a private repo) or in Secret Manager. ' +
55
+ 'A credential in defaults-config ships inside the image; never commit it to a repo that is or will be public. No fallback.'
55
56
  );
56
57
  }
57
58
  if (!signature || typeof signature !== 'string') {
58
59
  throw new Error(
59
60
  'createServiceApiClient: signature is required — the developer durable credential (from their ' +
60
- '.descix/wallet.json). Put it in the GITIGNORED dev-overrides.json (dev) / a secret (prod), ' +
61
- 'never defaults-config.json. No fallback.'
61
+ '.descix/wallet.json). It may live in the app defaults-config (your choice, e.g. a private repo) or in Secret Manager. ' +
62
+ 'A credential in defaults-config ships inside the image; never commit it to a repo that is or will be public. No fallback.'
62
63
  );
63
64
  }
64
65
 
@@ -103,7 +103,6 @@
103
103
  "config set-gateway-port": [],
104
104
  "config set-powch-url": [],
105
105
  "config set-site-url": [],
106
- "config set-url": [],
107
106
  "config show": [],
108
107
  "credits balance": [
109
108
  "get_credit_balance"
@@ -174,10 +173,6 @@
174
173
  "microservice register": [
175
174
  "register_service"
176
175
  ],
177
- "microservice register-delegate": [
178
- "fetch_my_purchases",
179
- "register_delegate"
180
- ],
181
176
  "microservice reload": [
182
177
  "reload_service_manifests"
183
178
  ],
@@ -5,7 +5,7 @@ import { resolveOrigin } from './origin.js';
5
5
  // The one canonical KB-sync surface, from its owner. The example in requireContext() is a remedy
6
6
  // a stuck developer will copy verbatim, so it must resolve to the live verb rather than a literal
7
7
  // that can go stale where nobody is watching.
8
- import { CANONICAL_KB_SYNC } from './commands/retired-kb-sync.js';
8
+ import { CANONICAL_KB_SYNC } from './commands/kb-sync-surface.js';
9
9
  import { npxCommand } from './invocation.js';
10
10
 
11
11
  /**
@@ -845,15 +845,27 @@ export class WorkspaceConfig {
845
845
  /**
846
846
  * Update an app's site config in env.products[] (or env.platform if it is the platform app).
847
847
  *
848
- * Parallel to setMicroservicePort(), but operates on the entry's site.{} slot's
849
- * static-site fields. This is the canonical write path for site.static — the relative path the
850
- * dev gateway's staticSitePlugin serves at /p/{appId}/ (see createViteProxyConfig:
851
- * site.static is resolved against the app's localPath; "." means the localPath itself).
848
+ * Parallel to setMicroservicePort(), but operates on the entry's site.{} slot. This is the
849
+ * canonical write path for site.static — the relative path the dev gateway's
850
+ * staticSitePlugin serves at /p/{appId}/ (see createViteProxyConfig: site.static is
851
+ * resolved against the app's localPath; "." means the localPath itself) — and for
852
+ * site.port / site.protocol, the dev-server upstream the gateway proxies /p/{appId} to.
852
853
  * Backs the `descix app set-site` command, closing the site.static workspace gap without
853
854
  * hand-editing workspace.json (the org rule forbids hand edits — CEO-D-2026-06-02-SSGPOD-SITE-PREPROD).
854
855
  *
855
- * Mutates site.static and/or site.port. Pass static === null to remove site.static; pass
856
- * port === null to remove site.port. If site.{} becomes empty after removals it is deleted.
856
+ * ONE ACTIVE MODE PER WRITE (measured 2026-10-02, EGPT-Evidence: `set-site --port` then
857
+ * `set-site --static` left BOTH fields, and the gateway silently served the dead dev-server
858
+ * port). Setting `static` without `port` in the same call CLEARS site.port and
859
+ * site.protocol; setting `port` without `static` CLEARS site.static. Passing BOTH in one
860
+ * call is the explicit combined write and keeps both — the gateway then serves the dev
861
+ * server (site.port wins; createViteProxyConfig), stated on the serve banner.
862
+ *
863
+ * site.protocol ("http" | "https", default https when absent) names the scheme of the
864
+ * dev-server upstream. It is meaningless without site.port, and a write that would leave
865
+ * protocol without port is refused loud — no auto-detection, no try-both fallback.
866
+ *
867
+ * Pass null for a field to remove it (what `--unset` does for all three). Removals clear
868
+ * only the named field. If site.{} becomes empty after removals it is deleted.
857
869
  * Persists via save() at the end — same auto-save pattern as setMicroservicePort().
858
870
  * Hard-fails if appId is not mapped in env.platform or env.products.
859
871
  *
@@ -861,37 +873,64 @@ export class WorkspaceConfig {
861
873
  * @param {Object} fields - Fields to set on site.{}
862
874
  * @param {string|null} [fields.static] - Relative static-site path to set, or null to remove site.static
863
875
  * @param {number|string|null} [fields.port] - Site dev-server port to set, or null to remove site.port
876
+ * @param {string|null} [fields.protocol] - Dev-server upstream scheme "http"|"https", or null to remove site.protocol
864
877
  * @returns {Promise<string>} Path to saved config (from save())
865
878
  */
866
879
  async setStaticSite(appId, fields = {}) {
867
880
  if (!appId) throw new Error('appId is required');
868
881
  if (!fields || typeof fields !== 'object') throw new Error('fields object is required');
869
882
 
883
+ const settingStatic = 'static' in fields && fields.static !== null && fields.static !== undefined;
884
+ const settingPort = 'port' in fields && fields.port !== null && fields.port !== undefined;
885
+ const settingProtocol = 'protocol' in fields && fields.protocol !== null && fields.protocol !== undefined;
886
+
887
+ if (settingProtocol && fields.protocol !== 'http' && fields.protocol !== 'https') {
888
+ throw new Error(
889
+ `site.protocol must be "http" or "https" (got ${JSON.stringify(fields.protocol)}). ` +
890
+ `It names the scheme of the app's own dev server behind the gateway's /p/${appId} proxy.`
891
+ );
892
+ }
893
+
870
894
  const entry = this._liveEnvEntry(appId);
871
895
 
872
- if (!entry.site) entry.site = {};
896
+ // Compute the resulting site.{} first, so validation failures write NOTHING.
897
+ const site = { ...(entry.site || {}) };
873
898
 
874
- // site.static — set or remove
875
- if ('static' in fields) {
876
- if (fields.static === null || fields.static === undefined) {
877
- delete entry.site.static;
878
- } else {
879
- entry.site.static = fields.static;
880
- }
899
+ // One active mode per write (see doc comment above).
900
+ if (settingStatic && !settingPort) {
901
+ delete site.port;
902
+ delete site.protocol;
903
+ }
904
+ if (settingPort && !settingStatic) {
905
+ delete site.static;
881
906
  }
882
907
 
883
- // site.port — set or remove (for static+devCommand sites)
908
+ if ('static' in fields) {
909
+ if (fields.static === null || fields.static === undefined) delete site.static;
910
+ else site.static = fields.static;
911
+ }
884
912
  if ('port' in fields) {
885
- if (fields.port === null || fields.port === undefined) {
886
- delete entry.site.port;
887
- } else {
888
- entry.site.port = fields.port;
889
- }
913
+ if (fields.port === null || fields.port === undefined) delete site.port;
914
+ else site.port = fields.port;
915
+ }
916
+ if ('protocol' in fields) {
917
+ if (fields.protocol === null || fields.protocol === undefined) delete site.protocol;
918
+ else site.protocol = fields.protocol;
890
919
  }
891
920
 
892
- // Clean up an empty site.{} so we never leave a bare {} behind
893
- if (Object.keys(entry.site).length === 0) {
921
+ // protocol describes the dev-server upstream — without a port it describes nothing.
922
+ if ('protocol' in site && !('port' in site)) {
923
+ throw new Error(
924
+ `site.protocol is only valid in dev-server mode: set it together with --port ` +
925
+ `(or on an app whose site.port is already set). Nothing was written for ${appId}.`
926
+ );
927
+ }
928
+
929
+ if (Object.keys(site).length === 0) {
930
+ // Clean up an empty site.{} so we never leave a bare {} behind
894
931
  delete entry.site;
932
+ } else {
933
+ entry.site = site;
895
934
  }
896
935
 
897
936
  return this.save();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@descix/cli",
3
- "version": "1.0.18",
3
+ "version": "1.0.20",
4
4
  "description": "DeSciX CLI and MCP Server - Unified command-line interface for DeSciX platform",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,8 +30,8 @@
30
30
  "author": "DeSciX",
31
31
  "license": "SEE LICENSE IN LICENSE.md",
32
32
  "dependencies": {
33
- "@descix/app-sdk": "^0.1.8",
34
- "@descix/sdk": "^2.0.0",
33
+ "@descix/app-sdk": "^0.2.0",
34
+ "@descix/sdk": "^3.0.0",
35
35
  "@modelcontextprotocol/sdk": "^1.25.1",
36
36
  "axios": "^1.7.9",
37
37
  "chalk": "^5.6.2",
@@ -153,7 +153,7 @@ descix kb corpus sync -a myapp -k General
153
153
 
154
154
  ### Calling /apifront from your microservice (data plane)
155
155
 
156
- **The microservice IS the CLI's api-client running in the cloud** (CEO-D-2026-06-02-APP-MICROSERVICE-IS-CLI-CLIENT-WALLET-SIG). To call Core commands (`app_records_*`, `get_app_asset`, `get_asset_upload_token`, RAG, ...) the scaffold's `mcpClient` authenticates to `/apifront` **exactly like the CLI**: it holds the **developer's own durable credential** (`wallet_address` + `signature`) and calls `reconnect_by_wallet` to mint a session — then makes calls **as the developer**. There is NO `register-delegate`, NO `SERVICE_KEY`, NO service account, NO OIDC.
156
+ **The microservice IS the CLI's api-client running in the cloud** (CEO-D-2026-06-02-APP-MICROSERVICE-IS-CLI-CLIENT-WALLET-SIG). To call Core commands (`app_records_*`, `get_app_asset`, `get_asset_upload_token`, RAG, ...) the scaffold's `mcpClient` authenticates to `/apifront` **exactly like the CLI**: it holds the **developer's own durable credential** (`wallet_address` + `signature`) and calls `reconnect_by_wallet` to mint a session — then makes calls **as the developer**. There is NO per-service key, NO service account, NO OIDC.
157
157
 
158
158
  **Provide the developer credential (gitignored / secret — never checked in):**
159
159
  1. Copy `dev-overrides.example.json` → `dev-overrides.json` (this file is `.gitignore`d).
@@ -170,7 +170,7 @@ descix microservice register
170
170
  # then: cp dev-overrides.example.json dev-overrides.json and fill in the developer credential
171
171
  ```
172
172
 
173
- > `descix microservice register-delegate` still exists for genuine **service-slot** mesh calls (one service calling another's tools as a delegate). It is NOT used for the app **data plane** — that authenticates as the developer per the model above.
173
+ **Inbound calls carry the caller, not a service identity** (CEO-D-2026-06-02-APP-DATA-PLANE). `/apifront` forwards each call to your service with the caller's identity injected as `params._descix`, isolated per `app_id`; your handler acts on that caller. `descix microservice register --help` prints this model.
174
174
 
175
175
  ---
176
176
 
@@ -334,9 +334,10 @@ A single-app (non-monorepo) workspace uses the same v2.1 shape with only `env.pl
334
334
  3. Check `.vscode/mcp.json` exists with a `descix` server (this file is skipped, by design, if the DeSciX VS Code extension is installed — it registers MCP natively)
335
335
 
336
336
  ### "401 Unauthorized on a loopback / mesh call"
337
- For the app data plane, your service authenticates AS the developer (wallet_address + signature). A 401 means the
338
- developer credential is missing or wrong: check `DEVELOPER_WALLET_ADDRESS` + `DEVELOPER_SIGNATURE` in the gitignored
339
- `dev-overrides.json` (dev) / the secret (prod). Then restart the service.
337
+ Outbound, your service authenticates AS the developer (wallet_address + signature) through `createServiceApiClient`. A 401
338
+ means the developer credential is missing or wrong: check `DEVELOPER_WALLET_ADDRESS` + `DEVELOPER_SIGNATURE` in the
339
+ gitignored `dev-overrides.json` (dev) / the secret (prod). Then restart the service. Inbound, the caller's identity
340
+ arrives as `params._descix`; no key is provisioned for your service.
340
341
  Discover it via `descix tell-me-how "my service gets 401 on a loopback call"`.
341
342
 
342
343
  ### "Session expired"
@@ -8,7 +8,7 @@ import express from 'express';
8
8
  import http from 'http';
9
9
  import cors from 'cors';
10
10
  import { initializeServiceConfig, utils } from './services/utils.js';
11
- import { killExistingProcess } from '@descix/cloud-core';
11
+ import { killExistingProcess, mountMeshApi } from '@descix/cloud-core';
12
12
  import apiRouter from './services/apiFront.js';
13
13
 
14
14
  const app = express();
@@ -72,8 +72,12 @@ initializeServiceConfig().then(() => {
72
72
  res.status(200).send('OK');
73
73
  });
74
74
 
75
- // Mount API Router
76
- app.use('/api', apiRouter);
75
+ // Mount the API router on /api — the surface the platform proxies mesh commands to. The
76
+ // mount is @descix/cloud-core's: it puts the signed-context verifier in front of the router,
77
+ // in the mode config resolved at boot. A deployed service boots only with
78
+ // MESH_CTX_VERIFY_MODE=enforce (defaults-config.json), so an unsigned request is refused
79
+ // before it reaches a handler. Do not mount /api yourself.
80
+ mountMeshApi(app, apiRouter);
77
81
 
78
82
  // Start Server
79
83
  if (!port || port === 'auto') {
@@ -3,5 +3,6 @@
3
3
  "LOCAL_PORT": "auto",
4
4
  "CORE_API_URL": "https://localhost:4000/apifront",
5
5
  "community_id": "{{COMMUNITY_ID}}",
6
- "app_id": "{{APP_ID}}"
6
+ "app_id": "{{APP_ID}}",
7
+ "MESH_CTX_VERIFY_MODE": "enforce"
7
8
  }
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "_comment": "Copy this file to dev-overrides.json (GITIGNORED) and fill in the developer credential. The microservice authenticates to /apifront AS the developer — exactly like the CLI — using these. In prod these come from a SECRET, never from a checked-in file. defaults-config.json must NOT contain the signature (it is a credential).",
3
3
  "DEVELOPER_WALLET_ADDRESS": "0xYOUR_WALLET_ADDRESS",
4
- "DEVELOPER_SIGNATURE": "PASTE_YOUR_DURABLE_SIGNATURE_FROM_.descix/wallet.json"
4
+ "DEVELOPER_SIGNATURE": "PASTE_YOUR_DURABLE_SIGNATURE_FROM_.descix/wallet.json",
5
+ "_MESH_CTX_VERIFY_MODE_comment": "defaults-config.json sets MESH_CTX_VERIFY_MODE to enforce, the only value a deployed service boots with. On your own machine you may relax it here: \"warn\" (verify and log, never refuse) or \"\" (no verification; every request is stamped meshContextVerified=false). Never set MESH_CTX_PUBLIC_KEY or MESH_CTX_KEY_ID in any config file: the platform key is resolved by @descix/cloud-core and a copy refuses boot."
5
6
  }
@@ -6,7 +6,8 @@
6
6
  "healthEndpoint": "/health",
7
7
  "debugPort": 3003,
8
8
  "app_id": "{{APP_ID}}",
9
- "community_id": "{{COMMUNITY_ID}}"
9
+ "community_id": "{{COMMUNITY_ID}}",
10
+ "runtime": { "profile": "default", "system_packages": [] }
10
11
  },
11
12
  "commands": {
12
13
  "sample_hello": {
@@ -11,7 +11,7 @@
11
11
  "gen-jwt-secret": "node -e 'console.log(require(\"crypto\").randomBytes(32).toString(\"hex\"))'"
12
12
  },
13
13
  "dependencies": {
14
- "@descix/cloud-core": "^1.0.0",
14
+ "@descix/cloud-core": "^1.2.0",
15
15
  "@descix/platform-api": "^2.0.0",
16
16
  "@google-cloud/secret-manager": "^5.0.1",
17
17
  "@google-cloud/storage": "^7.7.0",
@@ -19,7 +19,7 @@
19
19
  "cors": "^2.8.5",
20
20
  "dotenv": "^16.3.1",
21
21
  "express": "^4.18.2",
22
- "@descix/sdk": "^2.0.0",
22
+ "@descix/sdk": "^3.0.0",
23
23
  "@descix/cli": "^1.0.0"
24
24
  },
25
25
  "devDependencies": {
@@ -25,8 +25,9 @@ descix microservice register # register the service manifest
25
25
  > + `DEVELOPER_SIGNATURE` (the developer's own wallet credential, from
26
26
  > `.descix/wallet.json`) and calls `reconnect_by_wallet` to mint a session —
27
27
  > exactly like the CLI. Put the credential in the GITIGNORED `dev-overrides.json`
28
- > (dev) / a secret (prod), NEVER `defaults-config.json`. There is NO
29
- > `register-delegate`, NO `SERVICE_KEY`, NO service account for the data plane.
28
+ > (dev) / a secret (prod), NEVER `defaults-config.json`. There is NO per-service
29
+ > key and NO service account for the data plane. Inbound calls carry the caller's
30
+ > identity as `params._descix`, isolated per app_id (CEO-D-2026-06-02-APP-DATA-PLANE).
30
31
  > **HTTP 401?** The developer credential is missing or wrong — check those two keys.
31
32
 
32
33
  ## Prerequisites
@@ -1,112 +0,0 @@
1
- /**
2
- * ONE OWNER for the retirement of every superseded KB-sync surface.
3
- *
4
- * CEO 2026-08-29: "there should never be a deprecated path and then everything else is a
5
- * waterfall of problems." The CLI exposes exactly ONE kb sync surface — `descix kb corpus
6
- * sync` (git-synced manifest). Every other sync path is DELETED: its implementation is gone
7
- * and its old name exits NON-ZERO naming the replacement. That is not a compat fence — a
8
- * compat fence is a path that still WORKS under an old name. These do not work at all.
9
- *
10
- * Why a module and not a literal at each site: two derivations of one fact is the general
11
- * form of mirror drift. The retired set is enumerated ONCE here. bin/descix.js registers by
12
- * ITERATING this list via registerAllRetiredKbSync() -- it does not name any retired verb
13
- * itself -- and tests/kb-sync-single-surface.test.js asserts against the same list. So the
14
- * drift is closed in BOTH directions: a surface added to the list is registered, and a
15
- * surface cannot be registered without being in the list.
16
- */
17
-
18
- /** The single canonical KB sync surface. Named in every refusal. */
19
- export const CANONICAL_KB_SYNC = 'descix kb corpus sync';
20
-
21
- /** The verb that creates a KB — `kb corpus sync`'s dependency (I4). */
22
- export const CANONICAL_KB_CREATE = 'descix app init --kb <kb_name>';
23
-
24
- /**
25
- * Every KB-sync surface retired by ws-devplane-cli-kb-surface-single-canonical-sync.
26
- * `invocation` is what a user types; `parent` is the commander parent it hung off.
27
- */
28
- export const RETIRED_KB_SYNC_SURFACES = [
29
- { id: 'sync', parent: 'program', name: 'sync', invocation: 'descix sync' },
30
- { id: 'sync.kb', parent: 'sync', name: 'kb', invocation: 'descix sync kb' },
31
- { id: 'kb.chunk', parent: 'kb', name: 'chunk', invocation: 'descix kb chunk' },
32
- { id: 'kb.sync', parent: 'kb', name: 'sync', invocation: 'descix kb sync' },
33
- ];
34
-
35
- /** Implementations deleted with those surfaces. No exported symbol survives without a caller (I3). */
36
- export const DELETED_KB_SYNC_SYMBOLS = [
37
- 'runKbChunk',
38
- 'runKbSync',
39
- 'runKbBuild',
40
- 'runKbStatus',
41
- 'runKbCompare',
42
- 'updateKB',
43
- ];
44
-
45
- /**
46
- * The single refusal message. Fails loud and names the replacement — never warns, never falls back.
47
- * @param {string} invocation - the retired invocation the user typed, e.g. 'descix kb chunk'
48
- * @returns {string}
49
- */
50
- export function retiredKbSyncMessage(invocation) {
51
- return (
52
- `\`${invocation}\` has been REMOVED. There is no replacement flag and no fallback.\n\n` +
53
- `Use the one canonical KB sync surface:\n` +
54
- ` ${CANONICAL_KB_SYNC} -a <app_id> [-k <kb_name>]\n\n` +
55
- `It syncs from a git-tracked corpus manifest (.descix/manifests/), which the removed ` +
56
- `path could not do. If the KB does not exist yet, create it first with ` +
57
- `\`${CANONICAL_KB_CREATE}\`.`
58
- );
59
- }
60
-
61
- /**
62
- * Register a retired invocation so it exits NON-ZERO naming the canonical surface.
63
- * Accepts and ignores any args/options the old surface took, so an old script fails with
64
- * this message rather than a confusing commander parse error.
65
- *
66
- * @param {import('commander').Command} parent - commander parent to attach to
67
- * @param {string} name - the retired command name
68
- * @param {string} invocation - full invocation used in the message
69
- * @param {(s:string)=>string} red - chalk.red or equivalent
70
- */
71
- export function registerRetiredKbSync(parent, name, invocation, red) {
72
- return parent
73
- .command(name, { hidden: true })
74
- .description(`REMOVED — use \`${CANONICAL_KB_SYNC}\``)
75
- .allowUnknownOption(true)
76
- // helpOption(false) is load-bearing: commander intercepts `--help` BEFORE the action and
77
- // exits 0. A retired surface must exit NON-ZERO however it is invoked — `descix kb chunk`
78
- // and `descix kb chunk --help` must both fail and name the replacement. Without this, the
79
- // most natural way a user probes a dead verb is the one way it appears to still work.
80
- .helpOption(false)
81
- .argument('[args...]', 'ignored')
82
- .action(() => {
83
- console.error(red(`\n❌ ${retiredKbSyncMessage(invocation)}\n`));
84
- process.exit(1);
85
- });
86
- }
87
-
88
- /**
89
- * Register EVERY retired surface by iterating RETIRED_KB_SYNC_SURFACES.
90
- *
91
- * This is what makes the one-owner claim true rather than merely asserted: bin/descix.js
92
- * calls this once and never names a retired verb itself, so a retired surface cannot be
93
- * registered without appearing in the list above.
94
- *
95
- * @param {Record<string, import('commander').Command>} roots - seed parents, e.g. { program, kb }
96
- * @param {(s:string)=>string} red - chalk.red or equivalent
97
- * @returns {Record<string, import('commander').Command>} roots plus each registered surface by id
98
- */
99
- export function registerAllRetiredKbSync(roots, red) {
100
- const byId = { ...roots };
101
- for (const s of RETIRED_KB_SYNC_SURFACES) {
102
- const parent = byId[s.parent];
103
- if (!parent) {
104
- throw new Error(
105
- `retired-kb-sync: no parent '${s.parent}' available for '${s.invocation}'. ` +
106
- 'Seed it in the roots map or order the list so the parent is registered first.'
107
- );
108
- }
109
- byId[s.id] = registerRetiredKbSync(parent, s.name, s.invocation, red);
110
- }
111
- return byId;
112
- }