@crowdedkingdoms/construct 0.4.0-dev.1 → 0.4.1-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.
@@ -4,7 +4,7 @@ export interface SecurityHeaderOptions {
4
4
  /** Additional exact origins a fork needs in connect-src. No wildcards. */
5
5
  extraConnectSrc?: string[];
6
6
  /**
7
- * Origins that may FRAME this game (the Crowdy Games shell, or a shell of your own).
7
+ * Origins that may FRAME this game (a shell page of your own).
8
8
  * Empty (the default) renders `frame-ancestors 'none'`; the DSH pane always adds
9
9
  * `'self'` beside them.
10
10
  */
@@ -79,10 +79,9 @@ export function buildCsp({ apiOrigins = [], extraConnectSrc = [], frameAncestors
79
79
  "default-src 'self'",
80
80
  "base-uri 'none'",
81
81
  "object-src 'none'",
82
- // 'none' when self-hosted at the top level. When this game is FRAMED -- by the
83
- // Crowdy Games shell (which the platform's content edge sets for you), or by a
84
- // shell of your own -- name exactly that origin here (`frameAncestors`); a game
85
- // that can be framed anywhere is a clickjacking surface.
82
+ // 'none' when self-hosted at the top level. When this game is FRAMED by a shell
83
+ // page of your own, name exactly that origin here (`frameAncestors`); a game that
84
+ // can be framed anywhere is a clickjacking surface.
86
85
  `frame-ancestors ${frameAncestorsSource(frameAncestors)}`,
87
86
  // 'wasm-unsafe-eval' lets the SDK instantiate WebAssembly (the Rust
88
87
  // language worker and the player-mod runtime). It grants no JS eval.
@@ -127,8 +126,8 @@ export function buildDshCsp({ apiOrigins = [], extraConnectSrc = [], frameAncest
127
126
  "base-uri 'none'",
128
127
  "object-src 'none'",
129
128
  // 'self' (the game page frames the pane) PLUS whoever frames the game: frame-ancestors
130
- // is checked against EVERY ancestor, so under the Crowdy Games shell the pane's
131
- // ancestors are the game and the shell, and 'self' alone would refuse it.
129
+ // is checked against EVERY ancestor, so under a shell the pane's ancestors are the
130
+ // game and the shell, and 'self' alone would refuse it.
132
131
  `frame-ancestors 'self'${frameAncestors.length ? ` ${frameAncestorsSource(frameAncestors)}` : ''}`,
133
132
  "script-src 'self' 'unsafe-eval' 'unsafe-inline' blob:",
134
133
  "style-src 'self' 'unsafe-inline'",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crowdedkingdoms/construct",
3
- "version": "0.4.0-dev.1",
3
+ "version": "0.4.1-dev.1",
4
4
  "description": "The Construct framework: the Crowded Kingdoms game platform layer (hosted sign-in, World Stores, voice/webcam/chat, Crowdy Studio dock, app and grid mods, grid programs) and engine loop, shared by the-construct starter and the games built on it. Ships TypeScript source for Vite.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -108,7 +108,7 @@ export function ensureRedirectUris(
108
108
  identity: CrowdyClient,
109
109
  args: { appId: string; origins: string[] },
110
110
  log?: Logger,
111
- ): Promise<{ redirectUris: string[]; added: string[] }>;
111
+ ): Promise<{ redirectUris: string[]; added: string[]; launchUrl: string | null }>;
112
112
  export function ensureConstructorTier(
113
113
  identity: CrowdyClient,
114
114
  input: { appId: string; userId: string },
@@ -127,6 +127,11 @@ export async function ensureApp(identity, { orgId, orgSlug, name, slug, datacent
127
127
  * on the return leg and no CORS headers on anything else. Idempotent: existing
128
128
  * entries are kept, the given ones added; origin-matched by the server, so the
129
129
  * path does not matter but `/` keeps the list readable.
130
+ *
131
+ * An app with no launch URL also gets the first origin that is not loopback as
132
+ * its launch URL. Crowded Kingdoms does not host games, so that origin is where
133
+ * the game lives, and Crowdy Studio's Live code opens it. A launch URL the app
134
+ * already has is left alone.
130
135
  */
131
136
  export async function ensureRedirectUris(identity, { appId, origins }, log = noop) {
132
137
  const app = await identity.apps.app(appId);
@@ -134,14 +139,21 @@ export async function ensureRedirectUris(identity, { appId, origins }, log = noo
134
139
  const have = new Set(existing.map((u) => originOf(u)).filter(Boolean));
135
140
  const wanted = origins.map((o) => originOf(o)).filter(Boolean);
136
141
  const missing = [...new Set(wanted)].filter((o) => !have.has(o));
137
- if (missing.length === 0) {
142
+ const launchOrigin = app?.launchUrl ? null : wanted.find((o) => !isLoopback(o));
143
+ const launchUrl = launchOrigin ? `${launchOrigin}/` : null;
144
+ if (missing.length === 0 && !launchUrl) {
138
145
  log(`Redirect URIs already cover ${wanted.join(', ')}`);
139
- return { redirectUris: existing, added: [] };
146
+ return { redirectUris: existing, added: [], launchUrl: app?.launchUrl ?? null };
140
147
  }
141
148
  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 };
149
+ await identity.portal.setAppClientSettings({
150
+ appId,
151
+ redirectUris,
152
+ ...(launchUrl ? { launchUrl } : {}),
153
+ });
154
+ if (missing.length) log(`Registered redirect origin(s) ${missing.join(', ')}`);
155
+ if (launchUrl) log(`Launch URL set to ${launchUrl} (Crowdy Studio's Live code opens it)`);
156
+ return { redirectUris, added: missing, launchUrl: launchUrl ?? app?.launchUrl ?? null };
145
157
  }
146
158
 
147
159
  function originOf(value) {
@@ -152,6 +164,11 @@ function originOf(value) {
152
164
  }
153
165
  }
154
166
 
167
+ function isLoopback(origin) {
168
+ const host = new URL(origin).hostname;
169
+ return host === 'localhost' || host === '[::1]' || host.startsWith('127.');
170
+ }
171
+
155
172
  export async function ensureConstructorTier(identity, { appId, userId }, log = noop) {
156
173
  const tiers = await identity.appAccess.tiers(appId);
157
174
 
@@ -350,9 +367,25 @@ const AGENT_POLICY_CORE = `
350
367
 
351
368
  const AGENT_APP_POLICY_QUERY = `query ConstructAgentPolicy($appId: BigInt!) {
352
369
  crowdyStudioAgentPolicy(appId: $appId) { ${AGENT_POLICY_CORE} }
353
- crowdyStudioAgentEffectivePolicy(appId: $appId) { ${AGENT_POLICY_CORE} }
370
+ crowdyStudioAgentEffectivePolicy(appId: $appId) {
371
+ ${AGENT_POLICY_CORE} funding { billingMode payerKind walletDebitEnabled }
372
+ }
354
373
  }`;
355
374
 
375
+ /**
376
+ * Who pays for the agent's model use, as the effective policy has it. Metered, a
377
+ * request the payer's wallet cannot cover is refused, so say so up front.
378
+ */
379
+ function fundingNote(effective) {
380
+ const funding = effective?.funding;
381
+ if (!funding) return 'funding not reported';
382
+ if (funding.billingMode !== 'METERED' || funding.walletDebitEnabled !== true) {
383
+ return 'platform-funded';
384
+ }
385
+ const payer = funding.payerKind === 'ORG' ? "the app's org wallet" : "each player's own wallet";
386
+ return `metered: model use is charged to ${payer}, which needs funds before the agent answers`;
387
+ }
388
+
356
389
  const SET_AGENT_APP_POLICY = `mutation ConstructSetAgentPolicy($input: SetCrowdyStudioAgentAppPolicyInput!) {
357
390
  setCrowdyStudioAgentPolicy(input: $input) { ${AGENT_POLICY_CORE} }
358
391
  }`;
@@ -375,8 +408,8 @@ function catalogVisible(policy) {
375
408
  * The dock stays fail-closed until the *app* policy is enabled and the
376
409
  * player's Constructor tier holds `use_studio_agent`. The platform catalog is
377
410
  * an operator concern — this starter never reads or writes `cp*` fields.
378
- * Agent tokens are platform-funded; this does not touch a player wallet or an
379
- * OpenRouter key in the game.
411
+ * This touches no wallet and no OpenRouter key in the game; who pays for model
412
+ * use is the effective policy's funding, which the log reports.
380
413
  */
381
414
  export async function ensureAgentPolicy(identity, { appId }, log = noop) {
382
415
  let app;
@@ -400,7 +433,7 @@ export async function ensureAgentPolicy(identity, { appId }, log = noop) {
400
433
  'The dock stays closed until an operator publishes it. Studio → your app → Agent.',
401
434
  );
402
435
  } else {
403
- log('Studio Agent app policy already enabled');
436
+ log(`Studio Agent app policy already enabled (${fundingNote(effective)})`);
404
437
  }
405
438
  return { enabled: true, skipped: true };
406
439
  }
@@ -447,7 +480,7 @@ export async function ensureAgentPolicy(identity, { appId }, log = noop) {
447
480
  'Studio → your app → Agent; dock stays closed.',
448
481
  );
449
482
  } else {
450
- log('Studio Agent app policy enabled (platform-funded; no player OpenRouter key)');
483
+ log(`Studio Agent app policy enabled (${fundingNote(latestEffective)})`);
451
484
  }
452
485
  return { enabled: true, skipped: false };
453
486
  } catch (error) {
@@ -494,3 +494,126 @@ describe('onboarding steps', () => {
494
494
  expect(resConnected.repo).toBe('modder/my-mod');
495
495
  });
496
496
  });
497
+
498
+ describe('ensureRedirectUris', () => {
499
+ function clientFor(app: Any): Any {
500
+ return {
501
+ apps: { app: vi.fn(async () => app) },
502
+ portal: { setAppClientSettings: vi.fn(async () => ({})) },
503
+ };
504
+ }
505
+
506
+ it('adds the missing origins and makes the first public one the launch URL when there is none', async () => {
507
+ const client = clientFor({ redirectUris: ['http://localhost:5175/'], launchUrl: null });
508
+ const result = await steps.ensureRedirectUris(client, {
509
+ appId: '7',
510
+ origins: ['http://localhost:5175', 'https://play.example.com'],
511
+ });
512
+ expect(client.portal.setAppClientSettings).toHaveBeenCalledWith({
513
+ appId: '7',
514
+ redirectUris: ['http://localhost:5175/', 'https://play.example.com/'],
515
+ launchUrl: 'https://play.example.com/',
516
+ });
517
+ expect(result).toEqual({
518
+ redirectUris: ['http://localhost:5175/', 'https://play.example.com/'],
519
+ added: ['https://play.example.com'],
520
+ launchUrl: 'https://play.example.com/',
521
+ });
522
+ });
523
+
524
+ it('sets the launch URL even when the origin is already registered', async () => {
525
+ const client = clientFor({ redirectUris: ['https://play.example.com/'], launchUrl: null });
526
+ await steps.ensureRedirectUris(client, { appId: '7', origins: ['https://play.example.com'] });
527
+ expect(client.portal.setAppClientSettings).toHaveBeenCalledWith({
528
+ appId: '7',
529
+ redirectUris: ['https://play.example.com/'],
530
+ launchUrl: 'https://play.example.com/',
531
+ });
532
+ });
533
+
534
+ it('leaves a launch URL the app already has, and never makes loopback the launch URL', async () => {
535
+ const kept = clientFor({ redirectUris: [], launchUrl: 'https://mine.example/' });
536
+ await steps.ensureRedirectUris(kept, { appId: '7', origins: ['https://play.example.com'] });
537
+ expect(kept.portal.setAppClientSettings).toHaveBeenCalledWith({
538
+ appId: '7',
539
+ redirectUris: ['https://play.example.com/'],
540
+ });
541
+
542
+ const local = clientFor({ redirectUris: [], launchUrl: null });
543
+ const result = await steps.ensureRedirectUris(local, {
544
+ appId: '7',
545
+ origins: ['http://localhost:5175', 'http://127.0.0.1:4173'],
546
+ });
547
+ expect(local.portal.setAppClientSettings).toHaveBeenCalledWith({
548
+ appId: '7',
549
+ redirectUris: ['http://localhost:5175/', 'http://127.0.0.1:4173/'],
550
+ });
551
+ expect(result.launchUrl).toBeNull();
552
+ });
553
+
554
+ it('writes nothing when every origin is registered and the launch URL is set', async () => {
555
+ const client = clientFor({
556
+ redirectUris: ['https://play.example.com/'],
557
+ launchUrl: 'https://play.example.com/',
558
+ });
559
+ const result = await steps.ensureRedirectUris(client, {
560
+ appId: '7',
561
+ origins: ['https://play.example.com'],
562
+ });
563
+ expect(client.portal.setAppClientSettings).not.toHaveBeenCalled();
564
+ expect(result.added).toEqual([]);
565
+ });
566
+ });
567
+
568
+ describe('ensureAgentPolicy funding note', () => {
569
+ const ready = (funding: Any) => ({
570
+ enabled: true,
571
+ killSwitch: false,
572
+ allowedModelIds: [steps.STUDIO_AGENT_MODEL],
573
+ allowedModes: [...steps.STUDIO_AGENT_MODES],
574
+ funding,
575
+ });
576
+
577
+ it('says who pays when the tier meters model use', async () => {
578
+ const lines: string[] = [];
579
+ const policy = ready({ billingMode: 'METERED', payerKind: 'PLAYER', walletDebitEnabled: true });
580
+ const query = vi.fn(async () => ({
581
+ crowdyStudioAgentPolicy: policy,
582
+ crowdyStudioAgentEffectivePolicy: policy,
583
+ }));
584
+ await steps.ensureAgentPolicy({ graphql: { query } } as never, { appId: '77' }, (line) =>
585
+ lines.push(line),
586
+ );
587
+ expect(lines.join('\n')).toMatch(/metered: model use is charged to each player's own wallet/);
588
+
589
+ const orgLines: string[] = [];
590
+ const orgPolicy = ready({ billingMode: 'METERED', payerKind: 'ORG', walletDebitEnabled: true });
591
+ const orgQuery = vi.fn(async () => ({
592
+ crowdyStudioAgentPolicy: orgPolicy,
593
+ crowdyStudioAgentEffectivePolicy: orgPolicy,
594
+ }));
595
+ await steps.ensureAgentPolicy(
596
+ { graphql: { query: orgQuery } } as never,
597
+ { appId: '77' },
598
+ (line) => orgLines.push(line),
599
+ );
600
+ expect(orgLines.join('\n')).toMatch(/the app's org wallet/);
601
+ });
602
+
603
+ it('says platform-funded only when the tier does not meter', async () => {
604
+ const lines: string[] = [];
605
+ const policy = ready({
606
+ billingMode: 'PLATFORM_FUNDED',
607
+ payerKind: 'PLATFORM',
608
+ walletDebitEnabled: false,
609
+ });
610
+ const query = vi.fn(async () => ({
611
+ crowdyStudioAgentPolicy: policy,
612
+ crowdyStudioAgentEffectivePolicy: policy,
613
+ }));
614
+ await steps.ensureAgentPolicy({ graphql: { query } } as never, { appId: '77' }, (line) =>
615
+ lines.push(line),
616
+ );
617
+ expect(lines.join('\n')).toMatch(/already enabled \(platform-funded\)/);
618
+ });
619
+ });