@bitmagic/asset-core 0.1.2 → 0.2.0

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.
@@ -0,0 +1,270 @@
1
+ /**
2
+ * What "the source of a published game" means, shared by the two sides that must agree on it.
3
+ *
4
+ * The CLI walks a creator's project to produce a fingerprint and a source archive; api-server
5
+ * re-derives the fingerprint from that archive's manifest to check the two describe the same
6
+ * tree. If the exclusion lists, the path normalisation or the fold ever differ between them, a
7
+ * perfectly honest publish reports a mismatch. So they live here, once.
8
+ *
9
+ * Import path matters, twice over:
10
+ *
11
+ * - Published as the `@bitmagic/asset-core/publish-source` subpath, following `./favicon`'s
12
+ * precedent, so consumers do not drag in the root barrel's zod/three/forger client. This is
13
+ * not cosmetic: `api-server/src/cli/cli-routes.ts` documents that eagerly importing the
14
+ * three.js graph into api-server's tests OOM-killed the CI worker. Nothing here may import
15
+ * from `./index.js`.
16
+ * - No zip library, no `fs`. Zip *format* handling stays in each consumer (the CLI writes an
17
+ * archive, api-server streams one apart) and only the pure logic is shared, which keeps this
18
+ * package dependency-clean and keeps the npm blast radius of a change small.
19
+ *
20
+ * The CLI ships to npm with this package pinned, while api-server runs from `main`, so the two
21
+ * routinely run DIFFERENT copies of this file. That is why the fingerprint is versioned — see
22
+ * `SOURCE_INDEX_ALGO`.
23
+ */
24
+ /**
25
+ * The fingerprint algorithm's identity, sent by the CLI and dispatched on by the server.
26
+ *
27
+ * Without it, adding one entry to `EXCLUDED_FILES` — which has happened three times, each for a
28
+ * good reason documented below — would make every publish from an already-installed CLI report a
29
+ * fingerprint mismatch, because that CLI is still hashing the old file set. The server keeps every
30
+ * algorithm it has ever shipped and picks by this value; an unknown or absent one is reported as
31
+ * "cannot check", never as "mismatch". Bump it in lockstep with any change to the two sets below
32
+ * or to `fingerprintFromIndex`.
33
+ */
34
+ export declare const SOURCE_INDEX_ALGO = "v1";
35
+ /**
36
+ * Directories whose contents never change what the bundle contains: our own artifacts
37
+ * (`.bitmagic/`, `dist/`), the dependency tree (`node_modules/` — pinned by the lockfile the
38
+ * scaffold writes), vite's cache, and git's object store. Including any of them would make the
39
+ * fingerprint change on something other than an edit, which would make the staleness gate reject
40
+ * a publish of an unchanged project.
41
+ *
42
+ * `.git` is the one that bit in practice. Hashing it means an EMPTY `git commit` — no source
43
+ * change at all — flips the fingerprint, because the commit writes new objects, a new ref value
44
+ * and a new reflog entry. That turns the ordinary `bitmagic verify` → `git commit` →
45
+ * `bitmagic publish` sequence into exit 3 ("The project has changed since it was last verified"),
46
+ * and `bitmagic upgrade` actively pushes creators into git by refusing a dirty tree. The
47
+ * scaffolded AGENTS.md's "do not edit any tracked file between verifying and publishing" cannot
48
+ * prevent it either: committing is not editing. It also made every publish rebuild, since the
49
+ * build manifest's own recorded fingerprint could never still match by the time publish ran.
50
+ */
51
+ export declare const EXCLUDED_DIRS: ReadonlySet<string>;
52
+ /**
53
+ * Files that can never change what the bundle contains, excluded at basename granularity.
54
+ *
55
+ * `.DS_Store`: on macOS, Finder writes it merely for opening the project folder, and no build tool
56
+ * ever reads it — so it is another way to flip the fingerprint without editing anything.
57
+ *
58
+ * `mechanics-plan.md`: the agent is told to rewrite it after every slice — moving shipped items
59
+ * out of the backlog and re-ranking the rest is the whole point of the file. Left in, the ordinary
60
+ * `bitmagic verify` → update the backlog → `bitmagic publish` sequence exits 3 over prose, which
61
+ * teaches agents to reach for `--force`, the one flag that also waves through a genuinely stale
62
+ * verify.
63
+ *
64
+ * `GAME-DESIGN.md`: same "no build tool reads it" test — nothing imports it,
65
+ * `vite.publish.config.js` cannot put prose in the bundle, and it exists to be REWRITTEN as the
66
+ * game evolves (it is the input `bitmagic cover` reads).
67
+ *
68
+ * The list is deliberately not a general ignore mechanism, because a false "fresh" ships an
69
+ * unverified bundle while a false "stale" only costs one `bitmagic verify`.
70
+ *
71
+ * NOTE the asymmetry with `SECRET_FILE_PATTERNS` below: these files are excluded from the
72
+ * FINGERPRINT but still travel in the source archive, because they are exactly what a human
73
+ * reading the archive wants. Secrets are the other way round.
74
+ */
75
+ export declare const EXCLUDED_FILES: ReadonlySet<string>;
76
+ /**
77
+ * Excluded from the fingerprint, but still carried in the source archive.
78
+ *
79
+ * The two sets answer different questions. The fingerprint asks "would changing this change the
80
+ * bundle?" — for a design document, no, which is why editing one must not invalidate a verify. The
81
+ * archive asks "would a human opening this want it?" — for a design document, emphatically yes: it
82
+ * is the single most useful file for understanding what the game was trying to be.
83
+ *
84
+ * `.DS_Store` is in neither, so it is deliberately absent here: nobody wants it and it is not
85
+ * evidence of anything.
86
+ */
87
+ export declare const ARCHIVED_UNFINGERPRINTED_FILES: ReadonlySet<string>;
88
+ /**
89
+ * Files that must never travel inside the source archive, however the fingerprint treats them.
90
+ *
91
+ * The archive lands in the portal bucket, and that bucket carries a BUCKET-level
92
+ * `allUsers:objectViewer` grant (see `.github/workflows/README.md`) — every object in it is
93
+ * world-readable. A creator's project is their working directory, and working directories collect
94
+ * credentials. Shipping one to a public URL because nobody thought about it is how an incident
95
+ * starts.
96
+ *
97
+ * These files are still hashed and still listed in the manifest (path + hash, no content), so the
98
+ * server can reconcile the fingerprint without ever receiving the bytes. That is the whole reason
99
+ * the manifest is separate from the payload.
100
+ *
101
+ * Deliberately a deny-list of shapes rather than an allow-list of known-good paths: an allow-list
102
+ * would silently drop a creator's own source file the first time someone used an unusual layout,
103
+ * and dropping game code from the archive is a quieter failure than including a stray dotfile.
104
+ * The trade is accepted knowingly — this list will never be complete, which is why the archive's
105
+ * storage key is also unguessable rather than relying on this alone.
106
+ */
107
+ export declare const SECRET_FILE_PATTERNS: readonly RegExp[];
108
+ /** Would including this path in the archive payload risk publishing a credential? */
109
+ export declare function isSecretPath(posixPath: string): boolean;
110
+ /**
111
+ * A relative path as the fingerprint hashes it: always forward-slashed.
112
+ *
113
+ * `sep` is a parameter rather than a direct `path.sep` read so this is testable on any
114
+ * platform — the Windows behaviour that makes normalization necessary cannot otherwise be
115
+ * exercised on the POSIX runners CI uses, which is exactly how a vacuous test gets written.
116
+ */
117
+ export declare function toPosixPath(relativePath: string, sep?: string): string;
118
+ /** One file as both sides see it: the project-relative posix path and the hash of its bytes. */
119
+ export interface SourceIndexEntry {
120
+ path: string;
121
+ sha256: string;
122
+ bytes: number;
123
+ /**
124
+ * For vendored engine files only: the same content hashed with line endings normalised, used
125
+ * exclusively to compare against the official release. See `engineComparisonHash` for why the
126
+ * raw `sha256` above cannot serve — in short, a Windows checkout rewrites LF to CRLF in the
127
+ * committed `engine/` tree and would otherwise report every engine file as modified.
128
+ *
129
+ * Computed here, by the side that actually has the bytes: the archive carries hashes rather than
130
+ * engine content, so the server can never normalise after the fact.
131
+ */
132
+ engineSha256?: string;
133
+ }
134
+ /**
135
+ * Every fingerprinted file in a project, plus the fingerprint they roll up to.
136
+ *
137
+ * Produced once per publish by the CLI (which owns the filesystem walk) and reconstructed by
138
+ * api-server from the archive's manifest. `files` is sorted by path — `fingerprintFromIndex`
139
+ * depends on it and sorts defensively rather than trusting the caller.
140
+ */
141
+ export interface SourceIndex {
142
+ algo: string;
143
+ files: SourceIndexEntry[];
144
+ fingerprint: string;
145
+ /**
146
+ * Files the archive carries but the fingerprint ignores — see
147
+ * `ARCHIVED_UNFINGERPRINTED_FILES`. Kept apart from `files` so they can never reach
148
+ * `fingerprintFromIndex`, which is the whole reason they were excluded in the first place.
149
+ */
150
+ archivedOnly: SourceIndexEntry[];
151
+ }
152
+ /**
153
+ * The fingerprint: a hash of everything that determines the published bundle.
154
+ *
155
+ * This is what lets `publish` decide whether a `verify` is too stale to trust, by CONTENT
156
+ * rather than by clock: a timestamp policy would accept an edit made one second after
157
+ * verifying and reject an untouched project that merely sat overnight.
158
+ *
159
+ * The path is hashed alongside the content so that moving a file — which changes what the
160
+ * bundle imports — changes the fingerprint even though no byte of any file did.
161
+ *
162
+ * Kept deliberately coarse (every tracked file, not a module graph): a false "stale" costs one
163
+ * `bitmagic verify`, while a false "fresh" ships an unverified bundle.
164
+ *
165
+ * The exact byte sequence folded here is load-bearing across two independently-released
166
+ * packages. Do not "clean up" the trailing newline or the separator without bumping
167
+ * `SOURCE_INDEX_ALGO`.
168
+ */
169
+ export declare function fingerprintFromIndex(files: readonly SourceIndexEntry[]): string;
170
+ /** Where the archive's manifest lives inside the zip. */
171
+ export declare const SOURCE_MANIFEST_NAME = "bitmagic-source.json";
172
+ /**
173
+ * The archive's manifest: enough for the server to re-derive the fingerprint and to check the
174
+ * vendored engine, without the archive having to carry either the engine or any secret.
175
+ *
176
+ * `omitted` lists the paths that are in `files` but deliberately absent from the payload, so a
177
+ * reader (human or server) can tell "not shipped, on purpose" from "the archive is truncated".
178
+ *
179
+ * Deliberately NO `publishVersion`. The archive has to be built and hashed BEFORE `publish/begin`
180
+ * — that hash is what `begin` bakes into the meta block — and the version is not allocated until
181
+ * `begin` returns. A field that cannot be known when the file is written cannot be in the file.
182
+ * The version is recorded where it is actually available: in the archive's storage key and in the
183
+ * meta block's own `bm:publish-version`.
184
+ */
185
+ export interface SourceManifest {
186
+ algo: string;
187
+ gameId: string;
188
+ engineVersion: string;
189
+ genre: string;
190
+ exportedAt: string;
191
+ fingerprint: string;
192
+ files: Record<string, string>;
193
+ omitted: string[];
194
+ /**
195
+ * Line-ending-normalised hashes for the vendored engine paths only, keyed the same way as
196
+ * `files`. Separate from `files` because the two answer different questions: `files` must stay
197
+ * byte-exact for the fingerprint, while this exists to survive a platform's checkout rules.
198
+ */
199
+ engineHashes: Record<string, string>;
200
+ }
201
+ /**
202
+ * Project-relative prefix of the vendored engine. `bitmagic init`/`upgrade` unpack the release
203
+ * tarball's `game/src/<root>` into `engine/<root>`, so every vendored file lives under this.
204
+ */
205
+ export declare const VENDORED_ENGINE_PREFIX = "engine/";
206
+ /**
207
+ * Map a path as it appears in the engine release tarball to where it lands in a scaffolded
208
+ * project, or null when the tarball entry is not vendored into projects at all.
209
+ *
210
+ * The tarball stores three shapes at different depths (see `cli/src/scaffold/engine-download.ts`):
211
+ * `game/src/<root>/…` → `engine/<root>/…`, `game/agent-docs/…` → `engine/agent-docs/…`, and
212
+ * `game/sw-cache-buster.js` → `sw-cache-buster.js` at the project root. `templates/` ships in the
213
+ * tarball for the scaffolder to read and is never vendored, so it maps to null.
214
+ *
215
+ * Lives here rather than in api-server because it is a statement about what the CLI did to the
216
+ * tarball, and the CLI is the other consumer that has to agree.
217
+ */
218
+ export declare function tarballPathToProjectPath(tarballPath: string): string | null;
219
+ /**
220
+ * Bytes every genuine published bundle carries, used to refuse a publish that is not an engine
221
+ * build at all.
222
+ *
223
+ * The scaffold's `index.html` emits a `<script type="importmap">` whose body Vite only RELOCATES —
224
+ * its import-map hook lifts the tag out and reinserts the matched text unchanged before the first
225
+ * module script — so these land byte for byte near the top of the single-file bundle, inside the
226
+ * head range `complete` already reads for the meta block.
227
+ *
228
+ * **Position is the constraint, not just survival, and it is why `id="game-container"` is not on
229
+ * this list.** `vite-plugin-singlefile` hoists the inlined script into `<head>`, so in a real
230
+ * 8.4MB bundle `</head>` sits at byte 8,471,158 and `<body>` — with the container div — begins
231
+ * eight megabytes past the head window. The div is present and unchanged; it is simply
232
+ * unreachable without reading the whole object, which is exactly what this design refuses to do.
233
+ * That cost a live refusal of a genuine publish in dev: a first spike validated the markers
234
+ * against a 1.29KB bundle, where "end of file" and "first 64KB" are the same place. Any marker
235
+ * added here must be checked for its OFFSET in a multi-megabyte bundle, not merely its presence.
236
+ *
237
+ * The import map is structurally safe in a way the body is not: a browser requires an import map
238
+ * to precede the first module script, so it cannot migrate below the inlined bundle no matter how
239
+ * large that bundle grows.
240
+ *
241
+ * Why a SUBSET of the alias entries rather than all of them. A creator's installed CLI is an older
242
+ * npm release than the running server, so the server must only require markers that both agree on.
243
+ * The CDN entries are disqualified outright — they embed the pinned three.js version, which moves
244
+ * whenever the CLI bumps it. These two are the ones that have to exist for a project to resolve
245
+ * its entry module at all, so requiring more would buy nothing and would turn any future alias
246
+ * change into a spurious refusal for every not-yet-upgraded project.
247
+ *
248
+ * What this is worth: it stops bitmagic.ai being used to host arbitrary HTML by someone who has an
249
+ * account and a game id. It is trivially copy-pasteable by anyone who looks, and is not claimed to
250
+ * be more than that.
251
+ */
252
+ export declare const ENGINE_BUILD_MARKERS: readonly string[];
253
+ /** Does this bundle's head carry every marker a genuine engine build has? */
254
+ export declare function hasEngineBuildMarkers(head: string): boolean;
255
+ /**
256
+ * Hash used ONLY when comparing a vendored engine file against the official release.
257
+ *
258
+ * Line endings are normalised first, which the fingerprint deliberately never does. The reason is
259
+ * Windows: `renderGitignore()` does not ignore `engine/`, so the vendored tree is committed, and
260
+ * AGENTS.md pushes creators into git (`bitmagic upgrade` refuses a dirty tree). A Windows checkout
261
+ * with the default `core.autocrlf=true` rewrites LF to CRLF in every `.ts` file it restores — so
262
+ * without this, every Windows creator would see 100% of their engine files reported as modified,
263
+ * forever, on every publish. That is a false alarm about the one check most likely to be believed.
264
+ *
265
+ * Confined to this comparison on purpose. Normalising inside the fingerprint would instead make
266
+ * the fingerprint disagree with itself across platforms in the staleness gate, where a false
267
+ * "fresh" is the dangerous direction.
268
+ */
269
+ export declare function engineComparisonHash(content: Uint8Array): string;
270
+ //# sourceMappingURL=publish-source.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"publish-source.d.ts","sourceRoot":"","sources":["../src/publish-source.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAQH;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,OAAO,CAAC;AAEtC;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,aAAa,EAAE,WAAW,CAAC,MAAM,CAM5C,CAAC;AAEH;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,cAAc,EAAE,WAAW,CAAC,MAAM,CAI7C,CAAC;AAEH;;;;;;;;;;GAUG;AACH,eAAO,MAAM,8BAA8B,EAAE,WAAW,CAAC,MAAM,CAG7D,CAAC;AAEH;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,oBAAoB,EAAE,SAAS,MAAM,EASjD,CAAC;AAEF,qFAAqF;AACrF,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAEvD;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,YAAY,EAAE,MAAM,EAAE,GAAG,GAAE,MAAY,GAAG,MAAM,CAE3E;AAED,gGAAgG;AAChG,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,gBAAgB,EAAE,CAAC;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,YAAY,EAAE,gBAAgB,EAAE,CAAC;CAClC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,SAAS,gBAAgB,EAAE,GAAG,MAAM,CAO/E;AAED,yDAAyD;AACzD,eAAO,MAAM,oBAAoB,yBAAyB,CAAC;AAE3D;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB;;;;OAIG;IACH,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACtC;AAED;;;GAGG;AACH,eAAO,MAAM,sBAAsB,YAAY,CAAC;AAEhD;;;;;;;;;;;GAWG;AACH,wBAAgB,wBAAwB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAW3E;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,eAAO,MAAM,oBAAoB,EAAE,SAAS,MAAM,EAGjD,CAAC;AAEF,6EAA6E;AAC7E,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAE3D;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,UAAU,GAAG,MAAM,CAMhE"}
@@ -0,0 +1,267 @@
1
+ /**
2
+ * What "the source of a published game" means, shared by the two sides that must agree on it.
3
+ *
4
+ * The CLI walks a creator's project to produce a fingerprint and a source archive; api-server
5
+ * re-derives the fingerprint from that archive's manifest to check the two describe the same
6
+ * tree. If the exclusion lists, the path normalisation or the fold ever differ between them, a
7
+ * perfectly honest publish reports a mismatch. So they live here, once.
8
+ *
9
+ * Import path matters, twice over:
10
+ *
11
+ * - Published as the `@bitmagic/asset-core/publish-source` subpath, following `./favicon`'s
12
+ * precedent, so consumers do not drag in the root barrel's zod/three/forger client. This is
13
+ * not cosmetic: `api-server/src/cli/cli-routes.ts` documents that eagerly importing the
14
+ * three.js graph into api-server's tests OOM-killed the CI worker. Nothing here may import
15
+ * from `./index.js`.
16
+ * - No zip library, no `fs`. Zip *format* handling stays in each consumer (the CLI writes an
17
+ * archive, api-server streams one apart) and only the pure logic is shared, which keeps this
18
+ * package dependency-clean and keeps the npm blast radius of a change small.
19
+ *
20
+ * The CLI ships to npm with this package pinned, while api-server runs from `main`, so the two
21
+ * routinely run DIFFERENT copies of this file. That is why the fingerprint is versioned — see
22
+ * `SOURCE_INDEX_ALGO`.
23
+ */
24
+ import { createHash } from 'node:crypto';
25
+ // Imported rather than taken from the ambient global, matching `node:crypto` above: this file's
26
+ // eslint config enumerates its globals deliberately, and an explicit import says "node-only" as
27
+ // clearly as the crypto one does.
28
+ import { TextDecoder } from 'node:util';
29
+ /**
30
+ * The fingerprint algorithm's identity, sent by the CLI and dispatched on by the server.
31
+ *
32
+ * Without it, adding one entry to `EXCLUDED_FILES` — which has happened three times, each for a
33
+ * good reason documented below — would make every publish from an already-installed CLI report a
34
+ * fingerprint mismatch, because that CLI is still hashing the old file set. The server keeps every
35
+ * algorithm it has ever shipped and picks by this value; an unknown or absent one is reported as
36
+ * "cannot check", never as "mismatch". Bump it in lockstep with any change to the two sets below
37
+ * or to `fingerprintFromIndex`.
38
+ */
39
+ export const SOURCE_INDEX_ALGO = 'v1';
40
+ /**
41
+ * Directories whose contents never change what the bundle contains: our own artifacts
42
+ * (`.bitmagic/`, `dist/`), the dependency tree (`node_modules/` — pinned by the lockfile the
43
+ * scaffold writes), vite's cache, and git's object store. Including any of them would make the
44
+ * fingerprint change on something other than an edit, which would make the staleness gate reject
45
+ * a publish of an unchanged project.
46
+ *
47
+ * `.git` is the one that bit in practice. Hashing it means an EMPTY `git commit` — no source
48
+ * change at all — flips the fingerprint, because the commit writes new objects, a new ref value
49
+ * and a new reflog entry. That turns the ordinary `bitmagic verify` → `git commit` →
50
+ * `bitmagic publish` sequence into exit 3 ("The project has changed since it was last verified"),
51
+ * and `bitmagic upgrade` actively pushes creators into git by refusing a dirty tree. The
52
+ * scaffolded AGENTS.md's "do not edit any tracked file between verifying and publishing" cannot
53
+ * prevent it either: committing is not editing. It also made every publish rebuild, since the
54
+ * build manifest's own recorded fingerprint could never still match by the time publish ran.
55
+ */
56
+ export const EXCLUDED_DIRS = new Set([
57
+ '.bitmagic',
58
+ 'node_modules',
59
+ 'dist',
60
+ '.vite-cache',
61
+ '.git',
62
+ ]);
63
+ /**
64
+ * Files that can never change what the bundle contains, excluded at basename granularity.
65
+ *
66
+ * `.DS_Store`: on macOS, Finder writes it merely for opening the project folder, and no build tool
67
+ * ever reads it — so it is another way to flip the fingerprint without editing anything.
68
+ *
69
+ * `mechanics-plan.md`: the agent is told to rewrite it after every slice — moving shipped items
70
+ * out of the backlog and re-ranking the rest is the whole point of the file. Left in, the ordinary
71
+ * `bitmagic verify` → update the backlog → `bitmagic publish` sequence exits 3 over prose, which
72
+ * teaches agents to reach for `--force`, the one flag that also waves through a genuinely stale
73
+ * verify.
74
+ *
75
+ * `GAME-DESIGN.md`: same "no build tool reads it" test — nothing imports it,
76
+ * `vite.publish.config.js` cannot put prose in the bundle, and it exists to be REWRITTEN as the
77
+ * game evolves (it is the input `bitmagic cover` reads).
78
+ *
79
+ * The list is deliberately not a general ignore mechanism, because a false "fresh" ships an
80
+ * unverified bundle while a false "stale" only costs one `bitmagic verify`.
81
+ *
82
+ * NOTE the asymmetry with `SECRET_FILE_PATTERNS` below: these files are excluded from the
83
+ * FINGERPRINT but still travel in the source archive, because they are exactly what a human
84
+ * reading the archive wants. Secrets are the other way round.
85
+ */
86
+ export const EXCLUDED_FILES = new Set([
87
+ '.DS_Store',
88
+ 'GAME-DESIGN.md',
89
+ 'mechanics-plan.md',
90
+ ]);
91
+ /**
92
+ * Excluded from the fingerprint, but still carried in the source archive.
93
+ *
94
+ * The two sets answer different questions. The fingerprint asks "would changing this change the
95
+ * bundle?" — for a design document, no, which is why editing one must not invalidate a verify. The
96
+ * archive asks "would a human opening this want it?" — for a design document, emphatically yes: it
97
+ * is the single most useful file for understanding what the game was trying to be.
98
+ *
99
+ * `.DS_Store` is in neither, so it is deliberately absent here: nobody wants it and it is not
100
+ * evidence of anything.
101
+ */
102
+ export const ARCHIVED_UNFINGERPRINTED_FILES = new Set([
103
+ 'GAME-DESIGN.md',
104
+ 'mechanics-plan.md',
105
+ ]);
106
+ /**
107
+ * Files that must never travel inside the source archive, however the fingerprint treats them.
108
+ *
109
+ * The archive lands in the portal bucket, and that bucket carries a BUCKET-level
110
+ * `allUsers:objectViewer` grant (see `.github/workflows/README.md`) — every object in it is
111
+ * world-readable. A creator's project is their working directory, and working directories collect
112
+ * credentials. Shipping one to a public URL because nobody thought about it is how an incident
113
+ * starts.
114
+ *
115
+ * These files are still hashed and still listed in the manifest (path + hash, no content), so the
116
+ * server can reconcile the fingerprint without ever receiving the bytes. That is the whole reason
117
+ * the manifest is separate from the payload.
118
+ *
119
+ * Deliberately a deny-list of shapes rather than an allow-list of known-good paths: an allow-list
120
+ * would silently drop a creator's own source file the first time someone used an unusual layout,
121
+ * and dropping game code from the archive is a quieter failure than including a stray dotfile.
122
+ * The trade is accepted knowingly — this list will never be complete, which is why the archive's
123
+ * storage key is also unguessable rather than relying on this alone.
124
+ */
125
+ export const SECRET_FILE_PATTERNS = [
126
+ /(^|\/)\.env($|\.)/,
127
+ /\.pem$/,
128
+ /\.key$/,
129
+ /(^|\/)id_rsa/,
130
+ /(^|\/)\.npmrc$/,
131
+ /(^|\/)\.git-credentials$/,
132
+ /service-account[^/]*\.json$/,
133
+ /(^|\/)\.claude\/settings\.local\.json$/,
134
+ ];
135
+ /** Would including this path in the archive payload risk publishing a credential? */
136
+ export function isSecretPath(posixPath) {
137
+ return SECRET_FILE_PATTERNS.some((pattern) => pattern.test(posixPath));
138
+ }
139
+ /**
140
+ * A relative path as the fingerprint hashes it: always forward-slashed.
141
+ *
142
+ * `sep` is a parameter rather than a direct `path.sep` read so this is testable on any
143
+ * platform — the Windows behaviour that makes normalization necessary cannot otherwise be
144
+ * exercised on the POSIX runners CI uses, which is exactly how a vacuous test gets written.
145
+ */
146
+ export function toPosixPath(relativePath, sep = '/') {
147
+ return relativePath.split(sep).join('/');
148
+ }
149
+ /**
150
+ * The fingerprint: a hash of everything that determines the published bundle.
151
+ *
152
+ * This is what lets `publish` decide whether a `verify` is too stale to trust, by CONTENT
153
+ * rather than by clock: a timestamp policy would accept an edit made one second after
154
+ * verifying and reject an untouched project that merely sat overnight.
155
+ *
156
+ * The path is hashed alongside the content so that moving a file — which changes what the
157
+ * bundle imports — changes the fingerprint even though no byte of any file did.
158
+ *
159
+ * Kept deliberately coarse (every tracked file, not a module graph): a false "stale" costs one
160
+ * `bitmagic verify`, while a false "fresh" ships an unverified bundle.
161
+ *
162
+ * The exact byte sequence folded here is load-bearing across two independently-released
163
+ * packages. Do not "clean up" the trailing newline or the separator without bumping
164
+ * `SOURCE_INDEX_ALGO`.
165
+ */
166
+ export function fingerprintFromIndex(files) {
167
+ const sorted = [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
168
+ const hash = createHash('sha256');
169
+ for (const entry of sorted) {
170
+ hash.update(`${entry.path}:${entry.sha256}\n`);
171
+ }
172
+ return hash.digest('hex');
173
+ }
174
+ /** Where the archive's manifest lives inside the zip. */
175
+ export const SOURCE_MANIFEST_NAME = 'bitmagic-source.json';
176
+ /**
177
+ * Project-relative prefix of the vendored engine. `bitmagic init`/`upgrade` unpack the release
178
+ * tarball's `game/src/<root>` into `engine/<root>`, so every vendored file lives under this.
179
+ */
180
+ export const VENDORED_ENGINE_PREFIX = 'engine/';
181
+ /**
182
+ * Map a path as it appears in the engine release tarball to where it lands in a scaffolded
183
+ * project, or null when the tarball entry is not vendored into projects at all.
184
+ *
185
+ * The tarball stores three shapes at different depths (see `cli/src/scaffold/engine-download.ts`):
186
+ * `game/src/<root>/…` → `engine/<root>/…`, `game/agent-docs/…` → `engine/agent-docs/…`, and
187
+ * `game/sw-cache-buster.js` → `sw-cache-buster.js` at the project root. `templates/` ships in the
188
+ * tarball for the scaffolder to read and is never vendored, so it maps to null.
189
+ *
190
+ * Lives here rather than in api-server because it is a statement about what the CLI did to the
191
+ * tarball, and the CLI is the other consumer that has to agree.
192
+ */
193
+ export function tarballPathToProjectPath(tarballPath) {
194
+ if (tarballPath.startsWith('game/src/')) {
195
+ return `${VENDORED_ENGINE_PREFIX}${tarballPath.slice('game/src/'.length)}`;
196
+ }
197
+ if (tarballPath.startsWith('game/agent-docs/')) {
198
+ return `${VENDORED_ENGINE_PREFIX}agent-docs/${tarballPath.slice('game/agent-docs/'.length)}`;
199
+ }
200
+ if (tarballPath === 'game/sw-cache-buster.js') {
201
+ return 'sw-cache-buster.js';
202
+ }
203
+ return null;
204
+ }
205
+ /**
206
+ * Bytes every genuine published bundle carries, used to refuse a publish that is not an engine
207
+ * build at all.
208
+ *
209
+ * The scaffold's `index.html` emits a `<script type="importmap">` whose body Vite only RELOCATES —
210
+ * its import-map hook lifts the tag out and reinserts the matched text unchanged before the first
211
+ * module script — so these land byte for byte near the top of the single-file bundle, inside the
212
+ * head range `complete` already reads for the meta block.
213
+ *
214
+ * **Position is the constraint, not just survival, and it is why `id="game-container"` is not on
215
+ * this list.** `vite-plugin-singlefile` hoists the inlined script into `<head>`, so in a real
216
+ * 8.4MB bundle `</head>` sits at byte 8,471,158 and `<body>` — with the container div — begins
217
+ * eight megabytes past the head window. The div is present and unchanged; it is simply
218
+ * unreachable without reading the whole object, which is exactly what this design refuses to do.
219
+ * That cost a live refusal of a genuine publish in dev: a first spike validated the markers
220
+ * against a 1.29KB bundle, where "end of file" and "first 64KB" are the same place. Any marker
221
+ * added here must be checked for its OFFSET in a multi-megabyte bundle, not merely its presence.
222
+ *
223
+ * The import map is structurally safe in a way the body is not: a browser requires an import map
224
+ * to precede the first module script, so it cannot migrate below the inlined bundle no matter how
225
+ * large that bundle grows.
226
+ *
227
+ * Why a SUBSET of the alias entries rather than all of them. A creator's installed CLI is an older
228
+ * npm release than the running server, so the server must only require markers that both agree on.
229
+ * The CDN entries are disqualified outright — they embed the pinned three.js version, which moves
230
+ * whenever the CLI bumps it. These two are the ones that have to exist for a project to resolve
231
+ * its entry module at all, so requiring more would buy nothing and would turn any future alias
232
+ * change into a spurious refusal for every not-yet-upgraded project.
233
+ *
234
+ * What this is worth: it stops bitmagic.ai being used to host arbitrary HTML by someone who has an
235
+ * account and a game id. It is trivially copy-pasteable by anyone who looks, and is not claimed to
236
+ * be more than that.
237
+ */
238
+ export const ENGINE_BUILD_MARKERS = [
239
+ '"engine/": "/dist/engine/engine/"',
240
+ '"types/": "/dist/engine/types/"',
241
+ ];
242
+ /** Does this bundle's head carry every marker a genuine engine build has? */
243
+ export function hasEngineBuildMarkers(head) {
244
+ return ENGINE_BUILD_MARKERS.every((marker) => head.includes(marker));
245
+ }
246
+ /**
247
+ * Hash used ONLY when comparing a vendored engine file against the official release.
248
+ *
249
+ * Line endings are normalised first, which the fingerprint deliberately never does. The reason is
250
+ * Windows: `renderGitignore()` does not ignore `engine/`, so the vendored tree is committed, and
251
+ * AGENTS.md pushes creators into git (`bitmagic upgrade` refuses a dirty tree). A Windows checkout
252
+ * with the default `core.autocrlf=true` rewrites LF to CRLF in every `.ts` file it restores — so
253
+ * without this, every Windows creator would see 100% of their engine files reported as modified,
254
+ * forever, on every publish. That is a false alarm about the one check most likely to be believed.
255
+ *
256
+ * Confined to this comparison on purpose. Normalising inside the fingerprint would instead make
257
+ * the fingerprint disagree with itself across platforms in the staleness gate, where a false
258
+ * "fresh" is the dangerous direction.
259
+ */
260
+ export function engineComparisonHash(content) {
261
+ // TextDecoder rather than Buffer: this module is imported by an npm-published CLI as well as by
262
+ // the server, and keeping Node's Buffer out of it means the file has no ambient-global
263
+ // dependency beyond `node:crypto`, which it imports explicitly.
264
+ const text = new TextDecoder('utf-8').decode(content);
265
+ return createHash('sha256').update(text.replace(/\r\n/g, '\n'), 'utf8').digest('hex');
266
+ }
267
+ //# sourceMappingURL=publish-source.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"publish-source.js","sourceRoot":"","sources":["../src/publish-source.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitmagic/asset-core",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -17,12 +17,9 @@
17
17
  "types": "./dist/favicon.d.ts",
18
18
  "default": "./dist/favicon.js"
19
19
  },
20
- "./animation/*": {
21
- "default": "./src/animation/*"
22
- },
23
- "./animation/authored.mjs": {
24
- "types": "./src/animation/authored.d.mts",
25
- "default": "./src/animation/authored.mjs"
20
+ "./publish-source": {
21
+ "types": "./dist/publish-source.d.ts",
22
+ "default": "./dist/publish-source.js"
26
23
  }
27
24
  },
28
25
  "files": [
@@ -32,8 +29,7 @@
32
29
  "access": "public"
33
30
  },
34
31
  "dependencies": {
35
- "zod": "^3.25.76",
36
- "three": "^0.185.1"
32
+ "zod": "^3.25.76"
37
33
  },
38
34
  "devDependencies": {
39
35
  "@eslint/js": "^9.38.0",