cultnet-ts: a blank text in a raw frame is Unicode White_Space, not trim()

validateBinaryMessageRoot, requireNonEmptyString and requireOptionalStringArray
used String.prototype.trim(), which takes U+FEFF and misses U+0085, so the TS
decoder and the servers disagreed on what names nothing (write-receipt rule 5).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Libby 2026-11-09 10:22:15 +00:00
commit b082101c78
2 changed files with 27 additions and 3 deletions

View file

@ -1422,7 +1422,7 @@ function validateBinaryMessageRoot(
if (candidate.schemaVersion !== schemaVersion) {
throw new Error(`Validation failed for ${schemaVersion}: /schemaVersion: must equal ${schemaVersion}`);
}
if (typeof candidate.messageId !== "string" || candidate.messageId.trim().length === 0) {
if (typeof candidate.messageId !== "string" || isBlank(candidate.messageId)) {
throw new Error(`Validation failed for ${schemaVersion}: /messageId: must be a non-empty string`);
}
@ -1460,8 +1460,13 @@ function validateRawDocumentRecord(
requireOptionalStringArray(candidate.tags, `${instancePath}/tags`);
}
/** Blank is empty or only Unicode White_Space (write-receipt.md, rule 5), not `trim()`: JS trims U+FEFF and keeps U+0085. */
function isBlank(value: string): boolean {
return /^\p{White_Space}*$/u.test(value);
}
function requireNonEmptyString(input: unknown, instancePath: string): void {
if (typeof input !== "string" || input.trim().length === 0) {
if (typeof input !== "string" || isBlank(input)) {
throw new Error(`Validation failed for raw CultNet document record: ${instancePath}: must be a non-empty string`);
}
}
@ -1477,7 +1482,7 @@ function requireOptionalStringArray(input: unknown, instancePath: string): void
if (typeof input === "undefined" || input === null) {
return;
}
if (!Array.isArray(input) || input.some((item) => typeof item !== "string" || item.trim().length === 0)) {
if (!Array.isArray(input) || input.some((item) => typeof item !== "string" || isBlank(item))) {
throw new Error(`Validation failed for raw CultNet document record: ${instancePath}: must be an array of non-empty strings`);
}
}

View file

@ -227,3 +227,22 @@ test("a write whose own send failed settles local_send_failed even when its wind
tracker.sendFailed("w1", 1000);
assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "local_send_failed" });
});
// Blank is Unicode White_Space (write-receipt.md, rule 5), not what String.prototype.trim() trims: trim() takes U+FEFF and
// misses U+0085. Every frame decoder that rejects a blank text must agree with the servers that do.
test("a blank messageId, record key or tag is exactly Unicode White_Space, not trim()", () => {
const put = (document: Record<string, unknown>, messageId = "p1") => ({
schemaVersion: "cultnet.document_put_raw.v0", messageId,
document: { schemaId: "note.v0", recordKey: "note:1", storedAt: "2026-11-08T00:00:00Z", payloadEncoding: "messagepack", payload: Uint8Array.of(0x81, 0xa1, 0x61, 0x01), ...document },
});
for (const blank of ["\u0085", "  ", " "]) {
assert.throws(() => parseCultNetMessage(put({}, blank)), `messageId ${JSON.stringify(blank)}`);
assert.throws(() => parseCultNetMessage(put({ recordKey: blank })), `recordKey ${JSON.stringify(blank)}`);
assert.throws(() => parseCultNetMessage(put({ tags: [blank] })), `tag ${JSON.stringify(blank)}`);
}
for (const named of ["", "​", "\u001c", "᠎"]) {
parseCultNetMessage(put({}, named));
parseCultNetMessage(put({ recordKey: named }));
parseCultNetMessage(put({ tags: [named] }));
}
});