@crowdedkingdoms/construct 0.0.0 → 0.2.0-dev.1

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 (69) hide show
  1. package/README.md +76 -1
  2. package/node/copy-dsh-web.mjs +71 -0
  3. package/node/security-headers.d.mts +23 -0
  4. package/node/security-headers.mjs +243 -0
  5. package/node/vite-plugins.d.mts +6 -0
  6. package/node/vite-plugins.mjs +102 -0
  7. package/package.json +46 -3
  8. package/sandbox/boot.ts +74 -0
  9. package/src/engine/GameLoop.ts +106 -0
  10. package/src/engine/GameScene.ts +74 -0
  11. package/src/engine/Input.test.ts +129 -0
  12. package/src/engine/Input.ts +257 -0
  13. package/src/engine/SceneRouter.test.ts +80 -0
  14. package/src/engine/SceneRouter.ts +113 -0
  15. package/src/engine/controls.test.ts +26 -0
  16. package/src/engine/controls.ts +63 -0
  17. package/src/grid/GridProgramRunner.ts +205 -0
  18. package/src/grid/sandboxProtocol.ts +9 -0
  19. package/src/index.ts +88 -0
  20. package/src/platform/GameSession.ts +239 -0
  21. package/src/platform/auth/AuthService.ts +45 -0
  22. package/src/platform/config.test.ts +110 -0
  23. package/src/platform/config.ts +212 -0
  24. package/src/platform/envScope.test.ts +42 -0
  25. package/src/platform/envScope.ts +76 -0
  26. package/src/platform/exec/worldHub.test.ts +61 -0
  27. package/src/platform/exec/worldHub.ts +86 -0
  28. package/src/platform/media/VoiceService.ts +423 -0
  29. package/src/platform/media/WebcamService.ts +346 -0
  30. package/src/platform/media/mulaw.test.ts +20 -0
  31. package/src/platform/media/mulaw.ts +56 -0
  32. package/src/platform/media/videoFrames.test.ts +197 -0
  33. package/src/platform/media/voiceFrames.test.ts +82 -0
  34. package/src/platform/model/ModelService.test.ts +59 -0
  35. package/src/platform/model/ModelService.ts +124 -0
  36. package/src/platform/network/NetworkManager.ts +519 -0
  37. package/src/platform/onboarding/steps.d.mts +154 -0
  38. package/src/platform/onboarding/steps.mjs +616 -0
  39. package/src/platform/onboarding/steps.test.ts +486 -0
  40. package/src/platform/realtime/WorldStores.ts +153 -0
  41. package/src/platform/realtime/actorCodec.test.ts +62 -0
  42. package/src/platform/realtime/actorCodec.ts +142 -0
  43. package/src/platform/realtime/space.test.ts +33 -0
  44. package/src/platform/realtime/space.ts +63 -0
  45. package/src/platform/social/ChatService.ts +92 -0
  46. package/src/platform/studio/ClientModLifecycle.test.ts +56 -0
  47. package/src/platform/studio/ClientModLifecycle.ts +102 -0
  48. package/src/platform/studio/ConstructPlayerHostAdapter.test.ts +159 -0
  49. package/src/platform/studio/ConstructPlayerHostAdapter.ts +392 -0
  50. package/src/platform/studio/GridService.test.ts +131 -0
  51. package/src/platform/studio/GridService.ts +295 -0
  52. package/src/platform/studio/HumanInputMonitor.test.ts +51 -0
  53. package/src/platform/studio/HumanInputMonitor.ts +48 -0
  54. package/src/platform/studio/StudioService.ts +686 -0
  55. package/src/platform/studio/agentLocomotion.test.ts +31 -0
  56. package/src/platform/studio/agentLocomotion.ts +97 -0
  57. package/src/platform/studio/clientModHost.test.ts +199 -0
  58. package/src/platform/studio/clientModHost.ts +369 -0
  59. package/src/platform/studio/instanceSchema.test.ts +280 -0
  60. package/src/platform/studio/instanceSchema.ts +746 -0
  61. package/src/platform/studio/instanceStore.test.ts +97 -0
  62. package/src/platform/studio/instanceStore.ts +120 -0
  63. package/src/platform/studio/modOverlay.test.ts +56 -0
  64. package/src/platform/studio/modOverlay.ts +51 -0
  65. package/src/platform/studio/permissions.test.ts +51 -0
  66. package/src/platform/studio/permissions.ts +134 -0
  67. package/src/platform/studio/pointerClicks.test.ts +23 -0
  68. package/src/platform/studio/pointerClicks.ts +132 -0
  69. package/src/platform/util/Emitter.ts +32 -0
