castle-web-cli 0.4.59 → 0.4.61

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/api.d.ts CHANGED
@@ -1,3 +1,11 @@
1
+ interface GraphqlResponse {
2
+ data?: Record<string, unknown>;
3
+ errors?: Array<{
4
+ message?: string;
5
+ extensions?: Record<string, unknown>;
6
+ }>;
7
+ }
8
+ export declare function graphql(query: string, variables?: Record<string, unknown>): Promise<GraphqlResponse>;
1
9
  export declare function startCLILogin(): Promise<{
2
10
  pollToken: string;
3
11
  url: string;
@@ -71,3 +79,4 @@ export interface WebDeckSource {
71
79
  }
72
80
  export declare function webDeckSource(deckId: string): Promise<WebDeckSource | null>;
73
81
  export declare function saveWebDeckSource(deckId: string, uploadId: string): Promise<WebDeckSource>;
82
+ export {};
package/dist/api.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as config from './config.js';
2
2
  const API_HOST = 'https://api.castle.xyz/graphql';
3
- async function graphql(query, variables) {
3
+ export async function graphql(query, variables) {
4
4
  const token = config.getToken();
5
5
  const headers = {
6
6
  'X-OS': 'cli',
@@ -0,0 +1,58 @@
1
+ // Hand-written declaration for the vendored, self-contained host.js (runtime is
2
+ // copied here by scripts/copy-host-module.mjs). Kept tiny and import-free on
3
+ // purpose — the SDK's emitted .d.ts use extensionless imports that the CLI's
4
+ // Node16 resolution rejects. Mirror the public surface of
5
+ // castle-experimental-web/sdk/src/host.ts; update if that signature changes.
6
+
7
+ export interface HostContext {
8
+ deckId: string | null;
9
+ sessionId: string | null;
10
+ userId: string | null;
11
+ username: string | null;
12
+ }
13
+
14
+ export interface GraphqlError {
15
+ message?: string;
16
+ extensions?: Record<string, unknown>;
17
+ }
18
+
19
+ export interface GraphqlResponse {
20
+ data?: unknown;
21
+ errors?: GraphqlError[];
22
+ }
23
+
24
+ export type GraphqlFetch = (
25
+ query: string,
26
+ variables: Record<string, unknown>,
27
+ ) => Promise<GraphqlResponse>;
28
+
29
+ export type PlatformHandler = (
30
+ command: unknown,
31
+ params: Record<string, unknown>,
32
+ ctx: HostContext,
33
+ ) => Promise<unknown>;
34
+
35
+ export interface HostCapabilities {
36
+ graphqlFetch: GraphqlFetch;
37
+ platformHandler?: PlatformHandler;
38
+ }
39
+
40
+ export interface SerializedCommandError {
41
+ code: string;
42
+ message: string;
43
+ command?: string;
44
+ extensions?: Record<string, unknown>;
45
+ }
46
+
47
+ export interface HostResult {
48
+ ok: boolean;
49
+ data?: unknown;
50
+ error?: SerializedCommandError;
51
+ }
52
+
53
+ export function executeCommand(
54
+ ctx: HostContext,
55
+ command: unknown,
56
+ params: unknown,
57
+ capabilities: HostCapabilities,
58
+ ): Promise<HostResult>;
@@ -0,0 +1,457 @@
1
+ // GENERATED — do not edit. Vendored from castle-experimental-web/sdk/dist/host.js
2
+ // Re-run `npm run copy-host` in castle-experimental-web after an SDK change.
3
+
4
+ // Host-side command executor — the shared module run by every trusted host
5
+ // (web DeckPlayer, mobile React Native, the castle-web dev server). It takes a
6
+ // command + params from an untrusted deck, validates it against the allowlist,
7
+ // stamps trusted context (deckId/sessionId/userId — never taken from the deck),
8
+ // builds the GraphQL query, runs it via the host's own authed fetch, and
9
+ // returns a serializable result/error envelope. The deck never sees a token or
10
+ // builds a query. This file is NOT imported by deck-side code.
11
+ //
12
+ // Usage from a host:
13
+ // import { executeCommand } from "castle-web-sdk/host";
14
+ // const result = await executeCommand(ctx, command, params, graphqlFetch);
15
+ // // reply { castleSdk: 1, requestId, ...result } back to the deck.
16
+ // Runtime command allowlist — kept here (not in commands.ts) so the import
17
+ // above stays type-only. Must stay in sync with CommandParams in commands.ts.
18
+ const COMMAND_NAMES = [
19
+ "deckStorage.load",
20
+ "deckStorage.update",
21
+ "sharedDeckStorage.load",
22
+ "sharedDeckStorage.update",
23
+ "leaderboard.fetch",
24
+ "leaderboard.save",
25
+ "user.getCurrent",
26
+ "time.getServerTime",
27
+ "pass.has",
28
+ "pass.offer",
29
+ ];
30
+ // Platform/capability commands: NOT serviced by graphqlFetch. They're dispatched
31
+ // to the host's optional platformHandler (mobile renders native UI; web shows an
32
+ // upsell). A host with no platformHandler returns the command's normalized
33
+ // "unavailable" outcome rather than an error — capability divergence is the
34
+ // host's concern, never a deck-facing gate.
35
+ const PLATFORM_COMMAND_NAMES = ["pass.offer"];
36
+ function isCommandName(value) {
37
+ return (typeof value === "string" &&
38
+ COMMAND_NAMES.includes(value));
39
+ }
40
+ function isPlatformCommand(command) {
41
+ return PLATFORM_COMMAND_NAMES.includes(command);
42
+ }
43
+ class HostCommandError extends Error {
44
+ code;
45
+ command;
46
+ extensions;
47
+ constructor(code, message, command, extensions) {
48
+ super(message);
49
+ this.name = "HostCommandError";
50
+ this.code = code;
51
+ this.command = command;
52
+ this.extensions = extensions;
53
+ }
54
+ }
55
+ export async function executeCommand(ctx, command, params, capabilities) {
56
+ if (!isCommandName(command)) {
57
+ return {
58
+ ok: false,
59
+ error: {
60
+ code: "UNKNOWN_COMMAND",
61
+ message: "Unknown Castle command.",
62
+ command: typeof command === "string" ? command : undefined,
63
+ },
64
+ };
65
+ }
66
+ try {
67
+ const data = await runCommand(ctx, command, (params ?? {}), capabilities);
68
+ return { ok: true, data };
69
+ }
70
+ catch (error) {
71
+ return { ok: false, error: toSerializedError(error, command) };
72
+ }
73
+ }
74
+ function runCommand(ctx, command, params, caps) {
75
+ if (isPlatformCommand(command)) {
76
+ return runPlatformCommand(ctx, command, params, caps);
77
+ }
78
+ const gql = caps.graphqlFetch;
79
+ switch (command) {
80
+ case "deckStorage.load":
81
+ return deckStorageLoad(ctx, gql);
82
+ case "deckStorage.update":
83
+ return deckStorageUpdate(ctx, params, gql);
84
+ case "sharedDeckStorage.load":
85
+ return sharedDeckStorageLoad(ctx, params, gql);
86
+ case "sharedDeckStorage.update":
87
+ return sharedDeckStorageUpdate(ctx, params, gql);
88
+ case "leaderboard.fetch":
89
+ return leaderboardFetch(ctx, params, gql);
90
+ case "leaderboard.save":
91
+ return leaderboardSave(ctx, params, gql);
92
+ case "user.getCurrent":
93
+ return Promise.resolve(userGetCurrent(ctx));
94
+ case "time.getServerTime":
95
+ return timeGetServerTime(gql);
96
+ case "pass.has":
97
+ return passHas(ctx, params, gql);
98
+ // Platform commands are handled above; listed here to keep the switch
99
+ // exhaustive over CommandName.
100
+ case "pass.offer":
101
+ return runPlatformCommand(ctx, command, params, caps);
102
+ }
103
+ }
104
+ // Dispatch a platform/capability command to the host's platformHandler. No
105
+ // handler → the command's normalized "unavailable" outcome (a SUCCESS, not an
106
+ // error: a deck on a host without this capability still gets one uniform
107
+ // result). The handler's return is normalized so a malformed outcome can't
108
+ // leak through to the deck.
109
+ async function runPlatformCommand(ctx, command, params, caps) {
110
+ switch (command) {
111
+ case "pass.offer":
112
+ return passesOffer(ctx, params, caps);
113
+ default:
114
+ return unavailableOutcome(command);
115
+ }
116
+ }
117
+ async function passHas(ctx, params, gql) {
118
+ const deckId = requireDeckId(ctx, "pass.has");
119
+ const passId = asString(params.passId, "passId", "pass.has");
120
+ const data = await graphql(gql, PASSES_FOR_DECK_QUERY, { deckId }, "pass.has");
121
+ const match = (data.passesForDeck ?? []).find((p) => p?.passId === passId);
122
+ return { hasPass: match?.isActive === true };
123
+ }
124
+ async function passesOffer(ctx, params, caps) {
125
+ const passId = asString(params.passId, "passId", "pass.offer");
126
+ // Hosts without bricks support (dev CLI — no handler at all) get a normalized
127
+ // unavailable, never an error or a thrown MISSING_DECK_ID.
128
+ if (!caps.platformHandler)
129
+ return { status: "unavailable" };
130
+ // Real transaction path (mobile native sheet; web upsell): the pass belongs
131
+ // to a deck, so a trusted deckId is required before handing off.
132
+ const deckId = requireDeckId(ctx, "pass.offer");
133
+ const outcome = await caps.platformHandler("pass.offer", { passId, deckId }, ctx);
134
+ return normalizePassOutcome(outcome);
135
+ }
136
+ function normalizePassOutcome(value) {
137
+ const record = typeof value === "object" && value !== null
138
+ ? value
139
+ : {};
140
+ const status = record.status;
141
+ const valid = [
142
+ "purchased",
143
+ "alreadyOwned",
144
+ "cancelled",
145
+ "unavailable",
146
+ ];
147
+ if (typeof status === "string" && valid.includes(status)) {
148
+ return { status: status };
149
+ }
150
+ return { status: "cancelled" };
151
+ }
152
+ function unavailableOutcome(command) {
153
+ // Only passes exists today; keep this total over future platform commands.
154
+ if (command === "pass.offer") {
155
+ return { status: "unavailable" };
156
+ }
157
+ return { status: "unavailable" };
158
+ }
159
+ async function deckStorageLoad(ctx, gql) {
160
+ const deckId = requireDeckId(ctx, "deckStorage.load");
161
+ const data = await graphql(gql, DECK_STORAGE_QUERY, { deckId, sessionId: ctx.sessionId }, "deckStorage.load");
162
+ return { blob: data.deckStorage ?? {} };
163
+ }
164
+ async function deckStorageUpdate(ctx, params, gql) {
165
+ const deckId = requireDeckId(ctx, "deckStorage.update");
166
+ const data = await graphql(gql, UPDATE_DECK_STORAGE_MUTATION, {
167
+ deckId,
168
+ sessionId: ctx.sessionId,
169
+ updates: asUpdates(params.updates),
170
+ }, "deckStorage.update");
171
+ return { blob: data.updateDeckStorage ?? {} };
172
+ }
173
+ async function sharedDeckStorageLoad(ctx, params, gql) {
174
+ const deckId = requireDeckId(ctx, "sharedDeckStorage.load");
175
+ const scope = asScope(params.scope, "sharedDeckStorage.load");
176
+ // 'user' read: an explicit userId targets another player's public bucket;
177
+ // otherwise the current player's. 'deck' scope ignores userId.
178
+ const userId = scope === "user" ? asOptionalString(params.userId) ?? ctx.userId : null;
179
+ const data = await graphql(gql, SHARED_DECK_STORAGE_QUERY, { deckId, sessionId: ctx.sessionId, userId, keys: asKeys(params.keys) }, "sharedDeckStorage.load");
180
+ return { blob: data.sharedDeckStorage ?? {} };
181
+ }
182
+ async function sharedDeckStorageUpdate(ctx, params, gql) {
183
+ const deckId = requireDeckId(ctx, "sharedDeckStorage.update");
184
+ const scope = asScope(params.scope, "sharedDeckStorage.update");
185
+ // 'user'-scope writes are forced to the current player — the deck cannot
186
+ // write another player's bucket.
187
+ const userId = scope === "user" ? requireUserId(ctx, "sharedDeckStorage.update") : null;
188
+ await graphql(gql, UPDATE_SHARED_DECK_STORAGE_MUTATION, { deckId, sessionId: ctx.sessionId, userId, updates: asUpdates(params.updates) }, "sharedDeckStorage.update");
189
+ return { ok: true };
190
+ }
191
+ async function leaderboardFetch(ctx, params, gql) {
192
+ const deckId = requireDeckId(ctx, "leaderboard.fetch");
193
+ const variables = {
194
+ deckId,
195
+ variable: asString(params.variable, "variable", "leaderboard.fetch"),
196
+ type: asLeaderboardType(params.type),
197
+ filter: "dedupUsers",
198
+ includeFollowList: false,
199
+ includeParties: false,
200
+ scope: asOptionalString(params.scope) ?? null,
201
+ };
202
+ // A non-null score means the deck just wrote this value and wants its own
203
+ // score reflected immediately: write-and-read atomically via leaderboardV2
204
+ // (mirrors the engine's getLeaderboard path in core/src/leaderboards.cpp).
205
+ // No score → plain read of the settled leaderboard.
206
+ const score = asOptionalNumber(params.score);
207
+ if (score !== null) {
208
+ const data = await graphql(gql, LEADERBOARD_V2_MUTATION, { ...variables, score }, "leaderboard.fetch");
209
+ return { leaderboard: data.leaderboardV2, currentUserId: ctx.userId };
210
+ }
211
+ const data = await graphql(gql, LEADERBOARD_QUERY, variables, "leaderboard.fetch");
212
+ return { leaderboard: data.leaderboard, currentUserId: ctx.userId };
213
+ }
214
+ async function leaderboardSave(ctx, params, gql) {
215
+ const deckId = requireDeckId(ctx, "leaderboard.save");
216
+ await graphql(gql, SAVE_LEADERBOARD_MUTATION, {
217
+ deckId,
218
+ variable: asString(params.variable, "variable", "leaderboard.save"),
219
+ score: asNumber(params.score, "score", "leaderboard.save"),
220
+ scope: asOptionalString(params.scope) ?? null,
221
+ }, "leaderboard.save");
222
+ return { ok: true };
223
+ }
224
+ function userGetCurrent(ctx) {
225
+ if (!ctx.userId || !ctx.username)
226
+ return { user: null };
227
+ return { user: { userId: ctx.userId, username: ctx.username } };
228
+ }
229
+ async function timeGetServerTime(gql) {
230
+ const data = await graphql(gql, SERVER_TIME_QUERY, {}, "time.getServerTime");
231
+ return {
232
+ timestamp: data.serverTime.timestamp,
233
+ timezoneOffset: data.serverTime.timezoneOffset,
234
+ castleEpochData: data.serverTime.castleEpochData,
235
+ };
236
+ }
237
+ async function graphql(gql, query, variables, command) {
238
+ const result = await gql(query, variables);
239
+ if (result.errors && result.errors.length > 0) {
240
+ const first = result.errors[0];
241
+ const code = first?.extensions?.code;
242
+ throw new HostCommandError(typeof code === "string" ? code : "GRAPHQL_ERROR", first?.message ?? "Castle GraphQL request failed.", command, first?.extensions);
243
+ }
244
+ if (result.data === null || result.data === undefined) {
245
+ throw new HostCommandError("GRAPHQL_NO_DATA", "Castle GraphQL response did not include data.", command);
246
+ }
247
+ return result.data;
248
+ }
249
+ function requireDeckId(ctx, command) {
250
+ if (ctx.deckId)
251
+ return ctx.deckId;
252
+ throw new HostCommandError("MISSING_DECK_ID", "Save this deck to Castle before using this API.", command);
253
+ }
254
+ function requireUserId(ctx, command) {
255
+ if (ctx.userId)
256
+ return ctx.userId;
257
+ throw new HostCommandError("LOGIN_REQUIRED", "Log in to Castle before using this API.", command);
258
+ }
259
+ function toSerializedError(error, command) {
260
+ if (error instanceof HostCommandError) {
261
+ return {
262
+ code: error.code,
263
+ message: error.message,
264
+ command: error.command ?? command,
265
+ extensions: error.extensions,
266
+ };
267
+ }
268
+ return {
269
+ code: "CASTLE_HOST_ERROR",
270
+ message: error instanceof Error ? error.message : "Castle command failed.",
271
+ command,
272
+ };
273
+ }
274
+ function asUpdates(value) {
275
+ if (!Array.isArray(value))
276
+ return [];
277
+ return value.flatMap((entry) => {
278
+ if (typeof entry !== "object" || entry === null)
279
+ return [];
280
+ const record = entry;
281
+ if (typeof record.key !== "string")
282
+ return [];
283
+ const raw = record.value;
284
+ return [{ key: record.key, value: typeof raw === "string" ? raw : null }];
285
+ });
286
+ }
287
+ function asKeys(value) {
288
+ if (!Array.isArray(value))
289
+ return [];
290
+ return value.filter((key) => typeof key === "string");
291
+ }
292
+ function asScope(value, command) {
293
+ if (value === "deck" || value === "user")
294
+ return value;
295
+ throw new HostCommandError("CASTLE_STORAGE_INVALID_SCOPE", 'SharedStorage scope must be "deck" or "user".', command);
296
+ }
297
+ function asLeaderboardType(value) {
298
+ if (value === "high" || value === "low")
299
+ return value;
300
+ throw new HostCommandError("INVALID_LEADERBOARD_TYPE", "Leaderboard type must be high or low.", "leaderboard.fetch");
301
+ }
302
+ function asString(value, field, command) {
303
+ if (typeof value === "string" && value.length > 0)
304
+ return value;
305
+ throw new HostCommandError("INVALID_ARGUMENT", `Castle command ${command} requires ${field}.`, command);
306
+ }
307
+ function asNumber(value, field, command) {
308
+ if (typeof value === "number" && Number.isFinite(value))
309
+ return value;
310
+ throw new HostCommandError("INVALID_ARGUMENT", `Castle command ${command} requires a numeric ${field}.`, command);
311
+ }
312
+ function asOptionalString(value) {
313
+ return typeof value === "string" && value.length > 0 ? value : null;
314
+ }
315
+ function asOptionalNumber(value) {
316
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
317
+ }
318
+ const DECK_STORAGE_QUERY = `
319
+ query CastleDeckStorage($deckId: ID!, $sessionId: ID) {
320
+ deckStorage(deckId: $deckId, sessionId: $sessionId)
321
+ }
322
+ `;
323
+ const UPDATE_DECK_STORAGE_MUTATION = `
324
+ mutation CastleUpdateDeckStorage(
325
+ $deckId: ID!,
326
+ $updates: [DeckStorageUpdateInput!]!,
327
+ $sessionId: ID
328
+ ) {
329
+ updateDeckStorage(deckId: $deckId, updates: $updates, sessionId: $sessionId)
330
+ }
331
+ `;
332
+ const SHARED_DECK_STORAGE_QUERY = `
333
+ query CastleSharedDeckStorage(
334
+ $deckId: ID!,
335
+ $keys: [String!]!,
336
+ $sessionId: ID,
337
+ $userId: ID
338
+ ) {
339
+ sharedDeckStorage(deckId: $deckId, keys: $keys, sessionId: $sessionId, userId: $userId)
340
+ }
341
+ `;
342
+ const UPDATE_SHARED_DECK_STORAGE_MUTATION = `
343
+ mutation CastleUpdateSharedDeckStorage(
344
+ $deckId: ID!,
345
+ $updates: [SharedDeckStorageUpdateInput!]!,
346
+ $sessionId: ID,
347
+ $userId: ID
348
+ ) {
349
+ updateSharedDeckStorage(
350
+ deckId: $deckId,
351
+ updates: $updates,
352
+ sessionId: $sessionId,
353
+ userId: $userId
354
+ )
355
+ }
356
+ `;
357
+ const LEADERBOARD_QUERY = `
358
+ query CastleLeaderboard(
359
+ $deckId: ID!
360
+ $variable: String!
361
+ $type: LeaderboardType!
362
+ $filter: LeaderboardFilter!
363
+ $includeFollowList: Boolean
364
+ $includeParties: Boolean
365
+ $scope: String
366
+ ) {
367
+ leaderboard(
368
+ deckId: $deckId
369
+ variable: $variable
370
+ type: $type
371
+ filter: $filter
372
+ includeFollowList: $includeFollowList
373
+ includeParties: $includeParties
374
+ scope: $scope
375
+ ) {
376
+ yourScore { score }
377
+ list {
378
+ place
379
+ score
380
+ user {
381
+ userId
382
+ username
383
+ }
384
+ }
385
+ }
386
+ }
387
+ `;
388
+ // Same args + selection as LEADERBOARD_QUERY, plus the required `score`. Unlike
389
+ // saveVariableToLeaderboard, this writes the score AND returns the post-write
390
+ // leaderboard in one round trip, so the deck can show its own fresh score
391
+ // without waiting for server-side settling. Mirrors the engine's leaderboardV2
392
+ // use in core/src/leaderboards.cpp.
393
+ const LEADERBOARD_V2_MUTATION = `
394
+ mutation CastleLeaderboardV2(
395
+ $deckId: ID!
396
+ $variable: String!
397
+ $type: LeaderboardType!
398
+ $filter: LeaderboardFilter!
399
+ $score: Float!
400
+ $includeFollowList: Boolean
401
+ $includeParties: Boolean
402
+ $scope: String
403
+ ) {
404
+ leaderboardV2(
405
+ deckId: $deckId
406
+ variable: $variable
407
+ type: $type
408
+ filter: $filter
409
+ score: $score
410
+ includeFollowList: $includeFollowList
411
+ includeParties: $includeParties
412
+ scope: $scope
413
+ ) {
414
+ yourScore { score }
415
+ list {
416
+ place
417
+ score
418
+ user {
419
+ userId
420
+ username
421
+ }
422
+ }
423
+ }
424
+ }
425
+ `;
426
+ const SAVE_LEADERBOARD_MUTATION = `
427
+ mutation CastleSaveVariableToLeaderboard(
428
+ $deckId: ID!
429
+ $variable: String!
430
+ $score: Float!
431
+ $scope: String
432
+ ) {
433
+ saveVariableToLeaderboard(
434
+ deckId: $deckId
435
+ variable: $variable
436
+ score: $score
437
+ scope: $scope
438
+ )
439
+ }
440
+ `;
441
+ const PASSES_FOR_DECK_QUERY = `
442
+ query CastlePassesForDeck($deckId: ID!) {
443
+ passesForDeck(deckId: $deckId) {
444
+ passId
445
+ isActive
446
+ }
447
+ }
448
+ `;
449
+ const SERVER_TIME_QUERY = `
450
+ query CastleServerTime {
451
+ serverTime {
452
+ timestamp
453
+ timezoneOffset
454
+ castleEpochData
455
+ }
456
+ }
457
+ `;