castle-web-cli 0.4.166 → 0.4.168

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.
Files changed (112) hide show
  1. package/dist/agent.d.ts +1 -2
  2. package/dist/agent.js +22 -13
  3. package/dist/api.js +16 -1
  4. package/dist/castle-host/host.d.ts +2 -0
  5. package/dist/castle-host/host.js +17 -3
  6. package/dist/imports.js +19 -4
  7. package/dist/localPaths.d.ts +2 -0
  8. package/dist/localPaths.js +6 -0
  9. package/dist/platformDoc.js +1 -1
  10. package/dist/save-deck.js +13 -1
  11. package/dist/serve.js +7 -1
  12. package/dist/serveSecurity.d.ts +12 -0
  13. package/dist/serveSecurity.js +42 -0
  14. package/dist/shell/assets/index-CVHj503j.css +1 -0
  15. package/dist/shell/assets/index-D-qJ5VGp.js +425 -0
  16. package/dist/shell/index.html +2 -2
  17. package/kits/base/castle.json +64 -2
  18. package/kits/base/editors/ImageViewer.js +259 -0
  19. package/kits/base/editors/MediaPlayer.js +90 -0
  20. package/kits/base/editors/imageViewer.css +95 -0
  21. package/kits/base/editors/mediaPlayer.css +58 -0
  22. package/kits/base/sdk/README.md +3 -1
  23. package/kits/base/sdk/castle.d.ts +1 -0
  24. package/kits/base/sdk/commands.d.ts +7 -0
  25. package/kits/base/sdk/multiplayer.d.ts +6 -0
  26. package/kits/base/sdk/multiplayer.js +6 -0
  27. package/kits/blank/.castle/agent/children.json +1 -0
  28. package/kits/blank/.castle/agent/messages.json +9 -0
  29. package/kits/blank/.castle/agent/pending-sends.json +1 -0
  30. package/kits/blank/.castle/agent/tasks/index.md +2 -0
  31. package/kits/blank/.castle/docs/platform.md +36 -0
  32. package/kits/blank/node_modules/.vite/deps/_metadata.json +8 -0
  33. package/kits/blank/node_modules/.vite/deps/package.json +3 -0
  34. package/kits/multiplayer-2d/CLAUDE.md +6 -0
  35. package/kits/multiplayer-2d/castle.json +3 -3
  36. package/kits/multiplayer-2d/code/systems/multiplayer.js +5 -0
  37. package/kits/multiplayer-2d/package-lock.json +4 -1
  38. package/kits/multiplayer-3d/CLAUDE.md +6 -1
  39. package/kits/multiplayer-3d/castle.json +3 -3
  40. package/kits/multiplayer-3d/code/systems/multiplayer.js +5 -0
  41. package/kits/multiplayer-3d/package-lock.json +1 -1
  42. package/kits/physics-2d/behaviors/Joints.jsx +11 -5
  43. package/kits/physics-2d/behaviors/Sprite.jsx +6 -21
  44. package/kits/physics-2d/castle.json +1 -61
  45. package/kits/physics-2d/editors/BlueprintLibrary.jsx +8 -68
  46. package/kits/physics-2d/editors/SingleEditor.jsx +3 -10
  47. package/kits/physics-2d/editors/editorRegistry.jsx +36 -2
  48. package/kits/physics-2d/engine/art.js +25 -1
  49. package/kits/physics-2d/engine/artThumbnail.jsx +92 -0
  50. package/kits/physics-2d/engine/files.js +4 -15
  51. package/kits/physics-2d/engine/spriteField.jsx +176 -0
  52. package/kits/physics-2d/engine/ui.module.css +136 -0
  53. package/kits/physics-2d/package-lock.json +5 -0
  54. package/kits/physics-2d/pnpm-lock.yaml +96 -3
  55. package/kits/physics-3d/castle.json +1 -1
  56. package/kits/physics-3d/package-lock.json +1 -1
  57. package/kits/physics-3d/pnpm-lock.yaml +88 -3
  58. package/kits/real-time/CLAUDE.md +5 -0
  59. package/kits/real-time/castle.json +2 -2
  60. package/kits/real-time/code/client/connection.js +4 -0
  61. package/kits/real-time/code/client/gameHooks.js +3 -0
  62. package/kits/real-time/code/client/soloBadge.js +71 -0
  63. package/kits/real-time/package.json +3 -0
  64. package/kits/turn-based/castle.json +1 -1
  65. package/kits/turn-based/package-lock.json +1 -1
  66. package/package.json +1 -1
  67. package/dist/shell/assets/index-CG9TrvCm.js +0 -444
  68. package/dist/shell/assets/index-CIRBt1qf.css +0 -1
  69. package/kits/base/node_modules/.package-lock.json +0 -28
  70. package/kits/base/node_modules/castle-web-fonts/README.md +0 -114
  71. package/kits/base/node_modules/castle-web-fonts/dist/catalog.d.ts +0 -18
  72. package/kits/base/node_modules/castle-web-fonts/dist/catalog.js +0 -142
  73. package/kits/base/node_modules/castle-web-fonts/dist/face.d.ts +0 -1
  74. package/kits/base/node_modules/castle-web-fonts/dist/face.js +0 -52
  75. package/kits/base/node_modules/castle-web-fonts/dist/faces/Bore.d.ts +0 -3
  76. package/kits/base/node_modules/castle-web-fonts/dist/faces/Bore.js +0 -10
  77. package/kits/base/node_modules/castle-web-fonts/dist/faces/CourierPrime.d.ts +0 -3
  78. package/kits/base/node_modules/castle-web-fonts/dist/faces/CourierPrime.js +0 -10
  79. package/kits/base/node_modules/castle-web-fonts/dist/faces/DMSans.d.ts +0 -3
  80. package/kits/base/node_modules/castle-web-fonts/dist/faces/DMSans.js +0 -10
  81. package/kits/base/node_modules/castle-web-fonts/dist/faces/Glacier.d.ts +0 -3
  82. package/kits/base/node_modules/castle-web-fonts/dist/faces/Glacier.js +0 -10
  83. package/kits/base/node_modules/castle-web-fonts/dist/faces/HelicoCentrica.d.ts +0 -3
  84. package/kits/base/node_modules/castle-web-fonts/dist/faces/HelicoCentrica.js +0 -10
  85. package/kits/base/node_modules/castle-web-fonts/dist/faces/Piazzolla.d.ts +0 -3
  86. package/kits/base/node_modules/castle-web-fonts/dist/faces/Piazzolla.js +0 -10
  87. package/kits/base/node_modules/castle-web-fonts/dist/faces/Synco.d.ts +0 -3
  88. package/kits/base/node_modules/castle-web-fonts/dist/faces/Synco.js +0 -10
  89. package/kits/base/node_modules/castle-web-fonts/dist/faces/Tektur.d.ts +0 -3
  90. package/kits/base/node_modules/castle-web-fonts/dist/faces/Tektur.js +0 -10
  91. package/kits/base/node_modules/castle-web-fonts/dist/faces/YatraOne.d.ts +0 -3
  92. package/kits/base/node_modules/castle-web-fonts/dist/faces/YatraOne.js +0 -10
  93. package/kits/base/node_modules/castle-web-fonts/dist/index.d.ts +0 -16
  94. package/kits/base/node_modules/castle-web-fonts/dist/index.js +0 -15
  95. package/kits/base/node_modules/castle-web-fonts/licenses/CC-BY-ND-4.0.txt +0 -393
  96. package/kits/base/node_modules/castle-web-fonts/licenses/FONTS.md +0 -64
  97. package/kits/base/node_modules/castle-web-fonts/licenses/OFL-1.1.txt +0 -95
  98. package/kits/base/node_modules/castle-web-fonts/package.json +0 -47
  99. package/kits/base/node_modules/castle-web-fonts/woff2/Bore.woff2 +0 -0
  100. package/kits/base/node_modules/castle-web-fonts/woff2/CourierPrime.woff2 +0 -0
  101. package/kits/base/node_modules/castle-web-fonts/woff2/DMSans.woff2 +0 -0
  102. package/kits/base/node_modules/castle-web-fonts/woff2/Glacier.woff2 +0 -0
  103. package/kits/base/node_modules/castle-web-fonts/woff2/HelicoCentrica.woff2 +0 -0
  104. package/kits/base/node_modules/castle-web-fonts/woff2/Piazzolla.woff2 +0 -0
  105. package/kits/base/node_modules/castle-web-fonts/woff2/SOURCE.txt +0 -2
  106. package/kits/base/node_modules/castle-web-fonts/woff2/Synco.woff2 +0 -0
  107. package/kits/base/node_modules/castle-web-fonts/woff2/Tektur.woff2 +0 -0
  108. package/kits/base/node_modules/castle-web-fonts/woff2/YatraOne.woff2 +0 -0
  109. package/kits/physics-2d/editors/ImageViewer.jsx +0 -206
  110. package/kits/physics-2d/editors/MediaPlayer.jsx +0 -57
  111. package/kits/physics-2d/editors/mediaFile.js +0 -31
  112. package/kits/physics-2d/editors/mediaViewer.module.css +0 -128
