@onlyworlds/sdk 4.48.1 → 4.50.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.
package/AGENTS.md CHANGED
@@ -79,7 +79,10 @@ against live data). The v1 surface (`OnlyWorldsClient`, the `ElementType` enum)
79
79
  - World files (no account needed; since 4.10.0): `readWorldFile(bytes)` opens a `.zip` holding one
80
80
  world folder and returns `{ worldId, name, world, files: [{ path, data }] }`;
81
81
  `writeWorldFile(files, topDir)` makes one. Refusals throw `OwWorldFileError` with a `reason`.
82
- `world.json`'s `api` block (link and key) is removed both ways.
82
+ `world.json`'s `api` block (link and key) is removed both ways. Both follow Folder Format
83
+ §4.7 (`.owworld`): the reader checks the central directory before inflating
84
+ and ignores files beside the world's folder; the writer's output is byte-stable (marker
85
+ first, UTF-8 name order, 1980 stamps) and holds only `world.json` and the folder's parts.
83
86
  - World calendars (since 4.20.0): `worldCalendars(elements)` finds the elements carrying
84
87
  `atlas_calendar` (Atlas owns the field: read it, never write it); `resolveCalendarLens` picks
85
88
  one; `formatStep(config, step)` names a point on the world's integer time line, `parseDate`
