refactor: structured body (array|object, never string) for canonicalization

Message bodies are always arrays of strings (text lines) or objects
(structured data like PUBKEY). Never raw strings. This enables:
- Multiline messages without escape sequences
- Deterministic JSON canonicalization (RFC 8785 JCS) for signing
- Structured data where needed

Update all schemas: body fields use array type with string items.
Update message.json envelope: body is oneOf[array, object], id is UUID.
Update README: message envelope table, examples, and canonicalization docs.
Update schema/README.md: field types, examples with array bodies.
This commit is contained in:
clawbot
2026-02-10 10:36:02 -08:00
parent dfb1636be5
commit ab70f889a6
22 changed files with 446 additions and 171 deletions

View File

@@ -11,24 +11,33 @@ to IRC wire format:
```
IRC: :nick PRIVMSG #channel :hello world
JSON: {"command": "PRIVMSG", "from": "nick", "to": "#channel", "body": "hello world"}
JSON: {"command": "PRIVMSG", "from": "nick", "to": "#channel", "body": ["hello world"]}
IRC: :server 353 nick = #channel :user1 @op1 +voice1
JSON: {"command": "353", "to": "nick", "params": ["=", "#channel"], "body": "user1 @op1 +voice1"}
JSON: {"command": "353", "to": "nick", "params": ["=", "#channel"], "body": ["user1 @op1 +voice1"]}
Multiline: {"command": "PRIVMSG", "to": "#ch", "body": ["line 1", "line 2"]}
Structured: {"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64..."}}
```
Common fields (see `message.json` for full schema):
| Field | Type | Description |
|-----------|----------|-------------------------------------------------------|
| `id` | integer | Server-assigned ID (monotonically increasing) |
| `command` | string | IRC command or 3-digit numeric code |
| `from` | string | Source nick or server name (IRC prefix) |
| `to` | string | Target: #channel or nick |
| `params` | string[] | Middle parameters (mainly for numerics) |
| `body` | string | Trailing parameter (message text) |
| `ts` | string | ISO 8601 timestamp (server-assigned, not in raw IRC) |
| `meta` | object | Extensible metadata (not in raw IRC) |
| Field | Type | Description |
|-----------|----------------|------------------------------------------------------|
| `id` | string (uuid) | Server-assigned message UUID |
| `command` | string | IRC command or 3-digit numeric code |
| `from` | string | Source nick or server name (IRC prefix) |
| `to` | string | Target: #channel or nick |
| `params` | string[] | Middle parameters (mainly for numerics) |
| `body` | array \| object | Structured body — never a raw string (see below) |
| `ts` | string | ISO 8601 timestamp (server-assigned, not in raw IRC) |
| `meta` | object | Extensible metadata (signatures, hashes, etc.) |
**Structured bodies:** `body` is always an array of strings (for text) or an
object (for structured data like PUBKEY). Never a raw string. This enables:
- Multiline messages without escape sequences
- Deterministic canonicalization via RFC 8785 JCS for signing
- Structured data where needed
## Commands

View File

@@ -6,15 +6,8 @@
"$ref": "../message.json",
"properties": {
"command": { "const": "KICK" },
"from": {
"type": "string",
"description": "Nick that performed the kick."
},
"to": {
"type": "string",
"description": "Channel name.",
"pattern": "^#[a-zA-Z0-9_-]+$"
},
"from": { "type": "string", "description": "Nick that performed the kick." },
"to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" },
"params": {
"type": "array",
"items": { "type": "string" },
@@ -23,12 +16,14 @@
"maxItems": 1
},
"body": {
"type": "string",
"description": "Optional kick reason."
"type": "array",
"items": { "type": "string" },
"description": "Optional kick reason.",
"maxItems": 1
}
},
"required": ["command", "to", "params"],
"examples": [
{ "command": "KICK", "from": "op1", "to": "#general", "params": ["troll"], "body": "Behave" }
{ "command": "KICK", "from": "op1", "to": "#general", "params": ["troll"], "body": ["Behave"] }
]
}

View File

@@ -7,10 +7,16 @@
"properties": {
"command": { "const": "NICK" },
"from": { "type": "string", "description": "Old nick (S2C)." },
"body": { "type": "string", "description": "New nick.", "minLength": 1, "maxLength": 32, "pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$" }
"body": {
"type": "array",
"items": { "type": "string" },
"description": "New nick (single-element array).",
"minItems": 1,
"maxItems": 1
}
},
"required": ["command", "body"],
"examples": [
{ "command": "NICK", "from": "oldnick", "body": "newnick" }
{ "command": "NICK", "from": "oldnick", "body": ["newnick"] }
]
}

View File