package/dist/agent.d.ts CHANGED
@@ -3,8 +3,6 @@ import { Duplex } from 'stream';
3
3
  import type { ORReasoningEffort, ORRoutingMode } from './native/openrouter.js';
4
4
  import { type PlaytestToolResult } from './native/playtest.js';
5
5
  export declare const AGENT_WS_PATH = "/__castle/agent";
6
- export declare const AGENT_ATTACHMENT_PREFIX = "/__castle/agent/attachments/";
7
- export declare const AGENT_PLAYTEST_PREFIX = "/__castle/agent/playtest/";
8
6
  export declare const AGENT_MODEL_CAPS_PREFIX = "/__castle/agent/model-caps";
9
7
  export type AgentBackend = 'cursor' | 'claude' | 'smith';
10
8
  export type ClaudeModel = 'sonnet' | 'opus' | 'fable' | 'openrouter';
@@ -90,6 +88,7 @@ export declare function createAgentServer(opts: {
90
88
  port?: number;
91
89
  playtestServeUrl?: string;
92
90
  browserContentUrl?: () => string | null;
91
+ signPath?: (reqPath: string) => string;
93
92
  restart?: () => void;
94
93
  authorizeUpgrade?: (req: http.IncomingMessage, endpoint: 'agent') => boolean;
95
94
  }): AgentServer;
