@crowdedkingdoms/construct 0.4.0-dev.1 → 0.4.2-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 (
|
|
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
|
|
83
|
-
//
|
|
84
|
-
//
|
|
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
|
|
131
|
-
//
|
|
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.
|
|
3
|
+
"version": "0.4.2-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",
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
"./grid/*": "./src/grid/*.ts"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@crowdedkingdoms/crowdy-dsh": ">=0.4.0-dev.0 <1 || >=0.4.1-dev.0 <1 || >=0.4.2-dev.0 <1 || >=0.4.3-dev.0 <1 || >=0.4.4-dev.0 <1 || >=0.4.5-dev.0 <1 || >=0.5.0-dev.0 <1",
|
|
53
|
-
"@crowdedkingdoms/crowdyjs": ">=18.23.0-dev.0 <19"
|
|
52
|
+
"@crowdedkingdoms/crowdy-dsh": ">=0.4.0-dev.0 <1 || >=0.4.1-dev.0 <1 || >=0.4.2-dev.0 <1 || >=0.4.3-dev.0 <1 || >=0.4.4-dev.0 <1 || >=0.4.5-dev.0 <1 || >=0.5.0-dev.0 <1 || >=0.5.1-dev.0 <1",
|
|
53
|
+
"@crowdedkingdoms/crowdyjs": ">=18.23.0-dev.0 <19 || >=18.23.1-dev.0 <19"
|
|
54
54
|
},
|
|
55
55
|
"peerDependenciesMeta": {
|
|
56
56
|
"@crowdedkingdoms/crowdy-dsh": {
|
|
@@ -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
|
-
|
|
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({
|
|
143
|
-
|
|
144
|
-
|
|
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) {
|
|
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
|
-
*
|
|
379
|
-
*
|
|
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(
|
|
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(
|
|
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
|
+
});
|