@@ -8,10 +8,14 @@
"command": { "const": "NOTICE" },
"from": { "type": "string" },
"to": { "type": "string", "description": "Target: #channel, nick, or * (global)." },
"body": { "type": "string", "description": "Notice text." }
"body": {
"type": "array",
"items": { "type": "string" },
"description": "Notice text lines."
}
},
"required": ["command", "to", "body"],
"examples": [
{ "command": "NOTICE", "from": "server.example.com", "to": "*", "body": "Server restarting in 5 minutes" }
{ "command": "NOTICE", "from": "server.example.com", "to": "*", "body": ["Server restarting in 5 minutes"] }
]
}

View File

@@ -8,10 +8,15 @@
"command": { "const": "PART" },
"from": { "type": "string", "description": "Nick that left (S2C)." },
"to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" },
"body": { "type": "string", "description": "Optional part reason." }
"body": {
"type": "array",
"items": { "type": "string" },
"description": "Optional part reason.",
"maxItems": 1
}
},
"required": ["command", "to"],
"examples": [
{ "command": "PART", "from": "alice", "to": "#general", "body": "later" }
{ "command": "PART", "from": "alice", "to": "#general", "body": ["later"] }
]
}

View File

@@ -7,12 +7,14 @@
"properties": {
"command": { "const": "PING" },
"body": {
"type": "string",
"description": "Opaque token to be echoed in PONG."
"type": "array",
"items": { "type": "string" },
"description": "Opaque token to be echoed in PONG (single-element array).",
"maxItems": 1
}
},
"required": ["command"],
"examples": [
{ "command": "PING", "body": "1707580000" }
{ "command": "PING", "body": ["1707580000"] }
]
}

View File

@@ -6,17 +6,16 @@
"$ref": "../message.json",
"properties": {
"command": { "const": "PONG" },
"from": {
"type": "string",
"description": "Responding server name."
},
"from": { "type": "string", "description": "Responding server name." },
"body": {
"type": "string",
"description": "Echoed token from PING."
"type": "array",
"items": { "type": "string" },
"description": "Echoed token from PING (single-element array).",
"maxItems": 1
}
},
"required": ["command"],
"examples": [
{ "command": "PONG", "from": "server.example.com", "body": "1707580000" }
{ "command": "PONG", "from": "server.example.com", "body": ["1707580000"] }
]
}

View File

@@ -8,11 +8,17 @@
"command": { "const": "PRIVMSG" },
"from": { "type": "string", "description": "Sender nick (set by server on relay)." },
"to": { "type": "string", "description": "Target: #channel or nick.", "examples": ["#general", "alice"] },
"body": { "type": "string", "description": "Message text.", "minLength": 1 }
"body": {
"type": "array",
"items": { "type": "string" },
"description": "Message lines. One string per line.",
"minItems": 1
}
},
"required": ["command", "to", "body"],
"examples": [
{ "command": "PRIVMSG", "from": "bob", "to": "#general", "body": "hello world" },
{ "command": "PRIVMSG", "from": "bob", "to": "alice", "body": "hey" }
{ "command": "PRIVMSG", "from": "bob", "to": "#general", "body": ["hello world"] },
{ "command": "PRIVMSG", "from": "bob", "to": "#general", "body": ["line one", "line two"] },
{ "command": "PRIVMSG", "from": "bob", "to": "alice", "body": ["hey"], "meta": { "sig": "base64...", "alg": "ed25519" } }
]
}

View File

@@ -7,10 +7,15 @@
"properties": {
"command": { "const": "QUIT" },
"from": { "type": "string", "description": "Nick that quit." },
"body": { "type": "string", "description": "Optional quit reason." }
"body": {
"type": "array",
"items": { "type": "string" },
"description": "Optional quit reason.",
"maxItems": 1
}
},
"required": ["command", "from"],
"examples": [
{ "command": "QUIT", "from": "alice", "body": "Connection reset" }
{ "command": "QUIT", "from": "alice", "body": ["Connection reset"] }
]
}

View File

@@ -8,10 +8,15 @@
"command": { "const": "TOPIC" },
"from": { "type": "string", "description": "Nick that changed the topic (S2C)." },
"to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" },
"body": { "type": "string", "description": "New topic text. Empty string clears the topic.", "maxLength": 512 }
"body": {
"type": "array",
"items": { "type": "string" },
"description": "New topic text (single-element array). Empty array clears the topic.",
"maxItems": 1
}
},
"required": ["command", "to"],
"examples": [
{ "command": "TOPIC", "from": "alice", "to": "#general", "body": "Welcome to the chat" }
{ "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the chat"] }
]
}

View File