package/dist/agent.js CHANGED
@@ -24,7 +24,7 @@ import * as path from 'path';
24
24
  import { nanoid } from 'nanoid';
25
25
  import { WebSocketServer } from 'ws';
26
26
  import { rawDataToString } from './rawData.js';
27
- import { PLAN_FILE } from './localPaths.js';
27
+ import { AGENT_ATTACHMENT_PREFIX, AGENT_PLAYTEST_PREFIX, PLAN_FILE } from './localPaths.js';
28
28
  import { applyPlanOps, buildRouterPrompt, buildTaskPrompt, parsePlanOps, planOpenQuestionLines, truncateToBytes, userTurnInstruction, CLAUDE_TASK_SYSTEM_REMINDER, } from './agent-prompts.js';
29
29
  import { readCastleJson } from './castleJson.js';
30
30
  import { checkOpenrouterKey, checkOpenrouterModel, openrouterCatalogEntry, primeOpenrouterCatalog, } from './openrouter-catalog.js';
@@ -39,10 +39,6 @@ import { createPlaywrightPlaytestExecutor } from './native/playtest-executor.js'
39
39
  import { createRemotePlaytestExecutor } from './native/playtest-remote.js';
40
40
  import { runPlaytest, } from './native/playtest.js';
41
41
  export const AGENT_WS_PATH = '/__castle/agent';
42
- export const AGENT_ATTACHMENT_PREFIX = '/__castle/agent/attachments/';
43
- // Playtest frame PNGs (tasks/<id>/playtest/<file>.png), served for the
44
- // finished-task card's thumbnails -- see makePlaytestFrameHandler.
45
- export const AGENT_PLAYTEST_PREFIX = '/__castle/agent/playtest/';
46
42
  // Same-origin proxy for OpenRouter model capabilities (avoids browser CORS
47
43
  // against openrouter.ai). GET ?model=<slug> -> ModelCaps JSON. Powers the
48
44
  // settings popover's dynamic reasoning-effort / provider-tier pickers.
@@ -3077,7 +3073,7 @@ function asPromptTask(task) {
3077
3073
  blockedBy: task.status === 'blocked' ? task.blockedBy : undefined,
3078
3074
  };
3079
3075
  }
