@crowdedkingdoms/construct 0.0.0 → 0.2.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.
- package/README.md +76 -1
- package/node/copy-dsh-web.mjs +71 -0
- package/node/security-headers.d.mts +23 -0
- package/node/security-headers.mjs +243 -0
- package/node/vite-plugins.d.mts +6 -0
- package/node/vite-plugins.mjs +102 -0
- package/package.json +48 -3
- package/sandbox/boot.ts +74 -0
- package/src/engine/GameLoop.ts +106 -0
- package/src/engine/GameScene.ts +74 -0
- package/src/engine/Input.test.ts +129 -0
- package/src/engine/Input.ts +257 -0
- package/src/engine/SceneRouter.test.ts +80 -0
- package/src/engine/SceneRouter.ts +113 -0
- package/src/engine/controls.test.ts +26 -0
- package/src/engine/controls.ts +63 -0
- package/src/grid/GridProgramRunner.ts +205 -0
- package/src/grid/sandboxProtocol.ts +9 -0
- package/src/index.ts +88 -0
- package/src/platform/GameSession.ts +239 -0
- package/src/platform/auth/AuthService.ts +45 -0
- package/src/platform/config.test.ts +110 -0
- package/src/platform/config.ts +212 -0
- package/src/platform/envScope.test.ts +42 -0
- package/src/platform/envScope.ts +76 -0
- package/src/platform/exec/worldHub.test.ts +61 -0
- package/src/platform/exec/worldHub.ts +86 -0
- package/src/platform/media/VoiceService.ts +423 -0
- package/src/platform/media/WebcamService.ts +346 -0
- package/src/platform/media/mulaw.test.ts +20 -0
- package/src/platform/media/mulaw.ts +56 -0
- package/src/platform/media/videoFrames.test.ts +197 -0
- package/src/platform/media/voiceFrames.test.ts +82 -0
- package/src/platform/model/ModelService.test.ts +59 -0
- package/src/platform/model/ModelService.ts +124 -0
- package/src/platform/network/NetworkManager.ts +519 -0
- package/src/platform/onboarding/steps.d.mts +154 -0
- package/src/platform/onboarding/steps.mjs +616 -0
- package/src/platform/onboarding/steps.test.ts +486 -0
- package/src/platform/realtime/WorldStores.ts +153 -0
- package/src/platform/realtime/actorCodec.test.ts +62 -0
- package/src/platform/realtime/actorCodec.ts +142 -0
- package/src/platform/realtime/space.test.ts +33 -0
- package/src/platform/realtime/space.ts +63 -0
- package/src/platform/social/ChatService.ts +92 -0
- package/src/platform/studio/ClientModLifecycle.test.ts +56 -0
- package/src/platform/studio/ClientModLifecycle.ts +102 -0
- package/src/platform/studio/ConstructPlayerHostAdapter.test.ts +159 -0
- package/src/platform/studio/ConstructPlayerHostAdapter.ts +392 -0
- package/src/platform/studio/GridService.test.ts +131 -0
- package/src/platform/studio/GridService.ts +295 -0
- package/src/platform/studio/HumanInputMonitor.test.ts +51 -0
- package/src/platform/studio/HumanInputMonitor.ts +48 -0
- package/src/platform/studio/StudioService.ts +686 -0
- package/src/platform/studio/agentLocomotion.test.ts +31 -0
- package/src/platform/studio/agentLocomotion.ts +97 -0
- package/src/platform/studio/clientModHost.test.ts +199 -0
- package/src/platform/studio/clientModHost.ts +369 -0
- package/src/platform/studio/instanceSchema.test.ts +280 -0
- package/src/platform/studio/instanceSchema.ts +746 -0
- package/src/platform/studio/instanceStore.test.ts +97 -0
- package/src/platform/studio/instanceStore.ts +120 -0
- package/src/platform/studio/modOverlay.test.ts +56 -0
- package/src/platform/studio/modOverlay.ts +51 -0
- package/src/platform/studio/permissions.test.ts +51 -0
- package/src/platform/studio/permissions.ts +134 -0
- package/src/platform/studio/pointerClicks.test.ts +23 -0
- package/src/platform/studio/pointerClicks.ts +132 -0
- package/src/platform/util/Emitter.ts +32 -0
package/README.md
CHANGED
|
@@ -1,3 +1,78 @@
|
|
|
1
1
|
# @crowdedkingdoms/construct
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Construct framework: the Crowded Kingdoms platform layer and engine loop
|
|
4
|
+
that [the-construct](https://github.com/CrowdedKingdoms/the-construct) starter
|
|
5
|
+
and the games built on it share.
|
|
6
|
+
|
|
7
|
+
| Layer | What it does |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `engine/` | `GameLoop`, `SceneRouter`, `Input`, `Controls`, the `GameScene` contract |
|
|
10
|
+
| `platform/` | Hosted sign-in and app entry (`AuthService`, `NetworkManager`), `GameSession`, World Stores presence and the pose codec, chat, voice, webcam, envScope storage |
|
|
11
|
+
| `platform/exec/`, `platform/model/` | The game's world hub on ck-exec (`WorldHub`, one connection per page as `NetworkManager.worldHub`) and `ModelService`, which reads the pulse, programs and progression from it |
|
|
12
|
+
| `platform/studio/` | The Crowdy Studio dock (the SERVER target as a ck-exec mod), grid claims and the world hub's claim registry (`GridService`), CLIENT mods for the player's grid and consented grid mods, the agent's player host |
|
|
13
|
+
| `grid/` | JS grid programs (`GridProgramRunner`): player JavaScript with the full CrowdyJS SDK, sandboxed, relayed with a grid-scoped token |
|
|
14
|
+
| `node/` | `security-headers` (site, `/dsh/`, `/grid-program.html`), `vite-plugins`, `construct-copy-dsh` |
|
|
15
|
+
| `sandbox/boot` | The grid program sandbox bootstrap |
|
|
16
|
+
|
|
17
|
+
It ships **TypeScript source** for Vite (it uses `?worker&url` and
|
|
18
|
+
`import.meta.env`). Peer dependencies: `@crowdedkingdoms/crowdyjs` 17.7+ (17.12
|
|
19
|
+
for `ModelService`, `GridService` and the Studio's SERVER target on ck-exec)
|
|
20
|
+
and, for the Studio agent pane, `@crowdedkingdoms/crowdy-dsh` 0.4+.
|
|
21
|
+
|
|
22
|
+
## Installing it
|
|
23
|
+
|
|
24
|
+
Releases are on npm under one dist-tag per tier, and a game pins the exact
|
|
25
|
+
version of its own tier, as it pins CrowdyJS:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install --save-exact @crowdedkingdoms/construct@dev # a 0.2.0-dev.N build
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`dev` is `X.Y.Z-dev.N`, `test` is `X.Y.Z-test.N`, and `prod` is `X.Y.Z` on
|
|
32
|
+
`latest`. Each is published by the-construct's `publish-construct.yml` from a
|
|
33
|
+
`construct/<tier>/vX.Y.Z` tag on a commit already in that tier's branch.
|
|
34
|
+
|
|
35
|
+
## Using it in a game
|
|
36
|
+
|
|
37
|
+
`ModelService` and `GridService` call a world hub on ck-exec with the endpoints
|
|
38
|
+
the starter's `exec/construct` serves (`world`, `programs`, `progress`,
|
|
39
|
+
`claims`, `record_claim`, `release_claim`). The defaults name the starter's hub,
|
|
40
|
+
type `world` key `main`; a game whose hub has another name says so at boot:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { configureWorldHub, GameSession, GameLoop } from '@crowdedkingdoms/construct';
|
|
44
|
+
|
|
45
|
+
configureWorldHub({ nodeType: 'realm', key: 'main' });
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- Set `VITE_GAME_NAME` for the Studio dock and boot card.
|
|
49
|
+
- Serve `grid-program.html` (a one-line page whose module imports
|
|
50
|
+
`@crowdedkingdoms/construct/sandbox/boot`) and add it to
|
|
51
|
+
`build.rollupOptions.input`.
|
|
52
|
+
- In `vite.config.ts`:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { constructHeaderPlugins } from '@crowdedkingdoms/construct/node/vite-plugins';
|
|
56
|
+
import {
|
|
57
|
+
dshSecurityHeaders,
|
|
58
|
+
gridProgramSecurityHeaders,
|
|
59
|
+
securityHeaders,
|
|
60
|
+
} from '@crowdedkingdoms/construct/node/security-headers';
|
|
61
|
+
|
|
62
|
+
plugins: constructHeaderPlugins({
|
|
63
|
+
dshHeaders: dshSecurityHeaders({ apiOrigins: [apiOrigin] }),
|
|
64
|
+
gridProgramHeaders: gridProgramSecurityHeaders(),
|
|
65
|
+
}),
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- `construct-copy-dsh` (in `predev` / `prebuild`) copies the agent harness
|
|
69
|
+
into `public/dsh/`.
|
|
70
|
+
|
|
71
|
+
## Setup scripts
|
|
72
|
+
|
|
73
|
+
`@crowdedkingdoms/construct/platform/onboarding/steps` provisions an app
|
|
74
|
+
(org, app, access tier, redirect URIs, grid claim policy, your ck-exec code
|
|
75
|
+
(`deployExec`: `execBuild` + `execDeploy`), a Game Model if you keep one, Studio
|
|
76
|
+
Common Files, agent policy). Pass your own `exec` (manifest, crates, and a
|
|
77
|
+
client holding your session on the app's datacenter), `blueprints` and
|
|
78
|
+
`commonFiles`.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Put the Crowdy Studio agent harness where the page serves it.
|
|
4
|
+
*
|
|
5
|
+
* The harness (DeepSeek Harness web client + packed VFS image, ~13 MB) is
|
|
6
|
+
* published as `@crowdedkingdoms/crowdy-dsh`; its `dist/dsh-web/` is copied
|
|
7
|
+
* into the game's `public/dsh/` (the current working directory is the game)
|
|
8
|
+
* before `vite dev` / `vite build`. Run it as `construct-copy-dsh`. `public/dsh/` is
|
|
9
|
+
* gitignored: the artifact is versioned by the package, not by this template,
|
|
10
|
+
* and `public/dsh/BUILD.json` says exactly which build is being served.
|
|
11
|
+
*
|
|
12
|
+
* DSH_WEB_DIR=/path/to/dist/dsh-web use a locally built artifact instead
|
|
13
|
+
* CONSTRUCT_SKIP_DSH=1 leave public/dsh alone (no agent pane)
|
|
14
|
+
*/
|
|
15
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
const root = path.resolve(process.env.CONSTRUCT_APP_ROOT ?? process.cwd());
|
|
20
|
+
const target = path.join(root, 'public', 'dsh');
|
|
21
|
+
|
|
22
|
+
if (process.env.CONSTRUCT_SKIP_DSH === '1') {
|
|
23
|
+
console.log('copy-dsh-web: CONSTRUCT_SKIP_DSH=1, leaving public/dsh as is');
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let source = process.env.DSH_WEB_DIR?.trim();
|
|
28
|
+
let origin;
|
|
29
|
+
if (source) {
|
|
30
|
+
source = path.resolve(source);
|
|
31
|
+
origin = `DSH_WEB_DIR=${source}`;
|
|
32
|
+
} else {
|
|
33
|
+
const require = createRequire(path.join(root, 'package.json'));
|
|
34
|
+
let manifestPath;
|
|
35
|
+
try {
|
|
36
|
+
manifestPath = require.resolve('@crowdedkingdoms/crowdy-dsh/package.json');
|
|
37
|
+
} catch {
|
|
38
|
+
console.error(
|
|
39
|
+
'copy-dsh-web: @crowdedkingdoms/crowdy-dsh is not installed. Run `npm install`, or set ' +
|
|
40
|
+
'DSH_WEB_DIR to a locally built dist/dsh-web, or CONSTRUCT_SKIP_DSH=1 to build without the agent pane.',
|
|
41
|
+
);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
45
|
+
source = path.join(path.dirname(manifestPath), 'dist', 'dsh-web');
|
|
46
|
+
origin = `${manifest.name}@${manifest.version}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const required of ['index.html', 'BUILD.json', path.join('preview', 'vfs-image.tar.gz')]) {
|
|
50
|
+
if (!existsSync(path.join(source, required))) {
|
|
51
|
+
console.error(`copy-dsh-web: ${source} is not a harness artifact (missing ${required})`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
rmSync(target, { recursive: true, force: true });
|
|
57
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
58
|
+
cpSync(source, target, { recursive: true });
|
|
59
|
+
// A marker for anyone who finds public/dsh in a checkout and wonders.
|
|
60
|
+
writeFileSync(
|
|
61
|
+
path.join(target, 'SOURCE.txt'),
|
|
62
|
+
`Copied by construct-copy-dsh from ${origin} on ${new Date().toISOString()}.\n` +
|
|
63
|
+
'Do not edit or commit: public/dsh is gitignored and regenerated before every dev/build.\n',
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const stamp = JSON.parse(readFileSync(path.join(target, 'BUILD.json'), 'utf8'));
|
|
67
|
+
console.log(
|
|
68
|
+
`copy-dsh-web: public/dsh <- ${origin} ` +
|
|
69
|
+
`(harness ${stamp.upstream?.tag ?? '?'}, CrowdyJS ${stamp.crowdyjs?.version ?? '?'}, ` +
|
|
70
|
+
`built ${stamp.builtAt ?? '?'})`,
|
|
71
|
+
);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface SecurityHeaderOptions {
|
|
2
|
+
/** Every API origin the bundle may dial (configured, or the SDK default). */
|
|
3
|
+
apiOrigins?: Array<string | null | undefined>;
|
|
4
|
+
/** Additional exact origins a fork needs in connect-src. No wildcards. */
|
|
5
|
+
extraConnectSrc?: string[];
|
|
6
|
+
/**
|
|
7
|
+
* Origins that may FRAME this game (the Crowdy Games shell, or a shell of your own).
|
|
8
|
+
* Empty (the default) renders `frame-ancestors 'none'`; the DSH pane always adds
|
|
9
|
+
* `'self'` beside them.
|
|
10
|
+
*/
|
|
11
|
+
frameAncestors?: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function tierZoneWildcards(origins: Array<string | null | undefined>): string[];
|
|
15
|
+
export function frameAncestorsSource(frameAncestors?: string[]): string;
|
|
16
|
+
export function corpFor(options?: SecurityHeaderOptions): 'same-origin' | 'cross-origin';
|
|
17
|
+
export function buildCsp(options?: SecurityHeaderOptions): string;
|
|
18
|
+
export function buildDshCsp(options?: SecurityHeaderOptions): string;
|
|
19
|
+
export function securityHeaders(options?: SecurityHeaderOptions): Record<string, string>;
|
|
20
|
+
export function dshSecurityHeaders(options?: SecurityHeaderOptions): Record<string, string>;
|
|
21
|
+
export function buildGridProgramCsp(): string;
|
|
22
|
+
export function gridProgramSecurityHeaders(): Record<string, string>;
|
|
23
|
+
export const ISOLATION_HEADERS: readonly string[];
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security headers for The Construct — used by the Vite dev/preview servers and
|
|
3
|
+
* documented for every hosting recipe in docs/HOSTING.md.
|
|
4
|
+
*
|
|
5
|
+
* Why a game needs these at all: Crowdy Studio CLIENT mods run untrusted player
|
|
6
|
+
* code in a same-origin worker and talk to the page through a SharedArrayBuffer
|
|
7
|
+
* + Atomics bridge. Browsers only expose SharedArrayBuffer to pages that are
|
|
8
|
+
* cross-origin isolated, which takes BOTH `Cross-Origin-Opener-Policy` and
|
|
9
|
+
* `Cross-Origin-Embedder-Policy`. Without them `crossOriginIsolated` is false,
|
|
10
|
+
* the worker cannot start, and the feature silently disappears. The CSP is the
|
|
11
|
+
* other half: it keeps the worker same-origin and the page's network reach
|
|
12
|
+
* limited to the API it was built for.
|
|
13
|
+
*
|
|
14
|
+
* The API origin moves at runtime: the shared client origin hands the client
|
|
15
|
+
* to the app's datacenter and then to a direct API instance, all under the
|
|
16
|
+
* same one-label zone (e.g. `ck.<tier>.example` -> `*.<tier>.example`). The
|
|
17
|
+
* connect-src therefore admits the configured origin plus that one-label
|
|
18
|
+
* wildcard, and nothing wider.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
function originOf(value) {
|
|
22
|
+
if (!value) return null;
|
|
23
|
+
try {
|
|
24
|
+
return new URL(value).origin;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function unique(values) {
|
|
31
|
+
return [...new Set(values.filter(Boolean))];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `https://ck.prod.example.com` -> `https://*.prod.example.com` (and the wss:
|
|
36
|
+
* twin). Nothing for hosts with no parent zone (localhost, IPs).
|
|
37
|
+
*/
|
|
38
|
+
export function tierZoneWildcards(origins) {
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const origin of origins) {
|
|
41
|
+
if (!origin) continue;
|
|
42
|
+
let url;
|
|
43
|
+
try {
|
|
44
|
+
url = new URL(origin);
|
|
45
|
+
} catch {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const labels = url.hostname.split('.');
|
|
49
|
+
if (labels.length < 3) continue;
|
|
50
|
+
if (/^[\d.]+$/.test(url.hostname)) continue;
|
|
51
|
+
const zone = labels.slice(1).join('.');
|
|
52
|
+
out.push(`https://*.${zone}`, `wss://*.${zone}`);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build the Content-Security-Policy for the page and its same-origin workers.
|
|
59
|
+
*
|
|
60
|
+
* @param {{ apiOrigins?: Array<string | null | undefined>, extraConnectSrc?: string[] }} options
|
|
61
|
+
* `apiOrigins` — every API origin the bundle may dial (the configured one, or
|
|
62
|
+
* the SDK default). `extraConnectSrc` — additional exact origins a fork needs
|
|
63
|
+
* (analytics beacon, its own backend). Keep this list exact; no `*`.
|
|
64
|
+
*/
|
|
65
|
+
export function buildCsp({ apiOrigins = [], extraConnectSrc = [], frameAncestors = [] } = {}) {
|
|
66
|
+
const configured = apiOrigins.map(originOf);
|
|
67
|
+
const wsTwins = configured
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.map((origin) => origin.replace(/^http(s?):/, (_, secure) => `ws${secure}:`));
|
|
70
|
+
const connectSources = unique([
|
|
71
|
+
"'self'",
|
|
72
|
+
...configured,
|
|
73
|
+
...wsTwins,
|
|
74
|
+
...tierZoneWildcards(configured),
|
|
75
|
+
...extraConnectSrc,
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
return [
|
|
79
|
+
"default-src 'self'",
|
|
80
|
+
"base-uri 'none'",
|
|
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.
|
|
86
|
+
`frame-ancestors ${frameAncestorsSource(frameAncestors)}`,
|
|
87
|
+
// 'wasm-unsafe-eval' lets the SDK instantiate WebAssembly (the Rust
|
|
88
|
+
// language worker and the player-mod runtime). It grants no JS eval.
|
|
89
|
+
"script-src 'self' 'wasm-unsafe-eval'",
|
|
90
|
+
// Monaco injects computed styles; three.js/pixi do not need this but the
|
|
91
|
+
// editor does.
|
|
92
|
+
"style-src 'self' 'unsafe-inline'",
|
|
93
|
+
"img-src 'self' data: blob:",
|
|
94
|
+
"font-src 'self' data:",
|
|
95
|
+
`connect-src ${connectSources.join(' ')}`,
|
|
96
|
+
// The glue worker and Monaco workers are same-origin module workers; blob:
|
|
97
|
+
// covers Vite's dev-mode worker wrappers.
|
|
98
|
+
"worker-src 'self' blob:",
|
|
99
|
+
// 'self' lets Crowdy Studio embed the same-origin in-browser DeepSeek Harness (/dsh/).
|
|
100
|
+
"frame-src 'self'",
|
|
101
|
+
"media-src 'self' blob:",
|
|
102
|
+
"manifest-src 'self'",
|
|
103
|
+
].join('; ');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Build the Content-Security-Policy for the in-browser DeepSeek Harness (/dsh/*).
|
|
108
|
+
* The harness worker loads lowered module bodies with `new Function` and runs its
|
|
109
|
+
* own module system; its iframe is embedded by the game page ('self').
|
|
110
|
+
*/
|
|
111
|
+
export function buildDshCsp({ apiOrigins = [], extraConnectSrc = [], frameAncestors = [] } = {}) {
|
|
112
|
+
const configured = apiOrigins.map(originOf);
|
|
113
|
+
const wsTwins = configured
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
.map((origin) => origin.replace(/^http(s?):/, (_, secure) => `ws${secure}:`));
|
|
116
|
+
const connectSources = unique([
|
|
117
|
+
"'self'",
|
|
118
|
+
'blob:',
|
|
119
|
+
...configured,
|
|
120
|
+
...wsTwins,
|
|
121
|
+
...tierZoneWildcards(configured),
|
|
122
|
+
...extraConnectSrc,
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
return [
|
|
126
|
+
"default-src 'self'",
|
|
127
|
+
"base-uri 'none'",
|
|
128
|
+
"object-src 'none'",
|
|
129
|
+
// '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.
|
|
132
|
+
`frame-ancestors 'self'${frameAncestors.length ? ` ${frameAncestorsSource(frameAncestors)}` : ''}`,
|
|
133
|
+
"script-src 'self' 'unsafe-eval' 'unsafe-inline' blob:",
|
|
134
|
+
"style-src 'self' 'unsafe-inline'",
|
|
135
|
+
"img-src 'self' data: blob:",
|
|
136
|
+
"font-src 'self' data:",
|
|
137
|
+
`connect-src ${connectSources.join(' ')}`,
|
|
138
|
+
"worker-src 'self' blob:",
|
|
139
|
+
"frame-src 'self'",
|
|
140
|
+
"media-src 'self' blob:",
|
|
141
|
+
"manifest-src 'self'",
|
|
142
|
+
].join('; ');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Build the Content-Security-Policy for the JS grid program sandbox
|
|
147
|
+
* (`/grid-program.html`, framed by the game in `sandbox="allow-scripts"`).
|
|
148
|
+
* The program's code arrives as a blob module; it gets NO network at all
|
|
149
|
+
* (`connect-src 'none'`): everything it does crosses the MessagePort to the
|
|
150
|
+
* page, which relays it with a grid-scoped token (DN-10). No inline script,
|
|
151
|
+
* no eval, no workers, no frames, no forms.
|
|
152
|
+
*/
|
|
153
|
+
export function buildGridProgramCsp() {
|
|
154
|
+
return [
|
|
155
|
+
"default-src 'none'",
|
|
156
|
+
"base-uri 'none'",
|
|
157
|
+
"object-src 'none'",
|
|
158
|
+
"form-action 'none'",
|
|
159
|
+
"frame-ancestors 'self'",
|
|
160
|
+
"script-src 'self' blob:",
|
|
161
|
+
"style-src 'self' 'unsafe-inline'",
|
|
162
|
+
"img-src 'self' data: blob:",
|
|
163
|
+
"connect-src 'none'",
|
|
164
|
+
"worker-src 'none'",
|
|
165
|
+
"frame-src 'none'",
|
|
166
|
+
].join('; ');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** `frameAncestors` -> a CSP source list; `'none'` when nothing may frame the page. */
|
|
170
|
+
export function frameAncestorsSource(frameAncestors = []) {
|
|
171
|
+
const origins = unique(frameAncestors.map(originOf));
|
|
172
|
+
return origins.length ? origins.join(' ') : "'none'";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** `same-origin` unless somebody may frame the game, then `cross-origin` (see securityHeaders). */
|
|
176
|
+
export function corpFor({ frameAncestors = [] } = {}) {
|
|
177
|
+
return frameAncestors.some((a) => originOf(a)) ? 'cross-origin' : 'same-origin';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Which powerful features the page (and only the page) may use. */
|
|
181
|
+
export const PERMISSIONS_POLICY = 'camera=(self), microphone=(self)';
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The full header set. Apply to every response of the site (HTML, JS, worker
|
|
185
|
+
* scripts, wasm), not just index.html — a worker fetched without COEP breaks
|
|
186
|
+
* isolation for the page that spawned it.
|
|
187
|
+
*/
|
|
188
|
+
export function securityHeaders(options = {}) {
|
|
189
|
+
return {
|
|
190
|
+
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
191
|
+
// `credentialless` rather than `require-corp`: it lets the page load
|
|
192
|
+
// cross-origin subresources without CORP headers by stripping credentials,
|
|
193
|
+
// which is what a static game on a CDN needs. Both values enable isolation.
|
|
194
|
+
'Cross-Origin-Embedder-Policy': 'credentialless',
|
|
195
|
+
// `same-origin` for a top-level game. A FRAMED game must say `cross-origin`:
|
|
196
|
+
// browsers enforce CORP on nested navigations too, so under the shell's COEP a
|
|
197
|
+
// `same-origin` document is refused with ERR_BLOCKED_BY_RESPONSE before
|
|
198
|
+
// frame-ancestors is even consulted. Measured 2026-09-13 in the shell e2e.
|
|
199
|
+
'Cross-Origin-Resource-Policy': corpFor(options),
|
|
200
|
+
'Content-Security-Policy': buildCsp(options),
|
|
201
|
+
// The page may use the camera and microphone (WebcamService / VoiceService);
|
|
202
|
+
// nothing embedded may. An explicit policy also stops a hosting default
|
|
203
|
+
// from silently denying either device.
|
|
204
|
+
'Permissions-Policy': PERMISSIONS_POLICY,
|
|
205
|
+
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
|
206
|
+
'X-Content-Type-Options': 'nosniff',
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Security headers for the in-browser DeepSeek Harness surface (/dsh/*).
|
|
212
|
+
*/
|
|
213
|
+
export function dshSecurityHeaders(options = {}) {
|
|
214
|
+
return {
|
|
215
|
+
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
216
|
+
'Cross-Origin-Embedder-Policy': 'credentialless',
|
|
217
|
+
'Cross-Origin-Resource-Policy': corpFor(options),
|
|
218
|
+
'Content-Security-Policy': buildDshCsp(options),
|
|
219
|
+
'Permissions-Policy': PERMISSIONS_POLICY,
|
|
220
|
+
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
|
221
|
+
'X-Content-Type-Options': 'nosniff',
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Security headers for the JS grid program sandbox page. Same isolation as the
|
|
227
|
+
* game (a cross-origin-isolated page can only frame COEP documents), its own
|
|
228
|
+
* CSP, and no powerful features.
|
|
229
|
+
*/
|
|
230
|
+
export function gridProgramSecurityHeaders() {
|
|
231
|
+
return {
|
|
232
|
+
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
233
|
+
'Cross-Origin-Embedder-Policy': 'credentialless',
|
|
234
|
+
'Cross-Origin-Resource-Policy': 'same-origin',
|
|
235
|
+
'Content-Security-Policy': buildGridProgramCsp(),
|
|
236
|
+
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
|
|
237
|
+
'Referrer-Policy': 'no-referrer',
|
|
238
|
+
'X-Content-Type-Options': 'nosniff',
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The two headers that decide `crossOriginIsolated`; listed for docs/tests. */
|
|
243
|
+
export const ISOLATION_HEADERS = ['Cross-Origin-Opener-Policy', 'Cross-Origin-Embedder-Policy'];
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vite plugins every Construct game needs: path-scoped security headers for
|
|
3
|
+
* the two same-origin pages the game frames, the in-browser DeepSeek Harness
|
|
4
|
+
* (`/dsh/*`) and the JS grid program sandbox (`/grid-program.html`). The site
|
|
5
|
+
* headers (securityHeaders) are applied by Vite's `server.headers`; these
|
|
6
|
+
* override them for their own paths.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} name
|
|
11
|
+
* @param {(pathname: string) => boolean} match
|
|
12
|
+
* @param {Record<string, string>} headers
|
|
13
|
+
* @param {{ keepGzipArchives?: boolean }} [options]
|
|
14
|
+
*/
|
|
15
|
+
function pathHeadersPlugin(name, match, headers, options = {}) {
|
|
16
|
+
/** @param {import('vite').ViteDevServer | import('vite').PreviewServer} server */
|
|
17
|
+
const attach = (server) => {
|
|
18
|
+
server.middlewares.use((req, res, next) => {
|
|
19
|
+
const pathname = (req.url ?? '').split('?')[0] ?? '';
|
|
20
|
+
if (match(pathname)) {
|
|
21
|
+
// The harness's packed VFS image is a .tar.gz its worker inflates with
|
|
22
|
+
// DecompressionStream. Vite's static server adds `Content-Encoding: gzip`
|
|
23
|
+
// for that suffix, so the browser would inflate it first and the worker
|
|
24
|
+
// would then fail on plain tar. Only that suffix is affected.
|
|
25
|
+
const isGzipArchive = options.keepGzipArchives && /\.gz$/i.test(pathname);
|
|
26
|
+
const origSetHeader = res.setHeader.bind(res);
|
|
27
|
+
res.setHeader = (header, value) => {
|
|
28
|
+
if (isGzipArchive && header.toLowerCase() === 'content-encoding') return res;
|
|
29
|
+
const pinned = headers[header];
|
|
30
|
+
return origSetHeader(header, pinned !== undefined ? pinned : value);
|
|
31
|
+
};
|
|
32
|
+
for (const [header, value] of Object.entries(headers)) origSetHeader(header, value);
|
|
33
|
+
}
|
|
34
|
+
next();
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
return { name, configureServer: attach, configurePreviewServer: attach };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The grid program sandbox is an OPAQUE origin (`sandbox="allow-scripts"`
|
|
42
|
+
* without allow-same-origin), so its module scripts are fetched in CORS mode
|
|
43
|
+
* with `Origin: null`. Answer those, and only those, with
|
|
44
|
+
* `Access-Control-Allow-Origin: null`. The API paths are left alone: the
|
|
45
|
+
* sandbox has `connect-src 'none'` and reaches the API only through the page.
|
|
46
|
+
* The files served this way are the game's public bundle.
|
|
47
|
+
*/
|
|
48
|
+
function sandboxModuleCorsPlugin() {
|
|
49
|
+
/** @param {import('vite').ViteDevServer | import('vite').PreviewServer} server */
|
|
50
|
+
const attach = (server) => {
|
|
51
|
+
server.middlewares.use((req, res, next) => {
|
|
52
|
+
const pathname = (req.url ?? '').split('?')[0] ?? '';
|
|
53
|
+
const isApi = /^\/(graphql|realtime|s2s|api)(\/|$)/.test(pathname);
|
|
54
|
+
if (req.headers.origin === 'null' && req.method === 'GET' && !isApi) {
|
|
55
|
+
const origSetHeader = res.setHeader.bind(res);
|
|
56
|
+
res.setHeader = (header, value) =>
|
|
57
|
+
header.toLowerCase() === 'access-control-allow-origin'
|
|
58
|
+
? origSetHeader(header, 'null')
|
|
59
|
+
: origSetHeader(header, value);
|
|
60
|
+
origSetHeader('Access-Control-Allow-Origin', 'null');
|
|
61
|
+
origSetHeader('Vary', 'Origin');
|
|
62
|
+
}
|
|
63
|
+
next();
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
name: 'construct-grid-program-module-cors',
|
|
68
|
+
configureServer: attach,
|
|
69
|
+
configurePreviewServer: attach,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Omit `dshHeaders` when the game already serves `/dsh/*` under its own policy.
|
|
75
|
+
*
|
|
76
|
+
* @param {{ dshHeaders?: Record<string, string>, gridProgramHeaders?: Record<string, string> }} options
|
|
77
|
+
* @returns {import('vite').Plugin[]}
|
|
78
|
+
*/
|
|
79
|
+
export function constructHeaderPlugins({ dshHeaders, gridProgramHeaders }) {
|
|
80
|
+
const plugins = [];
|
|
81
|
+
if (dshHeaders) {
|
|
82
|
+
plugins.push(
|
|
83
|
+
pathHeadersPlugin(
|
|
84
|
+
'construct-dsh-headers',
|
|
85
|
+
(p) => p.startsWith('/dsh/') || p === '/dsh',
|
|
86
|
+
dshHeaders,
|
|
87
|
+
{ keepGzipArchives: true },
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (gridProgramHeaders) {
|
|
92
|
+
plugins.push(
|
|
93
|
+
sandboxModuleCorsPlugin(),
|
|
94
|
+
pathHeadersPlugin(
|
|
95
|
+
'construct-grid-program-headers',
|
|
96
|
+
(p) => p.endsWith('/grid-program.html'),
|
|
97
|
+
gridProgramHeaders,
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return plugins;
|
|
102
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crowdedkingdoms/construct",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The Construct framework
|
|
3
|
+
"version": "0.2.1-dev.1",
|
|
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
|
+
"type": "module",
|
|
6
7
|
"repository": {
|
|
7
8
|
"type": "git",
|
|
8
|
-
"url": "
|
|
9
|
+
"url": "https://github.com/CrowdedKingdoms/the-construct.git",
|
|
9
10
|
"directory": "packages/construct"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"sideEffects": [
|
|
16
|
+
"./sandbox/boot.ts"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
"src",
|
|
20
|
+
"node",
|
|
21
|
+
"sandbox",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"bin": {
|
|
25
|
+
"construct-copy-dsh": "./node/copy-dsh-web.mjs"
|
|
26
|
+
},
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./src/index.ts",
|
|
29
|
+
"./platform/onboarding/steps": {
|
|
30
|
+
"types": "./src/platform/onboarding/steps.d.mts",
|
|
31
|
+
"default": "./src/platform/onboarding/steps.mjs"
|
|
32
|
+
},
|
|
33
|
+
"./node/security-headers": {
|
|
34
|
+
"types": "./node/security-headers.d.mts",
|
|
35
|
+
"default": "./node/security-headers.mjs"
|
|
36
|
+
},
|
|
37
|
+
"./node/copy-dsh-web": "./node/copy-dsh-web.mjs",
|
|
38
|
+
"./node/vite-plugins": {
|
|
39
|
+
"types": "./node/vite-plugins.d.mts",
|
|
40
|
+
"default": "./node/vite-plugins.mjs"
|
|
41
|
+
},
|
|
42
|
+
"./sandbox/boot": "./sandbox/boot.ts",
|
|
43
|
+
"./engine/*": "./src/engine/*.ts",
|
|
44
|
+
"./platform/*": "./src/platform/*.ts",
|
|
45
|
+
"./grid/*": "./src/grid/*.ts"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@crowdedkingdoms/crowdyjs": ">=17.7.0-dev.0 <18 || >=17.8.0-dev.0 <18 || >=17.9.0-dev.0 <18 || >=17.12.0-dev.0 <18",
|
|
49
|
+
"@crowdedkingdoms/crowdy-dsh": ">=0.4.0-dev.0 <1"
|
|
50
|
+
},
|
|
51
|
+
"peerDependenciesMeta": {
|
|
52
|
+
"@crowdedkingdoms/crowdy-dsh": {
|
|
53
|
+
"optional": true
|
|
54
|
+
}
|
|
10
55
|
}
|
|
11
56
|
}
|
package/sandbox/boot.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs INSIDE the JS grid program sandbox (`grid-program.html`, framed with
|
|
3
|
+
* `sandbox="allow-scripts"`: an opaque origin with `connect-src 'none'`).
|
|
4
|
+
*
|
|
5
|
+
* The page sends one `init` message carrying the program's source and a
|
|
6
|
+
* MessagePort. The program is loaded as a blob module and its default export
|
|
7
|
+
* is called with real CrowdyJS whose network is that port (the page relays it
|
|
8
|
+
* with a grid-scoped token the program never sees):
|
|
9
|
+
*
|
|
10
|
+
* export default async function ({ client, grid, appId, gridId, box, log }) { … }
|
|
11
|
+
*
|
|
12
|
+
* Console output, errors and lifecycle are reported to the page, which shows
|
|
13
|
+
* them to the player and to the Studio agent (`grid_program_status`).
|
|
14
|
+
*/
|
|
15
|
+
import { createGridProgramClient } from '@crowdedkingdoms/crowdyjs/grid-program';
|
|
16
|
+
|
|
17
|
+
import { GRID_PROGRAM_SOURCE, type GridProgramSandboxMessage } from '../src/grid/sandboxProtocol';
|
|
18
|
+
|
|
19
|
+
const post = (message: GridProgramSandboxMessage) =>
|
|
20
|
+
window.parent.postMessage({ source: GRID_PROGRAM_SOURCE, ...message }, '*');
|
|
21
|
+
|
|
22
|
+
const format = (value: unknown): string => {
|
|
23
|
+
if (typeof value === 'string') return value;
|
|
24
|
+
if (value instanceof Error) return value.stack ?? value.message;
|
|
25
|
+
try {
|
|
26
|
+
return JSON.stringify(value, (_k, v) => (typeof v === 'bigint' ? v.toString() : v));
|
|
27
|
+
} catch {
|
|
28
|
+
return String(value);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
for (const level of ['log', 'info', 'warn', 'error'] as const) {
|
|
33
|
+
const original = console[level].bind(console);
|
|
34
|
+
console[level] = (...args: unknown[]) => {
|
|
35
|
+
original(...args);
|
|
36
|
+
post({ type: 'log', level, line: args.map(format).join(' ').slice(0, 2_000) });
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
window.addEventListener('error', (event) =>
|
|
40
|
+
post({ type: 'error', message: String(event.message) }),
|
|
41
|
+
);
|
|
42
|
+
window.addEventListener('unhandledrejection', (event) =>
|
|
43
|
+
post({ type: 'error', message: format((event as PromiseRejectionEvent).reason) }),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
let started = false;
|
|
47
|
+
window.addEventListener('message', (event) => {
|
|
48
|
+
if (event.source !== window.parent || started) return;
|
|
49
|
+
const data = event.data as { source?: string; type?: string; program?: string; path?: string };
|
|
50
|
+
if (data?.source !== GRID_PROGRAM_SOURCE || data.type !== 'init') return;
|
|
51
|
+
const port = event.ports[0];
|
|
52
|
+
if (!port || typeof data.program !== 'string') return;
|
|
53
|
+
started = true;
|
|
54
|
+
void (async () => {
|
|
55
|
+
const context = await createGridProgramClient(port);
|
|
56
|
+
const url = URL.createObjectURL(new Blob([data.program!], { type: 'text/javascript' }));
|
|
57
|
+
try {
|
|
58
|
+
const module = (await import(/* @vite-ignore */ url)) as {
|
|
59
|
+
default?: (ctx: unknown) => unknown;
|
|
60
|
+
};
|
|
61
|
+
if (typeof module.default !== 'function') {
|
|
62
|
+
throw new Error('a grid program must `export default` a function');
|
|
63
|
+
}
|
|
64
|
+
post({ type: 'started' });
|
|
65
|
+
await module.default({ ...context, log: (...args: unknown[]) => console.log(...args) });
|
|
66
|
+
} catch (error) {
|
|
67
|
+
post({ type: 'error', message: format(error) });
|
|
68
|
+
} finally {
|
|
69
|
+
URL.revokeObjectURL(url);
|
|
70
|
+
}
|
|
71
|
+
})();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
post({ type: 'ready' });
|