@@ -6,8 +6,9 @@
"type": "object",
"properties": {
"id": {
"type": "integer",
"description": "Server-assigned message ID, monotonically increasing. Present on all server-originated messages."
"type": "string",
"format": "uuid",
"description": "Server-assigned message UUID. Present on all server-originated messages."
},
"command": {
"type": "string",
@@ -28,8 +29,19 @@
"description": "Additional parameters (used primarily by numeric replies). Equivalent to IRC middle parameters."
},
"body": {
"type": "string",
"description": "Message body / trailing parameter. Equivalent to IRC trailing parameter (after the colon)."
"oneOf": [
{
"type": "array",
"items": { "type": "string" },
"description": "Array of strings (one per line for text messages)."
},
{
"type": "object",
"description": "Structured data (e.g. PUBKEY key material).",
"additionalProperties": true
}
],
"description": "Message body. MUST be an array or object, never a raw string. Arrays represent lines of text; objects carry structured data. This enables deterministic canonicalization (RFC 8785 JCS) for signing."
},
"ts": {
"type": "string",
@@ -38,7 +50,21 @@
},
"meta": {
"type": "object",
"description": "Extensible metadata (signatures, rich content hints, etc.). Not present in original IRC.",
"description": "Extensible metadata. Used for signatures (meta.sig, meta.alg), hashes (meta.hash), and client extensions.",
"properties": {
"sig": {
"type": "string",
"description": "Base64-encoded cryptographic signature over the canonical message form."
},
"alg": {
"type": "string",
"description": "Signature algorithm (e.g. 'ed25519')."
},
"hash": {
"type": "string",
"description": "Hash of the canonical message form (e.g. 'sha256:base64...')."
}
},
"additionalProperties": true
}
},

View File

@@ -2,19 +2,36 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/001.json",
"title": "001 RPL_WELCOME",
"description": "Welcome message sent after successful session creation. RFC 2812 §5.1.",
"description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
"$ref": "../message.json",
"properties": {
"command": { "const": "001" },
"to": { "type": "string", "description": "Target nick." },
"command": {
"const": "001"
},
"to": {
"type": "string",
"description": "Target nick."
},
"body": {
"type": "array",
"items": { "type": "string" },
"items": {
"type": "string"
},
"description": "Welcome text lines."
}
},
"required": ["command", "to", "body"],
"required": [
"command",
"to",
"body"
],
"examples": [
{ "command": "001", "to": "alice", "body": ["Welcome to the network, alice"] }
{
"command": "001",
"to": "alice",
"body": [
"Welcome to the network, alice"
]
}
]
}

View File

@@ -2,19 +2,35 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/002.json",
"title": "002 RPL_YOURHOST",
"description": "Server host info sent after session creation. RFC 2812 §5.1.",
"description": "Server host info sent after session creation. RFC 2812 \u00a75.1.",
"$ref": "../message.json",
"properties": {
"command": { "const": "002" },
"to": { "type": "string" },
"command": {
"const": "002"
},
"to": {
"type": "string"
},
"body": {
"type": "array",
"items": { "type": "string" },
"items": {
"type": "string"
},
"description": "Host info lines."
}
},
"required": ["command", "to", "body"],
"required": [
"command",
"to",
"body"
],
"examples": [
{ "command": "002", "to": "alice", "body": ["Your host is chat.example.com, running version 0.1.0"] }
{
"command": "002",
"to": "alice",
"body": [
"Your host is chat.example.com, running version 0.1.0"
]
}
]
}

View File

@@ -2,12 +2,26 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/323.json",
"title": "323 RPL_LISTEND",
"description": "End of channel list. RFC 1459 §6.2.",
"description": "End of channel list. RFC 1459 \u00a76.2.",
"$ref": "../message.json",
"properties": {
"command": { "const": "323" },
"to": { "type": "string" },
"body": { "const": "End of /LIST" }
"command": {
"const": "323"
},
"to": {
"type": "string"
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "End of /LIST",
"maxItems": 1
}
},
"required": ["command", "to"]
"required": [
"command",
"to"
]
}

View File

@@ -2,17 +2,34 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/366.json",
"title": "366 RPL_ENDOFNAMES",
"description": "End of NAMES list. RFC 1459 §6.2.",
"description": "End of NAMES list. RFC 1459 \u00a76.2.",
"$ref": "../message.json",
"properties": {
"command": { "const": "366" },
"to": { "type": "string" },
"command": {
"const": "366"
},
"to": {
"type": "string"
},
"params": {
"type": "array",
"items": { "type": "string" },
"items": {
"type": "string"
},
"description": "[channel]."
},
"body": { "const": "End of /NAMES list" }
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "End of /NAMES list",
"maxItems": 1
}
},
"required": ["command", "to", "params"]
"required": [
"command",
"to",
"params"
]
}

View File