3080
- function asClientTask(task) {
3076
+ function asClientTask(task, signPath) {
3081
3077
  return {
3082
3078
  id: task.id,
3083
3079
  title: task.title,
@@ -3094,7 +3090,20 @@ function asClientTask(task) {
3094
3090
  rejected: task.rejected,
3095
3091
  errorCopy: task.errorCopy,
3096
3092
  errorDetail: task.errorDetail,
3097
- playtestFrames: (task.playtestFrames ?? []).map((rel) => `${AGENT_PLAYTEST_PREFIX}${task.id}/${path.basename(rel)}`),
3093
+ playtestFrames: (task.playtestFrames ?? []).map((rel) => clientUrl(`${AGENT_PLAYTEST_PREFIX}${task.id}/${path.basename(rel)}`, signPath)),
3094
+ };
3095
+ }
3096
+ function clientUrl(reqPath, signPath) {
3097
+ return signPath ? `${reqPath}?t=${signPath(reqPath)}` : reqPath;
3098
+ }
3099
+ // A sent message renders its pasted images as `<img src>`, which cannot carry
3100
+ // the serve token any more than a playtest frame can -- see ServeSecurity.signPath.
3101
+ function asClientMessage(message, signPath) {
3102
+ if (!message.attachments?.length)
3103
+ return message;
3104
+ return {
3105
+ ...message,
3106
+ attachments: message.attachments.map((name) => clientUrl(`${AGENT_ATTACHMENT_PREFIX}${name}`, signPath)),
3098
3107
  };
3099
3108
  }
3100
3109
  function createTaskFeeds(broadcast) {
@@ -3133,7 +3142,7 @@ function createMessageThinking(broadcast) {
3133
3142
  }
3134
3143
  return { append, snapshot };
3135
3144
  }
3136
- function createMessageLog(messagesPath, broadcast, welcomeMessage) {
3145
+ function createMessageLog(messagesPath, broadcast, welcomeMessage, forClient) {
3137
3146
  const loaded = readJsonFile(messagesPath) ?? [];
3138
3147
  const messages = loaded
3139
3148
  .filter((m) => m.text.trim() !== '' || m.role === 'user')
@@ -3154,7 +3163,7 @@ function createMessageLog(messagesPath, broadcast, welcomeMessage) {
3154
3163
  function add(message) {
3155
3164
  messages.push(message);
3156
3165
  persist();
3157
- broadcast({ type: 'message-add', message });
3166
+ broadcast({ type: 'message-add', message: forClient(message) });
3158
3167
  }
3159
3168
  function addLog(text) {
3160
3169
  add({ id: nanoid(8), role: 'log', text, at: nowIso(), status: 'done' });
@@ -4215,7 +4224,7 @@ export function createAgentServer(opts) {
4215
4224
  socket.send(payload);
4216
4225
  }
4217
4226
  }
4218
- const log = createMessageLog(messagesPath, broadcast, welcomeMessage);
4227
+ const log = createMessageLog(messagesPath, broadcast, welcomeMessage, (message) => asClientMessage(message, opts.signPath));
4219
4228
  const messages = log.messages;
4220
4229
  const addLog = (text) => log.addLog(text);
4221
4230
  // Which CLI backs the router and the task agents -- independently
@@ -4265,7 +4274,7 @@ export function createAgentServer(opts) {
4265
4274
  playtest,
4266
4275
  restart: opts.restart,
4267
4276
  // Task lifecycle stays on the board only -- log lines for it were spam.
4268
- onUpdate: (task) => broadcast({ type: 'task-update', task: asClientTask(task) }),
4277
+ onUpdate: (task) => broadcast({ type: 'task-update', task: asClientTask(task, opts.signPath) }),
4269
4278
  onStarted: () => undefined,
4270
4279
  onRetry: (task, attempt) => addLog(`agent died, retrying (${attempt}/${MAX_TASK_ATTEMPTS}): ${task.title}`),
4271
4280
  onFinished: (task) => taskFeeds.map.delete(task.id),
@@ -4345,8 +4354,8 @@ export function createAgentServer(opts) {
4345
4354
  clients.add(socket);
4346
4355
  const hello = {
4347
4356
  type: 'hello',
4348
- messages,
4349
- tasks: taskStore.sorted().map(asClientTask),
4357
+ messages: messages.map((message) => asClientMessage(message, opts.signPath)),
4358
+ tasks: taskStore.sorted().map((task) => asClientTask(task, opts.signPath)),
4350
4359
  settings,
4351
4360
  feeds: Object.fromEntries(taskFeeds.map),
4352
4361
  thinking: messageThinking.snapshot(),
package/dist/api.js CHANGED
@@ -23,7 +23,22 @@ export async function graphql(query, variables, options = {}) {
23
23
  body: JSON.stringify(variables ? { query, variables } : { query }),
24
24
  signal,
25
25
  });
26
- return res.json();
26
+ const body = await res.text();
27
+ try {
28
+ return JSON.parse(body);
29
+ }
30
+ catch {
31
+ // A body that isn't JSON never reached the GraphQL layer at all -- a sandbox
32
+ // proxy refusing the operation, a gateway error page. Parsing it blind reports
33
+ // that as `Unexpected token 'G'`, which hid a broken publish for days.
34
+ throw new Error(`Castle API ${res.status}: ${firstLine(body)}`);
35
+ }
36
+ }
37
+ function firstLine(body) {
38
+ const line = body.split('\n', 1)[0].trim();
39
+ if (!line)
40
+ return '(empty response body)';
41
+ return line.length > 200 ? `${line.slice(0, 200)}...` : line;
27
42
  }
28
43
  export async function runBrowserJob(contentGrant, operation, signal) {
29
44
  const data = await graphql(`mutation($contentGrant: String!, $operation: Json!) {
@@ -62,6 +62,8 @@ export interface MultiplayerGetSessionResult {
62
62
  url: string | null;
63
63
  nonce: string | null;
64
64
  sessionId: string | null;
65
+ /** Set when `status` is 'solo'; null otherwise. */
66
+ soloReason: 'private_deck' | 'no_server_bundle' | 'unavailable' | null;
65
67
  }
66
68
 
67
69
  export interface LeaderboardStreakCredit {
@@ -171,14 +171,27 @@ function multiplayerSessionRequest(params) {
171
171
  function multiplayerSessionResult(value) {
172
172
  const sessionId = asOptionalString(value?.sessionId);
173
173
  if (value?.isSingleUserSession === true) {
174
- return { status: "solo", url: null, nonce: null, sessionId };
174
+ return {
175
+ status: "solo",
176
+ url: null,
177
+ nonce: null,
178
+ sessionId,
179
+ soloReason: asSoloReason(value?.soloReason),
180
+ };
175
181
  }
176
182
  const url = asOptionalString(value?.url);
177
183
  const nonce = asOptionalString(value?.nonce);
178
184
  if (!url || !nonce) {
179
- return { status: "unavailable", url: null, nonce: null, sessionId };
185
+ return { status: "unavailable", url: null, nonce: null, sessionId, soloReason: null };
180
186
  }
181
- return { status: "ok", url, nonce, sessionId };
187
+ return { status: "ok", url, nonce, sessionId, soloReason: null };
188
+ }
189
+ // A reason this SDK does not know reads as the catch-all: the deck still
190
+ // learns it is solo, just not why. This does not cover a ghost older than the
191
+ // field: that rejects the whole query as invalid, so ghost deploys before any
192
+ // host that carries this selection.
193
+ function asSoloReason(value) {
194
+ return value === "private_deck" || value === "no_server_bundle" ? value : "unavailable";
182
195
  }
183
196
  // Dispatch a platform/capability command to the host's platformHandler. No
184
197
  // handler → the command's normalized "unavailable" outcome (a SUCCESS, not an
@@ -1092,6 +1105,7 @@ const CAULDRON_MULTIPLAYER_SESSION_QUERY = `
1092
1105
  isSingleUserSession
1093
1106
  url
1094
1107
  nonce
1108
+ soloReason
1095
1109
  }
1096
1110
  }
1097
1111
  `;
package/dist/imports.js CHANGED
@@ -664,11 +664,22 @@ function importOptsIntoAutoUpdate(deckDir, alias) {
664
664
  // publish (the framework kits -- the same account `init` qualifies their aliases
665
665
  // with).
666
666
  //
667
- // Deliberately not a setting: nothing a deck or a person can flip, since that
668
- // would be the leash undoing itself. The env var is a test seam -- the automated
669
- // check can't publish a deck to the castle account (nothing should), so it
670
- // substitutes which account counts and exercises both branches for real.
667
+ // Deliberately not a deck setting: nothing in castle.json a person can flip,
668
+ // since that would be the leash undoing itself. Process env is the escape
669
+ // hatch for local kit work and tests:
670
+ // - CASTLE_NO_AUTO_UPDATE=1 -- skip serve-start auto-update entirely (keep
671
+ // whatever is already under imports/, including hand-synced kits from this
672
+ // checkout).
673
+ // - CASTLE_AUTO_UPDATE_TRUSTED_ACCOUNT -- which username counts as trusted
674
+ // (default `castle`). Tests set it to exercise both branches; setting it to
675
+ // a non-matching name also disables updates as a side effect.
671
676
  const AUTO_UPDATE_ACCOUNT = process.env.CASTLE_AUTO_UPDATE_TRUSTED_ACCOUNT ?? 'castle';
677
+ function autoUpdateDisabled() {
678
+ const raw = process.env.CASTLE_NO_AUTO_UPDATE;
679
+ if (raw == null || raw === '')
680
+ return false;
681
+ return raw !== '0' && raw.toLowerCase() !== 'false' && raw.toLowerCase() !== 'no';
682
+ }
672
683
  // Who published a deck. Readable without auth for the same decks webDeckSource
673
684
  // is, so this answers in a sandbox with no login. A lookup that fails answers
674
685
  // "not trusted": the failure direction has to be the one that leaves an import
@@ -689,6 +700,10 @@ async function importIsOnTrustedAccount(deckId) {
689
700
  // checked (offline, no source on the server, a bad archive) stays exactly as it
690
701
  // is pinned, and the serve starts anyway.
691
702
  export async function autoUpdateImports(deckDir) {
703
+ if (autoUpdateDisabled()) {
704
+ console.log('auto-update: skipped (CASTLE_NO_AUTO_UPDATE)');
705
+ return [];
706
+ }
692
707
  const pins = tryReadCastleJson(deckDir)?.imports ?? {};
693
708
  const updated = [];
694
709
  for (const [alias, pin] of Object.entries(pins)) {
@@ -5,4 +5,6 @@ export declare function getSdkPackagePath(): string;
5
5
  export declare function getKitsDir(): string;
6
6
  export declare const COVER_FILE = "preview.png";
7
7
  export declare const PLAN_FILE = "plan.md";
8
+ export declare const AGENT_PLAYTEST_PREFIX = "/__castle/agent/playtest/";
9
+ export declare const AGENT_ATTACHMENT_PREFIX = "/__castle/agent/attachments/";
8
10
  export declare function toPosixPath(filepath: string): string;
@@ -41,6 +41,12 @@ export const COVER_FILE = 'preview.png';
41
41
  // instead. Named here because the serve's file listing has to know it too, to
42
42
  // keep it out of a deck's path curation.
43
43
  export const PLAN_FILE = 'plan.md';
44
+ // Request-path prefixes the agent server serves a task's playtest frame PNGs
45
+ // and a chat message's pasted images under. Named here because ServeSecurity
46
+ // has to know them too -- they are the shell prefixes a signed path may open --
47
+ // and agent.ts already imports that.
48
+ export const AGENT_PLAYTEST_PREFIX = '/__castle/agent/playtest/';
49
+ export const AGENT_ATTACHMENT_PREFIX = '/__castle/agent/attachments/';
44
50
  export function toPosixPath(filepath) {
45
51
  return filepath.split(path.sep).join('/');
46
52
  }
@@ -219,7 +219,7 @@ const COMMON_SECTIONS = `## Imports are read-only
219
219
  \`\`\`
220
220
 
221
221
  \`named\` puts everyone passing the same \`key\` in one session, which is how you build room codes and invite links. \`public\` drops a player into a shared session for the deck; \`party\` uses the Castle party they are already in.
222
- - **Always handle \`conn.isSolo\`.** When a player cannot join — signed out, blocked, private deck, or no capacity free — the SDK returns a working single-player connection instead of throwing. Keep the deck playable on its own rather than gating the whole game behind other people showing up.
222
+ - **Always handle \`conn.isSolo\`.** When a player cannot join — signed out, blocked, private deck, or no capacity free — the SDK returns a working single-player connection instead of throwing. Keep the deck playable on its own rather than gating the whole game behind other people showing up. \`conn.soloReason\` says why: \`private_deck\` and \`no_server_bundle\` are things the creator can fix (make the deck unlisted or public; publish with a server entry), so it is worth telling them in the HUD; \`unavailable\` is everything else.
223
223
  - **What the server may and may not do.** Roughly 128MB of memory, no filesystem that survives, and **no network access at all**: no \`fetch\`, no database, no third-party API. Keep state in memory and send it to clients. A session ends once its last player leaves and its memory goes with it.
224
224
  - **\`session.storage\` is what outlives a session**, and it is the same store the deck's clients see through \`Store\`:
225
225
 
package/dist/save-deck.js CHANGED
@@ -376,7 +376,19 @@ export async function saveDeck(dir, opts = {}) {
376
376
  else {
377
377
  console.log(`Saved "${title}" to Castle.`);
378
378
  }
379
- await traceSaveStage('server_publish', () => publishServerBundle(projectDir, result.deckId));
379
+ // The deck went live above, so anything failing here leaves it skewed: the
380
+ // published client runs against whatever server was last deployed. Say that
381
+ // in the thrown message -- it is the only line the cloud editor gets back
382
+ // (it forwards stderr), and "not valid JSON" reads as a glitch, not as a
383
+ // deck that is now half-published.
384
+ try {
385
+ await traceSaveStage('server_publish', () => publishServerBundle(projectDir, result.deckId));
386
+ }
387
+ catch (e) {
388
+ const reason = e instanceof Error ? e.message : String(e);
389
+ throw new Error(`Server bundle NOT published (${reason}). ` +
390
+ `"${title}" is live on the new client but its server is unchanged.`);
391
+ }
380
392
  console.log('Uploading source archive...');
381
393
  await uploadSource(projectDir, result.deckId);
382
394
  console.log('Source archive saved.');
package/dist/serve.js CHANGED
@@ -404,7 +404,10 @@ window.__castleShellOrigin=(function(){
404
404
  const publicShellPath = reqPath === '/' ||
405
405
  reqPath.startsWith('/__castle/ide/') ||
406
406
  FAVICON_FILES.some((name) => reqPath === `/${name}`);
407
- if (reqPath.startsWith('/__castle/') && !publicShellPath && !security.authorizeHttp(req)) {
407
+ if (reqPath.startsWith('/__castle/') &&
408
+ !publicShellPath &&
409
+ !security.authorizeHttp(req) &&
410
+ !security.authorizeSignedPath(req, reqPath)) {
408
411
  security.rejectHttpAuth(res);
409
412
  return;
410
413
  }
@@ -469,6 +472,7 @@ async function handleCastleCommand(ws, projectDir, broadcast, multiplayer, multi
469
472
  url: null,
470
473
  nonce: null,
471
474
  sessionId: null,
475
+ soloReason: null,
472
476
  });
473
477
  }
474
478
  return Promise.resolve({
@@ -476,6 +480,7 @@ async function handleCastleCommand(ws, projectDir, broadcast, multiplayer, multi
476
480
  url: multiplayerUrl,
477
481
  nonce: multiplayer.mintNonce(devMultiplayerIdentity(ctx)),
478
482
  sessionId: multiplayer.sessionId,
483
+ soloReason: null,
479
484
  });
480
485
  },
481
486
  });
@@ -778,6 +783,7 @@ export async function serve(dir, options = {}) {
778
783
  deckLabel: path.basename(projectDir),
779
784
  port,
780
785
  playtestServeUrl: security.localContentOrigin('play'),
786
+ signPath: (reqPath) => security.signPath(reqPath),
781
787
  browserContentUrl: process.env.CASTLE_SANDBOX_ID
782
788
  ? () => security.browserContentOrigin('play')
783
789
  : undefined,
@@ -18,6 +18,7 @@ export declare class ServeSecurity {
18
18
  private readonly contentProxyToken;
19
19
  private readonly signingKey;
20
20
  private readonly serveNonce;
21
+ private readonly pathKey;
21
22
  private readonly tickets;
22
23
  private localContentPorts;
23
24
  private localContentHost;
@@ -26,6 +27,17 @@ export declare class ServeSecurity {
26
27
  setLocalContentHost(host: string): void;
27
28
  authorizeHttp(req: http.IncomingMessage): boolean;
28
29
  rejectHttpAuth(res: http.ServerResponse): void;
30
+ /**
31
+ * Sign one shell path so a browser can load it WITHOUT the serve token. The
32
+ * bootstrap supplies that token by patching `window.fetch`, and an `<img
33
+ * src>` or a `target="_blank"` link never goes through fetch -- so a URL the
34
+ * page hands to the browser instead of fetching itself has to carry its own
35
+ * capability. Bound to the exact path, and only a SIGNABLE_PATH_PREFIXES
36
+ * path: a signature minted for one frame opens nothing else under
37
+ * `/__castle/`, and a path outside that set cannot be signed at all.
38
+ */
39
+ signPath(reqPath: string): string;
40
+ authorizeSignedPath(req: http.IncomingMessage, reqPath: string): boolean;
29
41
  issueTicket(endpoint: SocketEndpoint): string;
30
42
  /** One-use admission for an SDK that predates the shell's MessagePort bridge. */
31
43
  issueLegacyContentTicket(role: ContentRole): string;
@@ -1,4 +1,5 @@
1
1
  import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
2
+ import { AGENT_ATTACHMENT_PREFIX, AGENT_PLAYTEST_PREFIX } from './localPaths.js';
2
3
  export const SERVE_AUTH_FAILURE_HEADER = 'x-castle-serve-auth';
3
4
  export const SERVE_AUTH_FAILURE_VALUE = 'invalid';
4
5
  const CROCKFORD = '0123456789abcdefghjkmnpqrstvwxyz';
@@ -7,6 +8,13 @@ const MAX_GRANT_SECONDS = 12 * 60 * 60;
7
8
  const GRANT_CLOCK_SKEW_SECONDS = 60;
8
9
  const BROWSER_GRANT_SECONDS = 3 * 60;
9
10
  export const MOBILE_HOST_PROTOCOL = '2';
11
+ // A signature covers the request path and nothing else, so every path it can
12
+ // open must be a static file the path alone names. A query-driven endpoint
13
+ // here (`/__castle/files/read?path=`) would be opened wholesale by one signature.
14
+ const SIGNABLE_PATH_PREFIXES = [AGENT_PLAYTEST_PREFIX, AGENT_ATTACHMENT_PREFIX];
15
+ function isSignablePath(reqPath) {
16
+ return SIGNABLE_PATH_PREFIXES.some((prefix) => reqPath.startsWith(prefix));
17
+ }
10
18
  function base32Lower(bytes) {
11
19
  let bits = 0;
12
20
  let value = 0;
@@ -57,6 +65,7 @@ export class ServeSecurity {
57
65
  contentProxyToken = randomBytes(32).toString('base64url');
58
66
  signingKey = process.env.CASTLE_AGENT_TOKEN || randomBytes(32).toString('base64url');
59
67
  serveNonce = base32Lower(randomBytes(8));
68
+ pathKey = randomBytes(32).toString('base64url');
60
69
  tickets = new Map();
61
70
  localContentPorts;
62
71
  localContentHost = '127.0.0.1';
@@ -87,6 +96,39 @@ export class ServeSecurity {
87
96
  })
88
97
  .end();
89
98
  }
99
+ /**
100
+ * Sign one shell path so a browser can load it WITHOUT the serve token. The
101
+ * bootstrap supplies that token by patching `window.fetch`, and an `<img
102
+ * src>` or a `target="_blank"` link never goes through fetch -- so a URL the
103
+ * page hands to the browser instead of fetching itself has to carry its own
104
+ * capability. Bound to the exact path, and only a SIGNABLE_PATH_PREFIXES
105
+ * path: a signature minted for one frame opens nothing else under
106
+ * `/__castle/`, and a path outside that set cannot be signed at all.
107
+ */
108
+ signPath(reqPath) {
109
+ if (!isSignablePath(reqPath)) {
110
+ throw new Error(`refusing to sign a shell path outside the signable set: ${reqPath}`);
111
+ }
112
+ return base32Lower(createHmac('sha256', this.pathKey)
113
+ .update(`castle-path-v1\n${reqPath}`)
114
+ .digest()
115
+ .subarray(0, 12));
116
+ }
117
+ authorizeSignedPath(req, reqPath) {
118
+ const method = req.method?.toUpperCase();
119
+ if (method !== 'GET' && method !== 'HEAD')
120
+ return false;
121
+ if (!isSignablePath(reqPath))
122
+ return false;
123
+ let signature = '';
124
+ try {
125
+ signature = new URL(req.url || '/', 'http://localhost').searchParams.get('t') || '';
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ return signature !== '' && safeEqual(this.signPath(reqPath), signature);
131
+ }
90
132
  issueTicket(endpoint) {
91
133
  this.pruneTickets();
92
134
  const ticket = randomBytes(16).toString('base64url');