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.
- package/dist/agent.d.ts +1 -2
- package/dist/agent.js +22 -13
- package/dist/api.js +16 -1
- package/dist/castle-host/host.d.ts +2 -0
- package/dist/castle-host/host.js +17 -3
- package/dist/imports.js +19 -4
- package/dist/localPaths.d.ts +2 -0
- package/dist/localPaths.js +6 -0
- package/dist/platformDoc.js +1 -1
- package/dist/save-deck.js +13 -1
- package/dist/serve.js +7 -1
- package/dist/serveSecurity.d.ts +12 -0
- package/dist/serveSecurity.js +42 -0
- package/dist/shell/assets/index-CVHj503j.css +1 -0
- package/dist/shell/assets/index-D-qJ5VGp.js +425 -0
- package/dist/shell/index.html +2 -2
- package/kits/base/castle.json +64 -2
- package/kits/base/editors/ImageViewer.js +259 -0
- package/kits/base/editors/MediaPlayer.js +90 -0
- package/kits/base/editors/imageViewer.css +95 -0
- package/kits/base/editors/mediaPlayer.css +58 -0
- package/kits/base/sdk/README.md +3 -1
- package/kits/base/sdk/castle.d.ts +1 -0
- package/kits/base/sdk/commands.d.ts +7 -0
- package/kits/base/sdk/multiplayer.d.ts +6 -0
- package/kits/base/sdk/multiplayer.js +6 -0
- package/kits/blank/.castle/agent/children.json +1 -0
- package/kits/blank/.castle/agent/messages.json +9 -0
- package/kits/blank/.castle/agent/pending-sends.json +1 -0
- package/kits/blank/.castle/agent/tasks/index.md +2 -0
- package/kits/blank/.castle/docs/platform.md +36 -0
- package/kits/blank/node_modules/.vite/deps/_metadata.json +8 -0
- package/kits/blank/node_modules/.vite/deps/package.json +3 -0
- package/kits/multiplayer-2d/CLAUDE.md +6 -0
- package/kits/multiplayer-2d/castle.json +3 -3
- package/kits/multiplayer-2d/code/systems/multiplayer.js +5 -0
- package/kits/multiplayer-2d/package-lock.json +4 -1
- package/kits/multiplayer-3d/CLAUDE.md +6 -1
- package/kits/multiplayer-3d/castle.json +3 -3
- package/kits/multiplayer-3d/code/systems/multiplayer.js +5 -0
- package/kits/multiplayer-3d/package-lock.json +1 -1
- package/kits/physics-2d/behaviors/Joints.jsx +11 -5
- package/kits/physics-2d/behaviors/Sprite.jsx +6 -21
- package/kits/physics-2d/castle.json +1 -61
- package/kits/physics-2d/editors/BlueprintLibrary.jsx +8 -68
- package/kits/physics-2d/editors/SingleEditor.jsx +3 -10
- package/kits/physics-2d/editors/editorRegistry.jsx +36 -2
- package/kits/physics-2d/engine/art.js +25 -1
- package/kits/physics-2d/engine/artThumbnail.jsx +92 -0
- package/kits/physics-2d/engine/files.js +4 -15
- package/kits/physics-2d/engine/spriteField.jsx +176 -0
- package/kits/physics-2d/engine/ui.module.css +136 -0
- package/kits/physics-2d/package-lock.json +5 -0
- package/kits/physics-2d/pnpm-lock.yaml +96 -3
- package/kits/physics-3d/castle.json +1 -1
- package/kits/physics-3d/package-lock.json +1 -1
- package/kits/physics-3d/pnpm-lock.yaml +88 -3
- package/kits/real-time/CLAUDE.md +5 -0
- package/kits/real-time/castle.json +2 -2
- package/kits/real-time/code/client/connection.js +4 -0
- package/kits/real-time/code/client/gameHooks.js +3 -0
- package/kits/real-time/code/client/soloBadge.js +71 -0
- package/kits/real-time/package.json +3 -0
- package/kits/turn-based/castle.json +1 -1
- package/kits/turn-based/package-lock.json +1 -1
- package/package.json +1 -1
- package/dist/shell/assets/index-CG9TrvCm.js +0 -444
- package/dist/shell/assets/index-CIRBt1qf.css +0 -1
- package/kits/base/node_modules/.package-lock.json +0 -28
- package/kits/base/node_modules/castle-web-fonts/README.md +0 -114
- package/kits/base/node_modules/castle-web-fonts/dist/catalog.d.ts +0 -18
- package/kits/base/node_modules/castle-web-fonts/dist/catalog.js +0 -142
- package/kits/base/node_modules/castle-web-fonts/dist/face.d.ts +0 -1
- package/kits/base/node_modules/castle-web-fonts/dist/face.js +0 -52
- package/kits/base/node_modules/castle-web-fonts/dist/faces/Bore.d.ts +0 -3
- package/kits/base/node_modules/castle-web-fonts/dist/faces/Bore.js +0 -10
- package/kits/base/node_modules/castle-web-fonts/dist/faces/CourierPrime.d.ts +0 -3
- package/kits/base/node_modules/castle-web-fonts/dist/faces/CourierPrime.js +0 -10
- package/kits/base/node_modules/castle-web-fonts/dist/faces/DMSans.d.ts +0 -3
- package/kits/base/node_modules/castle-web-fonts/dist/faces/DMSans.js +0 -10
- package/kits/base/node_modules/castle-web-fonts/dist/faces/Glacier.d.ts +0 -3
- package/kits/base/node_modules/castle-web-fonts/dist/faces/Glacier.js +0 -10
- package/kits/base/node_modules/castle-web-fonts/dist/faces/HelicoCentrica.d.ts +0 -3
- package/kits/base/node_modules/castle-web-fonts/dist/faces/HelicoCentrica.js +0 -10
- package/kits/base/node_modules/castle-web-fonts/dist/faces/Piazzolla.d.ts +0 -3
- package/kits/base/node_modules/castle-web-fonts/dist/faces/Piazzolla.js +0 -10
- package/kits/base/node_modules/castle-web-fonts/dist/faces/Synco.d.ts +0 -3
- package/kits/base/node_modules/castle-web-fonts/dist/faces/Synco.js +0 -10
- package/kits/base/node_modules/castle-web-fonts/dist/faces/Tektur.d.ts +0 -3
- package/kits/base/node_modules/castle-web-fonts/dist/faces/Tektur.js +0 -10
- package/kits/base/node_modules/castle-web-fonts/dist/faces/YatraOne.d.ts +0 -3
- package/kits/base/node_modules/castle-web-fonts/dist/faces/YatraOne.js +0 -10
- package/kits/base/node_modules/castle-web-fonts/dist/index.d.ts +0 -16
- package/kits/base/node_modules/castle-web-fonts/dist/index.js +0 -15
- package/kits/base/node_modules/castle-web-fonts/licenses/CC-BY-ND-4.0.txt +0 -393
- package/kits/base/node_modules/castle-web-fonts/licenses/FONTS.md +0 -64
- package/kits/base/node_modules/castle-web-fonts/licenses/OFL-1.1.txt +0 -95
- package/kits/base/node_modules/castle-web-fonts/package.json +0 -47
- package/kits/base/node_modules/castle-web-fonts/woff2/Bore.woff2 +0 -0
- package/kits/base/node_modules/castle-web-fonts/woff2/CourierPrime.woff2 +0 -0
- package/kits/base/node_modules/castle-web-fonts/woff2/DMSans.woff2 +0 -0
- package/kits/base/node_modules/castle-web-fonts/woff2/Glacier.woff2 +0 -0
- package/kits/base/node_modules/castle-web-fonts/woff2/HelicoCentrica.woff2 +0 -0
- package/kits/base/node_modules/castle-web-fonts/woff2/Piazzolla.woff2 +0 -0
- package/kits/base/node_modules/castle-web-fonts/woff2/SOURCE.txt +0 -2
- package/kits/base/node_modules/castle-web-fonts/woff2/Synco.woff2 +0 -0
- package/kits/base/node_modules/castle-web-fonts/woff2/Tektur.woff2 +0 -0
- package/kits/base/node_modules/castle-web-fonts/woff2/YatraOne.woff2 +0 -0
- package/kits/physics-2d/editors/ImageViewer.jsx +0 -206
- package/kits/physics-2d/editors/MediaPlayer.jsx +0 -57
- package/kits/physics-2d/editors/mediaFile.js +0 -31
- 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
|
-
|
|
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 {
|
package/dist/castle-host/host.js
CHANGED
|
@@ -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 {
|
|
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
|
|
668
|
-
// would be the leash undoing itself.
|
|
669
|
-
//
|
|
670
|
-
//
|
|
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)) {
|
package/dist/localPaths.d.ts
CHANGED
|
@@ -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;
|
package/dist/localPaths.js
CHANGED
|
@@ -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
|
}
|
package/dist/platformDoc.js
CHANGED
|
@@ -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
|
-
|
|
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/') &&
|
|
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,
|
package/dist/serveSecurity.d.ts
CHANGED
|
@@ -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;
|
package/dist/serveSecurity.js
CHANGED
|
@@ -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');
|