@@ -2,12 +2,26 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/376.json",
"title": "376 RPL_ENDOFMOTD",
"description": "End of MOTD. RFC 2812 §5.1.",
"description": "End of MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json",
"properties": {
"command": { "const": "376" },
"to": { "type": "string" },
"body": { "const": "End of /MOTD command" }
"command": {
"const": "376"
},
"to": {
"type": "string"
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "End of /MOTD command",
"maxItems": 1
}
},
"required": ["command", "to"]
"required": [
"command",
"to"
]
}

View File

@@ -2,20 +2,46 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/401.json",
"title": "401 ERR_NOSUCHNICK",
"description": "No such nick/channel. RFC 1459 §6.1.",
"description": "No such nick/channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json",
"properties": {
"command": { "const": "401" },
"to": { "type": "string" },
"command": {
"const": "401"
},
"to": {
"type": "string"
},
"params": {
"type": "array",
"items": { "type": "string" },
"items": {
"type": "string"
},
"description": "[target_nick]."
},
"body": { "const": "No such nick/channel" }
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "No such nick/channel",
"maxItems": 1
}
},
"required": ["command", "to", "params"],
"required": [
"command",
"to",
"params"
],
"examples": [
{ "command": "401", "to": "alice", "params": ["bob"], "body": "No such nick/channel" }
{
"command": "401",
"to": "alice",
"params": [
"bob"
],
"body": [
"No such nick/channel"
]
}
]
}

View File

@@ -2,20 +2,46 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/403.json",
"title": "403 ERR_NOSUCHCHANNEL",
"description": "No such channel. RFC 1459 §6.1.",
"description": "No such channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json",
"properties": {
"command": { "const": "403" },
"to": { "type": "string" },
"command": {
"const": "403"
},
"to": {
"type": "string"
},
"params": {
"type": "array",
"items": { "type": "string" },
"items": {
"type": "string"
},
"description": "[channel_name]."
},
"body": { "const": "No such channel" }
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "No such channel",
"maxItems": 1
}
},
"required": ["command", "to", "params"],
"required": [
"command",
"to",
"params"
],
"examples": [
{ "command": "403", "to": "alice", "params": ["#nonexistent"], "body": "No such channel" }
{
"command": "403",
"to": "alice",
"params": [
"#nonexistent"
],
"body": [
"No such channel"
]
}
]
}

View File

@@ -2,20 +2,46 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/433.json",
"title": "433 ERR_NICKNAMEINUSE",
"description": "Nickname is already in use. RFC 1459 §6.1.",
"description": "Nickname is already in use. RFC 1459 \u00a76.1.",
"$ref": "../message.json",
"properties": {
"command": { "const": "433" },
"to": { "type": "string" },
"command": {
"const": "433"
},
"to": {
"type": "string"
},
"params": {
"type": "array",
"items": { "type": "string" },
"items": {
"type": "string"
},
"description": "[requested_nick]."
},
"body": { "const": "Nickname is already in use" }
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "Nickname is already in use",
"maxItems": 1
}
},
"required": ["command", "to", "params"],
"required": [
"command",
"to",
"params"
],
"examples": [
{ "command": "433", "to": "*", "params": ["alice"], "body": "Nickname is already in use" }
{
"command": "433",
"to": "*",
"params": [
"alice"
],
"body": [
"Nickname is already in use"
]
}
]
}

View File

@@ -2,17 +2,34 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/442.json",
"title": "442 ERR_NOTONCHANNEL",
"description": "You're not on that channel. RFC 1459 §6.1.",
"description": "You're not on that channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json",
"properties": {
"command": { "const": "442" },
"to": { "type": "string" },
"command": {
"const": "442"
},
"to": {
"type": "string"
},
"params": {
"type": "array",
"items": { "type": "string" },
"items": {
"type": "string"
},
"description": "[channel]."
},
"body": { "const": "You're not on that channel" }
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "You're not on that channel",
"maxItems": 1
}
},
"required": ["command", "to", "params"]
"required": [
"command",
"to",
"params"
]
}

View File

@@ -2,17 +2,34 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/482.json",
"title": "482 ERR_CHANOPRIVSNEEDED",
"description": "You're not channel operator. RFC 1459 §6.1.",
"description": "You're not channel operator. RFC 1459 \u00a76.1.",
"$ref": "../message.json",
"properties": {
"command": { "const": "482" },
"to": { "type": "string" },
"command": {
"const": "482"
},
"to": {
"type": "string"
},
"params": {
"type": "array",
"items": { "type": "string" },
"items": {
"type": "string"
},
"description": "[channel]."
},
"body": { "const": "You're not channel operator" }
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "You're not channel operator",
"maxItems": 1
}
},
"required": ["command", "to", "params"]
"required": [
"command",
"to",
"params"
]
}