@@ -0,0 +1,616 @@
1
+ /**
2
+ * Self-service onboarding: from "I have an account" to "my app is ready to
3
+ * play and mod", using only the public API a signed-in developer can call.
4
+ *
5
+ * Every step is idempotent and reports whether it changed anything, so the
6
+ * whole sequence can be re-run after editing the model or the templates, and
7
+ * so a step that fails half-way can be retried without cleanup. Plain ES
8
+ * module: the browser Setup wizard and `scripts/setup.mjs` run the same code.
9
+ *
10
+ * Tokens: steps 1-3, the tier grant, and the Studio Agent policy use the
11
+ * IDENTITY session (org and app administration). Everything on the game plane
12
+ * (claim policy, model, Studio common files) uses an APP-SCOPED token minted
13
+ * for the new app — the caller supplies `enterApp(appId)` which returns a game
14
+ * client holding one. The ck-exec deploy uses the identity session again, on
15
+ * the app's own datacenter: the caller supplies `exec.client(appId)`.
16
+ */
17
+
18
+ /**
19
+ * Seeding a model twice reports "created 0" the second time; that is the
20
+ * idempotency working, not a failure. The counts are for the log.
21
+ */
22
+
23
+ export const CONSTRUCTOR_TIER_NAME = 'Constructor';
24
+
25
+ /** Default free-tier keys, the webcam key, and the four Crowdy Studio code keys. */
26
+ export const CONSTRUCTOR_TIER_KEYS = [
27
+ 'access',
28
+ 'teleport',
29
+ 'update_voxel_data',
30
+ 'use_voice_chat',
31
+ 'use_video_chat',
32
+ 'write_server_code',
33
+ 'run_server_code',
34
+ 'write_client_code',
35
+ 'run_client_code',
36
+ 'use_studio_agent',
37
+ ];
38
+
39
+ /** Hosted OpenRouter ZDR catalog. App policy must name every id; empty denies all. */
40
+ export const STUDIO_AGENT_MODELS = [
41
+ 'deepseek/deepseek-v4-flash',
42
+ 'z-ai/glm-5.3-flash',
43
+ 'deepseek/deepseek-v4-pro',
44
+ 'deepseek/deepseek-chat',
45
+ 'openai/gpt-oss-120b',
46
+ ];
47
+ export const STUDIO_AGENT_MODEL = STUDIO_AGENT_MODELS[0];
48
+ export const STUDIO_AGENT_MODES = ['ASK', 'BUILD'];
49
+
50
+ /**
51
+ * What every visitor gets: the default free tier's keys plus permission to RUN
52
+ * mods others wrote. Fetching a grid-attached CLIENT artifact is gated on the
53
+ * VISITOR holding `run_client_code` (measured 2026-09-07: "Attachment not
54
+ * found" without it), so a default tier without it means nobody ever sees
55
+ * anyone's mod. Writing code stays on the Constructor tier.
56
+ */
57
+ export const VISITOR_RUN_KEYS = ['run_server_code', 'run_client_code'];
58
+
59
+ export function slugify(value) {
60
+ return (
61
+ String(value)
62
+ .toLowerCase()
63
+ .normalize('NFKD')
64
+ .replace(/[^a-z0-9]+/g, '-')
65
+ .replace(/^-+|-+$/g, '')
66
+ .slice(0, 64) || 'construct'
67
+ );
68
+ }
69
+
70
+ /** 1. Use the caller's first org, or create one. */
71
+ export async function ensureOrganization(identity, { name, slug }, log = noop) {
72
+ const mine = await identity.organizations.mine();
73
+ const owned =
74
+ mine.find((m) => Array.isArray(m.permissions) && m.permissions.includes('manage_apps')) ??
75
+ mine[0];
76
+ if (owned) {
77
+ log(`Using organization "${owned.org.name}" (${owned.org.slug})`);
78
+ return { org: owned.org, created: false, permissions: owned.permissions ?? [] };
79
+ }
80
+ const org = await identity.organizations.create({ name, slug: slugify(slug ?? name) });
81
+ log(`Created organization "${org.name}" (${org.slug})`);
82
+ return { org, created: true, permissions: ['manage_apps', 'manage_access_tiers'] };
83
+ }
84
+
85
+ /** 2. Use the org's app with this slug, or create it on a placeable datacenter. */
86
+ export async function ensureApp(identity, { orgId, orgSlug, name, slug, datacenter }, log = noop) {
87
+ const existing = await identity.apps.forOrg(orgSlug);
88
+ const match = existing.find((app) => app.slug === slug) ?? null;
89
+ if (match) {
90
+ log(`Using app "${match.name}" (${match.appId})`);
91
+ return { app: match, created: false };
92
+ }
93
+ const placement = await identity.apps.placeableDatacenters();
94
+ const choices = (placement.datacenters ?? []).filter((dc) => dc.placeable && dc.serving);
95
+ const pick = datacenter
96
+ ? choices.find((dc) => dc.code === datacenter)
97
+ : choices.sort((a, b) => Number(a.appShardCount ?? 0) - Number(b.appShardCount ?? 0))[0];
98
+ if (!pick) {
99
+ throw new Error(
100
+ `No datacenter can place an app right now${datacenter ? ` (asked for "${datacenter}")` : ''}. ` +
101
+ `Placeable: ${choices.map((dc) => dc.code).join(', ') || 'none'}.`,
102
+ );
103
+ }
104
+ const app = await identity.apps.create({
105
+ orgId,
106
+ name,
107
+ slug,
108
+ datacenter: pick.code,
109
+ description: 'Created by The Construct starter.',
110
+ });
111
+ log(`Created app "${app.name}" (${app.appId}) in datacenter ${pick.code}`);
112
+ return { app, created: true };
113
+ }
114
+
115
+ /**
116
+ * 3. A "Constructor" access tier carrying the Crowdy Studio code keys, and a
117
+ * grant of that tier to the developer. Visitors keep the default free tier the
118
+ * app was created with (access/teleport/voxels/voice), so they can play and
119
+ * paint but not deploy code on grids they do not own.
120
+ */
121
+ /**
122
+ * Register the origins this game is served from as the app's redirect URIs.
123
+ *
124
+ * Since ck-api v1.88.0 this list is load-bearing twice over: it is where the
125
+ * hosted sign-in may send a player back, AND it is the API's CORS allow-list
126
+ * for the app. A game on an origin that is not here gets `HOSTED_SIGN_IN_REQUIRED`
127
+ * on the return leg and no CORS headers on anything else. Idempotent: existing
128
+ * entries are kept, the given ones added; origin-matched by the server, so the
129
+ * path does not matter but `/` keeps the list readable.
130
+ */
131
+ export async function ensureRedirectUris(identity, { appId, origins }, log = noop) {
132
+ const app = await identity.apps.app(appId);
133
+ const existing = Array.isArray(app?.redirectUris) ? app.redirectUris : [];
134
+ const have = new Set(existing.map((u) => originOf(u)).filter(Boolean));
135
+ const wanted = origins.map((o) => originOf(o)).filter(Boolean);
136
+ const missing = [...new Set(wanted)].filter((o) => !have.has(o));
137
+ if (missing.length === 0) {
138
+ log(`Redirect URIs already cover ${wanted.join(', ')}`);
139
+ return { redirectUris: existing, added: [] };
140
+ }
141
+ const redirectUris = [...existing, ...missing.map((o) => `${o}/`)];
142
+ await identity.portal.setAppClientSettings({ appId, redirectUris });
143
+ log(`Registered redirect origin(s) ${missing.join(', ')}`);
144
+ return { redirectUris, added: missing };
145
+ }
146
+
147
+ function originOf(value) {
148
+ try {
149
+ return new URL(value).origin;
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ export async function ensureConstructorTier(identity, { appId, userId }, log = noop) {
156
+ const tiers = await identity.appAccess.tiers(appId);
157
+
158
+ // Visitors (default tier) may run mods; only Constructors may write them.
159
+ const defaultTier = tiers.find((t) => t.isDefault && t.status !== 'archived');
160
+ if (defaultTier) {
161
+ const have = new Set((defaultTier.permissionKeys ?? []).map(String));
162
+ if (!VISITOR_RUN_KEYS.every((key) => have.has(key))) {
163
+ await identity.appAccess.updateTier(defaultTier.tierId, {
164
+ permissionKeys: [...new Set([...have, ...VISITOR_RUN_KEYS])],
165
+ });
166
+ log(
167
+ `Default tier "${defaultTier.name}" now lets visitors run mods (${VISITOR_RUN_KEYS.join(', ')})`,
168
+ );
169
+ }
170
+ }
171
+
172
+ let tier = tiers.find((t) => t.name === CONSTRUCTOR_TIER_NAME && t.status !== 'archived') ?? null;
173
+ let created = false;
174
+ let updated = false;
175
+ if (!tier) {
176
+ tier = await identity.appAccess.createTier({
177
+ appId,
178
+ name: CONSTRUCTOR_TIER_NAME,
179
+ isFree: true,
180
+ isDefault: false,
181
+ description:
182
+ 'The Construct developer tier: play, paint, write SERVER + CLIENT mods, and use Crowdy Agent.',
183
+ permissionKeys: CONSTRUCTOR_TIER_KEYS,
184
+ });
185
+ created = true;
186
+ log(`Created access tier "${CONSTRUCTOR_TIER_NAME}" (${tier.tierId})`);
187
+ } else {
188
+ const have = new Set((tier.permissionKeys ?? []).map(String));
189
+ if (!CONSTRUCTOR_TIER_KEYS.every((key) => have.has(key))) {
190
+ tier = await identity.appAccess.updateTier(tier.tierId, {
191
+ permissionKeys: [...new Set([...have, ...CONSTRUCTOR_TIER_KEYS])],
192
+ });
193
+ updated = true;
194
+ log(`Updated access tier "${CONSTRUCTOR_TIER_NAME}" with the code keys`);
195
+ }
196
+ }
197
+ // Check before granting rather than replaying an idempotency key: on
198
+ // 2026-09-07 the replay path of grantAppAccess answered a DateTime
199
+ // serialization error on dev, and "already on this tier" is the honest
200
+ // idempotent answer anyway.
201
+ const mine = await identity.appAccess.myAccess(appId).catch(() => null);
202
+ if (mine && String(mine.tierId) === String(tier.tierId) && mine.status !== 'revoked') {
203
+ log(`User ${userId} already holds "${CONSTRUCTOR_TIER_NAME}"`);
204
+ return { tier, created, updated, granted: false };
205
+ }
206
+ await identity.appAccess.grant({ appId, userId, tierId: tier.tierId });
207
+ log(`Granted "${CONSTRUCTOR_TIER_NAME}" to user ${userId}`);
208
+ return { tier, created, updated, granted: true };
209
+ }
210
+
211
+ /** 4. Players may claim an unowned chunk for themselves (the Claim pad). */
212
+ export async function ensureSelfClaimPolicy(client, { appId }, log = noop) {
213
+ const current = await client.marketplace.gridClaimPolicy({ appId }).catch(() => null);
214
+ const normalized = String(current ?? '')
215
+ .toUpperCase()
216
+ .replace(/-/g, '_');
217
+ if (normalized === 'SELF_CLAIM') {
218
+ log('Grid claim policy already SELF_CLAIM');
219
+ return { policy: 'SELF_CLAIM', changed: false };
220
+ }
221
+ try {
222
+ await client.marketplace.setGridClaimPolicy({ appId, policy: 'SELF_CLAIM' });
223
+ } catch {
224
+ // Local/single-node ck-api exposes this as manage_apps on the identity
225
+ // session (`setAppGridClaimPolicy`); an app-scoped token is refused.
226
+ await client.graphql.query(
227
+ `mutation ConstructSetClaimPolicy($appId: BigInt!, $policy: GridClaimPolicy!) {
228
+ setAppGridClaimPolicy(appId: $appId, policy: $policy)
229
+ }`,
230
+ { appId, policy: 'SELF_CLAIM' },
231
+ );
232
+ }
233
+ log(`Grid claim policy set to SELF_CLAIM${current ? ` (was ${current})` : ''}`);
234
+ return { policy: 'SELF_CLAIM', changed: true };
235
+ }
236
+
237
+ /**
238
+ * 5. Build and deploy the game's ck-exec code. The crates go to `execBuild` as
239
+ * sources (the platform compiles them, so no Rust toolchain is needed here) and
240
+ * the manifest to `execDeploy` with that build, which makes it the app's active
241
+ * version. Deploying the same sources again takes the build from the platform's
242
+ * cache and makes an identical version active.
243
+ *
244
+ * `exec` must hold the developer's own session (the org's `manage_compute`), not
245
+ * an app token, and point at the app's own datacenter: deploys go to that
246
+ * datacenter's execution manager.
247
+ *
248
+ * A running hub keeps the version it started with until it stops, and one whose
249
+ * timer is pending does not stop while players are in the app. `restart` names
250
+ * the types to switch off and on again after the deploy: they persist and stop,
251
+ * and their next call starts them on the new version from their snapshots.
252
+ * `retry` wraps each request (the scripts retry a busy game API).
253
+ */
254
+ export async function deployExec(
255
+ exec,
256
+ { appId, manifest, crates, restart = [], retry = (fn) => fn(), restartPauseMs = 5_000 },
257
+ log = noop,
258
+ ) {
259
+ const queued = await retry(() => exec.exec.build(appId, crates));
260
+ log(`Building ${crates.map((c) => c.name).join(', ')} on the platform (build ${queued.buildId})`);
261
+ const build = await retry(() =>
262
+ exec.exec.waitForBuild(appId, queued.buildId, { timeoutMs: 900_000 }),
263
+ );
264
+ if (build.status !== 'succeeded') {
265
+ throw new Error(`ck-exec build ${build.buildId} ${build.status}:\n${build.log ?? ''}`.trim());
266
+ }
267
+ for (const a of build.artifacts ?? []) log(` ${a.crate}: ${a.sizeBytes} bytes`);
268
+ const { version } = await retry(() =>
269
+ exec.exec.deploy({
270
+ appId,
271
+ root: manifest.root,
272
+ types: manifest.types,
273
+ buildId: build.buildId,
274
+ }),
275
+ );
276
+ log(`ck-exec version ${version} is active (${Object.keys(manifest.types).join(', ')})`);
277
+ if (restart.length > 0) {
278
+ for (const nodeType of restart) await retry(() => exec.exec.setEnabled(appId, false, nodeType));
279
+ // Stopping persists first; hosts read the switch on their next poll.
280
+ await new Promise((resolve) => setTimeout(resolve, restartPauseMs));
281
+ for (const nodeType of restart) await retry(() => exec.exec.setEnabled(appId, true, nodeType));
282
+ log(
283
+ `Switched ${restart.join(', ')} off and on: the next call starts them on version ${version}`,
284
+ );
285
+ }
286
+ return { buildId: build.buildId, version, restarted: restart };
287
+ }
288
+
289
+ /**
290
+ * Deploy a Game Model (kit blueprints), for a game that keeps one. The Construct
291
+ * starter keeps its state in its ck-exec world hub instead (`deployExec`).
292
+ *
293
+ * `gameModelSeed` upserts DEFINITIONS (types, properties, functions) by name,
294
+ * but seed CONTAINERS are instances and are created every time they are sent.
295
+ * Re-running a seed that lists containers therefore duplicates them — measured
296
+ * on 2026-09-07: two runs, two `WorldState`s. So before deploying, drop every
297
+ * seed container whose type already has an instance with the same display
298
+ * name. Definitions still refresh; existing data is left alone.
299
+ */
300
+ export async function deployModel(game, { appId, blueprints }, log = noop) {
301
+ if (!Array.isArray(blueprints)) {
302
+ throw new Error("deployModel needs the game's blueprints (e.g. constructBlueprints())");
303
+ }
304
+ for (const blueprint of blueprints) {
305
+ if (!blueprint.containers?.length) continue;
306
+ const keep = [];
307
+ const seenTypes = new Map();
308
+ for (const container of blueprint.containers) {
309
+ let existing = seenTypes.get(container.typeName);
310
+ if (!existing) {
311
+ existing = await game.gameModel
312
+ .containers({ appId, typeName: container.typeName })
313
+ .catch(() => []);
314
+ seenTypes.set(container.typeName, existing);
315
+ }
316
+ if (existing.some((row) => row.displayName === container.displayName)) {
317
+ log(
318
+ `Container "${container.displayName}" (${container.typeName}) already exists; not re-seeding`,
319
+ );
320
+ } else {
321
+ keep.push(container);
322
+ }
323
+ }
324
+ blueprint.containers = keep;
325
+ }
326
+ const result = await game.kit(appId).deploy(blueprints);
327
+ const seed = result.seed ?? {};
328
+ log(
329
+ `Model deployed: ${seed.containerTypesCreated ?? 0} new types, ` +
330
+ `${seed.functionsCreated ?? 0} new functions, ${seed.containersCreated ?? 0} new containers, ` +
331
+ `${result.automations?.length ?? 0} automations upserted`,
332
+ );
333
+ for (const warning of result.warnings ?? seed.warnings ?? []) log(` seed warning: ${warning}`);
334
+ return result;
335
+ }
336
+
337
+ const PUBLISH_COMMON = `mutation PublishCommon($input: PublishCrowdyStudioCommonFileInput!) {
338
+ crowdyStudioCommonPublish(input: $input) { commonFileId slug versionId versionNo }
339
+ }`;
340
+
341
+ /** 6. Publish the starter templates' entrypoints to the Studio's Common Files. */
342
+ export async function publishStarterFiles(game, { appId, commonFiles = [] }, log = noop) {
343
+ const existing = await game.crowdyStudio.listCommonFiles({ appId, gridId: '' });
344
+ const results = [];
345
+ for (const common of commonFiles) {
346
+ const current = existing.find(
347
+ (file) => file.title === common.title && file.content === common.content,
348
+ );
349
+ if (current) {
350
+ log(`Common file "${common.slug}" already current`);
351
+ results.push({ slug: common.slug, status: 'current' });
352
+ continue;
353
+ }
354
+ const data = await game.graphql.query(PUBLISH_COMMON, {
355
+ input: {
356
+ appId,
357
+ slug: common.slug,
358
+ title: common.title,
359
+ description: common.description,
360
+ path: common.path,
361
+ target: common.target,
362
+ tags: common.tags,
363
+ content: common.content,
364
+ idempotencyKey: `construct-common-${common.slug}-${simpleHash(common.content)}`,
365
+ },
366
+ });
367
+ const version = data?.crowdyStudioCommonPublish?.versionNo;
368
+ log(`Common file "${common.slug}" published (v${version ?? '?'})`);
369
+ results.push({ slug: common.slug, status: 'published', versionNo: version });
370
+ }
371
+ return results;
372
+ }
373
+
374
+ const AGENT_POLICY_CORE = `
375
+ revision enabled killSwitch allowedModelIds allowedModes disableReasonCode
376
+ `;
377
+
378
+ const AGENT_APP_POLICY_QUERY = `query ConstructAgentPolicy($appId: BigInt!) {
379
+ crowdyStudioAgentPolicy(appId: $appId) { ${AGENT_POLICY_CORE} }
380
+ crowdyStudioAgentEffectivePolicy(appId: $appId) { ${AGENT_POLICY_CORE} }
381
+ }`;
382
+
383
+ const SET_AGENT_APP_POLICY = `mutation ConstructSetAgentPolicy($input: SetCrowdyStudioAgentAppPolicyInput!) {
384
+ setCrowdyStudioAgentPolicy(input: $input) { ${AGENT_POLICY_CORE} }
385
+ }`;
386
+
387
+ /** App row is on and allows Ask/Build. Models inherit the platform catalog. */
388
+ function appPolicyArmed(policy) {
389
+ if (!policy || policy.enabled !== true || policy.killSwitch === true) return false;
390
+ const modes = (policy.allowedModes ?? []).map(String);
391
+ return STUDIO_AGENT_MODES.every((m) => modes.includes(m));
392
+ }
393
+
394
+ function catalogVisible(policy) {
395
+ if (!policy || policy.killSwitch === true) return false;
396
+ return (policy.allowedModelIds ?? []).length > 0;
397
+ }
398
+
399
+ /**
400
+ * Arm Agentic Crowdy Studio for this app only.
401
+ *
402
+ * The dock stays fail-closed until the *app* policy is enabled and the
403
+ * player's Constructor tier holds `use_studio_agent`. The platform catalog is
404
+ * an operator concern — this starter never reads or writes `cp*` fields.
405
+ * Agent tokens are platform-funded; this does not touch a player wallet or an
406
+ * OpenRouter key in the game.
407
+ */
408
+ export async function ensureAgentPolicy(identity, { appId }, log = noop) {
409
+ let app;
410
+ let effective;
411
+ try {
412
+ const data = await identity.graphql.query(AGENT_APP_POLICY_QUERY, { appId });
413
+ app = data?.crowdyStudioAgentPolicy;
414
+ effective = data?.crowdyStudioAgentEffectivePolicy;
415
+ } catch (error) {
416
+ log(
417
+ `Studio Agent app policy is not readable (${messageOf(error)}). ` +
418
+ 'Needs manage_compute. Enable it in Studio → your app → Agent.',
419
+ );
420
+ return { enabled: false, skipped: true };
421
+ }
422
+
423
+ if (appPolicyArmed(effective) || appPolicyArmed(app)) {
424
+ if (effective && !catalogVisible(effective)) {
425
+ log(
426
+ 'Studio Agent app policy is on, but the platform catalog is not published. ' +
427
+ 'The dock stays closed until an operator publishes it. Studio → your app → Agent.',
428
+ );
429
+ } else {
430
+ log('Studio Agent app policy already enabled');
431
+ }
432
+ return { enabled: true, skipped: true };
433
+ }
434
+
435
+ try {
436
+ const updated = (
437
+ await identity.graphql.query(SET_AGENT_APP_POLICY, {
438
+ input: {
439
+ appId,
440
+ enabled: true,
441
+ killSwitch: false,
442
+ // Explicit on purpose: unlike tools and risk classes, modes have no
443
+ // inherit-on-omit at the app layer ("there is no inherit-on-empty for
444
+ // modes" -- SetCrowdyStudioAgentPolicyInput). The live platform
445
+ // catalog is ASK + BUILD; PLAY is refused ("can only narrow the
446
+ // platform list").
447
+ allowedModes: STUDIO_AGENT_MODES,
448
+ // Empty allowedModelIds denies every model at both layers. Name the
449
+ // hosted ZDR catalog so DeepSeek / GLM / gpt-oss are all selectable.
450
+ allowedModelIds: STUDIO_AGENT_MODELS,
451
+ expectedRevision: app?.revision ?? '0',
452
+ idempotencyKey: `construct-agent-app-${appId}-v5`,
453
+ },
454
+ })
455
+ )?.setCrowdyStudioAgentPolicy;
456
+ let latestEffective = effective;
457
+ try {
458
+ latestEffective = (await identity.graphql.query(AGENT_APP_POLICY_QUERY, { appId }))
459
+ ?.crowdyStudioAgentEffectivePolicy;
460
+ } catch {
461
+ // The write is what matters; effective is best-effort for the log.
462
+ }
463
+ const armed = appPolicyArmed(updated) || updated?.enabled === true;
464
+ if (!armed) {
465
+ log(
466
+ `Studio Agent app policy written but not yet effective (${updated?.disableReasonCode ?? 'unknown'}). ` +
467
+ 'Studio → your app → Agent. The dock stays hidden until that row is live.',
468
+ );
469
+ return { enabled: false, skipped: false };
470
+ }
471
+ if (latestEffective && !catalogVisible(latestEffective)) {
472
+ log(
473
+ 'Studio Agent app policy enabled; platform catalog not published — ' +
474
+ 'Studio → your app → Agent; dock stays closed.',
475
+ );
476
+ } else {
477
+ log('Studio Agent app policy enabled (platform-funded; no player OpenRouter key)');
478
+ }
479
+ return { enabled: true, skipped: false };
480
+ } catch (error) {
481
+ log(
482
+ `Could not enable the Studio Agent app policy (${messageOf(error)}). ` +
483
+ 'Studio → your app → Agent. The dock stays hidden until that row is live.',
484
+ );
485
+ return { enabled: false, skipped: true };
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Best practice: verify whether the app is connected to GitHub so players know
491
+ * GitHub can be their source of truth for files. Advisory only.
492
+ */
493
+ export async function checkGitHubIntegration(game, { appId }, log = noop) {
494
+ try {
495
+ const status = await game?.crowdyStudioGitHub?.status?.({ appId });
496
+ if (status?.connected && status?.owner && status?.repo) {
497
+ const repo = `${status.owner}/${status.repo}`;
498
+ log(`GitHub repository connected: ${repo}`);
499
+ return { connected: true, skipped: false, repo };
500
+ }
501
+ log(
502
+ 'Advisory: No GitHub repository bound. In Crowdy Studio, bind a GitHub repository ' +
503
+ 'so GitHub serves as your source of truth for files.',
504
+ );
505
+ return { connected: false, skipped: true };
506
+ } catch (err) {
507
+ log(`GitHub integration check skipped (${messageOf(err)}).`);
508
+ return { connected: false, skipped: true };
509
+ }
510
+ }
511
+
512
+ function messageOf(error) {
513
+ return error instanceof Error ? error.message : String(error);
514
+ }
515
+
516
+ /**
517
+ * The whole sequence. `enterApp(appId)` must return a client holding an
518
+ * app-scoped token for `appId` (browser: NetworkManager.enterApp; Node: mint
519
+ * and build a client). `exec` (the game's ck-exec code) and `blueprints` (a
520
+ * Game Model) are each deployed when given. Returns a report the UI renders.
521
+ */
522
+ export async function runOnboarding(options) {
523
+ const {
524
+ identity,
525
+ userId,
526
+ enterApp,
527
+ orgName,
528
+ appName,
529
+ appSlug,
530
+ datacenter,
531
+ redirectOrigins = [],
532
+ blueprints,
533
+ exec,
534
+ commonFiles = [],
535
+ log = noop,
536
+ onStep = noop,
537
+ } = options;
538
+ const report = { steps: [] };
539
+ const step = async (id, label, fn) => {
540
+ onStep({ id, label, status: 'running' });
541
+ try {
542
+ const value = await fn();
543
+ report.steps.push({ id, label, status: 'done', value });
544
+ onStep({ id, label, status: 'done', value });
545
+ return value;
546
+ } catch (error) {
547
+ report.steps.push({ id, label, status: 'failed', error });
548
+ onStep({ id, label, status: 'failed', error });
549
+ throw error;
550
+ }
551
+ };
552
+
553
+ const { org } = await step('org', 'Organization', () =>
554
+ ensureOrganization(identity, { name: orgName, slug: orgName }, log),
555
+ );
556
+ const { app } = await step('app', 'App', () =>
557
+ ensureApp(
558
+ identity,
559
+ {
560
+ orgId: org.orgId,
561
+ orgSlug: org.slug,
562
+ name: appName,
563
+ slug: appSlug ?? slugify(appName),
564
+ datacenter,
565
+ },
566
+ log,
567
+ ),
568
+ );
569
+ const appId = String(app.appId);
570
+ await step('tier', 'Constructor access tier', () =>
571
+ ensureConstructorTier(identity, { appId, userId }, log),
572
+ );
573
+ if (redirectOrigins.length > 0) {
574
+ await step('redirects', 'Sign-in redirect URIs', () =>
575
+ ensureRedirectUris(identity, { appId, origins: redirectOrigins }, log),
576
+ );
577
+ }
578
+ const game = await step('enter', 'App token', () => enterApp(appId));
579
+ await step('claims', 'Grid claim policy', () => ensureSelfClaimPolicy(identity, { appId }, log));
580
+ if (blueprints) {
581
+ await step('model', 'Game model', () => deployModel(game, { appId, blueprints }, log));
582
+ }
583
+ if (exec) {
584
+ await step('exec', 'ck-exec code', async () => {
585
+ const client = await exec.client(appId);
586
+ try {
587
+ const { manifest, crates, restart, retry } = exec;
588
+ return await deployExec(client, { appId, manifest, crates, restart, retry }, log);
589
+ } finally {
590
+ client.close?.();
591
+ }
592
+ });
593
+ }
594
+ await step('studio', 'Crowdy Studio starter files', () =>
595
+ publishStarterFiles(game, { appId, commonFiles }, log),
596
+ );
597
+ await step('agent', 'Crowdy Agent policy', () => ensureAgentPolicy(identity, { appId }, log));
598
+ await step('github', 'GitHub repository integration', () =>
599
+ checkGitHubIntegration(game, { appId }, log),
600
+ );
601
+ report.org = org;
602
+ report.app = app;
603
+ report.appId = appId;
604
+ return report;
605
+ }
606
+
607
+ function simpleHash(text) {
608
+ let h = 2166136261;
609
+ for (let i = 0; i < text.length; i++) {
610
+ h ^= text.charCodeAt(i);
611
+ h = Math.imul(h, 16777619) >>> 0;
612
+ }
613
+ return h.toString(16);
614
+ }
615
+
616
+ function noop() {}