package/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ earlier history lives in git log only.
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [4.50.0] - 2026-10-22
9
+
10
+ ### Added
11
+
12
+ - `setMyCharacter(characterId | null)`: `PUT /me/character`, a person member's pick of the roster Character that is them in this world (keel PR 160, #533); answers the `/me` body. `err.isCharacterHidden` for the 422 `character_hidden` it and the member PATCH answer.
13
+ - `hidden?: true` on every element body (`OwElementBase`): present only when the owner or a co-builder hid the element and this reader still sees it (keel #537); never on other readers' bodies, ignored in a write.
14
+ - `site` joins `OwWriteDoor`: a write or delete the owner made signed in on onlyworlds.com (keel #530).
15
+
16
+ ## [4.49.0] - 2026-10-22
17
+
18
+ ### Changed
19
+
20
+ - **The world file follows Folder Format §4.7, the `.owworld` archive** (OW Council #522), and gives the spec's verdict and cause on every one of the 74 fixtures in its conformance set (Quillon's, `owworld-fixtures`), plus its three on-demand floor cases.
21
+ - The reader parses the central directory itself (zip64 included) and runs every check on names, kinds, methods and sizes before it inflates anything.
22
+ - Six new refusal causes join `OwWorldFileReason`: `duplicate` (OW-17), `collision` (OW-18: NFC, then simple case folding), `link` (OW-20), `method` (OW-21), `name` (OW-22) and `marker` (OW-6). A `Record<OwWorldFileReason, …>` in a caller needs the six new keys.
23
+ - The OW-18 fold is the C and S rows of Unicode's CaseFolding.txt 15.1, built in as a table, never the runtime's casing, so a fold a newer Unicode adds stays out until the pin moves (Skeld's ruling, OW Council #9830). `simpleCaseFold(text)` exports it.
24
+ - Files beside the world's folder are ignored, no longer `more_than_one` (OW-4). A `world.json` under `records/` is a producer's data, not a second world (OW-5). A spanned archive is `not_openable`. An entry that inflates past the size it declares is `too_big` at any budget (OW-14).
25
+ - The default budget is the spec's floor: `WORLD_FILE_MAX_BYTES` 2 GiB (was 1 GiB), `WORLD_FILE_MAX_ENTRIES` 500,000 (was 200,000) (OW-15).
26
+ - A `mimetype` marker is never required. One with other bytes than `WORLD_FILE_MEDIA_TYPE` (`application/vnd.onlyworlds.world+zip`, newly exported) is refused, and the marker is never returned as a file.
27
+ - `files` come back in code point order (the UTF-8 bytes of the name), not UTF-16 order.
28
+ - The writer gives one set of bytes for one input (OW-12, OW-13). It places the `mimetype` marker first and stored, orders entries by the UTF-8 bytes of their names, and stamps every entry 1980-01-01 00:00 (was the clock).
29
+ - The writer writes only `world.json`, `elements/`, `spatial/`, `media/`, `records/` and a tool's dot-folder it is handed (OW-7). It leaves out any other file, and a dot-name or `.meta` inside a content folder. It refuses a `name` or a `collision` as the reader would.
30
+
8
31
  ## [4.48.1] - 2026-10-22
9
32
 
10
33
  ### Changed
package/README.md CHANGED
@@ -240,19 +240,34 @@ const { token_rating, tokens_remaining, resets_at } = await account.allowance();
240
240
 
241
241
  ## World files
242
242
 
243
- A world file is one `.zip` holding a whole world folder (`world.json` and `elements/<type>/*.json`, as Atlas keeps a world on disk), the way Atlas downloads and opens one. It needs no account and works in the browser and in Node.
243
+ A world file (`.owworld`, a `.zip` by another name) is one archive holding a whole world folder (`world.json` and `elements/<type>/*.json`, as Atlas keeps a world on disk), the way Atlas downloads and opens one. It needs no account and works in the browser and in Node. Both functions follow the Folder Format spec's §4.7 and pass its fixture set.
244
244
 
245
245
  ```typescript
246
246
  import { readWorldFile, writeWorldFile, OwWorldFileError } from '@onlyworlds/sdk';
247
247
 
248
248
  const world = readWorldFile(bytes); // a Uint8Array
249
249
  world.worldId; world.name; world.world; // world.json, parsed
250
- world.files; // [{ path, data }], relative to the world's folder, sorted
250
+ world.files; // [{ path, data }], relative to the world's folder, in code point order
251
251
 
252
252
  const zip = writeWorldFile(world.files, 'hollow-reach-0192a000'); // one top-level folder
253
253
  ```
254
254
 
255
- The reader refuses a file before returning anything: a path that could land outside the folder, more than 1 GiB or 200,000 files uncompressed (`maxBytes`, `maxEntries` to change them), no `world.json` with an `id`, or more than one world. Check `err.reason` on the `OwWorldFileError` it throws. The world's OnlyWorlds link and key (`world.json`'s `api`) never travel: both functions remove them. A tool's own folder (`.atlas/` and the like) comes back like any other file; pass `skip: path => …` to leave files out.
255
+ The reader checks every entry before it inflates anything, and refuses the file before returning anything. Check `err.reason` on the `OwWorldFileError` it throws:
256
+
257
+ | `reason` | what the file holds |
258
+ |---|---|
259
+ | `not_openable` | not a zip, a spanned one, or a broken entry |
260
+ | `unsafe_path` | a path that could land outside the folder, or a path that is both a file and a folder |
261
+ | `name` | a name some disk cannot hold: `CON` and the other device names, a trailing dot or space, `<>:"\|?*`, a control character, a non-ASCII name without the UTF-8 flag |
262
+ | `duplicate`, `collision` | two entries with one name, or two names that differ only in case or in how an accent is written |
263
+ | `link`, `method` | a link or special file, an encrypted entry, or a method other than stored or deflate |
264
+ | `too_big`, `too_many` | more than 2 GiB or 500,000 files uncompressed (`maxBytes`, `maxEntries` to change them), or an entry bigger than it says |
265
+ | `marker` | a `mimetype` entry with other bytes than `WORLD_FILE_MEDIA_TYPE` (a marker is never required) |
266
+ | `not_a_world`, `more_than_one` | no `world.json` with an `id`, two worlds, or a second `world.json` inside the world (one under `records/` is data) |
267
+
268
+ Files beside the world's folder and archive junk (`__MACOSX`, `.DS_Store`) are ignored. The world's OnlyWorlds link and key (`world.json`'s `api`) never travel: both functions remove them. A tool's own folder (`.atlas/` and the like) comes back like any other file; pass `skip: path => …` to leave files out.
269
+
270
+ The writer gives the same bytes for the same files: the `mimetype` marker first, then every entry in the UTF-8 order of its name, stamped 1980-01-01 00:00. It writes `world.json`, `elements/`, `spatial/`, `media/` and `records/`, and leaves any other file out. A tool's dot-folder you hand it travels too, which makes the file a private copy rather than a portable `.owworld`.
256
271
 
257
272
  ## World calendars
258
273
 
package/dist/index.d.ts CHANGED
@@ -29,8 +29,10 @@ interface OwElementBase {
29
29
  created_by?: string | null;
30
30
  /** The membership whose write last changed this element (server-managed, read-only); null for the world's owner and for anything last changed before writes recorded it. */
31
31
  changed_by?: string | null;
32
- /** The door of the last write (server-managed, read-only): `seat` (an agent seat), `mcp`, `declared` (a client that sent X-OW-Agent), then `api`, `v1`, `sync`, or `server` (keel's own writes). The first three are agent-made. Null for anything last changed before writes recorded it. */
33
- changed_via?: 'seat' | 'mcp' | 'declared' | 'api' | 'v1' | 'sync' | 'server' | null;
32
+ /** The door of the last write (server-managed, read-only): `seat` (an agent seat), `mcp`, `declared` (a client that sent X-OW-Agent), then `api`, `v1`, `sync`, `server` (keel's own writes) or `site` (the owner signed in on onlyworlds.com; keel PR 160). The first three are agent-made. Null for anything last changed before writes recorded it. */
33
+ changed_via?: 'seat' | 'mcp' | 'declared' | 'api' | 'v1' | 'sync' | 'server' | 'site' | null;
34
+ /** Present, and true, only when the world's owner or a co-builder hid this element and this reader still sees it (server-managed, read-only): the owner, a co-builder, a key the owner minted, or the member who made it; to every other reader the element does not exist. Absent otherwise, whatever `fields` asked; on the read, the list, the change feed's upserts and MCP, never in the download, world sync or v1. Sent in a write it is ignored. */
35
+ hidden?: true;
34
36
  /** Present, and true, only on a stub (server-managed, read-only): an element a guest or player reaches only through its own Character's sheet. A stub keeps id, type, name, description, supertype, subtype, image_url, created_at, updated_at and change_seq, and the fields its type lists in PUBLIC_FIELDS with their values; every other field reads unset ("", null, []), created_by/changed_by/changed_via null, no extension fields, and its files are 404. Its empties are not values and not a deletion: never write a stub back. Absent: the element is whole. See isKnownOf(). */
35
37
  known_of?: true;
36
38
  /** Namespaced extension fields (atlas_* / shadow_* / x_*), returned verbatim. */
@@ -2920,6 +2922,11 @@ declare class OwApiError extends Error {
2920
2922
  * previewJoin, whose `characters` mark each `taken`. The code is not spent.
2921
2923
  */
2922
2924
  get isCharacterTaken(): boolean;
2925
+ /**
2926
+ * setMyCharacter, or the member PATCH's `character` (keel #421, PR 160): the picked Character is
2927
+ * hidden (422 `character_hidden`, `param` character). Unhide it first, or pick another.
2928
+ */
2929
+ get isCharacterHidden(): boolean;
2923
2930
  /**
2924
2931
  * The `character` sent was refused (422 `invalid_request`, `param` character). On redeemJoin
2925
2932
  * at a table (keel #422 PR 2): the pick is off the link's list, hidden or deleted since the
@@ -3148,6 +3155,14 @@ declare class OwV2Client {
3148
3155
  * creates; null for the owner) and the member's Character. A world key's call; an account
3149
3156
  * token is a TypeError before sending.
3150
3157
  */
3158
+ /**
3159
+ * PUT /me/character -- pick the roster Character that is you in this world, or none (keel PR 160,
3160
+ * #533). A person member's write key with its PIN; answers GET /me's body. 422 for a hidden
3161
+ * Character (`character_hidden`, `err.isCharacterHidden`), a guest's pick it did not make, or a
3162
+ * taken Character (`character_taken`); 404 for one this key cannot see; 403 for an agent seat, a
3163
+ * player, the owner (who sets it on the world page) or a read key. A guest's view resets once.
3164
+ */
3165
+ setMyCharacter(character: string | null): Promise<OwMe>;
3151
3166
  me(): Promise<OwMe>;
3152
3167
  /**
3153
3168
  * GET /members -- the world's roster: each member's id (null for the owner), kind, role,
@@ -3842,33 +3857,52 @@ declare function getElementIcon(type: string): string;
3842
3857
  declare function getElementLabel(elementType: ElementType): string;
3843
3858
 
3844
3859
  /**
3845
- * The world file: one `.zip` holding one whole world as a portable copy
3846
- * (Folder Format spec §4.1, "a portable copy"), and the reader that opens one
3847
- * back. Absorbed from Atlas's `src/store/world-file.ts` (Temper's, Atlas
3848
- * 1.17), so Atlas, Lantern and any script read one file the same way.
3849
- *
3850
- * THE FILE: one top-level folder holding every file of the world as it sits
3851
- * on disk, except the OnlyWorlds link and its key (`world.json` without `api`).
3852
- * JSON is deflated, everything else stored. A world.json at the root, with no
3853
- * folder, reads the same.
3860
+ * The world file, `.owworld` (Folder Format spec §4.7; a `.zip` by another
3861
+ * name): one archive holding one whole world as a portable copy (§4.1), and
3862
+ * the reader that opens one back. Absorbed from Atlas's
3863
+ * `src/store/world-file.ts` (Temper's, Atlas 1.17), so Atlas, Lantern and any
3864
+ * script read one file the same way; brought to §4.7's checks OW-1 to OW-22
3865
+ * and their fixture set (OW Council #522).
3854
3866
  *
3855
3867
  * THE READER takes bytes and returns bytes: no DOM, `File` or file system, so
3856
- * it runs in a browser and in Node alike. It refuses, before returning
3857
- * anything: a path that climbs out, is absolute, names a drive, holds a
3858
- * backslash or an empty part, or is both a file and a folder; a declared size
3859
- * or a file count past the budget (and the inflated bytes are counted again,
3860
- * since a declared size can lie); no world, more than one world, files
3861
- * outside the world's folder, a world.json nested inside the world; and a
3862
- * world.json that will not parse or has no string `id`. Archive junk
3863
- * (`__MACOSX`, `.DS_Store`, `Thumbs.db`) is dropped.
3868
+ * it runs in a browser and in Node alike. It reads the central directory
3869
+ * itself (zip64 included) and runs every check on names, kinds, methods and
3870
+ * sizes before it inflates anything. Each refusal names one cause
3871
+ * (`OwWorldFileReason`), checked in the reference checker's order:
3872
+ * - `not_openable`: not a zip, a spanned one, a broken entry or CRC.
3873
+ * - `unsafe_path` (OW-16, OW-19): a name that is not a plain relative path
3874
+ * (`\`, an empty, `.` or `..` segment, absolute, a drive), or a path that is
3875
+ * both a file and a folder.
3876
+ * - `link` (OW-20): a Unix mode that is not a regular file or a directory.
3877
+ * - `method` (OW-21): encrypted, or a method other than stored or deflate.
3878
+ * - `name` (OW-22): a Windows device name, a trailing dot or space, one of
3879
+ * `<>:"|?*` or a control character, a non-ASCII name without the UTF-8 flag.
3880
+ * - `duplicate` (OW-17), `collision` (OW-18: NFC, then simple case folding).
3881
+ * - `too_many`, `too_big` (OW-14, OW-15): past the budget by count or declared
3882
+ * size, or an entry that inflates past the size it declares.
3883
+ * - `marker` (OW-6): a root `mimetype` entry with other bytes than the media
3884
+ * type. A marker is never required.
3885
+ * - `not_a_world`, `more_than_one` (OW-2, OW-3, OW-5): no world, two worlds, a
3886
+ * second `world.json` inside the world (one under `records/` is a
3887
+ * producer's data), a `world.json` that will not parse or has no string id.
3888
+ * Files beside the world's folder and archive junk (`__MACOSX`, `.DS_Store`,
3889
+ * `Thumbs.db`) are ignored (OW-4), still checked and counted.
3890
+ *
3891
+ * THE WRITER emits a portable copy (OW-7, OW-12, OW-13): the `mimetype` marker
3892
+ * first and stored, then only the folder's parts, every entry stamped
3893
+ * 1980-01-01 00:00, in the UTF-8 byte order of its name, JSON deflated and
3894
+ * everything else stored.
3864
3895
  *
3865
- * A tool's own `.<tool>/` folder is the tool's (spec §4): this module knows
3866
- * none of them. A caller that leaves some of its files out passes `skip`.
3896
+ * A tool's own `.<tool>/` folder is the tool's (spec §4): the reader returns
3897
+ * it like any file (a caller leaves files out with `skip`), and the writer
3898
+ * carries one it is handed, which makes the file a private copy (OW-7, OW-9).
3867
3899
  */
3868
- /** What one world file may hold, uncompressed: Atlas's budget, the writer's and the reader's alike. */
3900
+ /** The `mimetype` marker's exact bytes (spec §4.7, OW-6): ASCII, no newline. */
3901
+ declare const WORLD_FILE_MEDIA_TYPE = "application/vnd.onlyworlds.world+zip";
3902
+ /** The budget, uncompressed: the floor every reader accepts (spec §4.7, OW-15). */
3869
3903
  declare const WORLD_FILE_MAX_BYTES: number;
3870
- declare const WORLD_FILE_MAX_ENTRIES = 200000;
3871
- type OwWorldFileReason = 'unsafe_path' | 'too_big' | 'too_many' | 'not_a_world' | 'more_than_one' | 'not_openable';
3904
+ declare const WORLD_FILE_MAX_ENTRIES = 500000;
3905
+ type OwWorldFileReason = 'unsafe_path' | 'too_big' | 'too_many' | 'not_a_world' | 'more_than_one' | 'not_openable' | 'duplicate' | 'collision' | 'link' | 'method' | 'name' | 'marker';
3872
3906
  /** A world file was refused; nothing was returned. `reason` says why, for a tool to word its own message. */
3873
3907
  declare class OwWorldFileError extends Error {
3874
3908
  readonly reason: OwWorldFileReason;
@@ -3885,26 +3919,37 @@ interface OwReadWorldFile {
3885
3919
  name: string;
3886
3920
  /** `world.json`, parsed, without `api`. */
3887
3921
  world: Record<string, unknown>;
3888
- /** The world's files, relative to its folder, `world.json` included (without `api`), sorted by path. */
3922
+ /** The world's files, relative to its folder, `world.json` included (without `api`), in code point order. */
3889
3923
  files: OwWorldFileEntry[];
3890
3924
  }
3891
3925
  interface OwReadWorldFileOptions {
3892
3926
  maxBytes?: number;
3893
3927
  maxEntries?: number;
3894
- /** Leave a file out of `files` (a path relative to the world's folder). It still counts against the budget. */
3928
+ /** Leave a file out of `files` (a path relative to the world's folder). It is still checked and counted. */
3895
3929
  skip?: (path: string) => boolean;
3896
3930
  }
3931
+ /**
3932
+ * Simple case folding (Unicode 15.1, CaseFolding.txt C and S), code point by
3933
+ * code point: what OW-18 applies after NFC. A code point the table does not
3934
+ * map folds to itself.
3935
+ */
3936
+ declare function simpleCaseFold(text: string): string;
3897
3937
  /**
3898
3938
  * Read a world file. Throws `OwWorldFileError` saying why, having returned
3899
3939
  * nothing; the caller writes only what this returns.
3900
3940
  */
3901
3941
  declare function readWorldFile(bytes: Uint8Array, opts?: OwReadWorldFileOptions): OwReadWorldFile;
3902
3942
  /**
3903
- * Write a world file: every file under one top-level folder `topDir`, JSON
3904
- * deflated, everything else stored, `world.json` without `api`. Refuses
3905
- * (`OwWorldFileError`) an unsafe path or `topDir`, a duplicate path, and a
3906
- * world.json that is missing, will not parse (it may hold a key, so it is
3907
- * never zipped raw) or has no `id`.
3943
+ * Write a world file under one top-level folder `topDir`: the `mimetype`
3944
+ * marker first and stored, then `world.json` (without `api`) and the folder's
3945
+ * parts, in UTF-8 byte order, every entry stamped 1980-01-01 00:00, JSON
3946
+ * deflated and everything else stored (spec §4.7, OW-6, OW-7, OW-12, OW-13).
3947
+ * Any other file is left out. A tool's dot-folder in `files` travels, and
3948
+ * makes the result a private copy, not a portable `.owworld` (OW-7): leave it
3949
+ * out of `files` for a portable one. Refuses (`OwWorldFileError`) an unsafe
3950
+ * path or `topDir`, a duplicate path, a name some disk cannot hold, two names
3951
+ * that fold to one, and a world.json that is missing, will not parse (it may
3952
+ * hold a key, so it is never zipped raw) or has no `id`.
3908
3953
  */
3909
3954
  declare function writeWorldFile(files: OwWorldFileEntry[], topDir: string): Uint8Array;
3910
3955
 
@@ -4717,4 +4762,4 @@ declare class TokenResource {
4717
4762
  getEncryptionInfo(): Promise<EncryptionInfo>;
4718
4763
  }
4719
4764
 
4720
- export { AGENT_DOORS, ASK_LIMITS, type AccessKeyResponse, CALENDAR_FIELD, CALENDAR_SUPERTYPE, type CalendarConfig, type CalendarEra, type CalendarIntercalary, type CalendarMonths, type CalendarWeek, ELEMENT_FAMILIES, ELEMENT_ICONS, ELEMENT_LABELS, ELEMENT_SECTIONS, ELEMENT_TYPES, type ElementFamily, type ElementType, type EncryptionInfo, type EraDomain, FAMILY_COLORS, FAMILY_ORDER, FIELD_ACRONYMS, FIELD_SCHEMA, FILE_EXTENSIONS, type FieldInfo, type FieldType, GameTier, type ListParams, ONLYWORLDS_VERSION, type OwAccountWorld, type OwAgentJoined, type OwAllowance, OwApiError, type OwAskAnswer, type OwAskContext, type OwAuthErrorCode, type OwBulkItem, type OwBulkItemResult, type OwBulkResponse, type OwCalendarElement, type OwChange, type OwChangeDelete, type OwChangeUpsert, type OwChangesPage, type OwClientConfig, type OwCreatedWorldLink, type OwDeleteReason, type OwDraught, type OwDraughtComponent, type OwDraughtItem, type OwDraughtPolicy, type OwElement, type OwElementBase, type OwErrorBody, type OwFileAddedBy, type OwFileDownload, type OwFileKind, type OwFileRow, type OwFileTicket, type OwHiddenRow, type OwJoinPreview, type OwJoined, type OwJoinedMembership, type OwKeyKind, type OwLinkEdit, type OwLookupElement, type OwLookupHit, type OwLookupLink, type OwLookupOptions, type OwLookupReason, type OwMe, type OwMediaTicket, type OwMember, type OwMemberRole, type OwMintedKey, OwNetworkError, type OwPage, type OwPhotoOptions, type OwPhotoPlan, type OwPlayerJoined, OwPreconditionFailedError, type OwPreparedPhoto, type OwReadWorldFile, type OwReadWorldFileOptions, type OwRemovalTicket, type OwRemovedImage, type OwRenderOptions, type OwResyncReason, type OwSeat, type OwSeatCreated, type OwSignInBegin, type OwSignInBegun, type OwSignInComplete, OwSignInError, type OwSignInReason, type OwSignInResult, type OwSignInStorage, type OwSignedIn, type OwSortAddToRow, type OwSortAnswer, type OwSortAskRow, type OwSortLinkRow, type OwSortMakeRow, type OwSortNote, type OwSortRef, type OwSortRow, type OwUploadedFile, type OwUploadedImage, OwV2Client, type OwWorldFileEntry, OwWorldFileError, type OwWorldFileReason, type OwWorldFileRow, type OwWorldFiles, type OwWorldIndex, type OwWorldLink, type OwWorldMeta, type OwWriteDoor, PHOTO_MAX_BYTES, PHOTO_TYPES, PUBLIC_FIELDS, type ParseError, type ParseOptions, type ParseResult, type RevokeAllSessionsResponse, type RevokeSessionResponse, SPATIAL_TYPES, type SectionInfo, type StepBreakdown, type TokenConsumeParams, type TokenConsumeResponse, TokenResource, type TokenStatus, type TokenTransport, WORLD_FILE_MAX_BYTES, WORLD_FILE_MAX_ENTRIES, type WorldCalendar, absYearForEraYear, absoluteYear, approxYearLength, base64url, breakdownStep, buildAskContext, buildWorldIndex, detectKeyKind, displayYear, elementColor, eraForStep, errorFromResponse, familyOf, fieldLabel, fileKindOf, floorDiv, floorMod, formatDuration, formatMidLabel, formatStep, formatYearLabel, getElementIcon, getElementLabel, isAgentDoor, isDemoKey, isKnownOf, isLeapYear, isParseError, kindCanWrite, lookupWorld, monthStartSteps, parseDate, parseErrorEnvelope, pinExpectation, pkceChallenge, planPhoto, preparePhoto, readCalendarConfig, readCalendarValue, readWorldFile, renderElement, resolveCalendarLens, resolveEraDomains, validateCalendarConfig, weekdayForStep, worldCalendars, writeWorldFile, yearLength, yearStartStep };
4765
+ export { AGENT_DOORS, ASK_LIMITS, type AccessKeyResponse, CALENDAR_FIELD, CALENDAR_SUPERTYPE, type CalendarConfig, type CalendarEra, type CalendarIntercalary, type CalendarMonths, type CalendarWeek, ELEMENT_FAMILIES, ELEMENT_ICONS, ELEMENT_LABELS, ELEMENT_SECTIONS, ELEMENT_TYPES, type ElementFamily, type ElementType, type EncryptionInfo, type EraDomain, FAMILY_COLORS, FAMILY_ORDER, FIELD_ACRONYMS, FIELD_SCHEMA, FILE_EXTENSIONS, type FieldInfo, type FieldType, GameTier, type ListParams, ONLYWORLDS_VERSION, type OwAccountWorld, type OwAgentJoined, type OwAllowance, OwApiError, type OwAskAnswer, type OwAskContext, type OwAuthErrorCode, type OwBulkItem, type OwBulkItemResult, type OwBulkResponse, type OwCalendarElement, type OwChange, type OwChangeDelete, type OwChangeUpsert, type OwChangesPage, type OwClientConfig, type OwCreatedWorldLink, type OwDeleteReason, type OwDraught, type OwDraughtComponent, type OwDraughtItem, type OwDraughtPolicy, type OwElement, type OwElementBase, type OwErrorBody, type OwFileAddedBy, type OwFileDownload, type OwFileKind, type OwFileRow, type OwFileTicket, type OwHiddenRow, type OwJoinPreview, type OwJoined, type OwJoinedMembership, type OwKeyKind, type OwLinkEdit, type OwLookupElement, type OwLookupHit, type OwLookupLink, type OwLookupOptions, type OwLookupReason, type OwMe, type OwMediaTicket, type OwMember, type OwMemberRole, type OwMintedKey, OwNetworkError, type OwPage, type OwPhotoOptions, type OwPhotoPlan, type OwPlayerJoined, OwPreconditionFailedError, type OwPreparedPhoto, type OwReadWorldFile, type OwReadWorldFileOptions, type OwRemovalTicket, type OwRemovedImage, type OwRenderOptions, type OwResyncReason, type OwSeat, type OwSeatCreated, type OwSignInBegin, type OwSignInBegun, type OwSignInComplete, OwSignInError, type OwSignInReason, type OwSignInResult, type OwSignInStorage, type OwSignedIn, type OwSortAddToRow, type OwSortAnswer, type OwSortAskRow, type OwSortLinkRow, type OwSortMakeRow, type OwSortNote, type OwSortRef, type OwSortRow, type OwUploadedFile, type OwUploadedImage, OwV2Client, type OwWorldFileEntry, OwWorldFileError, type OwWorldFileReason, type OwWorldFileRow, type OwWorldFiles, type OwWorldIndex, type OwWorldLink, type OwWorldMeta, type OwWriteDoor, PHOTO_MAX_BYTES, PHOTO_TYPES, PUBLIC_FIELDS, type ParseError, type ParseOptions, type ParseResult, type RevokeAllSessionsResponse, type RevokeSessionResponse, SPATIAL_TYPES, type SectionInfo, type StepBreakdown, type TokenConsumeParams, type TokenConsumeResponse, TokenResource, type TokenStatus, type TokenTransport, WORLD_FILE_MAX_BYTES, WORLD_FILE_MAX_ENTRIES, WORLD_FILE_MEDIA_TYPE, type WorldCalendar, absYearForEraYear, absoluteYear, approxYearLength, base64url, breakdownStep, buildAskContext, buildWorldIndex, detectKeyKind, displayYear, elementColor, eraForStep, errorFromResponse, familyOf, fieldLabel, fileKindOf, floorDiv, floorMod, formatDuration, formatMidLabel, formatStep, formatYearLabel, getElementIcon, getElementLabel, isAgentDoor, isDemoKey, isKnownOf, isLeapYear, isParseError, kindCanWrite, lookupWorld, monthStartSteps, parseDate, parseErrorEnvelope, pinExpectation, pkceChallenge, planPhoto, preparePhoto, readCalendarConfig, readCalendarValue, readWorldFile, renderElement, resolveCalendarLens, resolveEraDomains, simpleCaseFold, validateCalendarConfig, weekdayForStep, worldCalendars, writeWorldFile, yearLength, yearStartStep };
package/dist/index.js CHANGED
@@ -191,6 +191,13 @@ var OwApiError = class extends Error {
191
191
  get isCharacterTaken() {
192
192
  return this.status === 422 && this.code === "character_taken";
193
193
  }
194
+ /**
195
+ * setMyCharacter, or the member PATCH's `character` (keel #421, PR 160): the picked Character is
196
+ * hidden (422 `character_hidden`, `param` character). Unhide it first, or pick another.
197
+ */
198
+ get isCharacterHidden() {
199
+ return this.status === 422 && this.code === "character_hidden";
200
+ }
194
201
  /**
195
202
  * The `character` sent was refused (422 `invalid_request`, `param` character). On redeemJoin
196
203
  * at a table (keel #422 PR 2): the pick is off the link's list, hidden or deleted since the
@@ -533,6 +540,19 @@ var OwV2Client = class {
533
540
  * creates; null for the owner) and the member's Character. A world key's call; an account
534
541
  * token is a TypeError before sending.
535
542
  */
543
+ /**
544
+ * PUT /me/character -- pick the roster Character that is you in this world, or none (keel PR 160,
545
+ * #533). A person member's write key with its PIN; answers GET /me's body. 422 for a hidden
546
+ * Character (`character_hidden`, `err.isCharacterHidden`), a guest's pick it did not make, or a
547
+ * taken Character (`character_taken`); 404 for one this key cannot see; 403 for an agent seat, a
548
+ * player, the owner (who sets it on the world page) or a read key. A guest's view resets once.
549
+ */
550
+ async setMyCharacter(character) {
551
+ if (this.keyKind === "account") {
552
+ throw new TypeError("setMyCharacter needs a world key: an account token is not a member of one world");
553
+ }
554
+ return this.request("PUT", "/me/character", { write: true, body: { character } });
555
+ }
536
556
  async me() {
537
557
  if (this.keyKind === "account") {
538
558
  throw new TypeError("me needs a world key: an account token is not a member of one world");
@@ -2514,17 +2534,25 @@ function getElementLabel(elementType) {
2514
2534
  }
2515
2535
 
2516
2536
  // src/world-file.ts
2517
- import { unzipSync, zipSync } from "fflate";
2537
+ import { Inflate, inflateSync, zipSync } from "fflate";
2518
2538
  var WORLD_FILE = "world.json";
2519
- var WORLD_FILE_MAX_BYTES = 1024 ** 3;
2520
- var WORLD_FILE_MAX_ENTRIES = 2e5;
2539
+ var MARKER_NAME = "mimetype";
2540
+ var WORLD_FILE_MEDIA_TYPE = "application/vnd.onlyworlds.world+zip";
2541
+ var WORLD_FILE_MAX_BYTES = 2 * 1024 ** 3;
2542
+ var WORLD_FILE_MAX_ENTRIES = 5e5;
2521
2543
  var MESSAGES = {
2522
2544
  unsafe_path: "The world file holds a path that cannot be written safely.",
2523
- too_big: "The world file is bigger than the budget allows.",
2545
+ too_big: "The world file is bigger than the budget allows, or a file in it is bigger than it says.",
2524
2546
  too_many: "The world file holds more files than the budget allows.",
2525
2547
  not_a_world: "The file is not a world: it has no readable world.json with an id.",
2526
- more_than_one: "The file holds more than one world, or files outside the world's folder.",
2527
- not_openable: "The file is not a zip that can be read."
2548
+ more_than_one: "The file holds more than one world.",
2549
+ not_openable: "The file is not a zip that can be read.",
2550
+ duplicate: "The world file holds two entries with the same name.",
2551
+ collision: "The world file holds two names that differ only in case or in how an accent is written.",
2552
+ link: "The world file holds a link or a special file.",
2553
+ method: "The world file holds an encrypted entry, or one compressed other than stored or deflate.",
2554
+ name: "The world file holds a name that some disk cannot hold.",
2555
+ marker: "The world file's mimetype entry is not the OnlyWorlds media type."
2528
2556
  };
2529
2557
  var OwWorldFileError = class extends Error {
2530
2558
  constructor(reason, message = MESSAGES[reason]) {
@@ -2533,13 +2561,228 @@ var OwWorldFileError = class extends Error {
2533
2561
  this.reason = reason;
2534
2562
  }
2535
2563
  };
2536
- function isSafePath(path) {
2537
- if (!path || path.includes("\\") || path.startsWith("/") || /^[a-zA-Z]:/.test(path)) return false;
2538
- return path.split("/").every((seg) => seg !== "" && seg !== "." && seg !== "..");
2564
+ var refuse = (reason, message) => {
2565
+ throw new OwWorldFileError(reason, message);
2566
+ };
2567
+ function pathFault(name) {
2568
+ if (name.includes("\\")) return "holds a backslash";
2569
+ if (name.startsWith("/")) return "is absolute";
2570
+ if (/^[A-Za-z]:/.test(name)) return "names a drive";
2571
+ if (name.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) return "has an empty, . or .. segment";
2572
+ return null;
2573
+ }
2574
+ var RESERVED = /* @__PURE__ */ new Set([
2575
+ "CON",
2576
+ "PRN",
2577
+ "AUX",
2578
+ "NUL",
2579
+ ...[1, 2, 3, 4, 5, 6, 7, 8, 9].flatMap((i) => [`COM${i}`, `LPT${i}`])
2580
+ ]);
2581
+ var BAD_CHAR = /[<>:"|?*\p{Cc}]/u;
2582
+ function nameFault(name) {
2583
+ for (const seg of name.split("/")) {
2584
+ if (BAD_CHAR.test(seg)) return `segment ${JSON.stringify(seg)} holds a reserved or control character`;
2585
+ if (seg.endsWith(".") || seg.endsWith(" ")) return `segment ${JSON.stringify(seg)} ends in a dot or a space`;
2586
+ if (RESERVED.has(seg.split(".")[0].toUpperCase())) return `segment ${JSON.stringify(seg)} is a Windows device name`;
2587
+ }
2588
+ return null;
2539
2589
  }
2540
2590
  function isJunk(path) {
2541
2591
  return path.split("/").some((seg) => seg === "__MACOSX" || seg === ".DS_Store" || seg === "Thumbs.db");
2542
2592
  }
2593
+ var FOLD_RUNS = "1t.q..w,51...lj,5c.n..w,60.7..w,74.o.2.1,8i.3.2.1,8p.8.2.1,96.n.2.1,ag...-3d,ah.3.2.1,an...-7g,ap...5u,aq.2.2.1,au...5q,av...1,ax.2..5p,az...1,b2...27,b3...5m,b4...5n,b5...1,b7...5p,b8...5r,ba...5v,bb...5t,bc...1,bg...5v,bh...5x,bj...5y,bk.3.2.1,bq...62,br...1,bt...62,bw...1,by...62,bz...1,c1.2..61,c3.2.2.1,c7...63,c8...1,cc...1,ck...2,cl...1,cn...2,co...1,cq...2,cr.9.2.1,da.9.2.1,dt...2,du.2.2.1,dy...-2p,dz...-1k,e0.k.2.1,f4...-3m,f6.9.2.1,fu...8bv,fv...1,fx...-4j,fy...8bs,g1...1,g3...-5f,g4...1x,g5...1z,g6.5.2.1,n9...38,og.2.2.1,om...1,ov...38,p2...12,p4.3..11,p8...1s,pa.2..1r,pd.h..w,pv.9..w,qq...1,r3...8,r4...-u,r5...-p,r9...-f,ra...-m,rc.c.2.1,s0...-1i,s1...-1c,s4...-1o,s5...-1s,s7...1,s9...-7,sa...1,sd.3..-3m,sg.g..28,sw.w..w,v4.h.2.1,wa.r.2.1,xs...f,xt.7.2.1,y8.1c.2.1,10x.12..1c,3a8.12..5ls,3bb...5ls,3bh...5ls,3y0.6..-8,5mo...-4su,5mp...-4st,5mq...-4sk,5mr.2..-4si,5mt...-4sj,5mu...-4sc,5mv...-4ro,5mw...r7n,5n4.17..-2bk,5od.3..-2bk,5xc.23.2.1,61n...-1m,61q...-5vj,61s.1c.2.1,64o.8..-8,654.6..-8,65k.8..-8,660.8..-8,66g.6..-8,66x.4.2.-8,67c.8..-8,688.8..-8,68o.8..-8,694.8..-8,69k.2..-8,69m.2..-22,69o...-9,69q...-5j9,6a0.4..-2e,6a4...-9,6ab...-5kz,6ag.2..-8,6ai.2..-2s,6ar...-5kj,6aw.2..-8,6ay.2..-34,6b0...-7,6bc.2..-3k,6be.2..-3i,6bg...-9,6jq...-5st,6ju...-6gv,6jv...-6di,6k2...s,6lc.g..g,6mb...1,792.q..q,8ow.1c..1c,8rk...1,8rm...-8af,8rn...-2xy,8ro...-89z,8rr.3.2.1,8rx...-8bg,8ry...-8al,8rz...-8bj,8s0...-8bi,8s2...1,8s5...1,8se.2..-8cf,8sg.1e.2.1,8vf.2.2.1,8vm...1,wu8.n.2.1,ww0.e.2.1,x0i.7.2.1,x0y.v.2.1,x2x.2.2.1,x31...-r9g,x32.5.2.1,x3f...1,x3h...-wmg,x3k.2.2.1,x3q.a.2.1,x4a...-wn8,x4b...-wnj,x4c...-wnf,x4d...-wn5,x4e...-wn8,x4g...-wlu,x4h...-wmi,x4i...-wlx,x4j...ps,x4k.8.2.1,x50...-1c,x51...-wn7,x52...-raw,x53.2.2.1,x5c...1,x5i.2.2.1,x6d...1,xv4.28..-tzk,1dl1...1,1ee9.q..w,1fcw.14..14,1fhs.10..14,1fn4.b..13,1fng.f..13,1fnw.7..13,1fo4.2..13,1h1c.1f..1s,1jfk.w..w,20cg.w..w,2olc.y..y";
2594
+ var foldTable = null;
2595
+ function simpleCaseFold(text) {
2596
+ if (!foldTable) {
2597
+ foldTable = /* @__PURE__ */ new Map();
2598
+ for (const run of FOLD_RUNS.split(",")) {
2599
+ const [start, count, stride, delta] = run.split(".");
2600
+ const s = parseInt(start, 36);
2601
+ const n = count ? parseInt(count, 36) : 1;
2602
+ const step = stride ? parseInt(stride, 36) : 1;
2603
+ const d = parseInt(delta, 36);
2604
+ for (let k = 0; k < n; k++) foldTable.set(s + k * step, s + k * step + d);
2605
+ }
2606
+ }
2607
+ let out = "";
2608
+ for (const c of text) {
2609
+ const to = foldTable.get(c.codePointAt(0));
2610
+ out += to === void 0 ? c : String.fromCodePoint(to);
2611
+ }
2612
+ return out;
2613
+ }
2614
+ function foldName(name) {
2615
+ return simpleCaseFold(name.normalize("NFC"));
2616
+ }
2617
+ function byCodePoint(a, b) {
2618
+ const n = Math.min(a.length, b.length);
2619
+ for (let i = 0; i < n; i++) {
2620
+ const x = a.codePointAt(i);
2621
+ const y = b.codePointAt(i);
2622
+ if (x !== y) return x - y;
2623
+ if (x > 65535) i++;
2624
+ }
2625
+ return a.length - b.length;
2626
+ }
2627
+ var u16 = (b, o) => b[o] | b[o + 1] << 8;
2628
+ var u32 = (b, o) => (b[o] | b[o + 1] << 8 | b[o + 2] << 16) + b[o + 3] * 16777216;
2629
+ var u64 = (b, o) => u32(b, o) + u32(b, o + 4) * 4294967296;
2630
+ var SIG_LOCAL = 67324752;
2631
+ var SIG_CENTRAL = 33639248;
2632
+ var SIG_END = 101010256;
2633
+ var SIG_END64 = 101075792;
2634
+ var SIG_LOCATOR64 = 117853008;
2635
+ var MAX32 = 4294967295;
2636
+ var utf8 = new TextDecoder("utf-8", { fatal: true });
2637
+ var latin1 = (b) => {
2638
+ let s = "";
2639
+ for (const x of b) s += String.fromCharCode(x);
2640
+ return s;
2641
+ };
2642
+ function centralDirectory(bytes) {
2643
+ const len = bytes.length;
2644
+ let end = -1;
2645
+ for (let i = len - 22; i >= Math.max(0, len - 22 - 65535); i--) {
2646
+ if (u32(bytes, i) === SIG_END) {
2647
+ end = i;
2648
+ break;
2649
+ }
2650
+ }
2651
+ if (end < 0) refuse("not_openable", "The file is not a zip: it has no end of central directory.");
2652
+ const thisDisk = u16(bytes, end + 4);
2653
+ const cdDisk = u16(bytes, end + 6);
2654
+ if (!(thisDisk === 0 && cdDisk === 0 || thisDisk === 65535 && cdDisk === 65535)) {
2655
+ refuse("not_openable", `The zip spans disks (its end record says disk ${thisDisk}, the directory on disk ${cdDisk}).`);
2656
+ }
2657
+ let cdSize = u32(bytes, end + 12);
2658
+ let cdOffset = u32(bytes, end + 16);
2659
+ const loc = end - 20;
2660
+ if (loc >= 0 && u32(bytes, loc) === SIG_LOCATOR64) {
2661
+ if (u32(bytes, loc + 4) !== 0 || u32(bytes, loc + 16) > 1) refuse("not_openable", "The zip spans disks (zip64).");
2662
+ const rec = u64(bytes, loc + 8);
2663
+ if (rec + 56 > len || u32(bytes, rec) !== SIG_END64) refuse("not_openable", "The zip64 end record is missing.");
2664
+ if (u32(bytes, rec + 16) !== 0 || u32(bytes, rec + 20) !== 0) refuse("not_openable", "The zip spans disks (zip64).");
2665
+ cdSize = u64(bytes, rec + 40);
2666
+ cdOffset = u64(bytes, rec + 48);
2667
+ }
2668
+ const cdEnd = cdOffset + cdSize;
2669
+ if (cdEnd > len) refuse("not_openable", "The central directory lies past the end of the file.");
2670
+ const out = [];
2671
+ let p = cdOffset;
2672
+ while (p < cdEnd) {
2673
+ if (p + 46 > cdEnd || u32(bytes, p) !== SIG_CENTRAL) refuse("not_openable", "The central directory is broken.");
2674
+ const flags = u16(bytes, p + 8);
2675
+ const nlen = u16(bytes, p + 28);
2676
+ const elen = u16(bytes, p + 30);
2677
+ const clen = u16(bytes, p + 32);
2678
+ const nameBytes = bytes.subarray(p + 46, p + 46 + nlen);
2679
+ const extra = bytes.subarray(p + 46 + nlen, p + 46 + nlen + elen);
2680
+ let usize = u32(bytes, p + 24);
2681
+ let csize = u32(bytes, p + 20);
2682
+ let offset = u32(bytes, p + 42);
2683
+ if (usize === MAX32 || csize === MAX32 || offset === MAX32) {
2684
+ for (let q = 0; q + 4 <= extra.length; q += 4 + u16(extra, q + 2)) {
2685
+ if (u16(extra, q) !== 1) continue;
2686
+ let r = q + 4;
2687
+ const stop = r + u16(extra, q + 2);
2688
+ const next = () => {
2689
+ if (r + 8 > stop) refuse("not_openable", "A zip64 extra field is short.");
2690
+ const v = u64(extra, r);
2691
+ r += 8;
2692
+ return v;
2693
+ };
2694
+ if (usize === MAX32) usize = next();
2695
+ if (csize === MAX32) csize = next();
2696
+ if (offset === MAX32) offset = next();
2697
+ break;
2698
+ }
2699
+ }
2700
+ const nonAscii = nameBytes.some((x) => x > 127);
2701
+ let name;
2702
+ let badUtf8 = false;
2703
+ if (flags & 2048) {
2704
+ try {
2705
+ name = utf8.decode(nameBytes);
2706
+ } catch {
2707
+ name = latin1(nameBytes);
2708
+ badUtf8 = true;
2709
+ }
2710
+ } else {
2711
+ name = latin1(nameBytes);
2712
+ }
2713
+ out.push({
2714
+ name,
2715
+ flags,
2716
+ method: u16(bytes, p + 10),
2717
+ crc: u32(bytes, p + 16),
2718
+ csize,
2719
+ usize,
2720
+ offset,
2721
+ external: u32(bytes, p + 38),
2722
+ nonAscii,
2723
+ badUtf8
2724
+ });
2725
+ p += 46 + nlen + elen + clen;
2726
+ }
2727
+ if (p !== cdEnd) refuse("not_openable", "The central directory is broken.");
2728
+ return out;
2729
+ }
2730
+ var crcTable = null;
2731
+ function crc32(data) {
2732
+ if (!crcTable) {
2733
+ crcTable = new Int32Array(256);
2734
+ for (let n = 0; n < 256; n++) {
2735
+ let c2 = n;
2736
+ for (let k = 0; k < 8; k++) c2 = c2 & 1 ? 3988292384 ^ c2 >>> 1 : c2 >>> 1;
2737
+ crcTable[n] = c2;
2738
+ }
2739
+ }
2740
+ let c = -1;
2741
+ for (let i = 0; i < data.length; i++) c = crcTable[(c ^ data[i]) & 255] ^ c >>> 8;
2742
+ return (c ^ -1) >>> 0;
2743
+ }
2744
+ var CHUNK = 64 * 1024;
2745
+ function inflateEntry(bytes, e) {
2746
+ const off = e.offset;
2747
+ if (off + 30 > bytes.length || u32(bytes, off) !== SIG_LOCAL) refuse("not_openable", `${e.name}: no local header at its offset.`);
2748
+ const start = off + 30 + u16(bytes, off + 26) + u16(bytes, off + 28);
2749
+ const payload = bytes.subarray(start, start + e.csize);
2750
+ let data;
2751
+ if (e.method === 0) {
2752
+ data = payload;
2753
+ } else if (payload.length <= CHUNK) {
2754
+ try {
2755
+ data = inflateSync(payload);
2756
+ } catch {
2757
+ return refuse("not_openable", `${e.name}: its deflate stream is broken.`);
2758
+ }
2759
+ } else {
2760
+ const out = new Uint8Array(e.usize);
2761
+ let n = 0;
2762
+ let over = false;
2763
+ const inf = new Inflate((chunk) => {
2764
+ if (n + chunk.length > e.usize) {
2765
+ over = true;
2766
+ throw new Error("past its size");
2767
+ }
2768
+ out.set(chunk, n);
2769
+ n += chunk.length;
2770
+ });
2771
+ try {
2772
+ for (let i = 0; i < payload.length; i += CHUNK) {
2773
+ const stop = Math.min(i + CHUNK, payload.length);
2774
+ inf.push(payload.subarray(i, stop), stop === payload.length);
2775
+ }
2776
+ } catch {
2777
+ if (!over) refuse("not_openable", `${e.name}: its deflate stream is broken.`);
2778
+ }
2779
+ data = over ? new Uint8Array(e.usize + 1) : out.subarray(0, n);
2780
+ }
2781
+ if (data.length > e.usize) refuse("too_big", `${e.name} holds more than the ${e.usize} bytes it declares.`);
2782
+ if (data.length !== e.usize) refuse("not_openable", `${e.name} holds ${data.length} bytes and declares ${e.usize}.`);
2783
+ if (crc32(data) !== e.crc) refuse("not_openable", `${e.name} fails its CRC.`);
2784
+ return e.method === 0 ? data.slice() : data;
2785
+ }
2543
2786
  function parseWorld(data) {
2544
2787
  let meta;
2545
2788
  try {
@@ -2557,93 +2800,157 @@ function withoutLink(data, meta) {
2557
2800
  const { api: _link, ...rest } = meta;
2558
2801
  return { data: new TextEncoder().encode(JSON.stringify(rest, null, 2) + "\n"), world: rest };
2559
2802
  }
2560
- function readWorldFile(bytes, opts = {}) {
2561
- const maxBytes = opts.maxBytes ?? WORLD_FILE_MAX_BYTES;
2562
- const maxEntries = opts.maxEntries ?? WORLD_FILE_MAX_ENTRIES;
2563
- let declared = 0;
2564
- let count = 0;
2565
- let refusal = null;
2566
- let raw;
2567
- try {
2568
- raw = unzipSync(bytes, {
2569
- filter: (file) => {
2570
- if (refusal) return false;
2571
- if (file.name.endsWith("/")) return false;
2572
- if (!isSafePath(file.name)) {
2573
- refusal = "unsafe_path";
2574
- return false;
2575
- }
2576
- if (isJunk(file.name)) return false;
2577
- count++;
2578
- declared += file.originalSize;
2579
- if (count > maxEntries) {
2580
- refusal = "too_many";
2581
- return false;
2582
- }
2583
- if (declared > maxBytes) {
2584
- refusal = "too_big";
2585
- return false;
2586
- }
2587
- return true;
2803
+ function checkCollisions(names) {
2804
+ const byFold = /* @__PURE__ */ new Map();
2805
+ const segFold = /* @__PURE__ */ new Map();
2806
+ const done = /* @__PURE__ */ new Set();
2807
+ for (const raw of names) {
2808
+ const segs = (raw.endsWith("/") ? raw.slice(0, -1) : raw).split("/");
2809
+ let prefix = "";
2810
+ let folded = "";
2811
+ for (let i = 0; i < segs.length; i++) {
2812
+ let f = segFold.get(segs[i]);
2813
+ if (f === void 0) {
2814
+ f = foldName(segs[i]);
2815
+ segFold.set(segs[i], f);
2588
2816
  }
2589
- });
2590
- } catch {
2591
- throw new OwWorldFileError(refusal ?? "not_openable");
2817
+ prefix = i ? `${prefix}/${segs[i]}` : segs[i];
2818
+ folded = i ? `${folded}/${f}` : f;
2819
+ if (done.has(prefix)) continue;
2820
+ done.add(prefix);
2821
+ const other = byFold.get(folded);
2822
+ if (other !== void 0) {
2823
+ refuse("collision", `The names ${JSON.stringify(other)} and ${JSON.stringify(prefix)} are one name after NFC and case folding.`);
2824
+ }
2825
+ byFold.set(folded, prefix);
2826
+ }
2592
2827
  }
2593
- if (refusal) throw new OwWorldFileError(refusal);
2594
- const names = Object.keys(raw);
2595
- if (names.reduce((n, k) => n + raw[k].length, 0) > maxBytes) throw new OwWorldFileError("too_big");
2596
- const nameSet = new Set(names);
2597
- for (const n of names) {
2828
+ }
2829
+ function checkFileAndFolder(files) {
2830
+ const seen = /* @__PURE__ */ new Set();
2831
+ for (const n of files) {
2598
2832
  const segs = n.split("/");
2599
- for (let i = 1; i < segs.length; i++) {
2600
- if (nameSet.has(segs.slice(0, i).join("/"))) throw new OwWorldFileError("unsafe_path");
2833
+ let prefix = "";
2834
+ for (let i = 0; i < segs.length - 1; i++) {
2835
+ prefix = i ? `${prefix}/${segs[i]}` : segs[i];
2836
+ if (seen.has(prefix)) continue;
2837
+ seen.add(prefix);
2838
+ if (files.has(prefix)) refuse("unsafe_path", `${JSON.stringify(prefix)} is a file and a folder.`);
2601
2839
  }
2602
2840
  }
2841
+ }
2842
+ function readWorldFile(bytes, opts = {}) {
2843
+ const maxBytes = opts.maxBytes ?? WORLD_FILE_MAX_BYTES;
2844
+ const maxEntries = opts.maxEntries ?? WORLD_FILE_MAX_ENTRIES;
2845
+ const entries = centralDirectory(bytes);
2846
+ const seen = /* @__PURE__ */ new Set();
2847
+ const files = [];
2848
+ let declared = 0;
2849
+ for (const e of entries) {
2850
+ const name2 = e.name;
2851
+ const isDir = name2.endsWith("/");
2852
+ const bare = isDir ? name2.slice(0, -1) : name2;
2853
+ const quoted = JSON.stringify(name2);
2854
+ const fault = pathFault(bare);
2855
+ if (fault) refuse("unsafe_path", `The entry ${quoted} ${fault}.`);
2856
+ const kind = e.external >>> 16 & 61440;
2857
+ if (kind !== 0 && kind !== 32768 && kind !== 16384) refuse("link", `The entry ${quoted} is a link or a special file.`);
2858
+ if (e.flags & 65) refuse("method", `The entry ${quoted} is encrypted.`);
2859
+ if (e.method !== 0 && e.method !== 8) refuse("method", `The entry ${quoted} uses method ${e.method} (only stored and deflate).`);
2860
+ if (e.badUtf8) refuse("name", `The entry ${quoted} is flagged UTF-8 and is not.`);
2861
+ if (e.nonAscii && !(e.flags & 2048)) refuse("name", `The entry ${quoted} is not ASCII and lacks the UTF-8 flag.`);
2862
+ const bad = nameFault(bare);
2863
+ if (bad) refuse("name", `The entry ${quoted}: ${bad}.`);
2864
+ if (seen.has(name2)) refuse("duplicate", `The entry ${quoted} appears twice.`);
2865
+ seen.add(name2);
2866
+ if (isDir) continue;
2867
+ files.push(e);
2868
+ if (files.length > maxEntries) refuse("too_many", `The world file holds more than ${maxEntries} files.`);
2869
+ declared += e.usize;
2870
+ if (declared > maxBytes) refuse("too_big", `The world file declares more than ${maxBytes} bytes.`);
2871
+ }
2872
+ checkCollisions(seen);
2873
+ const names = new Set(files.map((e) => e.name));
2874
+ checkFileAndFolder(names);
2875
+ const marker = files.find((e) => e.name === MARKER_NAME);
2876
+ if (marker && new TextDecoder().decode(inflateEntry(bytes, marker)) !== WORLD_FILE_MEDIA_TYPE) {
2877
+ refuse("marker", "The mimetype entry holds other bytes than the OnlyWorlds media type.");
2878
+ }
2879
+ const candidates = [...names].filter((n) => n !== MARKER_NAME && !isJunk(n));
2603
2880
  let prefix;
2604
- if (nameSet.has(WORLD_FILE)) {
2881
+ if (candidates.includes(WORLD_FILE)) {
2605
2882
  prefix = "";
2606
2883
  } else {
2607
- const tops = new Set(names.map((n) => n.includes("/") ? n.split("/")[0] + "/" : n));
2608
- const worldTops = [...tops].filter((t) => t.endsWith("/") && nameSet.has(`${t}${WORLD_FILE}`));
2609
- if (worldTops.length === 0) throw new OwWorldFileError("not_a_world");
2610
- if (worldTops.length > 1 || tops.size > 1) throw new OwWorldFileError("more_than_one");
2611
- prefix = worldTops[0];
2884
+ const roots = new Set(candidates.filter((n) => n.split("/").length === 2 && n.endsWith(`/${WORLD_FILE}`) && !n.startsWith(".")).map((n) => n.slice(0, -WORLD_FILE.length)));
2885
+ if (roots.size === 0) refuse("not_a_world", "There is no world.json at the root or in one top-level folder.");
2886
+ if (roots.size > 1) refuse("more_than_one", `The file holds worlds in ${[...roots].join(", ")}.`);
2887
+ prefix = [...roots][0];
2888
+ }
2889
+ const inside = new Set(candidates.filter((n) => n.startsWith(prefix)));
2890
+ for (const n of inside) {
2891
+ const rel = n.slice(prefix.length);
2892
+ if (rel !== WORLD_FILE && rel.endsWith(`/${WORLD_FILE}`) && !rel.startsWith("records/")) {
2893
+ refuse("more_than_one", `A second world.json sits inside the world: ${n}.`);
2894
+ }
2895
+ }
2896
+ const content = /* @__PURE__ */ new Map();
2897
+ for (const e of files) {
2898
+ const data = inflateEntry(bytes, e);
2899
+ if (inside.has(e.name)) content.set(e.name, data);
2612
2900
  }
2613
2901
  const worldName = `${prefix}${WORLD_FILE}`;
2614
- if (names.some((n) => n !== worldName && n.endsWith(`/${WORLD_FILE}`))) throw new OwWorldFileError("more_than_one");
2615
- const parsed = withoutLink(raw[worldName], parseWorld(raw[worldName]));
2616
- const files = [];
2617
- for (const n of names) {
2902
+ const parsed = withoutLink(content.get(worldName), parseWorld(content.get(worldName)));
2903
+ const out = [];
2904
+ for (const [n, data] of content) {
2618
2905
  const path = n.slice(prefix.length);
2619
2906
  if (opts.skip?.(path)) continue;
2620
- files.push({ path, data: n === worldName ? parsed.data : raw[n] });
2907
+ out.push({ path, data: n === worldName ? parsed.data : data });
2621
2908
  }
2622
- files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
2909
+ out.sort((a, b) => byCodePoint(a.path, b.path));
2623
2910
  const name = parsed.world.name;
2624
2911
  return {
2625
2912
  worldId: parsed.world.id,
2626
2913
  name: typeof name === "string" ? name : "",
2627
2914
  world: parsed.world,
2628
- files
2915
+ files: out
2629
2916
  };
2630
2917
  }
2918
+ var PARTS = /* @__PURE__ */ new Set(["elements", "spatial", "media", "records"]);
2919
+ function carried(path) {
2920
+ if (path === WORLD_FILE) return true;
2921
+ const segs = path.split("/");
2922
+ if (segs.length < 2) return false;
2923
+ if (segs[0].startsWith(".")) return true;
2924
+ if (!PARTS.has(segs[0])) return false;
2925
+ if (segs[0] === "records") return true;
2926
+ return !segs.some((s) => s.startsWith(".")) && !path.endsWith(".meta");
2927
+ }
2928
+ var STAMP = new Date(1980, 0, 1, 0, 0, 0);
2631
2929
  function writeWorldFile(files, topDir) {
2632
- if (!isSafePath(topDir) || topDir.includes("/")) throw new OwWorldFileError("unsafe_path");
2633
- const input = {};
2634
- let sawWorld = false;
2930
+ if (pathFault(topDir) || topDir.includes("/")) throw new OwWorldFileError("unsafe_path");
2931
+ const seen = /* @__PURE__ */ new Set();
2635
2932
  for (const f of files) {
2636
- if (!isSafePath(f.path)) throw new OwWorldFileError("unsafe_path");
2637
- const key = `${topDir}/${f.path}`;
2638
- if (key in input) throw new OwWorldFileError("unsafe_path", `The path ${f.path} is listed twice.`);
2933
+ if (pathFault(f.path)) throw new OwWorldFileError("unsafe_path");
2934
+ if (seen.has(f.path)) throw new OwWorldFileError("unsafe_path", `The path ${f.path} is listed twice.`);
2935
+ seen.add(f.path);
2936
+ }
2937
+ const kept = files.filter((f) => carried(f.path));
2938
+ if (!kept.some((f) => f.path === WORLD_FILE)) throw new OwWorldFileError("not_a_world");
2939
+ const keys = kept.map((f) => `${topDir}/${f.path}`);
2940
+ for (const key of keys) {
2941
+ const bad = nameFault(key);
2942
+ if (bad) throw new OwWorldFileError("name", `The path ${key}: ${bad}.`);
2943
+ }
2944
+ checkCollisions([MARKER_NAME, ...keys]);
2945
+ checkFileAndFolder(/* @__PURE__ */ new Set([MARKER_NAME, ...keys]));
2946
+ const input = {
2947
+ [MARKER_NAME]: [new TextEncoder().encode(WORLD_FILE_MEDIA_TYPE), { level: 0, mtime: STAMP }]
2948
+ };
2949
+ for (const f of [...kept].sort((a, b) => byCodePoint(a.path, b.path))) {
2639
2950
  let data = f.data;
2640
- if (f.path === WORLD_FILE) {
2641
- data = withoutLink(data, parseWorld(data)).data;
2642
- sawWorld = true;
2643
- }
2644
- input[key] = [data, { level: /\.json$/i.test(f.path) ? 6 : 0 }];
2951
+ if (f.path === WORLD_FILE) data = withoutLink(data, parseWorld(data)).data;
2952
+ input[`${topDir}/${f.path}`] = [data, { level: /\.json$/i.test(f.path) ? 6 : 0, mtime: STAMP }];
2645
2953
  }
2646
- if (!sawWorld) throw new OwWorldFileError("not_a_world");
2647
2954
  return zipSync(input);
2648
2955
  }
2649
2956
 
@@ -3885,6 +4192,7 @@ export {
3885
4192
  TokenResource,
3886
4193
  WORLD_FILE_MAX_BYTES,
3887
4194
  WORLD_FILE_MAX_ENTRIES,
4195
+ WORLD_FILE_MEDIA_TYPE,
3888
4196
  absYearForEraYear,
3889
4197
  absoluteYear,
3890
4198
  approxYearLength,
@@ -3928,6 +4236,7 @@ export {
3928
4236
  renderElement,
3929
4237
  resolveCalendarLens,
3930
4238
  resolveEraDomains,
4239
+ simpleCaseFold,
3931
4240
  validateCalendarConfig,
3932
4241
  weekdayForStep,
3933
4242
  worldCalendars,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlyworlds/sdk",
3
- "version": "4.48.1",
3
+ "version": "4.50.0",
4
4
  "description": "TypeScript SDK for the OnlyWorlds API - build world-building applications with type safety",
5
5
  "type": "module",
6
6
  "exports": {